/** * Capability ledger module — ADR-1244 Phase 3 (Decision D4). * * Manages a per-runtime install manifest (`.gsd-capabilities.json`) that records * 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:crypto. No other src/ imports. * * Exports: * readLedger(runtimeDir) — structural-validated read, never throws * 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'; import crypto from 'node:crypto'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- 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 // --------------------------------------------------------------------------- interface LedgerEntry { id: string; version: string; source: string; integrity: string; files: string[]; sharedEdits: Array<{ file: string; marker: string }>; } interface LedgerFile { /** Ledger schema version — currently '1'. */ version: string; /** ISO-8601 timestamp of the last write. */ updatedAt: string; /** Map of capability id → LedgerEntry. */ entries: Record; } // --------------------------------------------------------------------------- // 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-]*$/; /** * 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. */ 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 { const buf = readSmallRegularFileBuffer(filePath, maxBytes); if (buf === null) return null; // Decode to UTF-8 for STRING consumers (JSON parsers, lock-body parsers). This decode is LOSSY for // binary content (invalid byte sequences → U+FFFD), so a content-hash binding must NOT use this — // it must hash the RAW bytes via readSmallRegularFileBuffer (#1459 finding 1b: a swapped binary // artifact differing only in invalid-UTF-8 bytes would otherwise not change the digest). return buf.toString('utf8'); } /** * #1459 finding 1 (HIGH): the RAW-BYTES variant of readSmallRegularFile. Identical open → fstat → * require-regular-file → size-cap → read-exactly-size protocol (so a FIFO/device/swapped/oversized * untrusted file can never block or read unbounded), but returns the bytes as a Buffer WITHOUT a * UTF-8 decode. This is the SOLE correct reader for the consent content-hash binding: the binding * must be byte-exact and INJECTIVE, and a utf8 decode is lossy (collapses distinct invalid byte * sequences to U+FFFD) so two different binary artifacts could collide. Returns the bytes, or null * for ENOENT (genuinely missing); throws LedgerIOError for every other fail-closed condition. */ function readSmallRegularFileBuffer(filePath: string, maxBytes: number): Buffer | 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 Buffer.alloc(0); 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 EXACTLY the bytes we read (off may be < st.size on a truncated-under-us read). return off === buf.length ? buf : buf.subarray(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 parsed: unknown = JSON.parse(raw); if (typeof parsed !== 'object' || parsed === null) return null; const p = parsed as Record; // 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; // 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; 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'], updatedAt: p['updatedAt'], entries: entries as Record, }; } catch { return null; } } /** * 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 { 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); } // --------------------------------------------------------------------------- // Mutation operations // --------------------------------------------------------------------------- /** * Record a capability installation in the ledger (idempotent). * * 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, 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 ledger: LedgerFile = existing ?? { version: LEDGER_SCHEMA_VERSION, updatedAt: new Date().toISOString(), entries: {}, }; ledger.entries[entry.id] = entry; ledger.updatedAt = new Date().toISOString(); writeLedger(runtimeDir, ledger); } /** * Remove a single capability entry from the ledger by id. * * 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 = 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(); writeLedger(runtimeDir, ledger); return true; } // --------------------------------------------------------------------------- // Reconciliation // --------------------------------------------------------------------------- interface ReconcileResult { /** Entries whose recorded files are partially or fully missing on disk. */ orphans: Array<{ id: string; missing: string[] }>; /** Reserved for future use — capabilities whose source has been superseded. */ stale: string[]; /** Non-fatal warnings (e.g. unreadable ledger). */ warnings: string[]; } /** * Check ledger consistency against the filesystem. * * Read-only — never mutates the ledger or the filesystem. Reports: * - orphans: entries with one or more recorded files missing on disk. * - stale: (reserved, always empty in Phase 3). * - warnings: problems encountered while reading the ledger. */ function reconcile(runtimeDir: string): ReconcileResult { const result: ReconcileResult = { orphans: [], stale: [], warnings: [] }; const ledger = readLedger(runtimeDir); if (ledger === null) { const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); // 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. return result; } for (const id of Object.keys(ledger.entries)) { const entry = ledger.entries[id]; const missing: string[] = []; for (const file of entry.files) { // Harden against hostile ledger JSON: a non-string member, or one that is // absolute or escapes runtimeDir via "..", must not crash reconcile or become // an existence oracle for files outside the runtime config dir. if (typeof file !== 'string' || file === '' || path.isAbsolute(file) || file.split(/[/\\]/).includes('..')) { // Note: do NOT String(file) — a hostile value like { toString: null } would throw. const shown = typeof file === 'string' ? file : `<${typeof file}>`; result.warnings.push(`Ledger entry "${id}" has an invalid file path; skipped: ${shown}`); continue; } const resolved = path.join(runtimeDir, file); if (!fs.existsSync(resolved)) { missing.push(file); } } if (missing.length > 0) { result.orphans.push({ id, missing }); } } return result; } // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- 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, // #1459 finding 1 (HIGH): the RAW-BYTES variant — the SOLE correct reader for the byte-exact, // injective consent content-hash binding (a utf8 decode is lossy and could collide binary artifacts). readSmallRegularFileBuffer, // 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, };