@sanning/proof 0.12.0 → 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 +25 -12
- 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 +173 -26
- package/dist/case.js +469 -123
- package/dist/cli.d.ts +38 -5
- package/dist/cli.js +557 -208
- 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/index.d.ts +10 -7
- package/dist/index.js +13 -6
- 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 +586 -132
- package/src/cli.ts +651 -206
- 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/index.ts +58 -2
- 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 +23 -5
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +3 -3
package/dist/cli.js
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
|
|
@@ -36,28 +43,83 @@
|
|
|
36
43
|
// checked, and exit 3 is how this command says "undetermined".
|
|
37
44
|
import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
38
45
|
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
46
|
+
import { randomBytes } from "node:crypto";
|
|
39
47
|
import { basename, join, resolve } from "node:path";
|
|
40
48
|
import { fileURLToPath } from "node:url";
|
|
41
|
-
import { isAgentProofSpec,
|
|
49
|
+
import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
|
|
42
50
|
import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
|
|
43
|
-
import { readCase, renderCaseSummary, renderException } from "./case.js";
|
|
51
|
+
import { CASE_RELIANCE, VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
|
|
52
|
+
import { CASE_FILE_INDEX_NAME, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
|
|
53
|
+
import { ZipReadError, readZip } from "./zip-reader.js";
|
|
44
54
|
import { composeExport } from "./compose.js";
|
|
45
|
-
import { isHexField, utf8 } from "./crypto.js";
|
|
55
|
+
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
46
56
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
47
57
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
48
58
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
|
|
60
|
+
// ── What a person reads is escaped at one choke point ──
|
|
61
|
+
//
|
|
62
|
+
// Every human-readable line this command prints passes through `guard`, which
|
|
63
|
+
// `runCli` and `runCase` apply to the streams they are handed. It escapes each
|
|
64
|
+
// control character, so a value the audited party wrote (a folder or file
|
|
65
|
+
// name, a `contents.json` field, a zip entry name, an exception built from
|
|
66
|
+
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
67
|
+
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
68
|
+
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
69
|
+
// only, which escapes its own control characters. The twin is `Io` in
|
|
70
|
+
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
71
|
+
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
72
|
+
// embedding, override and isolate controls.
|
|
73
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
74
|
+
/** Each control character in `text` as a visible escape: `\x1b` for ESC,
|
|
75
|
+
* `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
|
|
76
|
+
* backslash included, so a line with none is printed as it was. */
|
|
77
|
+
export function escapeControl(text) {
|
|
78
|
+
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
79
|
+
const code = c.charCodeAt(0);
|
|
80
|
+
return code < 0x100
|
|
81
|
+
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
82
|
+
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// The colors this command writes on a TTY are its own control characters, and
|
|
86
|
+
// the choke point would escape them with everything else. So `paint` and
|
|
87
|
+
// `mark` write this marker instead, and the choke point turns it into ESC
|
|
88
|
+
// after escaping. It is random per process, so no input can spell it.
|
|
89
|
+
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
90
|
+
const G = `${COLOR_MARK}32m`;
|
|
91
|
+
const R = `${COLOR_MARK}31m`;
|
|
92
|
+
const Y = `${COLOR_MARK}33m`;
|
|
93
|
+
const DIM = `${COLOR_MARK}2m`;
|
|
94
|
+
const B = `${COLOR_MARK}1m`;
|
|
95
|
+
const X = `${COLOR_MARK}0m`;
|
|
96
|
+
function forTerminal(text) {
|
|
97
|
+
return escapeControl(text).split(COLOR_MARK).join("\x1b[");
|
|
98
|
+
}
|
|
99
|
+
const RAW = Symbol("sanning.cli.raw");
|
|
100
|
+
function guard(io) {
|
|
101
|
+
if (RAW in io)
|
|
102
|
+
return io;
|
|
103
|
+
const guarded = {
|
|
104
|
+
out: (s) => io.out(forTerminal(s)),
|
|
105
|
+
err: (s) => io.err(forTerminal(s)),
|
|
106
|
+
[RAW]: io,
|
|
107
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
108
|
+
};
|
|
109
|
+
return guarded;
|
|
110
|
+
}
|
|
111
|
+
function rawOf(io) {
|
|
112
|
+
return io[RAW] ?? io;
|
|
113
|
+
}
|
|
114
|
+
/** JSON, printed unescaped: a script reads it, not a terminal. */
|
|
115
|
+
function outJson(io, text) {
|
|
116
|
+
rawOf(io).out(text);
|
|
117
|
+
}
|
|
118
|
+
/** A block this command wrote itself, such as a usage text: its own newlines
|
|
119
|
+
* are kept, and each line is escaped as `out` escapes it. */
|
|
120
|
+
function outBlock(io, text) {
|
|
121
|
+
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
122
|
+
}
|
|
61
123
|
function useColor() {
|
|
62
124
|
// Honor NO_COLOR; otherwise color only a TTY.
|
|
63
125
|
if (process.env.NO_COLOR)
|
|
@@ -75,7 +137,8 @@ function mark(ok) {
|
|
|
75
137
|
function paint(s, c) {
|
|
76
138
|
return useColor() ? `${c}${s}${X}` : s;
|
|
77
139
|
}
|
|
78
|
-
export async function runCli(argv,
|
|
140
|
+
export async function runCli(argv, stream) {
|
|
141
|
+
const io = guard(stream);
|
|
79
142
|
const [command, ...rest] = argv;
|
|
80
143
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
81
144
|
printUsage(io);
|
|
@@ -85,11 +148,15 @@ export async function runCli(argv, io) {
|
|
|
85
148
|
io.out("@sanning/proof verify CLI");
|
|
86
149
|
return EXIT_VERIFIED;
|
|
87
150
|
}
|
|
88
|
-
// `
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`). It
|
|
152
|
+
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
153
|
+
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
154
|
+
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
155
|
+
if (command === "export") {
|
|
156
|
+
if (rest.includes("--json")) {
|
|
157
|
+
return runJson(rest.filter((a) => a !== "--json"), io, runExport);
|
|
158
|
+
}
|
|
159
|
+
return runExport(rest, io, null);
|
|
93
160
|
}
|
|
94
161
|
if (command === "case") {
|
|
95
162
|
return runCase(rest, io);
|
|
@@ -99,13 +166,54 @@ export async function runCli(argv, io) {
|
|
|
99
166
|
printUsage(io);
|
|
100
167
|
return EXIT_MALFORMED;
|
|
101
168
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
if (rest.includes("--json")) {
|
|
170
|
+
return runJson(rest.filter((a) => a !== "--json"), io, runVerify);
|
|
171
|
+
}
|
|
172
|
+
return runVerify(rest, io, null);
|
|
173
|
+
}
|
|
174
|
+
async function runJson(rest, io, verb) {
|
|
175
|
+
const errs = [];
|
|
176
|
+
const sink = { data: null };
|
|
177
|
+
const quiet = {
|
|
178
|
+
out: () => { },
|
|
179
|
+
err: (s) => errs.push(s),
|
|
180
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
181
|
+
};
|
|
182
|
+
const code = await verb(rest, quiet, sink);
|
|
183
|
+
const notes = errs.filter((l) => l.startsWith("note: "));
|
|
184
|
+
for (const n of notes)
|
|
185
|
+
io.err(n);
|
|
186
|
+
if (sink.data !== null) {
|
|
187
|
+
outJson(io, jsonText({ ...sink.data, exit_code: code }));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
|
|
191
|
+
outJson(io, jsonText({ error, exit_code: code }));
|
|
192
|
+
}
|
|
193
|
+
return code;
|
|
194
|
+
}
|
|
195
|
+
async function runVerify(rest, io, sink) {
|
|
196
|
+
// `--logs <path>` feeds disclosed raw-log bytes for content verification
|
|
197
|
+
// (the evidence-bundle path only); `--gateways <url,url>` asks for the
|
|
198
|
+
// on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
|
|
199
|
+
// second positional argument until then, and a second positional is now a
|
|
200
|
+
// usage error). The one positional is the bundle.
|
|
105
201
|
let logsPath;
|
|
202
|
+
let gatewayArg;
|
|
106
203
|
const positional = [];
|
|
107
204
|
for (let i = 0; i < rest.length; i++) {
|
|
108
205
|
const a = rest[i];
|
|
206
|
+
if (a === "--gateways") {
|
|
207
|
+
const next = rest[i + 1];
|
|
208
|
+
if (next === undefined) {
|
|
209
|
+
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
210
|
+
printUsage(io);
|
|
211
|
+
return EXIT_MALFORMED;
|
|
212
|
+
}
|
|
213
|
+
gatewayArg = next;
|
|
214
|
+
i++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
109
217
|
if (a === "--logs") {
|
|
110
218
|
const next = rest[i + 1];
|
|
111
219
|
if (next === undefined) {
|
|
@@ -119,12 +227,17 @@ export async function runCli(argv, io) {
|
|
|
119
227
|
}
|
|
120
228
|
positional.push(a);
|
|
121
229
|
}
|
|
122
|
-
const [bundlePath
|
|
230
|
+
const [bundlePath] = positional;
|
|
123
231
|
if (!bundlePath) {
|
|
124
232
|
io.err("verify: a bundle file path is required");
|
|
125
233
|
printUsage(io);
|
|
126
234
|
return EXIT_MALFORMED;
|
|
127
235
|
}
|
|
236
|
+
if (positional.length > 1) {
|
|
237
|
+
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
238
|
+
printUsage(io);
|
|
239
|
+
return EXIT_MALFORMED;
|
|
240
|
+
}
|
|
128
241
|
const gateways = (gatewayArg ?? "")
|
|
129
242
|
.split(",")
|
|
130
243
|
.map((g) => g.trim())
|
|
@@ -171,11 +284,11 @@ export async function runCli(argv, io) {
|
|
|
171
284
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
172
285
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
173
286
|
}
|
|
174
|
-
const result = await
|
|
287
|
+
const result = await verifyAgentProof(parsed, {
|
|
175
288
|
gateways,
|
|
176
289
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
177
290
|
});
|
|
178
|
-
return reportAgentProof(result, bundlePath, gateways, io);
|
|
291
|
+
return reportAgentProof(result, bundlePath, gateways, io, sink);
|
|
179
292
|
}
|
|
180
293
|
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
181
294
|
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
@@ -204,26 +317,29 @@ export async function runCli(argv, io) {
|
|
|
204
317
|
catch (e) {
|
|
205
318
|
chains = { unreadable: errMsg(e) };
|
|
206
319
|
}
|
|
207
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
|
|
320
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
208
321
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
209
322
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
210
323
|
if (logs && result.status !== "malformed")
|
|
211
324
|
reportLogsCoverage(logs, result, io);
|
|
212
325
|
return exit;
|
|
213
326
|
}
|
|
214
|
-
// `proof
|
|
215
|
-
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
327
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
216
328
|
//
|
|
217
329
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
218
330
|
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
219
|
-
// Ed25519 key (evidence-export.md §5, composer). Writes the export to
|
|
331
|
+
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
|
|
220
332
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
221
333
|
//
|
|
222
334
|
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
223
335
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
224
336
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
225
337
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
226
|
-
async function
|
|
338
|
+
async function runExport(rest, io, sink) {
|
|
339
|
+
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
340
|
+
usageExport(io);
|
|
341
|
+
return EXIT_VERIFIED;
|
|
342
|
+
}
|
|
227
343
|
let attestationsPath;
|
|
228
344
|
let keyPath;
|
|
229
345
|
let outPath;
|
|
@@ -240,19 +356,19 @@ async function runBundle(rest, io) {
|
|
|
240
356
|
i++;
|
|
241
357
|
return next;
|
|
242
358
|
};
|
|
243
|
-
if (a === "--attestations"
|
|
359
|
+
if (a === "--attestations") {
|
|
244
360
|
const v = takeNext(a);
|
|
245
361
|
if (v === undefined)
|
|
246
362
|
return usageExport(io);
|
|
247
363
|
attestationsPath = v;
|
|
248
364
|
}
|
|
249
|
-
else if (a === "--key"
|
|
365
|
+
else if (a === "--key") {
|
|
250
366
|
const v = takeNext(a);
|
|
251
367
|
if (v === undefined)
|
|
252
368
|
return usageExport(io);
|
|
253
369
|
keyPath = v;
|
|
254
370
|
}
|
|
255
|
-
else if (a === "--out"
|
|
371
|
+
else if (a === "--out") {
|
|
256
372
|
const v = takeNext(a);
|
|
257
373
|
if (v === undefined)
|
|
258
374
|
return usageExport(io);
|
|
@@ -282,6 +398,13 @@ async function runBundle(rest, io) {
|
|
|
282
398
|
return usageExport(io);
|
|
283
399
|
opts.generatedAt = v;
|
|
284
400
|
}
|
|
401
|
+
else if (a.startsWith("-")) {
|
|
402
|
+
// A flag this verb does not take is a usage error, never a path: `-o`
|
|
403
|
+
// was a short form of `--out` until 0.13.0, and read as a path it would
|
|
404
|
+
// send the export to stdout while the caller waits for a file.
|
|
405
|
+
io.err(`export: unknown option ${a}`);
|
|
406
|
+
return usageExport(io);
|
|
407
|
+
}
|
|
285
408
|
else {
|
|
286
409
|
positional.push(a);
|
|
287
410
|
}
|
|
@@ -326,6 +449,18 @@ async function runBundle(rest, io) {
|
|
|
326
449
|
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
327
450
|
// still writes the artifact so the caller can inspect it.
|
|
328
451
|
const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
|
|
452
|
+
if (sink) {
|
|
453
|
+
sink.data = {
|
|
454
|
+
status: composed.status,
|
|
455
|
+
out: outPath ?? null,
|
|
456
|
+
source_bundle_hash: composed.sourceBundleHash,
|
|
457
|
+
attestations: attestations.length,
|
|
458
|
+
attestations_bound: composed.boundAttestations,
|
|
459
|
+
// Without --out the artifact rides inside this object, since stdout
|
|
460
|
+
// carries the object and not the artifact.
|
|
461
|
+
export: outPath ? null : composed.bundle,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
329
464
|
if (outPath) {
|
|
330
465
|
try {
|
|
331
466
|
await writeFile(outPath, serialized, "utf8");
|
|
@@ -339,25 +474,32 @@ async function runBundle(rest, io) {
|
|
|
339
474
|
`attestations ${composed.boundAttestations}/${attestations.length} bound`);
|
|
340
475
|
}
|
|
341
476
|
else {
|
|
342
|
-
io
|
|
477
|
+
outJson(io, serialized.replace(/\n$/, ""));
|
|
343
478
|
}
|
|
344
479
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
345
480
|
}
|
|
346
481
|
function usageExport(io) {
|
|
347
|
-
io.out("Usage:
|
|
482
|
+
io.out("Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]");
|
|
348
483
|
io.out("");
|
|
349
484
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
350
485
|
io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
|
|
351
|
-
io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
|
|
486
|
+
io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
|
|
352
487
|
io.out("");
|
|
353
488
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
354
489
|
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
355
490
|
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
356
|
-
io.out("
|
|
491
|
+
io.out("--out <file> Write the export here (default: stdout).");
|
|
357
492
|
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
358
493
|
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
359
494
|
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
360
495
|
io.out("--generated-at <ts> RFC 3339 compose time (default now).");
|
|
496
|
+
io.out("--json Print a summary as JSON (with the export inside it");
|
|
497
|
+
io.out(" when --out is not given).");
|
|
498
|
+
io.out("");
|
|
499
|
+
for (const line of EXIT_CODES_HELP.split("\n"))
|
|
500
|
+
io.out(line);
|
|
501
|
+
io.out("");
|
|
502
|
+
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
361
503
|
return EXIT_MALFORMED;
|
|
362
504
|
}
|
|
363
505
|
// Read + parse a JSON file; report a clear error and return undefined on any
|
|
@@ -585,7 +727,8 @@ function reportLogsCoverage(logs, result, io) {
|
|
|
585
727
|
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
586
728
|
if (undetermined.length > 0) {
|
|
587
729
|
io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
588
|
-
`content
|
|
730
|
+
`content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
731
|
+
`hand-off record) to bind them to. ` +
|
|
589
732
|
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
|
|
590
733
|
}
|
|
591
734
|
}
|
|
@@ -628,32 +771,39 @@ function decodeLogValue(v) {
|
|
|
628
771
|
return utf8(v); // raw utf8 bytes
|
|
629
772
|
}
|
|
630
773
|
function printUsage(io) {
|
|
631
|
-
io.out("Usage:
|
|
774
|
+
io.out("Usage: sanning-proof <command> [args]");
|
|
775
|
+
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
632
776
|
io.out("");
|
|
633
777
|
io.out("Commands:");
|
|
634
|
-
io.out(" verify <bundle.json> [gateways] [--logs <path>]
|
|
635
|
-
io.out("
|
|
778
|
+
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
|
|
779
|
+
io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
|
|
780
|
+
io.out(" Create an attested export");
|
|
636
781
|
io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
|
|
637
782
|
io.out(" (`case --help` for its exit codes)");
|
|
638
783
|
io.out("");
|
|
639
784
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
640
785
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
641
786
|
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
642
|
-
io.out("fully offline.
|
|
643
|
-
io.out("
|
|
787
|
+
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
788
|
+
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
644
789
|
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
645
790
|
io.out("");
|
|
791
|
+
io.out("--json Print the verdict, the causes and the exit code as");
|
|
792
|
+
io.out(" JSON, for a script, instead of the report.");
|
|
793
|
+
io.out("");
|
|
646
794
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
647
|
-
io.out(" content_hash
|
|
795
|
+
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
796
|
+
io.out(" Either shape works, and which one you");
|
|
648
797
|
io.out(" passed is decided by stat(), not by the extension:");
|
|
649
798
|
io.out("");
|
|
650
799
|
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
651
800
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
652
801
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
653
|
-
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
802
|
+
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
654
803
|
io.out("");
|
|
655
804
|
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
656
|
-
io.out(" even-length
|
|
805
|
+
io.out(" even-length hex value, in either case, ⇒ hex,");
|
|
806
|
+
io.out(" else utf8).");
|
|
657
807
|
io.out("");
|
|
658
808
|
io.out(" In-body events[].content is verified automatically");
|
|
659
809
|
io.out(" and takes precedence. An event with no disclosed");
|
|
@@ -663,8 +813,11 @@ function printUsage(io) {
|
|
|
663
813
|
io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
|
|
664
814
|
io.out("bundle itself is verified. Both verdicts are printed when they differ.");
|
|
665
815
|
io.out("");
|
|
666
|
-
|
|
667
|
-
|
|
816
|
+
for (const line of EXIT_CODES_HELP.split("\n"))
|
|
817
|
+
io.out(line);
|
|
818
|
+
io.out("");
|
|
819
|
+
io.out("verify: an unreachable gateway reads partial, and a pack with a record");
|
|
820
|
+
io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
|
|
668
821
|
}
|
|
669
822
|
/**
|
|
670
823
|
* The disclosure account this command can honestly make, from what the VERIFIER
|
|
@@ -699,8 +852,10 @@ function cliDisclosureAccount(result, logs) {
|
|
|
699
852
|
extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
|
|
700
853
|
};
|
|
701
854
|
}
|
|
702
|
-
function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
855
|
+
function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
|
|
703
856
|
if (result.status === "malformed") {
|
|
857
|
+
if (sink)
|
|
858
|
+
sink.data = verifyJson(result, null, null, null);
|
|
704
859
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
705
860
|
for (const e of result.errors)
|
|
706
861
|
io.err(` - ${e}`);
|
|
@@ -784,7 +939,7 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
784
939
|
// answers `partial` and the line below says so in words, and pack grading
|
|
785
940
|
// keeps its own verdict: every pack built before §5.4 carries no
|
|
786
941
|
// declaration, and re-grading them all as undetermined would be a change to
|
|
787
|
-
//
|
|
942
|
+
// their verdicts rather than a new check. A pack that DOES
|
|
788
943
|
// carry a declaration this reader cannot read is a different case and exits
|
|
789
944
|
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
790
945
|
const declaration = checkDeclaration(parsed, logs?.content);
|
|
@@ -808,7 +963,42 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
808
963
|
if (result.events.length > 0) {
|
|
809
964
|
io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
|
|
810
965
|
}
|
|
811
|
-
|
|
966
|
+
if (sink)
|
|
967
|
+
sink.data = verifyJson(result, grade, declaration, chains);
|
|
968
|
+
return mapGradeToExit(grade);
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
972
|
+
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
973
|
+
* `chains` are null for a bundle no verdict could be rendered over.
|
|
974
|
+
*/
|
|
975
|
+
function verifyJson(result, grade, declaration, chains) {
|
|
976
|
+
let chainBlock = null;
|
|
977
|
+
if (chains !== null) {
|
|
978
|
+
chainBlock =
|
|
979
|
+
"unreadable" in chains
|
|
980
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
981
|
+
: {
|
|
982
|
+
unreadable: null,
|
|
983
|
+
chains: chains.chains.length,
|
|
984
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
verdict: grade ? grade.verdict : result.status,
|
|
989
|
+
bundle_status: result.status,
|
|
990
|
+
asserted_status: result.assertedStatus ?? null,
|
|
991
|
+
body_type: result.bodyType ?? null,
|
|
992
|
+
on_chain_checked: result.onChainChecked,
|
|
993
|
+
declaration: declaration ? declaration.verdict : null,
|
|
994
|
+
declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
|
|
995
|
+
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
996
|
+
notes: [...result.errors],
|
|
997
|
+
records: grade
|
|
998
|
+
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
999
|
+
: null,
|
|
1000
|
+
chains: chainBlock,
|
|
1001
|
+
};
|
|
812
1002
|
}
|
|
813
1003
|
/**
|
|
814
1004
|
* The chain block: what following each record's pointer to the record before it
|
|
@@ -825,8 +1015,8 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
825
1015
|
* complete" does not follow, and two customer-facing documents had already
|
|
826
1016
|
* made that leap. The limit travels with the result or it gets dropped.
|
|
827
1017
|
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
828
|
-
* the verdict surface, and the
|
|
829
|
-
*
|
|
1018
|
+
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1019
|
+
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
830
1020
|
* decision this check is entitled to make.
|
|
831
1021
|
*/
|
|
832
1022
|
function reportChains(reading, io) {
|
|
@@ -868,7 +1058,16 @@ function reportExport(exp, io) {
|
|
|
868
1058
|
}
|
|
869
1059
|
}
|
|
870
1060
|
}
|
|
871
|
-
function reportAgentProof(result, path, gateways, io) {
|
|
1061
|
+
function reportAgentProof(result, path, gateways, io, sink) {
|
|
1062
|
+
if (sink) {
|
|
1063
|
+
sink.data = {
|
|
1064
|
+
verdict: result.status,
|
|
1065
|
+
bundle_status: result.status,
|
|
1066
|
+
spec_version: "sanning.agent.proof/v1",
|
|
1067
|
+
on_chain_checked: result.onChainChecked,
|
|
1068
|
+
notes: [...result.errors],
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
872
1071
|
if (result.status === "malformed") {
|
|
873
1072
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
874
1073
|
for (const e of result.errors)
|
|
@@ -886,7 +1085,7 @@ function reportAgentProof(result, path, gateways, io) {
|
|
|
886
1085
|
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
|
|
887
1086
|
for (const e of result.errors)
|
|
888
1087
|
io.out(paint(` note: ${e}`, DIM));
|
|
889
|
-
return
|
|
1088
|
+
return exitCodeFor(result.status);
|
|
890
1089
|
}
|
|
891
1090
|
function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
|
|
892
1091
|
const scope = onChainChecked
|
|
@@ -925,36 +1124,15 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
|
|
|
925
1124
|
}
|
|
926
1125
|
}
|
|
927
1126
|
/**
|
|
928
|
-
* The exit code
|
|
1127
|
+
* The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
|
|
929
1128
|
*
|
|
930
|
-
* `incomplete`
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1129
|
+
* `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
|
|
1130
|
+
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1131
|
+
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1132
|
+
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
933
1133
|
*/
|
|
934
|
-
function mapGradeToExit(grade
|
|
935
|
-
|
|
936
|
-
return EXIT_MALFORMED;
|
|
937
|
-
if (grade.verdict === "failed")
|
|
938
|
-
return EXIT_FAILED;
|
|
939
|
-
if (grade.verdict === "incomplete")
|
|
940
|
-
return EXIT_UNDETERMINED;
|
|
941
|
-
return mapStatusToExit(result.status, result.errors);
|
|
942
|
-
}
|
|
943
|
-
function mapStatusToExit(status, errors) {
|
|
944
|
-
if (status === "failed")
|
|
945
|
-
return EXIT_FAILED;
|
|
946
|
-
if (status === "malformed")
|
|
947
|
-
return EXIT_MALFORMED;
|
|
948
|
-
// "partial" caused specifically by an unreachable gateway — or an export
|
|
949
|
-
// source_bundle_ref whose bytes are unavailable offline (evidence-export.md
|
|
950
|
-
// §5 step 3) — maps to exit 3 (undetermined, network-dependent).
|
|
951
|
-
if (status === "partial" &&
|
|
952
|
-
errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))) {
|
|
953
|
-
return EXIT_UNDETERMINED;
|
|
954
|
-
}
|
|
955
|
-
// "verified" → 0. "partial" from a withheld record (semantics-undetermined,
|
|
956
|
-
// not a failure) also exits 0 — the bundle is cryptographically sound.
|
|
957
|
-
return EXIT_VERIFIED;
|
|
1134
|
+
function mapGradeToExit(grade) {
|
|
1135
|
+
return exitCodeFor(grade.verdict);
|
|
958
1136
|
}
|
|
959
1137
|
// ─── What `proof case` reads (proof#141) ─────────────────────────────────────
|
|
960
1138
|
//
|
|
@@ -1039,7 +1217,8 @@ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
|
|
|
1039
1217
|
const LOG_PATH = /^logs\/([^/]+)\.json$/;
|
|
1040
1218
|
const ZIP_NAME = /\.zip$/i;
|
|
1041
1219
|
/** The one line a `.zip` gets, as an argument or as the way through a refusal. */
|
|
1042
|
-
const UNZIP_FIRST = "
|
|
1220
|
+
const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
|
|
1221
|
+
"case/) and pass the folder.";
|
|
1043
1222
|
/**
|
|
1044
1223
|
* The input could not be read as a case. The message is the whole report,
|
|
1045
1224
|
* identical in both kernels, and the command exits 2 on it. `hint` is an
|
|
@@ -1066,39 +1245,6 @@ export class BundleReadError extends Error {
|
|
|
1066
1245
|
this.detail = detail;
|
|
1067
1246
|
}
|
|
1068
1247
|
}
|
|
1069
|
-
/**
|
|
1070
|
-
* Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
|
|
1071
|
-
* no recursion; the same scan as `_json_depth_exceeds` in the Python twin, so
|
|
1072
|
-
* both kernels refuse the same text.
|
|
1073
|
-
*/
|
|
1074
|
-
function jsonDepthExceeds(text, limit) {
|
|
1075
|
-
let depth = 0;
|
|
1076
|
-
let inString = false;
|
|
1077
|
-
let escaped = false;
|
|
1078
|
-
for (let i = 0; i < text.length; i++) {
|
|
1079
|
-
const ch = text[i];
|
|
1080
|
-
if (inString) {
|
|
1081
|
-
if (escaped)
|
|
1082
|
-
escaped = false;
|
|
1083
|
-
else if (ch === "\\")
|
|
1084
|
-
escaped = true;
|
|
1085
|
-
else if (ch === '"')
|
|
1086
|
-
inString = false;
|
|
1087
|
-
}
|
|
1088
|
-
else if (ch === '"') {
|
|
1089
|
-
inString = true;
|
|
1090
|
-
}
|
|
1091
|
-
else if (ch === "[" || ch === "{") {
|
|
1092
|
-
depth++;
|
|
1093
|
-
if (depth > limit)
|
|
1094
|
-
return true;
|
|
1095
|
-
}
|
|
1096
|
-
else if (ch === "]" || ch === "}") {
|
|
1097
|
-
depth--;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
return false;
|
|
1101
|
-
}
|
|
1102
1248
|
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1103
1249
|
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
1104
1250
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
@@ -1136,7 +1282,7 @@ export async function splitCase(tree, read) {
|
|
|
1136
1282
|
const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
|
|
1137
1283
|
const hint = zips.length > 0
|
|
1138
1284
|
? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
|
|
1139
|
-
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
|
|
1285
|
+
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
|
|
1140
1286
|
UNZIP_FIRST
|
|
1141
1287
|
: null;
|
|
1142
1288
|
throw new CaseInputError(NO_BUNDLE, hint);
|
|
@@ -1204,6 +1350,8 @@ export function loadBundle(pack) {
|
|
|
1204
1350
|
* the walk listed it is refused rather than followed or waited on.
|
|
1205
1351
|
*/
|
|
1206
1352
|
export async function readFileEntry(entry, key) {
|
|
1353
|
+
if (entry.data !== undefined && entry.data !== null)
|
|
1354
|
+
return entry.data;
|
|
1207
1355
|
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1208
1356
|
let handle;
|
|
1209
1357
|
try {
|
|
@@ -1301,24 +1449,79 @@ export async function treeFromArguments(paths) {
|
|
|
1301
1449
|
}
|
|
1302
1450
|
if (s.isDirectory())
|
|
1303
1451
|
continue;
|
|
1304
|
-
if (ZIP_NAME.test(arg))
|
|
1305
|
-
|
|
1306
|
-
throw new CaseInputError(`${arg} is not a folder. Pass pack folders,
|
|
1452
|
+
if (s.isFile() && ZIP_NAME.test(arg))
|
|
1453
|
+
continue;
|
|
1454
|
+
throw new CaseInputError(`${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`);
|
|
1307
1455
|
}
|
|
1308
1456
|
const tree = new Map();
|
|
1309
1457
|
const claimed = new Map();
|
|
1310
1458
|
const seen = { n: 0 };
|
|
1311
1459
|
for (const arg of paths) {
|
|
1312
|
-
|
|
1460
|
+
let isZip = false;
|
|
1461
|
+
if (ZIP_NAME.test(arg)) {
|
|
1462
|
+
try {
|
|
1463
|
+
isZip = !(await stat(arg)).isDirectory();
|
|
1464
|
+
}
|
|
1465
|
+
catch {
|
|
1466
|
+
isZip = true;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
let name = baseName(arg);
|
|
1470
|
+
if (isZip) {
|
|
1471
|
+
// `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
|
|
1472
|
+
// the archive is read under the folder that command makes.
|
|
1473
|
+
name = name.slice(0, -".zip".length);
|
|
1474
|
+
if (name.length === 0)
|
|
1475
|
+
throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
1476
|
+
}
|
|
1313
1477
|
const earlier = claimed.get(name);
|
|
1314
1478
|
if (earlier !== undefined) {
|
|
1315
1479
|
throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
|
|
1316
1480
|
}
|
|
1317
1481
|
claimed.set(name, arg);
|
|
1318
|
-
|
|
1482
|
+
if (isZip)
|
|
1483
|
+
await unzip(arg, `${name}/`, tree, seen);
|
|
1484
|
+
else
|
|
1485
|
+
await walk(arg, `${name}/`, arg, tree, seen);
|
|
1319
1486
|
}
|
|
1320
1487
|
return tree;
|
|
1321
1488
|
}
|
|
1489
|
+
/**
|
|
1490
|
+
* A case file named on the command line, read as the folder `unzip` would make
|
|
1491
|
+
* of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
|
|
1492
|
+
* unpacked folder give one reading. The archive is read by `readZip` and
|
|
1493
|
+
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
1494
|
+
* read, as the walk lists a link.
|
|
1495
|
+
*/
|
|
1496
|
+
async function unzip(arg, prefix, tree, seen) {
|
|
1497
|
+
let data;
|
|
1498
|
+
try {
|
|
1499
|
+
data = new Uint8Array(await readFile(arg));
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
throw new CaseInputError(`cannot read ${arg}`);
|
|
1503
|
+
}
|
|
1504
|
+
let entries;
|
|
1505
|
+
try {
|
|
1506
|
+
entries = readZip(data);
|
|
1507
|
+
}
|
|
1508
|
+
catch (e) {
|
|
1509
|
+
if (e instanceof ZipReadError)
|
|
1510
|
+
throw new CaseInputError(`${arg} ${e.message}`);
|
|
1511
|
+
throw e;
|
|
1512
|
+
}
|
|
1513
|
+
for (const entry of entries) {
|
|
1514
|
+
seen.n++;
|
|
1515
|
+
if (seen.n > MAX_WALK_ENTRIES) {
|
|
1516
|
+
throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
|
|
1517
|
+
}
|
|
1518
|
+
tree.set(`${prefix}${entry.name}`, {
|
|
1519
|
+
fsPath: "",
|
|
1520
|
+
regular: entry.refused === null,
|
|
1521
|
+
data: entry.data,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1322
1525
|
// ─── `proof case` (proof#141) ────────────────────────────────────────────────
|
|
1323
1526
|
//
|
|
1324
1527
|
// The case reading from a terminal, so an auditor holding several packs does
|
|
@@ -1331,67 +1534,65 @@ export async function treeFromArguments(paths) {
|
|
|
1331
1534
|
// The reading is `readCase`, entire. This command splits the input into packs
|
|
1332
1535
|
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
1333
1536
|
// No color: the two commands print the same bytes.
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
failed: EXIT_FAILED,
|
|
1343
|
-
malformed: EXIT_MALFORMED,
|
|
1344
|
-
incomplete: EXIT_UNDETERMINED,
|
|
1345
|
-
partial: EXIT_UNDETERMINED,
|
|
1346
|
-
mixed: EXIT_MIXED,
|
|
1347
|
-
};
|
|
1348
|
-
export const CASE_USAGE = `sanning-proof case -- one verdict over several evidence packs, offline
|
|
1537
|
+
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
1538
|
+
// `mixed` has its own code, 4, because each other code would say something a
|
|
1539
|
+
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
1540
|
+
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
1541
|
+
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
1542
|
+
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
1543
|
+
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
1544
|
+
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
1349
1545
|
|
|
1350
|
-
sanning-proof case <folder>... [--gateways <url,url>] [--json]
|
|
1546
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
|
|
1351
1547
|
|
|
1352
|
-
<folder> a pack folder (bundle.json plus logs/),
|
|
1353
|
-
several packs,
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1548
|
+
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
1549
|
+
several packs, or an unzipped case file. Packs are found by the
|
|
1550
|
+
verify page's splitting rule: every bundle.json is a pack, a file
|
|
1551
|
+
belongs to the deepest pack whose bundle.json or logs/ it sits
|
|
1552
|
+
in, and every other file is listed as outside every pack and
|
|
1553
|
+
never opened. A .zip inside a folder is a file like any other. A
|
|
1554
|
+
symbolic link is never followed: it is listed as outside and not
|
|
1555
|
+
read, even under a pack's logs/. Several arguments are read as
|
|
1556
|
+
one folder holding them, each under its own name. A folder
|
|
1557
|
+
nested more than 32 levels deep, more than 50000 files and
|
|
1558
|
+
folders in all, or a name that is not valid UTF-8 exits 2.
|
|
1559
|
+
<case file> a case file named as <name>.zip, read as the folder
|
|
1560
|
+
unzip <name>.zip -d <name> would make, so the two give one
|
|
1561
|
+
reading. An entry that is encrypted, a symbolic link, stored by
|
|
1562
|
+
another method, or named outside the folder is listed as outside
|
|
1563
|
+
and not read.
|
|
1564
|
+
Where the folder or case file holds contents.json at its root,
|
|
1565
|
+
the index is compared with what is there and each disagreement
|
|
1566
|
+
is listed; the index never moves the verdict.
|
|
1366
1567
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1568
|
+
looked up on the public record, pack by pack, and reported on
|
|
1569
|
+
the witness line. The lookup never changes the verdict word.
|
|
1570
|
+
--json the full reading as JSON: the verdict, every exception and note
|
|
1571
|
+
with its rendered line, every record, every contribution, the
|
|
1572
|
+
packs, the files outside them, the case file index comparison,
|
|
1573
|
+
the witness line and the on-chain half.
|
|
1373
1574
|
|
|
1374
1575
|
Each pack's bundle.json is read as verify reads it, so the two commands
|
|
1375
1576
|
classify one bundle the same way.
|
|
1376
1577
|
|
|
1377
1578
|
What a case reading establishes: every pack is verified offline by this
|
|
1378
|
-
kernel; every record every pack supplied is read, in one
|
|
1379
|
-
hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1380
|
-
counterpart among the
|
|
1381
|
-
the key it names is in the
|
|
1382
|
-
every candidate it had was accounted for by another record (A20),
|
|
1383
|
-
as no counterpart and names no pack.
|
|
1579
|
+
kernel and graded as a pack; every record every pack supplied is read, in one
|
|
1580
|
+
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1581
|
+
with no counterpart among the records read is always named, and when a pack
|
|
1582
|
+
signed by the key it names is in the reading, it says that pack holds none,
|
|
1583
|
+
unless every candidate it had was accounted for by another record (A20),
|
|
1584
|
+
which reads as no counterpart and names no pack.
|
|
1384
1585
|
|
|
1385
|
-
What it does not establish: that the
|
|
1386
|
-
hand over a pack); that a party which presented no pack under the named key
|
|
1586
|
+
What it does not establish: that the set of packs is complete (a party can
|
|
1587
|
+
decline to hand over a pack); that a party which presented no pack under the named key
|
|
1387
1588
|
recorded nothing; that two organizations are independent of each other; or
|
|
1388
|
-
that any witness
|
|
1589
|
+
that any witness is on the public record, which the reading never looks up.
|
|
1389
1590
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1591
|
+
${EXIT_CODES_HELP}
|
|
1592
|
+
|
|
1593
|
+
case: an input that cannot be read as packs, or an unexpected error in this
|
|
1594
|
+
command, exits 2. With --gateways, a witness that does not match on chain exits
|
|
1595
|
+
1, and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1395
1596
|
// What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
|
|
1396
1597
|
// rather than left to the stream, so the lines a test captures in process are
|
|
1397
1598
|
// the lines a reader sees.
|
|
@@ -1422,6 +1623,7 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1422
1623
|
if (gateways.length === 0)
|
|
1423
1624
|
return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1424
1625
|
const rows = [];
|
|
1626
|
+
const answered = new Set();
|
|
1425
1627
|
for (const [ref, bundle] of packs) {
|
|
1426
1628
|
const result = await verifyEvidenceBundle(bundle, {
|
|
1427
1629
|
gateways,
|
|
@@ -1448,13 +1650,58 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1448
1650
|
row.not_checked++;
|
|
1449
1651
|
}
|
|
1450
1652
|
rows.push(row);
|
|
1653
|
+
for (const w of witnesses) {
|
|
1654
|
+
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
1655
|
+
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
1656
|
+
answered.add(outcome.gateway);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1451
1660
|
}
|
|
1452
1661
|
const verdict = rows.some((r) => r.mismatched > 0)
|
|
1453
1662
|
? "failed"
|
|
1454
1663
|
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1455
1664
|
? "undetermined"
|
|
1456
1665
|
: "confirmed";
|
|
1457
|
-
return {
|
|
1666
|
+
return {
|
|
1667
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
1668
|
+
checked: true,
|
|
1669
|
+
gateways: [...gateways],
|
|
1670
|
+
packs: rows,
|
|
1671
|
+
verdict,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
|
|
1676
|
+
* half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
|
|
1677
|
+
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
1678
|
+
* headline.
|
|
1679
|
+
*/
|
|
1680
|
+
export function witnessLine(onChain) {
|
|
1681
|
+
if (!onChain.checked) {
|
|
1682
|
+
return {
|
|
1683
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
1684
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
1685
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
const services = [...(onChain.answered ?? [])];
|
|
1689
|
+
if (onChain.verdict === "failed")
|
|
1690
|
+
return { state: "differs", reasons: [], services };
|
|
1691
|
+
if (onChain.verdict === "confirmed")
|
|
1692
|
+
return { state: "confirmed", reasons: [], services };
|
|
1693
|
+
const rows = onChain.packs;
|
|
1694
|
+
const reasons = [];
|
|
1695
|
+
if (rows.some((r) => r.witnesses === 0))
|
|
1696
|
+
reasons.push("a pack names no witness");
|
|
1697
|
+
if (rows.some((r) => r.unreachable > 0)) {
|
|
1698
|
+
reasons.push("a lookup did not settle: the service could not be reached, or it " +
|
|
1699
|
+
"holds no such witness yet");
|
|
1700
|
+
}
|
|
1701
|
+
if (rows.some((r) => r.not_checked > 0)) {
|
|
1702
|
+
reasons.push("no lookup is made for a witness kind a pack names");
|
|
1703
|
+
}
|
|
1704
|
+
return { state: "not_checked", reasons, services };
|
|
1458
1705
|
}
|
|
1459
1706
|
/**
|
|
1460
1707
|
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
@@ -1462,20 +1709,37 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1462
1709
|
* on-chain half that could not be settled is never a clean 0.
|
|
1463
1710
|
*/
|
|
1464
1711
|
export function caseExitCode(verdict, onChainVerdict) {
|
|
1465
|
-
const code =
|
|
1712
|
+
const code = exitCodeFor(verdict);
|
|
1466
1713
|
if (onChainVerdict === "failed")
|
|
1467
1714
|
return EXIT_FAILED;
|
|
1468
1715
|
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
|
|
1469
1716
|
return EXIT_UNDETERMINED;
|
|
1470
1717
|
return code;
|
|
1471
1718
|
}
|
|
1719
|
+
const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
|
|
1720
|
+
? VERDICT_DISPLAY_TITLES[word]
|
|
1721
|
+
: word;
|
|
1722
|
+
/**
|
|
1723
|
+
* The text report, one line per entry, identical in both kernels. The headline
|
|
1724
|
+
* is the verdict's one display title and nothing else sets it (5.5.6, D7); the
|
|
1725
|
+
* scope sentence and the witness line are printed with it every time.
|
|
1726
|
+
*/
|
|
1472
1727
|
function caseReport(data) {
|
|
1473
|
-
const lines = [
|
|
1728
|
+
const lines = [
|
|
1729
|
+
data.title,
|
|
1730
|
+
` ${data.summary}`,
|
|
1731
|
+
` ${data.witness.rendered}`,
|
|
1732
|
+
` ${data.reliance}`,
|
|
1733
|
+
];
|
|
1474
1734
|
for (const e of data.exceptions)
|
|
1475
1735
|
lines.push(` - ${e.rendered}`);
|
|
1736
|
+
for (const n of data.notes)
|
|
1737
|
+
lines.push(` note: ${n.rendered}`);
|
|
1476
1738
|
lines.push(`packs ${data.packs.length}`);
|
|
1477
1739
|
for (const p of data.packs) {
|
|
1478
|
-
|
|
1740
|
+
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
1741
|
+
const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
|
|
1742
|
+
let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
|
|
1479
1743
|
if (p.unaccounted.length > 0) {
|
|
1480
1744
|
line +=
|
|
1481
1745
|
`; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
|
|
@@ -1491,28 +1755,79 @@ function caseReport(data) {
|
|
|
1491
1755
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
1492
1756
|
}
|
|
1493
1757
|
}
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1758
|
+
for (const c of data.case_file) {
|
|
1759
|
+
if (c.index === "absent")
|
|
1760
|
+
continue;
|
|
1761
|
+
const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
|
|
1762
|
+
lines.push(`case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
|
|
1763
|
+
`${label}; ${c.findings.length} disagreement(s) with what is here, ` +
|
|
1764
|
+
"none of which moves the verdict");
|
|
1765
|
+
for (const f of c.findings)
|
|
1766
|
+
lines.push(` - ${f.rendered}`);
|
|
1499
1767
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1768
|
+
const oc = data.on_chain;
|
|
1769
|
+
if (oc.checked) {
|
|
1770
|
+
lines.push(`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
|
|
1771
|
+
"pack by pack; the reading above is offline and this does not " +
|
|
1503
1772
|
"change it");
|
|
1504
1773
|
for (const r of oc.packs) {
|
|
1505
1774
|
lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
|
|
1506
1775
|
`confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
|
|
1507
1776
|
`unreachable, ${r.not_checked} not checked`);
|
|
1508
1777
|
}
|
|
1509
|
-
lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
|
|
1510
1778
|
}
|
|
1511
1779
|
return lines;
|
|
1512
1780
|
}
|
|
1781
|
+
/**
|
|
1782
|
+
* The index comparison, once per argument (5.6.3): each folder or case file
|
|
1783
|
+
* whose root holds `contents.json`. Every file under `packs/` and `report/` is
|
|
1784
|
+
* hashed for it, as bytes, and nothing is parsed but the index. It is never an
|
|
1785
|
+
* input to the reading.
|
|
1786
|
+
*/
|
|
1787
|
+
async function caseFileReadings(tree) {
|
|
1788
|
+
const out = [];
|
|
1789
|
+
const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0]))].sort();
|
|
1790
|
+
const keys = [...tree.keys()].sort();
|
|
1791
|
+
for (const root of roots) {
|
|
1792
|
+
const prefix = `${root}/`;
|
|
1793
|
+
const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
|
|
1794
|
+
const indexEntry = tree.get(indexKey);
|
|
1795
|
+
const indexBytes = indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
|
|
1796
|
+
const files = [];
|
|
1797
|
+
if (indexBytes !== null) {
|
|
1798
|
+
for (const key of keys) {
|
|
1799
|
+
const entry = tree.get(key);
|
|
1800
|
+
const rel = key.slice(prefix.length);
|
|
1801
|
+
if (key.startsWith(prefix) &&
|
|
1802
|
+
entry.regular &&
|
|
1803
|
+
(rel.startsWith("packs/") || rel.startsWith("report/"))) {
|
|
1804
|
+
files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
const reading = readCaseFileIndex(indexBytes, files);
|
|
1809
|
+
const nil = (v) => (v === undefined ? null : v);
|
|
1810
|
+
out.push({
|
|
1811
|
+
root,
|
|
1812
|
+
index: reading.index,
|
|
1813
|
+
case_id: reading.caseId,
|
|
1814
|
+
findings: reading.findings.map((f) => ({
|
|
1815
|
+
kind: f.kind,
|
|
1816
|
+
ref: nil(f.ref),
|
|
1817
|
+
path: nil(f.path),
|
|
1818
|
+
listed_sha256: nil(f.listedSha256),
|
|
1819
|
+
sha256: nil(f.sha256),
|
|
1820
|
+
reason: nil(f.reason),
|
|
1821
|
+
public_key: nil(f.publicKey),
|
|
1822
|
+
rendered: renderCaseFileFinding(f),
|
|
1823
|
+
})),
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
return out;
|
|
1827
|
+
}
|
|
1513
1828
|
function caseError(e, asJson, io) {
|
|
1514
1829
|
if (asJson)
|
|
1515
|
-
io
|
|
1830
|
+
outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
|
|
1516
1831
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
1517
1832
|
if (e.hint)
|
|
1518
1833
|
io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
@@ -1524,7 +1839,8 @@ function caseError(e, asJson, io) {
|
|
|
1524
1839
|
* never 1, because 1 would name the evidence failed over a defect in this
|
|
1525
1840
|
* command.
|
|
1526
1841
|
*/
|
|
1527
|
-
export async function runCase(argv,
|
|
1842
|
+
export async function runCase(argv, stream) {
|
|
1843
|
+
const io = guard(stream);
|
|
1528
1844
|
try {
|
|
1529
1845
|
return await caseCommand(argv, io);
|
|
1530
1846
|
}
|
|
@@ -1546,7 +1862,7 @@ async function caseCommand(argv, io) {
|
|
|
1546
1862
|
for (let i = 0; i < argv.length; i++) {
|
|
1547
1863
|
const arg = argv[i];
|
|
1548
1864
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
1549
|
-
io
|
|
1865
|
+
outBlock(io, CASE_USAGE);
|
|
1550
1866
|
return EXIT_VERIFIED;
|
|
1551
1867
|
}
|
|
1552
1868
|
if (arg === "--json") {
|
|
@@ -1556,7 +1872,7 @@ async function caseCommand(argv, io) {
|
|
|
1556
1872
|
const next = argv[i + 1];
|
|
1557
1873
|
if (next === undefined) {
|
|
1558
1874
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
1559
|
-
io
|
|
1875
|
+
outBlock(io, CASE_USAGE);
|
|
1560
1876
|
return EXIT_MALFORMED;
|
|
1561
1877
|
}
|
|
1562
1878
|
gateways = next
|
|
@@ -1567,7 +1883,7 @@ async function caseCommand(argv, io) {
|
|
|
1567
1883
|
}
|
|
1568
1884
|
else if (arg.startsWith("--")) {
|
|
1569
1885
|
io.err(`case: unknown option ${arg}`);
|
|
1570
|
-
io
|
|
1886
|
+
outBlock(io, CASE_USAGE);
|
|
1571
1887
|
return EXIT_MALFORMED;
|
|
1572
1888
|
}
|
|
1573
1889
|
else {
|
|
@@ -1576,13 +1892,15 @@ async function caseCommand(argv, io) {
|
|
|
1576
1892
|
}
|
|
1577
1893
|
if (paths.length === 0) {
|
|
1578
1894
|
io.err("case: at least one pack is required");
|
|
1579
|
-
io
|
|
1895
|
+
outBlock(io, CASE_USAGE);
|
|
1580
1896
|
return EXIT_MALFORMED;
|
|
1581
1897
|
}
|
|
1582
1898
|
let split;
|
|
1899
|
+
let tree;
|
|
1583
1900
|
let bundles;
|
|
1584
1901
|
try {
|
|
1585
|
-
|
|
1902
|
+
tree = await treeFromArguments(paths);
|
|
1903
|
+
split = await splitCase(tree, readFileEntry);
|
|
1586
1904
|
bundles = split.packs.map((p) => [p, loadBundle(p)]);
|
|
1587
1905
|
}
|
|
1588
1906
|
catch (e) {
|
|
@@ -1591,13 +1909,31 @@ async function caseCommand(argv, io) {
|
|
|
1591
1909
|
throw e;
|
|
1592
1910
|
}
|
|
1593
1911
|
const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
|
|
1912
|
+
let caseFile;
|
|
1913
|
+
try {
|
|
1914
|
+
caseFile = await caseFileReadings(tree);
|
|
1915
|
+
}
|
|
1916
|
+
catch (e) {
|
|
1917
|
+
if (e instanceof CaseInputError)
|
|
1918
|
+
return caseError(e, asJson, io);
|
|
1919
|
+
throw e;
|
|
1920
|
+
}
|
|
1594
1921
|
const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
|
|
1922
|
+
const witness = witnessLine(onChain);
|
|
1595
1923
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
1596
1924
|
const nil = (v) => (v === undefined ? null : v);
|
|
1597
1925
|
const data = {
|
|
1598
1926
|
verdict: reading.verdict,
|
|
1927
|
+
title: titleOf(reading.verdict),
|
|
1928
|
+
reliance: CASE_RELIANCE[reading.verdict],
|
|
1599
1929
|
exit_code: code,
|
|
1600
1930
|
summary: renderCaseSummary(reading),
|
|
1931
|
+
witness: {
|
|
1932
|
+
state: witness.state,
|
|
1933
|
+
reasons: witness.reasons,
|
|
1934
|
+
services: witness.services,
|
|
1935
|
+
rendered: renderWitnessLine(witness),
|
|
1936
|
+
},
|
|
1601
1937
|
handoff_records: reading.handoffRecords,
|
|
1602
1938
|
on_chain_checked: reading.onChainChecked,
|
|
1603
1939
|
exceptions: reading.exceptions.map((e) => ({
|
|
@@ -1608,30 +1944,43 @@ async function caseCommand(argv, io) {
|
|
|
1608
1944
|
failed_checks: e.failedChecks ?? [],
|
|
1609
1945
|
count: nil(e.count),
|
|
1610
1946
|
status: nil(e.status),
|
|
1947
|
+
causes: e.causes ?? [],
|
|
1611
1948
|
reasons: e.reasons ?? [],
|
|
1612
1949
|
rendered: renderException(e),
|
|
1613
1950
|
})),
|
|
1951
|
+
notes: reading.notes.map((n) => ({
|
|
1952
|
+
kind: n.kind,
|
|
1953
|
+
pack_ref: n.packRef,
|
|
1954
|
+
count: nil(n.count),
|
|
1955
|
+
rendered: renderCaseNote(n),
|
|
1956
|
+
})),
|
|
1614
1957
|
records: reading.records.map((r) => ({
|
|
1615
1958
|
pack_ref: r.packRef,
|
|
1616
1959
|
event_id: r.eventId,
|
|
1617
1960
|
verdict: nil(r.verdict),
|
|
1961
|
+
role: r.role,
|
|
1618
1962
|
findings: r.findings,
|
|
1619
1963
|
failed_checks: r.failedChecks,
|
|
1620
1964
|
counterparts: r.counterparts,
|
|
1621
1965
|
})),
|
|
1622
1966
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
1623
|
-
packs: bundles.map(([p]) => ({
|
|
1967
|
+
packs: bundles.map(([p], i) => ({
|
|
1624
1968
|
ref: p.ref,
|
|
1625
1969
|
bundle_path: p.bundlePath,
|
|
1626
1970
|
log_files: p.content.size,
|
|
1627
1971
|
unaccounted: p.unaccounted,
|
|
1972
|
+
signing_key: reading.packs[i].signingKey,
|
|
1973
|
+
grade: reading.packs[i].grade,
|
|
1974
|
+
records_withheld: reading.packs[i].recordsWithheld,
|
|
1975
|
+
declaration_version: reading.packs[i].declarationVersion,
|
|
1628
1976
|
})),
|
|
1629
1977
|
outside: split.outside,
|
|
1630
1978
|
not_regular: split.notRegular,
|
|
1979
|
+
case_file: caseFile,
|
|
1631
1980
|
on_chain: onChain,
|
|
1632
1981
|
};
|
|
1633
1982
|
if (asJson)
|
|
1634
|
-
io
|
|
1983
|
+
outJson(io, jsonText(data));
|
|
1635
1984
|
else
|
|
1636
1985
|
for (const line of caseReport(data))
|
|
1637
1986
|
io.out(noLoneSurrogates(line));
|
|
@@ -1670,7 +2019,7 @@ if (isMain) {
|
|
|
1670
2019
|
process.exitCode = code;
|
|
1671
2020
|
})
|
|
1672
2021
|
.catch((e) => {
|
|
1673
|
-
process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
2022
|
+
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
1674
2023
|
process.exitCode = EXIT_MALFORMED;
|
|
1675
2024
|
});
|
|
1676
2025
|
}
|