@sanning/proof 0.11.1 → 0.13.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.
Files changed (51) hide show
  1. package/README.md +43 -8
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +199 -43
  7. package/dist/case.js +522 -146
  8. package/dist/cli.d.ts +132 -1
  9. package/dist/cli.js +1140 -96
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/handoff.d.ts +16 -1
  21. package/dist/handoff.js +91 -16
  22. package/dist/index.d.ts +10 -7
  23. package/dist/index.js +15 -7
  24. package/dist/message-parts.d.ts +11 -9
  25. package/dist/message-parts.js +11 -9
  26. package/dist/types.d.ts +1 -0
  27. package/dist/verifier.d.ts +9 -2
  28. package/dist/verifier.js +28 -12
  29. package/dist/zip-reader.d.ts +50 -0
  30. package/dist/zip-reader.js +220 -0
  31. package/package.json +2 -2
  32. package/src/agent-proof.ts +4 -4
  33. package/src/case-file.ts +419 -0
  34. package/src/case.ts +651 -156
  35. package/src/cli.ts +1325 -100
  36. package/src/crypto.ts +5 -1
  37. package/src/declaration.ts +248 -52
  38. package/src/evidence.ts +142 -21
  39. package/src/exit-codes.ts +54 -0
  40. package/src/grading.ts +109 -52
  41. package/src/handoff.ts +108 -17
  42. package/src/index.ts +60 -3
  43. package/src/message-parts.ts +11 -9
  44. package/src/types.ts +5 -0
  45. package/src/verifier.ts +35 -13
  46. package/src/zip-reader.ts +229 -0
  47. package/vectors/README.md +56 -3
  48. package/vectors/case-file-index.json +668 -0
  49. package/vectors/case-split.json +1102 -0
  50. package/vectors/pack-grading.json +68 -4
  51. package/vectors/stamp-on-chain.json +3 -3
package/src/cli.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // npx @sanning/proof verify <bundle.json> [comma,sep,gateways]
2
+ // sanning-proof verify <bundle.json> [--gateways <url,url>]
3
+ // (`npx @sanning/proof verify ...` without installing: npm runs a package's
4
+ // only binary whatever it is called.)
3
5
  //
4
6
  // One turnkey verifier for both producer bundles in the stack:
5
7
  // - sanning.evidence/v1: bodies sanning.anchor.trace/v3, sanning.stamp.trace/v1,
@@ -7,12 +9,16 @@
7
9
  // - sanning.agent.proof/v1: the agent inclusion proof
8
10
  // Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
9
11
  //
10
- // Pinned exit codes (mirror `sanningd verify-status`):
11
- // 0 verified
12
- // 1 a real verification failure (bad signature / tamper / broken inclusion)
13
- // 2 malformed bundle (unparseable, unknown spec major, body_hash mismatch)
14
- // 3 UNDETERMINED: a gateway was unreachable when an on-chain re-fetch was
15
- // requested, or the PACK is `incomplete` (see below)
12
+ // Exit codes: one table for every verb, `./exit-codes.ts` (0.13.0):
13
+ // 0 verified, or the export was written
14
+ // 1 failed (bad signature / tamper / broken inclusion / on-chain mismatch)
15
+ // 2 usage or malformed (unparseable, unknown spec major, body_hash mismatch)
16
+ // 3 UNDETERMINED: partial, pending, incomplete or not_found. A gateway that
17
+ // could not be reached reads `partial`; a pack whose records nobody hashed
18
+ // reads `incomplete` (see below)
19
+ // 4 mixed
20
+ // `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
21
+ // exited 0 on `partial`; it moves with this one.
16
22
  //
17
23
  // ── The headline is the PACK verdict, not the bundle status (proof#110) ──
18
24
  //
@@ -25,9 +31,10 @@
25
31
  // terminal: two answers to one question, from one kernel, which is the exact
26
32
  // defect `grading.ts` was brought into the kernel to end.
27
33
  //
28
- // The two enums stay distinct, deliberately (`grading.ts` says so in its own
29
- // comments, and `evidence-bundle.md` §3 does not carry `incomplete`): the
30
- // BUNDLE status is still reported, unchanged, on its own line. What changed is
34
+ // The two layers stay distinct, deliberately (`grading.ts` says so in its own
35
+ // comments, and `incomplete` is a pack verdict in `evidence-bundle.md` §3,
36
+ // never a bundle status): the BUNDLE status is still reported, unchanged, on
37
+ // its own line. What changed is
31
38
  // which of the two is the headline and which one the exit code follows.
32
39
  //
33
40
  // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
@@ -35,12 +42,13 @@
35
42
  // PACK at `incomplete`, because a record nobody hashed is a record nobody
36
43
  // checked, and exit 3 is how this command says "undetermined".
37
44
 
38
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
39
- import { realpathSync } from "node:fs";
40
- import { join } from "node:path";
45
+ import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
46
+ import { constants as fsConstants, realpathSync } from "node:fs";
47
+ import { randomBytes } from "node:crypto";
48
+ import { basename, join, resolve } from "node:path";
41
49
  import { fileURLToPath } from "node:url";
42
50
 
43
- import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
51
+ import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
44
52
  import type { AgentProofResult } from "./agent-proof.js";
45
53
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
46
54
 
@@ -48,21 +56,41 @@ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js"
48
56
  // named statement that one could not be produced. Three states, not two.
49
57
  type ChainOutcome = ChainReading | { unreadable: string };
50
58
  import type { ChainReading } from "./chain.js";
59
+ import {
60
+ CASE_RELIANCE,
61
+ VERDICT_DISPLAY_TITLES,
62
+ WITNESS_NOT_LOOKED_UP,
63
+ type WitnessLine,
64
+ readCase,
65
+ renderCaseNote,
66
+ renderCaseSummary,
67
+ renderException,
68
+ renderWitnessLine,
69
+ } from "./case.js";
70
+ import {
71
+ CASE_FILE_INDEX_NAME,
72
+ jsonDepthExceeds,
73
+ readCaseFileIndex,
74
+ renderCaseFileFinding,
75
+ } from "./case-file.js";
76
+ import { ZipReadError, readZip } from "./zip-reader.js";
51
77
  import { composeExport } from "./compose.js";
52
78
  import type { ComposeExportOptions } from "./compose.js";
53
- import { isHexField, utf8 } from "./crypto.js";
79
+ import { isHexField, sha256Hex, utf8 } from "./crypto.js";
54
80
  import { verifyEvidenceBundle } from "./evidence.js";
55
81
  import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
56
82
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
57
83
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
58
84
  import type { DisclosureAccount, PackGrade } from "./grading.js";
59
85
 
60
- const EXIT_VERIFIED = 0;
61
- const EXIT_FAILED = 1;
62
- const EXIT_MALFORMED = 2;
63
- // One code, two undetermined outcomes: a gateway we could not reach, and a pack
64
- // whose records nobody hashed. Neither is an accusation, and neither is a pass.
65
- const EXIT_UNDETERMINED = 3;
86
+ import {
87
+ EXIT_CODES_HELP,
88
+ EXIT_FAILED,
89
+ EXIT_MALFORMED,
90
+ EXIT_UNDETERMINED,
91
+ EXIT_VERIFIED,
92
+ exitCodeFor,
93
+ } from "./exit-codes.js";
66
94
 
67
95
  interface Cli {
68
96
  out: (s: string) => void;
@@ -70,12 +98,78 @@ interface Cli {
70
98
  fetchImpl?: typeof fetch;
71
99
  }
72
100
 
73
- const G = "\x1b[32m";
74
- const R = "\x1b[31m";
75
- const Y = "\x1b[33m";
76
- const DIM = "\x1b[2m";
77
- const B = "\x1b[1m";
78
- const X = "\x1b[0m";
101
+ // ── What a person reads is escaped at one choke point ──
102
+ //
103
+ // Every human-readable line this command prints passes through `guard`, which
104
+ // `runCli` and `runCase` apply to the streams they are handed. It escapes each
105
+ // control character, so a value the audited party wrote (a folder or file
106
+ // name, a `contents.json` field, a zip entry name, an exception built from
107
+ // one) cannot move the cursor, clear the screen or reorder the line. Before
108
+ // this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
109
+ // a FAILED headline. `outJson` is the one way around it, and it carries JSON
110
+ // only, which escapes its own control characters. The twin is `Io` in
111
+ // `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
112
+
113
+ // C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
114
+ // embedding, override and isolate controls.
115
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
116
+
117
+ /** Each control character in `text` as a visible escape: `\x1b` for ESC,
118
+ * `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
119
+ * backslash included, so a line with none is printed as it was. */
120
+ export function escapeControl(text: string): string {
121
+ return text.replace(CONTROL_CHARACTERS, (c) => {
122
+ const code = c.charCodeAt(0);
123
+ return code < 0x100
124
+ ? `\\x${code.toString(16).padStart(2, "0")}`
125
+ : `\\u${code.toString(16).padStart(4, "0")}`;
126
+ });
127
+ }
128
+
129
+ // The colors this command writes on a TTY are its own control characters, and
130
+ // the choke point would escape them with everything else. So `paint` and
131
+ // `mark` write this marker instead, and the choke point turns it into ESC
132
+ // after escaping. It is random per process, so no input can spell it.
133
+ const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
134
+ const G = `${COLOR_MARK}32m`;
135
+ const R = `${COLOR_MARK}31m`;
136
+ const Y = `${COLOR_MARK}33m`;
137
+ const DIM = `${COLOR_MARK}2m`;
138
+ const B = `${COLOR_MARK}1m`;
139
+ const X = `${COLOR_MARK}0m`;
140
+
141
+ function forTerminal(text: string): string {
142
+ return escapeControl(text).split(COLOR_MARK).join("\x1b[");
143
+ }
144
+
145
+ const RAW = Symbol("sanning.cli.raw");
146
+ type Guarded = Cli & { [RAW]: Cli };
147
+
148
+ function guard(io: Cli): Cli {
149
+ if (RAW in io) return io;
150
+ const guarded: Guarded = {
151
+ out: (s) => io.out(forTerminal(s)),
152
+ err: (s) => io.err(forTerminal(s)),
153
+ [RAW]: io,
154
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
155
+ };
156
+ return guarded;
157
+ }
158
+
159
+ function rawOf(io: Cli): Cli {
160
+ return (io as Partial<Guarded>)[RAW] ?? io;
161
+ }
162
+
163
+ /** JSON, printed unescaped: a script reads it, not a terminal. */
164
+ function outJson(io: Cli, text: string): void {
165
+ rawOf(io).out(text);
166
+ }
167
+
168
+ /** A block this command wrote itself, such as a usage text: its own newlines
169
+ * are kept, and each line is escaped as `out` escapes it. */
170
+ function outBlock(io: Cli, text: string): void {
171
+ rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
172
+ }
79
173
 
80
174
  function useColor(): boolean {
81
175
  // Honor NO_COLOR; otherwise color only a TTY.
@@ -94,7 +188,8 @@ function paint(s: string, c: string): string {
94
188
  return useColor() ? `${c}${s}${X}` : s;
95
189
  }
96
190
 
97
- export async function runCli(argv: string[], io: Cli): Promise<number> {
191
+ export async function runCli(argv: string[], stream: Cli): Promise<number> {
192
+ const io = guard(stream);
98
193
  const [command, ...rest] = argv;
99
194
 
100
195
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
@@ -105,25 +200,97 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
105
200
  io.out("@sanning/proof verify CLI");
106
201
  return EXIT_VERIFIED;
107
202
  }
108
- // `bundle` is the customer-facing verb (proof#19: one artifact, one word).
109
- // `export` is kept working as a QUIET alias — it shipped and is published, so
110
- // it is not broken abruptly; it is simply no longer taught anywhere.
111
- if (command === "bundle" || command === "export") {
112
- return runBundle(rest, io);
203
+ // `export` composes an attested export (`sanning.evidence.export/v2`). It
204
+ // was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
205
+ // builds a pack (`anchor bundle`, DL-59), so this command no longer answers
206
+ // to it: `proof bundle` is an unknown command and exits 2, with no alias.
207
+ if (command === "export") {
208
+ if (rest.includes("--json")) {
209
+ return runJson(
210
+ rest.filter((a) => a !== "--json"),
211
+ io,
212
+ runExport,
213
+ );
214
+ }
215
+ return runExport(rest, io, null);
216
+ }
217
+ if (command === "case") {
218
+ return runCase(rest, io);
113
219
  }
114
220
  if (command !== "verify") {
115
221
  io.err(`unknown command: ${command}`);
116
222
  printUsage(io);
117
223
  return EXIT_MALFORMED;
118
224
  }
225
+ if (rest.includes("--json")) {
226
+ return runJson(
227
+ rest.filter((a) => a !== "--json"),
228
+ io,
229
+ runVerify,
230
+ );
231
+ }
232
+ return runVerify(rest, io, null);
233
+ }
234
+
235
+ // ─── `--json` on `verify` and `export` (C-03, 0.13.0) ──────────────────────
236
+ //
237
+ // `case --json` printed the reading as JSON and the other two verbs had no
238
+ // machine-readable form, so a script parsed the text. Each verb now runs as it
239
+ // does for a person, with its text discarded, and hands this one object back
240
+ // through `sink`: the same keys in both kernels, sorted at every depth, and
241
+ // printed byte for byte the same (`cross-kernel/run_cli.sh`,
242
+ // `cross-kernel/run_export_cli.sh`). A run that stops before it has anything to
243
+ // report (a bad flag, an unreadable file) prints `{ error, exit_code }`, as
244
+ // `case --json` does. `note:` lines still go to stderr.
245
+ interface JsonSink {
246
+ data: Record<string, unknown> | null;
247
+ }
248
+
249
+ type JsonVerb = (rest: string[], io: Cli, sink: JsonSink | null) => Promise<number>;
250
+
251
+ async function runJson(rest: string[], io: Cli, verb: JsonVerb): Promise<number> {
252
+ const errs: string[] = [];
253
+ const sink: JsonSink = { data: null };
254
+ const quiet: Cli = {
255
+ out: () => {},
256
+ err: (s) => errs.push(s),
257
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
258
+ };
259
+ const code = await verb(rest, quiet, sink);
260
+ const notes = errs.filter((l) => l.startsWith("note: "));
261
+ for (const n of notes) io.err(n);
262
+ if (sink.data !== null) {
263
+ outJson(io, jsonText({ ...sink.data, exit_code: code }));
264
+ } else {
265
+ const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
266
+ outJson(io, jsonText({ error, exit_code: code }));
267
+ }
268
+ return code;
269
+ }
270
+
271
+ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
119
272
 
120
- // Pull the optional `--logs <path>` flag out of the args; the rest are
121
- // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
122
- // content verification (the evidence-bundle path only).
273
+ // `--logs <path>` feeds disclosed raw-log bytes for content verification
274
+ // (the evidence-bundle path only); `--gateways <url,url>` asks for the
275
+ // on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
276
+ // second positional argument until then, and a second positional is now a
277
+ // usage error). The one positional is the bundle.
123
278
  let logsPath: string | undefined;
279
+ let gatewayArg: string | undefined;
124
280
  const positional: string[] = [];
125
281
  for (let i = 0; i < rest.length; i++) {
126
282
  const a = rest[i]!;
283
+ if (a === "--gateways") {
284
+ const next = rest[i + 1];
285
+ if (next === undefined) {
286
+ io.err("verify: --gateways requires a comma-separated list of URLs");
287
+ printUsage(io);
288
+ return EXIT_MALFORMED;
289
+ }
290
+ gatewayArg = next;
291
+ i++;
292
+ continue;
293
+ }
127
294
  if (a === "--logs") {
128
295
  const next = rest[i + 1];
129
296
  if (next === undefined) {
@@ -138,12 +305,17 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
138
305
  positional.push(a);
139
306
  }
140
307
 
141
- const [bundlePath, gatewayArg] = positional;
308
+ const [bundlePath] = positional;
142
309
  if (!bundlePath) {
143
310
  io.err("verify: a bundle file path is required");
144
311
  printUsage(io);
145
312
  return EXIT_MALFORMED;
146
313
  }
314
+ if (positional.length > 1) {
315
+ io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
316
+ printUsage(io);
317
+ return EXIT_MALFORMED;
318
+ }
147
319
  const gateways = (gatewayArg ?? "")
148
320
  .split(",")
149
321
  .map((g) => g.trim())
@@ -157,19 +329,26 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
157
329
  }
158
330
  const content = logs?.content;
159
331
 
160
- let raw: string;
332
+ let raw: Uint8Array;
161
333
  try {
162
- raw = await readFile(bundlePath, "utf8");
334
+ raw = new Uint8Array(await readFile(bundlePath));
163
335
  } catch (e) {
164
336
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
165
337
  return EXIT_MALFORMED;
166
338
  }
167
339
 
340
+ // The one reader both commands use, in both kernels: `case` reads each pack's
341
+ // bundle.json through it too, so the two commands classify one bundle the
342
+ // same way (see `readBundleBytes`).
168
343
  let parsed: unknown;
169
344
  try {
170
- parsed = JSON.parse(raw);
345
+ parsed = readBundleBytes(raw);
171
346
  } catch (e) {
172
- io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
347
+ if (e instanceof BundleReadError && e.tooDeep) {
348
+ io.err(`${bundlePath} ${e.detail}; refusing to read it`);
349
+ } else {
350
+ io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
351
+ }
173
352
  return EXIT_MALFORMED;
174
353
  }
175
354
 
@@ -184,11 +363,11 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
184
363
  // has no such per-event content. Say so rather than silently ignoring it.
185
364
  io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
186
365
  }
187
- const result = await verifyAgentProofBundle(parsed, {
366
+ const result = await verifyAgentProof(parsed, {
188
367
  gateways,
189
368
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
190
369
  });
191
- return reportAgentProof(result, bundlePath, gateways, io);
370
+ return reportAgentProof(result, bundlePath, gateways, io, sink);
192
371
  }
193
372
 
194
373
  // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
@@ -217,26 +396,29 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
217
396
  } catch (e) {
218
397
  chains = { unreadable: errMsg(e) };
219
398
  }
220
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
399
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
221
400
  // Coverage is reported only once the bundle parsed far enough to have events
222
401
  // to compare against; over a MALFORMED bundle there is nothing to say.
223
402
  if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
224
403
  return exit;
225
404
  }
226
405
 
227
- // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
228
- // (`proof export` still reaches this, undocumented — see the dispatch above.)
406
+ // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
229
407
  //
230
408
  // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
231
409
  // source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
232
- // Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
410
+ // Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
233
411
  // (or stdout when omitted, so it can be piped straight into `proof verify`).
234
412
  //
235
413
  // Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
236
414
  // forged/mis-bound attestation — the composer refuses to emit a green artifact
237
415
  // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
238
416
  // or non-JSON input, bad exporter key, malformed attestation record).
239
- async function runBundle(rest: string[], io: Cli): Promise<number> {
417
+ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
418
+ if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
419
+ usageExport(io);
420
+ return EXIT_VERIFIED;
421
+ }
240
422
  let attestationsPath: string | undefined;
241
423
  let keyPath: string | undefined;
242
424
  let outPath: string | undefined;
@@ -254,15 +436,15 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
254
436
  i++;
255
437
  return next;
256
438
  };
257
- if (a === "--attestations" || a === "-a") {
439
+ if (a === "--attestations") {
258
440
  const v = takeNext(a);
259
441
  if (v === undefined) return usageExport(io);
260
442
  attestationsPath = v;
261
- } else if (a === "--key" || a === "-k") {
443
+ } else if (a === "--key") {
262
444
  const v = takeNext(a);
263
445
  if (v === undefined) return usageExport(io);
264
446
  keyPath = v;
265
- } else if (a === "--out" || a === "-o") {
447
+ } else if (a === "--out") {
266
448
  const v = takeNext(a);
267
449
  if (v === undefined) return usageExport(io);
268
450
  outPath = v;
@@ -282,6 +464,12 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
282
464
  const v = takeNext(a);
283
465
  if (v === undefined) return usageExport(io);
284
466
  opts.generatedAt = v;
467
+ } else if (a.startsWith("-")) {
468
+ // A flag this verb does not take is a usage error, never a path: `-o`
469
+ // was a short form of `--out` until 0.13.0, and read as a path it would
470
+ // send the export to stdout while the caller waits for a file.
471
+ io.err(`export: unknown option ${a}`);
472
+ return usageExport(io);
285
473
  } else {
286
474
  positional.push(a);
287
475
  }
@@ -327,6 +515,18 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
327
515
  // FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
328
516
  // still writes the artifact so the caller can inspect it.
329
517
  const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
518
+ if (sink) {
519
+ sink.data = {
520
+ status: composed.status,
521
+ out: outPath ?? null,
522
+ source_bundle_hash: composed.sourceBundleHash,
523
+ attestations: attestations.length,
524
+ attestations_bound: composed.boundAttestations,
525
+ // Without --out the artifact rides inside this object, since stdout
526
+ // carries the object and not the artifact.
527
+ export: outPath ? null : composed.bundle,
528
+ };
529
+ }
330
530
  if (outPath) {
331
531
  try {
332
532
  await writeFile(outPath, serialized, "utf8");
@@ -340,28 +540,34 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
340
540
  `attestations ${composed.boundAttestations}/${attestations.length} bound`,
341
541
  );
342
542
  } else {
343
- io.out(serialized.replace(/\n$/, ""));
543
+ outJson(io, serialized.replace(/\n$/, ""));
344
544
  }
345
545
  return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
346
546
  }
347
547
 
348
548
  function usageExport(io: Cli): number {
349
549
  io.out(
350
- "Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]",
550
+ "Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]",
351
551
  );
352
552
  io.out("");
353
553
  io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
354
554
  io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
355
- io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
555
+ io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
356
556
  io.out("");
357
557
  io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
358
558
  io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
359
559
  io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
360
- io.out("-o, --out <file> Write the export here (default: stdout).");
560
+ io.out("--out <file> Write the export here (default: stdout).");
361
561
  io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
362
562
  io.out("--gateway <url> Named delivery surface (not trusted).");
363
563
  io.out("--previous-hash <h> Custody pointer (default GENESIS).");
364
564
  io.out("--generated-at <ts> RFC 3339 compose time (default now).");
565
+ io.out("--json Print a summary as JSON (with the export inside it");
566
+ io.out(" when --out is not given).");
567
+ io.out("");
568
+ for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
569
+ io.out("");
570
+ io.out("export: 1 means the recomputed export is failed; it is still written.");
365
571
  return EXIT_MALFORMED;
366
572
  }
367
573
 
@@ -435,7 +641,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
435
641
  // disclosure. This is the layout `materializeBundle` writes
436
642
  // (bundle.json + logs/) and the layout the verify page hashes,
437
643
  // so a pack verifies with NO side-input construction step.
438
- // JSON MAP `{ event_id: <even-length lowercase hex | utf8 text> }`, the
644
+ // JSON MAP `{ event_id: <even-length hex, either case | utf8 text> }`, the
439
645
  // original form. Unchanged.
440
646
  //
441
647
  // **Why the directory form exists at all.** Handing a pack to the CLI used to
@@ -454,7 +660,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
454
660
  // and the verify page agree about one pack.
455
661
  interface LoadedLogs {
456
662
  // Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
457
- // is lowercase hex (map form). Those are exactly the two the verifier
663
+ // is hex in either case (map form). Those are exactly the two the verifier
458
664
  // distinguishes.
459
665
  //
460
666
  // A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
@@ -647,7 +853,8 @@ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io:
647
853
  if (undetermined.length > 0) {
648
854
  io.err(
649
855
  `note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
650
- `content — no bytes disclosed, or no committed content_hash to bind them to. ` +
856
+ `content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
857
+ `hand-off record) to bind them to. ` +
651
858
  `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
652
859
  );
653
860
  }
@@ -695,30 +902,39 @@ function decodeLogValue(v: string): Uint8Array | string {
695
902
  }
696
903
 
697
904
  function printUsage(io: Cli): void {
698
- io.out("Usage: npx @sanning/proof <command> [args]");
905
+ io.out("Usage: sanning-proof <command> [args]");
906
+ io.out(" (npx @sanning/proof <command> [args], without installing)");
699
907
  io.out("");
700
908
  io.out("Commands:");
701
- io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
702
- io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
909
+ io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
910
+ io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
911
+ io.out(" Create an attested export");
912
+ io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
913
+ io.out(" (`case --help` for its exit codes)");
703
914
  io.out("");
704
915
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
705
916
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
706
917
  io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
707
- io.out("fully offline. Supply a comma-separated gateway list to also re-fetch");
708
- io.out("each checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
918
+ io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
919
+ io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
709
920
  io.out("a witness of any other kind is not checked and the run exits 3.");
710
921
  io.out("");
922
+ io.out("--json Print the verdict, the causes and the exit code as");
923
+ io.out(" JSON, for a script, instead of the report.");
924
+ io.out("");
711
925
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
712
- io.out(" content_hash. Either shape works, and which one you");
926
+ io.out(" content_hash, or message_hash for a hand-off record.");
927
+ io.out(" Either shape works, and which one you");
713
928
  io.out(" passed is decided by stat(), not by the extension:");
714
929
  io.out("");
715
930
  io.out(" a DIRECTORY of <event_id>.json files — the file's");
716
931
  io.out(" raw bytes ARE the content, nothing is decoded.");
717
932
  io.out(" This is a materialized pack's logs/ directory:");
718
- io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
933
+ io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
719
934
  io.out("");
720
935
  io.out(" a JSON FILE mapping event_id → bytes (an");
721
- io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
936
+ io.out(" even-length hex value, in either case, ⇒ hex,");
937
+ io.out(" else utf8).");
722
938
  io.out("");
723
939
  io.out(" In-body events[].content is verified automatically");
724
940
  io.out(" and takes precedence. An event with no disclosed");
@@ -728,8 +944,10 @@ function printUsage(io: Cli): void {
728
944
  io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
729
945
  io.out("bundle itself is verified. Both verdicts are printed when they differ.");
730
946
  io.out("");
731
- io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 undetermined");
732
- io.out(" (an unreachable gateway, or an incomplete pack)");
947
+ for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
948
+ io.out("");
949
+ io.out("verify: an unreachable gateway reads partial, and a pack with a record");
950
+ io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
733
951
  }
734
952
 
735
953
  /**
@@ -777,8 +995,10 @@ function reportEvidence(
777
995
  chains: ChainOutcome,
778
996
  parsed: unknown,
779
997
  logs: LoadedLogs | undefined,
998
+ sink: JsonSink | null,
780
999
  ): number {
781
1000
  if (result.status === "malformed") {
1001
+ if (sink) sink.data = verifyJson(result, null, null, null);
782
1002
  io.err(paint("MALFORMED", R) + ` ${path}`);
783
1003
  for (const e of result.errors) io.err(` - ${e}`);
784
1004
  return EXIT_MALFORMED;
@@ -876,7 +1096,7 @@ function reportEvidence(
876
1096
  // answers `partial` and the line below says so in words, and pack grading
877
1097
  // keeps its own verdict: every pack built before §5.4 carries no
878
1098
  // declaration, and re-grading them all as undetermined would be a change to
879
- // the pinned 0/1/2/3 contract rather than a new check. A pack that DOES
1099
+ // their verdicts rather than a new check. A pack that DOES
880
1100
  // carry a declaration this reader cannot read is a different case and exits
881
1101
  // 3: the holder chose that field, so UNKNOWN is the honest answer.
882
1102
  const declaration = checkDeclaration(parsed, logs?.content);
@@ -915,7 +1135,47 @@ function reportEvidence(
915
1135
  );
916
1136
  }
917
1137
 
918
- return mapGradeToExit(grade, result);
1138
+ if (sink) sink.data = verifyJson(result, grade, declaration, chains);
1139
+ return mapGradeToExit(grade);
1140
+ }
1141
+
1142
+ /**
1143
+ * What `verify --json` prints for an evidence bundle, the same keys and the
1144
+ * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
1145
+ * `chains` are null for a bundle no verdict could be rendered over.
1146
+ */
1147
+ function verifyJson(
1148
+ result: EvidenceBundleResult,
1149
+ grade: PackGrade | null,
1150
+ declaration: ReturnType<typeof checkDeclaration> | null,
1151
+ chains: ChainOutcome | null,
1152
+ ): Record<string, unknown> {
1153
+ let chainBlock: Record<string, unknown> | null = null;
1154
+ if (chains !== null) {
1155
+ chainBlock =
1156
+ "unreadable" in chains
1157
+ ? { unreadable: chains.unreadable, chains: 0, findings: [] }
1158
+ : {
1159
+ unreadable: null,
1160
+ chains: chains.chains.length,
1161
+ findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
1162
+ };
1163
+ }
1164
+ return {
1165
+ verdict: grade ? grade.verdict : result.status,
1166
+ bundle_status: result.status,
1167
+ asserted_status: result.assertedStatus ?? null,
1168
+ body_type: result.bodyType ?? null,
1169
+ on_chain_checked: result.onChainChecked,
1170
+ declaration: declaration ? declaration.verdict : null,
1171
+ declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
1172
+ causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
1173
+ notes: [...result.errors],
1174
+ records: grade
1175
+ ? { presented: result.events.length, content_verified: grade.recordsVerified }
1176
+ : null,
1177
+ chains: chainBlock,
1178
+ };
919
1179
  }
920
1180
 
921
1181
  /**
@@ -933,8 +1193,8 @@ function reportEvidence(
933
1193
  * complete" does not follow, and two customer-facing documents had already
934
1194
  * made that leap. The limit travels with the result or it gets dropped.
935
1195
  * 3. It does not touch the exit code. Findings are evidence; the exit code is
936
- * the verdict surface, and the pinned 0/1/2/3 contract stays exactly what it
937
- * was. A dangling link is something for a person to look at, not a machine
1196
+ * the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
1197
+ * the verdict and nothing else. A dangling link is something for a person to look at, not a machine
938
1198
  * decision this check is entitled to make.
939
1199
  */
940
1200
  function reportChains(reading: ChainOutcome, io: Cli): void {
@@ -996,7 +1256,17 @@ function reportAgentProof(
996
1256
  path: string,
997
1257
  gateways: string[],
998
1258
  io: Cli,
1259
+ sink: JsonSink | null,
999
1260
  ): number {
1261
+ if (sink) {
1262
+ sink.data = {
1263
+ verdict: result.status,
1264
+ bundle_status: result.status,
1265
+ spec_version: "sanning.agent.proof/v1",
1266
+ on_chain_checked: result.onChainChecked,
1267
+ notes: [...result.errors],
1268
+ };
1269
+ }
1000
1270
  if (result.status === "malformed") {
1001
1271
  io.err(paint("MALFORMED", R) + ` ${path}`);
1002
1272
  for (const e of result.errors) io.err(` - ${e}`);
@@ -1016,7 +1286,7 @@ function reportAgentProof(
1016
1286
  printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
1017
1287
  for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
1018
1288
 
1019
- return mapStatusToExit(result.status, result.errors);
1289
+ return exitCodeFor(result.status);
1020
1290
  }
1021
1291
 
1022
1292
  function printRollup(
@@ -1071,34 +1341,989 @@ function printRollup(
1071
1341
  }
1072
1342
 
1073
1343
  /**
1074
- * The exit code follows the PACK verdict, then falls back to the bundle status.
1344
+ * The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
1075
1345
  *
1076
- * `incomplete` is 3 rather than 0: it is undetermined, which is what 3 already
1077
- * meant for an unreachable gateway, and it is emphatically not 1: the bytes
1078
- * being absent is never reported as tamper (`evidence-bundle.md` §5.1).
1346
+ * `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
1347
+ * unreachable gateway too, and emphatically not 1: the bytes being absent is
1348
+ * never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
1349
+ * exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
1079
1350
  */
1080
- function mapGradeToExit(grade: PackGrade, result: EvidenceBundleResult): number {
1081
- if (grade.verdict === "malformed") return EXIT_MALFORMED;
1082
- if (grade.verdict === "failed") return EXIT_FAILED;
1083
- if (grade.verdict === "incomplete") return EXIT_UNDETERMINED;
1084
- return mapStatusToExit(result.status, result.errors);
1085
- }
1086
-
1087
- function mapStatusToExit(status: string, errors: string[]): number {
1088
- if (status === "failed") return EXIT_FAILED;
1089
- if (status === "malformed") return EXIT_MALFORMED;
1090
- // "partial" caused specifically by an unreachable gateway — or an export
1091
- // source_bundle_ref whose bytes are unavailable offline (evidence-export.md
1092
- // §5 step 3) — maps to exit 3 (undetermined, network-dependent).
1093
- if (
1094
- status === "partial" &&
1095
- errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))
1096
- ) {
1097
- return EXIT_UNDETERMINED;
1098
- }
1099
- // "verified" → 0. "partial" from a withheld record (semantics-undetermined,
1100
- // not a failure) also exits 0 — the bundle is cryptographically sound.
1101
- return EXIT_VERIFIED;
1351
+ function mapGradeToExit(grade: PackGrade): number {
1352
+ return exitCodeFor(grade.verdict);
1353
+ }
1354
+
1355
+ // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
1356
+ //
1357
+ // Directories on disk, split into packs. This lives in the CLI module, and not
1358
+ // in a kernel module, because it is the command's own read of files a person
1359
+ // named on the command line, the same class of read as `verify`'s read of
1360
+ // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
1361
+ // `readBundleBytes`.
1362
+ //
1363
+ // The case READING is `readCase`, and nothing here touches it. This turns the
1364
+ // directories a reader typed into a list of packs. Its twin is the same section
1365
+ // of `src/sanning_proof/cli.py`, and both are held to one table,
1366
+ // `vectors/case-split.json`, whose rows are directory trees.
1367
+ //
1368
+ // ── Directories only ──
1369
+ //
1370
+ // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
1371
+ // unzip it first and pass the folder, and a `.zip` inside a directory is a file
1372
+ // like any other: outside every pack, and never opened. Reading archives in the
1373
+ // kernel brought zip bombs, two zip libraries that disagree, and entry names
1374
+ // that are not file names, and `unzip` already does that job.
1375
+ //
1376
+ // ── The walk ──
1377
+ //
1378
+ // Iterative, never recursive, bytewise-sorted at each level, and bounded:
1379
+ //
1380
+ // - A directory nested more than MAX_WALK_DEPTH levels below an argument
1381
+ // refuses the whole input (exit 2).
1382
+ // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
1383
+ // the whole input (exit 2).
1384
+ // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
1385
+ // not escaped or replaced: two different names would then read as one.
1386
+ // - A symbolic link is never followed, whatever it points at, and anything
1387
+ // else that is not a regular file or a directory is never opened. Each is
1388
+ // listed as outside every pack and not read, even when it sits under a
1389
+ // pack's `logs/` or is named `bundle.json`.
1390
+ //
1391
+ // ── The split: the verify page's rule over the tree ──
1392
+ //
1393
+ // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
1394
+ // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
1395
+ // apply the same splitting rule to the same unpacked tree. The page's rule
1396
+ // lives in the page, so neither kernel can call it; it is restated here and in
1397
+ // the Python twin, and the shared table pins it:
1398
+ //
1399
+ // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1400
+ // at the directory that holds it.
1401
+ // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1402
+ // if it is that root's `bundle.json` or under that root's `logs/`.
1403
+ // 3. A file under no pack is OUTSIDE: listed, and never opened.
1404
+ // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1405
+ // `Array.prototype.sort`).
1406
+ // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1407
+ // and any other file under `logs/` is UNACCOUNTED.
1408
+ // 6. A pack's `ref` is its root without the trailing slash.
1409
+ // 7. No `bundle.json` anywhere refuses.
1410
+ //
1411
+ // One directory argument is read at `<its name>/<path inside it>`, which is how
1412
+ // the page names the files of a dropped folder. Several are read as one folder
1413
+ // holding them, each under its own name, and two that would land at one name
1414
+ // refuse.
1415
+ //
1416
+ // Known differences from the page: the page opens a dropped `.zip` and this
1417
+ // command does not; the page drops a leading byte-order mark from
1418
+ // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1419
+ // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1420
+ // where this command applies the walk's rules above; and the page keys
1421
+ // disclosed content by a plain object, so it loses a record named `__proto__`,
1422
+ // which this command keeps.
1423
+
1424
+ /** The error text for an input with no pack in it, word for word the page's. */
1425
+ export const NO_BUNDLE =
1426
+ "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
1427
+
1428
+ /** A directory nested deeper than this below an argument refuses the input. */
1429
+ export const MAX_WALK_DEPTH = 32;
1430
+ /** More names than this, across every argument, refuse the input. */
1431
+ export const MAX_WALK_ENTRIES = 50_000;
1432
+ /**
1433
+ * `bundle.json` nested deeper than this is refused before it is parsed, by
1434
+ * `verify` and by `case` alike (see `readBundleBytes`).
1435
+ */
1436
+ export const MAX_JSON_DEPTH = 512;
1437
+
1438
+ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
1439
+ const LOG_PATH = /^logs\/([^/]+)\.json$/;
1440
+ const ZIP_NAME = /\.zip$/i;
1441
+
1442
+ /** The one line a `.zip` gets, as an argument or as the way through a refusal. */
1443
+ const UNZIP_FIRST =
1444
+ "Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
1445
+ "case/) and pass the folder.";
1446
+
1447
+ /**
1448
+ * The input could not be read as a case. The message is the whole report,
1449
+ * identical in both kernels, and the command exits 2 on it. `hint` is an
1450
+ * optional second line naming a way through.
1451
+ */
1452
+ export class CaseInputError extends Error {
1453
+ readonly hint: string | null;
1454
+ constructor(message: string, hint: string | null = null) {
1455
+ super(message);
1456
+ this.hint = hint;
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1462
+ * the one refusal that is not a parse error; `detail` is the parser's own words,
1463
+ * which differ by kernel and are printed by `verify` only.
1464
+ */
1465
+ export class BundleReadError extends Error {
1466
+ readonly tooDeep: boolean;
1467
+ readonly detail: string;
1468
+ constructor(tooDeep: boolean, detail: string) {
1469
+ super(detail);
1470
+ this.tooDeep = tooDeep;
1471
+ this.detail = detail;
1472
+ }
1473
+ }
1474
+
1475
+ // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1476
+ // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1477
+ const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1478
+ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1479
+
1480
+ /**
1481
+ * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1482
+ * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1483
+ * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1484
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1485
+ * Throws `BundleReadError`.
1486
+ */
1487
+ export function readBundleBytes(bytes: Uint8Array): unknown {
1488
+ const text = bundleDecoder.decode(bytes);
1489
+ if (jsonDepthExceeds(text, MAX_JSON_DEPTH)) {
1490
+ throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1491
+ }
1492
+ try {
1493
+ return JSON.parse(text);
1494
+ } catch (e) {
1495
+ throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1496
+ }
1497
+ }
1498
+
1499
+ export interface SplitPack {
1500
+ ref: string;
1501
+ bundlePath: string;
1502
+ bundleBytes: Uint8Array;
1503
+ /**
1504
+ * `logs/<event_id>.json` bytes by event id. A `Map` and not an object: an
1505
+ * event id is written by the audited party, and `obj["__proto__"] = bytes`
1506
+ * replaces the prototype instead of storing the disclosure.
1507
+ */
1508
+ content: Map<string, Uint8Array>;
1509
+ unaccounted: string[];
1510
+ }
1511
+
1512
+ export interface SplitCase {
1513
+ packs: SplitPack[];
1514
+ /** Paths that belong to no pack, sorted. Never opened. */
1515
+ outside: string[];
1516
+ /** The part of `outside` that is not a regular file. Never followed, never opened. */
1517
+ notRegular: string[];
1518
+ }
1519
+
1520
+ /** One name the walk found that is not a directory. */
1521
+ export interface TreeEntry {
1522
+ /** Where it sits on disk. Empty for an entry read out of a case-file `.zip`. */
1523
+ fsPath: string;
1524
+ /**
1525
+ * `true` for a regular file; `false` for a link or anything else, including a
1526
+ * `.zip` entry the reader refused.
1527
+ */
1528
+ regular: boolean;
1529
+ /** The bytes of an entry read out of a case-file `.zip`. */
1530
+ data?: Uint8Array | null;
1531
+ }
1532
+
1533
+ /**
1534
+ * The page's `splitCase` followed by its `assemble` per pack, over the walk's
1535
+ * tree. `read` opens a file; it is called for the files a pack owns and for
1536
+ * nothing else.
1537
+ */
1538
+ export async function splitCase(
1539
+ tree: Map<string, TreeEntry>,
1540
+ read: (entry: TreeEntry, key: string) => Promise<Uint8Array>,
1541
+ ): Promise<SplitCase> {
1542
+ const roots = [...tree]
1543
+ .filter(([p, e]) => e.regular && BUNDLE_PATH.test(p))
1544
+ .map(([p]) => p.slice(0, p.length - "bundle.json".length))
1545
+ .sort();
1546
+
1547
+ if (roots.length === 0) {
1548
+ const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
1549
+ const hint =
1550
+ zips.length > 0
1551
+ ? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
1552
+ `${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
1553
+ UNZIP_FIRST
1554
+ : null;
1555
+ throw new CaseInputError(NO_BUNDLE, hint);
1556
+ }
1557
+
1558
+ const owned = new Map<string, string[]>(roots.map((r) => [r, []]));
1559
+ const outside: string[] = [];
1560
+ const notRegular: string[] = [];
1561
+ for (const [path, entry] of tree) {
1562
+ if (!entry.regular) {
1563
+ outside.push(path);
1564
+ notRegular.push(path);
1565
+ continue;
1566
+ }
1567
+ let best: string | null = null;
1568
+ for (const root of roots) {
1569
+ const mine = path === `${root}bundle.json` || path.startsWith(`${root}logs/`);
1570
+ if (mine && (best === null || root.length > best.length)) best = root;
1571
+ }
1572
+ if (best === null) outside.push(path);
1573
+ else owned.get(best)!.push(path);
1574
+ }
1575
+
1576
+ const packs: SplitPack[] = [];
1577
+ for (const root of roots) {
1578
+ const bundlePath = `${root}bundle.json`;
1579
+ const content = new Map<string, Uint8Array>();
1580
+ const unaccounted: string[] = [];
1581
+ for (const p of owned.get(root)!.sort()) {
1582
+ if (p === bundlePath) continue;
1583
+ const rel = p.slice(root.length);
1584
+ const m = LOG_PATH.exec(rel);
1585
+ if (m) content.set(m[1]!, await read(tree.get(p)!, p));
1586
+ else unaccounted.push(rel);
1587
+ }
1588
+ packs.push({
1589
+ ref: root.slice(0, -1),
1590
+ bundlePath,
1591
+ bundleBytes: await read(tree.get(bundlePath)!, bundlePath),
1592
+ content,
1593
+ unaccounted: unaccounted.sort(),
1594
+ });
1595
+ }
1596
+ return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1597
+ }
1598
+
1599
+ /** `verify`'s reader, with the refusal named by the pack's path. */
1600
+ export function loadBundle(pack: SplitPack): unknown {
1601
+ try {
1602
+ return readBundleBytes(pack.bundleBytes);
1603
+ } catch (e) {
1604
+ if (e instanceof BundleReadError && e.tooDeep) {
1605
+ throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1606
+ }
1607
+ throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1608
+ }
1609
+ }
1610
+
1611
+ /**
1612
+ * A regular file's bytes. Opened without following a link and without
1613
+ * blocking, then checked again, so a file swapped for a link or a pipe after
1614
+ * the walk listed it is refused rather than followed or waited on.
1615
+ */
1616
+ export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint8Array> {
1617
+ if (entry.data !== undefined && entry.data !== null) return entry.data;
1618
+ const flags =
1619
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1620
+ let handle;
1621
+ try {
1622
+ handle = await open(entry.fsPath, flags);
1623
+ } catch {
1624
+ throw new CaseInputError(`cannot read ${key}`);
1625
+ }
1626
+ try {
1627
+ if (!(await handle.stat()).isFile()) throw new CaseInputError(`cannot read ${key}`);
1628
+ return new Uint8Array(await handle.readFile());
1629
+ } catch {
1630
+ throw new CaseInputError(`cannot read ${key}`);
1631
+ } finally {
1632
+ await handle.close();
1633
+ }
1634
+ }
1635
+
1636
+ /**
1637
+ * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
1638
+ * first from an explicit queue, never by recursion. `seen` is the count of
1639
+ * names listed so far, across every argument.
1640
+ */
1641
+ async function walk(
1642
+ root: string,
1643
+ prefix: string,
1644
+ arg: string,
1645
+ tree: Map<string, TreeEntry>,
1646
+ seen: { n: number },
1647
+ ): Promise<void> {
1648
+ const queue: [string, string, number][] = [[root, prefix, 0]];
1649
+ for (let head = 0; head < queue.length; head++) {
1650
+ const [directory, rel, depth] = queue[head]!;
1651
+ let names: Buffer[];
1652
+ try {
1653
+ names = await readdir(directory, { encoding: "buffer" });
1654
+ } catch {
1655
+ throw new CaseInputError(`cannot read ${rel.slice(0, -1)}`);
1656
+ }
1657
+ names.sort(Buffer.compare);
1658
+ for (const raw of names) {
1659
+ seen.n++;
1660
+ if (seen.n > MAX_WALK_ENTRIES) {
1661
+ throw new CaseInputError(
1662
+ `${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`,
1663
+ );
1664
+ }
1665
+ let name: string;
1666
+ try {
1667
+ name = nameDecoder.decode(raw);
1668
+ } catch {
1669
+ throw new CaseInputError(
1670
+ `${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`,
1671
+ );
1672
+ }
1673
+ const path = join(directory, name);
1674
+ const key = `${rel}${name}`;
1675
+ let s;
1676
+ try {
1677
+ s = await lstat(path);
1678
+ } catch {
1679
+ throw new CaseInputError(`cannot read ${key}`);
1680
+ }
1681
+ if (s.isDirectory()) {
1682
+ if (depth + 1 > MAX_WALK_DEPTH) {
1683
+ throw new CaseInputError(
1684
+ `${arg} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`,
1685
+ );
1686
+ }
1687
+ queue.push([path, `${key}/`, depth + 1]);
1688
+ } else {
1689
+ tree.set(key, { fsPath: path, regular: s.isFile() });
1690
+ }
1691
+ }
1692
+ }
1693
+ }
1694
+
1695
+ // The name the page would see for a dropped folder: the last segment of the
1696
+ // absolute path, so `.` and `pack/` name the directory itself.
1697
+ function baseName(path: string): string {
1698
+ return basename(resolve(path));
1699
+ }
1700
+
1701
+ /**
1702
+ * Every name under the directories named, keyed `<name>/<path inside>`. Each
1703
+ * argument must be a directory, and not a link to one. A `.zip` is refused
1704
+ * with the way through.
1705
+ */
1706
+ export async function treeFromArguments(paths: readonly string[]): Promise<Map<string, TreeEntry>> {
1707
+ if (paths.length === 0) throw new CaseInputError("case: at least one pack is required");
1708
+ for (const arg of paths) {
1709
+ // `link/` resolves the link; the name without the slash does not.
1710
+ let s;
1711
+ try {
1712
+ s = await lstat(arg.replace(/\/+$/, "") || arg);
1713
+ } catch {
1714
+ throw new CaseInputError(`cannot read ${arg}`);
1715
+ }
1716
+ if (s.isSymbolicLink()) {
1717
+ throw new CaseInputError(
1718
+ `${arg} is a symbolic link, which case does not follow; pass the folder it points to`,
1719
+ );
1720
+ }
1721
+ if (s.isDirectory()) continue;
1722
+ if (s.isFile() && ZIP_NAME.test(arg)) continue;
1723
+ throw new CaseInputError(
1724
+ `${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`,
1725
+ );
1726
+ }
1727
+
1728
+ const tree = new Map<string, TreeEntry>();
1729
+ const claimed = new Map<string, string>();
1730
+ const seen = { n: 0 };
1731
+ for (const arg of paths) {
1732
+ let isZip = false;
1733
+ if (ZIP_NAME.test(arg)) {
1734
+ try {
1735
+ isZip = !(await stat(arg)).isDirectory();
1736
+ } catch {
1737
+ isZip = true;
1738
+ }
1739
+ }
1740
+ let name = baseName(arg);
1741
+ if (isZip) {
1742
+ // `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
1743
+ // the archive is read under the folder that command makes.
1744
+ name = name.slice(0, -".zip".length);
1745
+ if (name.length === 0) throw new CaseInputError(`${arg} has no name before .zip; rename it`);
1746
+ }
1747
+ const earlier = claimed.get(name);
1748
+ if (earlier !== undefined) {
1749
+ throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
1750
+ }
1751
+ claimed.set(name, arg);
1752
+ if (isZip) await unzip(arg, `${name}/`, tree, seen);
1753
+ else await walk(arg, `${name}/`, arg, tree, seen);
1754
+ }
1755
+ return tree;
1756
+ }
1757
+
1758
+ /**
1759
+ * A case file named on the command line, read as the folder `unzip` would make
1760
+ * of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
1761
+ * unpacked folder give one reading. The archive is read by `readZip` and
1762
+ * nothing else; an entry it refuses is listed as outside every pack and never
1763
+ * read, as the walk lists a link.
1764
+ */
1765
+ async function unzip(
1766
+ arg: string,
1767
+ prefix: string,
1768
+ tree: Map<string, TreeEntry>,
1769
+ seen: { n: number },
1770
+ ): Promise<void> {
1771
+ let data: Uint8Array;
1772
+ try {
1773
+ data = new Uint8Array(await readFile(arg));
1774
+ } catch {
1775
+ throw new CaseInputError(`cannot read ${arg}`);
1776
+ }
1777
+ let entries;
1778
+ try {
1779
+ entries = readZip(data);
1780
+ } catch (e) {
1781
+ if (e instanceof ZipReadError) throw new CaseInputError(`${arg} ${e.message}`);
1782
+ throw e;
1783
+ }
1784
+ for (const entry of entries) {
1785
+ seen.n++;
1786
+ if (seen.n > MAX_WALK_ENTRIES) {
1787
+ throw new CaseInputError(
1788
+ `${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`,
1789
+ );
1790
+ }
1791
+ tree.set(`${prefix}${entry.name}`, {
1792
+ fsPath: "",
1793
+ regular: entry.refused === null,
1794
+ data: entry.data,
1795
+ });
1796
+ }
1797
+ }
1798
+
1799
+ // ─── `proof case` (proof#141) ────────────────────────────────────────────────
1800
+ //
1801
+ // The case reading from a terminal, so an auditor holding several packs does
1802
+ // not depend on Sanning's page for the one check the audited party cannot
1803
+ // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
1804
+ // the two commands are compared as child processes by
1805
+ // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
1806
+ // over every `case/` vector.
1807
+ //
1808
+ // The reading is `readCase`, entire. This command splits the input into packs
1809
+ // (the section above, the verify page's rule) and prints what the kernel said.
1810
+ // No color: the two commands print the same bytes.
1811
+
1812
+ // The exit code of a case verdict is the one table's (`./exit-codes.ts`).
1813
+ // `mixed` has its own code, 4, because each other code would say something a
1814
+ // mixed case has not established: 0 would soften a case with an unsettled or
1815
+ // unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
1816
+ // would accuse a case in which nothing was proven wrong. A case holding a
1817
+ // failure never exits 4: a proven failure keeps the headline (evidence-bundle
1818
+ // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
1819
+
1820
+ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
1821
+
1822
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
1823
+
1824
+ <folder> a pack folder (bundle.json plus logs/), a folder that holds
1825
+ several packs, or an unzipped case file. Packs are found by the
1826
+ verify page's splitting rule: every bundle.json is a pack, a file
1827
+ belongs to the deepest pack whose bundle.json or logs/ it sits
1828
+ in, and every other file is listed as outside every pack and
1829
+ never opened. A .zip inside a folder is a file like any other. A
1830
+ symbolic link is never followed: it is listed as outside and not
1831
+ read, even under a pack's logs/. Several arguments are read as
1832
+ one folder holding them, each under its own name. A folder
1833
+ nested more than 32 levels deep, more than 50000 files and
1834
+ folders in all, or a name that is not valid UTF-8 exits 2.
1835
+ <case file> a case file named as <name>.zip, read as the folder
1836
+ unzip <name>.zip -d <name> would make, so the two give one
1837
+ reading. An entry that is encrypted, a symbolic link, stored by
1838
+ another method, or named outside the folder is listed as outside
1839
+ and not read.
1840
+ Where the folder or case file holds contents.json at its root,
1841
+ the index is compared with what is there and each disagreement
1842
+ is listed; the index never moves the verdict.
1843
+ --gateways comma-separated gateway URLs. Each pack's witnesses are then
1844
+ looked up on the public record, pack by pack, and reported on
1845
+ the witness line. The lookup never changes the verdict word.
1846
+ --json the full reading as JSON: the verdict, every exception and note
1847
+ with its rendered line, every record, every contribution, the
1848
+ packs, the files outside them, the case file index comparison,
1849
+ the witness line and the on-chain half.
1850
+
1851
+ Each pack's bundle.json is read as verify reads it, so the two commands
1852
+ classify one bundle the same way.
1853
+
1854
+ What a case reading establishes: every pack is verified offline by this
1855
+ kernel and graded as a pack; every record every pack supplied is read, in one
1856
+ pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
1857
+ with no counterpart among the records read is always named, and when a pack
1858
+ signed by the key it names is in the reading, it says that pack holds none,
1859
+ unless every candidate it had was accounted for by another record (A20),
1860
+ which reads as no counterpart and names no pack.
1861
+
1862
+ What it does not establish: that the set of packs is complete (a party can
1863
+ decline to hand over a pack); that a party which presented no pack under the named key
1864
+ recorded nothing; that two organizations are independent of each other; or
1865
+ that any witness is on the public record, which the reading never looks up.
1866
+
1867
+ ${EXIT_CODES_HELP}
1868
+
1869
+ case: an input that cannot be read as packs, or an unexpected error in this
1870
+ command, exits 2. With --gateways, a witness that does not match on chain exits
1871
+ 1, and an on-chain half that could not be settled turns 0 into 3.`;
1872
+
1873
+ // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
1874
+ // rather than left to the stream, so the lines a test captures in process are
1875
+ // the lines a reader sees.
1876
+ function noLoneSurrogates(text: string): string {
1877
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
1878
+ }
1879
+
1880
+ // Keys sorted at every depth, arrays left in their order: the Python twin
1881
+ // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
1882
+ // that, byte for byte.
1883
+ function sortKeys(v: unknown): unknown {
1884
+ if (Array.isArray(v)) return v.map(sortKeys);
1885
+ if (v !== null && typeof v === "object") {
1886
+ return Object.fromEntries(
1887
+ Object.keys(v as Record<string, unknown>)
1888
+ .sort()
1889
+ .map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]),
1890
+ );
1891
+ }
1892
+ return v;
1893
+ }
1894
+
1895
+ function jsonText(v: unknown): string {
1896
+ return JSON.stringify(sortKeys(v), null, 2);
1897
+ }
1898
+
1899
+ interface OnChainRow {
1900
+ ref: string;
1901
+ witnesses: number;
1902
+ confirmed: number;
1903
+ mismatched: number;
1904
+ unreachable: number;
1905
+ not_checked: number;
1906
+ }
1907
+
1908
+ interface OnChainHalf {
1909
+ /** Present only where a lookup ran: each gateway that answered, in the order given. */
1910
+ answered?: string[];
1911
+ checked: boolean;
1912
+ gateways: string[];
1913
+ packs: OnChainRow[];
1914
+ verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
1915
+ }
1916
+
1917
+ // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1918
+ // case reading on purpose: `readCase` makes no network call, and neither does
1919
+ // the verify page's case reading, so the case verdict stays the one both
1920
+ // surfaces compute. This adds the half the reading names as not checked.
1921
+ async function onChainHalf(
1922
+ packs: readonly [string, unknown][],
1923
+ gateways: string[],
1924
+ io: Cli,
1925
+ ): Promise<OnChainHalf> {
1926
+ if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1927
+ const rows: OnChainRow[] = [];
1928
+ const answered = new Set<string>();
1929
+ for (const [ref, bundle] of packs) {
1930
+ const result = await verifyEvidenceBundle(bundle, {
1931
+ gateways,
1932
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1933
+ });
1934
+ const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1935
+ const row: OnChainRow = {
1936
+ ref,
1937
+ witnesses: witnesses.length,
1938
+ confirmed: 0,
1939
+ mismatched: 0,
1940
+ unreachable: 0,
1941
+ not_checked: 0,
1942
+ };
1943
+ for (const w of witnesses) {
1944
+ const rollup = w.onChain?.rollup ?? null;
1945
+ if (rollup === "confirm") row.confirmed++;
1946
+ else if (rollup === "mismatch") row.mismatched++;
1947
+ else if (rollup === "unreachable") row.unreachable++;
1948
+ else row.not_checked++;
1949
+ }
1950
+ rows.push(row);
1951
+ for (const w of witnesses) {
1952
+ for (const outcome of w.onChain?.perGateway ?? []) {
1953
+ if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
1954
+ answered.add(outcome.gateway);
1955
+ }
1956
+ }
1957
+ }
1958
+ }
1959
+ const verdict = rows.some((r) => r.mismatched > 0)
1960
+ ? "failed"
1961
+ : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1962
+ ? "undetermined"
1963
+ : "confirmed";
1964
+ return {
1965
+ answered: gateways.filter((g) => answered.has(g)),
1966
+ checked: true,
1967
+ gateways: [...gateways],
1968
+ packs: rows,
1969
+ verdict,
1970
+ };
1971
+ }
1972
+
1973
+ /**
1974
+ * The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
1975
+ * half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
1976
+ * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1977
+ * headline.
1978
+ */
1979
+ export function witnessLine(onChain: OnChainHalf): WitnessLine {
1980
+ if (!onChain.checked) {
1981
+ return {
1982
+ state: WITNESS_NOT_LOOKED_UP.state,
1983
+ reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
1984
+ services: [...WITNESS_NOT_LOOKED_UP.services],
1985
+ };
1986
+ }
1987
+ const services = [...(onChain.answered ?? [])];
1988
+ if (onChain.verdict === "failed") return { state: "differs", reasons: [], services };
1989
+ if (onChain.verdict === "confirmed") return { state: "confirmed", reasons: [], services };
1990
+ const rows = onChain.packs;
1991
+ const reasons: string[] = [];
1992
+ if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
1993
+ if (rows.some((r) => r.unreachable > 0)) {
1994
+ reasons.push(
1995
+ "a lookup did not settle: the service could not be reached, or it " +
1996
+ "holds no such witness yet",
1997
+ );
1998
+ }
1999
+ if (rows.some((r) => r.not_checked > 0)) {
2000
+ reasons.push("no lookup is made for a witness kind a pack names");
2001
+ }
2002
+ return { state: "not_checked", reasons, services };
2003
+ }
2004
+
2005
+ /**
2006
+ * The case verdict's code, moved only by an on-chain half that was asked for:
2007
+ * a witness that does not match is a failure whatever the reading said, and an
2008
+ * on-chain half that could not be settled is never a clean 0.
2009
+ */
2010
+ export function caseExitCode(verdict: string, onChainVerdict: string): number {
2011
+ const code = exitCodeFor(verdict);
2012
+ if (onChainVerdict === "failed") return EXIT_FAILED;
2013
+ if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
2014
+ return code;
2015
+ }
2016
+
2017
+ interface CaseFileData {
2018
+ root: string;
2019
+ index: string;
2020
+ case_id: string | null;
2021
+ findings: { rendered: string }[];
2022
+ }
2023
+
2024
+ interface CaseData {
2025
+ title: string;
2026
+ reliance: string;
2027
+ summary: string;
2028
+ witness: { rendered: string };
2029
+ exceptions: { rendered: string }[];
2030
+ notes: { rendered: string }[];
2031
+ packs: {
2032
+ ref: string;
2033
+ log_files: number;
2034
+ unaccounted: string[];
2035
+ signing_key: string | null;
2036
+ grade: string | null;
2037
+ }[];
2038
+ outside: string[];
2039
+ not_regular: string[];
2040
+ case_file: CaseFileData[];
2041
+ on_chain: OnChainHalf;
2042
+ }
2043
+
2044
+ const titleOf = (word: string): string =>
2045
+ Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
2046
+ ? (VERDICT_DISPLAY_TITLES[word] as string)
2047
+ : word;
2048
+
2049
+ /**
2050
+ * The text report, one line per entry, identical in both kernels. The headline
2051
+ * is the verdict's one display title and nothing else sets it (5.5.6, D7); the
2052
+ * scope sentence and the witness line are printed with it every time.
2053
+ */
2054
+ function caseReport(data: CaseData): string[] {
2055
+ const lines = [
2056
+ data.title,
2057
+ ` ${data.summary}`,
2058
+ ` ${data.witness.rendered}`,
2059
+ ` ${data.reliance}`,
2060
+ ];
2061
+ for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
2062
+ for (const n of data.notes) lines.push(` note: ${n.rendered}`);
2063
+ lines.push(`packs ${data.packs.length}`);
2064
+ for (const p of data.packs) {
2065
+ const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2066
+ const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
2067
+ let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
2068
+ if (p.unaccounted.length > 0) {
2069
+ line +=
2070
+ `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
2071
+ `record: ${p.unaccounted.join(", ")}`;
2072
+ }
2073
+ lines.push(line);
2074
+ }
2075
+ if (data.outside.length > 0) {
2076
+ lines.push(
2077
+ `outside ${data.outside.length} file(s) sit outside every ` +
2078
+ "pack, so no verdict here is about them",
2079
+ );
2080
+ const links = new Set(data.not_regular);
2081
+ for (const path of data.outside) {
2082
+ lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
2083
+ }
2084
+ }
2085
+ for (const c of data.case_file) {
2086
+ if (c.index === "absent") continue;
2087
+ const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
2088
+ lines.push(
2089
+ `case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
2090
+ `${label}; ${c.findings.length} disagreement(s) with what is here, ` +
2091
+ "none of which moves the verdict",
2092
+ );
2093
+ for (const f of c.findings) lines.push(` - ${f.rendered}`);
2094
+ }
2095
+ const oc = data.on_chain;
2096
+ if (oc.checked) {
2097
+ lines.push(
2098
+ `on-chain looked up against ${oc.gateways.length} gateway(s), ` +
2099
+ "pack by pack; the reading above is offline and this does not " +
2100
+ "change it",
2101
+ );
2102
+ for (const r of oc.packs) {
2103
+ lines.push(
2104
+ ` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
2105
+ `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
2106
+ `unreachable, ${r.not_checked} not checked`,
2107
+ );
2108
+ }
2109
+ }
2110
+ return lines;
2111
+ }
2112
+
2113
+ /**
2114
+ * The index comparison, once per argument (5.6.3): each folder or case file
2115
+ * whose root holds `contents.json`. Every file under `packs/` and `report/` is
2116
+ * hashed for it, as bytes, and nothing is parsed but the index. It is never an
2117
+ * input to the reading.
2118
+ */
2119
+ async function caseFileReadings(tree: Map<string, TreeEntry>): Promise<CaseFileData[]> {
2120
+ const out: (CaseFileData & { findings: Record<string, unknown>[] })[] = [];
2121
+ const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0] as string))].sort();
2122
+ const keys = [...tree.keys()].sort();
2123
+ for (const root of roots) {
2124
+ const prefix = `${root}/`;
2125
+ const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
2126
+ const indexEntry = tree.get(indexKey);
2127
+ const indexBytes =
2128
+ indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
2129
+ const files: { path: string; sha256: string }[] = [];
2130
+ if (indexBytes !== null) {
2131
+ for (const key of keys) {
2132
+ const entry = tree.get(key)!;
2133
+ const rel = key.slice(prefix.length);
2134
+ if (
2135
+ key.startsWith(prefix) &&
2136
+ entry.regular &&
2137
+ (rel.startsWith("packs/") || rel.startsWith("report/"))
2138
+ ) {
2139
+ files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
2140
+ }
2141
+ }
2142
+ }
2143
+ const reading = readCaseFileIndex(indexBytes, files);
2144
+ const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2145
+ out.push({
2146
+ root,
2147
+ index: reading.index,
2148
+ case_id: reading.caseId,
2149
+ findings: reading.findings.map((f) => ({
2150
+ kind: f.kind,
2151
+ ref: nil(f.ref),
2152
+ path: nil(f.path),
2153
+ listed_sha256: nil(f.listedSha256),
2154
+ sha256: nil(f.sha256),
2155
+ reason: nil(f.reason),
2156
+ public_key: nil(f.publicKey),
2157
+ rendered: renderCaseFileFinding(f),
2158
+ })),
2159
+ });
2160
+ }
2161
+ return out as CaseFileData[];
2162
+ }
2163
+
2164
+ function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
2165
+ if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
2166
+ io.err(noLoneSurrogates(`case: ${e.message}`));
2167
+ if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
2168
+ return EXIT_MALFORMED;
2169
+ }
2170
+
2171
+ /**
2172
+ * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
2173
+ * command is an exit code from the table: an exception nobody expected exits 2,
2174
+ * never 1, because 1 would name the evidence failed over a defect in this
2175
+ * command.
2176
+ */
2177
+ export async function runCase(argv: readonly string[], stream: Cli): Promise<number> {
2178
+ const io = guard(stream);
2179
+ try {
2180
+ return await caseCommand(argv, io);
2181
+ } catch (e) {
2182
+ const name = e instanceof Error ? e.constructor.name : typeof e;
2183
+ try {
2184
+ return caseError(
2185
+ new CaseInputError(
2186
+ `an unexpected error stopped the reading (${name}); this is a defect in the ` +
2187
+ "command, not a verdict on the evidence",
2188
+ ),
2189
+ argv.includes("--json"),
2190
+ io,
2191
+ );
2192
+ } catch {
2193
+ return EXIT_MALFORMED; // a stream that cannot be written
2194
+ }
2195
+ }
2196
+ }
2197
+
2198
+ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2199
+ let asJson = false;
2200
+ let gateways: string[] = [];
2201
+ const paths: string[] = [];
2202
+ for (let i = 0; i < argv.length; i++) {
2203
+ const arg = argv[i]!;
2204
+ if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
2205
+ outBlock(io, CASE_USAGE);
2206
+ return EXIT_VERIFIED;
2207
+ }
2208
+ if (arg === "--json") {
2209
+ asJson = true;
2210
+ } else if (arg === "--gateways") {
2211
+ const next = argv[i + 1];
2212
+ if (next === undefined) {
2213
+ io.err("case: --gateways requires a comma-separated list of URLs");
2214
+ outBlock(io, CASE_USAGE);
2215
+ return EXIT_MALFORMED;
2216
+ }
2217
+ gateways = next
2218
+ .split(",")
2219
+ .map((g) => g.trim())
2220
+ .filter((g) => g.length > 0);
2221
+ i++;
2222
+ } else if (arg.startsWith("--")) {
2223
+ io.err(`case: unknown option ${arg}`);
2224
+ outBlock(io, CASE_USAGE);
2225
+ return EXIT_MALFORMED;
2226
+ } else {
2227
+ paths.push(arg);
2228
+ }
2229
+ }
2230
+ if (paths.length === 0) {
2231
+ io.err("case: at least one pack is required");
2232
+ outBlock(io, CASE_USAGE);
2233
+ return EXIT_MALFORMED;
2234
+ }
2235
+
2236
+ let split;
2237
+ let tree: Map<string, TreeEntry>;
2238
+ let bundles: [SplitPack, unknown][];
2239
+ try {
2240
+ tree = await treeFromArguments(paths);
2241
+ split = await splitCase(tree, readFileEntry);
2242
+ bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
2243
+ } catch (e) {
2244
+ if (e instanceof CaseInputError) return caseError(e, asJson, io);
2245
+ throw e;
2246
+ }
2247
+
2248
+ const reading = await readCase(
2249
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
2250
+ );
2251
+ let caseFile: CaseFileData[];
2252
+ try {
2253
+ caseFile = await caseFileReadings(tree);
2254
+ } catch (e) {
2255
+ if (e instanceof CaseInputError) return caseError(e, asJson, io);
2256
+ throw e;
2257
+ }
2258
+ const onChain = await onChainHalf(
2259
+ bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
2260
+ gateways,
2261
+ io,
2262
+ );
2263
+ const witness = witnessLine(onChain);
2264
+ const code = caseExitCode(reading.verdict, onChain.verdict);
2265
+ const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2266
+
2267
+ const data = {
2268
+ verdict: reading.verdict,
2269
+ title: titleOf(reading.verdict),
2270
+ reliance: CASE_RELIANCE[reading.verdict],
2271
+ exit_code: code,
2272
+ summary: renderCaseSummary(reading),
2273
+ witness: {
2274
+ state: witness.state,
2275
+ reasons: witness.reasons,
2276
+ services: witness.services,
2277
+ rendered: renderWitnessLine(witness),
2278
+ },
2279
+ handoff_records: reading.handoffRecords,
2280
+ on_chain_checked: reading.onChainChecked,
2281
+ exceptions: reading.exceptions.map((e) => ({
2282
+ kind: e.kind,
2283
+ pack_ref: nil(e.packRef),
2284
+ event_id: nil(e.eventId),
2285
+ counterparty_key: nil(e.counterpartyKey),
2286
+ failed_checks: e.failedChecks ?? [],
2287
+ count: nil(e.count),
2288
+ status: nil(e.status),
2289
+ causes: e.causes ?? [],
2290
+ reasons: e.reasons ?? [],
2291
+ rendered: renderException(e),
2292
+ })),
2293
+ notes: reading.notes.map((n) => ({
2294
+ kind: n.kind,
2295
+ pack_ref: n.packRef,
2296
+ count: nil(n.count),
2297
+ rendered: renderCaseNote(n),
2298
+ })),
2299
+ records: reading.records.map((r) => ({
2300
+ pack_ref: r.packRef,
2301
+ event_id: r.eventId,
2302
+ verdict: nil(r.verdict),
2303
+ role: r.role,
2304
+ findings: r.findings,
2305
+ failed_checks: r.failedChecks,
2306
+ counterparts: r.counterparts,
2307
+ })),
2308
+ contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
2309
+ packs: bundles.map(([p], i) => ({
2310
+ ref: p.ref,
2311
+ bundle_path: p.bundlePath,
2312
+ log_files: p.content.size,
2313
+ unaccounted: p.unaccounted,
2314
+ signing_key: reading.packs[i]!.signingKey,
2315
+ grade: reading.packs[i]!.grade,
2316
+ records_withheld: reading.packs[i]!.recordsWithheld,
2317
+ declaration_version: reading.packs[i]!.declarationVersion,
2318
+ })),
2319
+ outside: split.outside,
2320
+ not_regular: split.notRegular,
2321
+ case_file: caseFile,
2322
+ on_chain: onChain,
2323
+ };
2324
+ if (asJson) outJson(io, jsonText(data));
2325
+ else for (const line of caseReport(data)) io.out(noLoneSurrogates(line));
2326
+ return code;
1102
2327
  }
1103
2328
 
1104
2329
  function shortTx(tx: string): string {
@@ -1133,7 +2358,7 @@ if (isMain) {
1133
2358
  process.exitCode = code;
1134
2359
  })
1135
2360
  .catch((e) => {
1136
- process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
2361
+ process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
1137
2362
  process.exitCode = EXIT_MALFORMED;
1138
2363
  });
1139
2364
  }