@opengsd/gsd-core 1.5.0 → 1.6.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +199 -365
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +695 -5
- package/gsd-core/bin/lib/adr-parser.cjs +45 -23
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +98 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +7 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +33 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +73 -106
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +40 -15
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +6 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -1
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +4 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +12 -12
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +6 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/prompt-injection-scan.sh +1 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Capability ledger module — ADR-1244 Phase 3 (Decision D4).
|
|
4
|
+
*
|
|
5
|
+
* Manages a per-runtime install manifest (`.gsd-capabilities.json`) that records
|
|
6
|
+
* what each capability install wrote. Serves as the atomic commit point and
|
|
7
|
+
* reconciliation basis for Phase 4 upgrade/remove operations.
|
|
8
|
+
*
|
|
9
|
+
* LEAF MODULE — imports ONLY: node:fs, node:path, node:crypto. No other src/ imports.
|
|
10
|
+
*
|
|
11
|
+
* Exports:
|
|
12
|
+
* readLedger(runtimeDir) — structural-validated read, never throws
|
|
13
|
+
* readLedgerStrict(runtimeDir) — like readLedger but throws CorruptLedgerError when
|
|
14
|
+
* the file exists but is unparseable/invalid. The
|
|
15
|
+
* corrupt file is LEFT IN PLACE (not moved/quarantined)
|
|
16
|
+
* so every subsequent op also blocks until the user
|
|
17
|
+
* inspects and resolves it.
|
|
18
|
+
* writeLedger(runtimeDir, ledger) — atomic write (tmp + rename, crash-safe)
|
|
19
|
+
* recordInstall(runtimeDir, entry) — idempotent upsert of a ledger entry
|
|
20
|
+
* removeEntry(runtimeDir, capId) — remove a single entry by id
|
|
21
|
+
* reconcile(runtimeDir) — report orphans / stale entries (read-only)
|
|
22
|
+
* CorruptLedgerError — thrown by readLedgerStrict on corruption
|
|
23
|
+
*/
|
|
24
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
25
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
|
+
};
|
|
27
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
28
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
29
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const LEDGER_FILE_NAME = '.gsd-capabilities.json';
|
|
34
|
+
const LEDGER_SCHEMA_VERSION = '1';
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// CorruptLedgerError
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
/**
|
|
39
|
+
* Thrown by `readLedgerStrict` when the ledger file is present but cannot be
|
|
40
|
+
* parsed or is structurally invalid. The corrupt file is LEFT IN PLACE so that
|
|
41
|
+
* every subsequent operation also blocks until the user resolves it manually.
|
|
42
|
+
* Recovery: inspect the file, restore a backup, or move it aside to start fresh.
|
|
43
|
+
*/
|
|
44
|
+
class CorruptLedgerError extends Error {
|
|
45
|
+
/** Absolute path of the corrupt ledger file. */
|
|
46
|
+
ledgerPath;
|
|
47
|
+
constructor(message, ledgerPath) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'CorruptLedgerError';
|
|
50
|
+
this.ledgerPath = ledgerPath;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// IO helpers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/** Pattern for valid capability IDs (must match this to be accepted as ledger keys). */
|
|
57
|
+
const VALID_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
58
|
+
/**
|
|
59
|
+
* DOS-3 / finding 5(a): GENEROUS DoS backstop bounds — NOT product limits. No legitimate capability
|
|
60
|
+
* declares this many files or shared-config edits, but a hostile ledger with a 100k+-element array
|
|
61
|
+
* is rejected before it can be iterated/spread into a Set (memory/CPU DoS). Raised from the prior
|
|
62
|
+
* 256/64 (which risked false-rejecting large-but-legitimate installs) to clearly-generous bounds.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_FILES = 10_000;
|
|
65
|
+
const MAX_SHARED_EDITS = 256;
|
|
66
|
+
/** Cap for `_pending.sharedFiles` (finding 3) — same generous bound as `sharedEdits`. */
|
|
67
|
+
const MAX_SHARED_FILES = 256;
|
|
68
|
+
/**
|
|
69
|
+
* Finding 3 (MEDIUM): GENEROUS DoS backstops on the ledger FILE itself, NOT product limits. The
|
|
70
|
+
* ledger is untrusted on-disk content; readLedgerRaw must not read+parse+materialize an unbounded
|
|
71
|
+
* file. Before reading, `statSync` and reject (fail-closed via the corrupt path) if `size` exceeds
|
|
72
|
+
* LEDGER_MAX_BYTES. And enforce MAX_ENTRIES during validation so a hostile ledger with millions of
|
|
73
|
+
* keys cannot weaponize Object.keys iteration. 8 MiB / 4096 entries are far beyond any real install
|
|
74
|
+
* (a typical entry is a few hundred bytes; 4096 capabilities is wildly more than any user installs).
|
|
75
|
+
*/
|
|
76
|
+
const LEDGER_MAX_BYTES = 8 * 1024 * 1024;
|
|
77
|
+
const MAX_ENTRIES = 4096;
|
|
78
|
+
/**
|
|
79
|
+
* Returns true when `id` must never be used as an object key or ledger entry id — either
|
|
80
|
+
* because it would cause prototype pollution or because it fails the kebab-case constraint.
|
|
81
|
+
*
|
|
82
|
+
* Security note: uses INLINE LITERAL key comparisons (do NOT use a Set or computed lookup)
|
|
83
|
+
* as required by the CodeQL prototype-pollution barrier — a Set.has call could itself be
|
|
84
|
+
* attacked via a poisoned prototype.
|
|
85
|
+
*/
|
|
86
|
+
function isUnsafeCapabilityId(id) {
|
|
87
|
+
if (typeof id !== 'string')
|
|
88
|
+
return true;
|
|
89
|
+
if (id === '__proto__')
|
|
90
|
+
return true;
|
|
91
|
+
if (id === 'constructor')
|
|
92
|
+
return true;
|
|
93
|
+
if (id === 'prototype')
|
|
94
|
+
return true;
|
|
95
|
+
if (!VALID_ID_RE.test(id))
|
|
96
|
+
return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Sentinel for distinguishing IO errors (EACCES, EISDIR, EPERM, …) from
|
|
101
|
+
* parse/validation failures. Thrown internally by readLedgerRaw; caught by the
|
|
102
|
+
* two public readers to produce the right error type or return value.
|
|
103
|
+
*/
|
|
104
|
+
class LedgerIOError extends Error {
|
|
105
|
+
code;
|
|
106
|
+
constructor(message, code) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'LedgerIOError';
|
|
109
|
+
this.code = code;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Finding 2 (HIGH): the SINGLE shared robust bounded reader for every untrusted on-disk file the
|
|
114
|
+
* capability stack reads (the ledger here AND the .lock body in capability-lifecycle, which imports
|
|
115
|
+
* this). A path-`stat`(path)+`readFileSync`(path) pair is NOT safe: a FIFO, a symlink to a character
|
|
116
|
+
* device like /dev/zero, or a regular file SWAPPED/GROWN between the stat and the read defeats the
|
|
117
|
+
* size cap and can BLOCK (FIFO with no writer) or read UNBOUNDED (infinite device). Project-scope
|
|
118
|
+
* ledgers are repo-plantable, so this is a repo-borne DoS.
|
|
119
|
+
*
|
|
120
|
+
* The fix binds the type+size decision to the SAME open fd we read from:
|
|
121
|
+
* 1. openSync(path, O_RDONLY|O_NONBLOCK) — open ONCE, NON-BLOCKING. The O_NONBLOCK is essential:
|
|
122
|
+
* a plain openSync of a FIFO BLOCKS until a writer appears (the
|
|
123
|
+
* very hang we are defending against); O_NONBLOCK returns the fd
|
|
124
|
+
* immediately so fstat can reject it. (Symlinks are still followed
|
|
125
|
+
* to their target, as a read would; O_NONBLOCK is ignored for a
|
|
126
|
+
* regular file.)
|
|
127
|
+
* 2. fstatSync(fd) — stat the OPENED fd (not the path) — defeats the stat-then-read
|
|
128
|
+
* swap and reads the REAL target's type/size.
|
|
129
|
+
* 3. require stat.isFile() — reject FIFO / device / directory / symlink-to-nonregular. A
|
|
130
|
+
* directory keeps the legacy `EISDIR` code so existing callers
|
|
131
|
+
* that branch on it are unchanged.
|
|
132
|
+
* 4. require stat.size <= maxBytes — refuse an oversized regular file WITHOUT reading it whole.
|
|
133
|
+
* 5. read EXACTLY stat.size bytes from the fd — never an unbounded streaming read.
|
|
134
|
+
* 6. closeSync(fd) in finally.
|
|
135
|
+
*
|
|
136
|
+
* Returns the file content as a string, or null for ENOENT (genuinely missing). Throws LedgerIOError
|
|
137
|
+
* for every other condition (non-regular, oversized, IO error) so callers fail closed. Behavior for a
|
|
138
|
+
* normal small regular file is identical to the prior readFileSync(path,'utf8').
|
|
139
|
+
*/
|
|
140
|
+
function readSmallRegularFile(filePath, maxBytes) {
|
|
141
|
+
const buf = readSmallRegularFileBuffer(filePath, maxBytes);
|
|
142
|
+
if (buf === null)
|
|
143
|
+
return null;
|
|
144
|
+
// Decode to UTF-8 for STRING consumers (JSON parsers, lock-body parsers). This decode is LOSSY for
|
|
145
|
+
// binary content (invalid byte sequences → U+FFFD), so a content-hash binding must NOT use this —
|
|
146
|
+
// it must hash the RAW bytes via readSmallRegularFileBuffer (#1459 finding 1b: a swapped binary
|
|
147
|
+
// artifact differing only in invalid-UTF-8 bytes would otherwise not change the digest).
|
|
148
|
+
return buf.toString('utf8');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* #1459 finding 1 (HIGH): the RAW-BYTES variant of readSmallRegularFile. Identical open → fstat →
|
|
152
|
+
* require-regular-file → size-cap → read-exactly-size protocol (so a FIFO/device/swapped/oversized
|
|
153
|
+
* untrusted file can never block or read unbounded), but returns the bytes as a Buffer WITHOUT a
|
|
154
|
+
* UTF-8 decode. This is the SOLE correct reader for the consent content-hash binding: the binding
|
|
155
|
+
* must be byte-exact and INJECTIVE, and a utf8 decode is lossy (collapses distinct invalid byte
|
|
156
|
+
* sequences to U+FFFD) so two different binary artifacts could collide. Returns the bytes, or null
|
|
157
|
+
* for ENOENT (genuinely missing); throws LedgerIOError for every other fail-closed condition.
|
|
158
|
+
*/
|
|
159
|
+
function readSmallRegularFileBuffer(filePath, maxBytes) {
|
|
160
|
+
// O_RDONLY | O_NONBLOCK: never block on opening a FIFO/device — return the fd so fstat can reject it.
|
|
161
|
+
const openFlags = node_fs_1.default.constants.O_RDONLY | node_fs_1.default.constants.O_NONBLOCK;
|
|
162
|
+
let fd;
|
|
163
|
+
try {
|
|
164
|
+
fd = node_fs_1.default.openSync(filePath, openFlags);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
const code = err.code;
|
|
168
|
+
if (code === 'ENOENT')
|
|
169
|
+
return null; // genuinely missing — not a corruption.
|
|
170
|
+
throw new LedgerIOError(`Cannot open ${filePath}: ${err.message}`, code);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const st = node_fs_1.default.fstatSync(fd);
|
|
174
|
+
if (!st.isFile()) {
|
|
175
|
+
// FIFO / device / directory / symlink-to-nonregular. Preserve EISDIR for a directory so callers
|
|
176
|
+
// that distinguish it (and existing tests) still see that code; other non-regular kinds get a
|
|
177
|
+
// synthetic ENXIO. Either way it is an unreadable, fail-closed condition (not content parsing).
|
|
178
|
+
const code = st.isDirectory() ? 'EISDIR' : 'ENXIO';
|
|
179
|
+
throw new LedgerIOError(`Cannot read ${filePath}: not a regular file (unreadable; FIFO/device/directory) — refusing.`, code);
|
|
180
|
+
}
|
|
181
|
+
if (st.size > maxBytes) {
|
|
182
|
+
throw new LedgerIOError(`Cannot read ${filePath}: file size ${st.size} bytes exceeds the maximum of ${maxBytes} ` +
|
|
183
|
+
`bytes (refusing to read an oversized file). Inspect or move it aside.`, 'EFBIG');
|
|
184
|
+
}
|
|
185
|
+
if (st.size === 0)
|
|
186
|
+
return Buffer.alloc(0);
|
|
187
|
+
const buf = Buffer.allocUnsafe(st.size);
|
|
188
|
+
let off = 0;
|
|
189
|
+
// Read EXACTLY st.size bytes from the fd (never a streaming/unbounded read).
|
|
190
|
+
while (off < st.size) {
|
|
191
|
+
const n = node_fs_1.default.readSync(fd, buf, off, st.size - off, off);
|
|
192
|
+
if (n <= 0)
|
|
193
|
+
break; // EOF earlier than fstat reported (truncated under us) — return what we got.
|
|
194
|
+
off += n;
|
|
195
|
+
}
|
|
196
|
+
// Return EXACTLY the bytes we read (off may be < st.size on a truncated-under-us read).
|
|
197
|
+
return off === buf.length ? buf : buf.subarray(0, off);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (err instanceof LedgerIOError)
|
|
201
|
+
throw err;
|
|
202
|
+
throw new LedgerIOError(`Cannot read ${filePath}: ${err.message}`, err.code);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
try {
|
|
206
|
+
node_fs_1.default.closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
catch { /* best-effort */ }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Read and structurally validate the ledger file. Throws LedgerIOError when the
|
|
213
|
+
* file cannot be read due to an OS error (EACCES, EISDIR, EPERM, …). Returns
|
|
214
|
+
* null when the file is missing (ENOENT) or when its content fails validation.
|
|
215
|
+
* Never throws for parse or validation failures — those become null.
|
|
216
|
+
*/
|
|
217
|
+
function readLedgerRaw(runtimeDir) {
|
|
218
|
+
const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
|
|
219
|
+
// Finding 3 (MEDIUM) + Finding 2 (HIGH): the ledger file is untrusted. Read it via the shared
|
|
220
|
+
// fd-based bounded reader (open → fstat → require regular file → size cap → read exactly size). A
|
|
221
|
+
// FIFO/device/symlink-to-device or a stat-then-read swap can no longer block or bypass the cap; an
|
|
222
|
+
// oversized/non-regular file is surfaced as a LedgerIOError (a "cannot read" condition, not a
|
|
223
|
+
// content-parse failure) so readLedger returns null and readLedgerStrict rethrows it — every
|
|
224
|
+
// subsequent op then fails closed until the user resolves it, exactly like the corrupt path.
|
|
225
|
+
let raw;
|
|
226
|
+
try {
|
|
227
|
+
const content = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
|
|
228
|
+
if (content === null)
|
|
229
|
+
return null; // genuinely missing — not a corruption.
|
|
230
|
+
raw = content;
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (err instanceof LedgerIOError)
|
|
234
|
+
throw err; // non-regular / oversized / IO — fail closed.
|
|
235
|
+
throw new LedgerIOError(`Cannot read ledger at ${filePath}: ${err.message}`, err.code);
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(raw);
|
|
239
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
240
|
+
return null;
|
|
241
|
+
const p = parsed;
|
|
242
|
+
// Schema version must be the expected value (not any string) — finding 11.
|
|
243
|
+
if (p['version'] !== LEDGER_SCHEMA_VERSION)
|
|
244
|
+
return null;
|
|
245
|
+
// updatedAt must be a non-empty string — finding 11.
|
|
246
|
+
if (typeof p['updatedAt'] !== 'string' || !p['updatedAt'])
|
|
247
|
+
return null;
|
|
248
|
+
if (typeof p['entries'] !== 'object' || p['entries'] === null || Array.isArray(p['entries']))
|
|
249
|
+
return null;
|
|
250
|
+
// Validate each entry via isValidLedgerEntry — THE single validator (ROOT FIX 1).
|
|
251
|
+
// This eliminates the previous inline duplication and guarantees readLedger and
|
|
252
|
+
// isValidLedgerEntry can never diverge.
|
|
253
|
+
const entries = p['entries'];
|
|
254
|
+
const keys = Object.keys(entries);
|
|
255
|
+
// Finding 3 (MEDIUM): cap the entry COUNT so a hostile ledger with millions of keys cannot
|
|
256
|
+
// weaponize per-entry validation/iteration (the size cap above already bounds the parse; this
|
|
257
|
+
// bounds the post-parse key count). Generous DoS backstop, not a product limit.
|
|
258
|
+
if (keys.length > MAX_ENTRIES)
|
|
259
|
+
return null;
|
|
260
|
+
for (const key of keys) {
|
|
261
|
+
if (!isValidLedgerEntry(key, entries[key]))
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
version: p['version'],
|
|
266
|
+
updatedAt: p['updatedAt'],
|
|
267
|
+
entries: entries,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Validate a single ledger entry object against the per-entry shape that readLedger enforces.
|
|
276
|
+
* This is THE single validator — readLedger/readLedgerRaw call it per-entry instead of
|
|
277
|
+
* duplicating inline checks (ROOT FIX 1 — single source of truth; #1459 will also consume this).
|
|
278
|
+
*
|
|
279
|
+
* Returns true when the entry is structurally valid for the given `id` key.
|
|
280
|
+
* Returns false for any structural violation:
|
|
281
|
+
* - id is an unsafe prototype-pollution key (__proto__, constructor, prototype)
|
|
282
|
+
* - id fails the kebab-case constraint (VALID_ID_RE)
|
|
283
|
+
* - entry.id field missing or not matching the key
|
|
284
|
+
* - missing/wrong-type required fields (version, source, integrity)
|
|
285
|
+
* - files[] with non-string members
|
|
286
|
+
* - sharedEdits[] with missing / non-string file or marker fields
|
|
287
|
+
* - _pending present but wrong shape (kind not 'install'/'upgrade', bad backupName, missing sharedFiles[])
|
|
288
|
+
*/
|
|
289
|
+
function isValidLedgerEntry(id, entry) {
|
|
290
|
+
// ROOT FIX 3: reject unsafe ids using inline literal checks (CodeQL-safe pattern).
|
|
291
|
+
if (isUnsafeCapabilityId(id))
|
|
292
|
+
return false;
|
|
293
|
+
if (typeof entry !== 'object' || entry === null)
|
|
294
|
+
return false;
|
|
295
|
+
const e = entry;
|
|
296
|
+
if (typeof e['id'] !== 'string' || e['id'] !== id)
|
|
297
|
+
return false;
|
|
298
|
+
if (typeof e['version'] !== 'string')
|
|
299
|
+
return false;
|
|
300
|
+
if (typeof e['source'] !== 'string')
|
|
301
|
+
return false;
|
|
302
|
+
if (typeof e['integrity'] !== 'string')
|
|
303
|
+
return false;
|
|
304
|
+
if (!Array.isArray(e['files']))
|
|
305
|
+
return false;
|
|
306
|
+
// DOS-3 / finding 5(a): cap array sizes so a hostile ledger cannot weaponize a 100k+-element
|
|
307
|
+
// files[] (or sharedEdits[]/_pending.sharedFiles[]) into a memory/CPU DoS at validation/reconcile
|
|
308
|
+
// time. These are GENEROUS DoS backstops, NOT product limits — no legitimate capability declares
|
|
309
|
+
// 10k files or 256 shared-config edits, but a 100k+ hostile array is rejected (not iterated).
|
|
310
|
+
if (e['files'].length > MAX_FILES)
|
|
311
|
+
return false;
|
|
312
|
+
for (const f of e['files']) {
|
|
313
|
+
if (typeof f !== 'string')
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (!Array.isArray(e['sharedEdits']))
|
|
317
|
+
return false;
|
|
318
|
+
if (e['sharedEdits'].length > MAX_SHARED_EDITS)
|
|
319
|
+
return false; // DOS-3 (see above)
|
|
320
|
+
for (const se of e['sharedEdits']) {
|
|
321
|
+
if (se === null || typeof se !== 'object')
|
|
322
|
+
return false;
|
|
323
|
+
const seObj = se;
|
|
324
|
+
if (typeof seObj['file'] !== 'string' || !seObj['file'])
|
|
325
|
+
return false;
|
|
326
|
+
if (typeof seObj['marker'] !== 'string' || !seObj['marker'])
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
// Validate _pending shape if present (ROOT FIX 1 — previously only in readLedgerRaw).
|
|
330
|
+
if (Object.prototype.hasOwnProperty.call(e, '_pending')) {
|
|
331
|
+
const pending = e['_pending'];
|
|
332
|
+
if (pending !== undefined) {
|
|
333
|
+
if (typeof pending !== 'object' || pending === null)
|
|
334
|
+
return false;
|
|
335
|
+
const p = pending;
|
|
336
|
+
if (p['kind'] !== 'install' && p['kind'] !== 'upgrade')
|
|
337
|
+
return false;
|
|
338
|
+
// backupName must be string or null — not a number or object.
|
|
339
|
+
if (p['backupName'] !== null && typeof p['backupName'] !== 'string')
|
|
340
|
+
return false;
|
|
341
|
+
if (!Array.isArray(p['sharedFiles']))
|
|
342
|
+
return false;
|
|
343
|
+
// Finding 3: _pending.sharedFiles was previously ONLY Array.isArray-checked, so a hostile
|
|
344
|
+
// ledger with a 500k-element (or non-string) _pending.sharedFiles was accepted and later
|
|
345
|
+
// spread into a Set + iterated in reconcileCapabilities (DoS bypass). Cap its length with the
|
|
346
|
+
// same generous bound as sharedFiles and require every member to be a string.
|
|
347
|
+
if (p['sharedFiles'].length > MAX_SHARED_FILES)
|
|
348
|
+
return false;
|
|
349
|
+
for (const sf of p['sharedFiles']) {
|
|
350
|
+
if (typeof sf !== 'string')
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Validate a WHOLE ledger-file object against the SAME structural rules a strict read enforces
|
|
359
|
+
* (finding 5 — LOW): the schema version, a non-empty `updatedAt`, an entries map within MAX_ENTRIES,
|
|
360
|
+
* and every entry valid via isValidLedgerEntry. Used by recordInstall to gate the in-lock
|
|
361
|
+
* `baseLedger` fast-path so an invalid caller-supplied base can never be written verbatim. Never
|
|
362
|
+
* throws; returns false for any structural violation.
|
|
363
|
+
*/
|
|
364
|
+
function isValidLedgerFile(base) {
|
|
365
|
+
if (typeof base !== 'object' || base === null || Array.isArray(base))
|
|
366
|
+
return false;
|
|
367
|
+
const b = base;
|
|
368
|
+
if (b['version'] !== LEDGER_SCHEMA_VERSION)
|
|
369
|
+
return false;
|
|
370
|
+
if (typeof b['updatedAt'] !== 'string' || !b['updatedAt'])
|
|
371
|
+
return false;
|
|
372
|
+
const entriesVal = b['entries'];
|
|
373
|
+
if (typeof entriesVal !== 'object' || entriesVal === null || Array.isArray(entriesVal))
|
|
374
|
+
return false;
|
|
375
|
+
const entries = entriesVal;
|
|
376
|
+
const keys = Object.keys(entries);
|
|
377
|
+
if (keys.length > MAX_ENTRIES)
|
|
378
|
+
return false;
|
|
379
|
+
for (const key of keys) {
|
|
380
|
+
if (!isValidLedgerEntry(key, entries[key]))
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Read and structurally validate the ledger file.
|
|
387
|
+
*
|
|
388
|
+
* Returns null if the file is missing or structurally invalid.
|
|
389
|
+
* Returns the parsed ledger when the file is valid.
|
|
390
|
+
* On IO errors (EACCES, EISDIR, EPERM), returns null (non-throwing, compatible with old API).
|
|
391
|
+
* Never throws.
|
|
392
|
+
*/
|
|
393
|
+
function readLedger(runtimeDir) {
|
|
394
|
+
try {
|
|
395
|
+
return readLedgerRaw(runtimeDir);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (err instanceof LedgerIOError) {
|
|
399
|
+
// IO error — treat as unreadable (return null) so callers are not broken.
|
|
400
|
+
// readLedgerStrict will surface the real error.
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Like `readLedger` but distinguishes missing-vs-corrupt, and surfaces IO errors distinctly:
|
|
408
|
+
* - File missing → returns null (no ledger yet, fresh start is fine).
|
|
409
|
+
* - File present and valid → returns the parsed LedgerFile.
|
|
410
|
+
* - File present but unparseable/invalid CONTENT → throws CorruptLedgerError. The file is
|
|
411
|
+
* LEFT IN PLACE (not moved, renamed, or deleted) so every subsequent operation also
|
|
412
|
+
* blocks until the user resolves it. Recovery: inspect the file, restore a backup,
|
|
413
|
+
* or move it aside yourself to start fresh.
|
|
414
|
+
* - File present but unreadable (EACCES, EPERM, EISDIR, …) → throws LedgerIOError with
|
|
415
|
+
* the original OS errno/code preserved. This is an IO/permission problem — NOT a content
|
|
416
|
+
* corruption — and callers should surface it as such (finding 4).
|
|
417
|
+
*
|
|
418
|
+
* Callers that must fail-closed on corruption (upgrade, remove, install) should use this
|
|
419
|
+
* instead of `readLedger` so they never mistake a corrupt file for "not installed".
|
|
420
|
+
*/
|
|
421
|
+
function readLedgerStrict(runtimeDir) {
|
|
422
|
+
const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
|
|
423
|
+
let raw;
|
|
424
|
+
try {
|
|
425
|
+
raw = readLedgerRaw(runtimeDir);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
if (err instanceof LedgerIOError) {
|
|
429
|
+
// IO error (EACCES, EPERM, EISDIR, …) — rethrow as-is so callers see it as an IO
|
|
430
|
+
// problem with the original errno, not as content corruption (finding 4).
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
throw err; // unexpected — propagate
|
|
434
|
+
}
|
|
435
|
+
if (raw !== null)
|
|
436
|
+
return raw;
|
|
437
|
+
// readLedgerRaw returned null: either genuinely missing or present-but-invalid (or unreadable).
|
|
438
|
+
// ROOT FIX 4: use lstatSync (not existsSync) to detect dangling/broken symlinks.
|
|
439
|
+
// existsSync follows the symlink and returns false for a broken symlink, making the ledger
|
|
440
|
+
// appear "missing" when it is actually an IO problem — so a broken symlink would silently
|
|
441
|
+
// allow a "fresh install" over a dangling ledger pointer, losing all prior records.
|
|
442
|
+
// lstatSync checks the directory entry itself (not the target) — if it exists (even as a
|
|
443
|
+
// broken symlink), that is NOT "missing": surface it as an IO error so every subsequent op
|
|
444
|
+
// also fails closed until the user resolves it.
|
|
445
|
+
let lstatResult = null;
|
|
446
|
+
try {
|
|
447
|
+
lstatResult = node_fs_1.default.lstatSync(filePath);
|
|
448
|
+
}
|
|
449
|
+
catch (lstatErr) {
|
|
450
|
+
const lstatCode = lstatErr.code;
|
|
451
|
+
if (lstatCode === 'ENOENT')
|
|
452
|
+
return null; // genuinely missing directory entry — fresh start is fine.
|
|
453
|
+
// Any other lstat error (EACCES, EPERM, …) — treat as IO failure.
|
|
454
|
+
throw new LedgerIOError(`Cannot stat ledger at ${filePath}: ${lstatErr.message}`, lstatCode);
|
|
455
|
+
}
|
|
456
|
+
// lstat succeeded — the path exists in the directory (could be a broken symlink, dir, etc.).
|
|
457
|
+
if (lstatResult.isSymbolicLink()) {
|
|
458
|
+
// Broken symlink: the entry exists but the target is unreadable. This is an IO problem,
|
|
459
|
+
// not content corruption — surface as LedgerIOError (not CorruptLedgerError) so callers
|
|
460
|
+
// distinguish "I/O problem" from "corrupt content" (ROOT FIX 4).
|
|
461
|
+
throw new LedgerIOError(`Ledger path ${filePath} is a broken or dangling symlink. ` +
|
|
462
|
+
`Remove or fix the symlink so the ledger can be read normally.`, 'ENOENT');
|
|
463
|
+
}
|
|
464
|
+
// BC-1: distinguish a future/unsupported SCHEMA VERSION from genuine corruption. readLedgerRaw
|
|
465
|
+
// returns null both when the JSON is unparseable AND when it parses cleanly but carries a
|
|
466
|
+
// version string we do not support (currently only '1' exists). A version bump should surface a
|
|
467
|
+
// clear "unsupported schema version X" message, not a misleading "corrupt or invalid". This is a
|
|
468
|
+
// best-effort re-parse for the message only — the file is still LEFT IN PLACE.
|
|
469
|
+
//
|
|
470
|
+
// FIRST SCHEMA BUMP: when a v2 schema is introduced, ADD A MIGRATION BRANCH here (and in
|
|
471
|
+
// readLedgerRaw) — read the old shape, migrate it forward, and write the upgraded ledger — rather
|
|
472
|
+
// than throwing. Until then there are no v0/v2 ledgers in the wild (no released version wrote one),
|
|
473
|
+
// so blocking on an unknown version is the safe fail-closed behavior.
|
|
474
|
+
try {
|
|
475
|
+
// Finding 2 (HIGH): the reparse is ALSO a read of the untrusted ledger path — a FIFO/device or a
|
|
476
|
+
// file swapped after the first read must not block/bypass the cap here. Route it through the same
|
|
477
|
+
// bounded fd reader (a null/throw means there's nothing safely reparseable → fall through to the
|
|
478
|
+
// generic corrupt message).
|
|
479
|
+
const reparsedRaw = readSmallRegularFile(filePath, LEDGER_MAX_BYTES);
|
|
480
|
+
const reparsed = reparsedRaw === null ? null : JSON.parse(reparsedRaw);
|
|
481
|
+
if (typeof reparsed === 'object' && reparsed !== null) {
|
|
482
|
+
const ver = reparsed['version'];
|
|
483
|
+
if (typeof ver === 'string' && ver !== LEDGER_SCHEMA_VERSION) {
|
|
484
|
+
throw new CorruptLedgerError(`Capability ledger at ${filePath} uses unsupported ledger schema version "${ver}" ` +
|
|
485
|
+
`(this build supports version "${LEDGER_SCHEMA_VERSION}"). Upgrade GSD to a build that ` +
|
|
486
|
+
`understands this ledger, or move the file aside to start fresh.`, filePath);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (reparseErr) {
|
|
491
|
+
// A CorruptLedgerError from the unsupported-version branch must propagate; any other error
|
|
492
|
+
// (re-read/parse failure) means it is genuinely corrupt — fall through to the generic message.
|
|
493
|
+
if (reparseErr instanceof CorruptLedgerError)
|
|
494
|
+
throw reparseErr;
|
|
495
|
+
}
|
|
496
|
+
// File exists (not a symlink, not missing) but failed validation — throw. The file is
|
|
497
|
+
// intentionally LEFT IN PLACE so that every subsequent op is also blocked until the user
|
|
498
|
+
// resolves it (finding 1): auto-moving it would let the NEXT op proceed as fresh state
|
|
499
|
+
// → data-loss/orphan outcome.
|
|
500
|
+
// W-2: the recovery hint must be platform-aware — a POSIX `mv` with a forward-slash path is wrong
|
|
501
|
+
// on Windows (backslash paths, no `mv`). Show the native rename command for the running platform.
|
|
502
|
+
const moveHint = process.platform === 'win32'
|
|
503
|
+
? `ren "${filePath}" "${node_path_1.default.basename(filePath)}.bak" (or PowerShell: Move-Item "${filePath}" "${filePath}.bak")`
|
|
504
|
+
: `mv "${filePath}" "${filePath}.bak"`;
|
|
505
|
+
throw new CorruptLedgerError(`Capability ledger at ${filePath} is present but corrupt or invalid. ` +
|
|
506
|
+
`Inspect the file to recover your capability records, restore a known-good backup, ` +
|
|
507
|
+
`or move it aside to start fresh (e.g. ${moveHint}).`, filePath);
|
|
508
|
+
}
|
|
509
|
+
/** W-1: rename errnos that are transient on Windows (AV scanner / indexer holding a brief lock). */
|
|
510
|
+
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
511
|
+
const RENAME_MAX_ATTEMPTS = 3;
|
|
512
|
+
const RENAME_RETRY_BACKOFF_MS = 50;
|
|
513
|
+
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
|
|
514
|
+
let _renameSleepBuf = null;
|
|
515
|
+
function renameBackoff() {
|
|
516
|
+
if (_renameSleepBuf === null)
|
|
517
|
+
_renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
518
|
+
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
|
|
519
|
+
}
|
|
520
|
+
/** Errnos from a directory fsync that are tolerated (platforms/filesystems disallowing dir fsync). */
|
|
521
|
+
const DIR_FSYNC_TOLERATED_ERRNOS = new Set(['EISDIR', 'EPERM', 'EINVAL', 'EBADF']);
|
|
522
|
+
/**
|
|
523
|
+
* fsync the directory CONTAINING `dest` so the just-completed rename is durable across a power loss
|
|
524
|
+
* (DUR-2). Some platforms/filesystems disallow fsync on a directory fd (EISDIR/EPERM/EINVAL/EBADF) —
|
|
525
|
+
* those are tolerated (best-effort, swallowed). Finding 4: any OTHER errno (e.g. EIO — a real
|
|
526
|
+
* storage error) is RETHROWN as a clear durability-uncertain error rather than silently swallowed;
|
|
527
|
+
* the rename may already be visible, so the caller must NOT claim success when durability could not
|
|
528
|
+
* be confirmed. The directory fd is always closed (finally).
|
|
529
|
+
*/
|
|
530
|
+
function fsyncContainingDir(dest) {
|
|
531
|
+
let dirFd = null;
|
|
532
|
+
try {
|
|
533
|
+
dirFd = node_fs_1.default.openSync(node_path_1.default.dirname(dest), 'r');
|
|
534
|
+
node_fs_1.default.fsyncSync(dirFd);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
const code = err.code;
|
|
538
|
+
if (code !== undefined && !DIR_FSYNC_TOLERATED_ERRNOS.has(code)) {
|
|
539
|
+
// Real storage error (e.g. EIO): the rename may already be visible but its durability could
|
|
540
|
+
// NOT be confirmed. Rethrow rather than silently claim success (finding 4).
|
|
541
|
+
throw new Error(`Directory fsync of "${node_path_1.default.dirname(dest)}" failed (${code}); durability of the ledger ` +
|
|
542
|
+
`rename could NOT be confirmed: ${err.message}`);
|
|
543
|
+
}
|
|
544
|
+
/* tolerated errno (or no code) — best-effort: a missing dir-fsync only weakens durability */
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
if (dirFd !== null) {
|
|
548
|
+
try {
|
|
549
|
+
node_fs_1.default.closeSync(dirFd);
|
|
550
|
+
}
|
|
551
|
+
catch { /* best-effort */ }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Write the ledger atomically AND durably (tmp file in the same dir → fsync → close → rename →
|
|
557
|
+
* dir fsync, no truncating fallback). Using a local implementation rather than platformWriteSync
|
|
558
|
+
* so that a crash or power-loss mid-write cannot produce a zero-byte / truncated ledger — the
|
|
559
|
+
* corrupt file that LEDGER-1 mishandled (ADR-1244 D4 fix).
|
|
560
|
+
*
|
|
561
|
+
* Durability sequence (DUR-1 / DUR-2):
|
|
562
|
+
* 1. writeFileSync(fd, content) — full-buffer write (no short-writes).
|
|
563
|
+
* 2. fsyncSync(fd) — flush the file's bytes to stable storage BEFORE the rename;
|
|
564
|
+
* otherwise a power-loss AFTER a successful rename can leave a
|
|
565
|
+
* zero/partial ledger (total loss). If fsync throws, the temp is
|
|
566
|
+
* unlinked and the error rethrown (treated as a write failure) —
|
|
567
|
+
* we NEVER rename a possibly-unflushed file live.
|
|
568
|
+
* 3. closeSync(fd) — a close error can also signal delayed-writeback failure;
|
|
569
|
+
* unlink the temp and rethrow before the rename.
|
|
570
|
+
* 4. renameSync(tmp, dest) — atomic install (retried on transient Windows AV locks, W-1).
|
|
571
|
+
* 5. fsyncSync(dirname fd) — make the rename itself durable (DUR-2).
|
|
572
|
+
*
|
|
573
|
+
* Security hardening (adversarial re-review):
|
|
574
|
+
* - Temp path includes a random nonce (not just pid) to avoid predictable names and resist
|
|
575
|
+
* collision between concurrent processes.
|
|
576
|
+
* - Temp file is created with the exclusive `wx` flag (O_EXCL) so a pre-planted symlink at the
|
|
577
|
+
* same path cannot redirect the write to another file.
|
|
578
|
+
* - On any failure (write, fsync, close, or rename) the temp file is cleaned up before
|
|
579
|
+
* rethrowing, and the primary error is always preserved (finding 13).
|
|
580
|
+
*/
|
|
581
|
+
function writeLedger(runtimeDir, ledger) {
|
|
582
|
+
const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
|
|
583
|
+
const content = JSON.stringify(ledger, null, 2) + '\n';
|
|
584
|
+
node_fs_1.default.mkdirSync(runtimeDir, { recursive: true });
|
|
585
|
+
// Unique nonce in the name prevents predictable-path attacks; wx (O_EXCL) prevents
|
|
586
|
+
// a pre-existing symlink from silently redirecting the write.
|
|
587
|
+
const nonce = node_crypto_1.default.randomBytes(4).toString('hex');
|
|
588
|
+
const tmpPath = `${filePath}.tmp.${process.pid}-${nonce}`;
|
|
589
|
+
const fd = node_fs_1.default.openSync(tmpPath, 'wx'); // exclusive create — throws if already exists
|
|
590
|
+
let primaryErr = null;
|
|
591
|
+
try {
|
|
592
|
+
// Write as a Buffer in one call to prevent short-writes (finding 6).
|
|
593
|
+
// fs.writeFileSync(fd, …) internally uses a write-all loop that flushes the
|
|
594
|
+
// entire buffer before returning, unlike a bare writeSync which may short-write.
|
|
595
|
+
node_fs_1.default.writeFileSync(fd, content);
|
|
596
|
+
// DUR-1: fsync the file's contents to stable storage BEFORE closing/renaming. Without this a
|
|
597
|
+
// power-loss after a successful rename can leave a zero/partial ledger → total loss.
|
|
598
|
+
node_fs_1.default.fsyncSync(fd);
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
primaryErr = err instanceof Error ? err : new Error(String(err));
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
// closeSync can also throw (finding 2): a close error on the write fd can signal
|
|
605
|
+
// delayed-writeback failure, meaning the data may not have been durably committed
|
|
606
|
+
// to storage. In that case we must NOT install the possibly-unflushed temp as the
|
|
607
|
+
// live ledger — unlink it and rethrow the close error before the rename.
|
|
608
|
+
let closeErr = null;
|
|
609
|
+
try {
|
|
610
|
+
node_fs_1.default.closeSync(fd);
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
closeErr = err instanceof Error ? err : new Error(String(err));
|
|
614
|
+
}
|
|
615
|
+
// If the write OR fsync failed, always clean up and rethrow that error (DUR-1).
|
|
616
|
+
if (primaryErr !== null) {
|
|
617
|
+
try {
|
|
618
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
619
|
+
}
|
|
620
|
+
catch { /* best-effort — no orphan */ }
|
|
621
|
+
throw primaryErr;
|
|
622
|
+
}
|
|
623
|
+
// Write+fsync succeeded but close threw — unlink the possibly-unflushed temp and rethrow
|
|
624
|
+
// the close error. NEVER proceed to rename a potentially unflushed file (finding 2).
|
|
625
|
+
if (closeErr !== null) {
|
|
626
|
+
try {
|
|
627
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
628
|
+
}
|
|
629
|
+
catch { /* best-effort — no orphan */ }
|
|
630
|
+
throw closeErr;
|
|
631
|
+
}
|
|
632
|
+
// Write, fsync, and close all succeeded — fall through to rename.
|
|
633
|
+
}
|
|
634
|
+
// W-1: renameSync can transiently fail on Windows when an AV scanner / file indexer holds a
|
|
635
|
+
// brief lock (EPERM/EBUSY/EACCES). Retry a few times with a short backoff before giving up.
|
|
636
|
+
let renameErr = null;
|
|
637
|
+
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
|
|
638
|
+
try {
|
|
639
|
+
node_fs_1.default.renameSync(tmpPath, filePath);
|
|
640
|
+
renameErr = null;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
renameErr = err instanceof Error ? err : new Error(String(err));
|
|
645
|
+
const code = err.code ?? '';
|
|
646
|
+
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
|
|
647
|
+
renameBackoff();
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (renameErr !== null) {
|
|
654
|
+
// Clean up the orphaned temp file before rethrowing.
|
|
655
|
+
try {
|
|
656
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
657
|
+
}
|
|
658
|
+
catch { /* best-effort */ }
|
|
659
|
+
throw renameErr;
|
|
660
|
+
}
|
|
661
|
+
// DUR-2: make the rename durable by fsyncing the containing directory (best-effort).
|
|
662
|
+
fsyncContainingDir(filePath);
|
|
663
|
+
}
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Mutation operations
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
/**
|
|
668
|
+
* Record a capability installation in the ledger (idempotent).
|
|
669
|
+
*
|
|
670
|
+
* If an entry with the same id already exists it is replaced. The `updatedAt`
|
|
671
|
+
* timestamp is refreshed on every call. Rejects ids that would cause prototype
|
|
672
|
+
* pollution (__proto__, constructor, prototype).
|
|
673
|
+
*
|
|
674
|
+
* Uses `readLedgerStrict` so that a corrupt-but-present ledger fails closed (throws
|
|
675
|
+
* CorruptLedgerError, leaving the file in place) rather than silently overwriting it.
|
|
676
|
+
*
|
|
677
|
+
* DOS-4: `opts.baseLedger` lets an IN-LOCK caller pass the ledger it has ALREADY strict-read this
|
|
678
|
+
* critical section so recordInstall does not redundantly re-read+re-validate it (install does up to
|
|
679
|
+
* three strict reads per op). It is ONLY safe when the caller holds the mutation lock (so the
|
|
680
|
+
* on-disk ledger cannot change underneath the passed snapshot) AND obtained it via readLedgerStrict
|
|
681
|
+
* (so corruption was already fail-closed). The standalone strict read remains the DEFAULT — omit
|
|
682
|
+
* `baseLedger` and the strict guarantee is unchanged. A null/missing baseLedger falls back to the
|
|
683
|
+
* strict read; a non-object baseLedger is rejected.
|
|
684
|
+
*/
|
|
685
|
+
function recordInstall(runtimeDir, entry, opts) {
|
|
686
|
+
// ROOT FIX 3: reject ALL unsafe ids with a throw (not silent return) — this includes
|
|
687
|
+
// prototype-pollution keys AND non-kebab ids. Using isUnsafeCapabilityId (which uses
|
|
688
|
+
// inline literal === checks — CodeQL-safe pattern) as the single gate.
|
|
689
|
+
if (isUnsafeCapabilityId(entry.id)) {
|
|
690
|
+
throw new Error(`Invalid capability id "${entry.id}": must match /^[a-z][a-z0-9-]*$/ (kebab-case, lowercase). ` +
|
|
691
|
+
`Unsafe or non-kebab ids are rejected to prevent prototype pollution and ledger corruption.`);
|
|
692
|
+
}
|
|
693
|
+
// ROOT FIX 3 (finding 3): validate the WHOLE entry — not just entry.id — against the single
|
|
694
|
+
// per-entry validator. Otherwise recordInstall could write a structurally-invalid entry (e.g.
|
|
695
|
+
// files:[123] or a malformed sharedEdits member) that every subsequent readLedger/readLedgerStrict
|
|
696
|
+
// would then reject as corrupt — turning a bad write into a persistent self-inflicted lockout.
|
|
697
|
+
// Validating here makes recordInstall fail FAST (throw, write nothing) on a malformed entry.
|
|
698
|
+
if (!isValidLedgerEntry(entry.id, entry)) {
|
|
699
|
+
throw new Error(`Refusing to record a structurally-invalid ledger entry for "${entry.id}": the entry fails ` +
|
|
700
|
+
`the ledger schema (check files[]/sharedEdits[]/version/source/integrity types). ` +
|
|
701
|
+
`Writing it would corrupt the ledger so every later read rejects it.`);
|
|
702
|
+
}
|
|
703
|
+
// DOS-4 + finding 5 (LOW): use the caller-supplied in-lock base ONLY when it passes the SAME
|
|
704
|
+
// validation a strict read would (version, updatedAt, entry-count cap, and every entry via
|
|
705
|
+
// isValidLedgerEntry). Previously the base was accepted on a shallow `entries is an object` check
|
|
706
|
+
// and written VERBATIM — so a caller passing an invalid base (bad version/updatedAt, or a malformed
|
|
707
|
+
// entry) would write a self-corrupting ledger that every later read rejects. Now an INVALID base is
|
|
708
|
+
// ignored and we fall back to the strict read (the default, unchanged strict guarantee), so the
|
|
709
|
+
// ledger is only ever derived from validated state.
|
|
710
|
+
let existing;
|
|
711
|
+
const base = opts?.baseLedger;
|
|
712
|
+
if (base !== undefined && base !== null && isValidLedgerFile(base)) {
|
|
713
|
+
existing = base;
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
// readLedgerStrict: returns null when missing, parsed ledger when valid,
|
|
717
|
+
// throws CorruptLedgerError (leaving file in place) when present-but-corrupt.
|
|
718
|
+
existing = readLedgerStrict(runtimeDir);
|
|
719
|
+
}
|
|
720
|
+
const ledger = existing ?? {
|
|
721
|
+
version: LEDGER_SCHEMA_VERSION,
|
|
722
|
+
updatedAt: new Date().toISOString(),
|
|
723
|
+
entries: {},
|
|
724
|
+
};
|
|
725
|
+
ledger.entries[entry.id] = entry;
|
|
726
|
+
ledger.updatedAt = new Date().toISOString();
|
|
727
|
+
writeLedger(runtimeDir, ledger);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Remove a single capability entry from the ledger by id.
|
|
731
|
+
*
|
|
732
|
+
* Returns true if the entry was present and removed, false if GENUINELY not found.
|
|
733
|
+
*
|
|
734
|
+
* Finding 4 (fail-closed): uses `readLedgerStrict` (not the non-throwing `readLedger`) so a
|
|
735
|
+
* corrupt-but-present ledger THROWS (CorruptLedgerError / LedgerIOError, file left in place)
|
|
736
|
+
* rather than returning false. Returning false on corruption would let a corrupt ledger
|
|
737
|
+
* masquerade as "entry not installed" — a silent no-op that hides recorded state. `false` is
|
|
738
|
+
* now reserved exclusively for a genuinely-missing ledger or a genuinely-absent entry.
|
|
739
|
+
*/
|
|
740
|
+
function removeEntry(runtimeDir, capId) {
|
|
741
|
+
const ledger = readLedgerStrict(runtimeDir); // throws on corrupt-present / IO error (fail-closed)
|
|
742
|
+
if (ledger === null)
|
|
743
|
+
return false; // genuinely missing ledger — nothing installed
|
|
744
|
+
if (!Object.prototype.hasOwnProperty.call(ledger.entries, capId))
|
|
745
|
+
return false;
|
|
746
|
+
delete ledger.entries[capId];
|
|
747
|
+
ledger.updatedAt = new Date().toISOString();
|
|
748
|
+
writeLedger(runtimeDir, ledger);
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Check ledger consistency against the filesystem.
|
|
753
|
+
*
|
|
754
|
+
* Read-only — never mutates the ledger or the filesystem. Reports:
|
|
755
|
+
* - orphans: entries with one or more recorded files missing on disk.
|
|
756
|
+
* - stale: (reserved, always empty in Phase 3).
|
|
757
|
+
* - warnings: problems encountered while reading the ledger.
|
|
758
|
+
*/
|
|
759
|
+
function reconcile(runtimeDir) {
|
|
760
|
+
const result = { orphans: [], stale: [], warnings: [] };
|
|
761
|
+
const ledger = readLedger(runtimeDir);
|
|
762
|
+
if (ledger === null) {
|
|
763
|
+
const filePath = node_path_1.default.join(runtimeDir, LEDGER_FILE_NAME);
|
|
764
|
+
// Finding 5: use lstatSync (not existsSync) to detect the directory ENTRY itself. existsSync
|
|
765
|
+
// FOLLOWS the symlink and returns false for a dangling/broken symlink — so a ledger that is a
|
|
766
|
+
// broken symlink would be reported "missing" (no warning) when it is actually an unreadable IO
|
|
767
|
+
// problem. lstatSync stats the entry without following it: any entry present (even a broken
|
|
768
|
+
// symlink) is NOT "missing" and must surface a warning.
|
|
769
|
+
let entryExists = false;
|
|
770
|
+
try {
|
|
771
|
+
node_fs_1.default.lstatSync(filePath);
|
|
772
|
+
entryExists = true;
|
|
773
|
+
}
|
|
774
|
+
catch (lstatErr) {
|
|
775
|
+
// ENOENT — genuinely absent: nothing installed, not a warning. Any other error (EACCES,
|
|
776
|
+
// EPERM, …) means the entry is present-but-unreadable → treat as a parse/IO warning.
|
|
777
|
+
if (lstatErr.code !== 'ENOENT')
|
|
778
|
+
entryExists = true;
|
|
779
|
+
}
|
|
780
|
+
if (entryExists) {
|
|
781
|
+
result.warnings.push(`Ledger file exists but could not be parsed: ${filePath}`);
|
|
782
|
+
}
|
|
783
|
+
// Missing ledger is not a warning — it simply means nothing has been installed.
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
for (const id of Object.keys(ledger.entries)) {
|
|
787
|
+
const entry = ledger.entries[id];
|
|
788
|
+
const missing = [];
|
|
789
|
+
for (const file of entry.files) {
|
|
790
|
+
// Harden against hostile ledger JSON: a non-string member, or one that is
|
|
791
|
+
// absolute or escapes runtimeDir via "..", must not crash reconcile or become
|
|
792
|
+
// an existence oracle for files outside the runtime config dir.
|
|
793
|
+
if (typeof file !== 'string' || file === '' || node_path_1.default.isAbsolute(file) || file.split(/[/\\]/).includes('..')) {
|
|
794
|
+
// Note: do NOT String(file) — a hostile value like { toString: null } would throw.
|
|
795
|
+
const shown = typeof file === 'string' ? file : `<${typeof file}>`;
|
|
796
|
+
result.warnings.push(`Ledger entry "${id}" has an invalid file path; skipped: ${shown}`);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const resolved = node_path_1.default.join(runtimeDir, file);
|
|
800
|
+
if (!node_fs_1.default.existsSync(resolved)) {
|
|
801
|
+
missing.push(file);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (missing.length > 0) {
|
|
805
|
+
result.orphans.push({ id, missing });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
module.exports = {
|
|
811
|
+
readLedger,
|
|
812
|
+
readLedgerStrict,
|
|
813
|
+
writeLedger,
|
|
814
|
+
recordInstall,
|
|
815
|
+
removeEntry,
|
|
816
|
+
reconcile,
|
|
817
|
+
isValidLedgerEntry,
|
|
818
|
+
isUnsafeCapabilityId,
|
|
819
|
+
// Finding 2 (HIGH): the SINGLE shared bounded fd reader — also consumed by capability-lifecycle's
|
|
820
|
+
// lock-body reads so every untrusted file read goes through the regular-file + size-capped fd path.
|
|
821
|
+
readSmallRegularFile,
|
|
822
|
+
// #1459 finding 1 (HIGH): the RAW-BYTES variant — the SOLE correct reader for the byte-exact,
|
|
823
|
+
// injective consent content-hash binding (a utf8 decode is lossy and could collide binary artifacts).
|
|
824
|
+
readSmallRegularFileBuffer,
|
|
825
|
+
// Exported for testing / introspection
|
|
826
|
+
LEDGER_FILE_NAME,
|
|
827
|
+
CorruptLedgerError,
|
|
828
|
+
LedgerIOError,
|
|
829
|
+
// DoS backstop bounds — shared with the lifecycle/CLI early count check (finding 5).
|
|
830
|
+
MAX_SHARED_FILES,
|
|
831
|
+
};
|