/** * Unit tests for the capability ledger module (ADR-1244 Phase 3, Decision D4). * * Tests are hermetic: each uses its own tmpdir created by createTempDir and * cleaned up in t.after(). No shared state between tests. */ 'use strict'; const { test, mock } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const capLedger = require('../gsd-core/bin/lib/capability-ledger.cjs'); const { readLedger, writeLedger, recordInstall, removeEntry, reconcile, LEDGER_FILE_NAME, } = capLedger; // Destructure optional exports (new in this patch) — will be undefined until implemented. const { LedgerIOError, isValidLedgerEntry, readLedgerStrict, readSmallRegularFile } = capLedger; const { runHook } = require('./helpers/process-seam.cjs'); // A single `mkfifo` system call creating a fixture FIFO — no install or build. const MKFIFO_TIMEOUT_MS = 15000; /** POSIX-only: make a FIFO at `p` (skips/returns false where mkfifo is unavailable). */ function tryMkfifo(p) { if (process.platform === 'win32') return false; const res = runHook(p, [], { interpreter: 'mkfifo', timeoutMs: MKFIFO_TIMEOUT_MS }); return res.exitCode === 0; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build a minimal valid LedgerEntry. */ function makeEntry(id = 'test-cap', overrides = {}) { return { id, version: '1.0.0', source: 'registry:test', integrity: 'sha256-abc123', files: [], sharedEdits: [], ...overrides, }; } /** Build a minimal valid LedgerFile. */ function makeLedger(overrides = {}) { return { version: '1', updatedAt: new Date().toISOString(), entries: {}, ...overrides, }; } /** Return all tmp files left in dir (matches .tmp.- pattern). */ function orphanTmpFiles(dir) { if (!fs.existsSync(dir)) return []; // Temp names are .tmp.- — the nonce suffix after the pid is required // to avoid treating the bare .tmp. form as a hit (finding 17). return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+-[0-9a-f]+$/.test(n)); } // --------------------------------------------------------------------------- // readLedger — missing file // --------------------------------------------------------------------------- test('readLedger returns null for a missing file (no throw)', (t) => { const dir = createTempDir('ledger-missing-'); t.after(() => cleanup(dir)); const result = readLedger(dir); assert.equal(result, null, 'must return null for a missing ledger file'); }); // --------------------------------------------------------------------------- // readLedger — corrupt JSON // --------------------------------------------------------------------------- test('readLedger returns null for corrupt JSON (no throw)', (t) => { const dir = createTempDir('ledger-corrupt-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), 'NOT { valid JSON }\n'); const result = readLedger(dir); assert.equal(result, null, 'must return null for corrupt JSON'); }); // --------------------------------------------------------------------------- // writeLedger / readLedger round-trip // --------------------------------------------------------------------------- test('writeLedger then readLedger round-trips a valid ledger', (t) => { const dir = createTempDir('ledger-roundtrip-'); t.after(() => cleanup(dir)); const ledger = makeLedger({ entries: { 'my-cap': makeEntry('my-cap', { files: ['commands/gsd/my-cap.md'] }), }, }); writeLedger(dir, ledger); const readBack = readLedger(dir); assert.ok(readBack !== null, 'readLedger must return the written ledger'); assert.equal(readBack.version, '1'); assert.equal(typeof readBack.updatedAt, 'string'); assert.ok('my-cap' in readBack.entries, 'entry must survive the round-trip'); assert.deepEqual(readBack.entries['my-cap'].files, ['commands/gsd/my-cap.md']); }); // --------------------------------------------------------------------------- // writeLedger — no orphan .tmp file // --------------------------------------------------------------------------- test('writeLedger leaves no orphan .tmp file after a successful write', (t) => { const dir = createTempDir('ledger-no-orphan-'); t.after(() => cleanup(dir)); writeLedger(dir, makeLedger()); const orphans = orphanTmpFiles(dir); assert.deepEqual(orphans, [], 'must leave no .tmp. orphan after write'); // The real ledger file must exist. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), true); }); // --------------------------------------------------------------------------- // Finding 4 (MEDIUM): the directory fsync in writeLedger (fsyncContainingDir) // must NOT swallow ALL errors. It tolerates ONLY EISDIR/EPERM/EINVAL/EBADF // (platforms that disallow directory fsync); any other errno (e.g. EIO) must // RETHROW (durability could not be confirmed). The dir fd must still be closed. // --------------------------------------------------------------------------- /** * Run `fn` with fs.fsyncSync mocked to throw `errno` ONLY for the directory fd * (the fd openSync returned for a path opened with the 'r' flag — writeLedger * opens the containing dir with 'r'). File-fd fsync (the write fd) passes through. */ function withDirFsyncError(t, errno, fn) { const dirFds = new Set(); const realOpen = fs.openSync.bind(fs); const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { const fd = realOpen(p, flags, ...rest); if (flags === 'r') dirFds.add(fd); // writeLedger opens the containing DIR with 'r' return fd; }); const realClose = fs.closeSync.bind(fs); const closed = []; const closeMock = mock.method(fs, 'closeSync', function (fd) { // Remove the fd from the tracked set BEFORE closing: once closed the OS may reuse the same // fd NUMBER for an unrelated open, which must NOT be treated as the directory fd. if (dirFds.has(fd)) { closed.push(fd); dirFds.delete(fd); } return realClose(fd); }); const realFsync = fs.fsyncSync.bind(fs); const fsyncMock = mock.method(fs, 'fsyncSync', function (fd) { if (dirFds.has(fd)) { const e = new Error(`${errno}: injected`); e.code = errno; throw e; } return realFsync(fd); }); t.after(() => { openMock.mock.restore(); closeMock.mock.restore(); fsyncMock.mock.restore(); }); return fn({ dirFds, closed }); } // Revert-fails: restore the swallow-all behavior (no rethrow for non-tolerated // errnos) → writeLedger completes silently on an EIO dir-fsync, so this // assert.throws sees no throw and fails. test('finding-4: writeLedger RETHROWS a NON-tolerated dir-fsync errno (EIO) — durability not silently claimed', (t) => { const dir = createTempDir('ledger-finding4-eio-'); t.after(() => cleanup(dir)); withDirFsyncError(t, 'EIO', ({ closed }) => { assert.throws( () => writeLedger(dir, makeLedger()), (err) => { assert.match(String(err && err.message), /durab/i, 'the rethrown error must indicate durability could not be confirmed'); return true; }, 'an EIO directory-fsync error must NOT be swallowed', ); assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)'); }); }); // Revert-fails: if the tolerated-errno allowlist is removed (rethrow EVERYTHING), // EISDIR would throw and this "does not throw" assertion fails. test('finding-4: writeLedger TOLERATES an EISDIR dir-fsync errno (platform disallows dir fsync)', (t) => { const dir = createTempDir('ledger-finding4-eisdir-'); t.after(() => cleanup(dir)); withDirFsyncError(t, 'EISDIR', ({ closed }) => { assert.doesNotThrow(() => writeLedger(dir, makeLedger()), 'an EISDIR directory-fsync error must be tolerated (best-effort)'); assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), true, 'ledger still written'); assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)'); }); }); // --------------------------------------------------------------------------- // recordInstall — idempotent (same id twice → one entry, replaced) // --------------------------------------------------------------------------- test('recordInstall is idempotent: same id twice yields one entry with the latest data', (t) => { const dir = createTempDir('ledger-idempotent-'); t.after(() => cleanup(dir)); recordInstall(dir, makeEntry('cap-a', { version: '1.0.0' })); recordInstall(dir, makeEntry('cap-a', { version: '2.0.0' })); const ledger = readLedger(dir); assert.ok(ledger !== null); const ids = Object.keys(ledger.entries); assert.equal(ids.length, 1, 'must have exactly one entry'); assert.equal(ledger.entries['cap-a'].version, '2.0.0', 'entry must reflect the last write'); }); // --------------------------------------------------------------------------- // recordInstall — __proto__ injection rejected // --------------------------------------------------------------------------- test('recordInstall rejects a __proto__ id without polluting Object.prototype (now THROWS — ROOT FIX 3)', (t) => { const dir = createTempDir('ledger-proto-'); t.after(() => cleanup(dir)); // Capture the prototype BEFORE calling recordInstall. const preBefore = Object.prototype['injected']; // ROOT FIX 3: recordInstall now THROWS (not silently returns) for unsafe ids. // This is correct behavior — silent return allowed callers to assume success. assert.throws( () => recordInstall(dir, makeEntry('__proto__', { integrity: 'evil' })), (err) => err instanceof Error, 'recordInstall must throw for __proto__ id (ROOT FIX 3: throw not silent return)', ); // Prototype must not have been polluted. assert.equal(Object.prototype['injected'], preBefore); assert.equal(({}).__proto__['injected'], preBefore); // The ledger file must not exist (thrown before any write). assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, '__proto__ id must not produce a ledger file'); }); test('recordInstall rejects "constructor" and "prototype" ids (now THROWS — ROOT FIX 3)', (t) => { const dir = createTempDir('ledger-proto2-'); t.after(() => cleanup(dir)); // ROOT FIX 3: must throw, not silently return. assert.throws( () => recordInstall(dir, makeEntry('constructor')), (err) => err instanceof Error, 'must throw for constructor id', ); assert.throws( () => recordInstall(dir, makeEntry('prototype')), (err) => err instanceof Error, 'must throw for prototype id', ); // No ledger file must exist. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'no ledger must exist after throws for unsafe ids'); }); // --------------------------------------------------------------------------- // Finding 3 (MEDIUM): recordInstall must validate the WHOLE entry (via isValidLedgerEntry), // not only entry.id — so it can never write a ledger that readLedger would then reject as // corrupt (e.g. files:[123]). It must THROW on a structurally-invalid entry and write nothing. // --------------------------------------------------------------------------- test('finding-3: recordInstall THROWS on a structurally-invalid entry (files:[123]) and writes nothing', (t) => { const dir = createTempDir('ledger-record-badentry-'); t.after(() => cleanup(dir)); // Valid kebab id, but files[] holds a non-string — readLedger would reject this as corrupt. const badEntry = makeEntry('cap-bad', { files: [123] }); assert.throws( () => recordInstall(dir, badEntry), (err) => err instanceof Error, 'recordInstall must throw on a structurally-invalid entry (files:[123])', ); // It must NOT have written a self-corrupting ledger. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'recordInstall must write nothing when the entry is structurally invalid'); }); test('finding-3: recordInstall THROWS on an entry whose sharedEdits member is missing marker (writes nothing)', (t) => { const dir = createTempDir('ledger-record-badedit-'); t.after(() => cleanup(dir)); const badEntry = makeEntry('cap-bad2', { sharedEdits: [{ file: 'settings.json' }] }); assert.throws( () => recordInstall(dir, badEntry), (err) => err instanceof Error, 'recordInstall must throw on an entry with a malformed sharedEdits member', ); assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'recordInstall must write nothing for a malformed entry'); }); test('finding-3: recordInstall whole-entry validation does NOT reject a valid entry (non-regression)', (t) => { const dir = createTempDir('ledger-record-valid-'); t.after(() => cleanup(dir)); assert.doesNotThrow( () => recordInstall(dir, makeEntry('cap-ok', { files: ['commands/gsd/cap-ok.md'], sharedEdits: [{ file: 'settings.json', marker: 'cap-ok' }], })), 'a fully-valid entry must still record cleanly', ); const ledger = readLedger(dir); assert.ok(ledger && ledger.entries['cap-ok'], 'valid entry must be recorded'); }); // --------------------------------------------------------------------------- // removeEntry — removes target + returns true/false // --------------------------------------------------------------------------- test('removeEntry removes only the target entry and returns true', (t) => { const dir = createTempDir('ledger-remove-'); t.after(() => cleanup(dir)); recordInstall(dir, makeEntry('cap-x')); recordInstall(dir, makeEntry('cap-y')); const removed = removeEntry(dir, 'cap-x'); assert.equal(removed, true, 'must return true when the entry existed'); const ledger = readLedger(dir); assert.ok(ledger !== null); assert.ok(!('cap-x' in ledger.entries), 'cap-x must be gone'); assert.ok('cap-y' in ledger.entries, 'cap-y must remain'); }); test('removeEntry returns false when the id does not exist', (t) => { const dir = createTempDir('ledger-remove-miss-'); t.after(() => cleanup(dir)); recordInstall(dir, makeEntry('cap-z')); const removed = removeEntry(dir, 'nonexistent'); assert.equal(removed, false, 'must return false when the entry is absent'); // The remaining entry must be untouched. const ledger = readLedger(dir); assert.ok(ledger !== null); assert.ok('cap-z' in ledger.entries); }); // --------------------------------------------------------------------------- // Finding 4 (MEDIUM): removeEntry must be fail-closed on a corrupt-but-present ledger // — it must NOT return false (which would masquerade as "not installed") but instead // THROW (use readLedgerStrict) so a corrupt ledger cannot hide a recorded entry. // --------------------------------------------------------------------------- test('finding-4: removeEntry THROWS on a corrupt-but-present ledger (fail-closed, never returns false)', (t) => { const dir = createTempDir('ledger-remove-corrupt-'); t.after(() => cleanup(dir)); // First record a valid entry, then corrupt the on-disk ledger. recordInstall(dir, makeEntry('cap-corrupt')); const ledgerPath = path.join(dir, LEDGER_FILE_NAME); const corrupt = '{ broken json ---'; fs.writeFileSync(ledgerPath, corrupt); // removeEntry must FAIL CLOSED — throw (CorruptLedgerError), never silently return false. let threw = false; let ret; try { ret = removeEntry(dir, 'cap-corrupt'); } catch (err) { threw = true; assert.ok(/corrupt|invalid/i.test(err.message), `error must name corruption; got: "${err.message}"`); } assert.equal(threw, true, `removeEntry must THROW on a corrupt-present ledger, not return ${JSON.stringify(ret)} ` + `(returning false would masquerade as "not installed")`); // Non-destructive: the corrupt file is left in place untouched. assert.equal(fs.readFileSync(ledgerPath, 'utf8'), corrupt, 'corrupt ledger must be left in place untouched'); }); test('finding-4: removeEntry on a genuinely MISSING ledger still returns false (non-regression)', (t) => { const dir = createTempDir('ledger-remove-missing-'); t.after(() => cleanup(dir)); // No ledger file written at all. const removed = removeEntry(dir, 'nope'); assert.equal(removed, false, 'removeEntry on a missing ledger must return false (missing != corrupt)'); }); // --------------------------------------------------------------------------- // reconcile — orphans when recorded files are missing // --------------------------------------------------------------------------- test('reconcile reports orphans when a recorded file is missing on disk', (t) => { const dir = createTempDir('ledger-reconcile-miss-'); t.after(() => cleanup(dir)); recordInstall(dir, makeEntry('cap-missing', { files: ['commands/gsd/cap-missing.md', 'agents/gsd-cap.md'], })); const result = reconcile(dir); assert.equal(result.warnings.length, 0); assert.equal(result.orphans.length, 1, 'must report one orphan entry'); assert.equal(result.orphans[0].id, 'cap-missing'); assert.deepEqual( result.orphans[0].missing.sort(), ['agents/gsd-cap.md', 'commands/gsd/cap-missing.md'].sort(), ); }); // --------------------------------------------------------------------------- // reconcile — empty result when all files are present // --------------------------------------------------------------------------- test('reconcile returns empty orphans when all recorded files exist on disk', (t) => { const dir = createTempDir('ledger-reconcile-ok-'); t.after(() => cleanup(dir)); // Create the files that will be recorded. const subdir = path.join(dir, 'commands', 'gsd'); fs.mkdirSync(subdir, { recursive: true }); fs.writeFileSync(path.join(subdir, 'cap-present.md'), '# cap\n'); recordInstall(dir, makeEntry('cap-present', { files: ['commands/gsd/cap-present.md'], })); const result = reconcile(dir); assert.equal(result.warnings.length, 0); assert.deepEqual(result.orphans, [], 'must report no orphans when files exist'); assert.deepEqual(result.stale, []); }); // --------------------------------------------------------------------------- // reconcile — warning for corrupt ledger (file exists but not parseable) // --------------------------------------------------------------------------- test('reconcile issues a warning when the ledger file is corrupt', (t) => { const dir = createTempDir('ledger-reconcile-corrupt-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), '<<>>'); const result = reconcile(dir); assert.equal(result.orphans.length, 0, 'no orphans for unreadable ledger'); assert.ok(result.warnings.length > 0, 'must emit at least one warning'); assert.ok( result.warnings[0].includes('could not be parsed') || result.warnings[0].includes(dir), 'warning must reference the ledger file or describe the parse failure', ); }); // --------------------------------------------------------------------------- // Finding 5 (LOW): read-only reconcile() must detect a DANGLING-SYMLINK ledger via lstat, // not existsSync. existsSync follows the symlink → returns false for a broken symlink → // reports the ledger "missing" (no warning) when it is actually an unreadable IO problem. // --------------------------------------------------------------------------- test('finding-5: reconcile() WARNS for a dangling-symlink ledger (lstat, not existsSync)', (t) => { const dir = createTempDir('ledger-reconcile-dangling-'); t.after(() => cleanup(dir)); // Create the ledger path as a symlink to a non-existent target (dangling/broken symlink). const ledgerPath = path.join(dir, LEDGER_FILE_NAME); const missingTarget = path.join(dir, 'does-not-exist-target.json'); try { fs.symlinkSync(missingTarget, ledgerPath); } catch (err) { // Some CI filesystems (e.g. restrictive Windows) cannot create symlinks; skip cleanly. if (err && (err.code === 'EPERM' || err.code === 'ENOSYS')) { t.skip('symlink creation not permitted on this filesystem'); return; } throw err; } const result = reconcile(dir); // UNCONDITIONAL: a dangling-symlink ledger entry must NOT be silently treated as "missing". assert.equal(result.orphans.length, 0, 'no orphans for an unreadable ledger'); assert.ok(result.warnings.length > 0, 'reconcile() must emit a warning for a dangling-symlink ledger (lstat detects the entry; ' + 'existsSync would follow the broken link and report it missing with NO warning)'); }); // --------------------------------------------------------------------------- // fs fault-injection — writeLedger now uses local atomic write (tmp+rename, no // truncating fallback). A renameSync failure propagates as an error (LEDGER-2). // --------------------------------------------------------------------------- test('writeLedger throws when renameSync fails (no silent truncating fallback, LEDGER-2)', (t) => { const dir = createTempDir('ledger-fault-'); t.after(() => cleanup(dir)); let renameCalls = 0; const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { renameCalls++; // Simulate a cross-device rename failure. const err = new Error('EXDEV: cross-device link not permitted'); err.code = 'EXDEV'; throw err; }); t.after(() => renameMock.mock.restore()); const ledger = makeLedger({ entries: { 'fault-cap': makeEntry('fault-cap') }, }); // The new writeLedger has no truncating fallback — it must throw on renameSync // failure rather than silently writing a potentially corrupt direct file. assert.throws( () => writeLedger(dir, ledger), (err) => { assert.ok(err instanceof Error); assert.ok(err.code === 'EXDEV' || err.message.includes('EXDEV'), `expected EXDEV error; got: ${err.message}`); return true; }, 'writeLedger must propagate renameSync errors (no truncating fallback)', ); assert.ok(renameCalls >= 1, 'renameSync must have been invoked'); // No ledger file must exist (write was rejected) — the real ledger is safe. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); assert.equal( fs.existsSync(ledgerPath), false, 'no ledger file must be written when renameSync fails', ); // Any .tmp file must NOT remain as an orphan (finding 18). // writeLedger's try/catch around renameSync unlinks the temp file before rethrowing, // so no orphan is left behind — this is an enforced invariant, not merely acceptable. const orphansAfterRename = fs.readdirSync(dir).filter((n) => n.includes('.tmp.') || n.includes('.tmp-')); assert.deepEqual(orphansAfterRename, [], `no orphan tmp file must remain after renameSync failure; found: ${orphansAfterRename.join(', ')}`); }); // --------------------------------------------------------------------------- // LEDGER-1 regression: recordInstall on corrupt-but-present ledger must throw // and leave the corrupt file IN PLACE (no quarantine/move — finding 1, core redesign). // --------------------------------------------------------------------------- test('recordInstall throws on a corrupt-but-present ledger and leaves the file IN PLACE (LEDGER-1 / finding-1)', (t) => { const dir = createTempDir('ledger-corrupt-guard-'); t.after(() => cleanup(dir)); // 1. Write a valid ledger with entry "A". const entryA = makeEntry('cap-a', { files: ['commands/gsd/cap-a.md'], sharedEdits: [{ file: 'settings.json', marker: 'cap-a' }], }); recordInstall(dir, entryA); // 2. Corrupt the ledger file on disk. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); const corruptContent = '{ broken json ---'; fs.writeFileSync(ledgerPath, corruptContent); // 3. Attempting recordInstall for "B" must throw (not silently overwrite). assert.throws( () => recordInstall(dir, makeEntry('cap-b')), (err) => { assert.ok(err instanceof Error, 'must throw an Error instance'); assert.ok( err.message.includes('corrupt') || err.message.includes(ledgerPath), `error message must mention corruption or the path; got: ${err.message}`, ); return true; }, 'recordInstall must throw when the ledger file is present but corrupt', ); // 4. The corrupt file must still be at its ORIGINAL PATH (not moved/renamed/quarantined). // This is the key invariant: leaving it in place means every subsequent op also blocks // until the user resolves it (finding 1 — no "succeeds fresh on 2nd run"). assert.ok(fs.existsSync(ledgerPath), 'the corrupt ledger file must remain at its original path (not moved/quarantined)'); assert.equal(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'the corrupt content must be intact (file not altered)'); // 5. No quarantine files must exist (no auto-move behavior). const dirContents = fs.readdirSync(dir); const quarantineFiles = dirContents.filter((n) => n.includes(LEDGER_FILE_NAME) && n.includes('.corrupt.')); assert.deepEqual(quarantineFiles, [], `no quarantine files must exist; dir contents: ${dirContents.join(', ')}`); // 6. A SECOND recordInstall attempt must ALSO throw (not silently succeed on fresh state). // This proves finding 1 is fixed: repeated ops keep blocking. assert.throws( () => recordInstall(dir, makeEntry('cap-c')), (err) => err instanceof Error && (err.message.includes('corrupt') || err.message.includes(ledgerPath)), 'second recordInstall must also throw — the corrupt file blocks persistently', ); }); // --------------------------------------------------------------------------- // LEDGER-1 regression: recordInstall on a MISSING ledger still creates a fresh one // --------------------------------------------------------------------------- test('recordInstall on a genuinely missing ledger creates a fresh ledger and succeeds (LEDGER-1 non-regression)', (t) => { const dir = createTempDir('ledger-missing-fresh-'); t.after(() => cleanup(dir)); // No ledger file exists yet. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); assert.equal(fs.existsSync(ledgerPath), false, 'pre-condition: no ledger file'); // recordInstall must succeed and create a fresh ledger. assert.doesNotThrow( () => recordInstall(dir, makeEntry('cap-fresh', { files: ['commands/gsd/cap-fresh.md'] })), 'recordInstall must not throw for a missing ledger', ); const ledger = readLedger(dir); assert.ok(ledger !== null, 'ledger must exist after first recordInstall'); assert.ok('cap-fresh' in ledger.entries, 'cap-fresh entry must be present'); }); // --------------------------------------------------------------------------- // Finding 1 (persistence): corrupt-present ledger blocks ALL subsequent operations, // not just the first one. The file stays in place so no "succeeds fresh on 2nd run". // --------------------------------------------------------------------------- test('recordInstall: corrupt-present ledger blocks ALL subsequent calls persistently (finding-1 persistence)', (t) => { const dir = createTempDir('ledger-persistent-block-'); t.after(() => cleanup(dir)); const ledgerPath = path.join(dir, LEDGER_FILE_NAME); const corruptContent = '{ broken json ---'; fs.writeFileSync(ledgerPath, corruptContent); // Every successive call must throw with the same corruption message. for (let i = 0; i < 3; i++) { assert.throws( () => recordInstall(dir, makeEntry(`cap-${i}`)), (err) => err instanceof Error && (err.message.includes('corrupt') || err.message.includes(ledgerPath)), `call ${i + 1} must also throw — corrupt file blocks persistently`, ); } // The file must still be at its original path and content after all throws. assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after repeated throws'); assert.equal(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'content unchanged'); // No quarantine files must exist. const quarantineFiles = fs.readdirSync(dir).filter((n) => n.includes(LEDGER_FILE_NAME) && n.includes('.corrupt.')); assert.deepEqual(quarantineFiles, [], 'no auto-quarantine files must exist'); }); // --------------------------------------------------------------------------- // Finding 2 (non-destructive): multiple corrupt-ledger calls across different // dirs each block and leave the original file intact (no move/rename/delete). // --------------------------------------------------------------------------- test('recordInstall: two corrupt-ledger calls produce distinct errors but leave each corrupt file in place (non-destructive)', (t) => { const dirA = createTempDir('ledger-nd-a-'); const dirB = createTempDir('ledger-nd-b-'); t.after(() => { cleanup(dirA); cleanup(dirB); }); const corruptA = '{ broken json --- A'; const corruptB = '{ broken json --- B'; fs.writeFileSync(path.join(dirA, LEDGER_FILE_NAME), corruptA); fs.writeFileSync(path.join(dirB, LEDGER_FILE_NAME), corruptB); let errA, errB; try { recordInstall(dirA, makeEntry('a')); } catch (e) { errA = e; } try { recordInstall(dirB, makeEntry('b')); } catch (e) { errB = e; } assert.ok(errA instanceof Error, 'call A must throw'); assert.ok(errB instanceof Error, 'call B must throw'); // Both original corrupt files must still exist with their original content. assert.equal(fs.readFileSync(path.join(dirA, LEDGER_FILE_NAME), 'utf8'), corruptA, 'dirA corrupt file must remain intact'); assert.equal(fs.readFileSync(path.join(dirB, LEDGER_FILE_NAME), 'utf8'), corruptB, 'dirB corrupt file must remain intact'); // No quarantine files in either dir. assert.deepEqual( fs.readdirSync(dirA).filter((n) => n.includes('.corrupt.')), [], 'no quarantine files in dirA', ); assert.deepEqual( fs.readdirSync(dirB).filter((n) => n.includes('.corrupt.')), [], 'no quarantine files in dirB', ); }); // --------------------------------------------------------------------------- // Finding 3: writeLedger tmp path must use exclusive create (O_EXCL / wx) so // a pre-existing symlink at the tmp path cannot redirect the write. // // Scope note (test-quality): this test verifies the MECHANISM — that writeLedger // opens the tmp file with an exclusive flag (wx / O_EXCL) and writes the ledger // without clobbering a file outside the dir. It does NOT plant a symlink; the // actual pre-planted-symlink-throws behavior is covered by the finding-15 test // just below (which forces a known nonce and a real symlink at the tmp path). // (Renamed from a misleading "...causes a throw" title that asserted only the flag.) // --------------------------------------------------------------------------- test('writeLedger opens the tmp file with an exclusive flag (wx / O_EXCL) and does not clobber an outside file (finding-3)', (t) => { const dir = createTempDir('ledger-excl-'); const outside = createTempDir('ledger-excl-outside-'); t.after(() => { cleanup(dir); cleanup(outside); }); const victim = path.join(outside, 'victim.txt'); fs.writeFileSync(victim, 'precious', 'utf8'); // Intercept openSync to capture flags used for tmp files. // We use a wrapper that delegates to the real openSync. const realOpenSync = fs.openSync.bind(fs); let sawExclusiveFlag = false; const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { if (typeof flags === 'string' && flags.includes('x')) sawExclusiveFlag = true; if (typeof flags === 'number' && (flags & fs.constants.O_EXCL)) sawExclusiveFlag = true; return realOpenSync(p, flags, ...rest); }); t.after(() => openMock.mock.restore()); writeLedger(dir, makeLedger()); assert.ok(sawExclusiveFlag, 'writeLedger must open the tmp file with an exclusive flag (wx / O_EXCL)'); // The real ledger must exist and be valid. const back = readLedger(dir); assert.ok(back !== null, 'ledger must be written successfully'); // victim.txt must be untouched. assert.equal(fs.readFileSync(victim, 'utf8'), 'precious', 'victim outside dir must not be clobbered'); }); // --------------------------------------------------------------------------- // Finding 15: writeLedger: pre-existing symlink at known tmp path causes throw. // This test is made REAL by intercepting crypto.randomBytes to force a known // nonce and openSync to throw EEXIST for that specific tmp path (simulating a // pre-planted symlink), verifying O_EXCL defense works. // --------------------------------------------------------------------------- test('writeLedger: O_EXCL prevents write through a pre-planted symlink at the tmp path (finding-15)', (t) => { const dir = createTempDir('ledger-symlink-excl-'); const outside = createTempDir('ledger-symlink-outside-'); t.after(() => { cleanup(dir); cleanup(outside); }); const ledgerFilePath = path.join(dir, LEDGER_FILE_NAME); const knownNonce = 'deadbeef'; const tmpPath = `${ledgerFilePath}.tmp.${process.pid}-${knownNonce}`; const victimFile = path.join(outside, 'victim.txt'); fs.writeFileSync(victimFile, 'precious', 'utf8'); // Pre-plant a symlink at the exact tmp path pointing to our victim. fs.symlinkSync(victimFile, tmpPath); // Mock randomBytes to return the known nonce so we know exactly what tmp path // writeLedger will compute (finding 15: make the test non-vacuous). const crypto = require('node:crypto'); const randomBytesMock = mock.method(crypto, 'randomBytes', (_n) => { return Buffer.from(knownNonce, 'hex'); }); t.after(() => randomBytesMock.mock.restore()); // writeLedger must throw because openSync with 'wx' (O_EXCL) fails on the symlink. assert.throws( () => writeLedger(dir, makeLedger()), (err) => { // EEXIST is thrown by open(O_EXCL) when the path already exists. assert.ok(err instanceof Error); assert.ok(err.code === 'EEXIST', `expected EEXIST; got: ${err.code}`); return true; }, 'writeLedger must throw EEXIST when a symlink pre-exists at the tmp path (O_EXCL defense)', ); // The victim file must be intact — the symlink was NOT followed for writing. assert.equal(fs.readFileSync(victimFile, 'utf8'), 'precious', 'victim file must not be clobbered'); // The ledger must NOT have been written. assert.equal(fs.existsSync(ledgerFilePath), false, 'ledger must not exist after the throw'); }); // --------------------------------------------------------------------------- // Finding 4: writeLedger cleans up the tmp file when renameSync fails // (no orphan .tmp file left behind after a rename error). // --------------------------------------------------------------------------- test('writeLedger cleans up the tmp file when renameSync fails (finding-4)', (t) => { const dir = createTempDir('ledger-orphan-'); t.after(() => cleanup(dir)); // Mock renameSync to fail with EXDEV (after the tmp write has already succeeded). const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { const err = new Error('EXDEV: cross-device link not permitted'); err.code = 'EXDEV'; throw err; }); t.after(() => renameMock.mock.restore()); const ledger = makeLedger({ entries: { 'orphan-cap': makeEntry('orphan-cap') } }); // writeLedger must throw (propagate the rename error). assert.throws( () => writeLedger(dir, ledger), (err) => err.code === 'EXDEV' || err.message.includes('EXDEV'), 'writeLedger must rethrow after cleanup', ); // No orphan .tmp file must remain. const orphans = fs.readdirSync(dir).filter((n) => n.includes('.tmp.') || n.includes('.tmp-')); assert.deepEqual(orphans, [], `no orphan tmp file must remain; found: ${orphans.join(', ')}`); }); // --------------------------------------------------------------------------- // Issue 1 (HIGH): readLedger must deeply validate files[] and sharedEdits[] members. // A ledger with wrong-shape members must be treated as corrupt (readLedger → null, // readLedgerStrict → quarantine+throw), so upgradeCapability/removeCapability never // reach prior.sharedEdits.map() with non-object members. // --------------------------------------------------------------------------- test('readLedger returns null when files[] contains a non-string member (deep validation)', (t) => { const dir = createTempDir('ledger-deep-files-'); t.after(() => cleanup(dir)); const ledger = { version: '1', updatedAt: new Date().toISOString(), entries: { 'bad-cap': { id: 'bad-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-x', files: [123], // non-string member — must fail deep validation sharedEdits: [], }, }, }; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify(ledger, null, 2)); const result = readLedger(dir); assert.equal(result, null, 'readLedger must return null when files[] has a non-string member'); }); test('readLedger returns null when sharedEdits[] contains null (deep validation)', (t) => { const dir = createTempDir('ledger-deep-edits-null-'); t.after(() => cleanup(dir)); const ledger = { version: '1', updatedAt: new Date().toISOString(), entries: { 'bad-cap': { id: 'bad-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-x', files: [], sharedEdits: [null], // null member — must fail deep validation }, }, }; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify(ledger, null, 2)); const result = readLedger(dir); assert.equal(result, null, 'readLedger must return null when sharedEdits[] contains null'); }); test('readLedger returns null when sharedEdits[] member is missing required string fields (deep validation)', (t) => { const dir = createTempDir('ledger-deep-edits-shape-'); t.after(() => cleanup(dir)); const ledger = { version: '1', updatedAt: new Date().toISOString(), entries: { 'bad-cap': { id: 'bad-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-x', files: [], sharedEdits: [{ file: 'settings.json' }], // missing 'marker' field }, }, }; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify(ledger, null, 2)); const result = readLedger(dir); assert.equal(result, null, 'readLedger must return null when sharedEdits[] member lacks required fields'); }); test('readLedger returns null when sharedEdits[] member has non-string file field (deep validation)', (t) => { const dir = createTempDir('ledger-deep-edits-nonstr-'); t.after(() => cleanup(dir)); const ledger = { version: '1', updatedAt: new Date().toISOString(), entries: { 'bad-cap': { id: 'bad-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-x', files: [], sharedEdits: [{ file: 42, marker: 'GSD cap-bad' }], // non-string file field }, }, }; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify(ledger, null, 2)); const result = readLedger(dir); assert.equal(result, null, 'readLedger must return null when sharedEdits[] member has non-string file'); }); test('readLedger still accepts a valid ledger with populated files[] and sharedEdits[] (deep validation non-regression)', (t) => { const dir = createTempDir('ledger-deep-valid-'); t.after(() => cleanup(dir)); const ledger = { version: '1', updatedAt: new Date().toISOString(), entries: { 'good-cap': { id: 'good-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-x', files: ['commands/gsd/good-cap.md'], // marker is a non-empty string (finding-5: relaxed — need not match the entry key) sharedEdits: [{ file: 'settings.json', marker: 'good-cap' }], }, }, }; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify(ledger, null, 2)); const result = readLedger(dir); assert.ok(result !== null, 'readLedger must accept a valid ledger with populated arrays'); assert.ok('good-cap' in result.entries); }); // --------------------------------------------------------------------------- // Issue 2 (MEDIUM): writeLedger must clean up the orphan tmp file when the full // write call (fs.writeFileSync on the fd) fails — not just when renameSync fails. // --------------------------------------------------------------------------- test('writeLedger cleans up the tmp file when the write to the fd fails (issue-2)', (t) => { const dir = createTempDir('ledger-writesync-fail-'); t.after(() => cleanup(dir)); // writeLedger now uses fs.writeFileSync(fd, content) which is a full-buffer write. // Mock writeFileSync to throw when called with a number fd (the tmp file fd). const realWriteFileSync = fs.writeFileSync.bind(fs); const writeFileSyncMock = mock.method(fs, 'writeFileSync', function (fdOrPath, content, ...rest) { if (typeof fdOrPath === 'number') { // This is the fd-based write inside writeLedger — simulate ENOSPC. const err = new Error('ENOSPC: no space left on device'); err.code = 'ENOSPC'; throw err; } return realWriteFileSync(fdOrPath, content, ...rest); }); t.after(() => writeFileSyncMock.mock.restore()); const ledger = makeLedger({ entries: { 'ws-cap': makeEntry('ws-cap') } }); // writeLedger must throw. assert.throws( () => writeLedger(dir, ledger), (err) => err.code === 'ENOSPC' || err.message.includes('ENOSPC'), 'writeLedger must rethrow write errors', ); // No orphan .tmp file must remain after the failure. const orphans = fs.readdirSync(dir).filter((n) => n.includes('.tmp.') || n.includes('.tmp-')); assert.deepEqual(orphans, [], `no orphan tmp file must remain after write failure; found: ${orphans.join(', ')}`); }); // --------------------------------------------------------------------------- // Issue 3 (redesigned): readLedgerStrict on a corrupt ledger leaves the file // IN PLACE (non-destructive) and throws CorruptLedgerError with the ledgerPath. // Multiple calls all throw with the same path (persistent blocking). // --------------------------------------------------------------------------- test('readLedgerStrict: corrupt ledger is left in place and throws CorruptLedgerError with ledgerPath (issue-3)', (t) => { const dir = createTempDir('ledger-strict-inplace-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; const ledgerPath = path.join(dir, LEDGER_FILE_NAME); const corruptContent = '{ broken json --- iteration 1'; fs.writeFileSync(ledgerPath, corruptContent); // First call: must throw CorruptLedgerError with the ledger path. try { readLedgerStrict(dir); assert.fail('readLedgerStrict must throw on corrupt ledger'); } catch (err) { assert.ok(err instanceof CorruptLedgerError, 'must be CorruptLedgerError'); assert.ok(err.ledgerPath, 'must have ledgerPath property'); assert.ok(err.message.includes('corrupt') || err.message.includes(ledgerPath), `message must mention corruption or the path; got: ${err.message}`); } // The original file must still be at its original path and content. assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place'); assert.equal(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'content unchanged'); // No quarantine files must have been created. const dirContents = fs.readdirSync(dir); assert.deepEqual( dirContents.filter((n) => n.includes('.corrupt.')), [], `no quarantine files must exist; dir: ${dirContents.join(', ')}`, ); // Second call: must ALSO throw — not silently succeed (persistent blocking). assert.throws( () => readLedgerStrict(dir), (err) => err instanceof CorruptLedgerError, 'second readLedgerStrict must also throw — file still in place', ); }); // --------------------------------------------------------------------------- // Finding 11: tightened schema validation (version='1' required, key===id, // unsafe keys rejected, sharedEdits[].marker must match entry id). // --------------------------------------------------------------------------- test('readLedger returns null when schema version is not the expected value (finding-11)', (t) => { const dir = createTempDir('ledger-ver-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '2', updatedAt: new Date().toISOString(), entries: {}, })); assert.equal(readLedger(dir), null, 'must reject a non-expected version string'); }); test('readLedger returns null when entry key does not match entry.id (finding-11)', (t) => { const dir = createTempDir('ledger-key-id-mismatch-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'cap-a': { id: 'cap-b', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [] }, }, })); assert.equal(readLedger(dir), null, 'must reject entry where key != id'); }); test('readLedger returns null when entry key is an unsafe prototype-pollution key (finding-11)', (t) => { const dir = createTempDir('ledger-unsafe-key-'); t.after(() => cleanup(dir)); // We cannot produce a JSON object with literal __proto__ key via JSON.stringify due to // browser quirks, but we CAN produce one via JSON.parse (which bypasses the setter): const raw = '{"version":"1","updatedAt":"2026-01-01T00:00:00.000Z","entries":{"__proto__":{"id":"__proto__","version":"1","source":"s","integrity":"x","files":[],"sharedEdits":[]}}}'; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), raw); assert.equal(readLedger(dir), null, 'must reject a ledger with an unsafe key like __proto__'); }); test('readLedger ACCEPTS sharedEdits[].marker !== entry id (finding-5: over-strict check reverted, finding-11 update)', (t) => { const dir = createTempDir('ledger-marker-mismatch-'); t.after(() => cleanup(dir)); // Finding-5: requiring marker === id was over-strict and diverged from the loader, risking // false-corrupt lockout. The validation now only requires marker to be a non-empty string. fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'my-cap': { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [{ file: 'settings.json', marker: 'WRONG-marker' }], }, }, })); const result = readLedger(dir); assert.ok(result !== null, 'must ACCEPT sharedEdits[].marker !== entry id (finding-5: relaxed — only requires non-empty string)'); }); test('readLedger returns null when _pending has an invalid kind (finding-11)', (t) => { const dir = createTempDir('ledger-pending-kind-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'my-cap': { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [], _pending: { kind: 'unknown-kind', backupName: null, sharedFiles: [] }, }, }, })); assert.equal(readLedger(dir), null, 'must reject entry with invalid _pending.kind'); }); // --------------------------------------------------------------------------- // Finding 12: IO errors (EACCES/EISDIR/EPERM) must produce a CorruptLedgerError // with the original OS message, not be silently swallowed as corruption. // --------------------------------------------------------------------------- test('readLedgerStrict: a ledger file that cannot be read (EISDIR) throws LedgerIOError (not CorruptLedgerError) with the OS message (finding-12/finding-4)', (t) => { const dir = createTempDir('ledger-ioerr-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; // Create a DIRECTORY at the ledger path — readFileSync will throw EISDIR. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); fs.mkdirSync(ledgerPath); // this IS the directory // Finding 4: IO errors (EISDIR, EACCES, EPERM) must surface as LedgerIOError, // NOT as CorruptLedgerError — they are a permissions/IO problem, not content corruption. assert.throws( () => readLedgerStrict(dir), (err) => { // Must be LedgerIOError (IO problem, not content corruption). assert.ok( LedgerIOError !== undefined && err instanceof LedgerIOError, `must be LedgerIOError; got: ${err?.constructor?.name}`, ); assert.ok(!(err instanceof CorruptLedgerError), 'must NOT be CorruptLedgerError for an IO error'); // The message must contain an OS-level description. assert.ok( err.message.includes('EISDIR') || err.message.includes('unreadable') || err.message.includes('Cannot read'), `message must mention IO error; got: ${err.message}`, ); return true; }, 'readLedgerStrict must throw LedgerIOError with OS message for an EISDIR error', ); }); // --------------------------------------------------------------------------- // ADR-1244 D4 (adversarial re-review): a ledger whose files[] contains hostile members // (non-string like { toString: null }, "..", absolute) must FAIL CLOSED and never become // an existence-oracle for paths outside runtimeDir. // // CURRENT BEHAVIOR (corrected — the prior assertion was VACUOUS): isValidLedgerEntry now // rejects a non-string files[] member, so readLedger (which validates every entry) returns // NULL for this ledger. reconcile therefore reports the file as "exists but could not be // parsed" and NEVER reaches its per-member hostile-path loop. The op fails closed: no // orphans, no oracle, and the warning names a parse failure. (The previous test claimed // "reconcile skips hostile members" but readLedger rejected the ledger BEFORE the loop, so // the per-member skip branch was never exercised — vacuous.) test('reconcile fails closed on a hostile-files[] ledger: readLedger rejects it → parse warning, no oracle, no orphans', () => { const dir = createTempDir('gsd-ledger-hostile-'); try { // Hand-write a ledger whose files[] contains hostile members (a non-string forces rejection). const ledger = { version: '1', updatedAt: '2026-01-01T00:00:00.000Z', entries: { evil: { id: 'evil', version: '1.0.0', source: 'overlay-global', integrity: 'x', files: [{ toString: null, valueOf: null }, '../../../etc/passwd', '/etc/shadow', '', 123], sharedEdits: [], }, }, }; writeLedger(dir, ledger); // readLedger must REJECT this ledger (the non-string member fails isValidLedgerEntry). assert.strictEqual(readLedger(dir), null, 'a ledger with a non-string files[] member must be rejected by readLedger (fail closed)'); let result; assert.doesNotThrow(() => { result = reconcile(dir); }, 'reconcile must not throw'); // Because readLedger rejected it, reconcile reports a parse failure for the present-but-invalid // file — NOT the per-member "invalid file path; skipped" warning (that loop is never reached). assert.ok( result.warnings.some((w) => /could not be parsed/.test(w)), `reconcile must warn the present ledger could not be parsed; got: ${JSON.stringify(result.warnings)}`, ); // CRITICAL: no hostile member is ever treated as a real file, and nothing leaks as an orphan // (no existence-oracle for "../../../etc/passwd" or "/etc/shadow"). assert.deepEqual(result.orphans, [], 'no hostile member may become a real (missing) file / oracle'); } finally { cleanup(dir); } }); // --------------------------------------------------------------------------- // Finding 1 (CRITICAL): reconcileCapabilities must RETURN IMMEDIATELY on corrupt // ledger — no filesystem mutations (no backup sweep, no staging cleanup). // --------------------------------------------------------------------------- test('finding-1: reconcile with corrupt ledger + backup dir → backup still exists (no filesystem mutation)', (t) => { const dir = createTempDir('ledger-f1-reconcile-corrupt-'); t.after(() => cleanup(dir)); // Create a backup dir that reconcile would normally sweep. const capRoot = path.join(dir, '.gsd', 'capabilities'); fs.mkdirSync(capRoot, { recursive: true }); const backupDir = path.join(capRoot, 'mycap.upgrading-999-111'); fs.mkdirSync(backupDir, { recursive: true }); fs.writeFileSync(path.join(backupDir, 'capability.json'), '{"id":"mycap"}', 'utf8'); // Write a corrupt ledger file. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); fs.writeFileSync(ledgerPath, '{ broken json ---'); // Must not throw. Use the lifecycle module which wraps reconcileCapabilities. const lifecycle = require('../gsd-core/bin/lib/capability-lifecycle.cjs'); let report; assert.doesNotThrow( () => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); }, 'reconcileCapabilities must not throw on a corrupt ledger', ); // The warning must be present. assert.ok(report.warnings.length > 0, 'must surface a warning for corrupt ledger'); // CRITICAL: the backup dir must NOT have been deleted. assert.ok( fs.existsSync(backupDir), 'backup dir must still exist — reconcile must not mutate when ledger is corrupt', ); // The corrupt file must be in place. assert.ok(fs.existsSync(ledgerPath), 'corrupt ledger must remain in place'); }); // --------------------------------------------------------------------------- // Finding 2 (HIGH): writeLedger — closeSync EIO → throw, no orphan temp, no rename. // --------------------------------------------------------------------------- test('finding-2: writeLedger throws when closeSync fails (EIO) and leaves no orphan temp, original unchanged', (t) => { const dir = createTempDir('ledger-f2-close-eio-'); t.after(() => cleanup(dir)); // Write a valid ledger first so we can verify the original is unchanged. writeLedger(dir, makeLedger({ entries: { 'orig-cap': makeEntry('orig-cap') } })); const origContent = fs.readFileSync(path.join(dir, LEDGER_FILE_NAME), 'utf8'); // Mock closeSync to throw EIO once (for the tmp-fd call from writeLedger). let closeCalls = 0; const realCloseSync = fs.closeSync.bind(fs); const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) { closeCalls++; if (closeCalls === 1) { // Simulate a delayed-writeback failure on first close (the tmp file fd). const err = new Error('EIO: i/o error'); err.code = 'EIO'; throw err; } return realCloseSync(fd, ...rest); }); t.after(() => closeMock.mock.restore()); // writeLedger must throw (the close error surfaces). assert.throws( () => writeLedger(dir, makeLedger({ entries: { 'new-cap': makeEntry('new-cap') } })), (err) => { assert.ok(err instanceof Error); assert.ok(err.code === 'EIO' || err.message.includes('EIO'), `expected EIO error; got: ${err.message}`); return true; }, 'writeLedger must throw when closeSync fails with EIO', ); // No orphan tmp file must remain. const orphans = orphanTmpFiles(dir); assert.deepEqual(orphans, [], `no orphan tmp file after EIO close; found: ${orphans.join(', ')}`); // Original ledger must be unchanged. const nowContent = fs.readFileSync(path.join(dir, LEDGER_FILE_NAME), 'utf8'); assert.equal(nowContent, origContent, 'original ledger must not be modified when closeSync fails'); }); // --------------------------------------------------------------------------- // Finding 4 (MEDIUM): LedgerIOError must be exported; EISDIR must throw // LedgerIOError (not CorruptLedgerError) from readLedgerStrict. // --------------------------------------------------------------------------- test('finding-4: LedgerIOError is exported from capability-ledger', () => { assert.ok(LedgerIOError !== undefined, 'LedgerIOError must be exported'); // Verify it is a constructor (class). const e = new LedgerIOError('test', 'EACCES'); assert.ok(e instanceof Error, 'LedgerIOError must be an Error subclass'); assert.equal(e.name, 'LedgerIOError'); assert.equal(e.code, 'EACCES'); }); test('finding-4: readLedgerStrict throws LedgerIOError (not CorruptLedgerError) for EISDIR (IO error, not corrupt)', (t) => { const dir = createTempDir('ledger-f4-eisdir-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; // Create a DIRECTORY at the ledger path — readFileSync will throw EISDIR. const ledgerPath = path.join(dir, LEDGER_FILE_NAME); fs.mkdirSync(ledgerPath); assert.throws( () => readLedgerStrict(dir), (err) => { // Must be LedgerIOError, not CorruptLedgerError. assert.ok(err instanceof LedgerIOError, `must throw LedgerIOError for EISDIR; got: ${err?.constructor?.name}`); assert.ok(!(err instanceof CorruptLedgerError), 'must NOT be CorruptLedgerError for an IO error'); assert.ok( err.code === 'EISDIR' || err.message.includes('EISDIR') || err.message.includes('unreadable'), `message must mention IO error; got: ${err.message}`, ); return true; }, 'readLedgerStrict must throw LedgerIOError with OS message for EISDIR', ); }); // --------------------------------------------------------------------------- // Finding 5 (MEDIUM): sharedEdits[].marker !== id must be ACCEPTED (not corrupt). // A member missing 'marker' (e.g. {file, path}) must be REJECTED. // --------------------------------------------------------------------------- test('finding-5: sharedEdits[].marker !== entry id is ACCEPTED (over-strict check reverted)', (t) => { const dir = createTempDir('ledger-f5-marker-accept-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'my-cap': { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], // marker is a non-empty string but NOT equal to 'my-cap'. sharedEdits: [{ file: 'settings.json', marker: 'some-other-id' }], }, }, })); const result = readLedger(dir); assert.ok(result !== null, 'readLedger must ACCEPT a sharedEdits entry with marker !== entry id (finding-5: relaxed validation)'); assert.ok('my-cap' in result.entries); }); test('finding-5: sharedEdits[] member missing marker (e.g. {file, path}) is REJECTED', (t) => { const dir = createTempDir('ledger-f5-marker-reject-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'my-cap': { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], // old ADR shape — 'path' instead of 'marker' — no 'marker' key at all. sharedEdits: [{ file: 'settings.json', path: 'hooks.PostToolUse[0]' }], }, }, })); const result = readLedger(dir); assert.equal(result, null, 'readLedger must REJECT a sharedEdits entry missing the marker field'); }); test('finding-5: sharedEdits[] member with non-string marker is REJECTED', (t) => { const dir = createTempDir('ledger-f5-marker-nonstr-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'my-cap': { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [{ file: 'settings.json', marker: 42 }], }, }, })); const result = readLedger(dir); assert.equal(result, null, 'readLedger must REJECT a sharedEdits entry with a non-string marker'); }); // --------------------------------------------------------------------------- // Finding 6 (MEDIUM): isValidLedgerEntry exported from capability-ledger. // --------------------------------------------------------------------------- test('finding-6: isValidLedgerEntry is exported and validates entries correctly', () => { assert.ok(typeof isValidLedgerEntry === 'function', 'isValidLedgerEntry must be exported as a function'); // Valid entry. assert.equal( isValidLedgerEntry('my-cap', { id: 'my-cap', version: '1.0.0', source: 'registry:x', integrity: 'sha512-abc', files: ['commands/gsd/my-cap.md'], sharedEdits: [{ file: 'settings.json', marker: 'my-cap' }], }), true, 'must return true for a valid entry', ); // Wrong id. assert.equal( isValidLedgerEntry('other-cap', { id: 'my-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [] }), false, 'must return false when entry id does not match the key', ); // Non-string file in files[]. assert.equal( isValidLedgerEntry('bad', { id: 'bad', version: '1.0.0', source: 's', integrity: 'x', files: [123], sharedEdits: [] }), false, 'must return false when files[] has a non-string member', ); // sharedEdits member missing marker. assert.equal( isValidLedgerEntry('e', { id: 'e', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [{ file: 'f.json' }] }), false, 'must return false when sharedEdits member is missing marker', ); }); // --------------------------------------------------------------------------- // Finding 7 (LOW): recordInstall must validate id against VALID_ID_RE before // writing — a non-kebab id must throw, not poison the ledger. // --------------------------------------------------------------------------- test('finding-7: recordInstall throws for a non-kebab id (e.g. "Bad Cap!") before writing', (t) => { const dir = createTempDir('ledger-f7-bad-id-'); t.after(() => cleanup(dir)); const ledgerPath = path.join(dir, LEDGER_FILE_NAME); assert.equal(fs.existsSync(ledgerPath), false, 'pre-condition: no ledger'); assert.throws( () => recordInstall(dir, makeEntry('Bad Cap!')), (err) => { assert.ok(err instanceof Error, 'must throw an Error'); assert.ok( err.message.toLowerCase().includes('invalid') || err.message.includes('Bad Cap!'), `error must mention invalid id; got: ${err.message}`, ); return true; }, 'recordInstall must throw for a non-kebab id', ); // No ledger must have been written. assert.equal(fs.existsSync(ledgerPath), false, 'no ledger must be written for an invalid id'); }); test('finding-7: recordInstall throws for an id starting with a digit ("0cap")', (t) => { const dir = createTempDir('ledger-f7-digit-id-'); t.after(() => cleanup(dir)); assert.throws( () => recordInstall(dir, makeEntry('0cap')), (err) => err instanceof Error, 'must throw for id starting with digit', ); assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'no ledger written for invalid id starting with digit'); }); test('finding-7: recordInstall still succeeds for a valid kebab id ("my-cap-2")', (t) => { const dir = createTempDir('ledger-f7-valid-id-'); t.after(() => cleanup(dir)); assert.doesNotThrow( () => recordInstall(dir, makeEntry('my-cap-2')), 'recordInstall must succeed for a valid kebab id', ); const ledger = readLedger(dir); assert.ok(ledger !== null && 'my-cap-2' in ledger.entries); }); // --------------------------------------------------------------------------- // ROOT FIX 1: isValidLedgerEntry — single validator, matches readLedger exactly. // Table-driven: same verdict from isValidLedgerEntry AND from readLedger round-trip. // --------------------------------------------------------------------------- test('root-fix-1: isValidLedgerEntry and readLedger round-trip give identical verdicts (single source of truth)', (t) => { const dir = createTempDir('ledger-rf1-parity-'); t.after(() => cleanup(dir)); const { CorruptLedgerError: _CLE } = capLedger; const cases = [ // [description, id-key, entry-object, expectedValid] ['valid entry', 'good-cap', { id: 'good-cap', version: '1.0.0', source: 'reg:x', integrity: 'sha512-abc', files: ['commands/gsd/good-cap.md'], sharedEdits: [{ file: 'settings.json', marker: 'good-cap' }], }, true], ['valid entry with _pending', 'p-cap', { id: 'p-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [], _pending: { kind: 'install', backupName: null, sharedFiles: [] }, }, true], ['wrong id (key != entry.id)', 'cap-a', { id: 'cap-b', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ['missing version', 'no-ver', { id: 'no-ver', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ['non-string in files[]', 'bad-files', { id: 'bad-files', version: '1', source: 's', integrity: 'x', files: [42], sharedEdits: [], }, false], ['missing marker in sharedEdits', 'no-marker', { id: 'no-marker', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [{ file: 'f.json' }], }, false], ['_pending with invalid kind', 'bad-pend', { id: 'bad-pend', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], _pending: { kind: 'destroy', backupName: null, sharedFiles: [] }, }, false], ['_pending with non-null/non-string backupName', 'pend-bn', { id: 'pend-bn', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], _pending: { kind: 'upgrade', backupName: 123, sharedFiles: [] }, }, false], ['unsafe id __proto__', '__proto__', { id: '__proto__', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ['unsafe id constructor', 'constructor', { id: 'constructor', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ['unsafe id prototype', 'prototype', { id: 'prototype', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ['invalid kebab id (starts with digit)', '0cap', { id: '0cap', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], }, false], ]; for (const [desc, key, entry, expected] of cases) { // Check isValidLedgerEntry directly. const fromValidator = isValidLedgerEntry(key, entry); assert.equal(fromValidator, expected, `isValidLedgerEntry: ${desc} → expected ${expected}, got ${fromValidator}`); // Skip round-trip test for entries with unsafe or invalid keys — writeLedger // / JSON round-trip cannot faithfully represent them. const isSafeKey = /^[a-z][a-z0-9-]*$/.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype'; if (!isSafeKey) continue; // Write a synthetic ledger with this single entry and read it back. const ledgerRaw = JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { [key]: entry }, }); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), ledgerRaw); const read = readLedger(dir); const fromRoundTrip = read !== null && key in read.entries; assert.equal(fromRoundTrip, expected, `readLedger round-trip: ${desc} → expected ${expected}, got ${fromRoundTrip}`); } }); test('root-fix-1: isValidLedgerEntry rejects unsafe ids (prototype-safe, inline checks)', () => { assert.equal(isValidLedgerEntry('__proto__', { id: '__proto__', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), false, '__proto__ id must be rejected by isValidLedgerEntry'); assert.equal(isValidLedgerEntry('constructor', { id: 'constructor', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), false, 'constructor id must be rejected by isValidLedgerEntry'); assert.equal(isValidLedgerEntry('prototype', { id: 'prototype', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), false, 'prototype id must be rejected by isValidLedgerEntry'); assert.equal(isValidLedgerEntry('0starts-digit', { id: '0starts-digit', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), false, 'non-kebab id must be rejected by isValidLedgerEntry'); // Valid id still passes. assert.equal(isValidLedgerEntry('valid-cap', { id: 'valid-cap', version: '1.0.0', source: 's', integrity: 'x', files: [], sharedEdits: [] }), true, 'valid kebab id must still be accepted'); }); test('root-fix-1: isValidLedgerEntry validates _pending shape when present', () => { const base = { version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }; // Valid _pending with install kind. assert.equal(isValidLedgerEntry('cap', { id: 'cap', ...base, _pending: { kind: 'install', backupName: null, sharedFiles: [] } }), true); // Valid _pending with upgrade kind + backupName string. assert.equal(isValidLedgerEntry('cap', { id: 'cap', ...base, _pending: { kind: 'upgrade', backupName: 'cap.upgrading-1-2', sharedFiles: [] } }), true); // Invalid kind. assert.equal(isValidLedgerEntry('cap', { id: 'cap', ...base, _pending: { kind: 'delete', backupName: null, sharedFiles: [] } }), false); // Non-array sharedFiles. assert.equal(isValidLedgerEntry('cap', { id: 'cap', ...base, _pending: { kind: 'install', backupName: null, sharedFiles: 'x' } }), false); // Non-null/non-string backupName. assert.equal(isValidLedgerEntry('cap', { id: 'cap', ...base, _pending: { kind: 'upgrade', backupName: 42, sharedFiles: [] } }), false); }); // --------------------------------------------------------------------------- // ROOT FIX 3: isUnsafeCapabilityId exported; recordInstall THROWS (not silent) // on unsafe ids. Tests for __proto__, constructor, prototype. // --------------------------------------------------------------------------- test('root-fix-3: recordInstall THROWS (not silently returns) for __proto__ id', (t) => { const dir = createTempDir('ledger-rf3-throw-proto-'); t.after(() => cleanup(dir)); assert.throws( () => recordInstall(dir, { id: '__proto__', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), (err) => { assert.ok(err instanceof Error, 'must throw an Error'); assert.ok( err.message.toLowerCase().includes('invalid') || err.message.includes('__proto__'), `error must mention invalid id; got: ${err.message}`, ); return true; }, 'recordInstall must THROW for __proto__ id (not silently ignore)', ); // No ledger must exist. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false); }); test('root-fix-3: recordInstall THROWS for "constructor" and "prototype" ids', (t) => { const dir = createTempDir('ledger-rf3-throw-ctor-'); t.after(() => cleanup(dir)); assert.throws( () => recordInstall(dir, { id: 'constructor', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), (err) => err instanceof Error, 'must throw for constructor id', ); assert.throws( () => recordInstall(dir, { id: 'prototype', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }), (err) => err instanceof Error, 'must throw for prototype id', ); }); // --------------------------------------------------------------------------- // ROOT FIX 4: broken-symlink detection — readLedgerStrict and readLedger // treat a dangling symlink as an IO failure, not as "missing" (lstat-based). // --------------------------------------------------------------------------- test('root-fix-4: readLedgerStrict throws LedgerIOError for a broken symlink at the ledger path', (t) => { const dir = createTempDir('ledger-rf4-symlink-strict-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; const ledgerPath = path.join(dir, LEDGER_FILE_NAME); // Plant a dangling symlink (target does not exist). fs.symlinkSync('/nonexistent/target-that-does-not-exist', ledgerPath); assert.throws( () => readLedgerStrict(dir), (err) => { // Must throw LedgerIOError (IO problem), not CorruptLedgerError (content problem), // and NOT silently return null (which would treat it as "missing"). assert.ok( err instanceof LedgerIOError, `must throw LedgerIOError; got: ${err?.constructor?.name}: ${err?.message}`, ); assert.ok(!(err instanceof CorruptLedgerError), 'must NOT be CorruptLedgerError'); return true; }, 'readLedgerStrict must throw LedgerIOError for a dangling symlink (not treat as missing)', ); }); test('root-fix-4: reconcileCapabilities returns warning (no mutation) when ledger is a broken symlink', (t) => { const dir = createTempDir('ledger-rf4-symlink-reconcile-'); t.after(() => cleanup(dir)); const lifecycle = require('../gsd-core/bin/lib/capability-lifecycle.cjs'); const ledgerPath = path.join(dir, LEDGER_FILE_NAME); // Create a backup that reconcile would normally sweep. const capRoot = path.join(dir, '.gsd', 'capabilities'); fs.mkdirSync(capRoot, { recursive: true }); const backupDir = path.join(capRoot, 'somecap.upgrading-111-222'); fs.mkdirSync(backupDir); // Plant a dangling symlink (broken) at the ledger path. fs.symlinkSync('/nonexistent/absent-target', ledgerPath); let report; assert.doesNotThrow( () => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); }, 'reconcileCapabilities must not throw on a broken-symlink ledger', ); // Must warn — it's not "missing", it's an IO problem. assert.ok(report.warnings.length > 0, 'must surface a warning when ledger is a broken symlink'); // CRITICAL: the backup dir must NOT have been deleted (no mutation on IO error). assert.ok(fs.existsSync(backupDir), 'backup dir must still exist — reconcile must not mutate when ledger is a broken symlink'); }); test('root-fix-4: installCapability blocks when ledger is a broken symlink (not treats as missing → fresh install)', async (t) => { const dir = createTempDir('ledger-rf4-symlink-install-'); t.after(() => cleanup(dir)); const lifecycle = require('../gsd-core/bin/lib/capability-lifecycle.cjs'); const ledgerPath = path.join(dir, LEDGER_FILE_NAME); // Plant a dangling symlink at the ledger path. fs.symlinkSync('/nonexistent/absent-target', ledgerPath); // installCapability must block (fail closed), not silently proceed as a "fresh install". const result = await lifecycle.installCapability('./x', { runtimeDir: dir, hostVersion: '1.6.0', _resolve: async (spec, opts) => { const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging'); fs.mkdirSync(root, { recursive: true }); const staged = path.join(root, 'x-symlink-test'); fs.mkdirSync(staged, { recursive: true }); fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify({ id: 'x', role: 'feature', version: '1.0.0', title: 'x', description: 'x', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], }), 'utf8'); return { id: 'x', version: '1.0.0', stagedDir: staged, integrity: null, source: spec }; }, }); assert.strictEqual(result.status, 'blocked', `installCapability must be blocked by a broken-symlink ledger; got: ${result.status}`); assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons'); }); // --------------------------------------------------------------------------- // DUR-1 (HIGH): writeLedger must fsync the file fd BEFORE closeSync BEFORE // renameSync, so a power-loss after a successful rename cannot leave a // zero/partial ledger. // Revert-fails: remove the fs.fsyncSync(fd) call → this test fails because the // recorded call order no longer contains fsyncSync before closeSync. // --------------------------------------------------------------------------- test('DUR-1: writeLedger fsyncs the file fd before closeSync before renameSync (durable write order)', (t) => { const dir = createTempDir('ledger-dur1-order-'); t.after(() => cleanup(dir)); // Record the order of fsyncSync / closeSync / renameSync calls. We tag the file-fd fsync // distinctly from any directory fsync (DUR-2) by checking whether the fd belongs to the // tmp write (the first closeSync after a write is the tmp fd). const order = []; const realFsync = fs.fsyncSync.bind(fs); const realClose = fs.closeSync.bind(fs); const realRename = fs.renameSync.bind(fs); const fsyncMock = mock.method(fs, 'fsyncSync', function (fd, ...rest) { order.push({ op: 'fsync', fd }); return realFsync(fd, ...rest); }); const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) { order.push({ op: 'close', fd }); return realClose(fd, ...rest); }); const renameMock = mock.method(fs, 'renameSync', function (src, dest, ...rest) { order.push({ op: 'rename' }); return realRename(src, dest, ...rest); }); t.after(() => { fsyncMock.mock.restore(); closeMock.mock.restore(); renameMock.mock.restore(); }); writeLedger(dir, makeLedger({ entries: { 'dur-cap': makeEntry('dur-cap') } })); // There must be at least one fsync, one close, and one rename. const firstFsync = order.findIndex((e) => e.op === 'fsync'); const firstClose = order.findIndex((e) => e.op === 'close'); const firstRename = order.findIndex((e) => e.op === 'rename'); assert.ok(firstFsync !== -1, 'writeLedger must call fsyncSync on the file fd'); assert.ok(firstClose !== -1, 'writeLedger must call closeSync'); assert.ok(firstRename !== -1, 'writeLedger must call renameSync'); // The file fd fsync (and close) must both precede the rename. assert.ok(firstFsync < firstRename, `fsyncSync must be called before renameSync; order: ${JSON.stringify(order)}`); // The fsync of a given fd must precede the close of that SAME fd. const fileFd = order[firstFsync].fd; const closeOfSameFd = order.findIndex((e) => e.op === 'close' && e.fd === fileFd); assert.ok(closeOfSameFd !== -1, 'the fsynced fd must also be closed'); assert.ok(firstFsync < closeOfSameFd, `fsyncSync(fd) must precede closeSync(fd); order: ${JSON.stringify(order)}`); assert.ok(closeOfSameFd < firstRename, `closeSync(fd) must precede renameSync; order: ${JSON.stringify(order)}`); // Ledger must be readable after the durable write. const read = readLedger(dir); assert.ok(read !== null && 'dur-cap' in read.entries, 'ledger must round-trip after durable write'); }); // DUR-1: when fsyncSync throws, writeLedger must unlink the temp + rethrow (treated as // a write failure), never rename a possibly-unflushed file live and never orphan a temp. // Revert-fails: drop the fsync try/catch-unlink-rethrow and a thrown fsync would // fall through to rename — this test would see the live ledger overwritten and/or an // orphan temp, failing the unchanged-original and no-orphan assertions. test('DUR-1: writeLedger unlinks temp and rethrows when fsyncSync fails; no rename, original unchanged', (t) => { const dir = createTempDir('ledger-dur1-fsync-throw-'); t.after(() => cleanup(dir)); // Seed a valid original ledger we can prove is unchanged. writeLedger(dir, makeLedger({ entries: { 'orig-cap': makeEntry('orig-cap') } })); const origContent = fs.readFileSync(path.join(dir, LEDGER_FILE_NAME), 'utf8'); let renameCalled = false; const realRename = fs.renameSync.bind(fs); const renameMock = mock.method(fs, 'renameSync', function (src, dest, ...rest) { renameCalled = true; return realRename(src, dest, ...rest); }); // Make the FIRST fsyncSync (the file-fd fsync) throw EIO. let fsyncCalls = 0; const fsyncMock = mock.method(fs, 'fsyncSync', function () { fsyncCalls++; const err = new Error('EIO: i/o error on fsync'); err.code = 'EIO'; throw err; }); t.after(() => { renameMock.mock.restore(); fsyncMock.mock.restore(); }); assert.throws( () => writeLedger(dir, makeLedger({ entries: { 'new-cap': makeEntry('new-cap') } })), (err) => err.code === 'EIO' || err.message.includes('EIO'), 'writeLedger must rethrow when fsyncSync fails', ); assert.ok(fsyncCalls >= 1, 'fsyncSync must have been invoked'); assert.equal(renameCalled, false, 'renameSync must NOT run after an fsync failure'); // No orphan temp file must remain. const orphans = orphanTmpFiles(dir); assert.deepEqual(orphans, [], `no orphan tmp after fsync failure; found: ${orphans.join(', ')}`); // Original ledger must be unchanged. assert.equal(fs.readFileSync(path.join(dir, LEDGER_FILE_NAME), 'utf8'), origContent, 'original ledger must be unchanged when fsyncSync fails'); }); // --------------------------------------------------------------------------- // DUR-2 (MED): after the rename succeeds, writeLedger must fsync the CONTAINING // directory so the rename itself is durable. EISDIR/EPERM on platforms that // disallow dir fsync must be tolerated. // Revert-fails: remove the directory-fsync block → no openSync(dirname,'r') is // performed, so the asserted dir-open never happens and this test fails. // --------------------------------------------------------------------------- test('DUR-2: writeLedger fsyncs the containing directory after a successful rename', (t) => { const dir = createTempDir('ledger-dur2-dirfsync-'); t.after(() => cleanup(dir)); let dirOpened = false; let dirFsynced = false; const realOpen = fs.openSync.bind(fs); const realFsync = fs.fsyncSync.bind(fs); // Track which fds correspond to a directory open ('r' on the runtimeDir). const dirFds = new Set(); const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { const fd = realOpen(p, flags, ...rest); if (path.resolve(p) === path.resolve(dir) && flags === 'r') { dirOpened = true; dirFds.add(fd); } return fd; }); const fsyncMock = mock.method(fs, 'fsyncSync', function (fd, ...rest) { if (dirFds.has(fd)) dirFsynced = true; return realFsync(fd, ...rest); }); t.after(() => { openMock.mock.restore(); fsyncMock.mock.restore(); }); writeLedger(dir, makeLedger({ entries: { 'd2-cap': makeEntry('d2-cap') } })); assert.ok(dirOpened, 'writeLedger must open the containing directory for fsync (DUR-2)'); assert.ok(dirFsynced, 'writeLedger must fsync the containing directory fd (DUR-2)'); }); test('DUR-2: writeLedger tolerates EPERM from the directory fsync (still writes the ledger)', (t) => { const dir = createTempDir('ledger-dur2-dirfsync-eperm-'); t.after(() => cleanup(dir)); const realFsync = fs.fsyncSync.bind(fs); const realOpen = fs.openSync.bind(fs); const dirFds = new Set(); const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { const fd = realOpen(p, flags, ...rest); if (path.resolve(p) === path.resolve(dir) && flags === 'r') dirFds.add(fd); return fd; }); const fsyncMock = mock.method(fs, 'fsyncSync', function (fd, ...rest) { if (dirFds.has(fd)) { const err = new Error('EPERM: operation not permitted, fsync'); err.code = 'EPERM'; throw err; } return realFsync(fd, ...rest); }); t.after(() => { openMock.mock.restore(); fsyncMock.mock.restore(); }); assert.doesNotThrow( () => writeLedger(dir, makeLedger({ entries: { 'd2e-cap': makeEntry('d2e-cap') } })), 'writeLedger must tolerate EPERM from the directory fsync', ); const read = readLedger(dir); assert.ok(read !== null && 'd2e-cap' in read.entries, 'ledger must still be written despite dir-fsync EPERM'); }); // --------------------------------------------------------------------------- // W-1 (MED): renameSync can transiently fail on Windows (AV lock: EPERM/EBUSY/ // EACCES). writeLedger must retry the rename a few times before failing. // Revert-fails: remove the rename retry loop → the first EPERM propagates and // writeLedger throws, failing the doesNotThrow assertion. // --------------------------------------------------------------------------- test('W-1: writeLedger retries a transient EPERM/EBUSY renameSync before succeeding', (t) => { const dir = createTempDir('ledger-w1-rename-retry-'); t.after(() => cleanup(dir)); // Fail the rename twice with EBUSY, then succeed on the third attempt. let renameCalls = 0; const realRename = fs.renameSync.bind(fs); const renameMock = mock.method(fs, 'renameSync', function (src, dest, ...rest) { renameCalls++; if (renameCalls <= 2) { const err = new Error('EBUSY: resource busy or locked, rename'); err.code = 'EBUSY'; throw err; } return realRename(src, dest, ...rest); }); t.after(() => renameMock.mock.restore()); assert.doesNotThrow( () => writeLedger(dir, makeLedger({ entries: { 'w1-cap': makeEntry('w1-cap') } })), 'writeLedger must retry a transient rename failure', ); assert.ok(renameCalls >= 3, `renameSync must have been retried; calls=${renameCalls}`); const read = readLedger(dir); assert.ok(read !== null && 'w1-cap' in read.entries, 'ledger must be written after rename retries'); }); // --------------------------------------------------------------------------- // W-2 (NIT): the CorruptLedgerError recovery hint must be platform-aware — a // POSIX `mv` command is wrong on Windows. // Revert-fails: hardcode the message back to `mv "..."` → the win32-branch // assertion for `ren`/`Move-Item` fails when process.platform is forced to win32. // --------------------------------------------------------------------------- test('W-2: CorruptLedgerError recovery hint is platform-aware (win32 uses ren/Move-Item, not mv)', (t) => { const dir = createTempDir('ledger-w2-msg-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), '{ broken json ---'); // Force win32 to check the recovery hint branch. const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); t.after(() => Object.defineProperty(process, 'platform', realPlatform)); try { readLedgerStrict(dir); assert.fail('must throw on corrupt ledger'); } catch (err) { assert.ok(err instanceof CorruptLedgerError, 'must be CorruptLedgerError'); assert.ok( /\bren\b/.test(err.message) || /Move-Item/.test(err.message), `win32 recovery hint must reference ren/Move-Item, not mv; got: ${err.message}`, ); assert.ok(!/\bmv "/.test(err.message), `win32 message must not embed the POSIX mv command; got: ${err.message}`); } }); test('W-2: CorruptLedgerError recovery hint uses mv on non-win32 platforms', (t) => { const dir = createTempDir('ledger-w2-msg-posix-'); t.after(() => cleanup(dir)); const { readLedgerStrict, CorruptLedgerError } = capLedger; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), '{ broken json ---'); const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); t.after(() => Object.defineProperty(process, 'platform', realPlatform)); try { readLedgerStrict(dir); assert.fail('must throw on corrupt ledger'); } catch (err) { assert.ok(err instanceof CorruptLedgerError, 'must be CorruptLedgerError'); assert.ok(/\bmv\b/.test(err.message), `posix recovery hint must reference mv; got: ${err.message}`); } }); // --------------------------------------------------------------------------- // DOS-3 (LOW): isValidLedgerEntry must reject entries whose files[] or // sharedEdits[] are oversized (DoS via a huge array). // Revert-fails: remove the length caps → an oversized files[] passes validation, // so isValidLedgerEntry returns true and these assertions fail. // --------------------------------------------------------------------------- test('DOS-3: isValidLedgerEntry rejects an oversized files[] (>10000) and sharedEdits[] (>256)', () => { // Finding 5(a): the caps are GENEROUS DoS backstops (files <= 10000, sharedEdits <= 256), // not product limits — no legitimate capability hits them, but a hostile 100k+ array is stopped. // Oversized files[]. const bigFiles = { id: 'big', version: '1', source: 's', integrity: 'x', files: Array.from({ length: 10001 }, (_, i) => `f${i}.md`), sharedEdits: [], }; assert.equal(isValidLedgerEntry('big', bigFiles), false, 'must reject an entry with files.length > 10000 (DoS guard)'); // Oversized sharedEdits[]. const bigShared = { id: 'bigs', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: Array.from({ length: 257 }, (_, i) => ({ file: `s${i}.json`, marker: 'bigs' })), }; assert.equal(isValidLedgerEntry('bigs', bigShared), false, 'must reject an entry with sharedEdits.length > 256 (DoS guard)'); // At-the-cap entries are still valid. const atCap = { id: 'at-cap', version: '1', source: 's', integrity: 'x', files: Array.from({ length: 10000 }, (_, i) => `f${i}.md`), sharedEdits: Array.from({ length: 256 }, (_, i) => ({ file: `s${i}.json`, marker: 'at-cap' })), }; assert.equal(isValidLedgerEntry('at-cap', atCap), true, 'must accept an entry exactly at the caps (10000 files, 256 sharedEdits)'); }); test('DOS-3: readLedger returns null for a ledger with an oversized files[] entry', (t) => { const dir = createTempDir('ledger-dos3-readledger-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'big': { id: 'big', version: '1', source: 's', integrity: 'x', files: Array.from({ length: 10001 }, (_, i) => `f${i}`), sharedEdits: [] }, }, })); assert.equal(readLedger(dir), null, 'readLedger must reject an oversized files[] entry'); }); // --------------------------------------------------------------------------- // Finding 3 (HIGH): _pending.sharedFiles was only Array.isArray-checked, so a // hostile ledger with a huge _pending.sharedFiles array (or non-string members) // was accepted and later spread into a Set + iterated in reconcile (DoS bypass). // isValidLedgerEntry must validate every member is a string AND cap its length. // --------------------------------------------------------------------------- const base35 = { version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [] }; // Revert-fails: remove the per-member string check on _pending.sharedFiles → // the non-string member passes (only Array.isArray is checked), so // isValidLedgerEntry returns true and this assertion fails. test('finding-3: isValidLedgerEntry rejects a _pending.sharedFiles with a NON-STRING member', () => { const entry = { id: 'p', ...base35, _pending: { kind: 'install', backupName: null, sharedFiles: ['ok.json', 123] } }; assert.equal(isValidLedgerEntry('p', entry), false, 'must reject _pending.sharedFiles containing a non-string member'); }); // Revert-fails: remove the length cap on _pending.sharedFiles → the oversized // array passes validation, so isValidLedgerEntry returns true and this fails. // (257 is just over the 256 generous cap — the cap VALUE is what's under test, not // the absolute hostile size, so the array stays small enough to avoid OOM.) test('finding-3: isValidLedgerEntry rejects an OVERSIZED _pending.sharedFiles array (DoS guard)', () => { const entry = { id: 'p', ...base35, _pending: { kind: 'install', backupName: null, sharedFiles: Array.from({ length: 257 }, (_, i) => `f${i}.json`) }, }; assert.equal(isValidLedgerEntry('p', entry), false, 'must reject an oversized _pending.sharedFiles array (>256 cap)'); // The at-cap (256) all-string array must remain valid. const atCap = { id: 'p', ...base35, _pending: { kind: 'install', backupName: null, sharedFiles: Array.from({ length: 256 }, (_, i) => `f${i}.json`) }, }; assert.equal(isValidLedgerEntry('p', atCap), true, 'an at-cap (256) all-string _pending.sharedFiles must remain valid'); }); // Revert-fails: if the cap is set so low a legitimate _pending is rejected, OR // the all-strings path is broken, this in-bounds all-string _pending fails. test('finding-3: isValidLedgerEntry ACCEPTS a small all-string _pending.sharedFiles', () => { const entry = { id: 'p', ...base35, _pending: { kind: 'install', backupName: null, sharedFiles: ['a.json', 'b.json'] } }; assert.equal(isValidLedgerEntry('p', entry), true, 'a small all-string _pending.sharedFiles must remain valid'); }); // Revert-fails: remove the _pending.sharedFiles member validation → readLedger // would accept the hostile entry instead of returning null, so this fails. test('finding-3: readLedger returns null for a ledger whose _pending.sharedFiles is oversized', (t) => { const dir = createTempDir('ledger-finding3-readledger-'); t.after(() => cleanup(dir)); fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'p': { id: 'p', version: '1', source: 's', integrity: 'x', files: [], sharedEdits: [], _pending: { kind: 'install', backupName: null, sharedFiles: Array.from({ length: 257 }, (_, i) => `f${i}`) } }, }, })); assert.equal(readLedger(dir), null, 'readLedger must reject an oversized _pending.sharedFiles entry'); }); // --------------------------------------------------------------------------- // BC-1 (MED): a ledger whose version is a string but not '1' must surface a // DISTINCT "unsupported ledger schema version" error from readLedgerStrict, // not a generic corrupt error. // Revert-fails: remove the unsupported-version branch → readLedgerStrict throws // the generic CorruptLedgerError whose message lacks "unsupported"/"schema // version", failing the distinct-message assertion. // --------------------------------------------------------------------------- test('BC-1: readLedgerStrict surfaces a distinct "unsupported schema version" error for version "2"', (t) => { const dir = createTempDir('ledger-bc1-version-'); t.after(() => cleanup(dir)); const { readLedgerStrict } = capLedger; fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '2', updatedAt: new Date().toISOString(), entries: {}, })); assert.throws( () => readLedgerStrict(dir), (err) => { assert.ok(/unsupported/i.test(err.message) && /schema version/i.test(err.message), `must mention unsupported schema version; got: ${err.message}`); assert.ok(/\b2\b/.test(err.message), `must name the offending version; got: ${err.message}`); return true; }, 'readLedgerStrict must surface a distinct unsupported-version error for version "2"', ); }); // --------------------------------------------------------------------------- // DOS-4 (LOW): recordInstall accepts an optional in-lock baseLedger to avoid a // redundant strict re-read. A provided base is used as the write base; omitting // it preserves the strict-read default; a non-object base falls back to strict. // --------------------------------------------------------------------------- test('DOS-4: recordInstall(baseLedger) writes against the SUPPLIED base, not a re-read of disk', (t) => { const dir = createTempDir('ledger-dos4-base-'); t.after(() => cleanup(dir)); // DISK has NO ledger. The supplied base carries a pre-existing OTHER entry. If recordInstall // ignored the base and strict-read the (empty) disk, that other entry would be ABSENT from the // result. Its presence proves the supplied base was used as the write base (no redundant re-read). // Revert-fails: ignore opts.baseLedger → recordInstall strict-reads the empty disk, so // 'pre-existing' is dropped and the survival assertion fails. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'pre-condition: no ledger on disk'); const base = makeLedger({ entries: { 'pre-existing': makeEntry('pre-existing') } }); recordInstall(dir, makeEntry('dos4-cap'), { baseLedger: base }); const ledger = readLedger(dir); assert.ok(ledger !== null, 'ledger must be written'); assert.ok('dos4-cap' in ledger.entries, 'the new entry must be recorded'); assert.ok('pre-existing' in ledger.entries, 'the supplied base entry must survive — proving recordInstall wrote against the base, not a disk re-read (DOS-4)'); }); test('DOS-4: recordInstall WITHOUT baseLedger reads disk (a pre-existing disk entry is preserved)', (t) => { const dir = createTempDir('ledger-dos4-nobase-'); t.after(() => cleanup(dir)); // Seed a ledger on disk with one entry, then recordInstall a second WITHOUT a base. The default // strict read must pick up the on-disk entry and preserve it alongside the new one. recordInstall(dir, makeEntry('on-disk')); recordInstall(dir, makeEntry('dos4-default')); const ledger = readLedger(dir); assert.ok(ledger !== null && 'on-disk' in ledger.entries && 'dos4-default' in ledger.entries, 'without a base, recordInstall must strict-read disk and preserve the existing entry (default unchanged)'); }); test('DOS-4: recordInstall ignores a non-object baseLedger and falls back to strict read', (t) => { const dir = createTempDir('ledger-dos4-badbase-'); t.after(() => cleanup(dir)); // A garbage base must not be trusted; recordInstall must fall back to the strict read. assert.doesNotThrow( () => recordInstall(dir, makeEntry('dos4-fallback'), { baseLedger: /** intentionally bad */ 'not-a-ledger' }), 'a non-object baseLedger must be ignored, not crash', ); const ledger = readLedger(dir); assert.ok(ledger !== null && 'dos4-fallback' in ledger.entries); }); // --------------------------------------------------------------------------- // Finding 3 (MEDIUM): unbounded ledger read. readLedgerRaw must statSync the file // BEFORE reading and refuse an oversized ledger (fail-closed) without materializing // it; and it must cap the entry COUNT (MAX_ENTRIES) during validation. // --------------------------------------------------------------------------- // Revert-fails: drop the statSync size-cap in readLedgerRaw → the oversized file is read whole and // (being valid JSON with one valid entry) parses fine, so readLedger returns non-null and // readLedgerStrict does NOT throw — both assertions here then fail. test('finding-3: an OVERSIZED ledger file is refused without being read whole (fail closed)', (t) => { const dir = createTempDir('ledger-f3-oversized-'); t.after(() => cleanup(dir)); // A VALID ledger structurally — but padded past the 8 MiB cap via a long (valid) string field that // JSON.parse would accept. The size cap, not a parse failure, must block it: proving the bound. const filePath = path.join(dir, LEDGER_FILE_NAME); const entry = makeEntry('big-cap', { source: 'registry:' + 'p'.repeat(9 * 1024 * 1024) }); fs.writeFileSync(filePath, JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries: { 'big-cap': entry } })); assert.ok(fs.statSync(filePath).size > 8 * 1024 * 1024, 'pre-condition: file must exceed the 8 MiB cap'); // readLedger (non-throwing) must return null (it cannot read an oversized file). assert.strictEqual(readLedger(dir), null, 'readLedger must refuse an oversized ledger (returns null)'); // readLedgerStrict must fail closed (throw) so every subsequent op blocks until resolved. assert.throws( () => readLedgerStrict(dir), (err) => { assert.ok(err instanceof Error, 'must throw an Error'); assert.ok(LedgerIOError !== undefined && err instanceof LedgerIOError, `oversized ledger must be a LedgerIOError (cannot-read), not corruption; got: ${err?.constructor?.name}`); assert.ok(/exceeds the maximum|oversized/i.test(err.message), `message must name the size limit; got: ${err.message}`); return true; }, 'readLedgerStrict must throw a fail-closed IO error for an oversized ledger', ); }); // Revert-fails: drop the `keys.length > MAX_ENTRIES` reject in readLedgerRaw → a ledger with 4097 // valid entries is accepted, so readLedger returns non-null and this strictEqual(null) fails. test('finding-3: a ledger with more than MAX_ENTRIES entries is rejected (entry-count DoS cap)', (t) => { const dir = createTempDir('ledger-f3-maxentries-'); t.after(() => cleanup(dir)); const entries = {}; for (let i = 0; i <= 4096; i++) { // 4097 entries → one over the 4096 cap const id = `cap-${i}`; entries[id] = makeEntry(id); } fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries })); assert.strictEqual(readLedger(dir), null, 'a ledger exceeding MAX_ENTRIES must be rejected (returns null) — entry-count DoS backstop'); }); // Guard the boundary so the cap can't be quietly tightened below a generous value: exactly // MAX_ENTRIES (4096) entries must still be ACCEPTED. Revert-fails: lower MAX_ENTRIES below 4096 → // this 4096-entry ledger is wrongly rejected and the non-null assertion fails. test('finding-3: a ledger with exactly MAX_ENTRIES entries is still accepted (cap is generous)', (t) => { const dir = createTempDir('ledger-f3-atcap-'); t.after(() => cleanup(dir)); const entries = {}; for (let i = 0; i < 4096; i++) { const id = `cap-${i}`; entries[id] = makeEntry(id); } fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '1', updatedAt: new Date().toISOString(), entries })); const ledger = readLedger(dir); assert.ok(ledger !== null, 'a ledger at exactly MAX_ENTRIES must still be accepted'); assert.strictEqual(Object.keys(ledger.entries).length, 4096, 'all MAX_ENTRIES entries must be present'); }); // --------------------------------------------------------------------------- // Finding 5 (LOW): recordInstall(.,{baseLedger}) must NOT trust an INVALID base. // It validated only the NEW entry, then wrote the supplied base verbatim → a caller // passing an invalid base (bad version/updatedAt/entries) wrote a self-corrupting // ledger. The base is now usable ONLY when it passes the SAME validation a strict // read would; an invalid base is ignored and recordInstall falls back to the strict // read (so the on-disk truth — not the bad base — is the write basis). // --------------------------------------------------------------------------- // Revert-fails: restore the shallow `typeof base.entries === 'object'` acceptance → the base with a // BAD version is written verbatim, producing a ledger whose `version` !== '1', so readLedger rejects // it (null) and this "still valid + on-disk preserved" assertion fails. test('finding-5: recordInstall IGNORES a baseLedger with a bad schema version (falls back to strict disk read)', (t) => { const dir = createTempDir('ledger-f5-badversion-'); t.after(() => cleanup(dir)); // Seed a VALID ledger on disk so the strict-read fallback has real prior state to preserve. recordInstall(dir, makeEntry('on-disk-cap')); // A base that LOOKS like a ledger (has an entries object) but is structurally INVALID: wrong // schema version. The old shallow check accepted it; the fix must reject it and fall back to disk. const badBase = { version: '999', updatedAt: new Date().toISOString(), entries: { 'ghost': makeEntry('ghost') } }; recordInstall(dir, makeEntry('new-cap'), { baseLedger: badBase }); const ledger = readLedger(dir); assert.ok(ledger !== null, 'the written ledger must remain VALID (bad base must not corrupt it)'); assert.strictEqual(ledger.version, '1', 'the written ledger version must be the supported "1", not the bad base\'s "999"'); assert.ok('new-cap' in ledger.entries, 'the new entry must be recorded'); assert.ok('on-disk-cap' in ledger.entries, 'the strict-read disk entry must be preserved (fallback used)'); assert.ok(!('ghost' in ledger.entries), 'the invalid base\'s entry must NOT be written (base ignored)'); }); // Revert-fails: same shallow acceptance → a base carrying a structurally-invalid ENTRY (files:[123]) // is written verbatim, so the resulting ledger fails validation on the next read and this "still // valid" assertion fails. test('finding-5: recordInstall IGNORES a baseLedger that contains a structurally-invalid entry', (t) => { const dir = createTempDir('ledger-f5-badentry-'); t.after(() => cleanup(dir)); recordInstall(dir, makeEntry('on-disk-cap')); // entries map is an object (passes the OLD shallow check) but one entry is malformed (files: [123]). const badBase = { version: '1', updatedAt: new Date().toISOString(), entries: { 'bad': { id: 'bad', version: '1.0.0', source: 's', integrity: 'x', files: [123], sharedEdits: [] } }, }; recordInstall(dir, makeEntry('new-cap'), { baseLedger: badBase }); const ledger = readLedger(dir); assert.ok(ledger !== null, 'a base with a malformed entry must not corrupt the written ledger'); assert.ok('new-cap' in ledger.entries, 'the new entry must be recorded'); assert.ok('on-disk-cap' in ledger.entries, 'the strict-read disk entry must be preserved (base ignored, fallback used)'); assert.ok(!('bad' in ledger.entries), 'the invalid base entry must NOT be written'); }); // Positive control: a VALID base is still honored (the fast-path is not broken by the new gate). // Revert-fails: tighten isValidLedgerFile to reject a valid base → this base entry would be dropped // and the survival assertion fails. test('finding-5: recordInstall still USES a fully-valid baseLedger (fast-path preserved)', (t) => { const dir = createTempDir('ledger-f5-goodbase-'); t.after(() => cleanup(dir)); // No ledger on disk; a VALID base carrying a prior entry must be used as the write base. assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), false, 'pre-condition: no ledger on disk'); const goodBase = makeLedger({ entries: { 'prior': makeEntry('prior') } }); recordInstall(dir, makeEntry('new-cap'), { baseLedger: goodBase }); const ledger = readLedger(dir); assert.ok(ledger !== null && 'new-cap' in ledger.entries && 'prior' in ledger.entries, 'a fully-valid base must be honored (prior entry preserved without a disk re-read)'); }); // --------------------------------------------------------------------------- // Finding 2 (HIGH): read-size caps must be enforced via an fd-based stat (fstat // AFTER open), not a path-stat that a FIFO / device / symlink-to-device / stat-then- // read swap can bypass. A single shared `readSmallRegularFile(path, maxBytes)` helper // must: openSync('r') → fstatSync(fd) → require isFile() (reject FIFO/device/dir/ // symlink-target-nonregular) → require size <= maxBytes → read exactly size bytes → // closeSync in finally. readLedgerRaw + the unsupported-version reparse use it (fail // closed → LedgerIOError) and a normal small ledger still reads fine. // --------------------------------------------------------------------------- test('finding-2: readSmallRegularFile is exported (shared bounded fd reader)', () => { assert.equal(typeof readSmallRegularFile, 'function', 'readSmallRegularFile must be exported for both lifecycle + ledger to share one bounded reader'); }); // Revert-fails: replace the fstat(fd).isFile() guard with a path statSync+readFileSync → the FIFO // read blocks forever (no writer) OR (if a writer existed) bypasses the cap; with the fd helper the // non-regular fstat is rejected immediately, so this assertion (throws fast, does not hang) holds. test('finding-2: readSmallRegularFile rejects a FIFO (non-regular) — fail closed, no hang', (t) => { const dir = createTempDir('ledger-f2-fifo-'); t.after(() => cleanup(dir)); const fifo = path.join(dir, 'fifo'); if (!tryMkfifo(fifo)) { t.skip('mkfifo unavailable on this platform'); return; } assert.throws( () => readSmallRegularFile(fifo, 64 * 1024), (err) => { assert.ok(err instanceof Error, 'must throw an Error'); assert.ok(/regular|unreadable|not a regular/i.test(err.message), `must reject a non-regular file with a clear reason; got: ${err.message}`); return true; }, 'readSmallRegularFile must fail closed on a FIFO (not block/read-unbounded)', ); }); // Revert-fails: same as above — a path-stat helper would follow the symlink to /dev/zero (a char // DEVICE that is INFINITE) and read until OOM; the fd-fstat isFile() guard rejects the non-regular // target, so this "throws" assertion holds. (POSIX-only; /dev/zero is the device.) test('finding-2: readSmallRegularFile rejects a symlink to /dev/zero (char device, infinite)', (t) => { const dir = createTempDir('ledger-f2-devzero-'); t.after(() => cleanup(dir)); if (process.platform === 'win32' || !fs.existsSync('/dev/zero')) { t.skip('no /dev/zero on this platform'); return; } const link = path.join(dir, 'zerolink'); fs.symlinkSync('/dev/zero', link); assert.throws( () => readSmallRegularFile(link, 64 * 1024), (err) => { assert.ok(/regular|unreadable|not a regular/i.test(err.message), `must reject a symlink to a char device; got: ${err.message}`); return true; }, 'readSmallRegularFile must fail closed on a symlink to /dev/zero (not read unbounded)', ); }); // Revert-fails: drop the `fstat.size > maxBytes` reject → the oversized regular file is read whole, // so readSmallRegularFile returns its content instead of throwing and this assertion fails. test('finding-2: readSmallRegularFile rejects an OVERSIZED regular file (size cap on the fd stat)', (t) => { const dir = createTempDir('ledger-f2-oversize-'); t.after(() => cleanup(dir)); const big = path.join(dir, 'big.txt'); fs.writeFileSync(big, 'x'.repeat(70 * 1024)); // > 64 KiB assert.throws( () => readSmallRegularFile(big, 64 * 1024), (err) => { assert.ok(/exceeds|maximum|oversized|too large/i.test(err.message), `must reject an oversized file naming the cap; got: ${err.message}`); return true; }, 'readSmallRegularFile must fail closed on an oversized regular file', ); }); // Positive control: a normal small regular file reads byte-for-byte. Revert-fails: an over-tight cap // or a broken read would change the returned content, so this exact-content assertion fails. test('finding-2: readSmallRegularFile reads a normal small regular file byte-for-byte', (t) => { const dir = createTempDir('ledger-f2-small-'); t.after(() => cleanup(dir)); const f = path.join(dir, 'small.txt'); const content = JSON.stringify({ hello: 'world', n: 42 }); fs.writeFileSync(f, content); assert.strictEqual(readSmallRegularFile(f, 64 * 1024), content, 'a normal small regular file must read back exactly'); }); // Revert-fails: route readLedgerRaw back through statSync(path)+readFileSync(path) → a FIFO ledger // would block / bypass the cap; with the fd helper readLedgerStrict fails closed (LedgerIOError), // so this assertion holds. (Repo-plantable project-scope ledger → repo-borne DoS.) test('finding-2: a ledger path that is a FIFO fails closed via readLedgerStrict (LedgerIOError, no hang)', (t) => { const dir = createTempDir('ledger-f2-fifoledger-'); t.after(() => cleanup(dir)); const fifo = path.join(dir, LEDGER_FILE_NAME); if (!tryMkfifo(fifo)) { t.skip('mkfifo unavailable on this platform'); return; } // readLedger (non-throwing) must return null rather than hanging. assert.strictEqual(readLedger(dir), null, 'readLedger must refuse a FIFO ledger (returns null, no hang)'); assert.throws( () => readLedgerStrict(dir), (err) => { assert.ok(err instanceof LedgerIOError, `a FIFO ledger must fail closed as LedgerIOError; got: ${err?.constructor?.name}`); return true; }, 'readLedgerStrict must fail closed (LedgerIOError) for a FIFO ledger', ); }); // Revert-fails: route the unsupported-version reparse (capability-ledger ~385) back through // readFileSync(path) → a FIFO/oversized swapped in after the first read would block / bypass the cap // on the reparse. With the fd helper the reparse can't be exploited; this verifies the normal // unsupported-version message still surfaces (the helper path is taken for the reparse too). test('finding-2: unsupported-version reparse still surfaces a clear schema-version error (uses the bounded reader)', (t) => { const dir = createTempDir('ledger-f2-reparse-'); t.after(() => cleanup(dir)); // A structurally-fine ledger but with an UNSUPPORTED version → readLedgerRaw returns null, and the // strict reader reparses (via the bounded reader) to produce the distinct "unsupported version" msg. fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), JSON.stringify({ version: '2', updatedAt: new Date().toISOString(), entries: {} })); assert.throws( () => readLedgerStrict(dir), (err) => { assert.ok(/unsupported ledger schema version/i.test(err.message), `must name the unsupported version; got: ${err.message}`); return true; }, 'readLedgerStrict must reparse (bounded) and surface the unsupported-version message', ); });