841 lines
42 KiB
TypeScript
841 lines
42 KiB
TypeScript
/**
|
|
* 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<string, LedgerEntry>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<string, unknown>;
|
|
// 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<string, unknown>;
|
|
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<string, LedgerEntry>,
|
|
};
|
|
} 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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>)['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,
|
|
};
|