@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/dist/cli.js CHANGED
@@ -34,12 +34,13 @@
34
34
  // surfaces as a per-event note, and it does NOT fail the run. It does hold the
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
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
38
- import { realpathSync } from "node:fs";
39
- import { join } from "node:path";
37
+ import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
38
+ import { constants as fsConstants, realpathSync } from "node:fs";
39
+ import { basename, join, resolve } from "node:path";
40
40
  import { fileURLToPath } from "node:url";
41
41
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
42
42
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
43
+ import { readCase, renderCaseSummary, renderException } from "./case.js";
43
44
  import { composeExport } from "./compose.js";
44
45
  import { isHexField, utf8 } from "./crypto.js";
45
46
  import { verifyEvidenceBundle } from "./evidence.js";
@@ -90,6 +91,9 @@ export async function runCli(argv, io) {
90
91
  if (command === "bundle" || command === "export") {
91
92
  return runBundle(rest, io);
92
93
  }
94
+ if (command === "case") {
95
+ return runCase(rest, io);
96
+ }
93
97
  if (command !== "verify") {
94
98
  io.err(`unknown command: ${command}`);
95
99
  printUsage(io);
@@ -136,18 +140,26 @@ export async function runCli(argv, io) {
136
140
  const content = logs?.content;
137
141
  let raw;
138
142
  try {
139
- raw = await readFile(bundlePath, "utf8");
143
+ raw = new Uint8Array(await readFile(bundlePath));
140
144
  }
141
145
  catch (e) {
142
146
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
143
147
  return EXIT_MALFORMED;
144
148
  }
149
+ // The one reader both commands use, in both kernels: `case` reads each pack's
150
+ // bundle.json through it too, so the two commands classify one bundle the
151
+ // same way (see `readBundleBytes`).
145
152
  let parsed;
146
153
  try {
147
- parsed = JSON.parse(raw);
154
+ parsed = readBundleBytes(raw);
148
155
  }
149
156
  catch (e) {
150
- io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
157
+ if (e instanceof BundleReadError && e.tooDeep) {
158
+ io.err(`${bundlePath} ${e.detail}; refusing to read it`);
159
+ }
160
+ else {
161
+ io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
162
+ }
151
163
  return EXIT_MALFORMED;
152
164
  }
153
165
  const specVersion = parsed && typeof parsed === "object" && !Array.isArray(parsed)
@@ -621,6 +633,8 @@ function printUsage(io) {
621
633
  io.out("Commands:");
622
634
  io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
623
635
  io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
636
+ io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
637
+ io.out(" (`case --help` for its exit codes)");
624
638
  io.out("");
625
639
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
626
640
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
@@ -942,6 +956,687 @@ function mapStatusToExit(status, errors) {
942
956
  // not a failure) also exits 0 — the bundle is cryptographically sound.
943
957
  return EXIT_VERIFIED;
944
958
  }
959
+ // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
960
+ //
961
+ // Directories on disk, split into packs. This lives in the CLI module, and not
962
+ // in a kernel module, because it is the command's own read of files a person
963
+ // named on the command line, the same class of read as `verify`'s read of
964
+ // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
965
+ // `readBundleBytes`.
966
+ //
967
+ // The case READING is `readCase`, and nothing here touches it. This turns the
968
+ // directories a reader typed into a list of packs. Its twin is the same section
969
+ // of `src/sanning_proof/cli.py`, and both are held to one table,
970
+ // `vectors/case-split.json`, whose rows are directory trees.
971
+ //
972
+ // ── Directories only ──
973
+ //
974
+ // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
975
+ // unzip it first and pass the folder, and a `.zip` inside a directory is a file
976
+ // like any other: outside every pack, and never opened. Reading archives in the
977
+ // kernel brought zip bombs, two zip libraries that disagree, and entry names
978
+ // that are not file names, and `unzip` already does that job.
979
+ //
980
+ // ── The walk ──
981
+ //
982
+ // Iterative, never recursive, bytewise-sorted at each level, and bounded:
983
+ //
984
+ // - A directory nested more than MAX_WALK_DEPTH levels below an argument
985
+ // refuses the whole input (exit 2).
986
+ // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
987
+ // the whole input (exit 2).
988
+ // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
989
+ // not escaped or replaced: two different names would then read as one.
990
+ // - A symbolic link is never followed, whatever it points at, and anything
991
+ // else that is not a regular file or a directory is never opened. Each is
992
+ // listed as outside every pack and not read, even when it sits under a
993
+ // pack's `logs/` or is named `bundle.json`.
994
+ //
995
+ // ── The split: the verify page's rule over the tree ──
996
+ //
997
+ // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
998
+ // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
999
+ // apply the same splitting rule to the same unpacked tree. The page's rule
1000
+ // lives in the page, so neither kernel can call it; it is restated here and in
1001
+ // the Python twin, and the shared table pins it:
1002
+ //
1003
+ // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1004
+ // at the directory that holds it.
1005
+ // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1006
+ // if it is that root's `bundle.json` or under that root's `logs/`.
1007
+ // 3. A file under no pack is OUTSIDE: listed, and never opened.
1008
+ // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1009
+ // `Array.prototype.sort`).
1010
+ // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1011
+ // and any other file under `logs/` is UNACCOUNTED.
1012
+ // 6. A pack's `ref` is its root without the trailing slash.
1013
+ // 7. No `bundle.json` anywhere refuses.
1014
+ //
1015
+ // One directory argument is read at `<its name>/<path inside it>`, which is how
1016
+ // the page names the files of a dropped folder. Several are read as one folder
1017
+ // holding them, each under its own name, and two that would land at one name
1018
+ // refuse.
1019
+ //
1020
+ // Known differences from the page: the page opens a dropped `.zip` and this
1021
+ // command does not; the page drops a leading byte-order mark from
1022
+ // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1023
+ // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1024
+ // where this command applies the walk's rules above; and the page keys
1025
+ // disclosed content by a plain object, so it loses a record named `__proto__`,
1026
+ // which this command keeps.
1027
+ /** The error text for an input with no pack in it, word for word the page's. */
1028
+ export const NO_BUNDLE = "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
1029
+ /** A directory nested deeper than this below an argument refuses the input. */
1030
+ export const MAX_WALK_DEPTH = 32;
1031
+ /** More names than this, across every argument, refuse the input. */
1032
+ export const MAX_WALK_ENTRIES = 50_000;
1033
+ /**
1034
+ * `bundle.json` nested deeper than this is refused before it is parsed, by
1035
+ * `verify` and by `case` alike (see `readBundleBytes`).
1036
+ */
1037
+ export const MAX_JSON_DEPTH = 512;
1038
+ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
1039
+ const LOG_PATH = /^logs\/([^/]+)\.json$/;
1040
+ const ZIP_NAME = /\.zip$/i;
1041
+ /** The one line a `.zip` gets, as an argument or as the way through a refusal. */
1042
+ const UNZIP_FIRST = "Unzip it first (for example, unzip file.zip -d case/), then pass the folder.";
1043
+ /**
1044
+ * The input could not be read as a case. The message is the whole report,
1045
+ * identical in both kernels, and the command exits 2 on it. `hint` is an
1046
+ * optional second line naming a way through.
1047
+ */
1048
+ export class CaseInputError extends Error {
1049
+ hint;
1050
+ constructor(message, hint = null) {
1051
+ super(message);
1052
+ this.hint = hint;
1053
+ }
1054
+ }
1055
+ /**
1056
+ * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1057
+ * the one refusal that is not a parse error; `detail` is the parser's own words,
1058
+ * which differ by kernel and are printed by `verify` only.
1059
+ */
1060
+ export class BundleReadError extends Error {
1061
+ tooDeep;
1062
+ detail;
1063
+ constructor(tooDeep, detail) {
1064
+ super(detail);
1065
+ this.tooDeep = tooDeep;
1066
+ this.detail = detail;
1067
+ }
1068
+ }
1069
+ /**
1070
+ * Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
1071
+ * no recursion; the same scan as `_json_depth_exceeds` in the Python twin, so
1072
+ * both kernels refuse the same text.
1073
+ */
1074
+ function jsonDepthExceeds(text, limit) {
1075
+ let depth = 0;
1076
+ let inString = false;
1077
+ let escaped = false;
1078
+ for (let i = 0; i < text.length; i++) {
1079
+ const ch = text[i];
1080
+ if (inString) {
1081
+ if (escaped)
1082
+ escaped = false;
1083
+ else if (ch === "\\")
1084
+ escaped = true;
1085
+ else if (ch === '"')
1086
+ inString = false;
1087
+ }
1088
+ else if (ch === '"') {
1089
+ inString = true;
1090
+ }
1091
+ else if (ch === "[" || ch === "{") {
1092
+ depth++;
1093
+ if (depth > limit)
1094
+ return true;
1095
+ }
1096
+ else if (ch === "]" || ch === "}") {
1097
+ depth--;
1098
+ }
1099
+ }
1100
+ return false;
1101
+ }
1102
+ // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1103
+ // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1104
+ const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1105
+ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1106
+ /**
1107
+ * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1108
+ * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1109
+ * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1110
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1111
+ * Throws `BundleReadError`.
1112
+ */
1113
+ export function readBundleBytes(bytes) {
1114
+ const text = bundleDecoder.decode(bytes);
1115
+ if (jsonDepthExceeds(text, MAX_JSON_DEPTH)) {
1116
+ throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1117
+ }
1118
+ try {
1119
+ return JSON.parse(text);
1120
+ }
1121
+ catch (e) {
1122
+ throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1123
+ }
1124
+ }
1125
+ /**
1126
+ * The page's `splitCase` followed by its `assemble` per pack, over the walk's
1127
+ * tree. `read` opens a file; it is called for the files a pack owns and for
1128
+ * nothing else.
1129
+ */
1130
+ export async function splitCase(tree, read) {
1131
+ const roots = [...tree]
1132
+ .filter(([p, e]) => e.regular && BUNDLE_PATH.test(p))
1133
+ .map(([p]) => p.slice(0, p.length - "bundle.json".length))
1134
+ .sort();
1135
+ if (roots.length === 0) {
1136
+ const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
1137
+ const hint = zips.length > 0
1138
+ ? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
1139
+ `${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
1140
+ UNZIP_FIRST
1141
+ : null;
1142
+ throw new CaseInputError(NO_BUNDLE, hint);
1143
+ }
1144
+ const owned = new Map(roots.map((r) => [r, []]));
1145
+ const outside = [];
1146
+ const notRegular = [];
1147
+ for (const [path, entry] of tree) {
1148
+ if (!entry.regular) {
1149
+ outside.push(path);
1150
+ notRegular.push(path);
1151
+ continue;
1152
+ }
1153
+ let best = null;
1154
+ for (const root of roots) {
1155
+ const mine = path === `${root}bundle.json` || path.startsWith(`${root}logs/`);
1156
+ if (mine && (best === null || root.length > best.length))
1157
+ best = root;
1158
+ }
1159
+ if (best === null)
1160
+ outside.push(path);
1161
+ else
1162
+ owned.get(best).push(path);
1163
+ }
1164
+ const packs = [];
1165
+ for (const root of roots) {
1166
+ const bundlePath = `${root}bundle.json`;
1167
+ const content = new Map();
1168
+ const unaccounted = [];
1169
+ for (const p of owned.get(root).sort()) {
1170
+ if (p === bundlePath)
1171
+ continue;
1172
+ const rel = p.slice(root.length);
1173
+ const m = LOG_PATH.exec(rel);
1174
+ if (m)
1175
+ content.set(m[1], await read(tree.get(p), p));
1176
+ else
1177
+ unaccounted.push(rel);
1178
+ }
1179
+ packs.push({
1180
+ ref: root.slice(0, -1),
1181
+ bundlePath,
1182
+ bundleBytes: await read(tree.get(bundlePath), bundlePath),
1183
+ content,
1184
+ unaccounted: unaccounted.sort(),
1185
+ });
1186
+ }
1187
+ return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1188
+ }
1189
+ /** `verify`'s reader, with the refusal named by the pack's path. */
1190
+ export function loadBundle(pack) {
1191
+ try {
1192
+ return readBundleBytes(pack.bundleBytes);
1193
+ }
1194
+ catch (e) {
1195
+ if (e instanceof BundleReadError && e.tooDeep) {
1196
+ throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1197
+ }
1198
+ throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1199
+ }
1200
+ }
1201
+ /**
1202
+ * A regular file's bytes. Opened without following a link and without
1203
+ * blocking, then checked again, so a file swapped for a link or a pipe after
1204
+ * the walk listed it is refused rather than followed or waited on.
1205
+ */
1206
+ export async function readFileEntry(entry, key) {
1207
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1208
+ let handle;
1209
+ try {
1210
+ handle = await open(entry.fsPath, flags);
1211
+ }
1212
+ catch {
1213
+ throw new CaseInputError(`cannot read ${key}`);
1214
+ }
1215
+ try {
1216
+ if (!(await handle.stat()).isFile())
1217
+ throw new CaseInputError(`cannot read ${key}`);
1218
+ return new Uint8Array(await handle.readFile());
1219
+ }
1220
+ catch {
1221
+ throw new CaseInputError(`cannot read ${key}`);
1222
+ }
1223
+ finally {
1224
+ await handle.close();
1225
+ }
1226
+ }
1227
+ /**
1228
+ * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
1229
+ * first from an explicit queue, never by recursion. `seen` is the count of
1230
+ * names listed so far, across every argument.
1231
+ */
1232
+ async function walk(root, prefix, arg, tree, seen) {
1233
+ const queue = [[root, prefix, 0]];
1234
+ for (let head = 0; head < queue.length; head++) {
1235
+ const [directory, rel, depth] = queue[head];
1236
+ let names;
1237
+ try {
1238
+ names = await readdir(directory, { encoding: "buffer" });
1239
+ }
1240
+ catch {
1241
+ throw new CaseInputError(`cannot read ${rel.slice(0, -1)}`);
1242
+ }
1243
+ names.sort(Buffer.compare);
1244
+ for (const raw of names) {
1245
+ seen.n++;
1246
+ if (seen.n > MAX_WALK_ENTRIES) {
1247
+ throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
1248
+ }
1249
+ let name;
1250
+ try {
1251
+ name = nameDecoder.decode(raw);
1252
+ }
1253
+ catch {
1254
+ throw new CaseInputError(`${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`);
1255
+ }
1256
+ const path = join(directory, name);
1257
+ const key = `${rel}${name}`;
1258
+ let s;
1259
+ try {
1260
+ s = await lstat(path);
1261
+ }
1262
+ catch {
1263
+ throw new CaseInputError(`cannot read ${key}`);
1264
+ }
1265
+ if (s.isDirectory()) {
1266
+ if (depth + 1 > MAX_WALK_DEPTH) {
1267
+ throw new CaseInputError(`${arg} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`);
1268
+ }
1269
+ queue.push([path, `${key}/`, depth + 1]);
1270
+ }
1271
+ else {
1272
+ tree.set(key, { fsPath: path, regular: s.isFile() });
1273
+ }
1274
+ }
1275
+ }
1276
+ }
1277
+ // The name the page would see for a dropped folder: the last segment of the
1278
+ // absolute path, so `.` and `pack/` name the directory itself.
1279
+ function baseName(path) {
1280
+ return basename(resolve(path));
1281
+ }
1282
+ /**
1283
+ * Every name under the directories named, keyed `<name>/<path inside>`. Each
1284
+ * argument must be a directory, and not a link to one. A `.zip` is refused
1285
+ * with the way through.
1286
+ */
1287
+ export async function treeFromArguments(paths) {
1288
+ if (paths.length === 0)
1289
+ throw new CaseInputError("case: at least one pack is required");
1290
+ for (const arg of paths) {
1291
+ // `link/` resolves the link; the name without the slash does not.
1292
+ let s;
1293
+ try {
1294
+ s = await lstat(arg.replace(/\/+$/, "") || arg);
1295
+ }
1296
+ catch {
1297
+ throw new CaseInputError(`cannot read ${arg}`);
1298
+ }
1299
+ if (s.isSymbolicLink()) {
1300
+ throw new CaseInputError(`${arg} is a symbolic link, which case does not follow; pass the folder it points to`);
1301
+ }
1302
+ if (s.isDirectory())
1303
+ continue;
1304
+ if (ZIP_NAME.test(arg))
1305
+ throw new CaseInputError(`${arg} is a .zip archive. ${UNZIP_FIRST}`);
1306
+ throw new CaseInputError(`${arg} is not a folder. Pass pack folders, or the folder that holds them.`);
1307
+ }
1308
+ const tree = new Map();
1309
+ const claimed = new Map();
1310
+ const seen = { n: 0 };
1311
+ for (const arg of paths) {
1312
+ const name = baseName(arg);
1313
+ const earlier = claimed.get(name);
1314
+ if (earlier !== undefined) {
1315
+ throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
1316
+ }
1317
+ claimed.set(name, arg);
1318
+ await walk(arg, `${name}/`, arg, tree, seen);
1319
+ }
1320
+ return tree;
1321
+ }
1322
+ // ─── `proof case` (proof#141) ────────────────────────────────────────────────
1323
+ //
1324
+ // The case reading from a terminal, so an auditor holding several packs does
1325
+ // not depend on Sanning's page for the one check the audited party cannot
1326
+ // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
1327
+ // the two commands are compared as child processes by
1328
+ // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
1329
+ // over every `case/` vector.
1330
+ //
1331
+ // The reading is `readCase`, entire. This command splits the input into packs
1332
+ // (the section above, the verify page's rule) and prints what the kernel said.
1333
+ // No color: the two commands print the same bytes.
1334
+ // Exit code per case verdict word. The four codes `verify` uses mean what they
1335
+ // mean there, and `mixed` gets its own, because each code already in use would
1336
+ // say something a mixed case has not established: 0 and 3 would soften a case
1337
+ // that holds a failure, and 1 would accuse a case whose outcomes are all
1338
+ // undetermined.
1339
+ const EXIT_MIXED = 4;
1340
+ export const CASE_EXIT_CODES = {
1341
+ verified: EXIT_VERIFIED,
1342
+ failed: EXIT_FAILED,
1343
+ malformed: EXIT_MALFORMED,
1344
+ incomplete: EXIT_UNDETERMINED,
1345
+ partial: EXIT_UNDETERMINED,
1346
+ mixed: EXIT_MIXED,
1347
+ };
1348
+ export const CASE_USAGE = `sanning-proof case -- one verdict over several evidence packs, offline
1349
+
1350
+ sanning-proof case <folder>... [--gateways <url,url>] [--json]
1351
+
1352
+ <folder> a pack folder (bundle.json plus logs/), or a folder that holds
1353
+ several packs, such as an unpacked decision file. case reads
1354
+ folders only: given a .zip, it exits 2 and says to unzip it
1355
+ first (for example, unzip file.zip -d case/) and pass the folder.
1356
+ Packs are found by the verify page's splitting rule: every
1357
+ bundle.json is a pack, a file belongs to the deepest pack whose
1358
+ bundle.json or logs/ it sits in, and every other file is listed
1359
+ as outside every pack and never opened. A .zip inside a folder is
1360
+ a file like any other. A symbolic link is never followed: it is
1361
+ listed as outside and not read, even under a pack's logs/.
1362
+ Several folders are read as one folder holding them, each under
1363
+ its own name. A folder nested more than 32 levels deep, more than
1364
+ 50000 files and folders in all, or a name that is not valid UTF-8
1365
+ exits 2.
1366
+ --gateways comma-separated gateway URLs. Each pack's witnesses are then
1367
+ re-fetched on chain, pack by pack, and reported beside the case
1368
+ reading. Without it the on-chain half is NOT checked, and the
1369
+ output says so.
1370
+ --json the full reading as JSON: the verdict, every exception with its
1371
+ rendered line, every record, every contribution, the packs, the
1372
+ files outside them, and the on-chain half.
1373
+
1374
+ Each pack's bundle.json is read as verify reads it, so the two commands
1375
+ classify one bundle the same way.
1376
+
1377
+ What a case reading establishes: every pack is verified offline by this
1378
+ kernel; every record every pack supplied is read, in one pile, under the
1379
+ hand-off rules of envelope-spec section 4.1; a hand-off record with no
1380
+ counterpart among the case's records is always named, and when a pack signed by
1381
+ the key it names is in the case, the reading says that pack holds none, unless
1382
+ every candidate it had was accounted for by another record (A20), which reads
1383
+ as no counterpart and names no pack.
1384
+
1385
+ What it does not establish: that the case is complete (a party can decline to
1386
+ hand over a pack); that a party which presented no pack under the named key
1387
+ recorded nothing; that two organizations are independent of each other; or
1388
+ that any witness was re-fetched on chain, which the reading never does.
1389
+
1390
+ Exit codes: 0 verified · 1 failed · 2 malformed, usage, an input that cannot
1391
+ be read as a case, or an unexpected error in this command · 3 incomplete or
1392
+ partial (undetermined, not failed) · 4 mixed (more than one outcome: read the
1393
+ exceptions). With --gateways, a witness that does not match on chain exits 1,
1394
+ and an on-chain half that could not be settled turns 0 into 3.`;
1395
+ // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
1396
+ // rather than left to the stream, so the lines a test captures in process are
1397
+ // the lines a reader sees.
1398
+ function noLoneSurrogates(text) {
1399
+ return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
1400
+ }
1401
+ // Keys sorted at every depth, arrays left in their order: the Python twin
1402
+ // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
1403
+ // that, byte for byte.
1404
+ function sortKeys(v) {
1405
+ if (Array.isArray(v))
1406
+ return v.map(sortKeys);
1407
+ if (v !== null && typeof v === "object") {
1408
+ return Object.fromEntries(Object.keys(v)
1409
+ .sort()
1410
+ .map((k) => [k, sortKeys(v[k])]));
1411
+ }
1412
+ return v;
1413
+ }
1414
+ function jsonText(v) {
1415
+ return JSON.stringify(sortKeys(v), null, 2);
1416
+ }
1417
+ // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1418
+ // case reading on purpose: `readCase` makes no network call, and neither does
1419
+ // the verify page's case reading, so the case verdict stays the one both
1420
+ // surfaces compute. This adds the half the reading names as not checked.
1421
+ async function onChainHalf(packs, gateways, io) {
1422
+ if (gateways.length === 0)
1423
+ return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1424
+ const rows = [];
1425
+ for (const [ref, bundle] of packs) {
1426
+ const result = await verifyEvidenceBundle(bundle, {
1427
+ gateways,
1428
+ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1429
+ });
1430
+ const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1431
+ const row = {
1432
+ ref,
1433
+ witnesses: witnesses.length,
1434
+ confirmed: 0,
1435
+ mismatched: 0,
1436
+ unreachable: 0,
1437
+ not_checked: 0,
1438
+ };
1439
+ for (const w of witnesses) {
1440
+ const rollup = w.onChain?.rollup ?? null;
1441
+ if (rollup === "confirm")
1442
+ row.confirmed++;
1443
+ else if (rollup === "mismatch")
1444
+ row.mismatched++;
1445
+ else if (rollup === "unreachable")
1446
+ row.unreachable++;
1447
+ else
1448
+ row.not_checked++;
1449
+ }
1450
+ rows.push(row);
1451
+ }
1452
+ const verdict = rows.some((r) => r.mismatched > 0)
1453
+ ? "failed"
1454
+ : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1455
+ ? "undetermined"
1456
+ : "confirmed";
1457
+ return { checked: true, gateways: [...gateways], packs: rows, verdict };
1458
+ }
1459
+ /**
1460
+ * The case verdict's code, moved only by an on-chain half that was asked for:
1461
+ * a witness that does not match is a failure whatever the reading said, and an
1462
+ * on-chain half that could not be settled is never a clean 0.
1463
+ */
1464
+ export function caseExitCode(verdict, onChainVerdict) {
1465
+ const code = CASE_EXIT_CODES[verdict] ?? EXIT_MALFORMED;
1466
+ if (onChainVerdict === "failed")
1467
+ return EXIT_FAILED;
1468
+ if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
1469
+ return EXIT_UNDETERMINED;
1470
+ return code;
1471
+ }
1472
+ function caseReport(data) {
1473
+ const lines = [data.verdict.toUpperCase(), ` ${data.summary}`];
1474
+ for (const e of data.exceptions)
1475
+ lines.push(` - ${e.rendered}`);
1476
+ lines.push(`packs ${data.packs.length}`);
1477
+ for (const p of data.packs) {
1478
+ let line = ` ${p.ref} ${p.log_files} record file(s) under logs/`;
1479
+ if (p.unaccounted.length > 0) {
1480
+ line +=
1481
+ `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
1482
+ `record: ${p.unaccounted.join(", ")}`;
1483
+ }
1484
+ lines.push(line);
1485
+ }
1486
+ if (data.outside.length > 0) {
1487
+ lines.push(`outside ${data.outside.length} file(s) sit outside every ` +
1488
+ "pack, so no verdict here is about them");
1489
+ const links = new Set(data.not_regular);
1490
+ for (const path of data.outside) {
1491
+ lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
1492
+ }
1493
+ }
1494
+ const oc = data.on_chain;
1495
+ if (!oc.checked) {
1496
+ lines.push("on-chain NOT CHECKED: no gateway was supplied, so no witness " +
1497
+ "any pack names was re-fetched. Name gateways with --gateways to " +
1498
+ "check them.");
1499
+ }
1500
+ else {
1501
+ lines.push(`on-chain re-fetched against ${oc.gateways.length} gateway(s), ` +
1502
+ "pack by pack; the case reading above is offline and this does not " +
1503
+ "change it");
1504
+ for (const r of oc.packs) {
1505
+ lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
1506
+ `confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
1507
+ `unreachable, ${r.not_checked} not checked`);
1508
+ }
1509
+ lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
1510
+ }
1511
+ return lines;
1512
+ }
1513
+ function caseError(e, asJson, io) {
1514
+ if (asJson)
1515
+ io.out(jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
1516
+ io.err(noLoneSurrogates(`case: ${e.message}`));
1517
+ if (e.hint)
1518
+ io.err(noLoneSurrogates(`case: ${e.hint}`));
1519
+ return EXIT_MALFORMED;
1520
+ }
1521
+ /**
1522
+ * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
1523
+ * command is an exit code from the table: an exception nobody expected exits 2,
1524
+ * never 1, because 1 would name the evidence failed over a defect in this
1525
+ * command.
1526
+ */
1527
+ export async function runCase(argv, io) {
1528
+ try {
1529
+ return await caseCommand(argv, io);
1530
+ }
1531
+ catch (e) {
1532
+ const name = e instanceof Error ? e.constructor.name : typeof e;
1533
+ try {
1534
+ return caseError(new CaseInputError(`an unexpected error stopped the reading (${name}); this is a defect in the ` +
1535
+ "command, not a verdict on the evidence"), argv.includes("--json"), io);
1536
+ }
1537
+ catch {
1538
+ return EXIT_MALFORMED; // a stream that cannot be written
1539
+ }
1540
+ }
1541
+ }
1542
+ async function caseCommand(argv, io) {
1543
+ let asJson = false;
1544
+ let gateways = [];
1545
+ const paths = [];
1546
+ for (let i = 0; i < argv.length; i++) {
1547
+ const arg = argv[i];
1548
+ if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
1549
+ io.out(CASE_USAGE);
1550
+ return EXIT_VERIFIED;
1551
+ }
1552
+ if (arg === "--json") {
1553
+ asJson = true;
1554
+ }
1555
+ else if (arg === "--gateways") {
1556
+ const next = argv[i + 1];
1557
+ if (next === undefined) {
1558
+ io.err("case: --gateways requires a comma-separated list of URLs");
1559
+ io.out(CASE_USAGE);
1560
+ return EXIT_MALFORMED;
1561
+ }
1562
+ gateways = next
1563
+ .split(",")
1564
+ .map((g) => g.trim())
1565
+ .filter((g) => g.length > 0);
1566
+ i++;
1567
+ }
1568
+ else if (arg.startsWith("--")) {
1569
+ io.err(`case: unknown option ${arg}`);
1570
+ io.out(CASE_USAGE);
1571
+ return EXIT_MALFORMED;
1572
+ }
1573
+ else {
1574
+ paths.push(arg);
1575
+ }
1576
+ }
1577
+ if (paths.length === 0) {
1578
+ io.err("case: at least one pack is required");
1579
+ io.out(CASE_USAGE);
1580
+ return EXIT_MALFORMED;
1581
+ }
1582
+ let split;
1583
+ let bundles;
1584
+ try {
1585
+ split = await splitCase(await treeFromArguments(paths), readFileEntry);
1586
+ bundles = split.packs.map((p) => [p, loadBundle(p)]);
1587
+ }
1588
+ catch (e) {
1589
+ if (e instanceof CaseInputError)
1590
+ return caseError(e, asJson, io);
1591
+ throw e;
1592
+ }
1593
+ const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
1594
+ const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
1595
+ const code = caseExitCode(reading.verdict, onChain.verdict);
1596
+ const nil = (v) => (v === undefined ? null : v);
1597
+ const data = {
1598
+ verdict: reading.verdict,
1599
+ exit_code: code,
1600
+ summary: renderCaseSummary(reading),
1601
+ handoff_records: reading.handoffRecords,
1602
+ on_chain_checked: reading.onChainChecked,
1603
+ exceptions: reading.exceptions.map((e) => ({
1604
+ kind: e.kind,
1605
+ pack_ref: nil(e.packRef),
1606
+ event_id: nil(e.eventId),
1607
+ counterparty_key: nil(e.counterpartyKey),
1608
+ failed_checks: e.failedChecks ?? [],
1609
+ count: nil(e.count),
1610
+ status: nil(e.status),
1611
+ reasons: e.reasons ?? [],
1612
+ rendered: renderException(e),
1613
+ })),
1614
+ records: reading.records.map((r) => ({
1615
+ pack_ref: r.packRef,
1616
+ event_id: r.eventId,
1617
+ verdict: nil(r.verdict),
1618
+ findings: r.findings,
1619
+ failed_checks: r.failedChecks,
1620
+ counterparts: r.counterparts,
1621
+ })),
1622
+ contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
1623
+ packs: bundles.map(([p]) => ({
1624
+ ref: p.ref,
1625
+ bundle_path: p.bundlePath,
1626
+ log_files: p.content.size,
1627
+ unaccounted: p.unaccounted,
1628
+ })),
1629
+ outside: split.outside,
1630
+ not_regular: split.notRegular,
1631
+ on_chain: onChain,
1632
+ };
1633
+ if (asJson)
1634
+ io.out(jsonText(data));
1635
+ else
1636
+ for (const line of caseReport(data))
1637
+ io.out(noLoneSurrogates(line));
1638
+ return code;
1639
+ }
945
1640
  function shortTx(tx) {
946
1641
  if (tx.length <= 16)
947
1642
  return tx;