From 831f9672c4255e52cf45df69d55a4c1b8d62aa2c Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:29:02 -0400 Subject: [PATCH] fix: retain capsule locks with distinct ownership tokens --- scripts/lib/eval-harness/capsule.js | 21 ++++--- tests/lib/eval-harness/capsule.test.js | 87 +++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/scripts/lib/eval-harness/capsule.js b/scripts/lib/eval-harness/capsule.js index 7e0079aa0..eeb03b1c3 100644 --- a/scripts/lib/eval-harness/capsule.js +++ b/scripts/lib/eval-harness/capsule.js @@ -68,13 +68,13 @@ function metadataFailure(meta, entries = []) { return null; } -function releaseOwnedLock(lockPath, fd, identity) { +function releaseOwnedLock(lockPath, fd, identity, ownerToken) { let inspectionDenied; try { - // Keep the original descriptor open while checking ownership so its inode - // cannot be reused. Preserve a replacement detected before release; this - // check is not atomic against noncooperating filesystem mutation. - if (identity) { + // Keep the original descriptor open through the ownership checks. The + // token also distinguishes files when filesystem identities are ambiguous. + // This is not atomic against noncooperating filesystem mutation. + if (identity && ownerToken) { let current; try { current = fs.lstatSync(lockPath); } catch (error) { if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release'); @@ -82,7 +82,8 @@ function releaseOwnedLock(lockPath, fd, identity) { inspectionDenied = error; } if (!inspectionDenied) { - if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino) { + if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino + || fs.readFileSync(lockPath, 'utf8') !== ownerToken) { throw new CapsuleError('capsule.lock_lost', 'append lock ownership changed before release'); } fs.unlinkSync(lockPath); @@ -113,11 +114,17 @@ function withAppendLock(dir, operation) { throw error; } let identity; + let ownerToken; try { identity = fs.fstatSync(fd); + const token = crypto.randomBytes(16).toString('hex'); + fs.writeFileSync(fd, token, 'utf8'); + ownerToken = token; return operation(); } finally { - releaseOwnedLock(lockPath, fd, identity); + // Failed initialization keeps its partial barrier for diagnosis, without + // deleting a pathname whose ownership cannot be established. + releaseOwnedLock(lockPath, fd, identity, ownerToken); } } diff --git a/tests/lib/eval-harness/capsule.test.js b/tests/lib/eval-harness/capsule.test.js index 4640d525c..27d4d69dd 100644 --- a/tests/lib/eval-harness/capsule.test.js +++ b/tests/lib/eval-harness/capsule.test.js @@ -391,6 +391,45 @@ test('release preserves a detected replacement lock instead of deleting another } finally { cleanup(dir); } }); +test('release preserves a replacement lock even when inode identity is indistinguishable', () => { + const dir = tempDir('equal-lock-identity'); + const lock = appendLock(dir); + const original = { open: fs.openSync, stat: fs.lstatSync, fstat: fs.fstatSync }; + let ownedFd; + const ambiguous = stat => ({ ...stat, dev: 0, ino: 0, isFile: () => stat.isFile() }); + try { + capsule.Capsule.create(dir); + fs.openSync = (file, flags, ...args) => { + const fd = original.open(file, flags, ...args); + if (file === lock && flags === 'wx') ownedFd = fd; + return fd; + }; + fs.fstatSync = (fd, ...args) => { + const stat = original.fstat(fd, ...args); + return fd === ownedFd ? ambiguous(stat) : stat; + }; + fs.lstatSync = (file, ...args) => { + const stat = original.stat(file, ...args); + return file === lock ? ambiguous(stat) : stat; + }; + const c = capsule.Capsule.open(dir, { clock: () => { + fs.renameSync(lock, path.join(dir, 'displaced-lock')); + fs.writeFileSync(lock, 'replacement owner'); + return fixedClock(); + } }); + assert.throws(() => c.append('plan', 'owner', {}), error => error.code === 'capsule.lock_lost'); + assert.strictEqual(fs.readFileSync(lock, 'utf8'), 'replacement owner'); + assert.strictEqual(capsule.verify(dir).entry_count, 1); + assert.ok(capsule.verify(dir).ok); + assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy'); + } finally { + fs.openSync = original.open; + fs.lstatSync = original.stat; + fs.fstatSync = original.fstat; + cleanup(dir); + } +}); + test('a lock removed externally is reported as lost after closing owned descriptors', () => { const dir = tempDir('missing-lock'); try { @@ -418,10 +457,11 @@ for (const scenario of [ test(`lock release: ${scenario.name}`, () => { const dir = tempDir('lock-close-boundary'); const lock = appendLock(dir); - const original = { open: fs.openSync, close: fs.closeSync, stat: fs.lstatSync, unlink: fs.unlinkSync }; + const original = { open: fs.openSync, close: fs.closeSync, stat: fs.lstatSync, unlink: fs.unlinkSync, write: fs.writeFileSync }; const permissionError = Object.assign(new Error('synthetic lock inspection denied'), { code: scenario.code || 'EPERM' }); const closeError = Object.assign(new Error('synthetic ambiguous close failure'), { code: 'EIO' }); let ownedFd; + let ownedToken; let closed = false; let closes = 0; let inspections = 0; @@ -433,6 +473,11 @@ for (const scenario of [ if (file === lock && args[0] === 'wx') ownedFd = fd; return fd; }; + fs.writeFileSync = function(file, data, ...args) { + const result = original.write.call(this, file, data, ...args); + if (file === ownedFd) ownedToken = data; + return result; + }; fs.lstatSync = function(file, ...args) { if (file === lock) { inspections += 1; @@ -471,8 +516,9 @@ for (const scenario of [ fs.closeSync = original.close; fs.lstatSync = original.stat; fs.unlinkSync = original.unlink; + fs.writeFileSync = original.write; if (scenario.outcome === 'missing') assert.strictEqual(fs.existsSync(lock), false); - else assert.strictEqual(fs.readFileSync(lock, 'utf8'), scenario.outcome === 'replacement' ? 'replacement owner' : ''); + else assert.strictEqual(fs.readFileSync(lock, 'utf8'), scenario.outcome === 'replacement' ? 'replacement owner' : ownedToken); // Release failure can follow a complete durable append; never infer rollback. assert.strictEqual(capsule.verify(dir).entry_count, 1); assert.strictEqual(capsule.verify(dir).ok, true); @@ -481,11 +527,48 @@ for (const scenario of [ fs.closeSync = original.close; fs.lstatSync = original.stat; fs.unlinkSync = original.unlink; + fs.writeFileSync = original.write; cleanup(dir); } }); } +test('failed ownership-token initialization preserves the primary error and partial barrier', () => { + const dir = tempDir('lock-token-init'); + const lock = appendLock(dir); + const original = { open: fs.openSync, write: fs.writeFileSync, close: fs.closeSync }; + const failure = Object.assign(new Error('synthetic token initialization failure'), { code: 'EIO' }); + let ownedFd; + let closed = false; + try { + const c = capsule.Capsule.create(dir); + fs.openSync = (file, flags, ...args) => { + const fd = original.open(file, flags, ...args); + if (file === lock && flags === 'wx') ownedFd = fd; + return fd; + }; + fs.writeFileSync = (file, ...args) => { + if (file === ownedFd) throw failure; + return original.write(file, ...args); + }; + fs.closeSync = fd => { + const result = original.close(fd); + if (fd === ownedFd) closed = true; + return result; + }; + assert.throws(() => c.append('plan', 'owner', {}), error => error === failure); + assert.strictEqual(closed, true); + assert.strictEqual(fs.readFileSync(lock, 'utf8'), ''); + assert.strictEqual(capsule.verify(dir).entry_count, 0); + assert.throws(() => capsule.Capsule.open(dir).append('plan', 'blocked', {}), error => error.code === 'capsule.busy'); + } finally { + fs.openSync = original.open; + fs.writeFileSync = original.write; + fs.closeSync = original.close; + cleanup(dir); + } +}); + test('invalid payloads leave journal unchanged and release the append lock', () => { const dir = tempDir(); try {