@sanning/proof 0.12.0 → 0.14.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 +353 -0
- package/dist/case.d.ts +217 -26
- package/dist/case.js +535 -124
- package/dist/cli.d.ts +64 -8
- package/dist/cli.js +799 -219
- package/dist/compose.js +6 -1
- 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 +38 -0
- package/dist/evidence.js +238 -22
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +14 -7
- package/dist/index.js +20 -6
- package/dist/message-parts.d.ts +18 -10
- package/dist/message-parts.js +25 -15
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +63 -4
- package/dist/verifier.js +223 -15
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- 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 +422 -0
- package/src/case.ts +671 -131
- package/src/cli.ts +932 -217
- package/src/compose.ts +8 -1
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +257 -23
- package/src/exit-codes.ts +54 -0
- package/src/file-match.ts +461 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +101 -2
- package/src/message-parts.ts +25 -15
- package/src/types.ts +5 -0
- package/src/verifier.ts +225 -16
- package/src/witness-cache.ts +306 -0
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +66 -8
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +20 -5
- package/vectors/witness-cache.json +1304 -0
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,86 @@
|
|
|
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 { VERDICT_DISPLAY_TITLES, WITNESS_NOT_LOOKED_UP, caseReliance, 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";
|
|
57
|
+
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
58
|
+
import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
|
|
59
|
+
import { WITNESS_CACHE_NAME, checkWitnessCache, readWitnessCache, renderWitnessTime, } from "./witness-cache.js";
|
|
47
60
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
48
61
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
import { EXIT_CODES_HELP, EXIT_FAILED, EXIT_MALFORMED, EXIT_UNDETERMINED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
|
|
63
|
+
// ── What a person reads is escaped at one choke point ──
|
|
64
|
+
//
|
|
65
|
+
// Every human-readable line this command prints passes through `guard`, which
|
|
66
|
+
// `runCli` and `runCase` apply to the streams they are handed. It escapes each
|
|
67
|
+
// control character, so a value the audited party wrote (a folder or file
|
|
68
|
+
// name, a `contents.json` field, a zip entry name, an exception built from
|
|
69
|
+
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
70
|
+
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
71
|
+
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
72
|
+
// only, which escapes its own control characters. The twin is `Io` in
|
|
73
|
+
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
74
|
+
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
75
|
+
// embedding, override and isolate controls.
|
|
76
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
77
|
+
/** Each control character in `text` as a visible escape: `\x1b` for ESC,
|
|
78
|
+
* `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
|
|
79
|
+
* backslash included, so a line with none is printed as it was. */
|
|
80
|
+
export function escapeControl(text) {
|
|
81
|
+
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
82
|
+
const code = c.charCodeAt(0);
|
|
83
|
+
return code < 0x100
|
|
84
|
+
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
85
|
+
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// The colors this command writes on a TTY are its own control characters, and
|
|
89
|
+
// the choke point would escape them with everything else. So `paint` and
|
|
90
|
+
// `mark` write this marker instead, and the choke point turns it into ESC
|
|
91
|
+
// after escaping. It is random per process, so no input can spell it.
|
|
92
|
+
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
93
|
+
const G = `${COLOR_MARK}32m`;
|
|
94
|
+
const R = `${COLOR_MARK}31m`;
|
|
95
|
+
const Y = `${COLOR_MARK}33m`;
|
|
96
|
+
const DIM = `${COLOR_MARK}2m`;
|
|
97
|
+
const B = `${COLOR_MARK}1m`;
|
|
98
|
+
const X = `${COLOR_MARK}0m`;
|
|
99
|
+
function forTerminal(text) {
|
|
100
|
+
return escapeControl(text).split(COLOR_MARK).join("\x1b[");
|
|
101
|
+
}
|
|
102
|
+
const RAW = Symbol("sanning.cli.raw");
|
|
103
|
+
function guard(io) {
|
|
104
|
+
if (RAW in io)
|
|
105
|
+
return io;
|
|
106
|
+
const guarded = {
|
|
107
|
+
out: (s) => io.out(forTerminal(s)),
|
|
108
|
+
err: (s) => io.err(forTerminal(s)),
|
|
109
|
+
[RAW]: io,
|
|
110
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
111
|
+
};
|
|
112
|
+
return guarded;
|
|
113
|
+
}
|
|
114
|
+
function rawOf(io) {
|
|
115
|
+
return io[RAW] ?? io;
|
|
116
|
+
}
|
|
117
|
+
/** JSON, printed unescaped: a script reads it, not a terminal. */
|
|
118
|
+
function outJson(io, text) {
|
|
119
|
+
rawOf(io).out(text);
|
|
120
|
+
}
|
|
121
|
+
/** A block this command wrote itself, such as a usage text: its own newlines
|
|
122
|
+
* are kept, and each line is escaped as `out` escapes it. */
|
|
123
|
+
function outBlock(io, text) {
|
|
124
|
+
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
125
|
+
}
|
|
61
126
|
function useColor() {
|
|
62
127
|
// Honor NO_COLOR; otherwise color only a TTY.
|
|
63
128
|
if (process.env.NO_COLOR)
|
|
@@ -75,7 +140,8 @@ function mark(ok) {
|
|
|
75
140
|
function paint(s, c) {
|
|
76
141
|
return useColor() ? `${c}${s}${X}` : s;
|
|
77
142
|
}
|
|
78
|
-
export async function runCli(argv,
|
|
143
|
+
export async function runCli(argv, stream) {
|
|
144
|
+
const io = guard(stream);
|
|
79
145
|
const [command, ...rest] = argv;
|
|
80
146
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
81
147
|
printUsage(io);
|
|
@@ -85,11 +151,15 @@ export async function runCli(argv, io) {
|
|
|
85
151
|
io.out("@sanning/proof verify CLI");
|
|
86
152
|
return EXIT_VERIFIED;
|
|
87
153
|
}
|
|
88
|
-
// `
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
154
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`). It
|
|
155
|
+
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
156
|
+
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
157
|
+
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
158
|
+
if (command === "export") {
|
|
159
|
+
if (rest.includes("--json")) {
|
|
160
|
+
return runJson(rest.filter((a) => a !== "--json"), io, runExport);
|
|
161
|
+
}
|
|
162
|
+
return runExport(rest, io, null);
|
|
93
163
|
}
|
|
94
164
|
if (command === "case") {
|
|
95
165
|
return runCase(rest, io);
|
|
@@ -99,13 +169,55 @@ export async function runCli(argv, io) {
|
|
|
99
169
|
printUsage(io);
|
|
100
170
|
return EXIT_MALFORMED;
|
|
101
171
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
172
|
+
if (rest.includes("--json")) {
|
|
173
|
+
return runJson(rest.filter((a) => a !== "--json"), io, runVerify);
|
|
174
|
+
}
|
|
175
|
+
return runVerify(rest, io, null);
|
|
176
|
+
}
|
|
177
|
+
async function runJson(rest, io, verb) {
|
|
178
|
+
const errs = [];
|
|
179
|
+
const sink = { data: null };
|
|
180
|
+
const quiet = {
|
|
181
|
+
out: () => { },
|
|
182
|
+
err: (s) => errs.push(s),
|
|
183
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
184
|
+
};
|
|
185
|
+
const code = await verb(rest, quiet, sink);
|
|
186
|
+
const notes = errs.filter((l) => l.startsWith("note: "));
|
|
187
|
+
for (const n of notes)
|
|
188
|
+
io.err(n);
|
|
189
|
+
if (sink.data !== null) {
|
|
190
|
+
outJson(io, jsonText({ ...sink.data, exit_code: code }));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
|
|
194
|
+
outJson(io, jsonText({ error, exit_code: code }));
|
|
195
|
+
}
|
|
196
|
+
return code;
|
|
197
|
+
}
|
|
198
|
+
async function runVerify(rest, io, sink) {
|
|
199
|
+
// `--logs <path>` feeds disclosed raw-log bytes for content verification
|
|
200
|
+
// (the evidence-bundle path only); `--gateways <url,url>` asks for the
|
|
201
|
+
// on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
|
|
202
|
+
// second positional argument until then, and a second positional is now a
|
|
203
|
+
// usage error). The one positional is the bundle.
|
|
105
204
|
let logsPath;
|
|
205
|
+
let filePaths;
|
|
206
|
+
let gatewayArg;
|
|
106
207
|
const positional = [];
|
|
107
208
|
for (let i = 0; i < rest.length; i++) {
|
|
108
209
|
const a = rest[i];
|
|
210
|
+
if (a === "--gateways") {
|
|
211
|
+
const next = rest[i + 1];
|
|
212
|
+
if (next === undefined) {
|
|
213
|
+
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
214
|
+
printUsage(io);
|
|
215
|
+
return EXIT_MALFORMED;
|
|
216
|
+
}
|
|
217
|
+
gatewayArg = next;
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
109
221
|
if (a === "--logs") {
|
|
110
222
|
const next = rest[i + 1];
|
|
111
223
|
if (next === undefined) {
|
|
@@ -117,14 +229,30 @@ export async function runCli(argv, io) {
|
|
|
117
229
|
i++;
|
|
118
230
|
continue;
|
|
119
231
|
}
|
|
232
|
+
if (a === "--files") {
|
|
233
|
+
const taken = takeFilePaths(rest, i + 1);
|
|
234
|
+
if (taken.length === 0) {
|
|
235
|
+
io.err("verify: --files requires at least one file path");
|
|
236
|
+
printUsage(io);
|
|
237
|
+
return EXIT_MALFORMED;
|
|
238
|
+
}
|
|
239
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
240
|
+
i += taken.length;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
120
243
|
positional.push(a);
|
|
121
244
|
}
|
|
122
|
-
const [bundlePath
|
|
245
|
+
const [bundlePath] = positional;
|
|
123
246
|
if (!bundlePath) {
|
|
124
247
|
io.err("verify: a bundle file path is required");
|
|
125
248
|
printUsage(io);
|
|
126
249
|
return EXIT_MALFORMED;
|
|
127
250
|
}
|
|
251
|
+
if (positional.length > 1) {
|
|
252
|
+
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
253
|
+
printUsage(io);
|
|
254
|
+
return EXIT_MALFORMED;
|
|
255
|
+
}
|
|
128
256
|
const gateways = (gatewayArg ?? "")
|
|
129
257
|
.split(",")
|
|
130
258
|
.map((g) => g.trim())
|
|
@@ -138,6 +266,15 @@ export async function runCli(argv, io) {
|
|
|
138
266
|
io.err(`note: ${n}`);
|
|
139
267
|
}
|
|
140
268
|
const content = logs?.content;
|
|
269
|
+
let offered;
|
|
270
|
+
if (filePaths !== undefined) {
|
|
271
|
+
const read = await readOfferedFiles(filePaths);
|
|
272
|
+
if (typeof read === "string") {
|
|
273
|
+
io.err(`verify: ${read}`);
|
|
274
|
+
return EXIT_MALFORMED;
|
|
275
|
+
}
|
|
276
|
+
offered = read;
|
|
277
|
+
}
|
|
141
278
|
let raw;
|
|
142
279
|
try {
|
|
143
280
|
raw = new Uint8Array(await readFile(bundlePath));
|
|
@@ -157,6 +294,9 @@ export async function runCli(argv, io) {
|
|
|
157
294
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
158
295
|
io.err(`${bundlePath} ${e.detail}; refusing to read it`);
|
|
159
296
|
}
|
|
297
|
+
else if (e instanceof BundleReadError && e.duplicate) {
|
|
298
|
+
io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
299
|
+
}
|
|
160
300
|
else {
|
|
161
301
|
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
162
302
|
}
|
|
@@ -171,11 +311,14 @@ export async function runCli(argv, io) {
|
|
|
171
311
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
172
312
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
173
313
|
}
|
|
174
|
-
|
|
314
|
+
if (offered) {
|
|
315
|
+
io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
316
|
+
}
|
|
317
|
+
const result = await verifyAgentProof(parsed, {
|
|
175
318
|
gateways,
|
|
176
319
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
177
320
|
});
|
|
178
|
-
return reportAgentProof(result, bundlePath, gateways, io);
|
|
321
|
+
return reportAgentProof(result, bundlePath, gateways, io, sink);
|
|
179
322
|
}
|
|
180
323
|
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
181
324
|
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
@@ -204,26 +347,65 @@ export async function runCli(argv, io) {
|
|
|
204
347
|
catch (e) {
|
|
205
348
|
chains = { unreadable: errMsg(e) };
|
|
206
349
|
}
|
|
207
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
|
|
350
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
351
|
+
// Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
|
|
352
|
+
// beside the verdict, never inside it, so `exit` is untouched.
|
|
353
|
+
if (offered !== undefined) {
|
|
354
|
+
const reading = await matchFiles(parsed, offered, content);
|
|
355
|
+
for (const line of renderFileMatch(reading))
|
|
356
|
+
io.out(line);
|
|
357
|
+
if (sink !== null && sink.data !== null)
|
|
358
|
+
sink.data.files = fileMatchToJson(reading);
|
|
359
|
+
}
|
|
208
360
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
209
361
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
210
362
|
if (logs && result.status !== "malformed")
|
|
211
363
|
reportLogsCoverage(logs, result, io);
|
|
212
364
|
return exit;
|
|
213
365
|
}
|
|
214
|
-
//
|
|
215
|
-
|
|
366
|
+
// The paths after `--files`: every argument up to the next option.
|
|
367
|
+
function takeFilePaths(args, start) {
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const arg of args.slice(start)) {
|
|
370
|
+
if (arg.startsWith("--"))
|
|
371
|
+
break;
|
|
372
|
+
out.push(arg);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
// Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
|
|
377
|
+
// one error line when a path is not a readable regular file. The line names the
|
|
378
|
+
// path and not the operating system's words, which differ by kernel.
|
|
379
|
+
async function readOfferedFiles(paths) {
|
|
380
|
+
const out = [];
|
|
381
|
+
for (const path of paths) {
|
|
382
|
+
try {
|
|
383
|
+
if (!(await stat(path)).isFile())
|
|
384
|
+
return `--files ${path} is not a readable file`;
|
|
385
|
+
out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return `--files ${path} is not a readable file`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
216
394
|
//
|
|
217
395
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
218
396
|
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
219
|
-
// Ed25519 key (evidence-export.md §5, composer). Writes the export to
|
|
397
|
+
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
|
|
220
398
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
221
399
|
//
|
|
222
400
|
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
223
401
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
224
402
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
225
403
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
226
|
-
async function
|
|
404
|
+
async function runExport(rest, io, sink) {
|
|
405
|
+
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
406
|
+
usageExport(io);
|
|
407
|
+
return EXIT_VERIFIED;
|
|
408
|
+
}
|
|
227
409
|
let attestationsPath;
|
|
228
410
|
let keyPath;
|
|
229
411
|
let outPath;
|
|
@@ -240,19 +422,19 @@ async function runBundle(rest, io) {
|
|
|
240
422
|
i++;
|
|
241
423
|
return next;
|
|
242
424
|
};
|
|
243
|
-
if (a === "--attestations"
|
|
425
|
+
if (a === "--attestations") {
|
|
244
426
|
const v = takeNext(a);
|
|
245
427
|
if (v === undefined)
|
|
246
428
|
return usageExport(io);
|
|
247
429
|
attestationsPath = v;
|
|
248
430
|
}
|
|
249
|
-
else if (a === "--key"
|
|
431
|
+
else if (a === "--key") {
|
|
250
432
|
const v = takeNext(a);
|
|
251
433
|
if (v === undefined)
|
|
252
434
|
return usageExport(io);
|
|
253
435
|
keyPath = v;
|
|
254
436
|
}
|
|
255
|
-
else if (a === "--out"
|
|
437
|
+
else if (a === "--out") {
|
|
256
438
|
const v = takeNext(a);
|
|
257
439
|
if (v === undefined)
|
|
258
440
|
return usageExport(io);
|
|
@@ -282,6 +464,13 @@ async function runBundle(rest, io) {
|
|
|
282
464
|
return usageExport(io);
|
|
283
465
|
opts.generatedAt = v;
|
|
284
466
|
}
|
|
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);
|
|
473
|
+
}
|
|
285
474
|
else {
|
|
286
475
|
positional.push(a);
|
|
287
476
|
}
|
|
@@ -326,6 +515,18 @@ async function runBundle(rest, io) {
|
|
|
326
515
|
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
327
516
|
// still writes the artifact so the caller can inspect it.
|
|
328
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
|
+
}
|
|
329
530
|
if (outPath) {
|
|
330
531
|
try {
|
|
331
532
|
await writeFile(outPath, serialized, "utf8");
|
|
@@ -339,25 +540,32 @@ async function runBundle(rest, io) {
|
|
|
339
540
|
`attestations ${composed.boundAttestations}/${attestations.length} bound`);
|
|
340
541
|
}
|
|
341
542
|
else {
|
|
342
|
-
io
|
|
543
|
+
outJson(io, serialized.replace(/\n$/, ""));
|
|
343
544
|
}
|
|
344
545
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
345
546
|
}
|
|
346
547
|
function usageExport(io) {
|
|
347
|
-
io.out("Usage:
|
|
548
|
+
io.out("Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]");
|
|
348
549
|
io.out("");
|
|
349
550
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
350
551
|
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>`.");
|
|
552
|
+
io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
|
|
352
553
|
io.out("");
|
|
353
554
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
354
555
|
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
355
556
|
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
356
|
-
io.out("
|
|
557
|
+
io.out("--out <file> Write the export here (default: stdout).");
|
|
357
558
|
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
358
559
|
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
359
560
|
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
360
561
|
io.out("--generated-at <ts> RFC 3339 compose time (default now).");
|
|
562
|
+
io.out("--json Print a summary as JSON (with the export inside it");
|
|
563
|
+
io.out(" when --out is not given).");
|
|
564
|
+
io.out("");
|
|
565
|
+
for (const line of EXIT_CODES_HELP.split("\n"))
|
|
566
|
+
io.out(line);
|
|
567
|
+
io.out("");
|
|
568
|
+
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
361
569
|
return EXIT_MALFORMED;
|
|
362
570
|
}
|
|
363
571
|
// Read + parse a JSON file; report a clear error and return undefined on any
|
|
@@ -372,9 +580,13 @@ async function readJson(path, io) {
|
|
|
372
580
|
return undefined;
|
|
373
581
|
}
|
|
374
582
|
try {
|
|
375
|
-
return
|
|
583
|
+
return parseRefusingDuplicates(raw);
|
|
376
584
|
}
|
|
377
585
|
catch (e) {
|
|
586
|
+
if (e instanceof DuplicateKeyError) {
|
|
587
|
+
io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
378
590
|
io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
379
591
|
return undefined;
|
|
380
592
|
}
|
|
@@ -456,9 +668,15 @@ async function loadLogsMap(logsPath, io) {
|
|
|
456
668
|
}
|
|
457
669
|
let logsParsed;
|
|
458
670
|
try {
|
|
459
|
-
logsParsed =
|
|
671
|
+
logsParsed = parseRefusingDuplicates(logsRaw);
|
|
460
672
|
}
|
|
461
673
|
catch (e) {
|
|
674
|
+
if (e instanceof DuplicateKeyError) {
|
|
675
|
+
// One event id named twice hands the reader two contents for one
|
|
676
|
+
// record, and the last one silently won (proof#144).
|
|
677
|
+
io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
462
680
|
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
463
681
|
return undefined;
|
|
464
682
|
}
|
|
@@ -585,7 +803,8 @@ function reportLogsCoverage(logs, result, io) {
|
|
|
585
803
|
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
586
804
|
if (undetermined.length > 0) {
|
|
587
805
|
io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
588
|
-
`content
|
|
806
|
+
`content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
807
|
+
`hand-off record) to bind them to. ` +
|
|
589
808
|
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
|
|
590
809
|
}
|
|
591
810
|
}
|
|
@@ -628,32 +847,49 @@ function decodeLogValue(v) {
|
|
|
628
847
|
return utf8(v); // raw utf8 bytes
|
|
629
848
|
}
|
|
630
849
|
function printUsage(io) {
|
|
631
|
-
io.out("Usage:
|
|
850
|
+
io.out("Usage: sanning-proof <command> [args]");
|
|
851
|
+
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
632
852
|
io.out("");
|
|
633
853
|
io.out("Commands:");
|
|
634
|
-
io.out(" verify <bundle.json> [gateways] [--logs <path>]
|
|
635
|
-
io.out("
|
|
636
|
-
io.out("
|
|
854
|
+
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
|
|
855
|
+
io.out(" Verify a bundle");
|
|
856
|
+
io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
|
|
857
|
+
io.out(" Create an attested export");
|
|
858
|
+
io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
|
|
859
|
+
io.out(" Read several packs as one case");
|
|
637
860
|
io.out(" (`case --help` for its exit codes)");
|
|
638
861
|
io.out("");
|
|
639
862
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
640
863
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
641
864
|
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
642
|
-
io.out("fully offline.
|
|
643
|
-
io.out("
|
|
865
|
+
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
866
|
+
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
644
867
|
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
645
868
|
io.out("");
|
|
869
|
+
io.out("--json Print the verdict, the causes and the exit code as");
|
|
870
|
+
io.out(" JSON, for a script, instead of the report.");
|
|
871
|
+
io.out("");
|
|
872
|
+
io.out("--files <path>... Files handed in beside the pack, every path up to the");
|
|
873
|
+
io.out(" next option. Each is hashed and matched, by SHA-256");
|
|
874
|
+
io.out(" alone, against what the pack's signed records commit");
|
|
875
|
+
io.out(" to; the report lists the files the pack vouches for,");
|
|
876
|
+
io.out(" the files that match no record, and the commitments");
|
|
877
|
+
io.out(" no file was offered for. Names and folders are never");
|
|
878
|
+
io.out(" compared, and the verdict never moves.");
|
|
879
|
+
io.out("");
|
|
646
880
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
647
|
-
io.out(" content_hash
|
|
881
|
+
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
882
|
+
io.out(" Either shape works, and which one you");
|
|
648
883
|
io.out(" passed is decided by stat(), not by the extension:");
|
|
649
884
|
io.out("");
|
|
650
885
|
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
651
886
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
652
887
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
653
|
-
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
888
|
+
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
654
889
|
io.out("");
|
|
655
890
|
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
656
|
-
io.out(" even-length
|
|
891
|
+
io.out(" even-length hex value, in either case, ⇒ hex,");
|
|
892
|
+
io.out(" else utf8).");
|
|
657
893
|
io.out("");
|
|
658
894
|
io.out(" In-body events[].content is verified automatically");
|
|
659
895
|
io.out(" and takes precedence. An event with no disclosed");
|
|
@@ -663,8 +899,11 @@ function printUsage(io) {
|
|
|
663
899
|
io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
|
|
664
900
|
io.out("bundle itself is verified. Both verdicts are printed when they differ.");
|
|
665
901
|
io.out("");
|
|
666
|
-
|
|
667
|
-
|
|
902
|
+
for (const line of EXIT_CODES_HELP.split("\n"))
|
|
903
|
+
io.out(line);
|
|
904
|
+
io.out("");
|
|
905
|
+
io.out("verify: an unreachable gateway reads partial, and a pack with a record");
|
|
906
|
+
io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
|
|
668
907
|
}
|
|
669
908
|
/**
|
|
670
909
|
* The disclosure account this command can honestly make, from what the VERIFIER
|
|
@@ -699,8 +938,10 @@ function cliDisclosureAccount(result, logs) {
|
|
|
699
938
|
extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
|
|
700
939
|
};
|
|
701
940
|
}
|
|
702
|
-
function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
941
|
+
function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
|
|
703
942
|
if (result.status === "malformed") {
|
|
943
|
+
if (sink)
|
|
944
|
+
sink.data = verifyJson(result, null, null, null);
|
|
704
945
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
705
946
|
for (const e of result.errors)
|
|
706
947
|
io.err(` - ${e}`);
|
|
@@ -784,7 +1025,7 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
784
1025
|
// answers `partial` and the line below says so in words, and pack grading
|
|
785
1026
|
// keeps its own verdict: every pack built before §5.4 carries no
|
|
786
1027
|
// declaration, and re-grading them all as undetermined would be a change to
|
|
787
|
-
//
|
|
1028
|
+
// their verdicts rather than a new check. A pack that DOES
|
|
788
1029
|
// carry a declaration this reader cannot read is a different case and exits
|
|
789
1030
|
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
790
1031
|
const declaration = checkDeclaration(parsed, logs?.content);
|
|
@@ -808,7 +1049,42 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
808
1049
|
if (result.events.length > 0) {
|
|
809
1050
|
io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
|
|
810
1051
|
}
|
|
811
|
-
|
|
1052
|
+
if (sink)
|
|
1053
|
+
sink.data = verifyJson(result, grade, declaration, chains);
|
|
1054
|
+
return mapGradeToExit(grade);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
1058
|
+
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
1059
|
+
* `chains` are null for a bundle no verdict could be rendered over.
|
|
1060
|
+
*/
|
|
1061
|
+
function verifyJson(result, grade, declaration, chains) {
|
|
1062
|
+
let chainBlock = null;
|
|
1063
|
+
if (chains !== null) {
|
|
1064
|
+
chainBlock =
|
|
1065
|
+
"unreadable" in chains
|
|
1066
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1067
|
+
: {
|
|
1068
|
+
unreadable: null,
|
|
1069
|
+
chains: chains.chains.length,
|
|
1070
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
verdict: grade ? grade.verdict : result.status,
|
|
1075
|
+
bundle_status: result.status,
|
|
1076
|
+
asserted_status: result.assertedStatus ?? null,
|
|
1077
|
+
body_type: result.bodyType ?? null,
|
|
1078
|
+
on_chain_checked: result.onChainChecked,
|
|
1079
|
+
declaration: declaration ? declaration.verdict : null,
|
|
1080
|
+
declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
|
|
1081
|
+
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
1082
|
+
notes: [...result.errors],
|
|
1083
|
+
records: grade
|
|
1084
|
+
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
1085
|
+
: null,
|
|
1086
|
+
chains: chainBlock,
|
|
1087
|
+
};
|
|
812
1088
|
}
|
|
813
1089
|
/**
|
|
814
1090
|
* The chain block: what following each record's pointer to the record before it
|
|
@@ -825,8 +1101,8 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
825
1101
|
* complete" does not follow, and two customer-facing documents had already
|
|
826
1102
|
* made that leap. The limit travels with the result or it gets dropped.
|
|
827
1103
|
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
828
|
-
* the verdict surface, and the
|
|
829
|
-
*
|
|
1104
|
+
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1105
|
+
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
830
1106
|
* decision this check is entitled to make.
|
|
831
1107
|
*/
|
|
832
1108
|
function reportChains(reading, io) {
|
|
@@ -868,7 +1144,16 @@ function reportExport(exp, io) {
|
|
|
868
1144
|
}
|
|
869
1145
|
}
|
|
870
1146
|
}
|
|
871
|
-
function reportAgentProof(result, path, gateways, io) {
|
|
1147
|
+
function reportAgentProof(result, path, gateways, io, sink) {
|
|
1148
|
+
if (sink) {
|
|
1149
|
+
sink.data = {
|
|
1150
|
+
verdict: result.status,
|
|
1151
|
+
bundle_status: result.status,
|
|
1152
|
+
spec_version: "sanning.agent.proof/v1",
|
|
1153
|
+
on_chain_checked: result.onChainChecked,
|
|
1154
|
+
notes: [...result.errors],
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
872
1157
|
if (result.status === "malformed") {
|
|
873
1158
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
874
1159
|
for (const e of result.errors)
|
|
@@ -886,7 +1171,7 @@ function reportAgentProof(result, path, gateways, io) {
|
|
|
886
1171
|
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
|
|
887
1172
|
for (const e of result.errors)
|
|
888
1173
|
io.out(paint(` note: ${e}`, DIM));
|
|
889
|
-
return
|
|
1174
|
+
return exitCodeFor(result.status);
|
|
890
1175
|
}
|
|
891
1176
|
function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
|
|
892
1177
|
const scope = onChainChecked
|
|
@@ -925,36 +1210,15 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
|
|
|
925
1210
|
}
|
|
926
1211
|
}
|
|
927
1212
|
/**
|
|
928
|
-
* The exit code
|
|
1213
|
+
* The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
|
|
929
1214
|
*
|
|
930
|
-
* `incomplete`
|
|
931
|
-
*
|
|
932
|
-
*
|
|
1215
|
+
* `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
|
|
1216
|
+
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1217
|
+
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1218
|
+
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
933
1219
|
*/
|
|
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;
|
|
1220
|
+
function mapGradeToExit(grade) {
|
|
1221
|
+
return exitCodeFor(grade.verdict);
|
|
958
1222
|
}
|
|
959
1223
|
// ─── What `proof case` reads (proof#141) ─────────────────────────────────────
|
|
960
1224
|
//
|
|
@@ -1039,7 +1303,8 @@ const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
|
|
|
1039
1303
|
const LOG_PATH = /^logs\/([^/]+)\.json$/;
|
|
1040
1304
|
const ZIP_NAME = /\.zip$/i;
|
|
1041
1305
|
/** The one line a `.zip` gets, as an argument or as the way through a refusal. */
|
|
1042
|
-
const UNZIP_FIRST = "
|
|
1306
|
+
const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
|
|
1307
|
+
"case/) and pass the folder.";
|
|
1043
1308
|
/**
|
|
1044
1309
|
* The input could not be read as a case. The message is the whole report,
|
|
1045
1310
|
* identical in both kernels, and the command exits 2 on it. `hint` is an
|
|
@@ -1055,50 +1320,26 @@ export class CaseInputError extends Error {
|
|
|
1055
1320
|
/**
|
|
1056
1321
|
* `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
|
|
1057
1322
|
* the one refusal that is not a parse error; `detail` is the parser's own words,
|
|
1058
|
-
* which differ by kernel and are printed by `verify` only.
|
|
1323
|
+
* which differ by kernel and are printed by `verify` only. `duplicate` marks the
|
|
1324
|
+
* one parse refusal whose `detail` is kernel wording, identical in both kernels
|
|
1325
|
+
* (`duplicate key "<name>"`, proof#144), so both commands print it.
|
|
1059
1326
|
*/
|
|
1060
1327
|
export class BundleReadError extends Error {
|
|
1061
1328
|
tooDeep;
|
|
1062
1329
|
detail;
|
|
1063
|
-
|
|
1330
|
+
duplicate;
|
|
1331
|
+
constructor(tooDeep, detail, duplicate = false) {
|
|
1064
1332
|
super(detail);
|
|
1065
1333
|
this.tooDeep = tooDeep;
|
|
1066
1334
|
this.detail = detail;
|
|
1335
|
+
this.duplicate = duplicate;
|
|
1067
1336
|
}
|
|
1068
1337
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
}
|
|
1338
|
+
// What both commands say about bytes that repeat a member name, before the
|
|
1339
|
+
// kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
|
|
1340
|
+
// I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
|
|
1341
|
+
// would be the wrong sentence. `NOT_I_JSON` in the Python twin.
|
|
1342
|
+
export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
|
|
1102
1343
|
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1103
1344
|
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
1104
1345
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
@@ -1107,7 +1348,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
1107
1348
|
* How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
|
|
1108
1349
|
* kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
|
|
1109
1350
|
* sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
|
|
1110
|
-
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse
|
|
1351
|
+
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
|
|
1352
|
+
* refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
|
|
1111
1353
|
* Throws `BundleReadError`.
|
|
1112
1354
|
*/
|
|
1113
1355
|
export function readBundleBytes(bytes) {
|
|
@@ -1116,9 +1358,11 @@ export function readBundleBytes(bytes) {
|
|
|
1116
1358
|
throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1117
1359
|
}
|
|
1118
1360
|
try {
|
|
1119
|
-
return
|
|
1361
|
+
return parseRefusingDuplicates(text);
|
|
1120
1362
|
}
|
|
1121
1363
|
catch (e) {
|
|
1364
|
+
if (e instanceof DuplicateKeyError)
|
|
1365
|
+
throw new BundleReadError(false, e.message, true);
|
|
1122
1366
|
throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
|
|
1123
1367
|
}
|
|
1124
1368
|
}
|
|
@@ -1136,7 +1380,7 @@ export async function splitCase(tree, read) {
|
|
|
1136
1380
|
const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
|
|
1137
1381
|
const hint = zips.length > 0
|
|
1138
1382
|
? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
|
|
1139
|
-
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
|
|
1383
|
+
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
|
|
1140
1384
|
UNZIP_FIRST
|
|
1141
1385
|
: null;
|
|
1142
1386
|
throw new CaseInputError(NO_BUNDLE, hint);
|
|
@@ -1186,6 +1430,30 @@ export async function splitCase(tree, read) {
|
|
|
1186
1430
|
}
|
|
1187
1431
|
return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
|
|
1188
1432
|
}
|
|
1433
|
+
/**
|
|
1434
|
+
* The bytes of the `witness-cache.json` beside a pack's `bundle.json`
|
|
1435
|
+
* (evidence-bundle 5.6.6, v1.25, A30), or `null`.
|
|
1436
|
+
*
|
|
1437
|
+
* The split rule is not changed for it: the file stays outside every pack,
|
|
1438
|
+
* listed as such, and is opened here and nowhere else. It is read only where it
|
|
1439
|
+
* is a regular file the split left outside every pack, so a file under another
|
|
1440
|
+
* pack's `logs/` is that pack's, and a link is never followed. A file that
|
|
1441
|
+
* cannot be read is no cache, never a refusal of the drop.
|
|
1442
|
+
*/
|
|
1443
|
+
async function witnessCacheBytes(tree, split, pack) {
|
|
1444
|
+
const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
|
|
1445
|
+
const entry = tree.get(path);
|
|
1446
|
+
if (entry === undefined || !entry.regular || !split.outside.includes(path))
|
|
1447
|
+
return null;
|
|
1448
|
+
try {
|
|
1449
|
+
return await readFileEntry(entry, path);
|
|
1450
|
+
}
|
|
1451
|
+
catch (e) {
|
|
1452
|
+
if (e instanceof CaseInputError)
|
|
1453
|
+
return null;
|
|
1454
|
+
throw e;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1189
1457
|
/** `verify`'s reader, with the refusal named by the pack's path. */
|
|
1190
1458
|
export function loadBundle(pack) {
|
|
1191
1459
|
try {
|
|
@@ -1195,6 +1463,9 @@ export function loadBundle(pack) {
|
|
|
1195
1463
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
1196
1464
|
throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1197
1465
|
}
|
|
1466
|
+
if (e instanceof BundleReadError && e.duplicate) {
|
|
1467
|
+
throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
1468
|
+
}
|
|
1198
1469
|
throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
|
|
1199
1470
|
}
|
|
1200
1471
|
}
|
|
@@ -1204,6 +1475,8 @@ export function loadBundle(pack) {
|
|
|
1204
1475
|
* the walk listed it is refused rather than followed or waited on.
|
|
1205
1476
|
*/
|
|
1206
1477
|
export async function readFileEntry(entry, key) {
|
|
1478
|
+
if (entry.data !== undefined && entry.data !== null)
|
|
1479
|
+
return entry.data;
|
|
1207
1480
|
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1208
1481
|
let handle;
|
|
1209
1482
|
try {
|
|
@@ -1301,24 +1574,79 @@ export async function treeFromArguments(paths) {
|
|
|
1301
1574
|
}
|
|
1302
1575
|
if (s.isDirectory())
|
|
1303
1576
|
continue;
|
|
1304
|
-
if (ZIP_NAME.test(arg))
|
|
1305
|
-
|
|
1306
|
-
throw new CaseInputError(`${arg} is not a folder. Pass pack folders,
|
|
1577
|
+
if (s.isFile() && ZIP_NAME.test(arg))
|
|
1578
|
+
continue;
|
|
1579
|
+
throw new CaseInputError(`${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`);
|
|
1307
1580
|
}
|
|
1308
1581
|
const tree = new Map();
|
|
1309
1582
|
const claimed = new Map();
|
|
1310
1583
|
const seen = { n: 0 };
|
|
1311
1584
|
for (const arg of paths) {
|
|
1312
|
-
|
|
1585
|
+
let isZip = false;
|
|
1586
|
+
if (ZIP_NAME.test(arg)) {
|
|
1587
|
+
try {
|
|
1588
|
+
isZip = !(await stat(arg)).isDirectory();
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
isZip = true;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
let name = baseName(arg);
|
|
1595
|
+
if (isZip) {
|
|
1596
|
+
// `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
|
|
1597
|
+
// the archive is read under the folder that command makes.
|
|
1598
|
+
name = name.slice(0, -".zip".length);
|
|
1599
|
+
if (name.length === 0)
|
|
1600
|
+
throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
1601
|
+
}
|
|
1313
1602
|
const earlier = claimed.get(name);
|
|
1314
1603
|
if (earlier !== undefined) {
|
|
1315
1604
|
throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
|
|
1316
1605
|
}
|
|
1317
1606
|
claimed.set(name, arg);
|
|
1318
|
-
|
|
1607
|
+
if (isZip)
|
|
1608
|
+
await unzip(arg, `${name}/`, tree, seen);
|
|
1609
|
+
else
|
|
1610
|
+
await walk(arg, `${name}/`, arg, tree, seen);
|
|
1319
1611
|
}
|
|
1320
1612
|
return tree;
|
|
1321
1613
|
}
|
|
1614
|
+
/**
|
|
1615
|
+
* A case file named on the command line, read as the folder `unzip` would make
|
|
1616
|
+
* of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
|
|
1617
|
+
* unpacked folder give one reading. The archive is read by `readZip` and
|
|
1618
|
+
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
1619
|
+
* read, as the walk lists a link.
|
|
1620
|
+
*/
|
|
1621
|
+
async function unzip(arg, prefix, tree, seen) {
|
|
1622
|
+
let data;
|
|
1623
|
+
try {
|
|
1624
|
+
data = new Uint8Array(await readFile(arg));
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
throw new CaseInputError(`cannot read ${arg}`);
|
|
1628
|
+
}
|
|
1629
|
+
let entries;
|
|
1630
|
+
try {
|
|
1631
|
+
entries = readZip(data);
|
|
1632
|
+
}
|
|
1633
|
+
catch (e) {
|
|
1634
|
+
if (e instanceof ZipReadError)
|
|
1635
|
+
throw new CaseInputError(`${arg} ${e.message}`);
|
|
1636
|
+
throw e;
|
|
1637
|
+
}
|
|
1638
|
+
for (const entry of entries) {
|
|
1639
|
+
seen.n++;
|
|
1640
|
+
if (seen.n > MAX_WALK_ENTRIES) {
|
|
1641
|
+
throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
|
|
1642
|
+
}
|
|
1643
|
+
tree.set(`${prefix}${entry.name}`, {
|
|
1644
|
+
fsPath: "",
|
|
1645
|
+
regular: entry.refused === null,
|
|
1646
|
+
data: entry.data,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1322
1650
|
// ─── `proof case` (proof#141) ────────────────────────────────────────────────
|
|
1323
1651
|
//
|
|
1324
1652
|
// The case reading from a terminal, so an auditor holding several packs does
|
|
@@ -1331,67 +1659,69 @@ export async function treeFromArguments(paths) {
|
|
|
1331
1659
|
// The reading is `readCase`, entire. This command splits the input into packs
|
|
1332
1660
|
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
1333
1661
|
// 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
|
|
1662
|
+
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
1663
|
+
// `mixed` has its own code, 4, because each other code would say something a
|
|
1664
|
+
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
1665
|
+
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
1666
|
+
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
1667
|
+
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
1668
|
+
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
1669
|
+
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
1349
1670
|
|
|
1350
|
-
sanning-proof case <folder>... [--gateways <url,url>] [--json]
|
|
1671
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
|
|
1351
1672
|
|
|
1352
|
-
<folder> a pack folder (bundle.json plus logs/),
|
|
1353
|
-
several packs,
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1673
|
+
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
1674
|
+
several packs, or an unzipped case file. Packs are found by the
|
|
1675
|
+
verify page's splitting rule: every bundle.json is a pack, a file
|
|
1676
|
+
belongs to the deepest pack whose bundle.json or logs/ it sits
|
|
1677
|
+
in, and every other file is listed as outside every pack and
|
|
1678
|
+
never opened. A .zip inside a folder is a file like any other. A
|
|
1679
|
+
symbolic link is never followed: it is listed as outside and not
|
|
1680
|
+
read, even under a pack's logs/. Several arguments are read as
|
|
1681
|
+
one folder holding them, each under its own name. A folder
|
|
1682
|
+
nested more than 32 levels deep, more than 50000 files and
|
|
1683
|
+
folders in all, or a name that is not valid UTF-8 exits 2.
|
|
1684
|
+
<case file> a case file named as <name>.zip, read as the folder
|
|
1685
|
+
unzip <name>.zip -d <name> would make, so the two give one
|
|
1686
|
+
reading. An entry that is encrypted, a symbolic link, stored by
|
|
1687
|
+
another method, or named outside the folder is listed as outside
|
|
1688
|
+
and not read.
|
|
1689
|
+
Where the folder or case file holds contents.json at its root,
|
|
1690
|
+
the index is compared with what is there and each disagreement
|
|
1691
|
+
is listed; the index never moves the verdict.
|
|
1366
1692
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1693
|
+
looked up on the public record, pack by pack, and reported on
|
|
1694
|
+
the witness line. The lookup never changes the verdict word.
|
|
1695
|
+
--files files handed in beside the packs, every path up to the next
|
|
1696
|
+
option, matched by SHA-256 alone against what every pack's
|
|
1697
|
+
signed records commit to. A file under a pack's logs/ is
|
|
1698
|
+
offered only when it is named here. The verdict never moves.
|
|
1699
|
+
--json the full reading as JSON: the verdict, every exception and note
|
|
1700
|
+
with its rendered line, every record, every contribution, the
|
|
1701
|
+
packs, the files outside them, the case file index comparison,
|
|
1702
|
+
the witness line and the on-chain half.
|
|
1373
1703
|
|
|
1374
1704
|
Each pack's bundle.json is read as verify reads it, so the two commands
|
|
1375
1705
|
classify one bundle the same way.
|
|
1376
1706
|
|
|
1377
1707
|
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.
|
|
1708
|
+
kernel and graded as a pack; every record every pack supplied is read, in one
|
|
1709
|
+
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1710
|
+
with no counterpart among the records read is always named, and when a pack
|
|
1711
|
+
signed by the key it names is in the reading, it says that pack holds none,
|
|
1712
|
+
unless every candidate it had was accounted for by another record (A20),
|
|
1713
|
+
which reads as no counterpart and names no pack.
|
|
1384
1714
|
|
|
1385
|
-
What it does not establish: that the
|
|
1386
|
-
hand over a pack); that a party which presented no pack under the named key
|
|
1715
|
+
What it does not establish: that the set of packs is complete (a party can
|
|
1716
|
+
decline to hand over a pack); that a party which presented no pack under the named key
|
|
1387
1717
|
recorded nothing; that two organizations are independent of each other; or
|
|
1388
|
-
that any witness
|
|
1718
|
+
that any witness is on the public record, which the reading never looks up.
|
|
1389
1719
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1720
|
+
${EXIT_CODES_HELP}
|
|
1721
|
+
|
|
1722
|
+
case: an input that cannot be read as packs, or an unexpected error in this
|
|
1723
|
+
command, exits 2. With --gateways, a witness that does not match on chain exits
|
|
1724
|
+
1, and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1395
1725
|
// What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
|
|
1396
1726
|
// rather than left to the stream, so the lines a test captures in process are
|
|
1397
1727
|
// the lines a reader sees.
|
|
@@ -1414,6 +1744,19 @@ function sortKeys(v) {
|
|
|
1414
1744
|
function jsonText(v) {
|
|
1415
1745
|
return JSON.stringify(sortKeys(v), null, 2);
|
|
1416
1746
|
}
|
|
1747
|
+
function witnessTimeRow(t) {
|
|
1748
|
+
return {
|
|
1749
|
+
stamp_ref: t.cached.stampRef,
|
|
1750
|
+
cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
|
|
1751
|
+
outcome: t.outcome,
|
|
1752
|
+
answers: t.answers.map((a) => ({
|
|
1753
|
+
gateway: a.gateway,
|
|
1754
|
+
outcome: a.outcome,
|
|
1755
|
+
block_height: a.blockHeight,
|
|
1756
|
+
block_time: a.blockTime,
|
|
1757
|
+
})),
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1417
1760
|
// Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
|
|
1418
1761
|
// case reading on purpose: `readCase` makes no network call, and neither does
|
|
1419
1762
|
// the verify page's case reading, so the case verdict stays the one both
|
|
@@ -1422,7 +1765,8 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1422
1765
|
if (gateways.length === 0)
|
|
1423
1766
|
return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1424
1767
|
const rows = [];
|
|
1425
|
-
|
|
1768
|
+
const answered = new Set();
|
|
1769
|
+
for (const [ref, bundle, cached] of packs) {
|
|
1426
1770
|
const result = await verifyEvidenceBundle(bundle, {
|
|
1427
1771
|
gateways,
|
|
1428
1772
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
@@ -1447,14 +1791,114 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1447
1791
|
else
|
|
1448
1792
|
row.not_checked++;
|
|
1449
1793
|
}
|
|
1794
|
+
// The witness times the pack's witness-cache.json gives, looked up where
|
|
1795
|
+
// it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
|
|
1796
|
+
// no request.
|
|
1797
|
+
const times = await checkWitnessCache(cached, gateways, io.fetchImpl ?? fetch);
|
|
1798
|
+
if (times.length > 0)
|
|
1799
|
+
row.witness_times = times.map(witnessTimeRow);
|
|
1450
1800
|
rows.push(row);
|
|
1801
|
+
for (const w of witnesses) {
|
|
1802
|
+
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
1803
|
+
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
1804
|
+
answered.add(outcome.gateway);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
for (const t of times) {
|
|
1809
|
+
for (const a of t.answers) {
|
|
1810
|
+
if (a.outcome === "matches" || a.outcome === "differs")
|
|
1811
|
+
answered.add(a.gateway);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1451
1814
|
}
|
|
1452
|
-
const verdict = rows.some((r) => r.mismatched > 0)
|
|
1815
|
+
const verdict = rows.some((r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"))
|
|
1453
1816
|
? "failed"
|
|
1454
1817
|
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1455
1818
|
? "undetermined"
|
|
1456
1819
|
: "confirmed";
|
|
1457
|
-
return {
|
|
1820
|
+
return {
|
|
1821
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
1822
|
+
checked: true,
|
|
1823
|
+
gateways: [...gateways],
|
|
1824
|
+
packs: rows,
|
|
1825
|
+
verdict,
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
|
|
1830
|
+
* half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
|
|
1831
|
+
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
1832
|
+
* headline.
|
|
1833
|
+
*/
|
|
1834
|
+
// Distinct `[blockHeight, blockTime]` pairs, in time order.
|
|
1835
|
+
function distinctTimes(times) {
|
|
1836
|
+
const seen = new Map();
|
|
1837
|
+
for (const [h, t] of times)
|
|
1838
|
+
seen.set(`${h}:${t}`, [h, t]);
|
|
1839
|
+
return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
1840
|
+
}
|
|
1841
|
+
// Only the keys that carry something: a line with no cached or recorded time
|
|
1842
|
+
// is the line 0.13.0 printed, field for field.
|
|
1843
|
+
function withTimes(line, cached, recorded) {
|
|
1844
|
+
return {
|
|
1845
|
+
...line,
|
|
1846
|
+
...(cached.length > 0 ? { cached } : {}),
|
|
1847
|
+
...(recorded.length > 0 ? { recorded } : {}),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
|
|
1852
|
+
* lookup ran, each is printed labelled not checked. Where one ran, each time
|
|
1853
|
+
* the public record matched is printed as the record's, each time no lookup
|
|
1854
|
+
* settled stays labelled not checked, and a time the record contradicts is the
|
|
1855
|
+
* reason the line reads `differs`.
|
|
1856
|
+
*/
|
|
1857
|
+
export function witnessLine(onChain, cached = []) {
|
|
1858
|
+
if (!onChain.checked) {
|
|
1859
|
+
return withTimes({
|
|
1860
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
1861
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
1862
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
1863
|
+
}, distinctTimes(cached.map((c) => [c.blockHeight, c.blockTime])), []);
|
|
1864
|
+
}
|
|
1865
|
+
const services = [...(onChain.answered ?? [])];
|
|
1866
|
+
const rows = onChain.packs;
|
|
1867
|
+
const times = rows.flatMap((r) => r.witness_times ?? []);
|
|
1868
|
+
const unsettled = distinctTimes(times
|
|
1869
|
+
.filter((t) => t.outcome === "not_checked")
|
|
1870
|
+
.map((t) => [t.cached.block_height, t.cached.block_time]));
|
|
1871
|
+
const recorded = distinctTimes(times
|
|
1872
|
+
.filter((t) => t.outcome === "matches")
|
|
1873
|
+
.flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
|
|
1874
|
+
.map((a) => [a.block_height, a.block_time]));
|
|
1875
|
+
const contradicted = [];
|
|
1876
|
+
for (const t of times) {
|
|
1877
|
+
if (t.outcome !== "differs")
|
|
1878
|
+
continue;
|
|
1879
|
+
const a = t.answers.find((x) => x.outcome === "differs");
|
|
1880
|
+
const reason = `a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
|
|
1881
|
+
`, and the public record dates it ${renderWitnessTime(a.block_height, a.block_time)}`;
|
|
1882
|
+
if (!contradicted.includes(reason))
|
|
1883
|
+
contradicted.push(reason);
|
|
1884
|
+
}
|
|
1885
|
+
if (onChain.verdict === "failed") {
|
|
1886
|
+
return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
|
|
1887
|
+
}
|
|
1888
|
+
if (onChain.verdict === "confirmed") {
|
|
1889
|
+
return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
|
|
1890
|
+
}
|
|
1891
|
+
const reasons = [];
|
|
1892
|
+
if (rows.some((r) => r.witnesses === 0))
|
|
1893
|
+
reasons.push("a pack names no witness");
|
|
1894
|
+
if (rows.some((r) => r.unreachable > 0)) {
|
|
1895
|
+
reasons.push("a lookup did not settle: the service could not be reached, or it " +
|
|
1896
|
+
"holds no such witness yet");
|
|
1897
|
+
}
|
|
1898
|
+
if (rows.some((r) => r.not_checked > 0)) {
|
|
1899
|
+
reasons.push("no lookup is made for a witness kind a pack names");
|
|
1900
|
+
}
|
|
1901
|
+
return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
|
|
1458
1902
|
}
|
|
1459
1903
|
/**
|
|
1460
1904
|
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
@@ -1462,20 +1906,37 @@ async function onChainHalf(packs, gateways, io) {
|
|
|
1462
1906
|
* on-chain half that could not be settled is never a clean 0.
|
|
1463
1907
|
*/
|
|
1464
1908
|
export function caseExitCode(verdict, onChainVerdict) {
|
|
1465
|
-
const code =
|
|
1909
|
+
const code = exitCodeFor(verdict);
|
|
1466
1910
|
if (onChainVerdict === "failed")
|
|
1467
1911
|
return EXIT_FAILED;
|
|
1468
1912
|
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
|
|
1469
1913
|
return EXIT_UNDETERMINED;
|
|
1470
1914
|
return code;
|
|
1471
1915
|
}
|
|
1916
|
+
const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
|
|
1917
|
+
? VERDICT_DISPLAY_TITLES[word]
|
|
1918
|
+
: word;
|
|
1919
|
+
/**
|
|
1920
|
+
* The text report, one line per entry, identical in both kernels. The headline
|
|
1921
|
+
* is the verdict's one display title and nothing else sets it (5.5.6, D7); the
|
|
1922
|
+
* scope sentence and the witness line are printed with it every time.
|
|
1923
|
+
*/
|
|
1472
1924
|
function caseReport(data) {
|
|
1473
|
-
const lines = [
|
|
1925
|
+
const lines = [
|
|
1926
|
+
data.title,
|
|
1927
|
+
` ${data.summary}`,
|
|
1928
|
+
` ${data.witness.rendered}`,
|
|
1929
|
+
` ${data.reliance}`,
|
|
1930
|
+
];
|
|
1474
1931
|
for (const e of data.exceptions)
|
|
1475
1932
|
lines.push(` - ${e.rendered}`);
|
|
1933
|
+
for (const n of data.notes)
|
|
1934
|
+
lines.push(` note: ${n.rendered}`);
|
|
1476
1935
|
lines.push(`packs ${data.packs.length}`);
|
|
1477
1936
|
for (const p of data.packs) {
|
|
1478
|
-
|
|
1937
|
+
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
1938
|
+
const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
|
|
1939
|
+
let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
|
|
1479
1940
|
if (p.unaccounted.length > 0) {
|
|
1480
1941
|
line +=
|
|
1481
1942
|
`; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
|
|
@@ -1491,28 +1952,79 @@ function caseReport(data) {
|
|
|
1491
1952
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
1492
1953
|
}
|
|
1493
1954
|
}
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1955
|
+
for (const c of data.case_file) {
|
|
1956
|
+
if (c.index === "absent")
|
|
1957
|
+
continue;
|
|
1958
|
+
const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
|
|
1959
|
+
lines.push(`case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
|
|
1960
|
+
`${label}; ${c.findings.length} disagreement(s) with what is here, ` +
|
|
1961
|
+
"none of which moves the verdict");
|
|
1962
|
+
for (const f of c.findings)
|
|
1963
|
+
lines.push(` - ${f.rendered}`);
|
|
1499
1964
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1965
|
+
const oc = data.on_chain;
|
|
1966
|
+
if (oc.checked) {
|
|
1967
|
+
lines.push(`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
|
|
1968
|
+
"pack by pack; the reading above is offline and this does not " +
|
|
1503
1969
|
"change it");
|
|
1504
1970
|
for (const r of oc.packs) {
|
|
1505
1971
|
lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
|
|
1506
1972
|
`confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
|
|
1507
1973
|
`unreachable, ${r.not_checked} not checked`);
|
|
1508
1974
|
}
|
|
1509
|
-
lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
|
|
1510
1975
|
}
|
|
1511
1976
|
return lines;
|
|
1512
1977
|
}
|
|
1978
|
+
/**
|
|
1979
|
+
* The index comparison, once per argument (5.6.3): each folder or case file
|
|
1980
|
+
* whose root holds `contents.json`. Every file under `packs/` and `report/` is
|
|
1981
|
+
* hashed for it, as bytes, and nothing is parsed but the index. It is never an
|
|
1982
|
+
* input to the reading.
|
|
1983
|
+
*/
|
|
1984
|
+
async function caseFileReadings(tree) {
|
|
1985
|
+
const out = [];
|
|
1986
|
+
const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0]))].sort();
|
|
1987
|
+
const keys = [...tree.keys()].sort();
|
|
1988
|
+
for (const root of roots) {
|
|
1989
|
+
const prefix = `${root}/`;
|
|
1990
|
+
const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
|
|
1991
|
+
const indexEntry = tree.get(indexKey);
|
|
1992
|
+
const indexBytes = indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
|
|
1993
|
+
const files = [];
|
|
1994
|
+
if (indexBytes !== null) {
|
|
1995
|
+
for (const key of keys) {
|
|
1996
|
+
const entry = tree.get(key);
|
|
1997
|
+
const rel = key.slice(prefix.length);
|
|
1998
|
+
if (key.startsWith(prefix) &&
|
|
1999
|
+
entry.regular &&
|
|
2000
|
+
(rel.startsWith("packs/") || rel.startsWith("report/"))) {
|
|
2001
|
+
files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
const reading = readCaseFileIndex(indexBytes, files);
|
|
2006
|
+
const nil = (v) => (v === undefined ? null : v);
|
|
2007
|
+
out.push({
|
|
2008
|
+
root,
|
|
2009
|
+
index: reading.index,
|
|
2010
|
+
case_id: reading.caseId,
|
|
2011
|
+
findings: reading.findings.map((f) => ({
|
|
2012
|
+
kind: f.kind,
|
|
2013
|
+
ref: nil(f.ref),
|
|
2014
|
+
path: nil(f.path),
|
|
2015
|
+
listed_sha256: nil(f.listedSha256),
|
|
2016
|
+
sha256: nil(f.sha256),
|
|
2017
|
+
reason: nil(f.reason),
|
|
2018
|
+
public_key: nil(f.publicKey),
|
|
2019
|
+
rendered: renderCaseFileFinding(f),
|
|
2020
|
+
})),
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
return out;
|
|
2024
|
+
}
|
|
1513
2025
|
function caseError(e, asJson, io) {
|
|
1514
2026
|
if (asJson)
|
|
1515
|
-
io
|
|
2027
|
+
outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
|
|
1516
2028
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
1517
2029
|
if (e.hint)
|
|
1518
2030
|
io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
@@ -1524,7 +2036,8 @@ function caseError(e, asJson, io) {
|
|
|
1524
2036
|
* never 1, because 1 would name the evidence failed over a defect in this
|
|
1525
2037
|
* command.
|
|
1526
2038
|
*/
|
|
1527
|
-
export async function runCase(argv,
|
|
2039
|
+
export async function runCase(argv, stream) {
|
|
2040
|
+
const io = guard(stream);
|
|
1528
2041
|
try {
|
|
1529
2042
|
return await caseCommand(argv, io);
|
|
1530
2043
|
}
|
|
@@ -1543,10 +2056,11 @@ async function caseCommand(argv, io) {
|
|
|
1543
2056
|
let asJson = false;
|
|
1544
2057
|
let gateways = [];
|
|
1545
2058
|
const paths = [];
|
|
2059
|
+
let filePaths;
|
|
1546
2060
|
for (let i = 0; i < argv.length; i++) {
|
|
1547
2061
|
const arg = argv[i];
|
|
1548
2062
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
1549
|
-
io
|
|
2063
|
+
outBlock(io, CASE_USAGE);
|
|
1550
2064
|
return EXIT_VERIFIED;
|
|
1551
2065
|
}
|
|
1552
2066
|
if (arg === "--json") {
|
|
@@ -1556,7 +2070,7 @@ async function caseCommand(argv, io) {
|
|
|
1556
2070
|
const next = argv[i + 1];
|
|
1557
2071
|
if (next === undefined) {
|
|
1558
2072
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
1559
|
-
io
|
|
2073
|
+
outBlock(io, CASE_USAGE);
|
|
1560
2074
|
return EXIT_MALFORMED;
|
|
1561
2075
|
}
|
|
1562
2076
|
gateways = next
|
|
@@ -1565,9 +2079,19 @@ async function caseCommand(argv, io) {
|
|
|
1565
2079
|
.filter((g) => g.length > 0);
|
|
1566
2080
|
i++;
|
|
1567
2081
|
}
|
|
2082
|
+
else if (arg === "--files") {
|
|
2083
|
+
const taken = takeFilePaths(argv, i + 1);
|
|
2084
|
+
if (taken.length === 0) {
|
|
2085
|
+
io.err("case: --files requires at least one file path");
|
|
2086
|
+
outBlock(io, CASE_USAGE);
|
|
2087
|
+
return EXIT_MALFORMED;
|
|
2088
|
+
}
|
|
2089
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
2090
|
+
i += taken.length;
|
|
2091
|
+
}
|
|
1568
2092
|
else if (arg.startsWith("--")) {
|
|
1569
2093
|
io.err(`case: unknown option ${arg}`);
|
|
1570
|
-
io
|
|
2094
|
+
outBlock(io, CASE_USAGE);
|
|
1571
2095
|
return EXIT_MALFORMED;
|
|
1572
2096
|
}
|
|
1573
2097
|
else {
|
|
@@ -1576,13 +2100,22 @@ async function caseCommand(argv, io) {
|
|
|
1576
2100
|
}
|
|
1577
2101
|
if (paths.length === 0) {
|
|
1578
2102
|
io.err("case: at least one pack is required");
|
|
1579
|
-
io
|
|
2103
|
+
outBlock(io, CASE_USAGE);
|
|
1580
2104
|
return EXIT_MALFORMED;
|
|
1581
2105
|
}
|
|
2106
|
+
let offered;
|
|
2107
|
+
if (filePaths !== undefined) {
|
|
2108
|
+
const read = await readOfferedFiles(filePaths);
|
|
2109
|
+
if (typeof read === "string")
|
|
2110
|
+
return caseError(new CaseInputError(read), asJson, io);
|
|
2111
|
+
offered = read;
|
|
2112
|
+
}
|
|
1582
2113
|
let split;
|
|
2114
|
+
let tree;
|
|
1583
2115
|
let bundles;
|
|
1584
2116
|
try {
|
|
1585
|
-
|
|
2117
|
+
tree = await treeFromArguments(paths);
|
|
2118
|
+
split = await splitCase(tree, readFileEntry);
|
|
1586
2119
|
bundles = split.packs.map((p) => [p, loadBundle(p)]);
|
|
1587
2120
|
}
|
|
1588
2121
|
catch (e) {
|
|
@@ -1591,13 +2124,37 @@ async function caseCommand(argv, io) {
|
|
|
1591
2124
|
throw e;
|
|
1592
2125
|
}
|
|
1593
2126
|
const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
|
|
1594
|
-
|
|
2127
|
+
let caseFile;
|
|
2128
|
+
try {
|
|
2129
|
+
caseFile = await caseFileReadings(tree);
|
|
2130
|
+
}
|
|
2131
|
+
catch (e) {
|
|
2132
|
+
if (e instanceof CaseInputError)
|
|
2133
|
+
return caseError(e, asJson, io);
|
|
2134
|
+
throw e;
|
|
2135
|
+
}
|
|
2136
|
+
const caches = [];
|
|
2137
|
+
for (const [p, bundle] of bundles) {
|
|
2138
|
+
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
|
|
2139
|
+
}
|
|
2140
|
+
const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, io);
|
|
2141
|
+
const witness = witnessLine(onChain, caches.flat());
|
|
1595
2142
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
1596
2143
|
const nil = (v) => (v === undefined ? null : v);
|
|
1597
2144
|
const data = {
|
|
1598
2145
|
verdict: reading.verdict,
|
|
2146
|
+
title: titleOf(reading.verdict),
|
|
2147
|
+
reliance: caseReliance(reading.verdict, reading.handoffRecords),
|
|
1599
2148
|
exit_code: code,
|
|
1600
2149
|
summary: renderCaseSummary(reading),
|
|
2150
|
+
witness: {
|
|
2151
|
+
state: witness.state,
|
|
2152
|
+
reasons: witness.reasons,
|
|
2153
|
+
services: witness.services,
|
|
2154
|
+
...(witness.cached ? { cached: witness.cached } : {}),
|
|
2155
|
+
...(witness.recorded ? { recorded: witness.recorded } : {}),
|
|
2156
|
+
rendered: renderWitnessLine(witness),
|
|
2157
|
+
},
|
|
1601
2158
|
handoff_records: reading.handoffRecords,
|
|
1602
2159
|
on_chain_checked: reading.onChainChecked,
|
|
1603
2160
|
exceptions: reading.exceptions.map((e) => ({
|
|
@@ -1608,32 +2165,55 @@ async function caseCommand(argv, io) {
|
|
|
1608
2165
|
failed_checks: e.failedChecks ?? [],
|
|
1609
2166
|
count: nil(e.count),
|
|
1610
2167
|
status: nil(e.status),
|
|
2168
|
+
causes: e.causes ?? [],
|
|
1611
2169
|
reasons: e.reasons ?? [],
|
|
1612
2170
|
rendered: renderException(e),
|
|
1613
2171
|
})),
|
|
2172
|
+
notes: reading.notes.map((n) => ({
|
|
2173
|
+
kind: n.kind,
|
|
2174
|
+
pack_ref: n.packRef,
|
|
2175
|
+
count: nil(n.count),
|
|
2176
|
+
rendered: renderCaseNote(n),
|
|
2177
|
+
})),
|
|
1614
2178
|
records: reading.records.map((r) => ({
|
|
1615
2179
|
pack_ref: r.packRef,
|
|
1616
2180
|
event_id: r.eventId,
|
|
1617
2181
|
verdict: nil(r.verdict),
|
|
2182
|
+
role: r.role,
|
|
1618
2183
|
findings: r.findings,
|
|
1619
2184
|
failed_checks: r.failedChecks,
|
|
1620
2185
|
counterparts: r.counterparts,
|
|
1621
2186
|
})),
|
|
1622
2187
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
1623
|
-
packs: bundles.map(([p]) => ({
|
|
2188
|
+
packs: bundles.map(([p], i) => ({
|
|
1624
2189
|
ref: p.ref,
|
|
1625
2190
|
bundle_path: p.bundlePath,
|
|
1626
2191
|
log_files: p.content.size,
|
|
1627
2192
|
unaccounted: p.unaccounted,
|
|
2193
|
+
signing_key: reading.packs[i].signingKey,
|
|
2194
|
+
grade: reading.packs[i].grade,
|
|
2195
|
+
records_withheld: reading.packs[i].recordsWithheld,
|
|
2196
|
+
declaration_version: reading.packs[i].declarationVersion,
|
|
1628
2197
|
})),
|
|
1629
2198
|
outside: split.outside,
|
|
1630
2199
|
not_regular: split.notRegular,
|
|
2200
|
+
case_file: caseFile,
|
|
1631
2201
|
on_chain: onChain,
|
|
1632
2202
|
};
|
|
2203
|
+
// Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
|
|
2204
|
+
// logs/ is its side content and is never an offered file; only the paths
|
|
2205
|
+
// after --files are. Nothing here moves `code`.
|
|
2206
|
+
let fileLines = [];
|
|
2207
|
+
const out = data;
|
|
2208
|
+
if (offered !== undefined) {
|
|
2209
|
+
const fileReading = await matchCaseFiles(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), offered);
|
|
2210
|
+
out.files = fileMatchToJson(fileReading);
|
|
2211
|
+
fileLines = renderFileMatch(fileReading);
|
|
2212
|
+
}
|
|
1633
2213
|
if (asJson)
|
|
1634
|
-
io
|
|
2214
|
+
outJson(io, jsonText(out));
|
|
1635
2215
|
else
|
|
1636
|
-
for (const line of caseReport(data))
|
|
2216
|
+
for (const line of [...caseReport(data), ...fileLines])
|
|
1637
2217
|
io.out(noLoneSurrogates(line));
|
|
1638
2218
|
return code;
|
|
1639
2219
|
}
|
|
@@ -1670,7 +2250,7 @@ if (isMain) {
|
|
|
1670
2250
|
process.exitCode = code;
|
|
1671
2251
|
})
|
|
1672
2252
|
.catch((e) => {
|
|
1673
|
-
process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
2253
|
+
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
1674
2254
|
process.exitCode = EXIT_MALFORMED;
|
|
1675
2255
|
});
|
|
1676
2256
|
}
|