From 28a8fda5680bb2d7ba0e1c328d85356cb24bebf4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 15 Aug 2026 21:47:57 -0400 Subject: [PATCH] fix: close Nasiko filesystem race windows --- scripts/lib/nasiko-release.js | 85 ++++++++++++++++++++------- scripts/nasiko.js | 8 +-- tests/ci/nasiko-control-plane.test.js | 34 ++++++++++- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js index 21efaeafc..e04bf999f 100644 --- a/scripts/lib/nasiko-release.js +++ b/scripts/lib/nasiko-release.js @@ -144,7 +144,11 @@ function validateInstallDirectory(directory) { const resolved = path.resolve(directory); if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.'); let ancestor = resolved; - while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor); + while (!fs.existsSync(ancestor)) { + const parent = path.dirname(ancestor); + if (parent === ancestor) throw new Error('Nasiko install directory has no resolvable filesystem ancestor.'); + ancestor = parent; + } const canonical = fs.realpathSync(ancestor); return path.join(canonical, path.relative(ancestor, resolved)); } @@ -160,22 +164,54 @@ function assertPrivateInstallDirectory(directory) { function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); } +function readBoundedRegularFile(filePath, maximumBytes) { + const noFollow = fs.constants.O_NOFOLLOW; + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (noFollow || 0)); + try { + const stats = fs.fstatSync(descriptor); + if (!stats.isFile() || stats.size <= 0 || stats.size > maximumBytes) return null; + if (!noFollow) { + const pathStats = fs.lstatSync(filePath); + if (pathStats.isSymbolicLink() + || pathStats.dev !== stats.dev + || pathStats.ino !== stats.ino + || pathStats.birthtimeMs !== stats.birthtimeMs) { + const error = new Error('Nasiko managed files must not be symbolic links or reparse points.'); + error.code = 'ELOOP'; + throw error; + } + } + const bytes = Buffer.allocUnsafe(stats.size); + let total = 0; + while (total < bytes.length) { + const count = fs.readSync(descriptor, bytes, total, bytes.length - total, total); + if (count === 0) return null; + total += count; + } + if (fs.fstatSync(descriptor).size !== stats.size) return null; + return bytes; + } finally { fs.closeSync(descriptor); } +} + function readMetadata(executable) { try { - const metadataPath = metadataPathFor(executable); - const stats = fs.lstatSync(metadataPath); - if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_METADATA_BYTES) return null; - return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + const bytes = readBoundedRegularFile(metadataPathFor(executable), MAX_METADATA_BYTES); + return bytes ? JSON.parse(bytes.toString('utf8')) : null; } catch (_error) { return null; } } function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) { - if (!executable || !fs.existsSync(executable)) return { installed: false, qualified: false, version: null, executable: executable || null }; - const stats = fs.lstatSync(executable); - if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('Nasiko executable must be a regular file, not a symlink.'); - if (stats.size <= 0 || stats.size > MAX_BINARY_BYTES) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; - const binaryDigest = digestBytes(fs.readFileSync(executable)); + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; + let binary; + try { binary = readBoundedRegularFile(executable, MAX_BINARY_BYTES); } + catch (error) { + if (error.code === 'ENOENT') return { installed: false, qualified: false, version: null, executable }; + if (error.code === 'ELOOP') throw new Error('Nasiko executable must be a regular file, not a symlink.'); + throw error; + } + if (!binary) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; + const binaryDigest = digestBytes(binary); const metadata = readMetadata(executable); let release = null; try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; } @@ -193,17 +229,23 @@ function writeMetadataExclusive(metadataPath, metadata) { fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); } -function acquireLifecycleLock(installDirectory) { +function acquireLifecycleLock(installDirectory, fileSystem = fs) { const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock'); let descriptor; - try { descriptor = fs.openSync(lockPath, 'wx', 0o600); } + try { + descriptor = fileSystem.openSync(lockPath, 'wx', 0o600); + fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`); + fileSystem.fsyncSync(descriptor); + } catch (error) { - if (error.code === 'EEXIST') throw new Error('Another Nasiko lifecycle operation is already in progress.'); + if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`); + if (descriptor !== undefined) { + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } + } throw error; } return () => { - fs.closeSync(descriptor); - fs.rmSync(lockPath, { force: true }); + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } }; } @@ -223,8 +265,8 @@ async function installNasiko(options = {}, dependencies = {}) { let destinationOwned = false; let metadataOwned = false; try { - if (fs.existsSync(destination) || fs.existsSync(metadataPath)) { - const existing = inspectInstalledNasiko(destination); + const existing = inspectInstalledNasiko(destination); + if (existing.installed) { if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); } @@ -240,8 +282,11 @@ async function installNasiko(options = {}, dependencies = {}) { if (dependencies.beforePublish) dependencies.beforePublish(destination); const descriptor = fs.openSync(destination, 'wx', 0o700); destinationOwned = true; - try { fs.writeFileSync(descriptor, binary); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } - assertDigest(fs.readFileSync(destination), release.binaryDigest, 'Published Nasiko binary'); + try { + fs.writeFileSync(descriptor, binary); + fs.fsyncSync(descriptor); + if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); + } finally { fs.closeSync(descriptor); } const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); metadataOwned = true; @@ -292,4 +337,4 @@ function uninstallNasiko(options = {}, dependencies = {}) { } finally { releaseLock(); } } -module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; +module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, acquireLifecycleLock, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; diff --git a/scripts/nasiko.js b/scripts/nasiko.js index d1c06526b..27c9c5ddf 100644 --- a/scripts/nasiko.js +++ b/scripts/nasiko.js @@ -1,7 +1,6 @@ #!/usr/bin/env node 'use strict'; -const fs = require('fs'); const os = require('os'); const path = require('path'); const { @@ -75,17 +74,12 @@ function resolveExecutable(options = {}) { if (!path.isAbsolute(candidate)) { throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.'); } - if (!fs.existsSync(candidate)) return null; - const stats = fs.lstatSync(candidate); - if (!stats.isFile() || stats.isSymbolicLink()) { - throw new Error('Nasiko executable must be a regular file, not a symlink.'); - } return candidate; } function readStatus(options = {}) { const executable = resolveExecutable(options); - if (!executable) return { installed: false, version: null, executable: null }; + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; return inspectInstalledNasiko(executable); } diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js index b67e8338d..8f9d7746f 100644 --- a/tests/ci/nasiko-control-plane.test.js +++ b/tests/ci/nasiko-control-plane.test.js @@ -85,6 +85,23 @@ async function main() { assert.strictEqual(plan.registryOrigin, 'https://registry.nasiko.dev'); assert.strictEqual(fetchCount, 0); }], + ['cleans an exclusively created lifecycle lock when initialization fails', () => { + const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-lock-')); + const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock'); + try { + const failingFileSystem = { + ...fs, + writeFileSync: () => { throw new Error('lock metadata unavailable'); }, + }; + assert.throws(() => acquireLifecycleLock(installRoot, failingFileSystem), /metadata unavailable/i); + assert.strictEqual(fs.existsSync(lockPath), false); + const releaseLock = acquireLifecycleLock(installRoot); + assert.strictEqual(fs.existsSync(lockPath), true); + releaseLock(); + assert.strictEqual(fs.existsSync(lockPath), false); + } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } + }], ['verifies manifest and blob digests before an atomic install', async () => { const { installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-')); @@ -126,9 +143,11 @@ async function main() { binaryDigest: sha256Digest(binary), }; const preview = await uninstallNasiko({ installDir: installRoot, dryRun: true }, { - platform: 'darwin', arch: 'arm64', releaseOverride: result, + platform: 'darwin', arch: 'arm64', }); assert.strictEqual(preview.dryRun, true); + assert.strictEqual(preview.version, 'v0.1.0'); + assert.strictEqual(preview.destination, path.join(fs.realpathSync(installRoot), 'nasiko')); let renameCount = 0; await assert.rejects(async () => uninstallNasiko({ installDir: installRoot, yes: true }, { platform: 'darwin', arch: 'arm64', @@ -146,6 +165,7 @@ async function main() { inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), }); assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), false); } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } @@ -196,6 +216,18 @@ async function main() { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } }], + ['read-only status has a stable absent result shape', () => { + const { readStatus } = require('../../scripts/nasiko'); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-absent-')); + try { + assert.deepStrictEqual(readStatus({ installDir: fixtureRoot }), { + installed: false, + qualified: false, + version: null, + executable: path.join(fs.realpathSync(fixtureRoot), 'nasiko'), + }); + } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } + }], ['rejects and never executes an unqualified pre-existing binary', async () => { const { installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-existing-'));