@sanning/proof 0.11.1 → 0.12.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.
package/src/cli.ts CHANGED
@@ -35,9 +35,9 @@
35
35
  // PACK at `incomplete`, because a record nobody hashed is a record nobody
36
36
  // checked, and exit 3 is how this command says "undetermined".
37
37
 
38
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
39
- import { realpathSync } from "node:fs";
40
- import { join } from "node:path";
38
+ import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
39
+ import { constants as fsConstants, realpathSync } from "node:fs";
40
+ import { basename, join, resolve } from "node:path";
41
41
  import { fileURLToPath } from "node:url";
42
42
 
43
43
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
@@ -48,6 +48,7 @@ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js"
48
48
  // named statement that one could not be produced. Three states, not two.
49
49
  type ChainOutcome = ChainReading | { unreadable: string };
50
50
  import type { ChainReading } from "./chain.js";
51
+ import { readCase, renderCaseSummary, renderException } from "./case.js";
51
52
  import { composeExport } from "./compose.js";
52
53
  import type { ComposeExportOptions } from "./compose.js";
53
54
  import { isHexField, utf8 } from "./crypto.js";
@@ -111,6 +112,9 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
111
112
  if (command === "bundle" || command === "export") {
112
113
  return runBundle(rest, io);
113
114
  }
115
+ if (command === "case") {
116
+ return runCase(rest, io);
117
+ }
114
118
  if (command !== "verify") {
115
119
  io.err(`unknown command: ${command}`);
116
120
  printUsage(io);
@@ -157,19 +161,26 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
157
161
  }
158
162
  const content = logs?.content;
159
163
 
160
- let raw: string;
164
+ let raw: Uint8Array;
161
165
  try {
162
- raw = await readFile(bundlePath, "utf8");
166
+ raw = new Uint8Array(await readFile(bundlePath));
163
167
  } catch (e) {
164
168
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
165
169
  return EXIT_MALFORMED;
166
170
  }
167
171
 
172
+ // The one reader both commands use, in both kernels: `case` reads each pack's
173
+ // bundle.json through it too, so the two commands classify one bundle the
174
+ // same way (see `readBundleBytes`).
168
175
  let parsed: unknown;
169
176
  try {
170
- parsed = JSON.parse(raw);
177
+ parsed = readBundleBytes(raw);
171
178
  } catch (e) {
172
- io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
179
+ if (e instanceof BundleReadError && e.tooDeep) {
180
+ io.err(`${bundlePath} ${e.detail}; refusing to read it`);
181
+ } else {
182
+ io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
183
+ }
173
184
  return EXIT_MALFORMED;
174
185
  }
175
186
 
@@ -700,6 +711,8 @@ function printUsage(io: Cli): void {
700
711
  io.out("Commands:");
701
712
  io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
702
713
  io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
714
+ io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
715
+ io.out(" (`case --help` for its exit codes)");
703
716
  io.out("");
704
717
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
705
718
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
@@ -1101,6 +1114,773 @@ function mapStatusToExit(status: string, errors: string[]): number {
1101
1114
  return EXIT_VERIFIED;
1102
1115
  }
1103
1116
 
1117
+ // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
1118
+ //
1119
+ // Directories on disk, split into packs. This lives in the CLI module, and not
1120
+ // in a kernel module, because it is the command's own read of files a person
1121
+ // named on the command line, the same class of read as `verify`'s read of
1122
+ // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
1123
+ // `readBundleBytes`.
1124
+ //
1125
+ // The case READING is `readCase`, and nothing here touches it. This turns the
1126
+ // directories a reader typed into a list of packs. Its twin is the same section
1127
+ // of `src/sanning_proof/cli.py`, and both are held to one table,
1128
+ // `vectors/case-split.json`, whose rows are directory trees.
1129
+ //
1130
+ // ── Directories only ──
1131
+ //
1132
+ // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
1133
+ // unzip it first and pass the folder, and a `.zip` inside a directory is a file
1134
+ // like any other: outside every pack, and never opened. Reading archives in the
1135
+ // kernel brought zip bombs, two zip libraries that disagree, and entry names
1136
+ // that are not file names, and `unzip` already does that job.
1137
+ //
1138
+ // ── The walk ──
1139
+ //
1140
+ // Iterative, never recursive, bytewise-sorted at each level, and bounded:
1141
+ //
1142
+ // - A directory nested more than MAX_WALK_DEPTH levels below an argument
1143
+ // refuses the whole input (exit 2).
1144
+ // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
1145
+ // the whole input (exit 2).
1146
+ // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
1147
+ // not escaped or replaced: two different names would then read as one.
1148
+ // - A symbolic link is never followed, whatever it points at, and anything
1149
+ // else that is not a regular file or a directory is never opened. Each is
1150
+ // listed as outside every pack and not read, even when it sits under a
1151
+ // pack's `logs/` or is named `bundle.json`.
1152
+ //
1153
+ // ── The split: the verify page's rule over the tree ──
1154
+ //
1155
+ // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
1156
+ // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
1157
+ // apply the same splitting rule to the same unpacked tree. The page's rule
1158
+ // lives in the page, so neither kernel can call it; it is restated here and in
1159
+ // the Python twin, and the shared table pins it:
1160
+ //
1161
+ // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1162
+ // at the directory that holds it.
1163
+ // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1164
+ // if it is that root's `bundle.json` or under that root's `logs/`.
1165
+ // 3. A file under no pack is OUTSIDE: listed, and never opened.
1166
+ // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1167
+ // `Array.prototype.sort`).
1168
+ // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1169
+ // and any other file under `logs/` is UNACCOUNTED.
1170
+ // 6. A pack's `ref` is its root without the trailing slash.
1171
+ // 7. No `bundle.json` anywhere refuses.
1172
+ //
1173
+ // One directory argument is read at `<its name>/<path inside it>`, which is how
1174
+ // the page names the files of a dropped folder. Several are read as one folder
1175
+ // holding them, each under its own name, and two that would land at one name
1176
+ // refuse.
1177
+ //
1178
+ // Known differences from the page: the page opens a dropped `.zip` and this
1179
+ // command does not; the page drops a leading byte-order mark from
1180
+ // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1181
+ // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1182
+ // where this command applies the walk's rules above; and the page keys
1183
+ // disclosed content by a plain object, so it loses a record named `__proto__`,
1184
+ // which this command keeps.
1185
+
1186
+ /** The error text for an input with no pack in it, word for word the page's. */
1187
+ export const NO_BUNDLE =
1188
+ "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
1189
+
1190
+ /** A directory nested deeper than this below an argument refuses the input. */
1191
+ export const MAX_WALK_DEPTH = 32;
1192
+ /** More names than this, across every argument, refuse the input. */
1193
+ export const MAX_WALK_ENTRIES = 50_000;
1194
+ /**
1195
+ * `bundle.json` nested deeper than this is refused before it is parsed, by
1196
+ * `verify` and by `case` alike (see `readBundleBytes`).
1197
+ */
1198
+ export const MAX_JSON_DEPTH = 512;
1199
+
1200
+ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
1201
+ const LOG_PATH = /^logs\/([^/]+)\.json$/;
1202
+ const ZIP_NAME = /\.zip$/i;
1203
+
1204
+ /** The one line a `.zip` gets, as an argument or as the way through a refusal. */
1205
+ const UNZIP_FIRST = "Unzip it first (for example, unzip file.zip -d case/), then pass the folder.";
1206
+
1207
+ /**
1208
+ * The input could not be read as a case. The message is the whole report,
1209
+ * identical in both kernels, and the command exits 2 on it. `hint` is an
1210
+ * optional second line naming a way through.
1211
+ */
1212
+ export class CaseInputError extends Error {
1213
+ readonly hint: string | null;
1214
+ constructor(message: string, hint: string | null = null) {
1215
+ super(message);
1216
+ this.hint = hint;
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1222
+ * the one refusal that is not a parse error; `detail` is the parser's own words,
1223
+ * which differ by kernel and are printed by `verify` only.
1224
+ */
1225
+ export class BundleReadError extends Error {
1226
+ readonly tooDeep: boolean;
1227
+ readonly detail: string;
1228
+ constructor(tooDeep: boolean, detail: string) {
1229
+ super(detail);
1230
+ this.tooDeep = tooDeep;
1231
+ this.detail = detail;
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
1237
+ * no recursion; the same scan as `_json_depth_exceeds` in the Python twin, so
1238
+ * both kernels refuse the same text.
1239
+ */
1240
+ function jsonDepthExceeds(text: string, limit: number): boolean {
1241
+ let depth = 0;
1242
+ let inString = false;
1243
+ let escaped = false;
1244
+ for (let i = 0; i < text.length; i++) {
1245
+ const ch = text[i];
1246
+ if (inString) {
1247
+ if (escaped) escaped = false;
1248
+ else if (ch === "\\") escaped = true;
1249
+ else if (ch === '"') inString = false;
1250
+ } else if (ch === '"') {
1251
+ inString = true;
1252
+ } else if (ch === "[" || ch === "{") {
1253
+ depth++;
1254
+ if (depth > limit) return true;
1255
+ } else if (ch === "]" || ch === "}") {
1256
+ depth--;
1257
+ }
1258
+ }
1259
+ return false;
1260
+ }
1261
+
1262
+ // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1263
+ // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1264
+ const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1265
+ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1266
+
1267
+ /**
1268
+ * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1269
+ * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1270
+ * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1271
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1272
+ * Throws `BundleReadError`.
1273
+ */
1274
+ export function readBundleBytes(bytes: Uint8Array): unknown {
1275
+ const text = bundleDecoder.decode(bytes);
1276
+ if (jsonDepthExceeds(text, MAX_JSON_DEPTH)) {
1277
+ throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1278
+ }
1279
+ try {
1280
+ return JSON.parse(text);
1281
+ } catch (e) {
1282
+ throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1283
+ }
1284
+ }
1285
+
1286
+ export interface SplitPack {
1287
+ ref: string;
1288
+ bundlePath: string;
1289
+ bundleBytes: Uint8Array;
1290
+ /**
1291
+ * `logs/<event_id>.json` bytes by event id. A `Map` and not an object: an
1292
+ * event id is written by the audited party, and `obj["__proto__"] = bytes`
1293
+ * replaces the prototype instead of storing the disclosure.
1294
+ */
1295
+ content: Map<string, Uint8Array>;
1296
+ unaccounted: string[];
1297
+ }
1298
+
1299
+ export interface SplitCase {
1300
+ packs: SplitPack[];
1301
+ /** Paths that belong to no pack, sorted. Never opened. */
1302
+ outside: string[];
1303
+ /** The part of `outside` that is not a regular file. Never followed, never opened. */
1304
+ notRegular: string[];
1305
+ }
1306
+
1307
+ /** One name the walk found that is not a directory. */
1308
+ export interface TreeEntry {
1309
+ fsPath: string;
1310
+ /** `true` for a regular file; `false` for a link or anything else. */
1311
+ regular: boolean;
1312
+ }
1313
+
1314
+ /**
1315
+ * The page's `splitCase` followed by its `assemble` per pack, over the walk's
1316
+ * tree. `read` opens a file; it is called for the files a pack owns and for
1317
+ * nothing else.
1318
+ */
1319
+ export async function splitCase(
1320
+ tree: Map<string, TreeEntry>,
1321
+ read: (entry: TreeEntry, key: string) => Promise<Uint8Array>,
1322
+ ): Promise<SplitCase> {
1323
+ const roots = [...tree]
1324
+ .filter(([p, e]) => e.regular && BUNDLE_PATH.test(p))
1325
+ .map(([p]) => p.slice(0, p.length - "bundle.json".length))
1326
+ .sort();
1327
+
1328
+ if (roots.length === 0) {
1329
+ const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
1330
+ const hint =
1331
+ zips.length > 0
1332
+ ? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
1333
+ `${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
1334
+ UNZIP_FIRST
1335
+ : null;
1336
+ throw new CaseInputError(NO_BUNDLE, hint);
1337
+ }
1338
+
1339
+ const owned = new Map<string, string[]>(roots.map((r) => [r, []]));
1340
+ const outside: string[] = [];
1341
+ const notRegular: string[] = [];
1342
+ for (const [path, entry] of tree) {
1343
+ if (!entry.regular) {
1344
+ outside.push(path);
1345
+ notRegular.push(path);
1346
+ continue;
1347
+ }
1348
+ let best: string | null = null;
1349
+ for (const root of roots) {
1350
+ const mine = path === `${root}bundle.json` || path.startsWith(`${root}logs/`);
1351
+ if (mine && (best === null || root.length > best.length)) best = root;
1352
+ }
1353
+ if (best === null) outside.push(path);
1354
+ else owned.get(best)!.push(path);
1355
+ }
1356
+
1357
+ const packs: SplitPack[] = [];
1358
+ for (const root of roots) {
1359
+ const bundlePath = `${root}bundle.json`;
1360
+ const content = new Map<string, Uint8Array>();
1361
+ const unaccounted: string[] = [];
1362
+ for (const p of owned.get(root)!.sort()) {
1363
+ if (p === bundlePath) continue;
1364
+ const rel = p.slice(root.length);
1365
+ const m = LOG_PATH.exec(rel);
1366
+ if (m) content.set(m[1]!, await read(tree.get(p)!, p));
1367
+ else unaccounted.push(rel);
1368
+ }
1369
+ packs.push({
1370
+ ref: root.slice(0, -1),
1371
+ bundlePath,
1372
+ bundleBytes: await read(tree.get(bundlePath)!, bundlePath),
1373
+ content,
1374
+ unaccounted: unaccounted.sort(),
1375
+ });
1376
+ }
1377
+ return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1378
+ }
1379
+
1380
+ /** `verify`'s reader, with the refusal named by the pack's path. */
1381
+ export function loadBundle(pack: SplitPack): unknown {
1382
+ try {
1383
+ return readBundleBytes(pack.bundleBytes);
1384
+ } catch (e) {
1385
+ if (e instanceof BundleReadError && e.tooDeep) {
1386
+ throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1387
+ }
1388
+ throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * A regular file's bytes. Opened without following a link and without
1394
+ * blocking, then checked again, so a file swapped for a link or a pipe after
1395
+ * the walk listed it is refused rather than followed or waited on.
1396
+ */
1397
+ export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint8Array> {
1398
+ const flags =
1399
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1400
+ let handle;
1401
+ try {
1402
+ handle = await open(entry.fsPath, flags);
1403
+ } catch {
1404
+ throw new CaseInputError(`cannot read ${key}`);
1405
+ }
1406
+ try {
1407
+ if (!(await handle.stat()).isFile()) throw new CaseInputError(`cannot read ${key}`);
1408
+ return new Uint8Array(await handle.readFile());
1409
+ } catch {
1410
+ throw new CaseInputError(`cannot read ${key}`);
1411
+ } finally {
1412
+ await handle.close();
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
1418
+ * first from an explicit queue, never by recursion. `seen` is the count of
1419
+ * names listed so far, across every argument.
1420
+ */
1421
+ async function walk(
1422
+ root: string,
1423
+ prefix: string,
1424
+ arg: string,
1425
+ tree: Map<string, TreeEntry>,
1426
+ seen: { n: number },
1427
+ ): Promise<void> {
1428
+ const queue: [string, string, number][] = [[root, prefix, 0]];
1429
+ for (let head = 0; head < queue.length; head++) {
1430
+ const [directory, rel, depth] = queue[head]!;
1431
+ let names: Buffer[];
1432
+ try {
1433
+ names = await readdir(directory, { encoding: "buffer" });
1434
+ } catch {
1435
+ throw new CaseInputError(`cannot read ${rel.slice(0, -1)}`);
1436
+ }
1437
+ names.sort(Buffer.compare);
1438
+ for (const raw of names) {
1439
+ seen.n++;
1440
+ if (seen.n > MAX_WALK_ENTRIES) {
1441
+ throw new CaseInputError(
1442
+ `${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`,
1443
+ );
1444
+ }
1445
+ let name: string;
1446
+ try {
1447
+ name = nameDecoder.decode(raw);
1448
+ } catch {
1449
+ throw new CaseInputError(
1450
+ `${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`,
1451
+ );
1452
+ }
1453
+ const path = join(directory, name);
1454
+ const key = `${rel}${name}`;
1455
+ let s;
1456
+ try {
1457
+ s = await lstat(path);
1458
+ } catch {
1459
+ throw new CaseInputError(`cannot read ${key}`);
1460
+ }
1461
+ if (s.isDirectory()) {
1462
+ if (depth + 1 > MAX_WALK_DEPTH) {
1463
+ throw new CaseInputError(
1464
+ `${arg} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`,
1465
+ );
1466
+ }
1467
+ queue.push([path, `${key}/`, depth + 1]);
1468
+ } else {
1469
+ tree.set(key, { fsPath: path, regular: s.isFile() });
1470
+ }
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // The name the page would see for a dropped folder: the last segment of the
1476
+ // absolute path, so `.` and `pack/` name the directory itself.
1477
+ function baseName(path: string): string {
1478
+ return basename(resolve(path));
1479
+ }
1480
+
1481
+ /**
1482
+ * Every name under the directories named, keyed `<name>/<path inside>`. Each
1483
+ * argument must be a directory, and not a link to one. A `.zip` is refused
1484
+ * with the way through.
1485
+ */
1486
+ export async function treeFromArguments(paths: readonly string[]): Promise<Map<string, TreeEntry>> {
1487
+ if (paths.length === 0) throw new CaseInputError("case: at least one pack is required");
1488
+ for (const arg of paths) {
1489
+ // `link/` resolves the link; the name without the slash does not.
1490
+ let s;
1491
+ try {
1492
+ s = await lstat(arg.replace(/\/+$/, "") || arg);
1493
+ } catch {
1494
+ throw new CaseInputError(`cannot read ${arg}`);
1495
+ }
1496
+ if (s.isSymbolicLink()) {
1497
+ throw new CaseInputError(
1498
+ `${arg} is a symbolic link, which case does not follow; pass the folder it points to`,
1499
+ );
1500
+ }
1501
+ if (s.isDirectory()) continue;
1502
+ if (ZIP_NAME.test(arg)) throw new CaseInputError(`${arg} is a .zip archive. ${UNZIP_FIRST}`);
1503
+ throw new CaseInputError(`${arg} is not a folder. Pass pack folders, or the folder that holds them.`);
1504
+ }
1505
+
1506
+ const tree = new Map<string, TreeEntry>();
1507
+ const claimed = new Map<string, string>();
1508
+ const seen = { n: 0 };
1509
+ for (const arg of paths) {
1510
+ const name = baseName(arg);
1511
+ const earlier = claimed.get(name);
1512
+ if (earlier !== undefined) {
1513
+ throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
1514
+ }
1515
+ claimed.set(name, arg);
1516
+ await walk(arg, `${name}/`, arg, tree, seen);
1517
+ }
1518
+ return tree;
1519
+ }
1520
+
1521
+ // ─── `proof case` (proof#141) ────────────────────────────────────────────────
1522
+ //
1523
+ // The case reading from a terminal, so an auditor holding several packs does
1524
+ // not depend on Sanning's page for the one check the audited party cannot
1525
+ // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
1526
+ // the two commands are compared as child processes by
1527
+ // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
1528
+ // over every `case/` vector.
1529
+ //
1530
+ // The reading is `readCase`, entire. This command splits the input into packs
1531
+ // (the section above, the verify page's rule) and prints what the kernel said.
1532
+ // No color: the two commands print the same bytes.
1533
+
1534
+ // Exit code per case verdict word. The four codes `verify` uses mean what they
1535
+ // mean there, and `mixed` gets its own, because each code already in use would
1536
+ // say something a mixed case has not established: 0 and 3 would soften a case
1537
+ // that holds a failure, and 1 would accuse a case whose outcomes are all
1538
+ // undetermined.
1539
+ const EXIT_MIXED = 4;
1540
+ export const CASE_EXIT_CODES: Readonly<Record<string, number>> = {
1541
+ verified: EXIT_VERIFIED,
1542
+ failed: EXIT_FAILED,
1543
+ malformed: EXIT_MALFORMED,
1544
+ incomplete: EXIT_UNDETERMINED,
1545
+ partial: EXIT_UNDETERMINED,
1546
+ mixed: EXIT_MIXED,
1547
+ };
1548
+
1549
+ export const CASE_USAGE = `sanning-proof case -- one verdict over several evidence packs, offline
1550
+
1551
+ sanning-proof case <folder>... [--gateways <url,url>] [--json]
1552
+
1553
+ <folder> a pack folder (bundle.json plus logs/), or a folder that holds
1554
+ several packs, such as an unpacked decision file. case reads
1555
+ folders only: given a .zip, it exits 2 and says to unzip it
1556
+ first (for example, unzip file.zip -d case/) and pass the folder.
1557
+ Packs are found by the verify page's splitting rule: every
1558
+ bundle.json is a pack, a file belongs to the deepest pack whose
1559
+ bundle.json or logs/ it sits in, and every other file is listed
1560
+ as outside every pack and never opened. A .zip inside a folder is
1561
+ a file like any other. A symbolic link is never followed: it is
1562
+ listed as outside and not read, even under a pack's logs/.
1563
+ Several folders are read as one folder holding them, each under
1564
+ its own name. A folder nested more than 32 levels deep, more than
1565
+ 50000 files and folders in all, or a name that is not valid UTF-8
1566
+ exits 2.
1567
+ --gateways comma-separated gateway URLs. Each pack's witnesses are then
1568
+ re-fetched on chain, pack by pack, and reported beside the case
1569
+ reading. Without it the on-chain half is NOT checked, and the
1570
+ output says so.
1571
+ --json the full reading as JSON: the verdict, every exception with its
1572
+ rendered line, every record, every contribution, the packs, the
1573
+ files outside them, 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; every record every pack supplied is read, in one pile, under the
1580
+ hand-off rules of envelope-spec section 4.1; a hand-off record with no
1581
+ counterpart among the case's records is always named, and when a pack signed by
1582
+ the key it names is in the case, the reading says that pack holds none, unless
1583
+ every candidate it had was accounted for by another record (A20), which reads
1584
+ as no counterpart and names no pack.
1585
+
1586
+ What it does not establish: that the case is complete (a party can decline to
1587
+ 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 was re-fetched on chain, which the reading never does.
1590
+
1591
+ Exit codes: 0 verified · 1 failed · 2 malformed, usage, an input that cannot
1592
+ be read as a case, or an unexpected error in this command · 3 incomplete or
1593
+ partial (undetermined, not failed) · 4 mixed (more than one outcome: read the
1594
+ exceptions). With --gateways, a witness that does not match on chain exits 1,
1595
+ and an on-chain half that could not be settled turns 0 into 3.`;
1596
+
1597
+ // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
1598
+ // rather than left to the stream, so the lines a test captures in process are
1599
+ // the lines a reader sees.
1600
+ function noLoneSurrogates(text: string): string {
1601
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
1602
+ }
1603
+
1604
+ // Keys sorted at every depth, arrays left in their order: the Python twin
1605
+ // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
1606
+ // that, byte for byte.
1607
+ function sortKeys(v: unknown): unknown {
1608
+ if (Array.isArray(v)) return v.map(sortKeys);
1609
+ if (v !== null && typeof v === "object") {
1610
+ return Object.fromEntries(
1611
+ Object.keys(v as Record<string, unknown>)
1612
+ .sort()
1613
+ .map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]),
1614
+ );
1615
+ }
1616
+ return v;
1617
+ }
1618
+
1619
+ function jsonText(v: unknown): string {
1620
+ return JSON.stringify(sortKeys(v), null, 2);
1621
+ }
1622
+
1623
+ interface OnChainRow {
1624
+ ref: string;
1625
+ witnesses: number;
1626
+ confirmed: number;
1627
+ mismatched: number;
1628
+ unreachable: number;
1629
+ not_checked: number;
1630
+ }
1631
+
1632
+ interface OnChainHalf {
1633
+ checked: boolean;
1634
+ gateways: string[];
1635
+ packs: OnChainRow[];
1636
+ verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
1637
+ }
1638
+
1639
+ // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1640
+ // case reading on purpose: `readCase` makes no network call, and neither does
1641
+ // the verify page's case reading, so the case verdict stays the one both
1642
+ // surfaces compute. This adds the half the reading names as not checked.
1643
+ async function onChainHalf(
1644
+ packs: readonly [string, unknown][],
1645
+ gateways: string[],
1646
+ io: Cli,
1647
+ ): Promise<OnChainHalf> {
1648
+ if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1649
+ const rows: OnChainRow[] = [];
1650
+ for (const [ref, bundle] of packs) {
1651
+ const result = await verifyEvidenceBundle(bundle, {
1652
+ gateways,
1653
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1654
+ });
1655
+ const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1656
+ const row: OnChainRow = {
1657
+ ref,
1658
+ witnesses: witnesses.length,
1659
+ confirmed: 0,
1660
+ mismatched: 0,
1661
+ unreachable: 0,
1662
+ not_checked: 0,
1663
+ };
1664
+ for (const w of witnesses) {
1665
+ const rollup = w.onChain?.rollup ?? null;
1666
+ if (rollup === "confirm") row.confirmed++;
1667
+ else if (rollup === "mismatch") row.mismatched++;
1668
+ else if (rollup === "unreachable") row.unreachable++;
1669
+ else row.not_checked++;
1670
+ }
1671
+ rows.push(row);
1672
+ }
1673
+ const verdict = rows.some((r) => r.mismatched > 0)
1674
+ ? "failed"
1675
+ : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1676
+ ? "undetermined"
1677
+ : "confirmed";
1678
+ return { checked: true, gateways: [...gateways], packs: rows, verdict };
1679
+ }
1680
+
1681
+ /**
1682
+ * The case verdict's code, moved only by an on-chain half that was asked for:
1683
+ * a witness that does not match is a failure whatever the reading said, and an
1684
+ * on-chain half that could not be settled is never a clean 0.
1685
+ */
1686
+ export function caseExitCode(verdict: string, onChainVerdict: string): number {
1687
+ const code = CASE_EXIT_CODES[verdict] ?? EXIT_MALFORMED;
1688
+ if (onChainVerdict === "failed") return EXIT_FAILED;
1689
+ if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
1690
+ return code;
1691
+ }
1692
+
1693
+ interface CaseData {
1694
+ verdict: string;
1695
+ summary: string;
1696
+ exceptions: { rendered: string }[];
1697
+ packs: { ref: string; log_files: number; unaccounted: string[] }[];
1698
+ outside: string[];
1699
+ not_regular: string[];
1700
+ on_chain: OnChainHalf;
1701
+ }
1702
+
1703
+ function caseReport(data: CaseData): string[] {
1704
+ const lines = [data.verdict.toUpperCase(), ` ${data.summary}`];
1705
+ for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
1706
+ lines.push(`packs ${data.packs.length}`);
1707
+ for (const p of data.packs) {
1708
+ let line = ` ${p.ref} ${p.log_files} record file(s) under logs/`;
1709
+ if (p.unaccounted.length > 0) {
1710
+ line +=
1711
+ `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
1712
+ `record: ${p.unaccounted.join(", ")}`;
1713
+ }
1714
+ lines.push(line);
1715
+ }
1716
+ if (data.outside.length > 0) {
1717
+ lines.push(
1718
+ `outside ${data.outside.length} file(s) sit outside every ` +
1719
+ "pack, so no verdict here is about them",
1720
+ );
1721
+ const links = new Set(data.not_regular);
1722
+ for (const path of data.outside) {
1723
+ lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
1724
+ }
1725
+ }
1726
+ const oc = data.on_chain;
1727
+ if (!oc.checked) {
1728
+ lines.push(
1729
+ "on-chain NOT CHECKED: no gateway was supplied, so no witness " +
1730
+ "any pack names was re-fetched. Name gateways with --gateways to " +
1731
+ "check them.",
1732
+ );
1733
+ } else {
1734
+ lines.push(
1735
+ `on-chain re-fetched against ${oc.gateways.length} gateway(s), ` +
1736
+ "pack by pack; the case reading above is offline and this does not " +
1737
+ "change it",
1738
+ );
1739
+ for (const r of oc.packs) {
1740
+ lines.push(
1741
+ ` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
1742
+ `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
1743
+ `unreachable, ${r.not_checked} not checked`,
1744
+ );
1745
+ }
1746
+ lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
1747
+ }
1748
+ return lines;
1749
+ }
1750
+
1751
+ function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
1752
+ if (asJson) io.out(jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
1753
+ io.err(noLoneSurrogates(`case: ${e.message}`));
1754
+ if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
1755
+ return EXIT_MALFORMED;
1756
+ }
1757
+
1758
+ /**
1759
+ * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
1760
+ * command is an exit code from the table: an exception nobody expected exits 2,
1761
+ * never 1, because 1 would name the evidence failed over a defect in this
1762
+ * command.
1763
+ */
1764
+ export async function runCase(argv: readonly string[], io: Cli): Promise<number> {
1765
+ try {
1766
+ return await caseCommand(argv, io);
1767
+ } catch (e) {
1768
+ const name = e instanceof Error ? e.constructor.name : typeof e;
1769
+ try {
1770
+ return caseError(
1771
+ new CaseInputError(
1772
+ `an unexpected error stopped the reading (${name}); this is a defect in the ` +
1773
+ "command, not a verdict on the evidence",
1774
+ ),
1775
+ argv.includes("--json"),
1776
+ io,
1777
+ );
1778
+ } catch {
1779
+ return EXIT_MALFORMED; // a stream that cannot be written
1780
+ }
1781
+ }
1782
+ }
1783
+
1784
+ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
1785
+ let asJson = false;
1786
+ let gateways: string[] = [];
1787
+ const paths: string[] = [];
1788
+ for (let i = 0; i < argv.length; i++) {
1789
+ const arg = argv[i]!;
1790
+ if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
1791
+ io.out(CASE_USAGE);
1792
+ return EXIT_VERIFIED;
1793
+ }
1794
+ if (arg === "--json") {
1795
+ asJson = true;
1796
+ } else if (arg === "--gateways") {
1797
+ const next = argv[i + 1];
1798
+ if (next === undefined) {
1799
+ io.err("case: --gateways requires a comma-separated list of URLs");
1800
+ io.out(CASE_USAGE);
1801
+ return EXIT_MALFORMED;
1802
+ }
1803
+ gateways = next
1804
+ .split(",")
1805
+ .map((g) => g.trim())
1806
+ .filter((g) => g.length > 0);
1807
+ i++;
1808
+ } else if (arg.startsWith("--")) {
1809
+ io.err(`case: unknown option ${arg}`);
1810
+ io.out(CASE_USAGE);
1811
+ return EXIT_MALFORMED;
1812
+ } else {
1813
+ paths.push(arg);
1814
+ }
1815
+ }
1816
+ if (paths.length === 0) {
1817
+ io.err("case: at least one pack is required");
1818
+ io.out(CASE_USAGE);
1819
+ return EXIT_MALFORMED;
1820
+ }
1821
+
1822
+ let split;
1823
+ let bundles: [SplitPack, unknown][];
1824
+ try {
1825
+ split = await splitCase(await treeFromArguments(paths), readFileEntry);
1826
+ bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
1827
+ } catch (e) {
1828
+ if (e instanceof CaseInputError) return caseError(e, asJson, io);
1829
+ throw e;
1830
+ }
1831
+
1832
+ const reading = await readCase(
1833
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
1834
+ );
1835
+ const onChain = await onChainHalf(
1836
+ bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
1837
+ gateways,
1838
+ io,
1839
+ );
1840
+ const code = caseExitCode(reading.verdict, onChain.verdict);
1841
+ const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
1842
+
1843
+ const data = {
1844
+ verdict: reading.verdict,
1845
+ exit_code: code,
1846
+ summary: renderCaseSummary(reading),
1847
+ handoff_records: reading.handoffRecords,
1848
+ on_chain_checked: reading.onChainChecked,
1849
+ exceptions: reading.exceptions.map((e) => ({
1850
+ kind: e.kind,
1851
+ pack_ref: nil(e.packRef),
1852
+ event_id: nil(e.eventId),
1853
+ counterparty_key: nil(e.counterpartyKey),
1854
+ failed_checks: e.failedChecks ?? [],
1855
+ count: nil(e.count),
1856
+ status: nil(e.status),
1857
+ reasons: e.reasons ?? [],
1858
+ rendered: renderException(e),
1859
+ })),
1860
+ records: reading.records.map((r) => ({
1861
+ pack_ref: r.packRef,
1862
+ event_id: r.eventId,
1863
+ verdict: nil(r.verdict),
1864
+ findings: r.findings,
1865
+ failed_checks: r.failedChecks,
1866
+ counterparts: r.counterparts,
1867
+ })),
1868
+ contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
1869
+ packs: bundles.map(([p]) => ({
1870
+ ref: p.ref,
1871
+ bundle_path: p.bundlePath,
1872
+ log_files: p.content.size,
1873
+ unaccounted: p.unaccounted,
1874
+ })),
1875
+ outside: split.outside,
1876
+ not_regular: split.notRegular,
1877
+ on_chain: onChain,
1878
+ };
1879
+ if (asJson) io.out(jsonText(data));
1880
+ else for (const line of caseReport(data)) io.out(noLoneSurrogates(line));
1881
+ return code;
1882
+ }
1883
+
1104
1884
  function shortTx(tx: string): string {
1105
1885
  if (tx.length <= 16) return tx;
1106
1886
  return `${tx.slice(0, 8)}…${tx.slice(-4)}`;