@dogfood-lab/ingest 1.4.0 → 1.5.0

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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Chain verifier — the offline tamper-evidence gate.
3
+ *
4
+ * `verifyChain(repoRoot)` reads the append-only ledger at
5
+ * `indexes/integrity/chain.jsonl` and, FULLY OFFLINE (no network, no GitHub
6
+ * API), proves the chain is internally consistent:
7
+ *
8
+ * 1. For each entry, load the record file at `entry.path` and recompute its
9
+ * digest with the SAME `submissionDigest` helper persist used. Assert it
10
+ * equals BOTH `entry.submission_digest` (the ledger's claim) AND
11
+ * `record.integrity.submission_digest` (the record's self-claim). All three
12
+ * must agree — a tamper that touches the record but not the ledger, or the
13
+ * ledger but not the record, breaks one of these equalities.
14
+ * 2. Assert `entry.prev_digest` equals the PREVIOUS entry's
15
+ * `submission_digest` (GENESIS_DIGEST for seq 0) — the chain link.
16
+ * 3. Assert `seq` is 0,1,2,… strictly monotonic from 0.
17
+ *
18
+ * On the FIRST break it stops and reports `{ seq, run_id, reason }`; the caller
19
+ * (`--verify-chain` in run.js, or any consumer) exits non-zero. On success it
20
+ * returns the record count and head digest.
21
+ *
22
+ * Honesty (threat model): this is tamper-EVIDENT, not tamper-PROOF. An actor
23
+ * with the ingest write credential can rewrite a record, recompute its digest,
24
+ * and rewrite the matching ledger line — that re-verifies clean. The verifier
25
+ * catches any mutation to a record or ledger entry that is NOT consistently
26
+ * reflected in BOTH (an out-of-band edit, disk corruption, a partial restore, a
27
+ * push that touched a record but not the chain), and it catches middle-deletion,
28
+ * reorder, and forged-insertion (they break the seq run or the prev-link).
29
+ *
30
+ * KNOWN LIMITATION — tail truncation. Removing the most-recent entries (and
31
+ * their record files) leaves a SHORTER but internally-consistent chain, which
32
+ * verifies OK: the offline verifier has no external record of the expected head
33
+ * or leaf-count, so it can prove what remains is consistent but NOT that the
34
+ * chain is COMPLETE. Detecting truncation requires an external anchor of the
35
+ * head/count outside the writer's control — exactly what the optional XRPL
36
+ * anchor provides (its on-chain Merkle root + leaf count make any truncation
37
+ * BELOW an anchored point detectable). The offline chain alone is a completeness
38
+ * floor for in-place tampering, not a defense against tail truncation.
39
+ */
40
+
41
+ import { existsSync, readFileSync } from 'node:fs';
42
+ import { join } from 'node:path';
43
+
44
+ import { submissionDigest, GENESIS_DIGEST } from './lib/integrity.js';
45
+ import { readChainManifest } from './lib/chain-manifest.js';
46
+
47
+ /**
48
+ * @typedef {object} ChainBreak
49
+ * @property {number} seq - The seq at which verification failed.
50
+ * @property {string|null} run_id - The run_id of the failing entry, if known.
51
+ * @property {string} reason - Operator-legible description of what failed.
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} ChainVerifyResult
56
+ * @property {boolean} ok - True when the whole chain verifies.
57
+ * @property {number} count - Number of entries verified (0 for an empty chain).
58
+ * @property {string} head_digest - submission_digest of the last entry, or
59
+ * GENESIS_DIGEST for an empty chain.
60
+ * @property {ChainBreak|null} break - First break, or null when ok.
61
+ */
62
+
63
+ /**
64
+ * Verify the integrity chain at `<repoRoot>/indexes/integrity/chain.jsonl`.
65
+ *
66
+ * @param {string} repoRoot - Absolute path to the testing-os repo root.
67
+ * @returns {ChainVerifyResult}
68
+ */
69
+ export function verifyChain(repoRoot) {
70
+ let entries;
71
+ try {
72
+ entries = readChainManifest(repoRoot);
73
+ } catch (err) {
74
+ // A corrupt (non-JSON) manifest line is itself a tamper signal.
75
+ return {
76
+ ok: false,
77
+ count: 0,
78
+ head_digest: GENESIS_DIGEST,
79
+ break: { seq: -1, run_id: null, reason: err.message },
80
+ };
81
+ }
82
+
83
+ if (entries.length === 0) {
84
+ // An empty chain is trivially valid: genesis with no entries.
85
+ return { ok: true, count: 0, head_digest: GENESIS_DIGEST, break: null };
86
+ }
87
+
88
+ let prevDigest = GENESIS_DIGEST;
89
+
90
+ for (let i = 0; i < entries.length; i++) {
91
+ const entry = entries[i];
92
+ const seq = entry.seq;
93
+ const runId = entry.run_id ?? null;
94
+
95
+ const fail = (reason) => ({
96
+ ok: false,
97
+ count: entries.length,
98
+ head_digest: entries[entries.length - 1].submission_digest ?? GENESIS_DIGEST,
99
+ break: { seq, run_id: runId, reason },
100
+ });
101
+
102
+ // (3) Monotonic seq: 0,1,2,…
103
+ if (seq !== i) {
104
+ return fail(`seq is not monotonic: expected ${i}, found ${seq}`);
105
+ }
106
+
107
+ // (2) Chain link: this entry's prev_digest must equal the previous entry's
108
+ // submission_digest (GENESIS for the first entry).
109
+ if (entry.prev_digest !== prevDigest) {
110
+ return fail(
111
+ `broken prev_digest link: expected ${prevDigest}, found ${entry.prev_digest}`
112
+ );
113
+ }
114
+
115
+ // (1) Load the record and recompute its digest.
116
+ const recordPath = join(repoRoot, entry.path);
117
+ if (!existsSync(recordPath)) {
118
+ return fail(`record file missing at ${entry.path} (could not read to recompute digest)`);
119
+ }
120
+
121
+ let record;
122
+ try {
123
+ record = JSON.parse(readFileSync(recordPath, 'utf-8'));
124
+ } catch (err) {
125
+ return fail(`record file at ${entry.path} is not valid JSON: ${err.message}`);
126
+ }
127
+
128
+ const recomputed = submissionDigest(record);
129
+
130
+ // The recomputed digest must equal the ledger's claim.
131
+ if (recomputed !== entry.submission_digest) {
132
+ return fail(
133
+ `digest mismatch: recomputed ${recomputed} but manifest claims ${entry.submission_digest} ` +
134
+ `— record at ${entry.path} was modified after persist`
135
+ );
136
+ }
137
+
138
+ // …AND the record's own self-certified digest.
139
+ const selfDigest = record.integrity?.submission_digest;
140
+ if (selfDigest !== recomputed) {
141
+ return fail(
142
+ `record self-digest mismatch: record.integrity.submission_digest is ` +
143
+ `${selfDigest ?? '(absent)'} but recomputed ${recomputed} — record at ${entry.path} ` +
144
+ `was modified after persist`
145
+ );
146
+ }
147
+
148
+ prevDigest = entry.submission_digest;
149
+ }
150
+
151
+ return {
152
+ ok: true,
153
+ count: entries.length,
154
+ head_digest: entries[entries.length - 1].submission_digest,
155
+ break: null,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Render a verifyChain result as operator-legible lines (no raw stack traces).
161
+ * Returned as an array so callers can choose the sink (console.log/console.error).
162
+ *
163
+ * @param {ChainVerifyResult} result
164
+ * @returns {string[]}
165
+ */
166
+ export function formatChainResult(result) {
167
+ if (result.ok) {
168
+ return [
169
+ `integrity chain OK: ${result.count} record(s) verified`,
170
+ `head digest: ${result.head_digest}`,
171
+ ];
172
+ }
173
+ const b = result.break;
174
+ return [
175
+ `integrity chain BROKEN at seq ${b.seq}${b.run_id ? ` (run_id: ${b.run_id})` : ''}`,
176
+ `reason: ${b.reason}`,
177
+ ];
178
+ }