@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/README.md +22 -0
- package/dist/case.d.ts +25 -16
- package/dist/case.js +53 -23
- package/dist/cli.d.ts +98 -0
- package/dist/cli.js +701 -6
- package/dist/handoff.d.ts +16 -1
- package/dist/handoff.js +91 -16
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/src/case.ts +65 -24
- package/src/cli.ts +787 -7
- package/src/handoff.ts +108 -17
- package/src/index.ts +2 -1
- package/vectors/README.md +35 -0
- package/vectors/case-split.json +893 -0
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:
|
|
164
|
+
let raw: Uint8Array;
|
|
161
165
|
try {
|
|
162
|
-
raw = await readFile(bundlePath
|
|
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 =
|
|
177
|
+
parsed = readBundleBytes(raw);
|
|
171
178
|
} catch (e) {
|
|
172
|
-
|
|
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)}`;
|