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