@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/dist/cli.js 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,38 +31,95 @@
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
34
41
  // surfaces as a per-event note, and it does NOT fail the run. It does hold the
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
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
38
- import { realpathSync } from "node:fs";
39
- import { join } from "node:path";
44
+ import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
45
+ import { constants as fsConstants, realpathSync } from "node:fs";
46
+ import { randomBytes } from "node:crypto";
47
+ import { basename, join, resolve } from "node:path";
40
48
  import { fileURLToPath } from "node:url";
41
- import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
49
+ import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
42
50
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
51
+ import { CASE_RELIANCE, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
52
+ import { CASE_FILE_INDEX_NAME, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
53
+ import { ZipReadError, readZip } from "./zip-reader.js";
43
54
  import { composeExport } from "./compose.js";
44
- import { isHexField, utf8 } from "./crypto.js";
55
+ import { isHexField, sha256Hex, utf8 } from "./crypto.js";
45
56
  import { verifyEvidenceBundle } from "./evidence.js";
46
57
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
47
58
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
48
- const EXIT_VERIFIED = 0;
49
- const EXIT_FAILED = 1;
50
- const EXIT_MALFORMED = 2;
51
- // One code, two undetermined outcomes: a gateway we could not reach, and a pack
52
- // whose records nobody hashed. Neither is an accusation, and neither is a pass.
53
- const EXIT_UNDETERMINED = 3;
54
- const G = "\x1b[32m";
55
- const R = "\x1b[31m";
56
- const Y = "\x1b[33m";
57
- const DIM = "\x1b[2m";
58
- const B = "\x1b[1m";
59
- const X = "\x1b[0m";
59
+ import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
60
+ // ── What a person reads is escaped at one choke point ──
61
+ //
62
+ // Every human-readable line this command prints passes through `guard`, which
63
+ // `runCli` and `runCase` apply to the streams they are handed. It escapes each
64
+ // control character, so a value the audited party wrote (a folder or file
65
+ // name, a `contents.json` field, a zip entry name, an exception built from
66
+ // one) cannot move the cursor, clear the screen or reorder the line. Before
67
+ // this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
68
+ // a FAILED headline. `outJson` is the one way around it, and it carries JSON
69
+ // only, which escapes its own control characters. The twin is `Io` in
70
+ // `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
71
+ // C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
72
+ // embedding, override and isolate controls.
73
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
74
+ /** Each control character in `text` as a visible escape: `\x1b` for ESC,
75
+ * `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
76
+ * backslash included, so a line with none is printed as it was. */
77
+ export function escapeControl(text) {
78
+ return text.replace(CONTROL_CHARACTERS, (c) => {
79
+ const code = c.charCodeAt(0);
80
+ return code < 0x100
81
+ ? `\\x${code.toString(16).padStart(2, "0")}`
82
+ : `\\u${code.toString(16).padStart(4, "0")}`;
83
+ });
84
+ }
85
+ // The colors this command writes on a TTY are its own control characters, and
86
+ // the choke point would escape them with everything else. So `paint` and
87
+ // `mark` write this marker instead, and the choke point turns it into ESC
88
+ // after escaping. It is random per process, so no input can spell it.
89
+ const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
90
+ const G = `${COLOR_MARK}32m`;
91
+ const R = `${COLOR_MARK}31m`;
92
+ const Y = `${COLOR_MARK}33m`;
93
+ const DIM = `${COLOR_MARK}2m`;
94
+ const B = `${COLOR_MARK}1m`;
95
+ const X = `${COLOR_MARK}0m`;
96
+ function forTerminal(text) {
97
+ return escapeControl(text).split(COLOR_MARK).join("\x1b[");
98
+ }
99
+ const RAW = Symbol("sanning.cli.raw");
100
+ function guard(io) {
101
+ if (RAW in io)
102
+ return io;
103
+ const guarded = {
104
+ out: (s) => io.out(forTerminal(s)),
105
+ err: (s) => io.err(forTerminal(s)),
106
+ [RAW]: io,
107
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
108
+ };
109
+ return guarded;
110
+ }
111
+ function rawOf(io) {
112
+ return io[RAW] ?? io;
113
+ }
114
+ /** JSON, printed unescaped: a script reads it, not a terminal. */
115
+ function outJson(io, text) {
116
+ rawOf(io).out(text);
117
+ }
118
+ /** A block this command wrote itself, such as a usage text: its own newlines
119
+ * are kept, and each line is escaped as `out` escapes it. */
120
+ function outBlock(io, text) {
121
+ rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
122
+ }
60
123
  function useColor() {
61
124
  // Honor NO_COLOR; otherwise color only a TTY.
62
125
  if (process.env.NO_COLOR)
@@ -74,7 +137,8 @@ function mark(ok) {
74
137
  function paint(s, c) {
75
138
  return useColor() ? `${c}${s}${X}` : s;
76
139
  }
77
- export async function runCli(argv, io) {
140
+ export async function runCli(argv, stream) {
141
+ const io = guard(stream);
78
142
  const [command, ...rest] = argv;
79
143
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
80
144
  printUsage(io);
@@ -84,24 +148,72 @@ export async function runCli(argv, io) {
84
148
  io.out("@sanning/proof verify CLI");
85
149
  return EXIT_VERIFIED;
86
150
  }
87
- // `bundle` is the customer-facing verb (proof#19: one artifact, one word).
88
- // `export` is kept working as a QUIET alias — it shipped and is published, so
89
- // it is not broken abruptly; it is simply no longer taught anywhere.
90
- if (command === "bundle" || command === "export") {
91
- return runBundle(rest, io);
151
+ // `export` composes an attested export (`sanning.evidence.export/v2`). It
152
+ // was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
153
+ // builds a pack (`anchor bundle`, DL-59), so this command no longer answers
154
+ // to it: `proof bundle` is an unknown command and exits 2, with no alias.
155
+ if (command === "export") {
156
+ if (rest.includes("--json")) {
157
+ return runJson(rest.filter((a) => a !== "--json"), io, runExport);
158
+ }
159
+ return runExport(rest, io, null);
160
+ }
161
+ if (command === "case") {
162
+ return runCase(rest, io);
92
163
  }
93
164
  if (command !== "verify") {
94
165
  io.err(`unknown command: ${command}`);
95
166
  printUsage(io);
96
167
  return EXIT_MALFORMED;
97
168
  }
98
- // Pull the optional `--logs <path>` flag out of the args; the rest are
99
- // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
100
- // content verification (the evidence-bundle path only).
169
+ if (rest.includes("--json")) {
170
+ return runJson(rest.filter((a) => a !== "--json"), io, runVerify);
171
+ }
172
+ return runVerify(rest, io, null);
173
+ }
174
+ async function runJson(rest, io, verb) {
175
+ const errs = [];
176
+ const sink = { data: null };
177
+ const quiet = {
178
+ out: () => { },
179
+ err: (s) => errs.push(s),
180
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
181
+ };
182
+ const code = await verb(rest, quiet, sink);
183
+ const notes = errs.filter((l) => l.startsWith("note: "));
184
+ for (const n of notes)
185
+ io.err(n);
186
+ if (sink.data !== null) {
187
+ outJson(io, jsonText({ ...sink.data, exit_code: code }));
188
+ }
189
+ else {
190
+ const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
191
+ outJson(io, jsonText({ error, exit_code: code }));
192
+ }
193
+ return code;
194
+ }
195
+ async function runVerify(rest, io, sink) {
196
+ // `--logs <path>` feeds disclosed raw-log bytes for content verification
197
+ // (the evidence-bundle path only); `--gateways <url,url>` asks for the
198
+ // on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
199
+ // second positional argument until then, and a second positional is now a
200
+ // usage error). The one positional is the bundle.
101
201
  let logsPath;
202
+ let gatewayArg;
102
203
  const positional = [];
103
204
  for (let i = 0; i < rest.length; i++) {
104
205
  const a = rest[i];
206
+ if (a === "--gateways") {
207
+ const next = rest[i + 1];
208
+ if (next === undefined) {
209
+ io.err("verify: --gateways requires a comma-separated list of URLs");
210
+ printUsage(io);
211
+ return EXIT_MALFORMED;
212
+ }
213
+ gatewayArg = next;
214
+ i++;
215
+ continue;
216
+ }
105
217
  if (a === "--logs") {
106
218
  const next = rest[i + 1];
107
219
  if (next === undefined) {
@@ -115,12 +227,17 @@ export async function runCli(argv, io) {
115
227
  }
116
228
  positional.push(a);
117
229
  }
118
- const [bundlePath, gatewayArg] = positional;
230
+ const [bundlePath] = positional;
119
231
  if (!bundlePath) {
120
232
  io.err("verify: a bundle file path is required");
121
233
  printUsage(io);
122
234
  return EXIT_MALFORMED;
123
235
  }
236
+ if (positional.length > 1) {
237
+ io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
238
+ printUsage(io);
239
+ return EXIT_MALFORMED;
240
+ }
124
241
  const gateways = (gatewayArg ?? "")
125
242
  .split(",")
126
243
  .map((g) => g.trim())
@@ -136,18 +253,26 @@ export async function runCli(argv, io) {
136
253
  const content = logs?.content;
137
254
  let raw;
138
255
  try {
139
- raw = await readFile(bundlePath, "utf8");
256
+ raw = new Uint8Array(await readFile(bundlePath));
140
257
  }
141
258
  catch (e) {
142
259
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
143
260
  return EXIT_MALFORMED;
144
261
  }
262
+ // The one reader both commands use, in both kernels: `case` reads each pack's
263
+ // bundle.json through it too, so the two commands classify one bundle the
264
+ // same way (see `readBundleBytes`).
145
265
  let parsed;
146
266
  try {
147
- parsed = JSON.parse(raw);
267
+ parsed = readBundleBytes(raw);
148
268
  }
149
269
  catch (e) {
150
- io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
270
+ if (e instanceof BundleReadError && e.tooDeep) {
271
+ io.err(`${bundlePath} ${e.detail}; refusing to read it`);
272
+ }
273
+ else {
274
+ io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
275
+ }
151
276
  return EXIT_MALFORMED;
152
277
  }
153
278
  const specVersion = parsed && typeof parsed === "object" && !Array.isArray(parsed)
@@ -159,11 +284,11 @@ export async function runCli(argv, io) {
159
284
  // has no such per-event content. Say so rather than silently ignoring it.
160
285
  io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
161
286
  }
162
- const result = await verifyAgentProofBundle(parsed, {
287
+ const result = await verifyAgentProof(parsed, {
163
288
  gateways,
164
289
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
165
290
  });
166
- return reportAgentProof(result, bundlePath, gateways, io);
291
+ return reportAgentProof(result, bundlePath, gateways, io, sink);
167
292
  }
168
293
  // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
169
294
  // rejects an unknown major as "malformed" so a genuinely unknown shape still
@@ -192,26 +317,29 @@ export async function runCli(argv, io) {
192
317
  catch (e) {
193
318
  chains = { unreadable: errMsg(e) };
194
319
  }
195
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
320
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
196
321
  // Coverage is reported only once the bundle parsed far enough to have events
197
322
  // to compare against; over a MALFORMED bundle there is nothing to say.
198
323
  if (logs && result.status !== "malformed")
199
324
  reportLogsCoverage(logs, result, io);
200
325
  return exit;
201
326
  }
202
- // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
203
- // (`proof export` still reaches this, undocumented — see the dispatch above.)
327
+ // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
204
328
  //
205
329
  // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
206
330
  // source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
207
- // Ed25519 key (evidence-export.md §5, composer). Writes the export to `-o <out>`
331
+ // Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
208
332
  // (or stdout when omitted, so it can be piped straight into `proof verify`).
209
333
  //
210
334
  // Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
211
335
  // forged/mis-bound attestation — the composer refuses to emit a green artifact
212
336
  // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
213
337
  // or non-JSON input, bad exporter key, malformed attestation record).
214
- async function runBundle(rest, io) {
338
+ async function runExport(rest, io, sink) {
339
+ if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
340
+ usageExport(io);
341
+ return EXIT_VERIFIED;
342
+ }
215
343
  let attestationsPath;
216
344
  let keyPath;
217
345
  let outPath;
@@ -228,19 +356,19 @@ async function runBundle(rest, io) {
228
356
  i++;
229
357
  return next;
230
358
  };
231
- if (a === "--attestations" || a === "-a") {
359
+ if (a === "--attestations") {
232
360
  const v = takeNext(a);
233
361
  if (v === undefined)
234
362
  return usageExport(io);
235
363
  attestationsPath = v;
236
364
  }
237
- else if (a === "--key" || a === "-k") {
365
+ else if (a === "--key") {
238
366
  const v = takeNext(a);
239
367
  if (v === undefined)
240
368
  return usageExport(io);
241
369
  keyPath = v;
242
370
  }
243
- else if (a === "--out" || a === "-o") {
371
+ else if (a === "--out") {
244
372
  const v = takeNext(a);
245
373
  if (v === undefined)
246
374
  return usageExport(io);
@@ -270,6 +398,13 @@ async function runBundle(rest, io) {
270
398
  return usageExport(io);
271
399
  opts.generatedAt = v;
272
400
  }
401
+ else if (a.startsWith("-")) {
402
+ // A flag this verb does not take is a usage error, never a path: `-o`
403
+ // was a short form of `--out` until 0.13.0, and read as a path it would
404
+ // send the export to stdout while the caller waits for a file.
405
+ io.err(`export: unknown option ${a}`);
406
+ return usageExport(io);
407
+ }
273
408
  else {
274
409
  positional.push(a);
275
410
  }
@@ -314,6 +449,18 @@ async function runBundle(rest, io) {
314
449
  // FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
315
450
  // still writes the artifact so the caller can inspect it.
316
451
  const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
452
+ if (sink) {
453
+ sink.data = {
454
+ status: composed.status,
455
+ out: outPath ?? null,
456
+ source_bundle_hash: composed.sourceBundleHash,
457
+ attestations: attestations.length,
458
+ attestations_bound: composed.boundAttestations,
459
+ // Without --out the artifact rides inside this object, since stdout
460
+ // carries the object and not the artifact.
461
+ export: outPath ? null : composed.bundle,
462
+ };
463
+ }
317
464
  if (outPath) {
318
465
  try {
319
466
  await writeFile(outPath, serialized, "utf8");
@@ -327,25 +474,32 @@ async function runBundle(rest, io) {
327
474
  `attestations ${composed.boundAttestations}/${attestations.length} bound`);
328
475
  }
329
476
  else {
330
- io.out(serialized.replace(/\n$/, ""));
477
+ outJson(io, serialized.replace(/\n$/, ""));
331
478
  }
332
479
  return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
333
480
  }
334
481
  function usageExport(io) {
335
- io.out("Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
482
+ io.out("Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]");
336
483
  io.out("");
337
484
  io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
338
485
  io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
339
- io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
486
+ io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
340
487
  io.out("");
341
488
  io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
342
489
  io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
343
490
  io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
344
- io.out("-o, --out <file> Write the export here (default: stdout).");
491
+ io.out("--out <file> Write the export here (default: stdout).");
345
492
  io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
346
493
  io.out("--gateway <url> Named delivery surface (not trusted).");
347
494
  io.out("--previous-hash <h> Custody pointer (default GENESIS).");
348
495
  io.out("--generated-at <ts> RFC 3339 compose time (default now).");
496
+ io.out("--json Print a summary as JSON (with the export inside it");
497
+ io.out(" when --out is not given).");
498
+ io.out("");
499
+ for (const line of EXIT_CODES_HELP.split("\n"))
500
+ io.out(line);
501
+ io.out("");
502
+ io.out("export: 1 means the recomputed export is failed; it is still written.");
349
503
  return EXIT_MALFORMED;
350
504
  }
351
505
  // Read + parse a JSON file; report a clear error and return undefined on any
@@ -573,7 +727,8 @@ function reportLogsCoverage(logs, result, io) {
573
727
  const undetermined = result.events.filter((e) => e.contentOk === null);
574
728
  if (undetermined.length > 0) {
575
729
  io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
576
- `content — no bytes disclosed, or no committed content_hash to bind them to. ` +
730
+ `content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
731
+ `hand-off record) to bind them to. ` +
577
732
  `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
578
733
  }
579
734
  }
@@ -616,30 +771,39 @@ function decodeLogValue(v) {
616
771
  return utf8(v); // raw utf8 bytes
617
772
  }
618
773
  function printUsage(io) {
619
- io.out("Usage: npx @sanning/proof <command> [args]");
774
+ io.out("Usage: sanning-proof <command> [args]");
775
+ io.out(" (npx @sanning/proof <command> [args], without installing)");
620
776
  io.out("");
621
777
  io.out("Commands:");
622
- io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
623
- io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
778
+ io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
779
+ io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
780
+ io.out(" Create an attested export");
781
+ io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
782
+ io.out(" (`case --help` for its exit codes)");
624
783
  io.out("");
625
784
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
626
785
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
627
786
  io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
628
- io.out("fully offline. Supply a comma-separated gateway list to also re-fetch");
629
- io.out("each checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
787
+ io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
788
+ io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
630
789
  io.out("a witness of any other kind is not checked and the run exits 3.");
631
790
  io.out("");
791
+ io.out("--json Print the verdict, the causes and the exit code as");
792
+ io.out(" JSON, for a script, instead of the report.");
793
+ io.out("");
632
794
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
633
- io.out(" content_hash. Either shape works, and which one you");
795
+ io.out(" content_hash, or message_hash for a hand-off record.");
796
+ io.out(" Either shape works, and which one you");
634
797
  io.out(" passed is decided by stat(), not by the extension:");
635
798
  io.out("");
636
799
  io.out(" a DIRECTORY of <event_id>.json files — the file's");
637
800
  io.out(" raw bytes ARE the content, nothing is decoded.");
638
801
  io.out(" This is a materialized pack's logs/ directory:");
639
- io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
802
+ io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
640
803
  io.out("");
641
804
  io.out(" a JSON FILE mapping event_id → bytes (an");
642
- io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
805
+ io.out(" even-length hex value, in either case, ⇒ hex,");
806
+ io.out(" else utf8).");
643
807
  io.out("");
644
808
  io.out(" In-body events[].content is verified automatically");
645
809
  io.out(" and takes precedence. An event with no disclosed");
@@ -649,8 +813,11 @@ function printUsage(io) {
649
813
  io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
650
814
  io.out("bundle itself is verified. Both verdicts are printed when they differ.");
651
815
  io.out("");
652
- io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 undetermined");
653
- io.out(" (an unreachable gateway, or an incomplete pack)");
816
+ for (const line of EXIT_CODES_HELP.split("\n"))
817
+ io.out(line);
818
+ io.out("");
819
+ io.out("verify: an unreachable gateway reads partial, and a pack with a record");
820
+ io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
654
821
  }
655
822
  /**
656
823
  * The disclosure account this command can honestly make, from what the VERIFIER
@@ -685,8 +852,10 @@ function cliDisclosureAccount(result, logs) {
685
852
  extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
686
853
  };
687
854
  }
688
- function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
855
+ function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
689
856
  if (result.status === "malformed") {
857
+ if (sink)
858
+ sink.data = verifyJson(result, null, null, null);
690
859
  io.err(paint("MALFORMED", R) + ` ${path}`);
691
860
  for (const e of result.errors)
692
861
  io.err(` - ${e}`);
@@ -770,7 +939,7 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
770
939
  // answers `partial` and the line below says so in words, and pack grading
771
940
  // keeps its own verdict: every pack built before §5.4 carries no
772
941
  // declaration, and re-grading them all as undetermined would be a change to
773
- // the pinned 0/1/2/3 contract rather than a new check. A pack that DOES
942
+ // their verdicts rather than a new check. A pack that DOES
774
943
  // carry a declaration this reader cannot read is a different case and exits
775
944
  // 3: the holder chose that field, so UNKNOWN is the honest answer.
776
945
  const declaration = checkDeclaration(parsed, logs?.content);
@@ -794,7 +963,42 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
794
963
  if (result.events.length > 0) {
795
964
  io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
796
965
  }
797
- return mapGradeToExit(grade, result);
966
+ if (sink)
967
+ sink.data = verifyJson(result, grade, declaration, chains);
968
+ return mapGradeToExit(grade);
969
+ }
970
+ /**
971
+ * What `verify --json` prints for an evidence bundle, the same keys and the
972
+ * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
973
+ * `chains` are null for a bundle no verdict could be rendered over.
974
+ */
975
+ function verifyJson(result, grade, declaration, chains) {
976
+ let chainBlock = null;
977
+ if (chains !== null) {
978
+ chainBlock =
979
+ "unreadable" in chains
980
+ ? { unreadable: chains.unreadable, chains: 0, findings: [] }
981
+ : {
982
+ unreadable: null,
983
+ chains: chains.chains.length,
984
+ findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
985
+ };
986
+ }
987
+ return {
988
+ verdict: grade ? grade.verdict : result.status,
989
+ bundle_status: result.status,
990
+ asserted_status: result.assertedStatus ?? null,
991
+ body_type: result.bodyType ?? null,
992
+ on_chain_checked: result.onChainChecked,
993
+ declaration: declaration ? declaration.verdict : null,
994
+ declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
995
+ causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
996
+ notes: [...result.errors],
997
+ records: grade
998
+ ? { presented: result.events.length, content_verified: grade.recordsVerified }
999
+ : null,
1000
+ chains: chainBlock,
1001
+ };
798
1002
  }
799
1003
  /**
800
1004
  * The chain block: what following each record's pointer to the record before it
@@ -811,8 +1015,8 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
811
1015
  * complete" does not follow, and two customer-facing documents had already
812
1016
  * made that leap. The limit travels with the result or it gets dropped.
813
1017
  * 3. It does not touch the exit code. Findings are evidence; the exit code is
814
- * the verdict surface, and the pinned 0/1/2/3 contract stays exactly what it
815
- * was. A dangling link is something for a person to look at, not a machine
1018
+ * the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
1019
+ * the verdict and nothing else. A dangling link is something for a person to look at, not a machine
816
1020
  * decision this check is entitled to make.
817
1021
  */
818
1022
  function reportChains(reading, io) {
@@ -854,7 +1058,16 @@ function reportExport(exp, io) {
854
1058
  }
855
1059
  }
856
1060
  }
857
- function reportAgentProof(result, path, gateways, io) {
1061
+ function reportAgentProof(result, path, gateways, io, sink) {
1062
+ if (sink) {
1063
+ sink.data = {
1064
+ verdict: result.status,
1065
+ bundle_status: result.status,
1066
+ spec_version: "sanning.agent.proof/v1",
1067
+ on_chain_checked: result.onChainChecked,
1068
+ notes: [...result.errors],
1069
+ };
1070
+ }
858
1071
  if (result.status === "malformed") {
859
1072
  io.err(paint("MALFORMED", R) + ` ${path}`);
860
1073
  for (const e of result.errors)
@@ -872,7 +1085,7 @@ function reportAgentProof(result, path, gateways, io) {
872
1085
  printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
873
1086
  for (const e of result.errors)
874
1087
  io.out(paint(` note: ${e}`, DIM));
875
- return mapStatusToExit(result.status, result.errors);
1088
+ return exitCodeFor(result.status);
876
1089
  }
877
1090
  function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
878
1091
  const scope = onChainChecked
@@ -911,36 +1124,867 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
911
1124
  }
912
1125
  }
913
1126
  /**
914
- * The exit code follows the PACK verdict, then falls back to the bundle status.
1127
+ * The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
915
1128
  *
916
- * `incomplete` is 3 rather than 0: it is undetermined, which is what 3 already
917
- * meant for an unreachable gateway, and it is emphatically not 1: the bytes
918
- * being absent is never reported as tamper (`evidence-bundle.md` §5.1).
1129
+ * `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
1130
+ * unreachable gateway too, and emphatically not 1: the bytes being absent is
1131
+ * never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
1132
+ * exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
919
1133
  */
920
- function mapGradeToExit(grade, result) {
921
- if (grade.verdict === "malformed")
922
- return EXIT_MALFORMED;
923
- if (grade.verdict === "failed")
1134
+ function mapGradeToExit(grade) {
1135
+ return exitCodeFor(grade.verdict);
1136
+ }
1137
+ // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
1138
+ //
1139
+ // Directories on disk, split into packs. This lives in the CLI module, and not
1140
+ // in a kernel module, because it is the command's own read of files a person
1141
+ // named on the command line, the same class of read as `verify`'s read of
1142
+ // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
1143
+ // `readBundleBytes`.
1144
+ //
1145
+ // The case READING is `readCase`, and nothing here touches it. This turns the
1146
+ // directories a reader typed into a list of packs. Its twin is the same section
1147
+ // of `src/sanning_proof/cli.py`, and both are held to one table,
1148
+ // `vectors/case-split.json`, whose rows are directory trees.
1149
+ //
1150
+ // ── Directories only ──
1151
+ //
1152
+ // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
1153
+ // unzip it first and pass the folder, and a `.zip` inside a directory is a file
1154
+ // like any other: outside every pack, and never opened. Reading archives in the
1155
+ // kernel brought zip bombs, two zip libraries that disagree, and entry names
1156
+ // that are not file names, and `unzip` already does that job.
1157
+ //
1158
+ // ── The walk ──
1159
+ //
1160
+ // Iterative, never recursive, bytewise-sorted at each level, and bounded:
1161
+ //
1162
+ // - A directory nested more than MAX_WALK_DEPTH levels below an argument
1163
+ // refuses the whole input (exit 2).
1164
+ // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
1165
+ // the whole input (exit 2).
1166
+ // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
1167
+ // not escaped or replaced: two different names would then read as one.
1168
+ // - A symbolic link is never followed, whatever it points at, and anything
1169
+ // else that is not a regular file or a directory is never opened. Each is
1170
+ // listed as outside every pack and not read, even when it sits under a
1171
+ // pack's `logs/` or is named `bundle.json`.
1172
+ //
1173
+ // ── The split: the verify page's rule over the tree ──
1174
+ //
1175
+ // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
1176
+ // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
1177
+ // apply the same splitting rule to the same unpacked tree. The page's rule
1178
+ // lives in the page, so neither kernel can call it; it is restated here and in
1179
+ // the Python twin, and the shared table pins it:
1180
+ //
1181
+ // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1182
+ // at the directory that holds it.
1183
+ // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1184
+ // if it is that root's `bundle.json` or under that root's `logs/`.
1185
+ // 3. A file under no pack is OUTSIDE: listed, and never opened.
1186
+ // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1187
+ // `Array.prototype.sort`).
1188
+ // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1189
+ // and any other file under `logs/` is UNACCOUNTED.
1190
+ // 6. A pack's `ref` is its root without the trailing slash.
1191
+ // 7. No `bundle.json` anywhere refuses.
1192
+ //
1193
+ // One directory argument is read at `<its name>/<path inside it>`, which is how
1194
+ // the page names the files of a dropped folder. Several are read as one folder
1195
+ // holding them, each under its own name, and two that would land at one name
1196
+ // refuse.
1197
+ //
1198
+ // Known differences from the page: the page opens a dropped `.zip` and this
1199
+ // command does not; the page drops a leading byte-order mark from
1200
+ // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1201
+ // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1202
+ // where this command applies the walk's rules above; and the page keys
1203
+ // disclosed content by a plain object, so it loses a record named `__proto__`,
1204
+ // which this command keeps.
1205
+ /** The error text for an input with no pack in it, word for word the page's. */
1206
+ export const NO_BUNDLE = "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
1207
+ /** A directory nested deeper than this below an argument refuses the input. */
1208
+ export const MAX_WALK_DEPTH = 32;
1209
+ /** More names than this, across every argument, refuse the input. */
1210
+ export const MAX_WALK_ENTRIES = 50_000;
1211
+ /**
1212
+ * `bundle.json` nested deeper than this is refused before it is parsed, by
1213
+ * `verify` and by `case` alike (see `readBundleBytes`).
1214
+ */
1215
+ export const MAX_JSON_DEPTH = 512;
1216
+ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
1217
+ const LOG_PATH = /^logs\/([^/]+)\.json$/;
1218
+ const ZIP_NAME = /\.zip$/i;
1219
+ /** The one line a `.zip` gets, as an argument or as the way through a refusal. */
1220
+ const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
1221
+ "case/) and pass the folder.";
1222
+ /**
1223
+ * The input could not be read as a case. The message is the whole report,
1224
+ * identical in both kernels, and the command exits 2 on it. `hint` is an
1225
+ * optional second line naming a way through.
1226
+ */
1227
+ export class CaseInputError extends Error {
1228
+ hint;
1229
+ constructor(message, hint = null) {
1230
+ super(message);
1231
+ this.hint = hint;
1232
+ }
1233
+ }
1234
+ /**
1235
+ * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1236
+ * the one refusal that is not a parse error; `detail` is the parser's own words,
1237
+ * which differ by kernel and are printed by `verify` only.
1238
+ */
1239
+ export class BundleReadError extends Error {
1240
+ tooDeep;
1241
+ detail;
1242
+ constructor(tooDeep, detail) {
1243
+ super(detail);
1244
+ this.tooDeep = tooDeep;
1245
+ this.detail = detail;
1246
+ }
1247
+ }
1248
+ // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1249
+ // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1250
+ const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1251
+ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1252
+ /**
1253
+ * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1254
+ * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1255
+ * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1256
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1257
+ * Throws `BundleReadError`.
1258
+ */
1259
+ export function readBundleBytes(bytes) {
1260
+ const text = bundleDecoder.decode(bytes);
1261
+ if (jsonDepthExceeds(text, MAX_JSON_DEPTH)) {
1262
+ throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1263
+ }
1264
+ try {
1265
+ return JSON.parse(text);
1266
+ }
1267
+ catch (e) {
1268
+ throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1269
+ }
1270
+ }
1271
+ /**
1272
+ * The page's `splitCase` followed by its `assemble` per pack, over the walk's
1273
+ * tree. `read` opens a file; it is called for the files a pack owns and for
1274
+ * nothing else.
1275
+ */
1276
+ export async function splitCase(tree, read) {
1277
+ const roots = [...tree]
1278
+ .filter(([p, e]) => e.regular && BUNDLE_PATH.test(p))
1279
+ .map(([p]) => p.slice(0, p.length - "bundle.json".length))
1280
+ .sort();
1281
+ if (roots.length === 0) {
1282
+ const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
1283
+ const hint = zips.length > 0
1284
+ ? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
1285
+ `${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
1286
+ UNZIP_FIRST
1287
+ : null;
1288
+ throw new CaseInputError(NO_BUNDLE, hint);
1289
+ }
1290
+ const owned = new Map(roots.map((r) => [r, []]));
1291
+ const outside = [];
1292
+ const notRegular = [];
1293
+ for (const [path, entry] of tree) {
1294
+ if (!entry.regular) {
1295
+ outside.push(path);
1296
+ notRegular.push(path);
1297
+ continue;
1298
+ }
1299
+ let best = null;
1300
+ for (const root of roots) {
1301
+ const mine = path === `${root}bundle.json` || path.startsWith(`${root}logs/`);
1302
+ if (mine && (best === null || root.length > best.length))
1303
+ best = root;
1304
+ }
1305
+ if (best === null)
1306
+ outside.push(path);
1307
+ else
1308
+ owned.get(best).push(path);
1309
+ }
1310
+ const packs = [];
1311
+ for (const root of roots) {
1312
+ const bundlePath = `${root}bundle.json`;
1313
+ const content = new Map();
1314
+ const unaccounted = [];
1315
+ for (const p of owned.get(root).sort()) {
1316
+ if (p === bundlePath)
1317
+ continue;
1318
+ const rel = p.slice(root.length);
1319
+ const m = LOG_PATH.exec(rel);
1320
+ if (m)
1321
+ content.set(m[1], await read(tree.get(p), p));
1322
+ else
1323
+ unaccounted.push(rel);
1324
+ }
1325
+ packs.push({
1326
+ ref: root.slice(0, -1),
1327
+ bundlePath,
1328
+ bundleBytes: await read(tree.get(bundlePath), bundlePath),
1329
+ content,
1330
+ unaccounted: unaccounted.sort(),
1331
+ });
1332
+ }
1333
+ return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1334
+ }
1335
+ /** `verify`'s reader, with the refusal named by the pack's path. */
1336
+ export function loadBundle(pack) {
1337
+ try {
1338
+ return readBundleBytes(pack.bundleBytes);
1339
+ }
1340
+ catch (e) {
1341
+ if (e instanceof BundleReadError && e.tooDeep) {
1342
+ throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1343
+ }
1344
+ throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * A regular file's bytes. Opened without following a link and without
1349
+ * blocking, then checked again, so a file swapped for a link or a pipe after
1350
+ * the walk listed it is refused rather than followed or waited on.
1351
+ */
1352
+ export async function readFileEntry(entry, key) {
1353
+ if (entry.data !== undefined && entry.data !== null)
1354
+ return entry.data;
1355
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1356
+ let handle;
1357
+ try {
1358
+ handle = await open(entry.fsPath, flags);
1359
+ }
1360
+ catch {
1361
+ throw new CaseInputError(`cannot read ${key}`);
1362
+ }
1363
+ try {
1364
+ if (!(await handle.stat()).isFile())
1365
+ throw new CaseInputError(`cannot read ${key}`);
1366
+ return new Uint8Array(await handle.readFile());
1367
+ }
1368
+ catch {
1369
+ throw new CaseInputError(`cannot read ${key}`);
1370
+ }
1371
+ finally {
1372
+ await handle.close();
1373
+ }
1374
+ }
1375
+ /**
1376
+ * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
1377
+ * first from an explicit queue, never by recursion. `seen` is the count of
1378
+ * names listed so far, across every argument.
1379
+ */
1380
+ async function walk(root, prefix, arg, tree, seen) {
1381
+ const queue = [[root, prefix, 0]];
1382
+ for (let head = 0; head < queue.length; head++) {
1383
+ const [directory, rel, depth] = queue[head];
1384
+ let names;
1385
+ try {
1386
+ names = await readdir(directory, { encoding: "buffer" });
1387
+ }
1388
+ catch {
1389
+ throw new CaseInputError(`cannot read ${rel.slice(0, -1)}`);
1390
+ }
1391
+ names.sort(Buffer.compare);
1392
+ for (const raw of names) {
1393
+ seen.n++;
1394
+ if (seen.n > MAX_WALK_ENTRIES) {
1395
+ throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
1396
+ }
1397
+ let name;
1398
+ try {
1399
+ name = nameDecoder.decode(raw);
1400
+ }
1401
+ catch {
1402
+ throw new CaseInputError(`${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`);
1403
+ }
1404
+ const path = join(directory, name);
1405
+ const key = `${rel}${name}`;
1406
+ let s;
1407
+ try {
1408
+ s = await lstat(path);
1409
+ }
1410
+ catch {
1411
+ throw new CaseInputError(`cannot read ${key}`);
1412
+ }
1413
+ if (s.isDirectory()) {
1414
+ if (depth + 1 > MAX_WALK_DEPTH) {
1415
+ throw new CaseInputError(`${arg} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`);
1416
+ }
1417
+ queue.push([path, `${key}/`, depth + 1]);
1418
+ }
1419
+ else {
1420
+ tree.set(key, { fsPath: path, regular: s.isFile() });
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ // The name the page would see for a dropped folder: the last segment of the
1426
+ // absolute path, so `.` and `pack/` name the directory itself.
1427
+ function baseName(path) {
1428
+ return basename(resolve(path));
1429
+ }
1430
+ /**
1431
+ * Every name under the directories named, keyed `<name>/<path inside>`. Each
1432
+ * argument must be a directory, and not a link to one. A `.zip` is refused
1433
+ * with the way through.
1434
+ */
1435
+ export async function treeFromArguments(paths) {
1436
+ if (paths.length === 0)
1437
+ throw new CaseInputError("case: at least one pack is required");
1438
+ for (const arg of paths) {
1439
+ // `link/` resolves the link; the name without the slash does not.
1440
+ let s;
1441
+ try {
1442
+ s = await lstat(arg.replace(/\/+$/, "") || arg);
1443
+ }
1444
+ catch {
1445
+ throw new CaseInputError(`cannot read ${arg}`);
1446
+ }
1447
+ if (s.isSymbolicLink()) {
1448
+ throw new CaseInputError(`${arg} is a symbolic link, which case does not follow; pass the folder it points to`);
1449
+ }
1450
+ if (s.isDirectory())
1451
+ continue;
1452
+ if (s.isFile() && ZIP_NAME.test(arg))
1453
+ continue;
1454
+ throw new CaseInputError(`${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`);
1455
+ }
1456
+ const tree = new Map();
1457
+ const claimed = new Map();
1458
+ const seen = { n: 0 };
1459
+ for (const arg of paths) {
1460
+ let isZip = false;
1461
+ if (ZIP_NAME.test(arg)) {
1462
+ try {
1463
+ isZip = !(await stat(arg)).isDirectory();
1464
+ }
1465
+ catch {
1466
+ isZip = true;
1467
+ }
1468
+ }
1469
+ let name = baseName(arg);
1470
+ if (isZip) {
1471
+ // `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
1472
+ // the archive is read under the folder that command makes.
1473
+ name = name.slice(0, -".zip".length);
1474
+ if (name.length === 0)
1475
+ throw new CaseInputError(`${arg} has no name before .zip; rename it`);
1476
+ }
1477
+ const earlier = claimed.get(name);
1478
+ if (earlier !== undefined) {
1479
+ throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
1480
+ }
1481
+ claimed.set(name, arg);
1482
+ if (isZip)
1483
+ await unzip(arg, `${name}/`, tree, seen);
1484
+ else
1485
+ await walk(arg, `${name}/`, arg, tree, seen);
1486
+ }
1487
+ return tree;
1488
+ }
1489
+ /**
1490
+ * A case file named on the command line, read as the folder `unzip` would make
1491
+ * of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
1492
+ * unpacked folder give one reading. The archive is read by `readZip` and
1493
+ * nothing else; an entry it refuses is listed as outside every pack and never
1494
+ * read, as the walk lists a link.
1495
+ */
1496
+ async function unzip(arg, prefix, tree, seen) {
1497
+ let data;
1498
+ try {
1499
+ data = new Uint8Array(await readFile(arg));
1500
+ }
1501
+ catch {
1502
+ throw new CaseInputError(`cannot read ${arg}`);
1503
+ }
1504
+ let entries;
1505
+ try {
1506
+ entries = readZip(data);
1507
+ }
1508
+ catch (e) {
1509
+ if (e instanceof ZipReadError)
1510
+ throw new CaseInputError(`${arg} ${e.message}`);
1511
+ throw e;
1512
+ }
1513
+ for (const entry of entries) {
1514
+ seen.n++;
1515
+ if (seen.n > MAX_WALK_ENTRIES) {
1516
+ throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
1517
+ }
1518
+ tree.set(`${prefix}${entry.name}`, {
1519
+ fsPath: "",
1520
+ regular: entry.refused === null,
1521
+ data: entry.data,
1522
+ });
1523
+ }
1524
+ }
1525
+ // ─── `proof case` (proof#141) ────────────────────────────────────────────────
1526
+ //
1527
+ // The case reading from a terminal, so an auditor holding several packs does
1528
+ // not depend on Sanning's page for the one check the audited party cannot
1529
+ // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
1530
+ // the two commands are compared as child processes by
1531
+ // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
1532
+ // over every `case/` vector.
1533
+ //
1534
+ // The reading is `readCase`, entire. This command splits the input into packs
1535
+ // (the section above, the verify page's rule) and prints what the kernel said.
1536
+ // No color: the two commands print the same bytes.
1537
+ // The exit code of a case verdict is the one table's (`./exit-codes.ts`).
1538
+ // `mixed` has its own code, 4, because each other code would say something a
1539
+ // mixed case has not established: 0 would soften a case with an unsettled or
1540
+ // unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
1541
+ // would accuse a case in which nothing was proven wrong. A case holding a
1542
+ // failure never exits 4: a proven failure keeps the headline (evidence-bundle
1543
+ // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
1544
+ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
1545
+
1546
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
1547
+
1548
+ <folder> a pack folder (bundle.json plus logs/), a folder that holds
1549
+ several packs, or an unzipped case file. Packs are found by the
1550
+ verify page's splitting rule: every bundle.json is a pack, a file
1551
+ belongs to the deepest pack whose bundle.json or logs/ it sits
1552
+ in, and every other file is listed as outside every pack and
1553
+ never opened. A .zip inside a folder is a file like any other. A
1554
+ symbolic link is never followed: it is listed as outside and not
1555
+ read, even under a pack's logs/. Several arguments are read as
1556
+ one folder holding them, each under its own name. A folder
1557
+ nested more than 32 levels deep, more than 50000 files and
1558
+ folders in all, or a name that is not valid UTF-8 exits 2.
1559
+ <case file> a case file named as <name>.zip, read as the folder
1560
+ unzip <name>.zip -d <name> would make, so the two give one
1561
+ reading. An entry that is encrypted, a symbolic link, stored by
1562
+ another method, or named outside the folder is listed as outside
1563
+ and not read.
1564
+ Where the folder or case file holds contents.json at its root,
1565
+ the index is compared with what is there and each disagreement
1566
+ is listed; the index never moves the verdict.
1567
+ --gateways comma-separated gateway URLs. Each pack's witnesses are then
1568
+ looked up on the public record, pack by pack, and reported on
1569
+ the witness line. The lookup never changes the verdict word.
1570
+ --json the full reading as JSON: the verdict, every exception and note
1571
+ with its rendered line, every record, every contribution, the
1572
+ packs, the files outside them, the case file index comparison,
1573
+ the witness line and the on-chain half.
1574
+
1575
+ Each pack's bundle.json is read as verify reads it, so the two commands
1576
+ classify one bundle the same way.
1577
+
1578
+ What a case reading establishes: every pack is verified offline by this
1579
+ kernel and graded as a pack; every record every pack supplied is read, in one
1580
+ pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
1581
+ with no counterpart among the records read is always named, and when a pack
1582
+ signed by the key it names is in the reading, it says that pack holds none,
1583
+ unless every candidate it had was accounted for by another record (A20),
1584
+ which reads as no counterpart and names no pack.
1585
+
1586
+ What it does not establish: that the set of packs is complete (a party can
1587
+ decline to hand over a pack); that a party which presented no pack under the named key
1588
+ recorded nothing; that two organizations are independent of each other; or
1589
+ that any witness is on the public record, which the reading never looks up.
1590
+
1591
+ ${EXIT_CODES_HELP}
1592
+
1593
+ case: an input that cannot be read as packs, or an unexpected error in this
1594
+ command, exits 2. With --gateways, a witness that does not match on chain exits
1595
+ 1, and an on-chain half that could not be settled turns 0 into 3.`;
1596
+ // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
1597
+ // rather than left to the stream, so the lines a test captures in process are
1598
+ // the lines a reader sees.
1599
+ function noLoneSurrogates(text) {
1600
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
1601
+ }
1602
+ // Keys sorted at every depth, arrays left in their order: the Python twin
1603
+ // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
1604
+ // that, byte for byte.
1605
+ function sortKeys(v) {
1606
+ if (Array.isArray(v))
1607
+ return v.map(sortKeys);
1608
+ if (v !== null && typeof v === "object") {
1609
+ return Object.fromEntries(Object.keys(v)
1610
+ .sort()
1611
+ .map((k) => [k, sortKeys(v[k])]));
1612
+ }
1613
+ return v;
1614
+ }
1615
+ function jsonText(v) {
1616
+ return JSON.stringify(sortKeys(v), null, 2);
1617
+ }
1618
+ // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1619
+ // case reading on purpose: `readCase` makes no network call, and neither does
1620
+ // the verify page's case reading, so the case verdict stays the one both
1621
+ // surfaces compute. This adds the half the reading names as not checked.
1622
+ async function onChainHalf(packs, gateways, io) {
1623
+ if (gateways.length === 0)
1624
+ return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1625
+ const rows = [];
1626
+ const answered = new Set();
1627
+ for (const [ref, bundle] of packs) {
1628
+ const result = await verifyEvidenceBundle(bundle, {
1629
+ gateways,
1630
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1631
+ });
1632
+ const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1633
+ const row = {
1634
+ ref,
1635
+ witnesses: witnesses.length,
1636
+ confirmed: 0,
1637
+ mismatched: 0,
1638
+ unreachable: 0,
1639
+ not_checked: 0,
1640
+ };
1641
+ for (const w of witnesses) {
1642
+ const rollup = w.onChain?.rollup ?? null;
1643
+ if (rollup === "confirm")
1644
+ row.confirmed++;
1645
+ else if (rollup === "mismatch")
1646
+ row.mismatched++;
1647
+ else if (rollup === "unreachable")
1648
+ row.unreachable++;
1649
+ else
1650
+ row.not_checked++;
1651
+ }
1652
+ rows.push(row);
1653
+ for (const w of witnesses) {
1654
+ for (const outcome of w.onChain?.perGateway ?? []) {
1655
+ if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
1656
+ answered.add(outcome.gateway);
1657
+ }
1658
+ }
1659
+ }
1660
+ }
1661
+ const verdict = rows.some((r) => r.mismatched > 0)
1662
+ ? "failed"
1663
+ : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1664
+ ? "undetermined"
1665
+ : "confirmed";
1666
+ return {
1667
+ answered: gateways.filter((g) => answered.has(g)),
1668
+ checked: true,
1669
+ gateways: [...gateways],
1670
+ packs: rows,
1671
+ verdict,
1672
+ };
1673
+ }
1674
+ /**
1675
+ * The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
1676
+ * half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
1677
+ * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1678
+ * headline.
1679
+ */
1680
+ export function witnessLine(onChain) {
1681
+ if (!onChain.checked) {
1682
+ return {
1683
+ state: WITNESS_NOT_LOOKED_UP.state,
1684
+ reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
1685
+ services: [...WITNESS_NOT_LOOKED_UP.services],
1686
+ };
1687
+ }
1688
+ const services = [...(onChain.answered ?? [])];
1689
+ if (onChain.verdict === "failed")
1690
+ return { state: "differs", reasons: [], services };
1691
+ if (onChain.verdict === "confirmed")
1692
+ return { state: "confirmed", reasons: [], services };
1693
+ const rows = onChain.packs;
1694
+ const reasons = [];
1695
+ if (rows.some((r) => r.witnesses === 0))
1696
+ reasons.push("a pack names no witness");
1697
+ if (rows.some((r) => r.unreachable > 0)) {
1698
+ reasons.push("a lookup did not settle: the service could not be reached, or it " +
1699
+ "holds no such witness yet");
1700
+ }
1701
+ if (rows.some((r) => r.not_checked > 0)) {
1702
+ reasons.push("no lookup is made for a witness kind a pack names");
1703
+ }
1704
+ return { state: "not_checked", reasons, services };
1705
+ }
1706
+ /**
1707
+ * The case verdict's code, moved only by an on-chain half that was asked for:
1708
+ * a witness that does not match is a failure whatever the reading said, and an
1709
+ * on-chain half that could not be settled is never a clean 0.
1710
+ */
1711
+ export function caseExitCode(verdict, onChainVerdict) {
1712
+ const code = exitCodeFor(verdict);
1713
+ if (onChainVerdict === "failed")
924
1714
  return EXIT_FAILED;
925
- if (grade.verdict === "incomplete")
1715
+ if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
926
1716
  return EXIT_UNDETERMINED;
927
- return mapStatusToExit(result.status, result.errors);
1717
+ return code;
928
1718
  }
929
- function mapStatusToExit(status, errors) {
930
- if (status === "failed")
931
- return EXIT_FAILED;
932
- if (status === "malformed")
1719
+ const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
1720
+ ? VERDICT_DISPLAY_TITLES[word]
1721
+ : word;
1722
+ /**
1723
+ * The text report, one line per entry, identical in both kernels. The headline
1724
+ * is the verdict's one display title and nothing else sets it (5.5.6, D7); the
1725
+ * scope sentence and the witness line are printed with it every time.
1726
+ */
1727
+ function caseReport(data) {
1728
+ const lines = [
1729
+ data.title,
1730
+ ` ${data.summary}`,
1731
+ ` ${data.witness.rendered}`,
1732
+ ` ${data.reliance}`,
1733
+ ];
1734
+ for (const e of data.exceptions)
1735
+ lines.push(` - ${e.rendered}`);
1736
+ for (const n of data.notes)
1737
+ lines.push(` note: ${n.rendered}`);
1738
+ lines.push(`packs ${data.packs.length}`);
1739
+ for (const p of data.packs) {
1740
+ const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
1741
+ const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
1742
+ let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
1743
+ if (p.unaccounted.length > 0) {
1744
+ line +=
1745
+ `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
1746
+ `record: ${p.unaccounted.join(", ")}`;
1747
+ }
1748
+ lines.push(line);
1749
+ }
1750
+ if (data.outside.length > 0) {
1751
+ lines.push(`outside ${data.outside.length} file(s) sit outside every ` +
1752
+ "pack, so no verdict here is about them");
1753
+ const links = new Set(data.not_regular);
1754
+ for (const path of data.outside) {
1755
+ lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
1756
+ }
1757
+ }
1758
+ for (const c of data.case_file) {
1759
+ if (c.index === "absent")
1760
+ continue;
1761
+ const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
1762
+ lines.push(`case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
1763
+ `${label}; ${c.findings.length} disagreement(s) with what is here, ` +
1764
+ "none of which moves the verdict");
1765
+ for (const f of c.findings)
1766
+ lines.push(` - ${f.rendered}`);
1767
+ }
1768
+ const oc = data.on_chain;
1769
+ if (oc.checked) {
1770
+ lines.push(`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
1771
+ "pack by pack; the reading above is offline and this does not " +
1772
+ "change it");
1773
+ for (const r of oc.packs) {
1774
+ lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
1775
+ `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
1776
+ `unreachable, ${r.not_checked} not checked`);
1777
+ }
1778
+ }
1779
+ return lines;
1780
+ }
1781
+ /**
1782
+ * The index comparison, once per argument (5.6.3): each folder or case file
1783
+ * whose root holds `contents.json`. Every file under `packs/` and `report/` is
1784
+ * hashed for it, as bytes, and nothing is parsed but the index. It is never an
1785
+ * input to the reading.
1786
+ */
1787
+ async function caseFileReadings(tree) {
1788
+ const out = [];
1789
+ const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0]))].sort();
1790
+ const keys = [...tree.keys()].sort();
1791
+ for (const root of roots) {
1792
+ const prefix = `${root}/`;
1793
+ const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
1794
+ const indexEntry = tree.get(indexKey);
1795
+ const indexBytes = indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
1796
+ const files = [];
1797
+ if (indexBytes !== null) {
1798
+ for (const key of keys) {
1799
+ const entry = tree.get(key);
1800
+ const rel = key.slice(prefix.length);
1801
+ if (key.startsWith(prefix) &&
1802
+ entry.regular &&
1803
+ (rel.startsWith("packs/") || rel.startsWith("report/"))) {
1804
+ files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
1805
+ }
1806
+ }
1807
+ }
1808
+ const reading = readCaseFileIndex(indexBytes, files);
1809
+ const nil = (v) => (v === undefined ? null : v);
1810
+ out.push({
1811
+ root,
1812
+ index: reading.index,
1813
+ case_id: reading.caseId,
1814
+ findings: reading.findings.map((f) => ({
1815
+ kind: f.kind,
1816
+ ref: nil(f.ref),
1817
+ path: nil(f.path),
1818
+ listed_sha256: nil(f.listedSha256),
1819
+ sha256: nil(f.sha256),
1820
+ reason: nil(f.reason),
1821
+ public_key: nil(f.publicKey),
1822
+ rendered: renderCaseFileFinding(f),
1823
+ })),
1824
+ });
1825
+ }
1826
+ return out;
1827
+ }
1828
+ function caseError(e, asJson, io) {
1829
+ if (asJson)
1830
+ outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
1831
+ io.err(noLoneSurrogates(`case: ${e.message}`));
1832
+ if (e.hint)
1833
+ io.err(noLoneSurrogates(`case: ${e.hint}`));
1834
+ return EXIT_MALFORMED;
1835
+ }
1836
+ /**
1837
+ * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
1838
+ * command is an exit code from the table: an exception nobody expected exits 2,
1839
+ * never 1, because 1 would name the evidence failed over a defect in this
1840
+ * command.
1841
+ */
1842
+ export async function runCase(argv, stream) {
1843
+ const io = guard(stream);
1844
+ try {
1845
+ return await caseCommand(argv, io);
1846
+ }
1847
+ catch (e) {
1848
+ const name = e instanceof Error ? e.constructor.name : typeof e;
1849
+ try {
1850
+ return caseError(new CaseInputError(`an unexpected error stopped the reading (${name}); this is a defect in the ` +
1851
+ "command, not a verdict on the evidence"), argv.includes("--json"), io);
1852
+ }
1853
+ catch {
1854
+ return EXIT_MALFORMED; // a stream that cannot be written
1855
+ }
1856
+ }
1857
+ }
1858
+ async function caseCommand(argv, io) {
1859
+ let asJson = false;
1860
+ let gateways = [];
1861
+ const paths = [];
1862
+ for (let i = 0; i < argv.length; i++) {
1863
+ const arg = argv[i];
1864
+ if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
1865
+ outBlock(io, CASE_USAGE);
1866
+ return EXIT_VERIFIED;
1867
+ }
1868
+ if (arg === "--json") {
1869
+ asJson = true;
1870
+ }
1871
+ else if (arg === "--gateways") {
1872
+ const next = argv[i + 1];
1873
+ if (next === undefined) {
1874
+ io.err("case: --gateways requires a comma-separated list of URLs");
1875
+ outBlock(io, CASE_USAGE);
1876
+ return EXIT_MALFORMED;
1877
+ }
1878
+ gateways = next
1879
+ .split(",")
1880
+ .map((g) => g.trim())
1881
+ .filter((g) => g.length > 0);
1882
+ i++;
1883
+ }
1884
+ else if (arg.startsWith("--")) {
1885
+ io.err(`case: unknown option ${arg}`);
1886
+ outBlock(io, CASE_USAGE);
1887
+ return EXIT_MALFORMED;
1888
+ }
1889
+ else {
1890
+ paths.push(arg);
1891
+ }
1892
+ }
1893
+ if (paths.length === 0) {
1894
+ io.err("case: at least one pack is required");
1895
+ outBlock(io, CASE_USAGE);
933
1896
  return EXIT_MALFORMED;
934
- // "partial" caused specifically by an unreachable gateway — or an export
935
- // source_bundle_ref whose bytes are unavailable offline (evidence-export.md
936
- // §5 step 3) — maps to exit 3 (undetermined, network-dependent).
937
- if (status === "partial" &&
938
- errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))) {
939
- return EXIT_UNDETERMINED;
940
1897
  }
941
- // "verified" → 0. "partial" from a withheld record (semantics-undetermined,
942
- // not a failure) also exits 0 — the bundle is cryptographically sound.
943
- return EXIT_VERIFIED;
1898
+ let split;
1899
+ let tree;
1900
+ let bundles;
1901
+ try {
1902
+ tree = await treeFromArguments(paths);
1903
+ split = await splitCase(tree, readFileEntry);
1904
+ bundles = split.packs.map((p) => [p, loadBundle(p)]);
1905
+ }
1906
+ catch (e) {
1907
+ if (e instanceof CaseInputError)
1908
+ return caseError(e, asJson, io);
1909
+ throw e;
1910
+ }
1911
+ const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
1912
+ let caseFile;
1913
+ try {
1914
+ caseFile = await caseFileReadings(tree);
1915
+ }
1916
+ catch (e) {
1917
+ if (e instanceof CaseInputError)
1918
+ return caseError(e, asJson, io);
1919
+ throw e;
1920
+ }
1921
+ const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
1922
+ const witness = witnessLine(onChain);
1923
+ const code = caseExitCode(reading.verdict, onChain.verdict);
1924
+ const nil = (v) => (v === undefined ? null : v);
1925
+ const data = {
1926
+ verdict: reading.verdict,
1927
+ title: titleOf(reading.verdict),
1928
+ reliance: CASE_RELIANCE[reading.verdict],
1929
+ exit_code: code,
1930
+ summary: renderCaseSummary(reading),
1931
+ witness: {
1932
+ state: witness.state,
1933
+ reasons: witness.reasons,
1934
+ services: witness.services,
1935
+ rendered: renderWitnessLine(witness),
1936
+ },
1937
+ handoff_records: reading.handoffRecords,
1938
+ on_chain_checked: reading.onChainChecked,
1939
+ exceptions: reading.exceptions.map((e) => ({
1940
+ kind: e.kind,
1941
+ pack_ref: nil(e.packRef),
1942
+ event_id: nil(e.eventId),
1943
+ counterparty_key: nil(e.counterpartyKey),
1944
+ failed_checks: e.failedChecks ?? [],
1945
+ count: nil(e.count),
1946
+ status: nil(e.status),
1947
+ causes: e.causes ?? [],
1948
+ reasons: e.reasons ?? [],
1949
+ rendered: renderException(e),
1950
+ })),
1951
+ notes: reading.notes.map((n) => ({
1952
+ kind: n.kind,
1953
+ pack_ref: n.packRef,
1954
+ count: nil(n.count),
1955
+ rendered: renderCaseNote(n),
1956
+ })),
1957
+ records: reading.records.map((r) => ({
1958
+ pack_ref: r.packRef,
1959
+ event_id: r.eventId,
1960
+ verdict: nil(r.verdict),
1961
+ role: r.role,
1962
+ findings: r.findings,
1963
+ failed_checks: r.failedChecks,
1964
+ counterparts: r.counterparts,
1965
+ })),
1966
+ contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
1967
+ packs: bundles.map(([p], i) => ({
1968
+ ref: p.ref,
1969
+ bundle_path: p.bundlePath,
1970
+ log_files: p.content.size,
1971
+ unaccounted: p.unaccounted,
1972
+ signing_key: reading.packs[i].signingKey,
1973
+ grade: reading.packs[i].grade,
1974
+ records_withheld: reading.packs[i].recordsWithheld,
1975
+ declaration_version: reading.packs[i].declarationVersion,
1976
+ })),
1977
+ outside: split.outside,
1978
+ not_regular: split.notRegular,
1979
+ case_file: caseFile,
1980
+ on_chain: onChain,
1981
+ };
1982
+ if (asJson)
1983
+ outJson(io, jsonText(data));
1984
+ else
1985
+ for (const line of caseReport(data))
1986
+ io.out(noLoneSurrogates(line));
1987
+ return code;
944
1988
  }
945
1989
  function shortTx(tx) {
946
1990
  if (tx.length <= 16)
@@ -975,7 +2019,7 @@ if (isMain) {
975
2019
  process.exitCode = code;
976
2020
  })
977
2021
  .catch((e) => {
978
- process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
2022
+ process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
979
2023
  process.exitCode = EXIT_MALFORMED;
980
2024
  });
981
2025
  }