fix: close Nasiko filesystem race windows

This commit is contained in:
Affaan Mustafa
2026-08-15 21:47:57 -04:00
parent 9ba25b9360
commit 28a8fda568
3 changed files with 99 additions and 28 deletions
+65 -20
View File
@@ -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 };
+1 -7
View File
@@ -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);
}
+33 -1
View File
@@ -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-'));