From c866ac1b24ecd05d0b781c5117e02fc6be7d7034 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 19 Jun 2026 19:55:10 -0400 Subject: [PATCH] fix(#1462): fail closed without data loss on a corrupt capability ledger; atomic ledger write (#1469) --- .changeset/fix-1462-ledger-corruption.md | 5 + docs/adr/1244-capability-ecosystem.md | 2 +- docs/reference/gsd-capability-command.md | 2 +- gsd-core/bin/gsd-tools.cjs | 140 +- src/capability-ledger.cts | 676 ++++++- src/capability-lifecycle.cts | 1027 ++++++++-- tests/capability-cli.test.cjs | 283 +++ tests/capability-ledger.test.cjs | 2195 +++++++++++++++++++++- tests/capability-lifecycle.test.cjs | 1949 ++++++++++++++++++- 9 files changed, 6039 insertions(+), 240 deletions(-) create mode 100644 .changeset/fix-1462-ledger-corruption.md diff --git a/.changeset/fix-1462-ledger-corruption.md b/.changeset/fix-1462-ledger-corruption.md new file mode 100644 index 000000000..b7d691dfe --- /dev/null +++ b/.changeset/fix-1462-ledger-corruption.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1469 +--- +**Capability ledger: fail closed on corruption, with durable atomic writes and a race-safe install lock.** A corrupt or unreadable `.gsd-capabilities.json` is now left in place and surfaced (not silently overwritten) — `install`/`update`/`remove`/`list`/`reconcile` fail closed and report it, so a corrupt ledger can no longer wipe prior capabilities' tracked files and shared-config fragments (which previously left unremovable orphans in `settings.json`/`hooks.json`). Ledger writes are atomic and crash-durable (exclusive temp file + `fsync` of file and directory + rename, with temp cleanup on failure). The per-capability lock is race-safe: a holder is identified by `(pid, process start-time, hostname)`, so a reused PID cannot deadlock recovery and a verifiably-live holder is never stolen, with a hard deadman timeout for unverifiable or cross-host holders. Untrusted ledger and lock reads are bounded (regular-file + size caps; FIFOs/devices rejected) and validated through a single shared entry validator (prototype-safe ids, DoS length caps). (#1462, ADR-1244.) diff --git a/docs/adr/1244-capability-ecosystem.md b/docs/adr/1244-capability-ecosystem.md index 08f847d92..547db2a49 100644 --- a/docs/adr/1244-capability-ecosystem.md +++ b/docs/adr/1244-capability-ecosystem.md @@ -83,7 +83,7 @@ A per-runtime install manifest, e.g. `~/.claude/.gsd-capabilities.json`, recordi "source": "https://github.com/org/cap.git#sha:…", "integrity": "sha512-…", "files": ["skills/…", "agents/…"], // owned files written - "sharedEdits": [{ "file": "settings.json", "path": "hooks.PostToolUse[…]" }] + "sharedEdits": [{ "file": "settings.json", "marker": "" }] } } ``` diff --git a/docs/reference/gsd-capability-command.md b/docs/reference/gsd-capability-command.md index 585bb2830..a1b1c2bc4 100644 --- a/docs/reference/gsd-capability-command.md +++ b/docs/reference/gsd-capability-command.md @@ -158,7 +158,7 @@ gsd capability list [--json] | Flag | Description | |---|---| -| `--json` | Emit the JSON array explicitly. (In 1.6.0 `list` always emits JSON; a formatted table is planned.) | +| `--json` | Currently a **no-op**: `list` always emits the JSON array regardless of this flag. The flag is accepted for forward compatibility — a formatted human-readable table is planned, at which point `--json` will select the JSON form. Do not rely on omitting `--json` to get non-JSON output today. | **Behaviour** diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index bd39a24ee..04d467107 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1503,6 +1503,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return '0.0.0'; } }; + // UX-2: run the best-effort pre-op crash-recovery sweep AND surface any warnings it reports + // (e.g. a corrupt-present ledger, or a rollback that could not complete) on stderr. The previous + // bare `try { reconcile } catch {}` discarded the report entirely, so corruption detected during + // reconcile was invisible. We never abort on a reconcile warning here — the mutating op that + // follows runs its own fail-closed checks — but the warning must be OBSERVABLE. + const capRunReconcile = (runtimeDir, lifecycle) => { + try { + const report = lifecycle.reconcileCapabilities({ runtimeDir }); + if (report && Array.isArray(report.warnings)) { + for (const w of report.warnings) { + try { process.stderr.write(`capability reconcile: ${w}\n`); } catch { /* best-effort */ } + } + } + } catch { /* best-effort crash recovery — never block the op on a reconcile failure */ } + }; if (capSubcommand === 'state') { const configDirIdx = args.indexOf('--config-dir'); let configDir = null; @@ -1601,13 +1616,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope')); const lifecycle = require('./lib/capability-lifecycle.cjs'); const trust = require('./lib/capability-trust.cjs'); - try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ } + // Finding 5(b): bound the --shared-file COUNT EARLY — before reconcile, source resolution, + // staging, or any shared-config write — so an over-cap install fails fast with a clear count + // error and leaves NO staging dir / _pending behind. The lifecycle re-checks (defense in + // depth); this CLI-side guard short-circuits before even the pre-op reconcile runs. + const installSharedFiles = capRepeatedFlag('--shared-file'); + const ledgerModInstall = require('./lib/capability-ledger.cjs'); + if (installSharedFiles.length > ledgerModInstall.MAX_SHARED_FILES) { + error( + `capability install blocked: too many --shared-file entries: ${installSharedFiles.length} ` + + `exceeds the maximum of ${ledgerModInstall.MAX_SHARED_FILES}.`, + ERROR_REASON ? ERROR_REASON.USAGE : undefined, + ); + } + capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr const res = await lifecycle.installCapability(spec, { runtimeDir, hostVersion: capHostVersion(), consentGranted: capHasFlag('--yes'), integrity: capFlagValue('--integrity'), - sharedFiles: capRepeatedFlag('--shared-file'), + sharedFiles: installSharedFiles, strictKnownRegistries: capReadStrict(), }); if (res.status === 'installed') { @@ -1622,12 +1650,18 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // 'aborted' always means "executable surface needs consent" in the lifecycle contract — // match it regardless of the requiresConsent flag so a future aborted path can't fall // through to the generic "blocked: unknown reason" arm with a misleading message. - error( - ['This capability declares executable surfaces and needs your consent before install:'] - .concat(trust.summarizeDisclosure(res.disclosure || {}).map((l) => ' ' + l)) + const disclosure = trust.summarizeDisclosure(res.disclosure || {}); + // UX-5: emit a structured aborted envelope on STDOUT before the non-zero exit so automation + // can detect the consent requirement programmatically. We throw ExitError (not error(), + // which calls process.exit and would bypass the stdout-capture flush) so the buffered stdout + // is flushed before exit; the human-readable guidance still lands on stderr. + output({ status: 'aborted', requiresConsent: true, scope, disclosure }, raw); + throw new ExitError( + 1, + ['Error: This capability declares executable surfaces and needs your consent before install:'] + .concat(disclosure.map((l) => ' ' + l)) .concat(['Re-run with --yes to grant consent and install.']) .join('\n'), - ERROR_REASON ? ERROR_REASON.USAGE : undefined, ); } else { error( @@ -1649,8 +1683,29 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand const lifecycle = require('./lib/capability-lifecycle.cjs'); const ledgerMod = require('./lib/capability-ledger.cjs'); const trust = require('./lib/capability-trust.cjs'); - try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ } - const ledger = ledgerMod.readLedger(runtimeDir); + // Finding 4 (MEDIUM): parse the --shared-file list ONCE and enforce MAX_SHARED_FILES BEFORE + // the pre-op reconcile (install has this early guard; update did not — it ran reconcile, then + // re-parsed --shared-file per entry inside upgradeOne). An over-cap update now fails fast with + // a clear count error and leaves no reconcile side-effects, mirroring the install dispatch. + const updateSharedFiles = capRepeatedFlag('--shared-file'); + if (updateSharedFiles.length > ledgerMod.MAX_SHARED_FILES) { + error( + `capability update blocked: too many --shared-file entries: ${updateSharedFiles.length} ` + + `exceeds the maximum of ${ledgerMod.MAX_SHARED_FILES}.`, + ERROR_REASON ? ERROR_REASON.USAGE : undefined, + ); + } + capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr + // readLedgerStrict: returns null when MISSING (no installs yet), throws CorruptLedgerError + // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a + // corrupt-but-present ledger fails closed rather than silently reporting not_installed () + // or succeeding with an empty list (--all), both of which bypass fail-closed (Codex pass 3 M2). + let ledger; + try { + ledger = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + error(`capability update blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined); + } const entries = (ledger && ledger.entries) || {}; const upgradeOne = async (capId) => { const entry = entries[capId]; @@ -1661,18 +1716,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand runtimeDir, hostVersion: capHostVersion(), consentGranted: capHasFlag('--yes'), - sharedFiles: capRepeatedFlag('--shared-file'), + sharedFiles: updateSharedFiles, // finding 4: parsed once, count-checked before reconcile strictKnownRegistries: capReadStrict(), expectedId: capId, }); + // UX-6: normalize absent fields to explicit null so a not_installed/blocked row serializes + // them as null rather than omitting them (JSON.stringify drops undefined keys), giving a + // stable per-entry shape for `--all` consumers. return { id: capId, status: r.status, - fromVersion: r.fromVersion, - toVersion: r.toVersion, - requiresConsent: r.requiresConsent, - blockReasons: r.blockReasons, - disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : undefined, + fromVersion: r.fromVersion ?? null, + toVersion: r.toVersion ?? null, + requiresConsent: r.requiresConsent ?? null, + blockReasons: r.blockReasons ?? null, + disclosure: r.disclosure ? trust.summarizeDisclosure(r.disclosure) : null, }; }; if (all) { @@ -1684,12 +1742,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } const failed = results.filter((x) => x.status !== 'upgraded'); if (failed.length > 0) { - // Some entries did not upgrade (aborted / blocked) — surface as a non-zero exit so - // automation never reads a partial `--all` run as a clean success. - error( - `capability update --all: ${failed.length} of ${results.length} did not upgrade.\n` + - JSON.stringify({ scope, updated: results }, null, 2), - ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined, + // UX-1: emit the FULL structured result on STDOUT first (success and partial-failure + // alike), then set a non-zero exit. Previously the results JSON was embedded inside the + // error STRING on stderr, so automation could not parse a partial-failure run as + // structured data. We throw ExitError (not error(), which calls process.exit and would + // bypass the stdout-capture flush) so the buffered stdout is flushed before exit and a + // concise reason still lands on stderr. + output({ scope, updated: results }, raw); + throw new ExitError( + 1, + `Error: capability update --all: ${failed.length} of ${results.length} did not upgrade ` + + `(see the JSON result on stdout for per-capability status).`, ); } output({ scope, updated: results }, raw); @@ -1722,10 +1785,17 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand const { scope, runtimeDir } = capResolveScope(capFlagValue('--scope')); const lifecycle = require('./lib/capability-lifecycle.cjs'); const ledgerMod = require('./lib/capability-ledger.cjs'); - try { lifecycle.reconcileCapabilities({ runtimeDir }); } catch { /* best-effort crash recovery */ } + capRunReconcile(runtimeDir, lifecycle); // UX-2: surface reconcile warnings on stderr // Ledger first: an installed overlay is removable even if its id shadows a first-party name. // Only when the id is NOT an installed overlay do we reject a first-party id (vs. a typo). - const removeLedger = ledgerMod.readLedger(runtimeDir); + // Use readLedgerStrict so a corrupt-but-present ledger surfaces corruption here rather than + // silently reporting "first-party cannot be removed" for any id (finding 7). + let removeLedger; + try { + removeLedger = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + error(`capability remove blocked: ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined); + } const inLedger = !!(removeLedger && removeLedger.entries && Object.prototype.hasOwnProperty.call(removeLedger.entries, id)); if (!inLedger) { const base = require('./lib/capability-loader.cjs').loadRegistry(); @@ -1749,12 +1819,20 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand error(`capability remove blocked: ${(res.blockReasons || ['unknown reason']).join('; ')}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined); } } else if (capSubcommand === 'list') { - // capability list [--json] — emits a JSON array of capability descriptors (first-party + overlay). + // capability list [--json] [--scope global|project] — emits a JSON array of capability descriptors. + // When --scope is given, only that scope's overlay ledger is read (finding 8: honor --scope so a + // corrupt unrelated ledger in another scope does not block a scoped list). const loader = require('./lib/capability-loader.cjs'); const ledgerMod = require('./lib/capability-ledger.cjs'); const semver = require('./lib/semver-compare.cjs'); const host = capHostVersion(); const rows = []; + const listScopeArg = capFlagValue('--scope'); + // Validate --scope if provided. + if (listScopeArg && listScopeArg !== 'global' && listScopeArg !== 'project') { + error(`Invalid --scope "${listScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + // First-party capabilities are always included (they have no scope concept). const base = loader.loadRegistry(); const fp = (base && base.capabilities) || {}; for (const capId of Object.keys(fp)) { @@ -1770,9 +1848,21 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand title: cap.title || null, }); } - for (const sc of ['global', 'project']) { + // Overlay scopes: honor --scope to read only the requested scope (finding 8). + const overlayScopes = listScopeArg ? [listScopeArg] : ['global', 'project']; + for (const sc of overlayScopes) { const { runtimeDir } = capResolveScope(sc); - const ledger = ledgerMod.readLedger(runtimeDir); + // readLedgerStrict: returns null when MISSING (no overlays yet), throws CorruptLedgerError + // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a + // corrupt-but-present ledger is visible to the user (blocked/error) rather than silently + // dropping overlay entries and returning a first-party-only list (site A fix, #1462). + let ledger; + try { + ledger = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + // UX-3: name the offending scope so the user knows WHICH ledger to fix. + error(`capability list blocked (${sc} scope): ${err.message}`, ERROR_REASON ? ERROR_REASON.SDK_FAIL_FAST : undefined); + } if (!ledger || !ledger.entries) continue; for (const capId of Object.keys(ledger.entries)) { const entry = ledger.entries[capId]; diff --git a/src/capability-ledger.cts b/src/capability-ledger.cts index d14a720a9..2e17a7bf7 100644 --- a/src/capability-ledger.cts +++ b/src/capability-ledger.cts @@ -5,24 +5,25 @@ * what each capability install wrote. Serves as the atomic commit point and * reconciliation basis for Phase 4 upgrade/remove operations. * - * LEAF MODULE — imports ONLY: node:fs, node:path, node:os, and - * ./shell-command-projection.cjs (for platformWriteSync). No other src/ imports. + * LEAF MODULE — imports ONLY: node:fs, node:path, node:crypto. No other src/ imports. * * Exports: * readLedger(runtimeDir) — structural-validated read, never throws - * writeLedger(runtimeDir, ledger) — atomic write via platformWriteSync + * readLedgerStrict(runtimeDir) — like readLedger but throws CorruptLedgerError when + * the file exists but is unparseable/invalid. The + * corrupt file is LEFT IN PLACE (not moved/quarantined) + * so every subsequent op also blocks until the user + * inspects and resolves it. + * writeLedger(runtimeDir, ledger) — atomic write (tmp + rename, crash-safe) * recordInstall(runtimeDir, entry) — idempotent upsert of a ledger entry * removeEntry(runtimeDir, capId) — remove a single entry by id * reconcile(runtimeDir) — report orphans / stale entries (read-only) + * CorruptLedgerError — thrown by readLedgerStrict on corruption */ import fs from 'node:fs'; import path from 'node:path'; - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { platformWriteSync } = require('./shell-command-projection.cjs') as { - platformWriteSync: (filePath: string, content: string) => void; -}; +import crypto from 'node:crypto'; // --------------------------------------------------------------------------- // Constants @@ -31,6 +32,26 @@ const { platformWriteSync } = require('./shell-command-projection.cjs') as { const LEDGER_FILE_NAME = '.gsd-capabilities.json'; const LEDGER_SCHEMA_VERSION = '1'; +// --------------------------------------------------------------------------- +// CorruptLedgerError +// --------------------------------------------------------------------------- + +/** + * Thrown by `readLedgerStrict` when the ledger file is present but cannot be + * parsed or is structurally invalid. The corrupt file is LEFT IN PLACE so that + * every subsequent operation also blocks until the user resolves it manually. + * Recovery: inspect the file, restore a backup, or move it aside to start fresh. + */ +class CorruptLedgerError extends Error { + /** Absolute path of the corrupt ledger file. */ + ledgerPath: string; + constructor(message: string, ledgerPath: string) { + super(message); + this.name = 'CorruptLedgerError'; + this.ledgerPath = ledgerPath; + } +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -57,34 +78,180 @@ interface LedgerFile { // IO helpers // --------------------------------------------------------------------------- +/** Pattern for valid capability IDs (must match this to be accepted as ledger keys). */ +const VALID_ID_RE = /^[a-z][a-z0-9-]*$/; + /** - * Read and structurally validate the ledger file. - * - * Returns null if the file is missing, unreadable, or structurally invalid. - * Never throws. + * DOS-3 / finding 5(a): GENEROUS DoS backstop bounds — NOT product limits. No legitimate capability + * declares this many files or shared-config edits, but a hostile ledger with a 100k+-element array + * is rejected before it can be iterated/spread into a Set (memory/CPU DoS). Raised from the prior + * 256/64 (which risked false-rejecting large-but-legitimate installs) to clearly-generous bounds. */ -function readLedger(runtimeDir: string): LedgerFile | null { - const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); +const MAX_FILES = 10_000; +const MAX_SHARED_EDITS = 256; +/** Cap for `_pending.sharedFiles` (finding 3) — same generous bound as `sharedEdits`. */ +const MAX_SHARED_FILES = 256; +/** + * Finding 3 (MEDIUM): GENEROUS DoS backstops on the ledger FILE itself, NOT product limits. The + * ledger is untrusted on-disk content; readLedgerRaw must not read+parse+materialize an unbounded + * file. Before reading, `statSync` and reject (fail-closed via the corrupt path) if `size` exceeds + * LEDGER_MAX_BYTES. And enforce MAX_ENTRIES during validation so a hostile ledger with millions of + * keys cannot weaponize Object.keys iteration. 8 MiB / 4096 entries are far beyond any real install + * (a typical entry is a few hundred bytes; 4096 capabilities is wildly more than any user installs). + */ +const LEDGER_MAX_BYTES = 8 * 1024 * 1024; +const MAX_ENTRIES = 4096; + +/** + * Returns true when `id` must never be used as an object key or ledger entry id — either + * because it would cause prototype pollution or because it fails the kebab-case constraint. + * + * Security note: uses INLINE LITERAL key comparisons (do NOT use a Set or computed lookup) + * as required by the CodeQL prototype-pollution barrier — a Set.has call could itself be + * attacked via a poisoned prototype. + */ +function isUnsafeCapabilityId(id: unknown): boolean { + if (typeof id !== 'string') return true; + if (id === '__proto__') return true; + if (id === 'constructor') return true; + if (id === 'prototype') return true; + if (!VALID_ID_RE.test(id)) return true; + return false; +} + +/** + * Sentinel for distinguishing IO errors (EACCES, EISDIR, EPERM, …) from + * parse/validation failures. Thrown internally by readLedgerRaw; caught by the + * two public readers to produce the right error type or return value. + */ +class LedgerIOError extends Error { + code: string | undefined; + constructor(message: string, code?: string) { + super(message); + this.name = 'LedgerIOError'; + this.code = code; + } +} + +/** + * Finding 2 (HIGH): the SINGLE shared robust bounded reader for every untrusted on-disk file the + * capability stack reads (the ledger here AND the .lock body in capability-lifecycle, which imports + * this). A path-`stat`(path)+`readFileSync`(path) pair is NOT safe: a FIFO, a symlink to a character + * device like /dev/zero, or a regular file SWAPPED/GROWN between the stat and the read defeats the + * size cap and can BLOCK (FIFO with no writer) or read UNBOUNDED (infinite device). Project-scope + * ledgers are repo-plantable, so this is a repo-borne DoS. + * + * The fix binds the type+size decision to the SAME open fd we read from: + * 1. openSync(path, O_RDONLY|O_NONBLOCK) — open ONCE, NON-BLOCKING. The O_NONBLOCK is essential: + * a plain openSync of a FIFO BLOCKS until a writer appears (the + * very hang we are defending against); O_NONBLOCK returns the fd + * immediately so fstat can reject it. (Symlinks are still followed + * to their target, as a read would; O_NONBLOCK is ignored for a + * regular file.) + * 2. fstatSync(fd) — stat the OPENED fd (not the path) — defeats the stat-then-read + * swap and reads the REAL target's type/size. + * 3. require stat.isFile() — reject FIFO / device / directory / symlink-to-nonregular. A + * directory keeps the legacy `EISDIR` code so existing callers + * that branch on it are unchanged. + * 4. require stat.size <= maxBytes — refuse an oversized regular file WITHOUT reading it whole. + * 5. read EXACTLY stat.size bytes from the fd — never an unbounded streaming read. + * 6. closeSync(fd) in finally. + * + * Returns the file content as a string, or null for ENOENT (genuinely missing). Throws LedgerIOError + * for every other condition (non-regular, oversized, IO error) so callers fail closed. Behavior for a + * normal small regular file is identical to the prior readFileSync(path,'utf8'). + */ +function readSmallRegularFile(filePath: string, maxBytes: number): string | null { + // O_RDONLY | O_NONBLOCK: never block on opening a FIFO/device — return the fd so fstat can reject it. + const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK; + let fd: number; + try { + fd = fs.openSync(filePath, openFlags); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return null; // genuinely missing — not a corruption. + throw new LedgerIOError(`Cannot open ${filePath}: ${(err as Error).message}`, code); + } + try { + const st = fs.fstatSync(fd); + if (!st.isFile()) { + // FIFO / device / directory / symlink-to-nonregular. Preserve EISDIR for a directory so callers + // that distinguish it (and existing tests) still see that code; other non-regular kinds get a + // synthetic ENXIO. Either way it is an unreadable, fail-closed condition (not content parsing). + const code = st.isDirectory() ? 'EISDIR' : 'ENXIO'; + throw new LedgerIOError( + `Cannot read ${filePath}: not a regular file (unreadable; FIFO/device/directory) — refusing.`, + code, + ); + } + if (st.size > maxBytes) { + throw new LedgerIOError( + `Cannot read ${filePath}: file size ${st.size} bytes exceeds the maximum of ${maxBytes} ` + + `bytes (refusing to read an oversized file). Inspect or move it aside.`, + 'EFBIG', + ); + } + if (st.size === 0) return ''; + const buf = Buffer.allocUnsafe(st.size); + let off = 0; + // Read EXACTLY st.size bytes from the fd (never a streaming/unbounded read). + while (off < st.size) { + const n = fs.readSync(fd, buf, off, st.size - off, off); + if (n <= 0) break; // EOF earlier than fstat reported (truncated under us) — return what we got. + off += n; + } + return buf.toString('utf8', 0, off); + } catch (err) { + if (err instanceof LedgerIOError) throw err; + throw new LedgerIOError(`Cannot read ${filePath}: ${(err as Error).message}`, (err as NodeJS.ErrnoException).code); + } finally { + try { fs.closeSync(fd); } catch { /* best-effort */ } + } +} + +/** + * Read and structurally validate the ledger file. Throws LedgerIOError when the + * file cannot be read due to an OS error (EACCES, EISDIR, EPERM, …). Returns + * null when the file is missing (ENOENT) or when its content fails validation. + * Never throws for parse or validation failures — those become null. + */ +function readLedgerRaw(runtimeDir: string): LedgerFile | null { + const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); + // Finding 3 (MEDIUM) + Finding 2 (HIGH): the ledger file is untrusted. Read it via the shared + // fd-based bounded reader (open → fstat → require regular file → size cap → read exactly size). A + // FIFO/device/symlink-to-device or a stat-then-read swap can no longer block or bypass the cap; an + // oversized/non-regular file is surfaced as a LedgerIOError (a "cannot read" condition, not a + // content-parse failure) so readLedger returns null and readLedgerStrict rethrows it — every + // subsequent op then fails closed until the user resolves it, exactly like the corrupt path. + let raw: string; + try { + const content = readSmallRegularFile(filePath, LEDGER_MAX_BYTES); + if (content === null) return null; // genuinely missing — not a corruption. + raw = content; + } catch (err) { + if (err instanceof LedgerIOError) throw err; // non-regular / oversized / IO — fail closed. + throw new LedgerIOError(`Cannot read ledger at ${filePath}: ${(err as Error).message}`, (err as NodeJS.ErrnoException).code); + } try { - const raw = fs.readFileSync(filePath, 'utf8'); const parsed: unknown = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null) return null; const p = parsed as Record; - if (typeof p['version'] !== 'string') return null; - if (typeof p['updatedAt'] !== 'string') return null; + // Schema version must be the expected value (not any string) — finding 11. + if (p['version'] !== LEDGER_SCHEMA_VERSION) return null; + // updatedAt must be a non-empty string — finding 11. + if (typeof p['updatedAt'] !== 'string' || !p['updatedAt']) return null; if (typeof p['entries'] !== 'object' || p['entries'] === null || Array.isArray(p['entries'])) return null; - // Shallow-validate each entry + // Validate each entry via isValidLedgerEntry — THE single validator (ROOT FIX 1). + // This eliminates the previous inline duplication and guarantees readLedger and + // isValidLedgerEntry can never diverge. const entries = p['entries'] as Record; - for (const key of Object.keys(entries)) { - const e = entries[key]; - if (typeof e !== 'object' || e === null) return null; - const entry = e as Record; - if (typeof entry['id'] !== 'string') return null; - if (typeof entry['version'] !== 'string') return null; - if (typeof entry['source'] !== 'string') return null; - if (typeof entry['integrity'] !== 'string') return null; - if (!Array.isArray(entry['files'])) return null; - if (!Array.isArray(entry['sharedEdits'])) return null; + const keys = Object.keys(entries); + // Finding 3 (MEDIUM): cap the entry COUNT so a hostile ledger with millions of keys cannot + // weaponize per-entry validation/iteration (the size cap above already bounds the parse; this + // bounds the post-parse key count). Generous DoS backstop, not a product limit. + if (keys.length > MAX_ENTRIES) return null; + for (const key of keys) { + if (!isValidLedgerEntry(key, entries[key])) return null; } return { version: p['version'], @@ -97,13 +264,358 @@ function readLedger(runtimeDir: string): LedgerFile | null { } /** - * Write the ledger atomically via platformWriteSync (mkdirSync + tmp+rename). + * Validate a single ledger entry object against the per-entry shape that readLedger enforces. + * This is THE single validator — readLedger/readLedgerRaw call it per-entry instead of + * duplicating inline checks (ROOT FIX 1 — single source of truth; #1459 will also consume this). + * + * Returns true when the entry is structurally valid for the given `id` key. + * Returns false for any structural violation: + * - id is an unsafe prototype-pollution key (__proto__, constructor, prototype) + * - id fails the kebab-case constraint (VALID_ID_RE) + * - entry.id field missing or not matching the key + * - missing/wrong-type required fields (version, source, integrity) + * - files[] with non-string members + * - sharedEdits[] with missing / non-string file or marker fields + * - _pending present but wrong shape (kind not 'install'/'upgrade', bad backupName, missing sharedFiles[]) + */ +function isValidLedgerEntry(id: unknown, entry: unknown): boolean { + // ROOT FIX 3: reject unsafe ids using inline literal checks (CodeQL-safe pattern). + if (isUnsafeCapabilityId(id)) return false; + if (typeof entry !== 'object' || entry === null) return false; + const e = entry as Record; + if (typeof e['id'] !== 'string' || e['id'] !== id) return false; + if (typeof e['version'] !== 'string') return false; + if (typeof e['source'] !== 'string') return false; + if (typeof e['integrity'] !== 'string') return false; + if (!Array.isArray(e['files'])) return false; + // DOS-3 / finding 5(a): cap array sizes so a hostile ledger cannot weaponize a 100k+-element + // files[] (or sharedEdits[]/_pending.sharedFiles[]) into a memory/CPU DoS at validation/reconcile + // time. These are GENEROUS DoS backstops, NOT product limits — no legitimate capability declares + // 10k files or 256 shared-config edits, but a 100k+ hostile array is rejected (not iterated). + if (e['files'].length > MAX_FILES) return false; + for (const f of e['files'] as unknown[]) { + if (typeof f !== 'string') return false; + } + if (!Array.isArray(e['sharedEdits'])) return false; + if (e['sharedEdits'].length > MAX_SHARED_EDITS) return false; // DOS-3 (see above) + for (const se of e['sharedEdits'] as unknown[]) { + if (se === null || typeof se !== 'object') return false; + const seObj = se as Record; + if (typeof seObj['file'] !== 'string' || !seObj['file']) return false; + if (typeof seObj['marker'] !== 'string' || !seObj['marker']) return false; + } + // Validate _pending shape if present (ROOT FIX 1 — previously only in readLedgerRaw). + if (Object.prototype.hasOwnProperty.call(e, '_pending')) { + const pending = e['_pending']; + if (pending !== undefined) { + if (typeof pending !== 'object' || pending === null) return false; + const p = pending as Record; + if (p['kind'] !== 'install' && p['kind'] !== 'upgrade') return false; + // backupName must be string or null — not a number or object. + if (p['backupName'] !== null && typeof p['backupName'] !== 'string') return false; + if (!Array.isArray(p['sharedFiles'])) return false; + // Finding 3: _pending.sharedFiles was previously ONLY Array.isArray-checked, so a hostile + // ledger with a 500k-element (or non-string) _pending.sharedFiles was accepted and later + // spread into a Set + iterated in reconcileCapabilities (DoS bypass). Cap its length with the + // same generous bound as sharedFiles and require every member to be a string. + if ((p['sharedFiles'] as unknown[]).length > MAX_SHARED_FILES) return false; + for (const sf of p['sharedFiles'] as unknown[]) { + if (typeof sf !== 'string') return false; + } + } + } + return true; +} + +/** + * Validate a WHOLE ledger-file object against the SAME structural rules a strict read enforces + * (finding 5 — LOW): the schema version, a non-empty `updatedAt`, an entries map within MAX_ENTRIES, + * and every entry valid via isValidLedgerEntry. Used by recordInstall to gate the in-lock + * `baseLedger` fast-path so an invalid caller-supplied base can never be written verbatim. Never + * throws; returns false for any structural violation. + */ +function isValidLedgerFile(base: unknown): base is LedgerFile { + if (typeof base !== 'object' || base === null || Array.isArray(base)) return false; + const b = base as Record; + if (b['version'] !== LEDGER_SCHEMA_VERSION) return false; + if (typeof b['updatedAt'] !== 'string' || !b['updatedAt']) return false; + const entriesVal = b['entries']; + if (typeof entriesVal !== 'object' || entriesVal === null || Array.isArray(entriesVal)) return false; + const entries = entriesVal as Record; + const keys = Object.keys(entries); + if (keys.length > MAX_ENTRIES) return false; + for (const key of keys) { + if (!isValidLedgerEntry(key, entries[key])) return false; + } + return true; +} + +/** + * Read and structurally validate the ledger file. + * + * Returns null if the file is missing or structurally invalid. + * Returns the parsed ledger when the file is valid. + * On IO errors (EACCES, EISDIR, EPERM), returns null (non-throwing, compatible with old API). + * Never throws. + */ +function readLedger(runtimeDir: string): LedgerFile | null { + try { + return readLedgerRaw(runtimeDir); + } catch (err) { + if (err instanceof LedgerIOError) { + // IO error — treat as unreadable (return null) so callers are not broken. + // readLedgerStrict will surface the real error. + return null; + } + return null; + } +} + +/** + * Like `readLedger` but distinguishes missing-vs-corrupt, and surfaces IO errors distinctly: + * - File missing → returns null (no ledger yet, fresh start is fine). + * - File present and valid → returns the parsed LedgerFile. + * - File present but unparseable/invalid CONTENT → throws CorruptLedgerError. The file is + * LEFT IN PLACE (not moved, renamed, or deleted) so every subsequent operation also + * blocks until the user resolves it. Recovery: inspect the file, restore a backup, + * or move it aside yourself to start fresh. + * - File present but unreadable (EACCES, EPERM, EISDIR, …) → throws LedgerIOError with + * the original OS errno/code preserved. This is an IO/permission problem — NOT a content + * corruption — and callers should surface it as such (finding 4). + * + * Callers that must fail-closed on corruption (upgrade, remove, install) should use this + * instead of `readLedger` so they never mistake a corrupt file for "not installed". + */ +function readLedgerStrict(runtimeDir: string): LedgerFile | null { + const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); + let raw: LedgerFile | null; + try { + raw = readLedgerRaw(runtimeDir); + } catch (err) { + if (err instanceof LedgerIOError) { + // IO error (EACCES, EPERM, EISDIR, …) — rethrow as-is so callers see it as an IO + // problem with the original errno, not as content corruption (finding 4). + throw err; + } + throw err; // unexpected — propagate + } + if (raw !== null) return raw; + // readLedgerRaw returned null: either genuinely missing or present-but-invalid (or unreadable). + // ROOT FIX 4: use lstatSync (not existsSync) to detect dangling/broken symlinks. + // existsSync follows the symlink and returns false for a broken symlink, making the ledger + // appear "missing" when it is actually an IO problem — so a broken symlink would silently + // allow a "fresh install" over a dangling ledger pointer, losing all prior records. + // lstatSync checks the directory entry itself (not the target) — if it exists (even as a + // broken symlink), that is NOT "missing": surface it as an IO error so every subsequent op + // also fails closed until the user resolves it. + let lstatResult: fs.Stats | null = null; + try { + lstatResult = fs.lstatSync(filePath); + } catch (lstatErr) { + const lstatCode = (lstatErr as NodeJS.ErrnoException).code; + if (lstatCode === 'ENOENT') return null; // genuinely missing directory entry — fresh start is fine. + // Any other lstat error (EACCES, EPERM, …) — treat as IO failure. + throw new LedgerIOError( + `Cannot stat ledger at ${filePath}: ${(lstatErr as Error).message}`, + lstatCode, + ); + } + // lstat succeeded — the path exists in the directory (could be a broken symlink, dir, etc.). + if (lstatResult.isSymbolicLink()) { + // Broken symlink: the entry exists but the target is unreadable. This is an IO problem, + // not content corruption — surface as LedgerIOError (not CorruptLedgerError) so callers + // distinguish "I/O problem" from "corrupt content" (ROOT FIX 4). + throw new LedgerIOError( + `Ledger path ${filePath} is a broken or dangling symlink. ` + + `Remove or fix the symlink so the ledger can be read normally.`, + 'ENOENT', + ); + } + // BC-1: distinguish a future/unsupported SCHEMA VERSION from genuine corruption. readLedgerRaw + // returns null both when the JSON is unparseable AND when it parses cleanly but carries a + // version string we do not support (currently only '1' exists). A version bump should surface a + // clear "unsupported schema version X" message, not a misleading "corrupt or invalid". This is a + // best-effort re-parse for the message only — the file is still LEFT IN PLACE. + // + // FIRST SCHEMA BUMP: when a v2 schema is introduced, ADD A MIGRATION BRANCH here (and in + // readLedgerRaw) — read the old shape, migrate it forward, and write the upgraded ledger — rather + // than throwing. Until then there are no v0/v2 ledgers in the wild (no released version wrote one), + // so blocking on an unknown version is the safe fail-closed behavior. + try { + // Finding 2 (HIGH): the reparse is ALSO a read of the untrusted ledger path — a FIFO/device or a + // file swapped after the first read must not block/bypass the cap here. Route it through the same + // bounded fd reader (a null/throw means there's nothing safely reparseable → fall through to the + // generic corrupt message). + const reparsedRaw = readSmallRegularFile(filePath, LEDGER_MAX_BYTES); + const reparsed: unknown = reparsedRaw === null ? null : JSON.parse(reparsedRaw); + if (typeof reparsed === 'object' && reparsed !== null) { + const ver = (reparsed as Record)['version']; + if (typeof ver === 'string' && ver !== LEDGER_SCHEMA_VERSION) { + throw new CorruptLedgerError( + `Capability ledger at ${filePath} uses unsupported ledger schema version "${ver}" ` + + `(this build supports version "${LEDGER_SCHEMA_VERSION}"). Upgrade GSD to a build that ` + + `understands this ledger, or move the file aside to start fresh.`, + filePath, + ); + } + } + } catch (reparseErr) { + // A CorruptLedgerError from the unsupported-version branch must propagate; any other error + // (re-read/parse failure) means it is genuinely corrupt — fall through to the generic message. + if (reparseErr instanceof CorruptLedgerError) throw reparseErr; + } + // File exists (not a symlink, not missing) but failed validation — throw. The file is + // intentionally LEFT IN PLACE so that every subsequent op is also blocked until the user + // resolves it (finding 1): auto-moving it would let the NEXT op proceed as fresh state + // → data-loss/orphan outcome. + // W-2: the recovery hint must be platform-aware — a POSIX `mv` with a forward-slash path is wrong + // on Windows (backslash paths, no `mv`). Show the native rename command for the running platform. + const moveHint = process.platform === 'win32' + ? `ren "${filePath}" "${path.basename(filePath)}.bak" (or PowerShell: Move-Item "${filePath}" "${filePath}.bak")` + : `mv "${filePath}" "${filePath}.bak"`; + throw new CorruptLedgerError( + `Capability ledger at ${filePath} is present but corrupt or invalid. ` + + `Inspect the file to recover your capability records, restore a known-good backup, ` + + `or move it aside to start fresh (e.g. ${moveHint}).`, + filePath, + ); +} + +/** W-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */ +const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); +const RENAME_MAX_ATTEMPTS = 3; +const RENAME_RETRY_BACKOFF_MS = 50; + +/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */ +let _renameSleepBuf: Int32Array | null = null; +function renameBackoff(): void { + if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS); +} + +/** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */ +const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']); + +/** + * fsync the directory CONTAINING `dest` so the just-completed rename is durable across a power loss + * (DUR-2). Some platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) — + * those are tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real + * storage error) is RETHROWN as a clear durability-uncertain error rather than silently swallowed; + * the rename may already be visible, so the caller must NOT claim success when durability could not + * be confirmed. The directory fd is always closed (finally). + */ +function fsyncContainingDir(dest: string): void { + let dirFd: number | null = null; + try { + dirFd = fs.openSync(path.dirname(dest), 'r'); + fs.fsyncSync(dirFd); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) { + // Real storage error (e.g. EIO): the rename may already be visible but its durability could + // NOT be confirmed. Rethrow rather than silently claim success (finding 4). + throw new Error( + `Directory fsync of "${path.dirname(dest)}" failed (${code}); durability of the ledger ` + + `rename could NOT be confirmed: ${(err as Error).message}`, + ); + } + /* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */ + } finally { + if (dirFd !== null) { try { fs.closeSync(dirFd); } catch { /* best-effort */ } } + } +} + +/** + * Write the ledger atomically AND durably (tmp file in the same dir → fsync → close → rename → + * dir fsync, no truncating fallback). Using a local implementation rather than platformWriteSync + * so that a crash or power-loss mid-write cannot produce a zero-byte / truncated ledger — the + * corrupt file that LEDGER-1 mishandled (ADR-1244 D4 fix). + * + * Durability sequence (DUR-1 / DUR-2): + * 1. writeFileSync(fd, content) — full-buffer write (no short-writes). + * 2. fsyncSync(fd) — flush the file's bytes to stable storage BEFORE the rename; + * otherwise a power-loss AFTER a successful rename can leave a + * zero/partial ledger (total loss). If fsync throws, the temp is + * unlinked and the error rethrown (treated as a write failure) — + * we NEVER rename a possibly-unflushed file live. + * 3. closeSync(fd) — a close error can also signal delayed-writeback failure; + * unlink the temp and rethrow before the rename. + * 4. renameSync(tmp, dest) — atomic install (retried on transient Windows AV locks, W-1). + * 5. fsyncSync(dirname fd) — make the rename itself durable (DUR-2). + * + * Security hardening (adversarial re-review): + * - Temp path includes a random nonce (not just pid) to avoid predictable names and resist + * collision between concurrent processes. + * - Temp file is created with the exclusive `wx` flag (O_EXCL) so a pre-planted symlink at the + * same path cannot redirect the write to another file. + * - On any failure (write, fsync, close, or rename) the temp file is cleaned up before + * rethrowing, and the primary error is always preserved (finding 13). */ function writeLedger(runtimeDir: string, ledger: LedgerFile): void { - platformWriteSync( - path.join(runtimeDir, LEDGER_FILE_NAME), - JSON.stringify(ledger, null, 2) + '\n', - ); + const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); + const content = JSON.stringify(ledger, null, 2) + '\n'; + fs.mkdirSync(runtimeDir, { recursive: true }); + // Unique nonce in the name prevents predictable-path attacks; wx (O_EXCL) prevents + // a pre-existing symlink from silently redirecting the write. + const nonce = crypto.randomBytes(4).toString('hex'); + const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`; + const fd = fs.openSync(tmpPath, 'wx'); // exclusive create — throws if already exists + let primaryErr: Error | null = null; + try { + // Write as a Buffer in one call to prevent short-writes (finding 6). + // fs.writeFileSync(fd, …) internally uses a write-all loop that flushes the + // entire buffer before returning, unlike a bare writeSync which may short-write. + fs.writeFileSync(fd, content); + // DUR-1: fsync the file's contents to stable storage BEFORE closing/renaming. Without this a + // power-loss after a successful rename can leave a zero/partial ledger → total loss. + fs.fsyncSync(fd); + } catch (err) { + primaryErr = err instanceof Error ? err : new Error(String(err)); + } finally { + // closeSync can also throw (finding 2): a close error on the write fd can signal + // delayed-writeback failure, meaning the data may not have been durably committed + // to storage. In that case we must NOT install the possibly-unflushed temp as the + // live ledger — unlink it and rethrow the close error before the rename. + let closeErr: Error | null = null; + try { fs.closeSync(fd); } catch (err) { closeErr = err instanceof Error ? err : new Error(String(err)); } + // If the write OR fsync failed, always clean up and rethrow that error (DUR-1). + if (primaryErr !== null) { + try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ } + throw primaryErr; + } + // Write+fsync succeeded but close threw — unlink the possibly-unflushed temp and rethrow + // the close error. NEVER proceed to rename a potentially unflushed file (finding 2). + if (closeErr !== null) { + try { fs.unlinkSync(tmpPath); } catch { /* best-effort — no orphan */ } + throw closeErr; + } + // Write, fsync, and close all succeeded — fall through to rename. + } + // W-1: renameSync can transiently fail on Windows when an AV scanner / file indexer holds a + // brief lock (EPERM/EBUSY/EACCES). Retry a few times with a short backoff before giving up. + let renameErr: Error | null = null; + for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) { + try { + fs.renameSync(tmpPath, filePath); + renameErr = null; + break; + } catch (err) { + renameErr = err instanceof Error ? err : new Error(String(err)); + const code = (err as NodeJS.ErrnoException).code ?? ''; + if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) { + renameBackoff(); + continue; + } + break; + } + } + if (renameErr !== null) { + // Clean up the orphaned temp file before rethrowing. + try { fs.unlinkSync(tmpPath); } catch { /* best-effort */ } + throw renameErr; + } + // DUR-2: make the rename durable by fsyncing the containing directory (best-effort). + fsyncContainingDir(filePath); } // --------------------------------------------------------------------------- @@ -116,15 +628,63 @@ function writeLedger(runtimeDir: string, ledger: LedgerFile): void { * If an entry with the same id already exists it is replaced. The `updatedAt` * timestamp is refreshed on every call. Rejects ids that would cause prototype * pollution (__proto__, constructor, prototype). + * + * Uses `readLedgerStrict` so that a corrupt-but-present ledger fails closed (throws + * CorruptLedgerError, leaving the file in place) rather than silently overwriting it. + * + * DOS-4: `opts.baseLedger` lets an IN-LOCK caller pass the ledger it has ALREADY strict-read this + * critical section so recordInstall does not redundantly re-read+re-validate it (install does up to + * three strict reads per op). It is ONLY safe when the caller holds the mutation lock (so the + * on-disk ledger cannot change underneath the passed snapshot) AND obtained it via readLedgerStrict + * (so corruption was already fail-closed). The standalone strict read remains the DEFAULT — omit + * `baseLedger` and the strict guarantee is unchanged. A null/missing baseLedger falls back to the + * strict read; a non-object baseLedger is rejected. */ -function recordInstall(runtimeDir: string, entry: LedgerEntry): void { - // Prototype-pollution guard — inline literal checks (CodeQL-safe pattern). - if (entry.id === '__proto__' || entry.id === 'constructor' || entry.id === 'prototype') { - // Silently ignore — the id is invalid and must never reach the ledger. - return; +function recordInstall( + runtimeDir: string, + entry: LedgerEntry, + opts?: { baseLedger?: LedgerFile | null }, +): void { + // ROOT FIX 3: reject ALL unsafe ids with a throw (not silent return) — this includes + // prototype-pollution keys AND non-kebab ids. Using isUnsafeCapabilityId (which uses + // inline literal === checks — CodeQL-safe pattern) as the single gate. + if (isUnsafeCapabilityId(entry.id)) { + throw new Error( + `Invalid capability id "${entry.id}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` + + `Unsafe or non-kebab ids are rejected to prevent prototype pollution and ledger corruption.`, + ); + } + + // ROOT FIX 3 (finding 3): validate the WHOLE entry — not just entry.id — against the single + // per-entry validator. Otherwise recordInstall could write a structurally-invalid entry (e.g. + // files:[123] or a malformed sharedEdits member) that every subsequent readLedger/readLedgerStrict + // would then reject as corrupt — turning a bad write into a persistent self-inflicted lockout. + // Validating here makes recordInstall fail FAST (throw, write nothing) on a malformed entry. + if (!isValidLedgerEntry(entry.id, entry)) { + throw new Error( + `Refusing to record a structurally-invalid ledger entry for "${entry.id}": the entry fails ` + + `the ledger schema (check files[]/sharedEdits[]/version/source/integrity types). ` + + `Writing it would corrupt the ledger so every later read rejects it.`, + ); + } + + // DOS-4 + finding 5 (LOW): use the caller-supplied in-lock base ONLY when it passes the SAME + // validation a strict read would (version, updatedAt, entry-count cap, and every entry via + // isValidLedgerEntry). Previously the base was accepted on a shallow `entries is an object` check + // and written VERBATIM — so a caller passing an invalid base (bad version/updatedAt, or a malformed + // entry) would write a self-corrupting ledger that every later read rejects. Now an INVALID base is + // ignored and we fall back to the strict read (the default, unchanged strict guarantee), so the + // ledger is only ever derived from validated state. + let existing: LedgerFile | null; + const base = opts?.baseLedger; + if (base !== undefined && base !== null && isValidLedgerFile(base)) { + existing = base; + } else { + // readLedgerStrict: returns null when missing, parsed ledger when valid, + // throws CorruptLedgerError (leaving file in place) when present-but-corrupt. + existing = readLedgerStrict(runtimeDir); } - const existing = readLedger(runtimeDir); const ledger: LedgerFile = existing ?? { version: LEDGER_SCHEMA_VERSION, updatedAt: new Date().toISOString(), @@ -140,11 +700,17 @@ function recordInstall(runtimeDir: string, entry: LedgerEntry): void { /** * Remove a single capability entry from the ledger by id. * - * Returns true if the entry was present and removed, false if not found. + * Returns true if the entry was present and removed, false if GENUINELY not found. + * + * Finding 4 (fail-closed): uses `readLedgerStrict` (not the non-throwing `readLedger`) so a + * corrupt-but-present ledger THROWS (CorruptLedgerError / LedgerIOError, file left in place) + * rather than returning false. Returning false on corruption would let a corrupt ledger + * masquerade as "entry not installed" — a silent no-op that hides recorded state. `false` is + * now reserved exclusively for a genuinely-missing ledger or a genuinely-absent entry. */ function removeEntry(runtimeDir: string, capId: string): boolean { - const ledger = readLedger(runtimeDir); - if (ledger === null) return false; + const ledger = readLedgerStrict(runtimeDir); // throws on corrupt-present / IO error (fail-closed) + if (ledger === null) return false; // genuinely missing ledger — nothing installed if (!Object.prototype.hasOwnProperty.call(ledger.entries, capId)) return false; delete ledger.entries[capId]; ledger.updatedAt = new Date().toISOString(); @@ -179,7 +745,21 @@ function reconcile(runtimeDir: string): ReconcileResult { const ledger = readLedger(runtimeDir); if (ledger === null) { const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); - if (fs.existsSync(filePath)) { + // Finding 5: use lstatSync (not existsSync) to detect the directory ENTRY itself. existsSync + // FOLLOWS the symlink and returns false for a dangling/broken symlink — so a ledger that is a + // broken symlink would be reported "missing" (no warning) when it is actually an unreadable IO + // problem. lstatSync stats the entry without following it: any entry present (even a broken + // symlink) is NOT "missing" and must surface a warning. + let entryExists = false; + try { + fs.lstatSync(filePath); + entryExists = true; + } catch (lstatErr) { + // ENOENT — genuinely absent: nothing installed, not a warning. Any other error (EACCES, + // EPERM, …) means the entry is present-but-unreadable → treat as a parse/IO warning. + if ((lstatErr as NodeJS.ErrnoException).code !== 'ENOENT') entryExists = true; + } + if (entryExists) { result.warnings.push(`Ledger file exists but could not be parsed: ${filePath}`); } // Missing ledger is not a warning — it simply means nothing has been installed. @@ -218,10 +798,20 @@ function reconcile(runtimeDir: string): ReconcileResult { export = { readLedger, + readLedgerStrict, writeLedger, recordInstall, removeEntry, reconcile, + isValidLedgerEntry, + isUnsafeCapabilityId, + // Finding 2 (HIGH): the SINGLE shared bounded fd reader — also consumed by capability-lifecycle's + // lock-body reads so every untrusted file read goes through the regular-file + size-capped fd path. + readSmallRegularFile, // Exported for testing / introspection LEDGER_FILE_NAME, + CorruptLedgerError, + LedgerIOError, + // DoS backstop bounds — shared with the lifecycle/CLI early count check (finding 5). + MAX_SHARED_FILES, }; diff --git a/src/capability-lifecycle.cts b/src/capability-lifecycle.cts index 5341bcafd..1b8637dc0 100644 --- a/src/capability-lifecycle.cts +++ b/src/capability-lifecycle.cts @@ -21,6 +21,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import crypto from 'node:crypto'; +import os from 'node:os'; /* eslint-disable @typescript-eslint/no-require-imports */ const sourceMod = require('./capability-source.cjs') as { @@ -32,9 +34,18 @@ const sourceMod = require('./capability-source.cjs') as { }; const ledgerMod = require('./capability-ledger.cjs') as { readLedger: (runtimeDir: string) => LedgerFile | null; - recordInstall: (runtimeDir: string, entry: LedgerEntry) => void; + readLedgerStrict: (runtimeDir: string) => LedgerFile | null; + writeLedger: (runtimeDir: string, ledger: LedgerFile) => void; + recordInstall: (runtimeDir: string, entry: LedgerEntry, opts?: { baseLedger?: LedgerFile | null }) => void; removeEntry: (runtimeDir: string, capId: string) => boolean; reconcile: (runtimeDir: string) => unknown; + isUnsafeCapabilityId: (id: unknown) => boolean; + CorruptLedgerError: new (message: string, ledgerPath: string) => Error & { ledgerPath: string }; + LEDGER_FILE_NAME: string; + MAX_SHARED_FILES: number; + // Finding 2 (HIGH): the shared fd-based bounded reader. Returns the content, null for ENOENT, or + // THROWS for a non-regular (FIFO/device/dir) / oversized / IO-error file (fail closed). + readSmallRegularFile: (filePath: string, maxBytes: number) => string | null; }; const trustMod = require('./capability-trust.cjs') as { evaluateInstallTrust: (args: Record) => InstallTrustVerdict; @@ -45,8 +56,13 @@ const trustMod = require('./capability-trust.cjs') as { strict: string[] | null | undefined, ) => { allowed: boolean; reason: string | null }; }; -const { platformWriteSync } = require('./shell-command-projection.cjs') as { +const { platformWriteSync, execTool } = require('./shell-command-projection.cjs') as { platformWriteSync: (filePath: string, content: string) => void; + execTool: ( + program: string, + args: string[], + opts?: { cwd?: string; env?: Record; timeout?: number }, + ) => { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null }; }; /* eslint-enable @typescript-eslint/no-require-imports */ @@ -149,78 +165,589 @@ function capDataDir(runtimeDir: string, id: string): string { return path.join(runtimeDir, '.gsd', 'capability-data', id); } +/** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */ +const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']); + +/** + * fsync a DIRECTORY so a rename inside it is durable across a power loss (DUR-2/DUR-3). Some + * platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) — those are + * tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real storage error) + * is RETHROWN as a clear durability-uncertain error rather than silently swallowed; the rename may + * already be visible, so the caller must NOT claim success when durability could not be confirmed. + * The directory fd is always closed (finally). + */ +function fsyncDir(dirPath: string): void { + let fd: number | null = null; + try { + fd = fs.openSync(dirPath, 'r'); + fs.fsyncSync(fd); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + // openSync itself failing (e.g. dir vanished) is also non-fatal best-effort UNLESS it's a real + // storage error; treat tolerated errnos (and a missing code) as best-effort, rethrow the rest. + if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) { + throw new Error( + `Directory fsync of "${dirPath}" failed (${code}); durability of the preceding rename ` + + `could NOT be confirmed: ${(err as Error).message}`, + ); + } + /* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */ + } finally { + if (fd !== null) { try { fs.closeSync(fd); } catch { /* best-effort */ } } + } +} + +/** + * Build a collision-resistant backup-dir name for `id` (CONC-3). Two processes upgrading the same + * capability in the same millisecond would otherwise produce identical `.upgrading--` + * names; the random nonce eliminates that collision. The name still matches BACKUP_NAME_RE so a + * recorded intent can find the backup after a crash. + */ +function newBackupName(id: string): string { + return `${id}.upgrading-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; +} + // --------------------------------------------------------------------------- // Cross-process mutual exclusion // --------------------------------------------------------------------------- -/** A lock older than this is presumed stale (holder crashed) and may be stolen. */ +/** + * A lock older than this is a CANDIDATE for stealing (the holder may have crashed). A same-host + * lock past this age whose recorded pid is DEAD is stolen immediately (fast local recovery). + */ const LOCK_STALE_MS = 60_000; +/** + * HARD deadman timeout (finding 1). A lock older than this is stolen REGARDLESS of pid liveness or + * host. This is the only thing that can break a permanent deadlock caused by: + * - PID REUSE: a crashed holder's pid reused by an unrelated long-lived process makes + * `isPidAlive` return true forever, so the dead-pid fast-recovery branch never fires. + * - CROSS-HOST (NFS): a remote holder's pid is meaningless to local `process.kill(pid,0)`, so + * liveness cannot be judged at all — only the deadman can reclaim such a lock. + * Much larger than LOCK_STALE_MS so a genuinely slow-but-live SAME-host holder is given a wide grace + * window (it is protected by the same-host liveness check until then); 10 minutes is far longer than + * any real sub-second capability fs critical section. + */ +const LOCK_DEADMAN_MS = 600_000; /** A `.staging/*` dir younger than this may belong to an in-flight resolve; do not sweep it. */ const STAGING_ORPHAN_MS = 600_000; +/** A `.gsd-capabilities.json.tmp.*` temp younger than this may belong to an in-flight write; spare it (W-3/DUR-5). */ +const LEDGER_TMP_ORPHAN_MS = 300_000; /** Valid capability id (kebab-case). Used to reject tampered ledger keys before acting on them. */ const KEBAB_ID_RE = /^[a-z][a-z0-9-]*$/; +/** + * Finding 2 (HIGH): the lockfile body is UNTRUSTED content. A well-formed lock body is a tiny JSON + * object (a few hundred bytes at most). The body is read via the shared fd-based bounded reader + * (ledgerMod.readSmallRegularFile): open → fstat → require a REGULAR file (reject FIFO/device/dir, + * which could block/read-unbounded) → enforce this size cap on the fstat → read exactly size bytes. + * A non-regular/oversized body is treated as UNPARSEABLE (no pid/host) → routed to the deadman policy + * (cannot verify liveness → steal only after the deadman). 64 KiB is orders of magnitude larger than + * any legitimate lock body. + */ +const LOCK_MAX_BODY_BYTES = 64 * 1024; -/** A held lock: the lockfile path plus the unique OWNER TOKEN we wrote into it. */ -interface LockHandle { path: string; token: string; } +/** + * A held lock: the lockfile path, the unique OWNER TOKEN we wrote into it, and the (dev, ino) of the + * lockfile inode captured at acquire (finding 4). releaseLock re-confirms BOTH the token AND the + * captured dev/ino still match the path on disk immediately before rmSync, so a successor lock that + * replaced ours at the same path (different inode) is never deleted. dev/ino are null when the post- + * create stat could not be taken (best-effort) — then release falls back to the token check alone. + */ +interface LockHandle { path: string; token: string; dev: number | null; ino: number | null; } let _lockSeq = 0; -/** A per-acquire unique token so release is owner-safe (never deletes a successor's lock). */ +/** + * A per-acquire unique token so release is owner-safe (never deletes a successor's lock). The FIRST + * `-`-delimited segment is the holder PID — acquireLock parses it back out to check liveness before + * stealing a stale lock (CONC-1). + */ function newLockToken(): string { return `${process.pid}-${Date.now()}-${++_lockSeq}`; } +/** Bounded steal/retry attempts so a pathological never-acquirable lock cannot recurse forever (CONC-2). */ +const LOCK_MAX_ATTEMPTS = 8; +const LOCK_RETRY_BACKOFF_MS = 25; +let _lockSleepBuf: Int32Array | null = null; +function lockBackoff(): void { + // Small jittered backoff between steal attempts (yields the thread via Atomics.wait). + if (_lockSleepBuf === null) _lockSleepBuf = new Int32Array(new SharedArrayBuffer(4)); + const jitter = Math.floor(Math.random() * LOCK_RETRY_BACKOFF_MS); + Atomics.wait(_lockSleepBuf, 0, 0, LOCK_RETRY_BACKOFF_MS + jitter); +} + +/** + * Parse the holder PID from a legacy plain-token lockfile body (the first `-`-delimited segment). + * Returns null when the body has no numeric leading segment (e.g. JSON content, or legacy no-pid). + */ +function lockHolderPid(body: string): number | null { + const seg = body.split('-')[0]; + if (!/^\d+$/.test(seg)) return null; + const pid = Number(seg); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + +/** + * Parsed view of a lockfile body. `hostname` is null for a legacy lock (no hostname was recorded + * before finding 1) — a null hostname is treated as SAME-host (conservative, backward compatible: + * legacy locks were always same-machine since the lock predates cross-host concerns). `startTime` + * is the holder process's recorded start-time (finding 1, process-start-time liveness); null for a + * legacy lock or one whose body did not record it — a null recorded start-time cannot be matched, so + * liveness cannot be verified and the holder is treated as NOT verified-live (steal-eligible). + */ +interface ParsedLock { pid: number | null; hostname: string | null; startTime: string | null; ts: number | null; } + +/** + * Parse a lockfile body into { pid, hostname, startTime, ts }. The new format (finding 1) is JSON + * `{ token, pid, hostname, startTime, ts }`; a legacy body is a plain `pid-ts-seq` token (or + * non-numeric junk). Never throws — unparseable content yields all-null. + * + * Finding 1 (HIGH) — lock-steal TOCTOU: `ts` is the body's OWN recorded timestamp. The age decision + * is bound to `now - ts` (a FRESH replacement body carries a FRESH ts → small age → not stolen), NOT + * to the file `mtime` (which a stale-old `mtime` on a freshly-replaced body would mis-report). `ts` is + * also the per-body identity re-checked immediately before the atomic rename-steal. A legacy/no-`ts` + * body yields ts:null and the caller falls back to the file `mtime` age. + */ +function parseLockBody(body: string): ParsedLock { + const trimmed = body.trim(); + if (trimmed.startsWith('{')) { + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const p = parsed as Record; + const pidVal = p['pid']; + const pid = typeof pidVal === 'number' && Number.isInteger(pidVal) && pidVal > 0 ? pidVal : null; + const hostVal = p['hostname']; + const hostname = typeof hostVal === 'string' && hostVal ? hostVal : null; + const stVal = p['startTime']; + const startTime = typeof stVal === 'string' && stVal ? stVal : null; + const tsVal = p['ts']; + const ts = typeof tsVal === 'number' && Number.isFinite(tsVal) ? tsVal : null; + return { pid, hostname, startTime, ts }; + } + } catch { /* fall through to legacy parse */ } + } + // Legacy plain-token body: hostname/startTime/ts were never recorded → null (treated as same-host, + // unverifiable liveness, mtime-age fallback). + return { pid: lockHolderPid(trimmed), hostname: null, startTime: null, ts: null }; +} + +/** + * Finding 1 (HIGH) — future/implausible `ts` deadlock. Derive the lock AGE (ms) from the body's own + * `ts` when that ts is TRUSTWORTHY, else fall back to the file `mtime`. A `ts` is distrusted when it is + * in the FUTURE (now - ts < 0 — a planted body or a clock-skewed/back-stepped writer) or implausibly + * far in the future (small forward skew is tolerated, but a `ts` more than the deadman ahead of now is + * nonsense). A trusted future `ts` would keep `age = now - ts <= LOCK_STALE_MS` forever, so the lock + * would never become stale/deadman/steal-eligible → permanent block. Falling back to `mtime` keeps + * stale/deadman recovery working (a future mtime is far less likely, and the deadman still bounds it). + * A null `ts` (legacy/garbage/no-ts body) also uses the `mtime` age. + */ +function lockAgeMs(ts: number | null, mtimeMs: number): number { + if (ts !== null) { + const age = Date.now() - ts; + // Trust the body ts ONLY when it is not in the future and not implausibly far ahead. A small + // forward clock skew (age slightly negative) is rejected too — any future ts is distrusted. + if (age >= 0 && age <= Number.MAX_SAFE_INTEGER) return age; + } + // MEDIUM finding: if mtime is ALSO in the future (planted lock, clock stepped backward after write), + // `Date.now() - mtimeMs` is negative → age <= LOCK_STALE_MS forever → permanent deadlock. A mtime + // MORE than LOCK_STALE_MS / 2 in the future is untrustworthy (planted or a significant clock step); + // return MAX_SAFE_INTEGER so the lock routes into the normal steal decision tree (verified-live + // same-host holders are still protected there — that check is age-independent). A small negative + // (sub-second jitter from filesystem timestamp precision) is clamped to 0 (treat as brand-new / fresh) + // rather than MAX_SAFE_INTEGER, so a lock written and immediately stat'd is never mis-stolen. + const mtimeAge = Date.now() - mtimeMs; + if (mtimeAge >= 0) return mtimeAge; + // mtimeAge is negative → mtime is in the future. Small jitter (within LOCK_STALE_MS / 2, i.e. 30s) + // → clamp to 0 (fresh, conservative). Large future (> 30s) → untrustworthy → MAX_SAFE_INTEGER. + return mtimeAge >= -(LOCK_STALE_MS / 2) ? 0 : Number.MAX_SAFE_INTEGER; +} + +/** Is the parsed lock from THIS host? A null (legacy) hostname is treated as same-host. */ +function isSameHost(parsed: ParsedLock): boolean { + return parsed.hostname === null || parsed.hostname === os.hostname(); +} + +/** + * Best-effort process start-time for `pid`, as an OPAQUE platform-specific string used ONLY for + * equality comparison (never parsed as a date). The pair (pid, startTime) uniquely identifies a + * process instance: even if a crashed holder's pid is REUSED by an unrelated process, the new + * process's start-time differs, so a recorded start-time that no longer matches proves pid-reuse. + * + * Platform handling (all bounded — the shell-outs only run on the rare STEAL-decision path, never the + * happy path): + * - Linux: read `/proc//stat` field 22 (starttime, in clock ticks since boot). No shell-out. + * Field 2 (comm) may contain spaces/parens, so we split AFTER the last ')' to index reliably. + * - macOS/other POSIX: `ps -p -o lstart=` via the bounded execTool seam (process start + * wall-clock; stable for a given live process). + * - Windows: PowerShell `(Get-Process -Id ).StartTime.Ticks` via the bounded execTool seam. + * Returns null on ANY error / unobtainable value — a null observed start-time means liveness cannot + * be VERIFIED (so the holder is treated as not-verified-live → steal-eligible past the deadman). + */ +function getProcessStartTime(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + try { + if (process.platform === 'linux') { + // Field 22 is `starttime`. comm (field 2) is wrapped in parens and may itself contain spaces + // and ')'; everything after the LAST ')' is space-delimited and stable to index. + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const rparen = stat.lastIndexOf(')'); + if (rparen === -1) return null; + const rest = stat.slice(rparen + 1).trim().split(/\s+/); + // After comm, fields are state(0) ppid(1) ... starttime is field 22 overall → index 19 of rest. + const starttime = rest[19]; + return typeof starttime === 'string' && /^\d+$/.test(starttime) ? starttime : null; + } + if (process.platform === 'win32') { + const res = execTool( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.Ticks`], + { timeout: 5_000 }, + ); + if (res.exitCode !== 0 || res.error) return null; + const out = res.stdout.trim(); + return /^\d+$/.test(out) ? out : null; + } + // macOS and other POSIX: ps lstart is the process's start wall-clock (stable per live process). + const res = execTool('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 5_000 }); + if (res.exitCode !== 0 || res.error) return null; + const out = res.stdout.trim(); + return out ? out : null; + } catch { + return null; + } +} + +/** + * THIS process's start-time, captured ONCE at module load so we never re-shell on every lock write + * (the happy path stamps it from this cached value). Best-effort — null if unobtainable here. + */ +const _selfStartTime: string | null = getProcessStartTime(process.pid); + +/** + * Serialize the lockfile body (finding 1): JSON carrying the owner token, pid, hostname, this + * process's cached start-time, and a timestamp. startTime lets a later acquirer verify the recorded + * holder is still the SAME process instance (defeats pid-reuse) without ever re-shelling here. + */ +function lockFileBody(token: string): string { + return JSON.stringify({ token, pid: process.pid, hostname: os.hostname(), startTime: _selfStartTime, ts: Date.now() }); +} + +/** + * Test seams (finding 1): the steal-decision path goes through these indirections so unit tests can + * mock liveness + process start-time DETERMINISTICALLY (without depending on real OS pids beyond the + * current process). The defaults are the real implementations. `_setLockProbes`/`_resetLockProbes` + * are exported for tests ONLY — they are not part of the CLI surface. + */ +const _lockProbes: { + isPidAlive: (pid: number) => boolean; + getProcessStartTime: (pid: number) => string | null; +} = { isPidAlive: _realIsPidAlive, getProcessStartTime }; + +/** Is `pid` a live process? `process.kill(pid, 0)` succeeds for a live (signalable) process. */ +function _realIsPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; // signalable → alive + } catch (err) { + // EPERM means the process exists but we cannot signal it (still ALIVE). ESRCH means it's gone. + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function isPidAlive(pid: number): boolean { + return _lockProbes.isPidAlive(pid); +} + +/** + * Finding 2 (HIGH): parse the lockfile body via the SHARED fd-based bounded reader. The body is + * untrusted: a FIFO/device/symlink-to-device `.lock` (or a swapped/grown file) would block or read + * unbounded under a path-`stat`+`readFileSync`; an oversized/garbage body is a memory DoS. The + * shared reader (open → fstat → require regular file → size cap → read exactly size) returns null for + * a non-regular/oversized/IO body (it throws → we swallow), routing the holder to the deadman policy + * (no verifiable pid/host/startTime → steal only after the deadman). A normal small body is read and + * parsed. Never throws. + */ +function readParsedLockBounded(lockPath: string): ParsedLock { + const allNull: ParsedLock = { pid: null, hostname: null, startTime: null, ts: null }; + try { + const body = ledgerMod.readSmallRegularFile(lockPath, LOCK_MAX_BODY_BYTES); + if (body === null) return allNull; // vanished/missing — cannot verify anything. + return parseLockBody(body); + } catch { + // Non-regular (FIFO/device/dir), oversized, or unreadable untrusted body → unparseable. + return allNull; + } +} + +/** + * Finding 1 (HIGH): the per-body IDENTITY used to confirm, immediately before the atomic rename-steal, + * that the lock A decided to steal is STILL the same body instance (B did not replace it). Binds + * (dev, ino) from a fresh stat AND the body's own `ts` (when JSON). A null on any field means we could + * not read it (vanished/non-regular/oversized) — the caller treats that as "changed" and retries + * rather than stealing. Never throws. + */ +interface LockIdentity { dev: number | null; ino: number | null; ts: number | null; } +function lockIdentity(lockPath: string): LockIdentity { + let dev: number | null = null; + let ino: number | null = null; + try { + const st = fs.statSync(lockPath); + dev = typeof st.dev === 'number' ? st.dev : null; + ino = typeof st.ino === 'number' ? st.ino : null; + } catch { + return { dev: null, ino: null, ts: null }; // vanished/unstatable — treat as changed. + } + // ts comes from the (bounded) body; null for a legacy/no-ts body — then only dev/ino gate the steal. + const ts = readParsedLockBounded(lockPath).ts; + return { dev, ino, ts }; +} + +/** + * Two lock identities refer to the SAME body instance only when dev AND ino match AND the `ts` is + * unchanged. A null dev/ino on EITHER side (unreadable/vanished) is treated as a CHANGE (fail-safe: + * do not steal). A null `ts` on BOTH sides (legacy bodies) does not block the match — dev/ino carry it. + * + * Finding 3 (LOW): if the DECISION body (a) had a non-null JSON `ts`, the recheck body (b) MUST carry + * the SAME non-null `ts`. A recheck `ts` that is now null/absent (the body was rewritten to no-ts or + * garbage on the same inode) is NOT the same instance — treating it as "same" would contradict the + * "ts re-confirmed before steal" invariant and let A steal a body it can no longer identify. So a + * disappearing ts (a.ts !== null && b.ts === null) is a CHANGE → do not steal, retry. + */ +function sameLockInstance(a: LockIdentity, b: LockIdentity): boolean { + if (a.dev === null || a.ino === null || b.dev === null || b.ino === null) return false; + if (a.dev !== b.dev || a.ino !== b.ino) return false; + // If the decision body recorded a ts, it must STILL be present AND unchanged on recheck. A fresh + // replacement body carries a fresh ts (mismatch); a no-ts/garbage rewrite drops it (now null) — + // either way the body changed under us → not the same instance. + if (a.ts !== null && a.ts !== b.ts) return false; + return true; +} + +/** + * Is the recorded SAME-host holder VERIFIED-LIVE (finding 1, process-start-time)? True ONLY when ALL + * hold: the pid signals alive AND the lock recorded a non-null start-time AND the pid's CURRENT + * observed start-time matches that recorded value. Any failure — dead pid, no recorded start-time, + * unobtainable current start-time, or a MISMATCH (= pid-reuse: the pid is alive but belongs to a + * different process instance now) — means NOT verified-live, so the holder may be stolen. This is the + * crux that defeats pid-reuse WITHOUT ever stealing a genuinely-live holder. + */ +function holderVerifiedLive(parsed: ParsedLock): boolean { + if (parsed.pid === null) return false; + if (!isPidAlive(parsed.pid)) return false; + if (parsed.startTime === null) return false; + const observed = _lockProbes.getProcessStartTime(parsed.pid); + if (observed === null) return false; + return observed === parsed.startTime; +} + /** * Acquire an exclusive capability-mutation lock (a single lockfile created with O_EXCL), stamping - * a unique owner token. Returns a LockHandle on success, or null if another live operation holds - * it. A lock older than LOCK_STALE_MS is presumed abandoned and stolen ATOMICALLY (rename-then- - * recreate, so two racing processes cannot both win the steal — only one can rename the inode). - * Serializing install/upgrade/remove/reconcile closes the race where a concurrent reconcile clears - * a just-written, not-yet-swapped intent. + * a JSON body that records a unique owner token, our PID, our HOSTNAME, our process START-TIME, and a + * timestamp. Returns a LockHandle on success, or null if another LIVE operation holds it. + * + * Steal protocol (finding 1 — process-start-time liveness; never deadlocks AND never steals a + * verified-live SAME-host holder). The age is bound to the BODY instance A acts on — `age = now - + * body.ts` for a JSON body (a fresh replacement body carries a fresh ts), falling back to `now - + * mtime` for a legacy/no-`ts` body — and the (dev, ino, ts) identity is re-confirmed immediately + * before the rename so A can never steal a fresh lock B swapped in mid-decision (lock-steal TOCTOU): + * - age <= LOCK_STALE_MS → FRESH: never stolen (genuinely held → blocked). + * - age > LOCK_STALE_MS: + * · SAME host: compute live = pid alive AND recorded startTime present AND observed + * startTime === recorded startTime. If VERIFIED-LIVE → NEVER steal (blocked) — even past the + * deadman; a provably-live holder is sacrosanct. If NOT verified-live (pid dead, start-time + * mismatch = pid-reuse, or start-time unobtainable) → STEAL (fast local recovery). + * · DIFFERENT host, or no parseable pid (legacy/oversized/garbage body) → liveness cannot be + * verified at all → steal ONLY after age > LOCK_DEADMAN_MS (the deadman fallback). Under the + * deadman such a lock is left in place (blocked). + * + * Why this is the convergent design: an age-only rule lost-updates a live holder; a pid-liveness rule + * deadlocks forever on pid-reuse (a reused pid looks alive); a deadman rule can steal a live holder + * before the deadman. The (pid, start-time) pair uniquely identifies a process INSTANCE, so a reused + * pid is detected as a start-time MISMATCH and stolen, while a verified-live holder is never stolen. + * + * The steal itself is atomic (rename-then-recreate, so only ONE racing process can rename the + * inode), and the whole thing is a BOUNDED iterative loop (CONC-2/DOS-1) — no unbounded recursion. */ function acquireLock(runtimeDir: string): LockHandle | null { const root = capabilitiesRoot(runtimeDir); try { fs.mkdirSync(root, { recursive: true }); } catch { /* best-effort */ } const lockPath = path.join(root, '.lock'); - const token = newLockToken(); - try { - const fd = fs.openSync(lockPath, 'wx'); // exclusive create — fails if held - try { fs.writeSync(fd, token); } finally { fs.closeSync(fd); } - return { path: lockPath, token }; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return null; - // Held — steal only if stale, and do it atomically: rename the stale lock aside (only ONE - // racing process can rename a given inode; the loser gets ENOENT and backs off), then recreate. + + for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) { + const token = newLockToken(); + try { + const fd = fs.openSync(lockPath, 'wx'); // exclusive create — fails if held + // Finding 3 (LOW): once the exclusive create SUCCEEDS, a writeSync/closeSync failure must NOT + // leave the empty `.lock` behind — an orphan body self-blocks every later acquirer until the + // deadman. On any write/close error, best-effort unlink the file we just created and return null. + // Finding 2 (MEDIUM): use fs.writeFileSync(fd, body) — its internal write-all loop flushes the + // WHOLE buffer (no short-write), unlike a bare fs.writeSync(fd, …) which may write fewer bytes + // and leave a malformed body whose token releaseLock can never match (orphan until the deadman). + // Mirrors the writeLedger short-write fix. + try { + fs.writeFileSync(fd, lockFileBody(token)); + } catch (writeErr) { + try { fs.closeSync(fd); } catch { /* best-effort */ } + try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ } + throw writeErr; + } + try { + fs.closeSync(fd); + } catch (closeErr) { + try { fs.unlinkSync(lockPath); } catch { /* best-effort — no orphan */ } + throw closeErr; + } + // Finding 4 (LOW): capture the lock inode's (dev, ino) so releaseLock can confirm, immediately + // before rmSync, that the path still holds OUR inode (not a successor's) — minimizing the + // check-then-unlink window. Best-effort: a null dev/ino just falls back to the token check. + let dev: number | null = null; + let ino: number | null = null; + try { + const lst = fs.statSync(lockPath); + dev = typeof lst.dev === 'number' ? lst.dev : null; + ino = typeof lst.ino === 'number' ? lst.ino : null; + } catch { /* best-effort — release falls back to the token check alone */ } + return { path: lockPath, token, dev, ino }; + } catch (err) { + // EEXIST → held (fall through to the steal decision). Any other error here is either the + // create failing for a real reason OR a write/close failure we already cleaned up → bail out. + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return null; + } + // Held — decide whether to steal. let st: fs.Stats; - try { st = fs.statSync(lockPath); } catch { return acquireLock(runtimeDir); } - if (Date.now() - st.mtimeMs <= LOCK_STALE_MS) return null; // genuinely held - const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}`; + try { + st = fs.statSync(lockPath); + } catch { + // Lock vanished between open and stat — retry the create immediately. + continue; + } + + // Finding 1 (HIGH) — bind the age decision to the SAME body instance A acts on. Parse the + // (bounded) body ONCE; derive age from the body's own `ts` (now - ts) for a JSON body so a FRESH + // replacement body (fresh ts) is correctly seen as fresh even if the file `mtime` is stale-old. + // A legacy/garbage/no-`ts` body — AND a FUTURE/implausible `ts` (see lockAgeMs) — falls back to + // the file `mtime` age so a planted/clock-skewed future ts can never deadlock the lock forever. + const parsed = readParsedLockBounded(lockPath); + const age = lockAgeMs(parsed.ts, st.mtimeMs); + if (age <= LOCK_STALE_MS) return null; // genuinely held (fresh) — blocked. + + // Capture the identity (dev/ino + body ts) of the EXACT body the steal decision is made against, + // so we can confirm it is UNCHANGED immediately before the rename-steal (finding 1). + const decisionIdentity: LockIdentity = { + dev: typeof st.dev === 'number' ? st.dev : null, + ino: typeof st.ino === 'number' ? st.ino : null, + ts: parsed.ts, + }; + + if (isSameHost(parsed) && parsed.pid !== null) { + // SAME host with a parseable pid → we CAN verify liveness via the (pid, start-time) pair. + // A VERIFIED-LIVE holder is NEVER stolen — even past the deadman. Otherwise (dead pid, + // start-time mismatch = pid-reuse, or start-time unobtainable) → steal (fast local recovery). + if (holderVerifiedLive(parsed)) return null; // provably-live same-host holder — blocked. + // else fall through to the atomic steal. + } else { + // DIFFERENT host, or no parseable pid (legacy / oversized / garbage body) → liveness cannot be + // verified locally. Only the deadman can reclaim it; under the deadman, leave it (blocked). + if (age <= LOCK_DEADMAN_MS) return null; + // else (age > deadman) → fall through to the atomic steal. + } + + // Finding 1 (HIGH): re-stat + re-read the body IMMEDIATELY before the rename and confirm it is the + // SAME instance (dev/ino unchanged AND, for a JSON body, ts unchanged). If B stole+recreated a + // FRESH lock between A's decision and now, the identity differs → do NOT steal B's fresh lock; + // RETRY the bounded loop instead. The rename itself remains the atomic single-winner. + if (!sameLockInstance(decisionIdentity, lockIdentity(lockPath))) { + if (attempt + 1 < LOCK_MAX_ATTEMPTS) lockBackoff(); + continue; // the body changed under us — re-evaluate from scratch rather than steal a replacement. + } + + // Steal atomically (only one racer can rename the inode). + const stolen = `${lockPath}.stale-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; try { fs.renameSync(lockPath, stolen); } catch { return null; } // another process won the steal try { fs.rmSync(stolen, { force: true }); } catch { /* best-effort */ } - return acquireLock(runtimeDir); + // Loop and retry the create (bounded — no recursion, CONC-2). Brief backoff to de-sync racers. + if (attempt + 1 < LOCK_MAX_ATTEMPTS) lockBackoff(); } + return null; // attempt budget exhausted (pathological contention) — never throws/recurses. } /** - * Release a lock only if it still carries our owner token, so the common path never deletes a - * lock that was stale-stolen out from under us (its file now holds the successor's token). + * Release a lock only if it still carries our owner token (PRIMARY discriminator) — and, as a best- + * effort SECONDARY check, if its inode still matches the (dev, ino) we captured at acquire (finding 4), + * so the common path never deletes a lock that was stale-stolen out from under us. * - * Accepted residual: a check-then-unlink TOCTOU remains, but it is only REACHABLE when a holder is - * BOTH stale (older than LOCK_STALE_MS) AND still alive to call release — i.e. a live process that - * froze for >60s in the middle of a sub-second fs critical section (a crashed holder never calls - * release; a normal holder finishes in milliseconds). A rename-claim variant was tried but merely - * moves the same window (the restore step can clobber a third acquirer — Codex R6). A truly - * race-free cross-process mutex needs OS advisory locks (flock), which Node core does not expose. - * This lock is same-user, same-machine DEFENSE-IN-DEPTH; it is NOT the trust barrier (that is - * consent + integrity + reversibility, see the trust-model doc), and the residual crosses no - * privilege boundary — the same disposition accepted for safeRmUnder's parent TOCTOU. + * The TOKEN re-check is what actually protects a successor: a real successor wrote a DIFFERENT owner + * token, so we read a non-matching token and refuse to delete — this holds on every filesystem. The + * dev/ino recheck is only a best-effort secondary guard: it may be DEFEATED by inode reuse on some + * filesystems (e.g. Linux ext4/overlay reusing the freed inode after a successor's unlink+recreate at + * the same path), so correctness does NOT depend on it. We keep it as harmless extra hardening (it can + * catch a same-token reuse edge), but the token check is the load-bearing invariant. + * + * Residual (finding 4 — minimized, honestly stated): a check-then-unlink window remains between the + * final token+inode recheck and the rmSync. This is the IRREDUCIBLE final-instruction window of any + * path-based lock without native OS advisory locking (flock), which GSD avoids (no native deps). The + * token+inode recheck shrinks the window to that last instruction: a successor must replace BOTH the + * token and the inode within it to be wrongly deleted, and that is only REACHABLE when a holder is + * BOTH stale (>LOCK_STALE_MS) AND still alive to call release — a live process frozen >60s mid sub- + * second fs critical section (a crashed holder never calls release; a normal holder finishes in ms). + * A rename-claim variant was tried but merely moves the same window (the restore step can clobber a + * third acquirer — Codex R6). This lock is same-user, same-machine DEFENSE-IN-DEPTH; it is NOT the + * trust barrier (that is consent + integrity + reversibility, see the trust-model doc), and the + * residual crosses no privilege boundary — the same disposition accepted for safeRmUnder's parent TOCTOU. */ function releaseLock(handle: LockHandle | null): void { if (!handle) return; try { - if (fs.readFileSync(handle.path, 'utf8') === handle.token) fs.rmSync(handle.path, { force: true }); + // Finding 2 (HIGH): the lock body is untrusted — read it via the shared fd-based bounded reader + // (regular-file + size cap). A FIFO/device/oversized/non-regular body at handle.path cannot be + // ours (our writes are tiny regular-file JSON), so it is simply not released by us (left for the + // deadman / its real owner) — and a FIFO can never block release. Null means gone/non-regular → + // nothing of ours to release. + let body: string | null; + try { + body = ledgerMod.readSmallRegularFile(handle.path, LOCK_MAX_BODY_BYTES); + } catch { + return; // non-regular / oversized / unreadable → not ours; do not read or delete. + } + if (body === null) return; // gone / missing — nothing of ours to release. + // The body is now JSON `{ token, pid, hostname, startTime, ts }` (finding 1); release only if the + // recorded token is still OURS. A legacy plain-token body (whole body === token) is also honored + // so an in-flight handle written by an older build can still be released. + if (lockBodyToken(body) !== handle.token && body !== handle.token) return; // not our token (PRIMARY). + // Finding 4 (LOW): best-effort SECONDARY guard — re-stat the path IMMEDIATELY before rmSync and, if + // we captured an inode at acquire, confirm it is STILL ours (the dev/ino captured at acquire). A + // successor recreated at the same path MAY have a different inode → then do NOT delete it. This is + // only a window-minimizer, NOT the correctness invariant: inode reuse on some filesystems (Linux + // ext4/overlay after a successor's unlink+recreate) can make the inode match again, so the TOKEN + // check above is the load-bearing protection. When we captured no inode (best-effort null), the + // token check alone gated the delete. + if (handle.dev !== null && handle.ino !== null) { + let cur: fs.Stats; + try { + cur = fs.statSync(handle.path); + } catch { + return; // vanished/unstatable between read and rmSync → nothing of ours to release. + } + if (cur.dev !== handle.dev || cur.ino !== handle.ino) return; // successor inode — not ours. + } + fs.rmSync(handle.path, { force: true }); } catch { /* already gone / stale-stolen / unreadable — nothing of ours to release */ } } +/** Extract the owner token from a lockfile body (JSON `token` field), or null if not JSON/absent. */ +function lockBodyToken(body: string): string | null { + const trimmed = body.trim(); + if (!trimmed.startsWith('{')) return null; + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const t = (parsed as Record)['token']; + return typeof t === 'string' ? t : null; + } + } catch { /* not JSON */ } + return null; +} + function readManifest(dir: string): Record | null { try { const raw = fs.readFileSync(path.join(dir, 'capability.json'), 'utf8'); @@ -326,21 +853,30 @@ function promoteStagingToFinal( finalDir: string, backupName?: string, ): { backupDir: string | null } { + // Both finalDir and the backup share this parent; fsyncing it makes each rename durable (DUR-3). + const parent = path.dirname(finalDir); if (fs.existsSync(finalDir)) { const backupDir = backupName - ? path.join(path.dirname(finalDir), backupName) - : `${finalDir}.upgrading-${process.pid}-${Date.now()}`; + ? path.join(parent, backupName) + // CONC-3: a random nonce in the unnamed-branch backup name prevents same-ms cross-process collision. + : path.join(parent, newBackupName(path.basename(finalDir))); fs.renameSync(finalDir, backupDir); + // DUR-3: fsync the parent dir so the old→backup rename is durable BEFORE the second rename — + // a crash here must not lose the backup (the only recovery path for reconcile). + fsyncDir(parent); try { fs.renameSync(stagingDir, finalDir); } catch (err) { try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ } throw err; } + // DUR-3: fsync the parent dir again so the staging→final rename is durable too. + fsyncDir(parent); return { backupDir }; } - fs.mkdirSync(path.dirname(finalDir), { recursive: true }); + fs.mkdirSync(parent, { recursive: true }); fs.renameSync(stagingDir, finalDir); + fsyncDir(parent); // DUR-3: durable fresh-install promotion. return { backupDir: null }; } @@ -558,6 +1094,21 @@ function isFirstPartyCapabilityId(id: string): boolean { } } +/** + * Finding 5(b): bound the --shared-file COUNT against the same generous DoS cap the ledger applies + * to `_pending.sharedFiles`. Returns an error string when over-cap (so the caller can fail fast + * BEFORE source resolution / staging / shared-config writes), or null when within bounds. + */ +function checkSharedFileCount(sharedFiles: string[] | undefined): string | null { + if (!Array.isArray(sharedFiles)) return null; + if (sharedFiles.length > ledgerMod.MAX_SHARED_FILES) { + return `too many --shared-file entries: ${sharedFiles.length} exceeds the maximum of ` + + `${ledgerMod.MAX_SHARED_FILES}. A capability does not need this many shared-config files; ` + + `reduce the --shared-file count.`; + } + return null; +} + // --------------------------------------------------------------------------- // Install // --------------------------------------------------------------------------- @@ -586,6 +1137,26 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise< return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] }; } + // Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution, staging, + // or any shared-config write — so an over-cap install fails fast with a clear count error instead + // of writing files + leaving a `_pending` for reconcile to clean up. The same generous DoS cap as + // the ledger's `_pending.sharedFiles` validation. + const sharedCountError = checkSharedFileCount(sharedFiles); + if (sharedCountError) return { status: 'blocked', blockReasons: [sharedCountError] }; + + // Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or + // consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption + // reason. The previous order called _resolve first (creating .gsd/capabilities/.staging) and + // only strict-read later, so a corrupt ledger could surface as `aborted` (consent) for an + // executable install without --yes BEFORE the corruption was ever reported, and would leave a + // staging dir behind. A non-throwing read here is a READ-ONLY operation: it touches no lock and + // creates no directory. The later read (re-read under lock before commit) is kept for race-safety. + try { + ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', blockReasons: [(err as Error).message] }; + } + // Resolve copy-only into staging (do NOT promote — trust gate decides first). const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource; let resolved; @@ -618,6 +1189,12 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise< if (opts.expectedId && resolved.id !== opts.expectedId) { return { status: 'blocked', id: resolved.id, blockReasons: [`source resolved to capability id "${resolved.id}" but "${opts.expectedId}" was expected; refusing`] }; } + // ROOT FIX 3: reject unsafe capability ids before any promotion or ledger write. + // A .gsd/capabilities/constructor (or __proto__, prototype) bundle must never be promoted — + // the resolved id is untrusted data from the bundle's capability.json. + if (ledgerMod.isUnsafeCapabilityId(resolved.id)) { + return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to install`] }; + } if (isFirstPartyCapabilityId(resolved.id)) { return { status: 'blocked', id: resolved.id, blockReasons: [`"${resolved.id}" is a first-party capability id and cannot be overridden by a third-party overlay`] }; } @@ -642,14 +1219,23 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise< const files = sharedFiles ?? []; // A reinstall over an existing bundle behaves like an upgrade (preserve the old on rollback). - const existingLedger = ledgerMod.readLedger(runtimeDir); + // readLedgerStrict: returns null when MISSING (fresh first install), throws CorruptLedgerError + // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a + // corrupt-but-present ledger fails closed rather than silently treating it as "no prior entry". + let existingLedger: LedgerFile | null; + try { + existingLedger = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] }; + } const prior = existingLedger && Object.prototype.hasOwnProperty.call(existingLedger.entries, resolved.id) ? existingLedger.entries[resolved.id] : null; const hadDir = fs.existsSync(finalDir); const priorSharedFiles = prior && Array.isArray(prior.sharedEdits) ? prior.sharedEdits.map((e) => e.file) : []; const candidateFiles = Array.from(new Set([...priorSharedFiles, ...files])); - const backupName = hadDir ? `${resolved.id}.upgrading-${process.pid}-${Date.now()}` : null; + // CONC-3: nonce'd backup name prevents same-ms cross-process collision. + const backupName = hadDir ? newBackupName(resolved.id) : null; // INTENT: record BEFORE any filesystem mutation so a crash is recoverable (Codex R2 H1). // Kind 'upgrade' is used ONLY when BOTH a prior ledger entry AND the on-disk bundle exist (a @@ -668,10 +1254,18 @@ async function installCapability(spec: string, opts: LifecycleOptions): Promise< files: [relCapDir], sharedEdits: prior?.sharedEdits ?? [], }; - ledgerMod.recordInstall(runtimeDir, { - ...pendingBase, - _pending: { kind: isUpgradeLike ? 'upgrade' : 'install', backupName, sharedFiles: candidateFiles }, - }); + // recordInstall calls readLedgerStrict internally and can throw CorruptLedgerError if the + // ledger is corrupt. Catch it here so the function always returns a typed result, never throws. + // DOS-4: pass the already-strict-read `existingLedger` as the base so recordInstall skips a + // redundant strict re-read (we hold the lock, so the on-disk ledger cannot change underneath it). + try { + ledgerMod.recordInstall(runtimeDir, { + ...pendingBase, + _pending: { kind: isUpgradeLike ? 'upgrade' : 'install', backupName, sharedFiles: candidateFiles }, + }, { baseLedger: existingLedger }); + } catch (err) { + return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] }; + } let committed = false; let backupDir: string | null = null; @@ -735,6 +1329,21 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise< return { status: 'blocked', blockReasons: [srcPre.reason ?? 'source not allowed'] }; } + // Finding 5(b) (MEDIUM): bound the --shared-file COUNT EARLY — BEFORE source resolution/staging. + const sharedCountError = checkSharedFileCount(sharedFiles); + if (sharedCountError) return { status: 'blocked', blockReasons: [sharedCountError] }; + + // Finding 1 (HIGH): strict ledger PREFLIGHT — BEFORE source resolution, staging, trust, or + // re-consent. On a corrupt-but-present ledger this must block IMMEDIATELY with a corruption + // reason, never fetch/stage the new bundle, and never surface a downstream not_installed/consent + // result that masks the corruption. Read-only — takes no lock, creates no directory. The later + // read (re-read under lock before commit) is kept for race-safety. + try { + ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', blockReasons: [(err as Error).message] }; + } + const resolve = opts._resolve ?? sourceMod.resolveCapabilitySource; let resolved; try { @@ -762,7 +1371,19 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise< if (opts.expectedId && resolved.id !== opts.expectedId) { return { status: 'blocked', id: resolved.id, blockReasons: [`source for "${opts.expectedId}" now resolves to a different capability id "${resolved.id}"; refusing to upgrade`] }; } - const existing = ledgerMod.readLedger(runtimeDir); + // ROOT FIX 3: reject unsafe capability ids before any ledger read or promotion. + if (ledgerMod.isUnsafeCapabilityId(resolved.id)) { + return { status: 'blocked', id: resolved.id, blockReasons: [`capability id "${resolved.id}" is unsafe (prototype-pollution key or invalid kebab-case); refusing to upgrade`] }; + } + // readLedgerStrict: returns null when MISSING (not installed), throws CorruptLedgerError + // when the ledger FILE EXISTS but is unparseable. Using the strict variant ensures a + // corrupt-but-present ledger fails closed rather than silently reporting not_installed. + let existing: LedgerFile | null; + try { + existing = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] }; + } const prior = existing && Object.prototype.hasOwnProperty.call(existing.entries, resolved.id) ? existing.entries[resolved.id] : null; @@ -804,8 +1425,14 @@ async function upgradeCapability(spec: string, opts: LifecycleOptions): Promise< // INTENT: record the in-flight upgrade BEFORE touching the filesystem. Its presence — not a // version comparison — is the commit signal reconcile uses (Codex R1 H3). - const backupName = `${resolved.id}.upgrading-${process.pid}-${Date.now()}`; - ledgerMod.recordInstall(runtimeDir, { ...prior, _pending: { kind: 'upgrade', backupName, sharedFiles: candidateFiles } }); + // Wrap in try/catch so a disk failure (EPERM, ENOSPC, …) at the intent-write stage + // returns a blocked result rather than a raw stack trace (finding 4). + const backupName = newBackupName(resolved.id); // CONC-3: nonce'd, collision-resistant. + try { + ledgerMod.recordInstall(runtimeDir, { ...prior, _pending: { kind: 'upgrade', backupName, sharedFiles: candidateFiles } }); + } catch (err) { + return { status: 'blocked', id: resolved.id, blockReasons: [(err as Error).message] }; + } let backupDir: string | null = null; try { @@ -863,10 +1490,27 @@ interface RemoveResult { */ function removeCapability(id: string, opts: LifecycleOptions): RemoveResult { const { runtimeDir, removeData } = opts; + // Finding 2 (HIGH): READ-ONLY corruption preflight BEFORE acquireLock. acquireLock creates + // .gsd/capabilities and a .lock file; doing it before detecting corruption pollutes the scope + // (and takes a lock) on a ledger we will refuse anyway. A strict read takes no lock and creates + // no directory, so on a corrupt/IO-error ledger we return blocked with NO lock and NO dir created. + try { + ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', id, blockReasons: [(err as Error).message] }; + } const lock = acquireLock(runtimeDir); try { if (!lock) return { status: 'blocked', id, blockReasons: ['another capability operation is in progress'] }; - const ledger = ledgerMod.readLedger(runtimeDir); + // Re-read under the lock to close the race (the ledger could have gone corrupt between the + // preflight and acquiring the lock). readLedgerStrict: returns null when MISSING (not + // installed), throws CorruptLedgerError when the file exists but is corrupt — fail-closed. + let ledger: LedgerFile | null; + try { + ledger = ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + return { status: 'blocked', id, blockReasons: [(err as Error).message] }; + } const entry = ledger && Object.prototype.hasOwnProperty.call(ledger.entries, id) ? ledger.entries[id] : null; if (!entry) return { status: 'not_installed', id }; @@ -887,7 +1531,36 @@ function removeCapability(id: string, opts: LifecycleOptions): RemoveResult { if (removeData) safeRmUnder(runtimeDir, path.relative(runtimeDir, capDataDir(runtimeDir, id))); // 4. Ledger commit point — entry no longer referenced. - ledgerMod.removeEntry(runtimeDir, id); + // Finding 3 (HIGH): commit from the ALREADY-read in-memory ledger (the one we strict-read + // at the top of this function), NOT via removeEntry's non-strict re-read. If the ledger + // goes corrupt between the strict pre-read and the commit, removeEntry would return false + // (it re-reads non-strictly → null → returns false) while removeCapability still returns + // 'removed', leaving a dangling reference in the corrupt file for a capability whose files + // are already gone. Writing from the in-memory snapshot is atomic and coherent. + // + // If the write fails (EPERM, EBUSY, EXDEV, …) after the files are already deleted, we + // return a typed 'blocked' result with recovery info rather than letting an unhandled + // throw propagate as a CLI stack trace. The ledger would still reference files that no + // longer exist — the user can re-run `gsd capability remove ` to retry the commit (the + // next install/update/remove also runs the reconcile sweep automatically). There is no + // standalone `reconcile` CLI subcommand (UX-4). + try { + if (ledger !== null) { + delete ledger.entries[id]; + ledger.updatedAt = new Date().toISOString(); + ledgerMod.writeLedger(runtimeDir, ledger); + } + } catch (err) { + return { + status: 'blocked', + id, + blockReasons: [ + `Capability files were deleted but the ledger commit failed: ${(err as Error).message}. ` + + `To recover: run 'gsd capability remove ${id}' again, or manually inspect and restore ` + + `the ledger file to remove the stale entry for "${id}".`, + ], + }; + } return { status: 'removed', id, strippedEdits, removedFiles, dataPreserved: !removeData }; } finally { @@ -904,10 +1577,16 @@ interface ReconcileReport { rolledForward: string[]; orphansRemoved: string[]; ledger: unknown; + /** Non-fatal warnings encountered during reconciliation (e.g. a corrupt-present ledger). */ + warnings: string[]; } -/** Backup-dir name shape; the id segment is kebab-case so no traversal is possible. */ -const BACKUP_NAME_RE = /^[a-z][a-z0-9-]*\.upgrading-\d+-\d+$/; +/** + * Backup-dir name shape; the id segment is kebab-case so no traversal is possible. The trailing + * `-` nonce (CONC-3) is OPTIONAL so legacy backups written before the nonce was added still + * match (backward compatible). + */ +const BACKUP_NAME_RE = /^[a-z][a-z0-9-]*\.upgrading-\d+-\d+(-[0-9a-f]+)?$/; /** A backup name is trustworthy for `id` only if it is well-formed AND names that exact id. */ function backupNameMatchesId(name: unknown, id: string): name is string { @@ -933,77 +1612,160 @@ function backupNameMatchesId(name: unknown, id: string): name is string { */ function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport { const { runtimeDir } = opts; - const report: ReconcileReport = { rolledBack: [], rolledForward: [], orphansRemoved: [], ledger: null }; + const report: ReconcileReport = { rolledBack: [], rolledForward: [], orphansRemoved: [], ledger: null, warnings: [] }; const root = capabilitiesRoot(runtimeDir); + // Finding 2 (HIGH): READ-ONLY corruption preflight BEFORE acquireLock. acquireLock creates + // .gsd/capabilities and a .lock file; doing it before detecting corruption pollutes the scope + // (and takes a lock) on a ledger we will refuse to mutate anyway. A strict read takes no lock and + // creates no directory, so on a corrupt/IO-error/broken-symlink ledger we WARN and return WITHOUT + // any filesystem mutation and WITHOUT a lock or directory created. (The in-lock re-read below + // still fires to close the race if the ledger goes corrupt after this preflight.) + try { + ledgerMod.readLedgerStrict(runtimeDir); + } catch (err) { + report.warnings.push( + `Capability ledger file exists but could not be read: ${(err as Error).message}`, + ); + return report; // no lock taken, no directory created, no filesystem mutation (finding 2) + } + const lock = acquireLock(runtimeDir); if (!lock) return report; // another op is in flight and will reconcile itself. try { // --- Step 1: resolve uncommitted operations flagged by the intent. --- let ledger = ledgerMod.readLedger(runtimeDir); + // Detect corrupt-present or IO-error ledger: readLedger returns null but the file exists. + // Finding 1 (CRITICAL): when the ledger file is present but unreadable/unparseable (or is a + // broken symlink), RETURN IMMEDIATELY with the warning — perform NO filesystem mutations (no + // backup sweep, no staging cleanup, no rmSync/rename). Continuing into step 2 would delete + // `.upgrading-*` backups that may be the only recovery path for the user. + // + // ROOT FIX 4: use lstatSync (not existsSync) — existsSync follows the symlink and returns + // false for a broken/dangling symlink, making reconcile treat a dangling ledger pointer as + // "no ledger yet" and proceed to sweep backups. lstatSync checks the directory ENTRY itself, + // so a broken symlink is detected and treated as an IO problem requiring user intervention. + if (ledger === null) { + const ledgerFilePath = path.join(runtimeDir, '.gsd-capabilities.json'); + let ledgerEntryExists = false; + try { + fs.lstatSync(ledgerFilePath); + ledgerEntryExists = true; + } catch (lstatErr) { + // ENOENT means genuinely absent — no ledger, no entry, fresh start is fine. + // Any other error (EACCES, EPERM, …) means an IO problem — also treat as "exists but broken". + if ((lstatErr as NodeJS.ErrnoException).code !== 'ENOENT') { + ledgerEntryExists = true; // IO problem accessing the entry — treat as corrupt/broken. + } + } + if (ledgerEntryExists) { + report.warnings.push(`Capability ledger file exists but could not be parsed: ${ledgerFilePath}`); + return report; // MUST return here — no mutations when ledger is corrupt/broken (finding 1) + } + } if (ledger) { - for (const id of Object.keys(ledger.entries)) { - // Reject a tampered ledger key: a non-kebab id (e.g. one containing `../`) must never reach - // capDir()/safeRmUnder() (Codex R3 M5). Leave it in place for ledger.reconcile to report. - if (!KEBAB_ID_RE.test(id)) continue; - const entry = ledger.entries[id]; - const pending = entry._pending; - if (!pending) continue; - // Candidate shared files: the intent's list UNION the entry's recorded files, so a - // tampered/missing `sharedFiles` still cleans the genuinely-touched files (Codex R2 M5). - const candidateFiles = Array.from(new Set([ - ...(Array.isArray(pending.sharedFiles) ? pending.sharedFiles : []), - ...(Array.isArray(entry.sharedEdits) ? entry.sharedEdits.map((e) => e.file) : []), - ])); - const finalDir = capDir(runtimeDir, id); + // DOS-2: accumulate ALL step-1 ledger mutations in this in-memory copy and write ONCE at the + // end of step 1, instead of a full read+write per pending entry (O(N) reads/writes → O(1)). + // We already hold the lock and the ledger has passed the corruption preflight, so writing the + // validated in-memory copy is coherent. `ledgerDirty` gates whether the single write runs. + const workingLedger = ledger; + let ledgerDirty = false; + for (const id of Object.keys(workingLedger.entries)) { + // W-6: a per-entry mutation can now throw (the strip/restore IO, or a future strict write). + // One bad entry must NOT abort the whole reconcile — wrap it, warn, and continue. + try { + // Reject a tampered ledger key: a non-kebab id (e.g. one containing `../`) must never reach + // capDir()/safeRmUnder() (Codex R3 M5). Leave it in place for ledger.reconcile to report. + if (!KEBAB_ID_RE.test(id)) continue; + const entry = workingLedger.entries[id]; + const pending = entry._pending; + if (!pending) continue; + // Candidate shared files: the intent's list UNION the entry's recorded files, so a + // tampered/missing `sharedFiles` still cleans the genuinely-touched files (Codex R2 M5). + const candidateFiles = Array.from(new Set([ + ...(Array.isArray(pending.sharedFiles) ? pending.sharedFiles : []), + ...(Array.isArray(entry.sharedEdits) ? entry.sharedEdits.map((e) => e.file) : []), + ])); + const finalDir = capDir(runtimeDir, id); - if (pending.kind === 'install') { - // Uncommitted FRESH install -> remove dir + shared edits + the half-installed entry. - stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) }); - // Only drop the entry once the dir is actually gone (safeRmUnder returns true when the - // dir is already absent). If the delete genuinely FAILS (e.g. EPERM), keep `_pending` so - // the next run retries — never orphan the dir with no recovery signal (code-review H). - if (!safeRmUnder(runtimeDir, path.relative(runtimeDir, finalDir))) continue; - ledgerMod.removeEntry(runtimeDir, id); - report.rolledBack.push(id); - continue; - } - - // Uncommitted UPGRADE/reinstall. A kind 'upgrade' intent ALWAYS carries a well-formed - // backupName naming this id; if it does not, the intent is tampered/corrupt — fail CLOSED - // (leave it pending for manual handling) rather than silently accepting the live dir - // (Codex R3 M6). - if (!backupNameMatchesId(pending.backupName, id)) continue; - const backupDir = path.join(root, pending.backupName); - let restored: boolean; - if (fs.existsSync(backupDir)) { - try { - fs.rmSync(finalDir, { recursive: true, force: true }); - fs.renameSync(backupDir, finalDir); - restored = true; - } catch { - restored = false; // restore failed — leave the intent for a later retry. + if (pending.kind === 'install') { + // Uncommitted FRESH install -> remove dir + shared edits + the half-installed entry. + stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) }); + // Only drop the entry once the dir is actually gone (safeRmUnder returns true when the + // dir is already absent). If the delete genuinely FAILS (e.g. EPERM), keep `_pending` so + // the next run retries — never orphan the dir with no recovery signal (code-review H). + if (!safeRmUnder(runtimeDir, path.relative(runtimeDir, finalDir))) continue; + delete workingLedger.entries[id]; // DOS-2: in-memory drop; single write at end of step 1. + ledgerDirty = true; + report.rolledBack.push(id); + continue; } - } else if (fs.existsSync(finalDir)) { - // Backup absent with a valid pointer: the swap never started, so the OLD bundle is live. - restored = true; - } else { - // BOTH the backup and the live dir are gone (external deletion of both) — the bundle no - // longer exists. Self-heal as a clean uninstall (strip + drop the entry) rather than - // looping on a never-satisfiable restore (code-review M). - stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) }); - ledgerMod.removeEntry(runtimeDir, id); + + // Uncommitted UPGRADE/reinstall. A kind 'upgrade' intent ALWAYS carries a well-formed + // backupName naming this id; if it does not, the intent is tampered/corrupt — fail CLOSED + // (leave it pending for manual handling) rather than silently accepting the live dir + // (Codex R3 M6). + if (!backupNameMatchesId(pending.backupName, id)) continue; + const backupDir = path.join(root, pending.backupName); + let restored: boolean; + if (fs.existsSync(backupDir)) { + try { + // DUR-6: NEVER rmSync(finalDir) before restoring — a crash between the rm and the + // rename would leave BOTH the new dir AND the backup gone (the old `rmSync` then + // `rename` ordering). Instead, move the uncommitted new dir ASIDE (atomic rename), then + // rename the backup over the now-free finalDir, then drop the aside copy. (`rename` + // cannot atomically replace a non-empty directory on POSIX, so a single rename-over is + // not an option.) At every instant at least one intact copy of the old bundle exists: + // - crash after step (a): backup still present + `_pending` still references it → retry. + // - crash after step (b): old bundle live at finalDir; only the aside copy leaks → swept. + const discard = `${finalDir}.discard-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; + if (fs.existsSync(finalDir)) fs.renameSync(finalDir, discard); // (a) set the new dir aside + fs.renameSync(backupDir, finalDir); // (b) restore the old bundle + fsyncDir(root); // make the restore durable + try { fs.rmSync(discard, { recursive: true, force: true }); } catch { /* swept later */ } + restored = true; + } catch { + restored = false; // restore failed — leave the intent for a later retry. + } + } else if (fs.existsSync(finalDir)) { + // Backup absent with a valid pointer: the swap never started, so the OLD bundle is live. + restored = true; + } else { + // BOTH the backup and the live dir are gone (external deletion of both) — the bundle no + // longer exists. Self-heal as a clean uninstall (strip + drop the entry) rather than + // looping on a never-satisfiable restore (code-review M). + stripCapabilitySharedEdits({ runtimeDir, capId: id, sharedEdits: candidateFiles.map((file) => ({ file, marker: id })) }); + delete workingLedger.entries[id]; // DOS-2: in-memory drop. + ledgerDirty = true; + report.rolledBack.push(id); + continue; + } + + if (!restored) continue; // keep `_pending` so recovery is retried, never silently committed. + + const refreshed = resyncCapabilitySharedEdits({ runtimeDir, capId: id, sharedFiles: candidateFiles }); + const cleared: LedgerEntry = { ...entry, sharedEdits: refreshed }; + delete cleared._pending; + workingLedger.entries[id] = cleared; // DOS-2: in-memory update; single write at end. + ledgerDirty = true; report.rolledBack.push(id); - continue; + } catch (entryErr) { + // W-6: surface the failed entry as a warning and keep going with the rest. + report.warnings.push( + `Reconcile could not roll back capability "${id}": ${(entryErr as Error).message}`, + ); + } + } + // DOS-2: write the accumulated step-1 mutations exactly ONCE. + if (ledgerDirty) { + workingLedger.updatedAt = new Date().toISOString(); + try { + ledgerMod.writeLedger(runtimeDir, workingLedger); + } catch (writeErr) { + report.warnings.push( + `Reconcile could not persist rolled-back ledger state: ${(writeErr as Error).message}`, + ); } - - if (!restored) continue; // keep `_pending` so recovery is retried, never silently committed. - - const refreshed = resyncCapabilitySharedEdits({ runtimeDir, capId: id, sharedFiles: candidateFiles }); - const cleared: LedgerEntry = { ...entry, sharedEdits: refreshed }; - delete cleared._pending; - ledgerMod.recordInstall(runtimeDir, cleared); - report.rolledBack.push(id); } ledger = ledgerMod.readLedger(runtimeDir); } @@ -1018,7 +1780,15 @@ function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport { } for (const name of entries) { - const m = /^(.+)\.upgrading-\d+-\d+$/.exec(name); + // DUR-6: sweep `.discard-*` dirs left by an interrupted upgrade-rollback (the uncommitted new + // bundle that was moved aside before the backup was renamed back in). They never carry a live + // intent, so they are always safe to drop here. + if (/\.discard-\d+-\d+-[0-9a-f]+$/.test(name)) { + try { fs.rmSync(path.join(root, name), { recursive: true, force: true }); report.orphansRemoved.push(name); } catch { /* best-effort */ } + continue; + } + // Match both the legacy `.upgrading--` and the nonce'd `.upgrading---`. + const m = /^(.+)\.upgrading-\d+-\d+(?:-[0-9a-f]+)?$/.exec(name); if (!m) continue; const id = m[1]; // If a pending intent still references this backup, step 1 left it (failed restore) — keep it. @@ -1047,6 +1817,25 @@ function reconcileCapabilities(opts: { runtimeDir: string }): ReconcileReport { } } catch { /* no staging dir */ } + // W-3 / DUR-5: sweep STALE ledger temp orphans (`.gsd-capabilities.json.tmp.-`) from + // the runtime dir. A double-IO-error (or Windows AV lock) during writeLedger's cleanup-unlink can + // leave a temp behind; without this sweep they accumulate forever. Spare recently-created ones, + // which may belong to an in-flight write in another process. Best-effort. + try { + const now = Date.now(); + const tmpPrefix = `${ledgerMod.LEDGER_FILE_NAME}.tmp.`; + for (const f of fs.readdirSync(runtimeDir)) { + if (!f.startsWith(tmpPrefix)) continue; + const p = path.join(runtimeDir, f); + try { + const st = fs.statSync(p); + if (now - st.mtimeMs <= LEDGER_TMP_ORPHAN_MS) continue; // too fresh — could be a live write + fs.rmSync(p, { force: true }); + report.orphansRemoved.push(f); + } catch { /* best-effort */ } + } + } catch { /* runtimeDir unreadable — nothing to sweep */ } + try { report.ledger = ledgerMod.reconcile(runtimeDir); } catch { /* best-effort */ } return report; } finally { @@ -1066,4 +1855,18 @@ export = { applyCapabilitySharedEdits, stripCapabilitySharedEdits, CAP_MARKER, + // Exported for cross-process-lock unit tests (CONC-1/CONC-2/finding-1). Not part of the public CLI + // surface. `_setLockProbes`/`_resetLockProbes` let tests inject deterministic isPidAlive / + // getProcessStartTime so the start-time liveness branches are exercised without real OS pids. + acquireLock, + releaseLock, + getProcessStartTime, + _setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean; getProcessStartTime: (pid: number) => string | null }>): void { + if (typeof probes.isPidAlive === 'function') _lockProbes.isPidAlive = probes.isPidAlive; + if (typeof probes.getProcessStartTime === 'function') _lockProbes.getProcessStartTime = probes.getProcessStartTime; + }, + _resetLockProbes(): void { + _lockProbes.isPidAlive = _realIsPidAlive; + _lockProbes.getProcessStartTime = getProcessStartTime; + }, }; diff --git a/tests/capability-cli.test.cjs b/tests/capability-cli.test.cjs index cdf9452d7..83ff78342 100644 --- a/tests/capability-cli.test.cjs +++ b/tests/capability-cli.test.cjs @@ -231,6 +231,36 @@ describe('capability update', () => { assert.equal(o.toVersion, '2.0.0'); assert.equal(readLedgerEntry(home, 'upcap').version, '2.0.0'); }); + + // Finding 4 (MEDIUM): `capability update --shared-file` over-cap previously ran the pre-op + // reconcile (and re-parsed --shared-file per entry) BEFORE rejecting; install already had the early + // guard, update did not. The count must now be enforced BEFORE capRunReconcile. + // + // To PROVE reconcile did not run, the ledger is intentionally CORRUPT: a reconcile sweep would + // surface a "capability reconcile:" warning on stderr. The over-cap update must be rejected with a + // count error and that reconcile prefix must be ABSENT (reconcile never executed). + // Revert-fails: move the count check back below capRunReconcile (or drop it) → the corrupt-ledger + // reconcile runs first and emits "capability reconcile:" on stderr, so the "prefix absent" + // assertion fails (and/or the count error is missing). + test('finding-4: an OVER-CAP --shared-file update is rejected BEFORE the pre-op reconcile runs', () => { + const home = tmpDir('cap-cli-home-f4-'); + fs.mkdirSync(home, { recursive: true }); + // A corrupt ledger: if the pre-op reconcile RAN, it would emit a "capability reconcile:" warning. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + + const sharedArgs = []; + for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); } // over the 256 cap + const r = runGsdTools( + ['capability', 'update', '--all', '--scope', 'global', ...sharedArgs, '--raw'], + makeCwd(), scopeEnv(home), + ); + assert.equal(r.success, false, 'an over-cap --shared-file update must be rejected'); + const combined = `${r.error}\n${r.output}`; + assert.match(combined, /shared.?file|count|too many|256/i, + 'the failure must clearly name the shared-file count problem'); + assert.doesNotMatch(combined, /capability reconcile:/i, + 'the pre-op reconcile must NOT have run — the count check precedes it (finding 4)'); + }); }); // ─── remove ───────────────────────────────────────────────────────────────── @@ -425,6 +455,36 @@ describe('capability install (--shared-file confinement)', () => { assert.ok(!fs.existsSync(path.join(outside, 'settings.json')), 'must NOT write through the escaping symlink'); }); + // Finding 5(b) (MEDIUM): the --shared-file COUNT must be bounded EARLY — at the CLI/lifecycle + // entry, BEFORE source resolution / staging / shared-config writes — so an over-cap install fails + // fast with a clear count error instead of writing files + leaving a _pending to reconcile. + // Revert-fails: remove the early count check in installCapability/gsd-tools → the install proceeds + // to staging (a .gsd/capabilities/.staging dir is created) before any cap is enforced, so the + // "no staging created" assertion fails (and there is no clear count error). + test('finding-5b: an install with OVER-CAP --shared-file count is rejected BEFORE any staging dir is created', () => { + const home = tmpDir('cap-cli-home-'); + fs.mkdirSync(home, { recursive: true }); + const src = writeCapSource('overcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] }); + // Build 300 --shared-file args (over the 256 generous cap). + const sharedArgs = []; + for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); } + const r = runGsdTools( + ['capability', 'install', src, '--scope', 'global', '--yes', ...sharedArgs, '--raw'], + makeCwd(), scopeEnv(home), + ); + assert.equal(r.success, false, 'an over-cap --shared-file install must be rejected'); + assert.match(`${r.error}\n${r.output}`, /shared.?file|count|too many|256/i, + 'the failure must clearly name the shared-file count problem'); + // NO staging dir may have been created — the bound is enforced before resolution/staging. + const staging = path.join(home, '.gsd', 'capabilities', '.staging'); + assert.equal(fs.existsSync(staging) && fs.readdirSync(staging).length > 0, false, + 'no staging dir may be created when the over-cap install is rejected early'); + // NO ledger entry / _pending must be left behind. + assert.equal(readLedgerEntry(home, 'overcap'), null, 'no ledger entry / _pending may be left'); + // NO shared-config file may have been written. + assert.equal(fs.existsSync(path.join(home, 'f0.json')), false, 'no shared-config file may be written'); + }); + test('install does not clobber a user mcpServers entry whose name collides with the capability', () => { const home = tmpDir('cap-cli-home-'); fs.mkdirSync(home, { recursive: true }); @@ -473,3 +533,226 @@ describe('capability (argument + empty-state handling)', () => { assert.match(`${r.error}\n${r.output}`, /Missing value for --integrity/i); }); }); + +// ─── corrupt-ledger fail-closed — list + remove (sites A and C) ───────────── + +describe('capability list (corrupt ledger fail-closed — site A)', () => { + test('capability list on a corrupt ledger exits non-zero with a blocked/corrupt error (finding-19)', () => { + const home = tmpDir('cap-cli-home-list-corrupt-'); + fs.mkdirSync(home, { recursive: true }); + // Write a corrupt (unparseable) ledger file in the global scope location. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], makeCwd(), scopeEnv(home)); + // Must exit non-zero (fail-closed — finding 19). A silent exit-0 is not acceptable. + assert.equal(r.success, false, 'capability list must exit non-zero when the ledger is corrupt (fail-closed)'); + const combined = `${r.error}\n${r.output}`; + assert.match(combined, /corrupt|blocked/i, + 'must mention corruption or blocked, not silently fail'); + }); + + test('capability list --scope global: healthy global + corrupt project → exits zero (finding-8)', () => { + // When --scope global is given, only the global ledger is read. + // A corrupt project ledger must not block a global-only list. + // Project scope runtimeDir = cwd (where .gsd-capabilities.json would live). + const home = tmpDir('cap-cli-home-list-scoped-'); + fs.mkdirSync(home, { recursive: true }); + const cwd = makeCwd(); + // Write a corrupt ledger at the project scope location (cwd/.gsd-capabilities.json). + fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---'); + const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], cwd, scopeEnv(home)); + // Global scope is healthy (no ledger = null = fine). Only the global scope is read. + assert.equal(r.success, true, `list --scope global must succeed when only the project ledger is corrupt; got: ${r.error || r.output}`); + const rows = parse(r.output); + assert.ok(Array.isArray(rows), 'output must be a JSON array'); + // First-party capabilities must appear (they are always included). + assert.ok(rows.some((x) => x.source === 'first-party'), 'first-party entries must appear'); + }); + + test('capability list --scope project: corrupt project ledger exits non-zero (finding-8)', () => { + const home = tmpDir('cap-cli-home-list-proj-corrupt-'); + fs.mkdirSync(home, { recursive: true }); + const cwd = makeCwd(); + // Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json. + fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---'); + const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home)); + assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption'); + }); +}); + +describe('capability remove (corrupt ledger fail-closed — site C)', () => { + test('capability remove on a corrupt global ledger exits non-zero with a blocked/corrupt error, NOT not_installed or silent success', () => { + const home = tmpDir('cap-cli-home-remove-corrupt-'); + fs.mkdirSync(home, { recursive: true }); + // Write a corrupt (unparseable) ledger file so the scope has one. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'must exit non-zero on corrupt ledger'); + // Must NOT silently report "not installed" — that would hide the corruption. + assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i, + 'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, + 'must mention corruption or blocked'); + }); + + test('capability remove first-party id on corrupt ledger surfaces corruption, not first-party error (finding-7)', () => { + // Finding 7: with readLedger (old), a corrupt ledger + first-party id reports "first-party cannot be removed" + // (hiding the corruption). With readLedgerStrict, corruption is surfaced first. + const reg = require('../gsd-core/bin/lib/capability-registry.cjs'); + const firstParty = Object.keys(reg.capabilities)[0]; + const home = tmpDir('cap-cli-home-f7-'); + fs.mkdirSync(home, { recursive: true }); + // Corrupt the ledger. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const r = runGsdTools(['capability', 'remove', firstParty, '--scope', 'global'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'must exit non-zero on corrupt ledger'); + // Must NOT report "first-party" (which would hide the corruption). + assert.doesNotMatch(`${r.error}\n${r.output}`, /first-party/i, + 'corrupt ledger must surface corruption, not first-party gate'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, + 'must mention corruption or blocked'); + }); + + test('capability remove on a corrupt project-scope ledger exits non-zero (finding-20)', () => { + const home = tmpDir('cap-cli-home-remove-proj-corrupt-'); + fs.mkdirSync(home, { recursive: true }); + const cwd = makeCwd(); + // Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json. + fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project json ---'); + const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'project'], cwd, scopeEnv(home)); + assert.equal(r.success, false, 'must exit non-zero on corrupt project ledger'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption or blocked'); + }); +}); + +// ─── corrupt-ledger fail-closed (Codex pass 3 — medium #2) ────────────────── + +describe('capability update (corrupt ledger fail-closed)', () => { + test('capability update on a corrupt ledger exits non-zero with a blocked/corrupt error, NOT not_installed', () => { + const home = tmpDir('cap-cli-home-corrupt-'); + fs.mkdirSync(home, { recursive: true }); + // Write a corrupt (unparseable) ledger file. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const r = runGsdTools(['capability', 'update', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'must exit non-zero on corrupt ledger'); + // Must NOT report "not installed" — that would hide the corruption silently. + assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i, + 'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, + 'must mention corruption or blocked'); + }); + + test('capability update --all on a corrupt ledger exits non-zero, does NOT silently succeed with an empty list', () => { + const home = tmpDir('cap-cli-home-corrupt-all-'); + fs.mkdirSync(home, { recursive: true }); + // Write a corrupt (unparseable) ledger file. + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'must exit non-zero on corrupt ledger for --all'); + assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, + 'must mention corruption or blocked; not silently succeed'); + }); +}); + +// ─── orthogonal adversarial review (#1462): UX / observability ────────────── + +describe('capability update --all (UX-1: structured stdout on partial failure)', () => { + test('UX-1: a partial --all failure emits {scope, updated:[...]} JSON on STDOUT and exits non-zero', () => { + const home = tmpDir('cap-cli-home-ux1-'); + // Install an executable capability, then change its exec surface so the update needs re-consent + // and (without --yes) ABORTS — a partial-failure --all run. + const src = writeCapSource('ux1cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/a.js' }] }); + assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true); + const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8')); + cap.version = '2.0.0'; + cap.hooks = [{ event: 'PostToolUse', script: 'hooks/b.js' }]; + fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2)); + fs.writeFileSync(path.join(src, 'hooks', 'b.js'), '// artifact'); + + const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)); + // Non-zero exit (partial failure). + assert.equal(r.success, false, 'a partial --all failure must exit non-zero'); + // STRUCTURED data on STDOUT (not embedded in the error string) — UX-1. + assert.ok(r.output && r.output.length > 0, 'structured result must be emitted on stdout'); + const parsed = JSON.parse(r.output); + assert.equal(parsed.scope, 'global', 'stdout JSON must carry the scope'); + assert.ok(Array.isArray(parsed.updated), 'stdout JSON must carry the updated[] array'); + assert.ok(parsed.updated.some((x) => x.id === 'ux1cap' && x.status !== 'upgraded'), + `updated[] must include the failed entry; got: ${JSON.stringify(parsed.updated)}`); + }); +}); + +describe('capability list (UX-3: corrupt-scope error names the scope)', () => { + test('UX-3: a corrupt project-scope ledger error names the scope', () => { + const home = tmpDir('cap-cli-home-ux3-'); + fs.mkdirSync(home, { recursive: true }); + const cwd = makeCwd(); + fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---'); + const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home)); + assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt'); + assert.match(`${r.error}\n${r.output}`, /\bproject\b/, + `the corrupt-scope error must name the scope ("project"); got: ${r.error}\n${r.output}`); + }); +}); + +describe('capability install (UX-5: structured aborted/requiresConsent on stdout)', () => { + test('UX-5: an executable install WITHOUT --yes in --raw mode emits a structured aborted envelope on stdout', () => { + const home = tmpDir('cap-cli-home-ux5-'); + const src = writeCapSource('ux5cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] }); + const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'an executable install without --yes must exit non-zero'); + assert.ok(r.output && r.output.length > 0, 'stdout must NOT be empty in raw aborted mode (UX-5)'); + const out = JSON.parse(r.output); + assert.equal(out.status, 'aborted', 'structured stdout must carry status=aborted'); + assert.equal(out.requiresConsent, true, 'structured stdout must carry requiresConsent=true'); + assert.ok(Array.isArray(out.disclosure), 'structured stdout must carry the disclosure list'); + }); +}); + +describe('capability update (UX-6: normalized per-entry fields)', () => { + test('UX-6: a not_installed entry in --all output has explicit null fields (not undefined)', () => { + // Seed a ledger with an entry whose recorded source resolves to a DIFFERENT id, so upgradeOne + // reports a non-upgraded status with no fromVersion/toVersion — those must serialize as null. + const home = tmpDir('cap-cli-home-ux6-'); + fs.mkdirSync(home, { recursive: true }); + // Hand-write a ledger entry pointing at a non-existent source so the update blocks. + const ledger = { + version: '1', updatedAt: new Date().toISOString(), + entries: { + 'ux6cap': { id: 'ux6cap', version: '1.0.0', source: '/nonexistent/path/that/does/not/resolve', integrity: '', files: [], sharedEdits: [] }, + }, + }; + fs.writeFileSync(ledgerPath(home), JSON.stringify(ledger, null, 2)); + const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)); + // Partial failure (the blocked entry) → non-zero, structured stdout. + assert.equal(r.success, false, 'a blocked --all entry must exit non-zero'); + const parsed = JSON.parse(r.output); + const row = parsed.updated.find((x) => x.id === 'ux6cap'); + assert.ok(row, `updated[] must include ux6cap; got: ${JSON.stringify(parsed.updated)}`); + // JSON.stringify omits undefined keys; explicit null is preserved. The fields must be present + // as null (normalized), not absent. + assert.ok('fromVersion' in row, 'fromVersion must be an explicit field (null), not omitted (UX-6)'); + assert.strictEqual(row.fromVersion, null, 'fromVersion must be null for a blocked entry (UX-6)'); + assert.ok('toVersion' in row, 'toVersion must be an explicit field (null), not omitted (UX-6)'); + assert.strictEqual(row.toVersion, null, 'toVersion must be null for a blocked entry (UX-6)'); + }); +}); + +describe('capability install (UX-2: reconcile warnings surfaced on stderr)', () => { + // Revert-fails: restore the bare `try{reconcile}catch{}` that discards the report → the distinctive + // "capability reconcile:" warning prefix is never emitted to stderr, so this assertion fails. (The + // install block reason references "corrupt" but NOT the reconcile-warning prefix, so the prefix + // assertion is non-vacuous.) + test('UX-2: a corrupt ledger detected by the pre-op reconcile is surfaced on stderr (not swallowed)', () => { + const home = tmpDir('cap-cli-home-ux2-'); + fs.mkdirSync(home, { recursive: true }); + fs.writeFileSync(ledgerPath(home), '{ broken json ---'); + const src = writeCapSource('ux2cap'); + const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home)); + assert.equal(r.success, false, 'install on a corrupt ledger must exit non-zero'); + // The reconcile report's warning must be surfaced with its distinctive prefix on stderr — proving + // the report was captured and emitted, not discarded in a bare try/catch. + assert.match(`${r.error}\n${r.output}`, /capability reconcile:/i, + 'the pre-op reconcile warning must be surfaced on stderr with its prefix (UX-2)'); + }); +}); diff --git a/tests/capability-ledger.test.cjs b/tests/capability-ledger.test.cjs index c6dc2a71f..b8e23ac34 100644 --- a/tests/capability-ledger.test.cjs +++ b/tests/capability-ledger.test.cjs @@ -22,6 +22,16 @@ const { reconcile, LEDGER_FILE_NAME, } = capLedger; +// Destructure optional exports (new in this patch) — will be undefined until implemented. +const { LedgerIOError, isValidLedgerEntry, readLedgerStrict, readSmallRegularFile } = capLedger; + +const cp = require('node:child_process'); +/** 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 = cp.spawnSync('mkfifo', [p], { stdio: 'ignore' }); + return res.status === 0; +} // --------------------------------------------------------------------------- // Helpers @@ -50,10 +60,12 @@ function makeLedger(overrides = {}) { }; } -/** Return all tmp files left in dir (matches .tmp. pattern). */ +/** Return all tmp files left in dir (matches .tmp.- pattern). */ function orphanTmpFiles(dir) { if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n)); + // 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)); } // --------------------------------------------------------------------------- @@ -122,6 +134,76 @@ test('writeLedger leaves no orphan .tmp file after a successful write', (t) => { 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) // --------------------------------------------------------------------------- @@ -144,39 +226,103 @@ test('recordInstall is idempotent: same id twice yields one entry with the lates // recordInstall — __proto__ injection rejected // --------------------------------------------------------------------------- -test('recordInstall rejects a __proto__ id without polluting Object.prototype', (t) => { +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']; - recordInstall(dir, makeEntry('__proto__', { integrity: 'evil' })); + // 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 should either not exist or contain zero entries. - const ledger = readLedger(dir); - if (ledger !== null) { - assert.equal(Object.keys(ledger.entries).length, 0, - '__proto__ id must not appear in entries'); - } + // 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', (t) => { +test('recordInstall rejects "constructor" and "prototype" ids (now THROWS — ROOT FIX 3)', (t) => { const dir = createTempDir('ledger-proto2-'); t.after(() => cleanup(dir)); - recordInstall(dir, makeEntry('constructor')); - recordInstall(dir, makeEntry('prototype')); + // 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); - if (ledger !== null) { - assert.ok(!('constructor' in ledger.entries), '"constructor" must be excluded'); - assert.ok(!('prototype' in ledger.entries), '"prototype" must be excluded'); - } + assert.ok(ledger && ledger.entries['cap-ok'], 'valid entry must be recorded'); }); // --------------------------------------------------------------------------- @@ -214,6 +360,51 @@ test('removeEntry returns false when the id does not exist', (t) => { 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 // --------------------------------------------------------------------------- @@ -279,54 +470,682 @@ test('reconcile issues a warning when the ledger file is corrupt', (t) => { }); // --------------------------------------------------------------------------- -// fs fault-injection — platformWriteSync fallback via mock.method(fs, 'renameSync') +// 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('writeLedger succeeds via platformWriteSync fallback when renameSync fails', (t) => { +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)); - // Capture the real renameSync BEFORE installing the mock (avoids calling - // the mock's own wrapper in the fallback path — mirrors the concurrent-write - // test in feat-3595-fs-fault-injection-atomic-write.test.cjs). - const originalRename = fs.renameSync; let renameCalls = 0; - const renameMock = mock.method(fs, 'renameSync', (src, dest) => { + const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { renameCalls++; - if (renameCalls === 1) { - // Simulate a cross-device rename failure. - const err = new Error('EXDEV: cross-device link not permitted'); - err.code = 'EXDEV'; - throw err; - } - return originalRename.call(fs, src, dest); + // 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') }, }); - writeLedger(dir, ledger); - // File must exist and be parseable despite the rename failure. - const readBack = readLedger(dir); - assert.ok(readBack !== null, 'ledger must be readable after fallback write'); - assert.ok('fault-cap' in readBack.entries, 'entry must survive the fallback write'); + // 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)', + ); - // No orphan tmp files must remain. - assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphan after fallback write'); + assert.ok(renameCalls >= 1, 'renameSync must have been invoked'); - assert.equal(renameCalls, 1, 'renameSync was invoked exactly once before falling back'); + // 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(', ')}`); }); -// ADR-1244 D4 (adversarial re-review): reconcile must never THROW on hostile JSON. -// A non-string files[] member like { toString: null } would crash String(file); a -// '..' or absolute member would otherwise become an existence oracle outside runtimeDir. -test('reconcile does not throw on hostile files[] members (non-string, "..", absolute)', () => { +// --------------------------------------------------------------------------- +// 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. + // 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', @@ -339,12 +1158,1292 @@ test('reconcile does not throw on hostile files[] members (non-string, "..", abs }, }; 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 on hostile members'); - // Every hostile member is skipped with a warning; none becomes an orphan/oracle. - assert.ok(result.warnings.length >= 1, 'hostile members must be reported as warnings'); - assert.deepEqual(result.orphans, [], 'no hostile member is treated as a real (missing) file'); + 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', + ); +}); diff --git a/tests/capability-lifecycle.test.cjs b/tests/capability-lifecycle.test.cjs index 9182621e5..b3bcaa738 100644 --- a/tests/capability-lifecycle.test.cjs +++ b/tests/capability-lifecycle.test.cjs @@ -11,6 +11,7 @@ */ const test = require('node:test'); +const { mock } = require('node:test'); const assert = require('node:assert'); const fs = require('node:fs'); const os = require('node:os'); @@ -25,6 +26,14 @@ const { CAP_MARKER } = lifecycle; // Helpers // --------------------------------------------------------------------------- +const cp = require('node:child_process'); +/** POSIX-only: make a FIFO at `p` (returns false where mkfifo is unavailable). */ +function tryMkfifoLife(p) { + if (process.platform === 'win32') return false; + const res = cp.spawnSync('mkfifo', [p], { stdio: 'ignore' }); + return res.status === 0; +} + const cleanups = []; function runtime() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-life-')); @@ -212,18 +221,21 @@ test('install: re-installing over an existing capability (via install, not upgra assert.strictEqual(readSettings(dir).hooks.PostToolUse.length, 1, 'exactly one stamped hook'); }); -test('install: a stale lock is stolen so a crashed prior holder does not block forever', async () => { +test('install: a deadman-stale lock is stolen so a crashed prior holder does not block forever', async () => { const dir = runtime(); const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // A legacy no-pid body: liveness cannot be verified, so (finding 1) it is reclaimable ONLY by the + // HARD deadman timeout — backdate it past LOCK_DEADMAN_MS (10 min) to simulate a crashed holder the + // deadman must eventually free. (An under-deadman no-pid lock is intentionally NOT stolen; that case + // is covered in the finding-1 lock suite.) fs.writeFileSync(lockPath, 'dead-holder-token', 'utf8'); - // Backdate the lock well past the stale threshold (simulate a crashed holder). - const old = new Date(Date.now() - 5 * 60 * 1000); + const old = new Date(Date.now() - 11 * 60 * 1000); fs.utimesSync(lockPath, old, old); const res = await lifecycle.installCapability('./x', { runtimeDir: dir, hostVersion: '1.6.0', _resolve: fakeResolve(declarativeCap('x')), }); - assert.strictEqual(res.status, 'installed', 'stale lock stolen, install proceeds'); + assert.strictEqual(res.status, 'installed', 'deadman-stale lock stolen, install proceeds'); }); test('install: a resolver failure (e.g. integrity mismatch) is reported as blocked', async () => { @@ -422,12 +434,24 @@ test('reconcile M5: a tampered ledger key (non-kebab id) is skipped, never used const dir = runtime(); // A precious file under runtimeDir the traversal id would resolve to. fs.writeFileSync(path.join(dir, 'precious.txt'), 'keep', 'utf8'); - // Tamper: a ledger entry keyed by a traversal id with a fresh-install intent. - ledgerMod.recordInstall(dir, { - id: '../../precious', version: '1.0.0', source: 's', integrity: '', - files: ['.gsd/capabilities/x'], sharedEdits: [], - _pending: { kind: 'install', backupName: null, sharedFiles: [] }, - }); + // Tamper: write a ledger file directly (bypassing recordInstall's id validation, which now + // correctly rejects non-kebab ids — finding 7) to simulate an externally tampered ledger. + // The tampered entry uses a traversal id that reconcile must not act on. + const LEDGER_FILE_NAME = ledgerMod.LEDGER_FILE_NAME; + fs.writeFileSync( + path.join(dir, LEDGER_FILE_NAME), + JSON.stringify({ + version: '1', + updatedAt: new Date().toISOString(), + entries: { + '../../precious': { + id: '../../precious', version: '1.0.0', source: 's', integrity: '', + files: ['.gsd/capabilities/x'], sharedEdits: [], + _pending: { kind: 'install', backupName: null, sharedFiles: [] }, + }, + }, + }), + ); const report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); assert.ok(!report.rolledBack.includes('../../precious'), 'tampered id not acted upon'); assert.ok(fs.existsSync(path.join(dir, 'precious.txt')), 'no delete via the tampered id'); @@ -587,6 +611,187 @@ test('remove: CAPABILITY_DATA is preserved by default and deleted only on remove assert.ok(!fs.existsSync(dataDir), 'data deleted on removeData'); }); +// --------------------------------------------------------------------------- +// Finding 1: upgrade + remove with a corrupt-present ledger must fail closed +// (throw/quarantine), NOT silently return not_installed. +// --------------------------------------------------------------------------- + +test('upgrade: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (issue-4)', async () => { + const dir = runtime(); + // Write a corrupt ledger file (present but unparseable). + const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + fs.mkdirSync(dir, { recursive: true }); + const corruptContent = '{ broken json ---'; + fs.writeFileSync(ledgerPath, corruptContent); + + // upgradeCapability must NOT throw — it must return a blocked result. + let result; + await assert.doesNotReject( + async () => { + result = await lifecycle.upgradeCapability('./x', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('x', '2.0.0')), + }); + }, + 'upgradeCapability must not throw on a corrupt ledger — must return a blocked result', + ); + + assert.strictEqual(result.status, 'blocked', + `upgradeCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)), + `blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`, + ); + + // The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1). + assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked upgrade'); + assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged'); + // No quarantine files must exist. + const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.')); + assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior'); +}); + +test('remove: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (issue-4)', () => { + const dir = runtime(); + const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + fs.mkdirSync(dir, { recursive: true }); + const corruptContent = '{ broken json ---'; + fs.writeFileSync(ledgerPath, corruptContent); + + // removeCapability must NOT throw — it must return a blocked result. + let result; + assert.doesNotThrow( + () => { + result = lifecycle.removeCapability('some-cap', { runtimeDir: dir }); + }, + 'removeCapability must not throw on a corrupt ledger — must return a blocked result', + ); + + assert.strictEqual(result.status, 'blocked', + `removeCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)), + `blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`, + ); + + // The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1). + assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked remove'); + assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged'); + // No quarantine files must exist. + const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.')); + assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior'); +}); + +// --------------------------------------------------------------------------- +// Finding 2 (HIGH): remove must run a READ-ONLY corruption preflight BEFORE acquireLock. +// On a corrupt ledger it must NOT create .gsd/capabilities and must NOT create a .lock. +// --------------------------------------------------------------------------- + +test('finding-2: removeCapability on a corrupt ledger does NOT acquire a lock or create .gsd/capabilities (preflight precedes acquireLock)', () => { + const dir = runtime(); + const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(ledgerPath, '{ broken json ---'); + + const capsRoot = path.join(dir, '.gsd', 'capabilities'); + assert.ok(!fs.existsSync(capsRoot), 'precondition: .gsd/capabilities must not exist yet'); + + const result = lifecycle.removeCapability('some-cap', { runtimeDir: dir }); + + assert.strictEqual(result.status, 'blocked', + `removeCapability must be blocked on corrupt ledger; got: ${result?.status}`); + assert.ok(result.blockReasons && /corrupt|invalid/i.test(result.blockReasons.join(' ')), + `block reason must name corruption; got: "${(result.blockReasons || []).join(' ')}"`); + + // UNCONDITIONAL: the read-only preflight failed BEFORE acquireLock, so no lock dir/file exists. + assert.ok(!fs.existsSync(capsRoot), + '.gsd/capabilities must NOT be created by acquireLock when the corruption preflight blocks first'); + assert.ok(!fs.existsSync(path.join(capsRoot, '.lock')), + 'no .lock file may be created when the corruption preflight blocks before acquireLock'); +}); + +// --------------------------------------------------------------------------- +// Issue 1 (HIGH): wrong-shape sharedEdits/files member → readLedgerStrict quarantines +// → upgradeCapability/removeCapability return 'blocked', not a thrown error. +// --------------------------------------------------------------------------- + +test('upgrade: wrong-shape sharedEdits member is treated as corrupt → status=blocked, not a thrown error (issue-1)', async () => { + const dir = runtime(); + fs.mkdirSync(dir, { recursive: true }); + + // Write a ledger whose sharedEdits contains null — must fail deep validation. + const badLedger = { + version: '1', + updatedAt: new Date().toISOString(), + entries: { + x: { + id: 'x', version: '1.0.0', source: 'registry:test', integrity: 'sha256-abc', + files: [], + sharedEdits: [null], // null member — must fail deep validation + }, + }, + }; + const ledgerFilePath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + const badContent = JSON.stringify(badLedger, null, 2); + fs.writeFileSync(ledgerFilePath, badContent); + + let result; + await assert.doesNotReject( + async () => { + result = await lifecycle.upgradeCapability('./x', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('x', '2.0.0')), + }); + }, + 'upgradeCapability must not throw on wrong-shape sharedEdits — must return blocked', + ); + + assert.strictEqual(result.status, 'blocked', + `must return status='blocked' for wrong-shape sharedEdits; got: ${result?.status}`); + + // The corrupt file must still be in place (non-destructive — finding 1). + assert.ok(fs.existsSync(ledgerFilePath), 'malformed ledger must remain in place'); + // No quarantine files must exist. + const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.')); + assert.strictEqual(quarantines.length, 0, 'no quarantine files — non-destructive'); +}); + +test('remove: wrong-shape files member is treated as corrupt → status=blocked, not a thrown error (issue-1)', () => { + const dir = runtime(); + fs.mkdirSync(dir, { recursive: true }); + + // Write a ledger whose files[] contains a number — must fail deep validation. + const badLedger = { + version: '1', + updatedAt: new Date().toISOString(), + entries: { + 'some-cap': { + id: 'some-cap', version: '1.0.0', source: 'registry:test', integrity: 'sha256-abc', + files: [123], // non-string — must fail deep validation + sharedEdits: [], + }, + }, + }; + const ledgerFilePath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + fs.writeFileSync(ledgerFilePath, JSON.stringify(badLedger, null, 2)); + + let result; + assert.doesNotThrow( + () => { + result = lifecycle.removeCapability('some-cap', { runtimeDir: dir }); + }, + 'removeCapability must not throw on wrong-shape files[] — must return blocked', + ); + + assert.strictEqual(result.status, 'blocked', + `must return status='blocked' for wrong-shape files[]; got: ${result?.status}`); + + // The malformed ledger must remain in place (non-destructive — finding 1). + assert.ok(fs.existsSync(ledgerFilePath), 'malformed ledger must remain in place'); + const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.')); + assert.strictEqual(quarantines.length, 0, 'no quarantine files — non-destructive'); +}); + // --------------------------------------------------------------------------- // Shared-edit helpers (direct) — prototype-pollution guard // --------------------------------------------------------------------------- @@ -608,6 +813,180 @@ test('applyCapabilitySharedEdits: __proto__ event/name is skipped (no pollution) assert.strictEqual({}.command, undefined); }); +// --------------------------------------------------------------------------- +// Site B: reconcileCapabilities on a corrupt-present ledger must surface a +// warning in its report, not silently do nothing (#1462). +// --------------------------------------------------------------------------- + +test('reconcileCapabilities: a corrupt-present ledger surfaces a warning in report.warnings (site B)', () => { + const dir = runtime(); + fs.mkdirSync(dir, { recursive: true }); + // Write a corrupt (unparseable) ledger file. + fs.writeFileSync(path.join(dir, ledgerMod.LEDGER_FILE_NAME), '{ broken json ---'); + + let report; + assert.doesNotThrow( + () => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); }, + 'reconcileCapabilities must not throw on a corrupt ledger', + ); + + assert.ok(report, 'must return a report object'); + // The warning must be in the top-level report.warnings[] field (not only nested + // under report.ledger.warnings which is typed as unknown and callers miss it). + assert.ok(Array.isArray(report.warnings), + `report.warnings must be an array; got: ${typeof report.warnings}`); + assert.ok( + report.warnings.some((w) => /corrupt|could not be parsed/i.test(w)), + `report.warnings must contain a warning mentioning corruption; got: ${JSON.stringify(report.warnings)}`, + ); +}); + +// --------------------------------------------------------------------------- +// Finding 2 (HIGH): reconcile must run a READ-ONLY corruption preflight BEFORE acquireLock. +// On a corrupt ledger it must warn WITHOUT creating .gsd/capabilities or a .lock. +// --------------------------------------------------------------------------- + +test('finding-2: reconcileCapabilities on a corrupt ledger does NOT acquire a lock or create .gsd/capabilities (preflight precedes acquireLock)', () => { + const dir = runtime(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ledgerMod.LEDGER_FILE_NAME), '{ broken json ---'); + + const capsRoot = path.join(dir, '.gsd', 'capabilities'); + assert.ok(!fs.existsSync(capsRoot), 'precondition: .gsd/capabilities must not exist yet'); + + const report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); + + assert.ok(report.warnings.some((w) => /corrupt|could not be parsed/i.test(w)), + `report.warnings must mention corruption; got: ${JSON.stringify(report.warnings)}`); + + // UNCONDITIONAL: the read-only preflight warned BEFORE acquireLock, so no lock dir/file exists. + assert.ok(!fs.existsSync(capsRoot), + '.gsd/capabilities must NOT be created by acquireLock when the corruption preflight warns first'); + assert.ok(!fs.existsSync(path.join(capsRoot, '.lock')), + 'no .lock file may be created when the corruption preflight warns before acquireLock'); +}); + +// --------------------------------------------------------------------------- +// Issue HIGH: installCapability corrupt-ledger fail-closed (Codex pass 3) +// Prior-entry readLedger (non-strict) + uncaught recordInstall CorruptLedgerError +// — both paths must return blocked, never throw. +// --------------------------------------------------------------------------- + +test('install: a corrupt-present ledger fails closed with status=blocked — must NOT throw or return not_installed (codex-p3-h1)', async () => { + const dir = runtime(); + const ledgerPath = path.join(dir, ledgerMod.LEDGER_FILE_NAME); + fs.mkdirSync(dir, { recursive: true }); + const corruptContent = '{ broken json ---'; + fs.writeFileSync(ledgerPath, corruptContent); + + // installCapability must NOT throw — it must return a blocked result. + let result; + await assert.doesNotReject( + async () => { + result = await lifecycle.installCapability('./newcap', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('newcap', '1.0.0')), + }); + }, + 'installCapability must not throw on a corrupt ledger — must return a blocked result', + ); + + assert.strictEqual(result.status, 'blocked', + `installCapability must return status='blocked' on corrupt ledger; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.some((r) => /corrupt/i.test(r)), + `blockReasons must mention corruption; got: ${JSON.stringify(result?.blockReasons)}`, + ); + + // The corrupt file must still be at its ORIGINAL PATH (non-destructive — finding 1). + assert.ok(fs.existsSync(ledgerPath), 'corrupt file must remain in place after blocked install'); + assert.strictEqual(fs.readFileSync(ledgerPath, 'utf8'), corruptContent, 'corrupt content unchanged'); + // No quarantine files must exist. + const quarantines = fs.readdirSync(dir).filter((n) => n.includes(ledgerMod.LEDGER_FILE_NAME) && n.includes('.corrupt.')); + assert.strictEqual(quarantines.length, 0, 'no quarantine files must exist — non-destructive behavior'); +}); + +// --------------------------------------------------------------------------- +// Finding 3: removeCapability ledger-write failure after files deleted → blocked result, +// no unhandled throw. Coherent state: files gone, ledger still references them (retry-able). +// --------------------------------------------------------------------------- + +test('remove: ledger commit failure after files are deleted returns blocked (finding-3)', async (t) => { + const dir = runtime(); + + // Install a capability. + await lifecycle.installCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('e')), + }); + assert.ok(ledgerMod.readLedger(dir)?.entries['e'], 'e must be installed'); + + // Mock renameSync to fail (ledger write = tmp+rename; make the rename fail). + const { mock } = require('node:test'); + const renameMock = mock.method(require('node:fs'), 'renameSync', (_src, _dest) => { + const err = new Error('EXDEV: cross-device rename not permitted'); + err.code = 'EXDEV'; + throw err; + }); + t.after(() => renameMock.mock.restore()); + + // removeCapability must NOT throw — must return a blocked result. + let result; + assert.doesNotThrow( + () => { result = lifecycle.removeCapability('e', { runtimeDir: dir }); }, + 'removeCapability must not throw when ledger commit fails', + ); + + assert.strictEqual(result.status, 'blocked', + `must return blocked when ledger write fails; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.some((r) => /ledger commit failed|EXDEV/i.test(r)), + `blockReasons must mention ledger commit failure; got: ${JSON.stringify(result?.blockReasons)}`, + ); +}); + +// --------------------------------------------------------------------------- +// Finding 4: upgradeCapability intent-write failure → blocked result, no unhandled throw. +// --------------------------------------------------------------------------- + +test('upgrade: intent recordInstall failure returns blocked (finding-4)', async (t) => { + const dir = runtime(); + + // Install first. + await lifecycle.installCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('e', '1.0.0')), + }); + + // Mock renameSync to fail (writeLedger uses tmp+rename; the intent write will fail). + const { mock } = require('node:test'); + const renameMock = mock.method(require('node:fs'), 'renameSync', (_src, _dest) => { + const err = new Error('EXDEV: cross-device rename not permitted'); + err.code = 'EXDEV'; + throw err; + }); + t.after(() => renameMock.mock.restore()); + + // upgradeCapability must NOT throw — must return blocked. + let result; + await assert.doesNotReject( + async () => { + result = await lifecycle.upgradeCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('e', '2.0.0')), + }); + }, + 'upgradeCapability must not throw when intent write fails', + ); + + assert.strictEqual(result.status, 'blocked', + `must return blocked when upgrade intent write fails; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.length > 0, + `must have blockReasons; got: ${JSON.stringify(result?.blockReasons)}`, + ); +}); + // --------------------------------------------------------------------------- // Real-resolver integration (no _resolve seam) // --------------------------------------------------------------------------- @@ -625,3 +1004,1553 @@ test('integration: install a real, valid, declarative local capability through t assert.ok(fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'realcap', 'capability.json'))); assert.ok(readLedgerEntry(dir, 'realcap'), 'ledger entry recorded via real path'); }); + +// --------------------------------------------------------------------------- +// Finding 3 (HIGH): removeCapability must commit from the ALREADY-read in-memory +// ledger — not re-read via removeEntry's non-strict readLedger. A ledger corrupted +// between the strict pre-read and the commit must not produce a silent 'removed' +// result with dangling refs. +// --------------------------------------------------------------------------- + +test('remove: finding-3 — removeCapability NEVER calls ledgerMod.removeEntry (uses in-memory writeLedger, not a re-read path)', async (t) => { + const dir = runtime(); + + // Install a capability so it exists in the ledger. + await lifecycle.installCapability('./rf3', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('rf3')), + }); + assert.ok(readLedgerEntry(dir, 'rf3'), 'rf3 must be installed before remove test'); + + // Spy on ledgerMod.removeEntry — removeCapability must NEVER call it. + // (removeCapability commits by mutating the in-memory ledger + writeLedger directly, + // never via removeEntry whose re-read is non-strict and would silently swallow corruption.) + const { mock } = require('node:test'); + let removeEntryCalls = 0; + const removeEntryMock = mock.method(ledgerMod, 'removeEntry', function (...args) { + removeEntryCalls++; + // Still call through so ledger stays consistent if the code ever uses it. + return ledgerMod.removeEntry.__origFn ? ledgerMod.removeEntry.__origFn(...args) : undefined; + }); + t.after(() => removeEntryMock.mock.restore()); + + const result = lifecycle.removeCapability('rf3', { runtimeDir: dir }); + assert.strictEqual(result.status, 'removed', + 'removeCapability must succeed using the in-memory writeLedger path'); + assert.strictEqual(removeEntryCalls, 0, + 'removeCapability must NEVER call ledgerMod.removeEntry — it must commit via the in-memory writeLedger path'); + assert.strictEqual(readLedgerEntry(dir, 'rf3'), null, 'entry must be gone after remove'); +}); + +test('remove: finding-3 — mid-remove corruption blocks coherently: capability files gone but ledger write fails → blocked (not silent removed with dangling refs)', async (t) => { + const dir = runtime(); + + // Install a capability. + await lifecycle.installCapability('./rf3b', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('rf3b')), + }); + assert.ok(readLedgerEntry(dir, 'rf3b'), 'rf3b must be installed'); + + // After strict pre-read, corrupt the ledger on disk so the writeLedger commit fails. + // We do this by intercepting the SECOND renameSync call (the atomic ledger write's rename) + // with an EXDEV error, simulating a commit failure after files are already deleted. + const { mock } = require('node:test'); + const realRename = fs.renameSync.bind(fs); + let renameCount = 0; + const renameMock = mock.method(fs, 'renameSync', function (src, dst) { + renameCount++; + // The first rename may be for staging during install setup; skip it. + // The ledger commit rename will be for a .tmp.- → .gsd-capabilities.json path. + if (typeof dst === 'string' && dst.includes('.gsd-capabilities.json') && renameCount >= 1) { + const err = new Error('EXDEV: cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + } + return realRename(src, dst); + }); + t.after(() => renameMock.mock.restore()); + + const result = lifecycle.removeCapability('rf3b', { runtimeDir: dir }); + + // The result must be 'blocked' — not 'removed' — because the ledger commit failed. + // Returning 'removed' when the ledger write failed would be a "silent removed with dangling refs". + assert.strictEqual(result.status, 'blocked', + `removeCapability must return blocked when the ledger commit fails; got: ${result?.status}`); + assert.ok( + result.blockReasons && result.blockReasons.length > 0, + 'must include blockReasons explaining the failure', + ); + // The error message must NOT reference a non-existent CLI command ('gsd capability reconcile'). + const reason = result.blockReasons[0] || ''; + assert.ok( + !reason.includes('gsd capability reconcile'), + `blockReasons must not reference non-existent CLI subcommand 'gsd capability reconcile'; got: "${reason}"`, + ); +}); + +// --------------------------------------------------------------------------- +// ROOT FIX 2: preflight strict-read BEFORE source resolution + staging. +// A corrupt ledger must block install/upgrade BEFORE any staging dir is created. +// --------------------------------------------------------------------------- + +test('root-fix-2: install on a corrupt-present ledger blocks BEFORE resolving source / creating staging', async (_t) => { + const dir = runtime(); + + // Write a corrupt ledger before attempting install. + const ledgerPath = path.join(dir, '.gsd-capabilities.json'); + fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8'); + + let resolveCalled = false; + const trackingResolve = async (spec, opts) => { + resolveCalled = true; + // Materialize a staging dir so a regression (resolve before strict read) is observable. + const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging'); + fs.mkdirSync(root, { recursive: true }); + const staged = path.join(root, 'preflight-test'); + fs.mkdirSync(staged, { recursive: true }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify( + { id: 'pf', role: 'feature', version: '1.0.0', title: 'pf', description: 'x', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] } + ), 'utf8'); + return { id: 'pf', version: '1.0.0', stagedDir: staged, integrity: null, source: spec }; + }; + + const result = await lifecycle.installCapability('./pf', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: trackingResolve, + }); + + assert.strictEqual(result.status, 'blocked', + 'installCapability must be blocked by a corrupt ledger'); + assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons'); + // The block reason must be the CORRUPTION (not some downstream staging/consent message). + assert.ok( + /corrupt|invalid/i.test(result.blockReasons.join(' ')), + `block reason must name ledger corruption; got: "${result.blockReasons.join(' ')}"`, + ); + + // UNCONDITIONAL invariant: the strict read precedes source resolution, so the resolver is + // NEVER invoked when the ledger is corrupt. (Was gated behind `if (!resolveCalled)` — vacuous.) + assert.strictEqual(resolveCalled, false, + 'resolver must NOT be called when the ledger is corrupt (strict read precedes _resolve)'); + + // And because resolve never ran, NO staging dir was created. + const stagingRoot = path.join(dir, '.gsd', 'capabilities', '.staging'); + assert.ok( + !fs.existsSync(stagingRoot), + 'no .staging dir may be created when the ledger is corrupt (preflight precedes staging)', + ); +}); + +test('root-fix-2: upgrade on a corrupt-present ledger blocks BEFORE resolving source / creating staging', async (_t) => { + const dir = runtime(); + + // First install successfully. + await lifecycle.installCapability('./u2', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('u2', '1.0.0')), + }); + + // Then corrupt the ledger. + const ledgerPath = path.join(dir, '.gsd-capabilities.json'); + fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8'); + + let resolveCalled = false; + const trackingResolve = async (spec, opts) => { + resolveCalled = true; + const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging'); + fs.mkdirSync(root, { recursive: true }); + const staged = path.join(root, 'preflight-upgrade-test'); + fs.mkdirSync(staged, { recursive: true }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify( + { id: 'u2', role: 'feature', version: '2.0.0', title: 'u2', description: 'x', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] } + ), 'utf8'); + return { id: 'u2', version: '2.0.0', stagedDir: staged, integrity: null, source: spec }; + }; + + // Snapshot the .staging dir's prior contents (the successful install above may have left none, + // but be precise): the corrupt-upgrade attempt must add NOTHING. + const stagingRoot = path.join(dir, '.gsd', 'capabilities', '.staging'); + const before = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot).sort() : []; + + const result = await lifecycle.upgradeCapability('./u2-v2', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: trackingResolve, + }); + + assert.strictEqual(result.status, 'blocked', + 'upgradeCapability must be blocked by a corrupt ledger'); + assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons'); + assert.ok( + /corrupt|invalid/i.test(result.blockReasons.join(' ')), + `block reason must name ledger corruption; got: "${result.blockReasons.join(' ')}"`, + ); + + // UNCONDITIONAL: strict read precedes resolution, so the resolver is never invoked. + assert.strictEqual(resolveCalled, false, + 'resolver must NOT be called when the ledger is corrupt (strict read precedes _resolve)'); + + // No NEW staging entry was created by the corrupt-upgrade attempt. + const after = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot).sort() : []; + assert.deepStrictEqual(after, before, + 'no new .staging entry may be created when the ledger is corrupt (preflight precedes staging)'); +}); + +test('root-fix-2: corrupt-ledger EXECUTABLE install WITHOUT --yes blocks on CORRUPTION (not aborts on consent)', async (_t) => { + const dir = runtime(); + + // Write a corrupt ledger before attempting install. + const ledgerPath = path.join(dir, '.gsd-capabilities.json'); + fs.writeFileSync(ledgerPath, '{ broken json ---', 'utf8'); + + let resolveCalled = false; + const trackingResolve = async (spec, opts) => { + resolveCalled = true; + const root = path.join(opts.gsdHome, '.gsd', 'capabilities', '.staging'); + fs.mkdirSync(root, { recursive: true }); + const staged = path.join(root, 'exec-corrupt-test'); + fs.mkdirSync(staged, { recursive: true }); + // An EXECUTABLE capability (declares a hook) — would normally require consent. + const manifest = execCap('execcap', '1.0.0', { script: 'hooks/run.js' }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify(manifest), 'utf8'); + materialize(staged, 'hooks/run.js'); + return { id: 'execcap', version: '1.0.0', stagedDir: staged, integrity: null, source: spec }; + }; + + // No consentGranted (i.e. no --yes). Pre-fix order returned 'aborted' (consent) BEFORE the + // strict read at ~660 ever ran, masking the corruption. Post-fix: corruption is detected FIRST. + const result = await lifecycle.installCapability('./execcap', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: trackingResolve, + // consentGranted intentionally omitted (falsy) — this is the WITHOUT --yes case. + }); + + assert.strictEqual(result.status, 'blocked', + `corrupt-ledger executable install without --yes must be 'blocked' on corruption, ` + + `not 'aborted' on consent; got: ${result.status}`); + assert.notStrictEqual(result.status, 'aborted', + 'must NOT report aborted-on-consent before the corruption is reported'); + assert.ok(result.blockReasons && /corrupt|invalid/i.test(result.blockReasons.join(' ')), + `block reason must name ledger corruption; got: "${(result.blockReasons || []).join(' ')}"`); + assert.strictEqual(resolveCalled, false, + 'resolver must NOT be called — corruption is detected before resolution/consent'); +}); + +// --------------------------------------------------------------------------- +// ROOT FIX 3: unsafe capability ids rejected at install/upgrade before staging. +// A __proto__/constructor/prototype bundle must never be promoted. +// --------------------------------------------------------------------------- + +test('root-fix-3: installCapability rejects unsafe id (constructor) before staging/promotion', async (_t) => { + const dir = runtime(); + + const result = await lifecycle.installCapability('./evil', { + 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, 'unsafe-id-test'); + fs.mkdirSync(staged, { recursive: true }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify( + { id: 'constructor', role: 'feature', version: '1.0.0', title: 'evil', + description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] } + ), 'utf8'); + return { id: 'constructor', version: '1.0.0', stagedDir: staged, integrity: null, source: spec }; + }, + }); + + assert.strictEqual(result.status, 'blocked', + `installCapability must block a capability with id='constructor'; got: ${result.status}`); + assert.ok(result.blockReasons && result.blockReasons.length > 0, 'must have blockReasons'); + // Must NOT have installed a bundle at .gsd/capabilities/constructor + assert.ok( + !fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'constructor')), + 'no .gsd/capabilities/constructor bundle must be promoted', + ); + // Must NOT have a ledger entry for 'constructor' + const l = ledgerMod.readLedger(dir); + assert.ok(!l || !Object.prototype.hasOwnProperty.call(l.entries, 'constructor'), + 'no ledger entry for id=constructor must exist'); +}); + +test('root-fix-3: installCapability rejects __proto__ and prototype ids', async (_t) => { + const dir = runtime(); + + for (const unsafeId of ['__proto__', 'prototype']) { + const res = await lifecycle.installCapability('./evil', { + 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, `unsafe-${unsafeId}`); + fs.mkdirSync(staged, { recursive: true }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify( + { id: unsafeId, role: 'feature', version: '1.0.0', title: 'evil', + description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] } + ), 'utf8'); + return { id: unsafeId, version: '1.0.0', stagedDir: staged, integrity: null, source: spec }; + }, + }); + assert.strictEqual(res.status, 'blocked', + `installCapability must block id='${unsafeId}'; got: ${res.status}`); + } +}); + +test('root-fix-3: upgradeCapability rejects unsafe id (constructor) before staging/promotion', async (_t) => { + const dir = runtime(); + + // Install a safe version first. + await lifecycle.installCapability('./safe', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('safe-cap', '1.0.0')), + }); + + // Attempt an upgrade where the resolved id is 'constructor' (source retargeted to unsafe id). + const result = await lifecycle.upgradeCapability('./evil-upgrade', { + 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, 'unsafe-upgrade-test'); + fs.mkdirSync(staged, { recursive: true }); + fs.writeFileSync(path.join(staged, 'capability.json'), JSON.stringify( + { id: 'constructor', role: 'feature', version: '2.0.0', title: 'evil', + description: 'evil', tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [] } + ), 'utf8'); + return { id: 'constructor', version: '2.0.0', stagedDir: staged, integrity: null, source: spec }; + }, + }); + + assert.strictEqual(result.status, 'blocked', + `upgradeCapability must block unsafe id='constructor'; got: ${result.status}`); + assert.ok(!fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'constructor')), + 'no .gsd/capabilities/constructor bundle must be promoted on upgrade'); +}); + +// --------------------------------------------------------------------------- +// FIX 5: removeCapability failure guidance must NOT reference 'gsd capability reconcile' +// (a non-existent CLI subcommand). +// --------------------------------------------------------------------------- + +test('fix-5: removeCapability commit-fail guidance does not reference nonexistent "gsd capability reconcile" subcommand', async (t) => { + const dir = runtime(); + + // Install a capability. + await lifecycle.installCapability('./fix5cap', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('fix5cap')), + }); + + // Simulate commit failure by making the ledger write's renameSync throw. + const { mock } = require('node:test'); + const realRename = fs.renameSync.bind(fs); + const renameMock = mock.method(fs, 'renameSync', function (src, dst) { + if (typeof dst === 'string' && dst.includes('.gsd-capabilities.json')) { + const err = new Error('EXDEV: cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + } + return realRename(src, dst); + }); + t.after(() => renameMock.mock.restore()); + + const result = lifecycle.removeCapability('fix5cap', { runtimeDir: dir }); + assert.strictEqual(result.status, 'blocked'); + + const reason = (result.blockReasons || []).join(' '); + assert.ok( + !reason.includes('gsd capability reconcile'), + `Failure guidance must not reference non-existent "gsd capability reconcile"; got: "${reason}"`, + ); + // Must still mention a useful recovery action (inspect/restore the ledger file). + assert.ok( + reason.includes('ledger') || reason.includes('.gsd-capabilities.json') || reason.includes('remove'), + `Failure guidance must mention the ledger or re-run remove; got: "${reason}"`, + ); +}); + +// =========================================================================== +// Orthogonal adversarial review (#1462) — durability / concurrency / Windows / +// DoS / UX cross-cutting findings. TDD red-first. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Finding 1 (HIGH): lock liveness via PROCESS START-TIME. The lock has oscillated +// (age-based→lost-update; pid-liveness→pid-reuse-deadlock; deadman→live-steal). +// The convergent design records THIS process's start-time in the lock body and, +// on the steal-decision path, treats a SAME-host holder as live ONLY if its pid +// is alive AND the pid's CURRENT start-time matches the recorded one. That pair +// (pid, start-time) identifies a process INSTANCE, so pid-reuse is detected as a +// start-time MISMATCH and stolen, while a verified-live holder is NEVER stolen +// (even past the deadman). A DIFFERENT host / unparseable-or-no-pid body can't be +// verified locally → stolen only by the deadman fallback. A fresh lock is never +// stolen. +// +// Tests inject DETERMINISTIC isPidAlive / getProcessStartTime via the exported +// _setLockProbes seam so the start-time branches are exercised without depending on +// real OS pids beyond the current process. Every test resets the probes in t.after. +// --------------------------------------------------------------------------- + +/** + * Build a JSON lock body matching the new lockfile shape. `startTime` is included by default; pass + * `startTime: null` to simulate a body that did not record one (legacy-ish / unverifiable). + */ +function lockBody({ pid = process.pid, host = os.hostname(), ts = Date.now(), startTime = 'START-A' } = {}) { + // First `-`-segment is still the pid (legacy token compatibility); the JSON body carries host + startTime. + return JSON.stringify({ token: `${pid}-${ts}-1`, pid, hostname: host, startTime, ts }); +} + +function writeLock(dir, body, ageMs) { + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // Finding 1: age now binds to the BODY's own `ts` for a JSON body (not the file mtime). So a lock + // that is `ageMs` old must carry a `ts` that is `ageMs` in the past — backdate BOTH the body ts (for + // JSON bodies) AND the file mtime (for legacy/no-ts bodies, which still use the mtime fallback). + let written = body; + if (typeof body === 'string' && body.trim().startsWith('{')) { + try { + const obj = JSON.parse(body); + if (obj && typeof obj === 'object' && 'ts' in obj) { + obj.ts = Date.now() - ageMs; // backdate the body's own timestamp to the intended age. + written = JSON.stringify(obj); + } + } catch { /* not JSON after all — write verbatim */ } + } + fs.writeFileSync(lockPath, written, 'utf8'); + const t = new Date(Date.now() - ageMs); + fs.utimesSync(lockPath, t, t); + return lockPath; +} + +/** Install deterministic lock probes and auto-reset them after the test. */ +function withLockProbes(t, { alive, startTime }) { + lifecycle._setLockProbes({ isPidAlive: () => alive, getProcessStartTime: () => startTime }); + t.after(() => lifecycle._resetLockProbes()); +} + +test('finding-1: acquireLock is exported (used by lock unit tests)', () => { + assert.ok(typeof lifecycle.acquireLock === 'function', 'acquireLock must be exported for testing'); + assert.ok(typeof lifecycle._setLockProbes === 'function', '_setLockProbes seam must be exported'); + assert.ok(typeof lifecycle.getProcessStartTime === 'function', 'getProcessStartTime must be exported'); +}); + +// Revert-fails: drop the start-time match from holderVerifiedLive (treat any live pid as live) → +// the verified-live SAME-host holder past the deadman is no longer protected and gets stolen, so +// acquireLock returns a handle and this strictEqual(null) assertion fails. +test('finding-1: a SAME-host VERIFIED-LIVE holder (pid alive + start-time MATCH) is NOT stolen — even past the deadman', (t) => { + const dir = runtime(); + // pid alive AND its observed start-time equals the recorded one → verified-live → sacrosanct. + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 11 * 60 * 1000); + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'a verified-live same-host holder must NEVER be stolen, even past the deadman'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched'); +}); + +// Same protection must hold UNDER the deadman too (the older deadman-only design would also block +// here, but this guards the explicit "verified-live → blocked" branch under the stale window). +// Revert-fails: same as above — drop the start-time match → stolen → handle non-null → fails. +test('finding-1: a SAME-host VERIFIED-LIVE holder (start-time MATCH), stale but under the deadman, is NOT stolen', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000); + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'a stale-but-verified-live same-host holder must NOT be stolen'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched'); +}); + +// Revert-fails: drop the start-time MISMATCH check in holderVerifiedLive (return true on any live +// pid) → the pid-reuse lock (alive pid, but a DIFFERENT current start-time) is treated as live and +// NOT stolen, so acquireLock returns null and this "must be stolen" assertion fails — the permanent +// pid-reuse deadlock the whole design exists to defeat. +test('finding-1: a SAME-host pid-reuse holder (pid alive but start-time MISMATCH) IS stolen after stale', (t) => { + const dir = runtime(); + // pid is "alive" but its CURRENT start-time differs from the recorded one → reuse → steal-eligible. + withLockProbes(t, { alive: true, startTime: 'NEW-START-after-reuse' }); + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'OLD-START-before-crash' }), 2 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a same-host lock whose pid is alive but whose start-time no longer matches (pid-reuse) must be stolen'); + assert.strictEqual(JSON.parse(fs.readFileSync(lockPath, 'utf8')).token, handle.token, + 'the stolen lock body must now carry OUR token'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: drop the dead-pid steal (require the deadman for same-host) → a demonstrably-dead +// local holder past the stale window but under the deadman is never stolen, so acquireLock returns +// null and this "must be stolen" assertion fails. +test('finding-1: a SAME-host stale (>60s, { + const dir = runtime(); + withLockProbes(t, { alive: false, startTime: 'START-A' }); // pid dead → not verified-live + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a same-host stale lock whose pid is dead must be stolen before the deadman timeout'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: drop the "recorded.startTime != null" requirement (treat a live pid with no recorded +// start-time as verified-live) → a same-host live-pid lock that recorded NO start-time would be +// blocked and never stolen, so this "must be stolen" assertion fails. A start-time we cannot verify +// is NOT verified-live. +test('finding-1: a SAME-host live-pid lock with NO recorded start-time is stolen after stale (unverifiable liveness)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); // pid "alive" but body recorded no start-time + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: null }), 2 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a same-host live-pid lock whose body recorded no start-time cannot be verified-live → stolen after stale'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: drop the "observed start-time unobtainable → not live" handling (return true when +// getProcessStartTime is null) → a live-pid lock whose CURRENT start-time can't be read would be +// blocked and never stolen, so this "must be stolen" assertion fails. +test('finding-1: a SAME-host live-pid lock whose CURRENT start-time is unobtainable is stolen after stale', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: null }); // can't observe a current start-time + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), 2 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a same-host live-pid lock whose current start-time is unobtainable cannot be verified-live → stolen'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: route a legacy (no-pid) body into the same-host-pid branch (or steal it before the +// deadman) → a legacy lock under the deadman would be stolen, so this strictEqual(null) fails. +// A no-pid body is unverifiable → only the deadman may reclaim it. +test('finding-1: a stale legacy (no parseable pid) lock UNDER the deadman is NOT stolen (deadman-only)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = writeLock(dir, 'legacy-token-no-pid', 2 * 60 * 1000); // non-JSON, no pid + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'a no-pid legacy lock under the deadman cannot be verified and must NOT be stolen yet'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the legacy lock body must be untouched'); +}); + +// Revert-fails: drop the deadman fallback for the no-pid branch → a legacy lock past the deadman is +// never reclaimed (permanent deadlock), so acquireLock returns null and this "must be stolen" fails. +test('finding-1: a stale legacy (no parseable pid) lock OLDER than the deadman IS stolen (deadman defeats deadlock)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + writeLock(dir, 'legacy-token-no-pid', 11 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, 'a no-pid legacy lock past the deadman must be stolen'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: drop the DIFFERENT-host handling (judge any host by local pid liveness) → a remote +// lock under the deadman gets stolen via a local pid that happens to be alive, so this +// strictEqual(null) assertion fails. Local pid liveness is meaningless cross-host. +test('finding-1: a DIFFERENT-host stale ( { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() + '-OTHER-HOST' }), 2 * 60 * 1000); + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'a different-host lock under the deadman must NOT be stolen (local pid liveness is meaningless cross-host)'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the cross-host lock body must be untouched'); +}); + +// Revert-fails: drop the deadman branch for the different-host case → a remote lock past the deadman +// is never reclaimed, so acquireLock returns null and this "must be stolen" assertion fails. +test('finding-1: a DIFFERENT-host lock OLDER than the deadman timeout IS stolen', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() + '-OTHER-HOST' }), 11 * 60 * 1000); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a different-host lock older than LOCK_DEADMAN_MS must be stolen (deadman defeats cross-host deadlock)'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: remove the fresh-lock short-circuit (age <= LOCK_STALE_MS) → a 1-second-old lock +// would be evaluated for stealing and (with a dead pid) stolen, so this strictEqual(null) fails. +test('finding-1: a FRESH lock (under the stale window) is never stolen regardless of host/pid', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: false, startTime: 'START-A' }); // even a dead pid must not matter while fresh + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname() }), 1000); + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'a fresh lock must never be stolen'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'fresh lock body untouched'); +}); + +// Finding 2 (MEDIUM): the lock body is untrusted. An OVERSIZED lock body must NOT be read whole; it +// is treated as unparseable (no pid/host) → routed to the deadman policy. +// +// The oversized body is crafted so that, IF it were (wrongly) read, it would parse as a SAME-host, +// alive-pid holder with a MISMATCHED start-time (pid-reuse) → which is steal-eligible after stale. So +// WITHOUT the size cap the lock would be STOLEN (handle non-null); WITH the cap it is treated as +// no-pid → NOT stolen under the deadman (handle null). The `strictEqual(null)` assertion therefore +// holds ONLY when the cap is in effect — a true discriminator, not a vacuous pass. +// +// Revert-fails: drop the statSync size-cap in readParsedLockBounded (read the whole body) → the body +// parses as an alive same-host holder with a mismatched start-time and is STOLEN, so acquireLock +// returns a handle and this strictEqual(null) assertion fails. +test('finding-2: an OVERSIZED lock body is treated as unparseable (no pid) and NOT stolen under the deadman', (t) => { + const dir = runtime(); + // pid "alive" but the OBSERVED start-time differs from the recorded one → if the body were read it + // would look like steal-eligible pid-reuse. The size cap must prevent that read entirely. + withLockProbes(t, { alive: true, startTime: 'OBSERVED-NEW' }); + const huge = JSON.stringify({ token: 't', pid: process.pid, hostname: os.hostname(), startTime: 'RECORDED-OLD', ts: Date.now(), pad: 'x'.repeat(70 * 1024) }); + assert.ok(huge.length > 64 * 1024, 'test body must exceed the 64 KiB cap'); + const lockPath = writeLock(dir, huge, 2 * 60 * 1000); // stale, under the deadman + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'an oversized lock body must be treated as unverifiable (no pid) → NOT stolen under the deadman'); + assert.ok(fs.existsSync(lockPath), 'the oversized lock must remain in place (not read/stolen)'); +}); + +// Revert-fails: drop the startTime field from the lock body written by acquireLock → the body has no +// `startTime` key, so this assertion (startTime present + equals the cached self start-time when +// obtainable, else null) fails on the missing key. +test('finding-1: acquireLock records hostname + pid + token + startTime in the lockfile body', () => { + const dir = runtime(); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle, 'acquireLock must succeed on a fresh dir'); + const raw = fs.readFileSync(handle.path, 'utf8'); + let parsed; + assert.doesNotThrow(() => { parsed = JSON.parse(raw); }, 'lock body must be JSON'); + assert.strictEqual(parsed.hostname, os.hostname(), 'lock body must record the hostname'); + assert.strictEqual(parsed.pid, process.pid, 'lock body must record the pid'); + assert.strictEqual(typeof parsed.token, 'string', 'lock body must record the owner token'); + assert.strictEqual(parsed.token, handle.token, 'the recorded token must equal the handle token'); + // startTime must be PRESENT as a key (string when obtainable on this OS, null otherwise) — and must + // equal what getProcessStartTime reports for THIS process (the cached self start-time). + assert.ok('startTime' in parsed, 'lock body must record a startTime key'); + const selfStart = lifecycle.getProcessStartTime(process.pid); + assert.strictEqual(parsed.startTime, selfStart === null ? null : selfStart, + 'recorded startTime must equal this process\'s observed start-time'); + lifecycle.releaseLock(handle); +}); + +// --------------------------------------------------------------------------- +// Finding 1 (HIGH): lock-steal TOCTOU — stale `mtime` age applied to a REPLACEMENT +// lock body. A acquirer must bind its age decision to the SAME body instance it acts +// on: for a JSON body the age comes from the body's own `ts` field (now - body.ts), +// NOT the file `mtime` (a fresh replacement body carries a fresh `ts` → small age → +// not stolen). And immediately BEFORE the atomic rename-steal it must re-stat and +// confirm dev/ino (and, for JSON, body `ts`) are UNCHANGED; if changed → do NOT +// steal, retry the bounded loop (B's fresh lock must not be rename-stolen). +// --------------------------------------------------------------------------- + +// Revert-fails: derive age from `mtime` instead of the body `ts` → the body carries a RECENT ts but +// the file mtime is backdated 2 min, so a mtime-age would read it as STALE and (pid dead) STEAL it, +// making handle non-null. With ts-bound age the lock is FRESH → NOT stolen, so this strictEqual(null) +// holds only when age is bound to the body instance. +test('finding-1: a JSON lock with a RECENT body `ts` but an artificially-OLD mtime is NOT stolen (age binds to the body, not mtime)', (t) => { + const dir = runtime(); + // pid "dead" so a mtime-derived STALE age would steal it; only ts-bound freshness can protect it. + withLockProbes(t, { alive: false, startTime: 'START-A' }); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // Body `ts` is NOW (fresh replacement); file mtime is backdated 2 minutes (would look stale). + const freshBody = JSON.stringify({ token: `${process.pid}-${Date.now()}-1`, pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now() }); + fs.writeFileSync(lockPath, freshBody, 'utf8'); + const old = new Date(Date.now() - 2 * 60 * 1000); + fs.utimesSync(lockPath, old, old); + + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'a JSON lock whose BODY ts is fresh must be treated as FRESH (not stolen) even with an old mtime'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), freshBody, 'the fresh-ts lock body must be untouched'); +}); + +// A legacy/no-`ts` body still uses the mtime age (fallback). Revert-fails: if the fallback to mtime +// for a no-ts body is dropped (e.g. treat missing ts as age 0 = fresh), a stale dead-pid legacy lock +// past the deadman would never be stolen and this "must be stolen" assertion fails. +test('finding-1: a legacy/no-`ts` body falls back to mtime age (stale past deadman → stolen)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + writeLock(dir, 'legacy-token-no-pid', 11 * 60 * 1000); // no ts → mtime age → past deadman + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, 'a no-ts legacy lock past the deadman (mtime age) must be stolen'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: drop the pre-rename dev/ino recheck → when B replaces the lock inode between A's +// steal-decision and A's rename, A rename-steals B's FRESH lock (concurrent mutation). With the +// recheck, the changed inode makes A `continue` (no steal), so the on-disk lock is never renamed — +// this test forces a DIFFERENT ino on every recheck stat and asserts A never steals (returns null, +// body untouched). +test('finding-1: an inode change between the steal-decision and the rename causes a RETRY, not a steal', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → otherwise steal-eligible + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // Stale legacy body (no ts → mtime age), backdated past the deadman so the steal branch is reached. + const body = 'legacy-token-no-pid'; + fs.writeFileSync(lockPath, body, 'utf8'); + const old = new Date(Date.now() - 11 * 60 * 1000); + fs.utimesSync(lockPath, old, old); + + // openSync('wx') always reports the lock held; renameSync would (without the recheck) "succeed". + const realOpen = fs.openSync.bind(fs); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') { + const err = new Error('EEXIST'); err.code = 'EEXIST'; throw err; + } + return realOpen(p, flags, ...rest); + }); + // Alternate the reported inode: the DECISION stat sees ino=1, the pre-rename RECHECK stat sees + // ino=2 (B swapped the inode). Every recheck therefore observes a changed inode → A must retry. + let statCalls = 0; + const realStat = fs.statSync.bind(fs); + const statMock = mock.method(fs, 'statSync', function (p, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock')) { + statCalls += 1; + const ino = (statCalls % 2 === 1) ? 1 : 2; // decision: 1, recheck: 2 (changed) + return { mtimeMs: Date.now() - 11 * 60 * 1000, dev: 1, ino, size: body.length, isFile: () => true }; + } + return realStat(p, ...rest); + }); + // If the recheck were absent, rename would fire and steal; track whether it was ever called on .lock. + let renamedLock = false; + const renameMock = mock.method(fs, 'renameSync', function (from, ...rest) { + if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; } + return require('node:fs').renameSync.wrappedMethod + ? require('node:fs').renameSync.wrappedMethod(from, ...rest) + : undefined; + }); + t.after(() => { openMock.mock.restore(); statMock.mock.restore(); renameMock.mock.restore(); }); + + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'A must NOT acquire (every recheck saw a changed inode → retry, never steal)'); + assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose inode changed under it'); + assert.ok(statCalls >= 2, 'both a decision-stat and a pre-rename recheck-stat must have run'); +}); + +// Revert-fails: drop the pre-rename body `ts` recheck for JSON bodies → when B replaces the JSON body +// with a fresh `ts` (same inode) between A's decision and rename, A still steals. With the ts recheck, +// the changed ts makes A `continue`. Here the DECISION read sees an OLD ts (steal-eligible) but the +// RECHECK read sees a NEW ts → A must NOT steal. +test('finding-1: a body `ts` change between the steal-decision and the rename causes a RETRY, not a steal', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → steal-eligible if stale + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // DECISION body: OLD ts (stale → steal-eligible with a dead pid). RECHECK body: fresh ts (B's swap). + const oldTs = Date.now() - 2 * 60 * 1000; + const oldBody = JSON.stringify({ token: 't-old', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: oldTs }); + const newBody = JSON.stringify({ token: 't-new', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now() }); + fs.writeFileSync(lockPath, oldBody, 'utf8'); + + // Track which fds belong to the .lock so fstat/readSync can be steered for them only. A 'wx' create + // throws EEXIST (held); an O_RDONLY|O_NONBLOCK read open returns the real fd and is registered. + const lockFds = new Set(); + const realOpen = fs.openSync.bind(fs); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') { + const err = new Error('EEXIST'); err.code = 'EEXIST'; throw err; + } + const fd = realOpen(p, flags, ...rest); + if (typeof p === 'string' && p.endsWith('.lock')) lockFds.add(fd); + return fd; + }); + // Same dev/ino across stats (so the body TS — not the inode — is the discriminator under test). + const realStat = fs.statSync.bind(fs); + const statMock = mock.method(fs, 'statSync', function (p, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock')) { + return { mtimeMs: oldTs, dev: 7, ino: 7, size: oldBody.length, isFile: () => true }; + } + return realStat(p, ...rest); + }); + // The body is read via the fd-based reader (fstatSync + readSync). The DECISION read (the 1st + // readSmallRegularFile of the .lock) returns oldBody; every later RECHECK read returns newBody. + // Count fstatSync calls on .lock fds — one per readSmallRegularFile — to alternate the body. + const realFstat = fs.fstatSync.bind(fs); + let bodyReads = 0; + let activeBody = oldBody; + const fstatMock = mock.method(fs, 'fstatSync', function (fd, ...rest) { + if (lockFds.has(fd)) { + bodyReads += 1; + activeBody = bodyReads === 1 ? oldBody : newBody; // decision: old, recheck(s): new + return { isFile: () => true, isDirectory: () => false, size: activeBody.length }; + } + return realFstat(fd, ...rest); + }); + const realReadSync = fs.readSync.bind(fs); + const readMock = mock.method(fs, 'readSync', function (fd, buffer, offset, length, position, ...rest) { + if (lockFds.has(fd)) { + const bytes = Buffer.from(activeBody, 'utf8'); + const n = Math.min(length, bytes.length - (position || 0)); + if (n <= 0) return 0; + bytes.copy(buffer, offset, position || 0, (position || 0) + n); + return n; + } + return realReadSync(fd, buffer, offset, length, position, ...rest); + }); + let renamedLock = false; + const renameMock = mock.method(fs, 'renameSync', function (from) { + if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; } + return undefined; + }); + t.after(() => { openMock.mock.restore(); statMock.mock.restore(); fstatMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); }); + + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'A must NOT steal a JSON lock whose body ts changed (B replaced it) before the rename'); + assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose body ts changed under it'); + assert.ok(bodyReads >= 2, 'both a decision body-read and a pre-rename recheck body-read must have run'); +}); + +// Finding 3 (LOW): sameLockInstance must reject a DISAPPEARING ts, not just a ts mismatch. If the +// DECISION body had a non-null JSON ts but the RECHECK body (same inode) is now no-ts/garbage, the +// "ts re-confirmed before steal" invariant is broken — the body changed under us, so A must retry, +// NOT steal. (The prior code only rejected when BOTH ts were non-null and differed; a null recheck ts +// slipped through as "same".) +// Revert-fails: keep the old `a.ts !== null && b.ts !== null && a.ts !== b.ts` guard → a decision ts +// that goes null on recheck is treated as the SAME instance, so A rename-steals it; this +// strictEqual(null)/renamedLock===false pair fails. +test('finding-3: a body `ts` going NULL between the steal-decision and the rename causes a RETRY, not a steal', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + withLockProbes(t, { alive: false, startTime: 'START-A' }); // dead pid → steal-eligible if stale + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // DECISION body: a JSON body with a non-null OLD ts (stale → steal-eligible with a dead pid). + // RECHECK body: a no-`ts` legacy/garbage body on the SAME inode (B replaced the body content). + const oldTs = Date.now() - 2 * 60 * 1000; + const oldBody = JSON.stringify({ token: 't-old', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: oldTs }); + const recheckBody = 'legacy-no-ts-garbage'; + fs.writeFileSync(lockPath, oldBody, 'utf8'); + + const lockFds = new Set(); + const realOpen = fs.openSync.bind(fs); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') { + const err = new Error('EEXIST'); err.code = 'EEXIST'; throw err; + } + const fd = realOpen(p, flags, ...rest); + if (typeof p === 'string' && p.endsWith('.lock')) lockFds.add(fd); + return fd; + }); + // Same dev/ino across stats (so the body ts disappearing — not the inode — is the discriminator). + const realStat = fs.statSync.bind(fs); + const statMock = mock.method(fs, 'statSync', function (p, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock')) { + return { mtimeMs: oldTs, dev: 9, ino: 9, size: oldBody.length, isFile: () => true }; + } + return realStat(p, ...rest); + }); + // fd-based reader (fstatSync + readSync): 1st .lock body read = oldBody (has ts); later reads = + // recheckBody (no ts). Alternate on the fstatSync call count (one per readSmallRegularFile). + const realFstat = fs.fstatSync.bind(fs); + let bodyReads = 0; + let activeBody = oldBody; + const fstatMock = mock.method(fs, 'fstatSync', function (fd, ...rest) { + if (lockFds.has(fd)) { + bodyReads += 1; + activeBody = bodyReads === 1 ? oldBody : recheckBody; // decision: has-ts, recheck(s): no-ts + return { isFile: () => true, isDirectory: () => false, size: activeBody.length }; + } + return realFstat(fd, ...rest); + }); + const realReadSync = fs.readSync.bind(fs); + const readMock = mock.method(fs, 'readSync', function (fd, buffer, offset, length, position, ...rest) { + if (lockFds.has(fd)) { + const bytes = Buffer.from(activeBody, 'utf8'); + const n = Math.min(length, bytes.length - (position || 0)); + if (n <= 0) return 0; + bytes.copy(buffer, offset, position || 0, (position || 0) + n); + return n; + } + return realReadSync(fd, buffer, offset, length, position, ...rest); + }); + let renamedLock = false; + const renameMock = mock.method(fs, 'renameSync', function (from) { + if (typeof from === 'string' && from.endsWith('.lock')) { renamedLock = true; return; } + return undefined; + }); + t.after(() => { openMock.mock.restore(); statMock.mock.restore(); fstatMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); }); + + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'A must NOT steal a lock whose decision ts went NULL on recheck (body changed)'); + assert.strictEqual(renamedLock, false, 'A must NEVER rename-steal a lock whose body ts disappeared under it'); + assert.ok(bodyReads >= 2, 'both a decision body-read and a pre-rename recheck body-read must have run'); +}); + +// --------------------------------------------------------------------------- +// Finding 2 (HIGH): the lock body is untrusted — its bounded read must go through the +// shared fd-based regular-file reader (reject FIFO/device/non-regular, cap size) so a +// FIFO/device/symlink lock cannot block or read unbounded. A non-regular lock body is +// treated as unparseable (no pid/host) → deadman policy (not stolen under the deadman). +// --------------------------------------------------------------------------- + +// Revert-fails: read the lock body via statSync(path)+readFileSync(path) → a FIFO lock body blocks +// acquireLock forever (no writer). With the fd-based regular-file reader the FIFO body is rejected as +// non-regular → unparseable (no pid) → NOT stolen under the deadman, so acquireLock returns promptly. +test('finding-2: a FIFO lock body does NOT hang acquireLock — treated as unparseable (deadman policy)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + if (!tryMkfifoLife(lockPath)) { t.skip('mkfifo unavailable on this platform'); return; } + const old = new Date(Date.now() - 2 * 60 * 1000); // stale, under the deadman + try { fs.utimesSync(lockPath, old, old); } catch { /* FIFO utimes best-effort */ } + + let handle; + assert.doesNotThrow(() => { handle = lifecycle.acquireLock(dir); }, + 'acquireLock must not hang/throw on a FIFO lock body'); + assert.strictEqual(handle, null, + 'a FIFO (non-regular) lock body is unparseable (no pid) → NOT stolen under the deadman'); +}); + +// --------------------------------------------------------------------------- +// Finding 3 (LOW): partial lock orphan. After openSync(lockPath,'wx') succeeds, a +// body-write/closeSync failure must unlink the just-created (empty) .lock so it does +// not self-block until the deadman. Revert-fails: drop the cleanup-unlink → the +// orphan .lock remains and this "no .lock left behind" assertion fails. +// +// Finding 2 (MEDIUM): the lock body is written with fs.writeFileSync(fd, …) (full-buffer write, no +// short-writes), NOT a bare fs.writeSync(fd, …). Mocking fs.writeFileSync to fail proves the body +// write goes through writeFileSync — if the code regressed to a bare writeSync this mock would NOT +// fire, the write would succeed, and acquireLock would return a handle (this strictEqual(null) fails). +// --------------------------------------------------------------------------- + +test('finding-3/2: a body-write (writeFileSync) failure after the exclusive create leaves NO orphan .lock behind', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + + // Let the exclusive create succeed (real openSync), then force the body write to fail. The body MUST + // be written via writeFileSync (finding 2), so mocking writeFileSync to throw is what trips it; if + // the code used a bare writeSync this would never fire (proving writeFileSync is the write path). + let writeFileFired = false; + const writeFileMock = mock.method(fs, 'writeFileSync', function (fd) { + // Only fail the fd-targeted lock body write (a numeric fd), not any path-based writes. + if (typeof fd === 'number') { + writeFileFired = true; + const err = new Error('ENOSPC: no space left on device'); err.code = 'ENOSPC'; throw err; + } + return undefined; + }); + t.after(() => { writeFileMock.mock.restore(); }); + + const handle = lifecycle.acquireLock(dir); + assert.ok(writeFileFired, 'the lock body must be written via fs.writeFileSync(fd, …) (finding 2 short-write fix)'); + assert.strictEqual(handle, null, 'acquireLock must return null when the lock body write fails'); + assert.ok(!fs.existsSync(lockPath), + 'the empty .lock created before the failed write must be unlinked (no self-blocking orphan)'); +}); + +// Finding 5(c): the closeSync-failure variant. After openSync(lockPath,'wx') succeeds and the body +// write succeeds, a closeSync failure must ALSO unlink the just-created .lock (the body may be +// unflushed/partial) so no self-blocking orphan remains until the deadman. +// Revert-fails: drop the closeSync-error cleanup-unlink in acquireLock → the .lock written before the +// failed close is left behind and this "no .lock left behind" assertion fails. +test('finding-3: a closeSync failure after the exclusive create+write leaves NO orphan .lock behind', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + + // Let the exclusive create AND the body write succeed; force ONLY the .lock fd's closeSync to fail. + // Track which fds belong to the .lock so unrelated closeSync calls (dir fsync, etc.) are untouched. + const realOpen = fs.openSync.bind(fs); + const lockFds = new Set(); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + const fd = realOpen(p, flags, ...rest); + if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') lockFds.add(fd); + return fd; + }); + const realClose = fs.closeSync.bind(fs); + const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) { + if (lockFds.has(fd)) { + lockFds.delete(fd); + const err = new Error('EIO: delayed-writeback failure on close'); err.code = 'EIO'; throw err; + } + return realClose(fd, ...rest); + }); + t.after(() => { openMock.mock.restore(); closeMock.mock.restore(); }); + + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, 'acquireLock must return null when the lock fd close fails'); + assert.ok(!fs.existsSync(lockPath), + 'the .lock created before the failed close must be unlinked (no self-blocking orphan)'); +}); + +// --------------------------------------------------------------------------- +// Finding 1 (HIGH): a FUTURE / implausibly-far-future body `ts` must NOT deadlock the +// lock forever. age = now - ts goes negative (or stays tiny) for a future ts, keeping +// age <= LOCK_STALE_MS so the lock is NEVER stale/deadman/steal-eligible → permanent +// block. The fix distrusts a future ts and falls back to the file `mtime` for the age, +// so stale/deadman recovery still bounds the lock. +// --------------------------------------------------------------------------- + +// Revert-fails: drop the future-ts guard (use `now - ts` even when ts is in the future) → the +// far-future body ts makes age negative (<= LOCK_STALE_MS) forever, so the lock is never stolen and +// acquireLock returns null — this "must be stolen" assertion fails (the permanent deadlock). +test('finding-1: a lock whose JSON body `ts` is far in the FUTURE is still reclaimed (mtime fallback), not blocked forever', (t) => { + const dir = runtime(); + // Different host + past the deadman by MTIME so the deadman branch reclaims it once the future ts is + // distrusted. (A future ts under the trusting code would compute a negative age → never stale.) + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + // Body ts is 1 hour in the FUTURE; file mtime is backdated 11 min (past the deadman). + const futureBody = JSON.stringify({ + token: 't-future', pid: process.pid, hostname: os.hostname() + '-OTHER-HOST', + startTime: 'START-A', ts: Date.now() + 60 * 60 * 1000, + }); + fs.writeFileSync(lockPath, futureBody, 'utf8'); + const old = new Date(Date.now() - 11 * 60 * 1000); + fs.utimesSync(lockPath, old, old); + + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a lock with a far-FUTURE body ts must distrust the ts and fall back to mtime age → reclaimed past the deadman'); + lifecycle.releaseLock(handle); +}); + +// --------------------------------------------------------------------------- +// Finding 4 (LOW): releaseLock check-then-unlink TOCTOU minimization. The original +// holder must rmSync ONLY when its OWN owner token is still in the lock body. A +// SUCCESSOR lock (a new acquirer's lock at the same path, with a DIFFERENT token) +// must NOT be deleted by the original holder's stale release. +// +// The PRIMARY, portable discriminator is the TOKEN re-check: a real successor wrote a +// different token, so releaseLock reads a non-matching token and refuses to delete. +// (The dev/ino recheck in releaseLock is a best-effort SECONDARY guard that may be +// defeated by inode reuse on some filesystems — e.g. Linux ext4/overlay reusing the +// freed inode after unlink+recreate — so this test does NOT rely on it: it asserts the +// token mechanism, which holds on macOS AND Linux.) +// --------------------------------------------------------------------------- + +// Revert-fails: drop the token re-check in releaseLock (delete on inode-match-only, or unconditionally) +// → the original holder rmSyncs the successor's lock at the same path even though the body now carries a +// DIFFERENT token, so the successor .lock is deleted and this "successor survives" assertion fails. The +// token re-check (body token != handle.token) is what prevents the delete. +test('finding-4: releaseLock does NOT delete a SUCCESSOR lock (different token) at the same path', () => { + const dir = runtime(); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + + // The original holder acquires (captures its token + dev/ino in the handle). + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.path === lockPath, 'original holder must acquire the lock'); + + // Simulate the lock being stale-stolen then re-created by a SUCCESSOR at the same path with a + // DIFFERENT token. We do NOT mock the body read — releaseLock reads the REAL successor token below. + // (We deliberately do not assert anything about the inode: Linux may reuse the freed inode after + // unlink+recreate, so inode-distinctness is non-portable and is NOT the mechanism under test.) + fs.unlinkSync(lockPath); + const successorBody = JSON.stringify({ + token: 'successor-token', pid: process.pid, hostname: os.hostname(), startTime: 'START-A', ts: Date.now(), + }); + fs.writeFileSync(lockPath, successorBody, 'utf8'); + assert.notStrictEqual(handle.token, 'successor-token', 'the successor token must differ from the original holder token'); + + lifecycle.releaseLock(handle); + + assert.ok(fs.existsSync(lockPath), 'the SUCCESSOR lock at the same path must NOT be deleted by the original holder'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), successorBody, + 'the successor lock body must be untouched (original holder read a non-matching token and did not rmSync it)'); +}); + +// Sanity companion: the NORMAL case (same inode + our token) still releases (so the inode guard does +// not break legitimate release). Revert this would not be a fix-revert; it pins that the guard is not +// over-strict (the dev/ino captured at acquire matches the unchanged on-disk lock → rmSync runs). +test('finding-4: releaseLock STILL deletes our own unchanged lock (inode guard is not over-strict)', () => { + const dir = runtime(); + const lockPath = path.join(dir, '.gsd', 'capabilities', '.lock'); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle, 'must acquire'); + assert.ok(fs.existsSync(lockPath), 'lock present before release'); + lifecycle.releaseLock(handle); + assert.ok(!fs.existsSync(lockPath), 'our own unchanged lock (matching token + inode) must be released'); +}); + +// --------------------------------------------------------------------------- +// CONC-2 / DOS-1 (LOW): acquireLock must be a BOUNDED iterative loop, not +// unbounded recursion. A pathological never-acquirable lock must return null +// without a stack overflow. +// Revert-fails: restore the recursive `return acquireLock(runtimeDir)` calls → +// the forced infinite contention recurses until RangeError (stack overflow), +// so this test throws instead of returning null within the attempt cap. +// --------------------------------------------------------------------------- + +test('CONC-2: acquireLock returns null on contention exhaustion WITHOUT a stack overflow (bounded loop)', (t) => { + const dir = runtime(); + const { mock } = require('node:test'); + fs.mkdirSync(path.join(dir, '.gsd', 'capabilities'), { recursive: true }); + + // Force every open to look "held" (EEXIST) and every stat to look STALE with a DEAD pid, + // so the steal path is always taken — but the rename never actually frees the lock (we make + // the lockfile re-appear). This exercises the retry loop to exhaustion. + const realOpen = fs.openSync.bind(fs); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock') && flags === 'wx') { + const err = new Error('EEXIST: file already exists'); + err.code = 'EEXIST'; + throw err; + } + return realOpen(p, flags, ...rest); + }); + // statSync: present + stale (old mtime) so the steal branch is taken every time. + const statMock = mock.method(fs, 'statSync', function (p, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock')) { + return { mtimeMs: Date.now() - 10 * 60 * 1000, isFile: () => true }; + } + return require('node:fs').statSync.wrappedMethod + ? require('node:fs').statSync.wrappedMethod(p, ...rest) + : p; + }); + // The lockfile reads as a dead-pid token so the steal is "allowed" but never succeeds in + // freeing the path (open keeps throwing EEXIST). + const realReadFile = fs.readFileSync.bind(fs); + const readMock = mock.method(fs, 'readFileSync', function (p, ...rest) { + if (typeof p === 'string' && p.endsWith('.lock')) return '999999999-1-1'; + return realReadFile(p, ...rest); + }); + // rename "succeeds" (so we proceed to retry) but the next open still throws EEXIST. + const renameMock = mock.method(fs, 'renameSync', function () { /* no-op: lock stays held */ }); + const rmMock = mock.method(fs, 'rmSync', function () { /* no-op */ }); + t.after(() => { openMock.mock.restore(); statMock.mock.restore(); readMock.mock.restore(); renameMock.mock.restore(); rmMock.mock.restore(); }); + + let handle; + assert.doesNotThrow( + () => { handle = lifecycle.acquireLock(dir); }, + 'acquireLock must not throw (no stack overflow) under pathological contention', + ); + assert.strictEqual(handle, null, 'acquireLock must return null on attempt exhaustion'); +}); + +// --------------------------------------------------------------------------- +// MEDIUM finding — future mtime can deadlock lock recovery. +// +// `lockAgeMs` already distrusts a future body `ts` and falls back to `mtime`. +// But if `mtime` is ALSO in the future (planted lock, or system clock stepped +// backward after the lock was written), `Date.now() - mtimeMs` is negative → +// `age <= LOCK_STALE_MS` stays true → the lock is treated as "fresh" forever → +// permanent block of all capability mutations. +// +// Fix: when the mtime fallback also yields a negative age (untrustworthy source), +// return `Number.MAX_SAFE_INTEGER` so the lock routes into the normal steal +// decision tree. The verified-live guard (same-host + pid alive + start-time +// match) is age-independent and still prevents false steals. +// --------------------------------------------------------------------------- + +// Revert-fails: revert the `Number.MAX_SAFE_INTEGER` clamp in lockAgeMs (leave the +// raw `Date.now() - mtimeMs` when it is negative) → age stays negative → +// `age <= LOCK_STALE_MS` is always true → acquireLock returns null instead of a +// handle, so this `assert.ok(handle && handle.token)` fails. +test('MEDIUM: future mtime + DEAD pid → lock IS stolen (negative mtime age must not deadlock recovery)', (t) => { + const dir = runtime(); + // Probe: pid is dead AND no start-time → definitely not verified-live. + withLockProbes(t, { alive: false, startTime: 'START-A' }); + // Write a lock with FUTURE body ts AND future mtime (negative ageMs = future). + // writeLock(dir, body, ageMs) sets both `obj.ts = Date.now() - ageMs` and + // `utimesSync` to `Date.now() - ageMs`; negative ageMs pushes both into the future. + const FUTURE_MS = -5 * 60 * 1000; // 5 minutes in the future + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a future-mtime lock with a dead pid must be stolen (negative age must clamp to MAX_SAFE_INTEGER, not block forever)'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: revert the clamp → age stays negative → acquireLock returns null +// (permanent block), so this `ok(handle)` fails. +test('MEDIUM: future body ts + future mtime + dead/unverifiable pid → lock IS stolen (both sources untrustworthy)', (t) => { + const dir = runtime(); + withLockProbes(t, { alive: true, startTime: null }); // alive but start-time unobservable → unverifiable + const FUTURE_MS = -10 * 60 * 1000; // 10 minutes in the future + writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS); + const handle = lifecycle.acquireLock(dir); + assert.ok(handle && handle.token, + 'a lock with both future body-ts and future mtime, held by an unverifiable pid, must be stolen'); + lifecycle.releaseLock(handle); +}); + +// Revert-fails: NOT a fix-revert; pins that the clamp does NOT break the verified-live +// protection. A future-mtime lock whose holder is SAME-host + pid alive + start-time +// MATCH must NOT be stolen even after the age clamp kicks in (clamping to MAX_SAFE_INTEGER +// makes it steal-eligible by age, but the verified-live check is age-independent and still +// blocks the steal). If the clamp incorrectly bypasses verified-live, acquireLock returns a +// handle here instead of null, and the `strictEqual(null)` fails. +test('MEDIUM: future mtime + VERIFIED-LIVE same-host holder → lock is NOT stolen (clamp does not break liveness guard)', (t) => { + const dir = runtime(); + // Probe: pid alive AND observed start-time MATCHES the recorded one → verified-live. + withLockProbes(t, { alive: true, startTime: 'START-A' }); + const FUTURE_MS = -5 * 60 * 1000; + const lockPath = writeLock(dir, lockBody({ pid: process.pid, host: os.hostname(), startTime: 'START-A' }), FUTURE_MS); + const original = fs.readFileSync(lockPath, 'utf8'); + const handle = lifecycle.acquireLock(dir); + assert.strictEqual(handle, null, + 'a future-mtime lock held by a verified-live same-host process must NOT be stolen'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf8'), original, 'the verified-live lock body must be untouched'); +}); + +// --------------------------------------------------------------------------- +// CONC-3 (LOW): backup names must carry a crypto nonce so two processes upgrading +// at the same millisecond cannot collide. +// Revert-fails: drop the randomBytes nonce from the upgrade backupName → with +// Date.now and pid stubbed equal the second upgrade's backupName equals the +// first's, so the captured backup names are identical and the inequality fails. +// --------------------------------------------------------------------------- + +test('CONC-3: upgrade backupName includes a random nonce (collision-resistant across same-ms processes)', async (t) => { + const dir = runtime(); + await lifecycle.installCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(execCap('e', '1.0.0', { script: 'hooks/a.js' })), + }); + + // Capture the backupName the upgrade records into the _pending intent by spying on + // ledgerMod.recordInstall and reading the _pending.backupName. + const { mock } = require('node:test'); + const capturedBackupNames = []; + const realRecord = ledgerMod.recordInstall.bind(ledgerMod); + const recordMock = mock.method(ledgerMod, 'recordInstall', function (rd, entry) { + if (entry && entry._pending && typeof entry._pending.backupName === 'string') { + capturedBackupNames.push(entry._pending.backupName); + } + return realRecord(rd, entry); + }); + // Freeze Date.now so the timestamp portion is identical between two upgrades — only the + // nonce can differ. + const realNow = Date.now; + Date.now = () => 1700000000000; + t.after(() => { recordMock.mock.restore(); Date.now = realNow; }); + + await lifecycle.upgradeCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(execCap('e', '2.0.0', { script: 'hooks/a.js' })), + }); + await lifecycle.upgradeCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(execCap('e', '3.0.0', { script: 'hooks/a.js' })), + }); + + assert.ok(capturedBackupNames.length >= 2, + `must capture at least two upgrade backupNames; got: ${JSON.stringify(capturedBackupNames)}`); + assert.notStrictEqual(capturedBackupNames[0], capturedBackupNames[1], + `same-ms upgrade backup names must differ via the random nonce; got both: ${capturedBackupNames[0]}`); +}); + +// --------------------------------------------------------------------------- +// DUR-3 (HIGH): promoteStagingToFinal must fsync the parent directory after BOTH +// renames (old→backup AND staging→final) so a crash between them cannot lose the +// backup → reconcile silent-uninstall. The upgrade/reinstall path does exactly +// two renames, so it must produce exactly TWO parent-dir fsyncs. +// Revert-fails: drop EITHER fsyncDir(parent) in promoteStagingToFinal → the count +// drops to 1 (or 0) and the `=== 2` assertion fails (a one-fsync regression that the +// prior `>= 1` assertion would have silently passed). +// +// Note: the fd-tracking set REMOVES fds on close, because once a capsRoot dir fd is +// closed the OS may reuse the SAME fd number for an unrelated open (e.g. the ledger +// write's containing-dir fsync of runtimeDir), which must NOT be miscounted. Without +// close-tracking the count is noisy (observed 4) and would mask a regression to 2/3. +// --------------------------------------------------------------------------- + +test('DUR-3: promoteStagingToFinal fsyncs the parent directory after BOTH renames (exactly two, durable backup swap)', async (t) => { + const dir = runtime(); + // First install so a prior bundle exists (so the upgrade path renames old→backup). + await lifecycle.installCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('e', '1.0.0')), + }); + + const capsRoot = path.join(dir, '.gsd', 'capabilities'); + const { mock } = require('node:test'); + const realOpen = fs.openSync.bind(fs); + const realFsync = fs.fsyncSync.bind(fs); + const realClose = fs.closeSync.bind(fs); + const dirFds = new Set(); + let parentDirFsyncs = 0; + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + const fd = realOpen(p, flags, ...rest); + if (typeof p === 'string' && path.resolve(p) === path.resolve(capsRoot) && flags === 'r') dirFds.add(fd); + return fd; + }); + const fsyncMock = mock.method(fs, 'fsyncSync', function (fd, ...rest) { + if (dirFds.has(fd)) parentDirFsyncs++; + return realFsync(fd, ...rest); + }); + const closeMock = mock.method(fs, 'closeSync', function (fd, ...rest) { + // Drop the fd from the tracked set BEFORE closing: a reused fd number for a later non-capsRoot + // open must not be counted as a capsRoot dir fsync. + if (dirFds.has(fd)) dirFds.delete(fd); + return realClose(fd, ...rest); + }); + t.after(() => { openMock.mock.restore(); fsyncMock.mock.restore(); closeMock.mock.restore(); }); + + // Reinstall over the existing bundle (upgrade-like path → old→backup, staging→final = two renames). + const res = await lifecycle.installCapability('./e', { + runtimeDir: dir, hostVersion: '1.6.0', consentGranted: true, + _resolve: fakeResolve(declarativeCap('e', '2.0.0')), + }); + assert.strictEqual(res.status, 'installed'); + assert.strictEqual(parentDirFsyncs, 2, + `promoteStagingToFinal must fsync the parent dir after BOTH renames (exactly 2); fsync count=${parentDirFsyncs}`); +}); + +// --------------------------------------------------------------------------- +// Finding 4 (MEDIUM): the directory fsync in the lifecycle (fsyncDir, used by +// promoteStagingToFinal) must NOT swallow ALL errors. It tolerates ONLY +// EISDIR/EPERM/EINVAL/EBADF; any other errno (e.g. EIO) RETHROWS (durability +// could not be confirmed). The dir fd must still be closed. +// --------------------------------------------------------------------------- + +/** Mock fs.fsyncSync to throw `errno` ONLY for the capabilities-root dir fd (opened 'r'). */ +function withLifecycleDirFsyncError(t, capsRoot, errno) { + const dirFds = new Set(); + const closed = []; + const realOpen = fs.openSync.bind(fs); + const openMock = mock.method(fs, 'openSync', function (p, flags, ...rest) { + const fd = realOpen(p, flags, ...rest); + if (typeof p === 'string' && path.resolve(p) === path.resolve(capsRoot) && flags === 'r') dirFds.add(fd); + return fd; + }); + const realClose = fs.closeSync.bind(fs); + 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 (e.g. writeLedger's temp file), which must NOT be treated + // as the capabilities-root dir 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 { closed }; +} + +// Revert-fails: restore the swallow-all `catch {}` in fsyncDir → the EIO dir-fsync +// during promotion is swallowed, the install COMMITS and returns 'installed', so +// this "must be blocked" assertion fails. +test('finding-4: a NON-tolerated dir-fsync errno (EIO) during install promotion surfaces as blocked (durability not silently claimed)', async (t) => { + const dir = runtime(); + const capsRoot = path.join(dir, '.gsd', 'capabilities'); + fs.mkdirSync(capsRoot, { recursive: true }); + const { closed } = withLifecycleDirFsyncError(t, capsRoot, 'EIO'); + + const res = await lifecycle.installCapability('./d', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('d', '1.0.0')), + }); + assert.strictEqual(res.status, 'blocked', + 'an EIO directory-fsync error during promotion must NOT be swallowed (install blocked)'); + assert.ok((res.blockReasons || []).some((r) => /durab/i.test(r)), + `block reason must indicate durability could not be confirmed; got ${JSON.stringify(res.blockReasons)}`); + assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)'); +}); + +// Revert-fails: remove the tolerated-errno allowlist (rethrow EVERYTHING) → EISDIR +// would block the install, so this 'installed' assertion fails. +test('finding-4: a TOLERATED dir-fsync errno (EISDIR) during install promotion is ignored (install succeeds)', async (t) => { + const dir = runtime(); + const capsRoot = path.join(dir, '.gsd', 'capabilities'); + fs.mkdirSync(capsRoot, { recursive: true }); + const { closed } = withLifecycleDirFsyncError(t, capsRoot, 'EISDIR'); + + const res = await lifecycle.installCapability('./d', { + runtimeDir: dir, hostVersion: '1.6.0', + _resolve: fakeResolve(declarativeCap('d', '1.0.0')), + }); + assert.strictEqual(res.status, 'installed', + 'an EISDIR directory-fsync error must be tolerated (best-effort) — install succeeds'); + assert.ok(closed.length >= 1, 'the directory fd must still be closed (finally)'); +}); + +// --------------------------------------------------------------------------- +// DUR-6 (LOW): reconcile upgrade-rollback must renameSync(backup→final) FIRST +// (atomic replace), not rmSync(final) before the rename — a crash between the +// two leaves both gone. +// Revert-fails: restore `rmSync(finalDir)` BEFORE `renameSync(backupDir, finalDir)` +// → if we make ONLY the post-rmSync rename fail, the old rmSync-first order has +// already destroyed finalDir, so the bundle is lost; the new order renames first +// (no rmSync of finalDir) so the bundle survives. This test injects a crash right +// after a (hypothetical) rmSync of finalDir and asserts the final bundle survives. +// --------------------------------------------------------------------------- + +test('DUR-6: reconcile upgrade-rollback renames backup→final atomically (no rmSync-before-rename data loss)', (t) => { + const dir = runtime(); + // Seed an in-flight upgrade: a NEW (uncommitted) bundle live + the OLD backup set aside, + // with a pending upgrade intent naming the backup. + const backupName = 'c.upgrading-111-222'; + seedCapDir(dir, 'c', declarativeCap('c', '2.0.0')); // new/uncommitted live dir + seedCapDir(dir, backupName, declarativeCap('c', '1.0.0')); // old backup to restore + recordPending(dir, 'c', '2.0.0', { kind: 'upgrade', backupName, sharedFiles: [] }); + + // Spy: assert reconcile NEVER rmSyncs the finalDir before the backup rename. If the buggy + // order is restored, finalDir is rmSync'd first; the new order must rename the backup over + // finalDir directly (renameSync replaces atomically), so no rmSync of finalDir occurs. + const { mock } = require('node:test'); + const finalDir = path.join(dir, '.gsd', 'capabilities', 'c'); + const realRm = fs.rmSync.bind(fs); + const realRename = fs.renameSync.bind(fs); // capture BEFORE mocking + let finalDirRmBeforeRename = false; + let backupRenamedToFinal = false; + const renameMock = mock.method(fs, 'renameSync', function (src, dst, ...rest) { + if (typeof src === 'string' && typeof dst === 'string' + && path.resolve(src) === path.resolve(path.join(dir, '.gsd', 'capabilities', backupName)) + && path.resolve(dst) === path.resolve(finalDir)) { + backupRenamedToFinal = true; + } + return realRename(src, dst, ...rest); + }); + const rmMock = mock.method(fs, 'rmSync', function (p, ...rest) { + if (typeof p === 'string' && path.resolve(p) === path.resolve(finalDir) && !backupRenamedToFinal) { + finalDirRmBeforeRename = true; + } + return realRm(p, ...rest); + }); + t.after(() => { renameMock.mock.restore(); rmMock.mock.restore(); }); + + const report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); + assert.ok(report.rolledBack.includes('c'), 'upgrade rollback must roll back c'); + assert.strictEqual(finalDirRmBeforeRename, false, + 'reconcile must NOT rmSync(finalDir) before renaming the backup over it (DUR-6 ordering)'); + assert.ok(backupRenamedToFinal, 'reconcile must renameSync(backup→final) to restore the old bundle'); + // The restored bundle is the OLD version. + assert.strictEqual(capManifestVersion(dir, 'c'), '1.0.0', 'rolled-back bundle must be the old version'); +}); + +// --------------------------------------------------------------------------- +// DOS-2 (MED): reconcile step-1 must accumulate mutations and write the ledger +// ONCE at the end of step 1, not once per pending entry. +// Revert-fails: restore per-entry recordInstall/removeEntry/writeLedger calls in +// step 1 → with N pending entries the ledger is written N times, so the spied +// writeLedger call count exceeds 1 for step-1 mutations and the "<= a small +// bound" assertion fails. +// --------------------------------------------------------------------------- + +test('DOS-2: reconcile with N pending entries writes the ledger at most once for step-1 mutations', (t) => { + const dir = runtime(); + // Seed several uncommitted FRESH installs (kind 'install') — each would, in the buggy + // version, trigger its own removeEntry → writeLedger. + const ids = ['a-cap', 'b-cap', 'c-cap', 'd-cap']; + for (const id of ids) { + recordPending(dir, id, '1.0.0', { kind: 'install', backupName: null, sharedFiles: [] }); + // Do NOT create the on-disk dir → safeRmUnder returns true (already gone) → entry rolled back. + } + + const { mock } = require('node:test'); + let ledgerWrites = 0; + const realWrite = ledgerMod.writeLedger.bind(ledgerMod); + const writeMock = mock.method(ledgerMod, 'writeLedger', function (rd, ledger) { + ledgerWrites++; + return realWrite(rd, ledger); + }); + // removeEntry also writes the ledger internally; spy it too so any per-entry path is visible. + let removeEntryCalls = 0; + const realRemove = ledgerMod.removeEntry.bind(ledgerMod); + const removeMock = mock.method(ledgerMod, 'removeEntry', function (rd, id) { + removeEntryCalls++; + return realRemove(rd, id); + }); + t.after(() => { writeMock.mock.restore(); removeMock.mock.restore(); }); + + const report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); + // All N entries must be rolled back. + for (const id of ids) { + assert.ok(report.rolledBack.includes(id), `${id} must be rolled back`); + assert.strictEqual(readLedgerEntry(dir, id), null, `${id} entry must be removed`); + } + // Step-1 must batch: at most ONE ledger write for the step-1 mutations (plus possibly + // the read-only reconcile() at the end does no write). It must be far below N. + assert.ok(ledgerWrites <= 1, + `step-1 must write the ledger at most once for N=${ids.length} pending entries; writes=${ledgerWrites}`); + assert.strictEqual(removeEntryCalls, 0, + `step-1 batching must not call removeEntry per entry; calls=${removeEntryCalls}`); +}); + +// --------------------------------------------------------------------------- +// W-6 (NIT): reconcile's removeEntry/recordInstall calls can now throw (strict). +// One bad entry must NOT abort the whole reconcile — it must warn and continue. +// Revert-fails: remove the per-entry try/catch around the rollback mutations → +// a throw on the first entry propagates out of the loop, so the SECOND (good) +// entry is never rolled back and a warning is never recorded; the test's +// "good entry still rolled back" + "warning recorded" assertions fail. +// --------------------------------------------------------------------------- + +test('W-6: a throwing per-entry mutation does not abort reconcile (warns and continues)', (t) => { + const dir = runtime(); + // 'bad-cap' is an uncommitted UPGRADE with a backup; we make a per-entry filesystem call throw + // for ITS backup path only. 'good-cap' is an uncommitted FRESH install that must still roll back. + const badBackup = 'bad-cap.upgrading-111-222'; + seedCapDir(dir, badBackup, declarativeCap('bad-cap', '1.0.0')); + recordPending(dir, 'bad-cap', '1.0.0', { kind: 'upgrade', backupName: badBackup, sharedFiles: [] }); + recordPending(dir, 'good-cap', '1.0.0', { kind: 'install', backupName: null, sharedFiles: [] }); + + const { mock } = require('node:test'); + const realExists = fs.existsSync.bind(fs); + const badBackupPath = path.join(dir, '.gsd', 'capabilities', badBackup); + // existsSync(backupDir) is a direct per-entry call in reconcile's step-1 loop (outside the inner + // restore try/catch) — making it throw for bad-cap exercises the W-6 per-entry catch. + const existsMock = mock.method(fs, 'existsSync', function (p) { + if (typeof p === 'string' && path.resolve(p) === path.resolve(badBackupPath)) { + throw new Error('simulated per-entry IO failure for bad-cap'); + } + return realExists(p); + }); + t.after(() => existsMock.mock.restore()); + + let report; + assert.doesNotThrow( + () => { report = lifecycle.reconcileCapabilities({ runtimeDir: dir }); }, + 'a throwing per-entry mutation must not abort the whole reconcile', + ); + + // The GOOD entry must still be rolled back despite the bad one throwing. + assert.ok(report.rolledBack.includes('good-cap'), + `good-cap must still be rolled back after bad-cap threw; rolledBack=${JSON.stringify(report.rolledBack)}`); + assert.strictEqual(readLedgerEntry(dir, 'good-cap'), null, 'good-cap entry removed'); + // The bad entry must be LEFT in place (not silently committed) for a later retry. + assert.ok(readLedgerEntry(dir, 'bad-cap'), 'bad-cap entry must remain (not silently dropped)'); + // A warning must record the bad entry. + assert.ok(Array.isArray(report.warnings) && report.warnings.some((w) => /bad-cap/.test(w)), + `a warning must name the failed entry; warnings=${JSON.stringify(report.warnings)}`); +}); + +// --------------------------------------------------------------------------- +// W-3 / DUR-5 (LOW): reconcile step-2 must sweep stale `.gsd-capabilities.json.tmp.*` +// orphan temp files (older than a threshold) from the runtime dir. +// Revert-fails: remove the stale-temp sweep → the old orphan temp file remains +// after reconcile, so the "orphan removed" assertion fails. +// --------------------------------------------------------------------------- + +test('W-3/DUR-5: reconcile sweeps a stale .gsd-capabilities.json.tmp.* orphan from the runtime dir', () => { + const dir = runtime(); + fs.mkdirSync(dir, { recursive: true }); + // A committed, valid ledger so reconcile proceeds past the corruption preflight. + ledgerMod.recordInstall(dir, { id: 'z', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] }); + + // Plant a STALE orphan temp (older than the 5-min threshold) and a FRESH one (must be kept). + const staleTmp = path.join(dir, `${ledgerMod.LEDGER_FILE_NAME}.tmp.99999-deadbeef`); + const freshTmp = path.join(dir, `${ledgerMod.LEDGER_FILE_NAME}.tmp.99998-cafef00d`); + fs.writeFileSync(staleTmp, 'orphan'); + fs.writeFileSync(freshTmp, 'fresh'); + const old = new Date(Date.now() - 10 * 60 * 1000); + fs.utimesSync(staleTmp, old, old); + + lifecycle.reconcileCapabilities({ runtimeDir: dir }); + + assert.ok(!fs.existsSync(staleTmp), 'stale orphan tmp file must be swept by reconcile (W-3/DUR-5)'); + assert.ok(fs.existsSync(freshTmp), 'a fresh tmp file (possible in-flight write) must NOT be swept'); +});