@sanning/proof 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -8
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +349 -0
- package/dist/case.d.ts +199 -43
- package/dist/case.js +522 -146
- package/dist/cli.d.ts +132 -1
- package/dist/cli.js +1140 -96
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.js +134 -19
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/handoff.d.ts +16 -1
- package/dist/handoff.js +91 -16
- package/dist/index.d.ts +10 -7
- package/dist/index.js +15 -7
- package/dist/message-parts.d.ts +11 -9
- package/dist/message-parts.js +11 -9
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +9 -2
- package/dist/verifier.js +28 -12
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +419 -0
- package/src/case.ts +651 -156
- package/src/cli.ts +1325 -100
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +142 -21
- package/src/exit-codes.ts +54 -0
- package/src/grading.ts +109 -52
- package/src/handoff.ts +108 -17
- package/src/index.ts +60 -3
- package/src/message-parts.ts +11 -9
- package/src/types.ts +5 -0
- package/src/verifier.ts +35 -13
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +56 -3
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +1102 -0
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +3 -3
package/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,38 +31,95 @@
|
|
|
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
|
|
34
41
|
// surfaces as a per-event note, and it does NOT fail the run. It does hold the
|
|
35
42
|
// PACK at `incomplete`, because a record nobody hashed is a record nobody
|
|
36
43
|
// checked, and exit 3 is how this command says "undetermined".
|
|
37
|
-
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
38
|
-
import { realpathSync } from "node:fs";
|
|
39
|
-
import {
|
|
44
|
+
import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
45
|
+
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
46
|
+
import { randomBytes } from "node:crypto";
|
|
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";
|
|
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";
|
|
43
54
|
import { composeExport } from "./compose.js";
|
|
44
|
-
import { isHexField, utf8 } from "./crypto.js";
|
|
55
|
+
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
45
56
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
46
57
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
47
58
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
|
60
123
|
function useColor() {
|
|
61
124
|
// Honor NO_COLOR; otherwise color only a TTY.
|
|
62
125
|
if (process.env.NO_COLOR)
|
|
@@ -74,7 +137,8 @@ function mark(ok) {
|
|
|
74
137
|
function paint(s, c) {
|
|
75
138
|
return useColor() ? `${c}${s}${X}` : s;
|
|
76
139
|
}
|
|
77
|
-
export async function runCli(argv,
|
|
140
|
+
export async function runCli(argv, stream) {
|
|
141
|
+
const io = guard(stream);
|
|
78
142
|
const [command, ...rest] = argv;
|
|
79
143
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
80
144
|
printUsage(io);
|
|
@@ -84,24 +148,72 @@ export async function runCli(argv, io) {
|
|
|
84
148
|
io.out("@sanning/proof verify CLI");
|
|
85
149
|
return EXIT_VERIFIED;
|
|
86
150
|
}
|
|
87
|
-
// `
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
160
|
+
}
|
|
161
|
+
if (command === "case") {
|
|
162
|
+
return runCase(rest, io);
|
|
92
163
|
}
|
|
93
164
|
if (command !== "verify") {
|
|
94
165
|
io.err(`unknown command: ${command}`);
|
|
95
166
|
printUsage(io);
|
|
96
167
|
return EXIT_MALFORMED;
|
|
97
168
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
101
201
|
let logsPath;
|
|
202
|
+
let gatewayArg;
|
|
102
203
|
const positional = [];
|
|
103
204
|
for (let i = 0; i < rest.length; i++) {
|
|
104
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
|
+
}
|
|
105
217
|
if (a === "--logs") {
|
|
106
218
|
const next = rest[i + 1];
|
|
107
219
|
if (next === undefined) {
|
|
@@ -115,12 +227,17 @@ export async function runCli(argv, io) {
|
|
|
115
227
|
}
|
|
116
228
|
positional.push(a);
|
|
117
229
|
}
|
|
118
|
-
const [bundlePath
|
|
230
|
+
const [bundlePath] = positional;
|
|
119
231
|
if (!bundlePath) {
|
|
120
232
|
io.err("verify: a bundle file path is required");
|
|
121
233
|
printUsage(io);
|
|
122
234
|
return EXIT_MALFORMED;
|
|
123
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
|
+
}
|
|
124
241
|
const gateways = (gatewayArg ?? "")
|
|
125
242
|
.split(",")
|
|
126
243
|
.map((g) => g.trim())
|
|
@@ -136,18 +253,26 @@ export async function runCli(argv, io) {
|
|
|
136
253
|
const content = logs?.content;
|
|
137
254
|
let raw;
|
|
138
255
|
try {
|
|
139
|
-
raw = await readFile(bundlePath
|
|
256
|
+
raw = new Uint8Array(await readFile(bundlePath));
|
|
140
257
|
}
|
|
141
258
|
catch (e) {
|
|
142
259
|
io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
143
260
|
return EXIT_MALFORMED;
|
|
144
261
|
}
|
|
262
|
+
// The one reader both commands use, in both kernels: `case` reads each pack's
|
|
263
|
+
// bundle.json through it too, so the two commands classify one bundle the
|
|
264
|
+
// same way (see `readBundleBytes`).
|
|
145
265
|
let parsed;
|
|
146
266
|
try {
|
|
147
|
-
parsed =
|
|
267
|
+
parsed = readBundleBytes(raw);
|
|
148
268
|
}
|
|
149
269
|
catch (e) {
|
|
150
|
-
|
|
270
|
+
if (e instanceof BundleReadError && e.tooDeep) {
|
|
271
|
+
io.err(`${bundlePath} ${e.detail}; refusing to read it`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
275
|
+
}
|
|
151
276
|
return EXIT_MALFORMED;
|
|
152
277
|
}
|
|
153
278
|
const specVersion = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
@@ -159,11 +284,11 @@ export async function runCli(argv, io) {
|
|
|
159
284
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
160
285
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
161
286
|
}
|
|
162
|
-
const result = await
|
|
287
|
+
const result = await verifyAgentProof(parsed, {
|
|
163
288
|
gateways,
|
|
164
289
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
165
290
|
});
|
|
166
|
-
return reportAgentProof(result, bundlePath, gateways, io);
|
|
291
|
+
return reportAgentProof(result, bundlePath, gateways, io, sink);
|
|
167
292
|
}
|
|
168
293
|
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
169
294
|
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
@@ -192,26 +317,29 @@ export async function runCli(argv, io) {
|
|
|
192
317
|
catch (e) {
|
|
193
318
|
chains = { unreadable: errMsg(e) };
|
|
194
319
|
}
|
|
195
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
|
|
320
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
196
321
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
197
322
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
198
323
|
if (logs && result.status !== "malformed")
|
|
199
324
|
reportLogsCoverage(logs, result, io);
|
|
200
325
|
return exit;
|
|
201
326
|
}
|
|
202
|
-
// `proof
|
|
203
|
-
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
327
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
204
328
|
//
|
|
205
329
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
206
330
|
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
207
|
-
// 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>`
|
|
208
332
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
209
333
|
//
|
|
210
334
|
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
211
335
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
212
336
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
213
337
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
214
|
-
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
|
+
}
|
|
215
343
|
let attestationsPath;
|
|
216
344
|
let keyPath;
|
|
217
345
|
let outPath;
|
|
@@ -228,19 +356,19 @@ async function runBundle(rest, io) {
|
|
|
228
356
|
i++;
|
|
229
357
|
return next;
|
|
230
358
|
};
|
|
231
|
-
if (a === "--attestations"
|
|
359
|
+
if (a === "--attestations") {
|
|
232
360
|
const v = takeNext(a);
|
|
233
361
|
if (v === undefined)
|
|
234
362
|
return usageExport(io);
|
|
235
363
|
attestationsPath = v;
|
|
236
364
|
}
|
|
237
|
-
else if (a === "--key"
|
|
365
|
+
else if (a === "--key") {
|
|
238
366
|
const v = takeNext(a);
|
|
239
367
|
if (v === undefined)
|
|
240
368
|
return usageExport(io);
|
|
241
369
|
keyPath = v;
|
|
242
370
|
}
|
|
243
|
-
else if (a === "--out"
|
|
371
|
+
else if (a === "--out") {
|
|
244
372
|
const v = takeNext(a);
|
|
245
373
|
if (v === undefined)
|
|
246
374
|
return usageExport(io);
|
|
@@ -270,6 +398,13 @@ async function runBundle(rest, io) {
|
|
|
270
398
|
return usageExport(io);
|
|
271
399
|
opts.generatedAt = v;
|
|
272
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
|
+
}
|
|
273
408
|
else {
|
|
274
409
|
positional.push(a);
|
|
275
410
|
}
|
|
@@ -314,6 +449,18 @@ async function runBundle(rest, io) {
|
|
|
314
449
|
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
315
450
|
// still writes the artifact so the caller can inspect it.
|
|
316
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
|
+
}
|
|
317
464
|
if (outPath) {
|
|
318
465
|
try {
|
|
319
466
|
await writeFile(outPath, serialized, "utf8");
|
|
@@ -327,25 +474,32 @@ async function runBundle(rest, io) {
|
|
|
327
474
|
`attestations ${composed.boundAttestations}/${attestations.length} bound`);
|
|
328
475
|
}
|
|
329
476
|
else {
|
|
330
|
-
io
|
|
477
|
+
outJson(io, serialized.replace(/\n$/, ""));
|
|
331
478
|
}
|
|
332
479
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
333
480
|
}
|
|
334
481
|
function usageExport(io) {
|
|
335
|
-
io.out("Usage:
|
|
482
|
+
io.out("Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]");
|
|
336
483
|
io.out("");
|
|
337
484
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
338
485
|
io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
|
|
339
|
-
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>`.");
|
|
340
487
|
io.out("");
|
|
341
488
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
342
489
|
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
343
490
|
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
344
|
-
io.out("
|
|
491
|
+
io.out("--out <file> Write the export here (default: stdout).");
|
|
345
492
|
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
346
493
|
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
347
494
|
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
348
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.");
|
|
349
503
|
return EXIT_MALFORMED;
|
|
350
504
|
}
|
|
351
505
|
// Read + parse a JSON file; report a clear error and return undefined on any
|
|
@@ -573,7 +727,8 @@ function reportLogsCoverage(logs, result, io) {
|
|
|
573
727
|
const undetermined = result.events.filter((e) => e.contentOk === null);
|
|
574
728
|
if (undetermined.length > 0) {
|
|
575
729
|
io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
576
|
-
`content
|
|
730
|
+
`content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
731
|
+
`hand-off record) to bind them to. ` +
|
|
577
732
|
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
|
|
578
733
|
}
|
|
579
734
|
}
|
|
@@ -616,30 +771,39 @@ function decodeLogValue(v) {
|
|
|
616
771
|
return utf8(v); // raw utf8 bytes
|
|
617
772
|
}
|
|
618
773
|
function printUsage(io) {
|
|
619
|
-
io.out("Usage:
|
|
774
|
+
io.out("Usage: sanning-proof <command> [args]");
|
|
775
|
+
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
620
776
|
io.out("");
|
|
621
777
|
io.out("Commands:");
|
|
622
|
-
io.out(" verify <bundle.json> [gateways] [--logs <path>]
|
|
623
|
-
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");
|
|
781
|
+
io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
|
|
782
|
+
io.out(" (`case --help` for its exit codes)");
|
|
624
783
|
io.out("");
|
|
625
784
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
626
785
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
627
786
|
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
628
|
-
io.out("fully offline.
|
|
629
|
-
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;");
|
|
630
789
|
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
631
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("");
|
|
632
794
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
633
|
-
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");
|
|
634
797
|
io.out(" passed is decided by stat(), not by the extension:");
|
|
635
798
|
io.out("");
|
|
636
799
|
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
637
800
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
638
801
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
639
|
-
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
802
|
+
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
640
803
|
io.out("");
|
|
641
804
|
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
642
|
-
io.out(" even-length
|
|
805
|
+
io.out(" even-length hex value, in either case, ⇒ hex,");
|
|
806
|
+
io.out(" else utf8).");
|
|
643
807
|
io.out("");
|
|
644
808
|
io.out(" In-body events[].content is verified automatically");
|
|
645
809
|
io.out(" and takes precedence. An event with no disclosed");
|
|
@@ -649,8 +813,11 @@ function printUsage(io) {
|
|
|
649
813
|
io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
|
|
650
814
|
io.out("bundle itself is verified. Both verdicts are printed when they differ.");
|
|
651
815
|
io.out("");
|
|
652
|
-
|
|
653
|
-
|
|
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.");
|
|
654
821
|
}
|
|
655
822
|
/**
|
|
656
823
|
* The disclosure account this command can honestly make, from what the VERIFIER
|
|
@@ -685,8 +852,10 @@ function cliDisclosureAccount(result, logs) {
|
|
|
685
852
|
extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
|
|
686
853
|
};
|
|
687
854
|
}
|
|
688
|
-
function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
855
|
+
function reportEvidence(result, path, gateways, io, chains, parsed, logs, sink) {
|
|
689
856
|
if (result.status === "malformed") {
|
|
857
|
+
if (sink)
|
|
858
|
+
sink.data = verifyJson(result, null, null, null);
|
|
690
859
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
691
860
|
for (const e of result.errors)
|
|
692
861
|
io.err(` - ${e}`);
|
|
@@ -770,7 +939,7 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
770
939
|
// answers `partial` and the line below says so in words, and pack grading
|
|
771
940
|
// keeps its own verdict: every pack built before §5.4 carries no
|
|
772
941
|
// declaration, and re-grading them all as undetermined would be a change to
|
|
773
|
-
//
|
|
942
|
+
// their verdicts rather than a new check. A pack that DOES
|
|
774
943
|
// carry a declaration this reader cannot read is a different case and exits
|
|
775
944
|
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
776
945
|
const declaration = checkDeclaration(parsed, logs?.content);
|
|
@@ -794,7 +963,42 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
794
963
|
if (result.events.length > 0) {
|
|
795
964
|
io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
|
|
796
965
|
}
|
|
797
|
-
|
|
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
|
+
};
|
|
798
1002
|
}
|
|
799
1003
|
/**
|
|
800
1004
|
* The chain block: what following each record's pointer to the record before it
|
|
@@ -811,8 +1015,8 @@ function reportEvidence(result, path, gateways, io, chains, parsed, logs) {
|
|
|
811
1015
|
* complete" does not follow, and two customer-facing documents had already
|
|
812
1016
|
* made that leap. The limit travels with the result or it gets dropped.
|
|
813
1017
|
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
814
|
-
* the verdict surface, and the
|
|
815
|
-
*
|
|
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
|
|
816
1020
|
* decision this check is entitled to make.
|
|
817
1021
|
*/
|
|
818
1022
|
function reportChains(reading, io) {
|
|
@@ -854,7 +1058,16 @@ function reportExport(exp, io) {
|
|
|
854
1058
|
}
|
|
855
1059
|
}
|
|
856
1060
|
}
|
|
857
|
-
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
|
+
}
|
|
858
1071
|
if (result.status === "malformed") {
|
|
859
1072
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
860
1073
|
for (const e of result.errors)
|
|
@@ -872,7 +1085,7 @@ function reportAgentProof(result, path, gateways, io) {
|
|
|
872
1085
|
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
|
|
873
1086
|
for (const e of result.errors)
|
|
874
1087
|
io.out(paint(` note: ${e}`, DIM));
|
|
875
|
-
return
|
|
1088
|
+
return exitCodeFor(result.status);
|
|
876
1089
|
}
|
|
877
1090
|
function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked, io) {
|
|
878
1091
|
const scope = onChainChecked
|
|
@@ -911,36 +1124,867 @@ function printRollup(headline, bundleStatus, asserted, gateways, onChainChecked,
|
|
|
911
1124
|
}
|
|
912
1125
|
}
|
|
913
1126
|
/**
|
|
914
|
-
* The exit code
|
|
1127
|
+
* The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
|
|
915
1128
|
*
|
|
916
|
-
* `incomplete`
|
|
917
|
-
*
|
|
918
|
-
*
|
|
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.
|
|
919
1133
|
*/
|
|
920
|
-
function mapGradeToExit(grade
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1134
|
+
function mapGradeToExit(grade) {
|
|
1135
|
+
return exitCodeFor(grade.verdict);
|
|
1136
|
+
}
|
|
1137
|
+
// ─── What `proof case` reads (proof#141) ─────────────────────────────────────
|
|
1138
|
+
//
|
|
1139
|
+
// Directories on disk, split into packs. This lives in the CLI module, and not
|
|
1140
|
+
// in a kernel module, because it is the command's own read of files a person
|
|
1141
|
+
// named on the command line, the same class of read as `verify`'s read of
|
|
1142
|
+
// `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
|
|
1143
|
+
// `readBundleBytes`.
|
|
1144
|
+
//
|
|
1145
|
+
// The case READING is `readCase`, and nothing here touches it. This turns the
|
|
1146
|
+
// directories a reader typed into a list of packs. Its twin is the same section
|
|
1147
|
+
// of `src/sanning_proof/cli.py`, and both are held to one table,
|
|
1148
|
+
// `vectors/case-split.json`, whose rows are directory trees.
|
|
1149
|
+
//
|
|
1150
|
+
// ── Directories only ──
|
|
1151
|
+
//
|
|
1152
|
+
// `case` reads no archive. A `.zip` argument exits 2 with one line that says to
|
|
1153
|
+
// unzip it first and pass the folder, and a `.zip` inside a directory is a file
|
|
1154
|
+
// like any other: outside every pack, and never opened. Reading archives in the
|
|
1155
|
+
// kernel brought zip bombs, two zip libraries that disagree, and entry names
|
|
1156
|
+
// that are not file names, and `unzip` already does that job.
|
|
1157
|
+
//
|
|
1158
|
+
// ── The walk ──
|
|
1159
|
+
//
|
|
1160
|
+
// Iterative, never recursive, bytewise-sorted at each level, and bounded:
|
|
1161
|
+
//
|
|
1162
|
+
// - A directory nested more than MAX_WALK_DEPTH levels below an argument
|
|
1163
|
+
// refuses the whole input (exit 2).
|
|
1164
|
+
// - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
|
|
1165
|
+
// the whole input (exit 2).
|
|
1166
|
+
// - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
|
|
1167
|
+
// not escaped or replaced: two different names would then read as one.
|
|
1168
|
+
// - A symbolic link is never followed, whatever it points at, and anything
|
|
1169
|
+
// else that is not a regular file or a directory is never opened. Each is
|
|
1170
|
+
// listed as outside every pack and not read, even when it sits under a
|
|
1171
|
+
// pack's `logs/` or is named `bundle.json`.
|
|
1172
|
+
//
|
|
1173
|
+
// ── The split: the verify page's rule over the tree ──
|
|
1174
|
+
//
|
|
1175
|
+
// The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
|
|
1176
|
+
// `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
|
|
1177
|
+
// apply the same splitting rule to the same unpacked tree. The page's rule
|
|
1178
|
+
// lives in the page, so neither kernel can call it; it is restated here and in
|
|
1179
|
+
// the Python twin, and the shared table pins it:
|
|
1180
|
+
//
|
|
1181
|
+
// 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
|
|
1182
|
+
// at the directory that holds it.
|
|
1183
|
+
// 2. A file belongs to the pack with the LONGEST root it sits under, and only
|
|
1184
|
+
// if it is that root's `bundle.json` or under that root's `logs/`.
|
|
1185
|
+
// 3. A file under no pack is OUTSIDE: listed, and never opened.
|
|
1186
|
+
// 4. Roots and outside paths are sorted by UTF-16 code unit (the default
|
|
1187
|
+
// `Array.prototype.sort`).
|
|
1188
|
+
// 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
|
|
1189
|
+
// and any other file under `logs/` is UNACCOUNTED.
|
|
1190
|
+
// 6. A pack's `ref` is its root without the trailing slash.
|
|
1191
|
+
// 7. No `bundle.json` anywhere refuses.
|
|
1192
|
+
//
|
|
1193
|
+
// One directory argument is read at `<its name>/<path inside it>`, which is how
|
|
1194
|
+
// the page names the files of a dropped folder. Several are read as one folder
|
|
1195
|
+
// holding them, each under its own name, and two that would land at one name
|
|
1196
|
+
// refuse.
|
|
1197
|
+
//
|
|
1198
|
+
// Known differences from the page: the page opens a dropped `.zip` and this
|
|
1199
|
+
// command does not; the page drops a leading byte-order mark from
|
|
1200
|
+
// `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
|
|
1201
|
+
// browser hands it for a link, a name that is not UTF-8 or a very deep tree,
|
|
1202
|
+
// where this command applies the walk's rules above; and the page keys
|
|
1203
|
+
// disclosed content by a plain object, so it loses a record named `__proto__`,
|
|
1204
|
+
// which this command keeps.
|
|
1205
|
+
/** The error text for an input with no pack in it, word for word the page's. */
|
|
1206
|
+
export const NO_BUNDLE = "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
|
|
1207
|
+
/** A directory nested deeper than this below an argument refuses the input. */
|
|
1208
|
+
export const MAX_WALK_DEPTH = 32;
|
|
1209
|
+
/** More names than this, across every argument, refuse the input. */
|
|
1210
|
+
export const MAX_WALK_ENTRIES = 50_000;
|
|
1211
|
+
/**
|
|
1212
|
+
* `bundle.json` nested deeper than this is refused before it is parsed, by
|
|
1213
|
+
* `verify` and by `case` alike (see `readBundleBytes`).
|
|
1214
|
+
*/
|
|
1215
|
+
export const MAX_JSON_DEPTH = 512;
|
|
1216
|
+
const BUNDLE_PATH = /(?:^|\/)bundle\.json$/;
|
|
1217
|
+
const LOG_PATH = /^logs\/([^/]+)\.json$/;
|
|
1218
|
+
const ZIP_NAME = /\.zip$/i;
|
|
1219
|
+
/** The one line a `.zip` gets, as an argument or as the way through a refusal. */
|
|
1220
|
+
const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
|
|
1221
|
+
"case/) and pass the folder.";
|
|
1222
|
+
/**
|
|
1223
|
+
* The input could not be read as a case. The message is the whole report,
|
|
1224
|
+
* identical in both kernels, and the command exits 2 on it. `hint` is an
|
|
1225
|
+
* optional second line naming a way through.
|
|
1226
|
+
*/
|
|
1227
|
+
export class CaseInputError extends Error {
|
|
1228
|
+
hint;
|
|
1229
|
+
constructor(message, hint = null) {
|
|
1230
|
+
super(message);
|
|
1231
|
+
this.hint = hint;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
|
|
1236
|
+
* the one refusal that is not a parse error; `detail` is the parser's own words,
|
|
1237
|
+
* which differ by kernel and are printed by `verify` only.
|
|
1238
|
+
*/
|
|
1239
|
+
export class BundleReadError extends Error {
|
|
1240
|
+
tooDeep;
|
|
1241
|
+
detail;
|
|
1242
|
+
constructor(tooDeep, detail) {
|
|
1243
|
+
super(detail);
|
|
1244
|
+
this.tooDeep = tooDeep;
|
|
1245
|
+
this.detail = detail;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1249
|
+
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
1250
|
+
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
1251
|
+
const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
1252
|
+
/**
|
|
1253
|
+
* How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
|
|
1254
|
+
* kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
|
|
1255
|
+
* sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
|
|
1256
|
+
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
|
|
1257
|
+
* Throws `BundleReadError`.
|
|
1258
|
+
*/
|
|
1259
|
+
export function readBundleBytes(bytes) {
|
|
1260
|
+
const text = bundleDecoder.decode(bytes);
|
|
1261
|
+
if (jsonDepthExceeds(text, MAX_JSON_DEPTH)) {
|
|
1262
|
+
throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
return JSON.parse(text);
|
|
1266
|
+
}
|
|
1267
|
+
catch (e) {
|
|
1268
|
+
throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The page's `splitCase` followed by its `assemble` per pack, over the walk's
|
|
1273
|
+
* tree. `read` opens a file; it is called for the files a pack owns and for
|
|
1274
|
+
* nothing else.
|
|
1275
|
+
*/
|
|
1276
|
+
export async function splitCase(tree, read) {
|
|
1277
|
+
const roots = [...tree]
|
|
1278
|
+
.filter(([p, e]) => e.regular && BUNDLE_PATH.test(p))
|
|
1279
|
+
.map(([p]) => p.slice(0, p.length - "bundle.json".length))
|
|
1280
|
+
.sort();
|
|
1281
|
+
if (roots.length === 0) {
|
|
1282
|
+
const zips = [...tree.keys()].filter((p) => ZIP_NAME.test(p)).sort();
|
|
1283
|
+
const hint = zips.length > 0
|
|
1284
|
+
? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
|
|
1285
|
+
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
|
|
1286
|
+
UNZIP_FIRST
|
|
1287
|
+
: null;
|
|
1288
|
+
throw new CaseInputError(NO_BUNDLE, hint);
|
|
1289
|
+
}
|
|
1290
|
+
const owned = new Map(roots.map((r) => [r, []]));
|
|
1291
|
+
const outside = [];
|
|
1292
|
+
const notRegular = [];
|
|
1293
|
+
for (const [path, entry] of tree) {
|
|
1294
|
+
if (!entry.regular) {
|
|
1295
|
+
outside.push(path);
|
|
1296
|
+
notRegular.push(path);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
let best = null;
|
|
1300
|
+
for (const root of roots) {
|
|
1301
|
+
const mine = path === `${root}bundle.json` || path.startsWith(`${root}logs/`);
|
|
1302
|
+
if (mine && (best === null || root.length > best.length))
|
|
1303
|
+
best = root;
|
|
1304
|
+
}
|
|
1305
|
+
if (best === null)
|
|
1306
|
+
outside.push(path);
|
|
1307
|
+
else
|
|
1308
|
+
owned.get(best).push(path);
|
|
1309
|
+
}
|
|
1310
|
+
const packs = [];
|
|
1311
|
+
for (const root of roots) {
|
|
1312
|
+
const bundlePath = `${root}bundle.json`;
|
|
1313
|
+
const content = new Map();
|
|
1314
|
+
const unaccounted = [];
|
|
1315
|
+
for (const p of owned.get(root).sort()) {
|
|
1316
|
+
if (p === bundlePath)
|
|
1317
|
+
continue;
|
|
1318
|
+
const rel = p.slice(root.length);
|
|
1319
|
+
const m = LOG_PATH.exec(rel);
|
|
1320
|
+
if (m)
|
|
1321
|
+
content.set(m[1], await read(tree.get(p), p));
|
|
1322
|
+
else
|
|
1323
|
+
unaccounted.push(rel);
|
|
1324
|
+
}
|
|
1325
|
+
packs.push({
|
|
1326
|
+
ref: root.slice(0, -1),
|
|
1327
|
+
bundlePath,
|
|
1328
|
+
bundleBytes: await read(tree.get(bundlePath), bundlePath),
|
|
1329
|
+
content,
|
|
1330
|
+
unaccounted: unaccounted.sort(),
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
|
|
1334
|
+
}
|
|
1335
|
+
/** `verify`'s reader, with the refusal named by the pack's path. */
|
|
1336
|
+
export function loadBundle(pack) {
|
|
1337
|
+
try {
|
|
1338
|
+
return readBundleBytes(pack.bundleBytes);
|
|
1339
|
+
}
|
|
1340
|
+
catch (e) {
|
|
1341
|
+
if (e instanceof BundleReadError && e.tooDeep) {
|
|
1342
|
+
throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1343
|
+
}
|
|
1344
|
+
throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* A regular file's bytes. Opened without following a link and without
|
|
1349
|
+
* blocking, then checked again, so a file swapped for a link or a pipe after
|
|
1350
|
+
* the walk listed it is refused rather than followed or waited on.
|
|
1351
|
+
*/
|
|
1352
|
+
export async function readFileEntry(entry, key) {
|
|
1353
|
+
if (entry.data !== undefined && entry.data !== null)
|
|
1354
|
+
return entry.data;
|
|
1355
|
+
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1356
|
+
let handle;
|
|
1357
|
+
try {
|
|
1358
|
+
handle = await open(entry.fsPath, flags);
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
throw new CaseInputError(`cannot read ${key}`);
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
if (!(await handle.stat()).isFile())
|
|
1365
|
+
throw new CaseInputError(`cannot read ${key}`);
|
|
1366
|
+
return new Uint8Array(await handle.readFile());
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
throw new CaseInputError(`cannot read ${key}`);
|
|
1370
|
+
}
|
|
1371
|
+
finally {
|
|
1372
|
+
await handle.close();
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
|
|
1377
|
+
* first from an explicit queue, never by recursion. `seen` is the count of
|
|
1378
|
+
* names listed so far, across every argument.
|
|
1379
|
+
*/
|
|
1380
|
+
async function walk(root, prefix, arg, tree, seen) {
|
|
1381
|
+
const queue = [[root, prefix, 0]];
|
|
1382
|
+
for (let head = 0; head < queue.length; head++) {
|
|
1383
|
+
const [directory, rel, depth] = queue[head];
|
|
1384
|
+
let names;
|
|
1385
|
+
try {
|
|
1386
|
+
names = await readdir(directory, { encoding: "buffer" });
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
throw new CaseInputError(`cannot read ${rel.slice(0, -1)}`);
|
|
1390
|
+
}
|
|
1391
|
+
names.sort(Buffer.compare);
|
|
1392
|
+
for (const raw of names) {
|
|
1393
|
+
seen.n++;
|
|
1394
|
+
if (seen.n > MAX_WALK_ENTRIES) {
|
|
1395
|
+
throw new CaseInputError(`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`);
|
|
1396
|
+
}
|
|
1397
|
+
let name;
|
|
1398
|
+
try {
|
|
1399
|
+
name = nameDecoder.decode(raw);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
throw new CaseInputError(`${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`);
|
|
1403
|
+
}
|
|
1404
|
+
const path = join(directory, name);
|
|
1405
|
+
const key = `${rel}${name}`;
|
|
1406
|
+
let s;
|
|
1407
|
+
try {
|
|
1408
|
+
s = await lstat(path);
|
|
1409
|
+
}
|
|
1410
|
+
catch {
|
|
1411
|
+
throw new CaseInputError(`cannot read ${key}`);
|
|
1412
|
+
}
|
|
1413
|
+
if (s.isDirectory()) {
|
|
1414
|
+
if (depth + 1 > MAX_WALK_DEPTH) {
|
|
1415
|
+
throw new CaseInputError(`${arg} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`);
|
|
1416
|
+
}
|
|
1417
|
+
queue.push([path, `${key}/`, depth + 1]);
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
tree.set(key, { fsPath: path, regular: s.isFile() });
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
// The name the page would see for a dropped folder: the last segment of the
|
|
1426
|
+
// absolute path, so `.` and `pack/` name the directory itself.
|
|
1427
|
+
function baseName(path) {
|
|
1428
|
+
return basename(resolve(path));
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Every name under the directories named, keyed `<name>/<path inside>`. Each
|
|
1432
|
+
* argument must be a directory, and not a link to one. A `.zip` is refused
|
|
1433
|
+
* with the way through.
|
|
1434
|
+
*/
|
|
1435
|
+
export async function treeFromArguments(paths) {
|
|
1436
|
+
if (paths.length === 0)
|
|
1437
|
+
throw new CaseInputError("case: at least one pack is required");
|
|
1438
|
+
for (const arg of paths) {
|
|
1439
|
+
// `link/` resolves the link; the name without the slash does not.
|
|
1440
|
+
let s;
|
|
1441
|
+
try {
|
|
1442
|
+
s = await lstat(arg.replace(/\/+$/, "") || arg);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
throw new CaseInputError(`cannot read ${arg}`);
|
|
1446
|
+
}
|
|
1447
|
+
if (s.isSymbolicLink()) {
|
|
1448
|
+
throw new CaseInputError(`${arg} is a symbolic link, which case does not follow; pass the folder it points to`);
|
|
1449
|
+
}
|
|
1450
|
+
if (s.isDirectory())
|
|
1451
|
+
continue;
|
|
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.`);
|
|
1455
|
+
}
|
|
1456
|
+
const tree = new Map();
|
|
1457
|
+
const claimed = new Map();
|
|
1458
|
+
const seen = { n: 0 };
|
|
1459
|
+
for (const arg of paths) {
|
|
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
|
+
}
|
|
1477
|
+
const earlier = claimed.get(name);
|
|
1478
|
+
if (earlier !== undefined) {
|
|
1479
|
+
throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
|
|
1480
|
+
}
|
|
1481
|
+
claimed.set(name, arg);
|
|
1482
|
+
if (isZip)
|
|
1483
|
+
await unzip(arg, `${name}/`, tree, seen);
|
|
1484
|
+
else
|
|
1485
|
+
await walk(arg, `${name}/`, arg, tree, seen);
|
|
1486
|
+
}
|
|
1487
|
+
return tree;
|
|
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
|
+
}
|
|
1525
|
+
// ─── `proof case` (proof#141) ────────────────────────────────────────────────
|
|
1526
|
+
//
|
|
1527
|
+
// The case reading from a terminal, so an auditor holding several packs does
|
|
1528
|
+
// not depend on Sanning's page for the one check the audited party cannot
|
|
1529
|
+
// write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
|
|
1530
|
+
// the two commands are compared as child processes by
|
|
1531
|
+
// `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
|
|
1532
|
+
// over every `case/` vector.
|
|
1533
|
+
//
|
|
1534
|
+
// The reading is `readCase`, entire. This command splits the input into packs
|
|
1535
|
+
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
1536
|
+
// No color: the two commands print the same bytes.
|
|
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
|
|
1545
|
+
|
|
1546
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
|
|
1547
|
+
|
|
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.
|
|
1567
|
+
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
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.
|
|
1574
|
+
|
|
1575
|
+
Each pack's bundle.json is read as verify reads it, so the two commands
|
|
1576
|
+
classify one bundle the same way.
|
|
1577
|
+
|
|
1578
|
+
What a case reading establishes: every pack is verified offline by this
|
|
1579
|
+
kernel 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.
|
|
1585
|
+
|
|
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
|
|
1588
|
+
recorded nothing; that two organizations are independent of each other; or
|
|
1589
|
+
that any witness is on the public record, which the reading never looks up.
|
|
1590
|
+
|
|
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.`;
|
|
1596
|
+
// What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
|
|
1597
|
+
// rather than left to the stream, so the lines a test captures in process are
|
|
1598
|
+
// the lines a reader sees.
|
|
1599
|
+
function noLoneSurrogates(text) {
|
|
1600
|
+
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
|
|
1601
|
+
}
|
|
1602
|
+
// Keys sorted at every depth, arrays left in their order: the Python twin
|
|
1603
|
+
// writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
|
|
1604
|
+
// that, byte for byte.
|
|
1605
|
+
function sortKeys(v) {
|
|
1606
|
+
if (Array.isArray(v))
|
|
1607
|
+
return v.map(sortKeys);
|
|
1608
|
+
if (v !== null && typeof v === "object") {
|
|
1609
|
+
return Object.fromEntries(Object.keys(v)
|
|
1610
|
+
.sort()
|
|
1611
|
+
.map((k) => [k, sortKeys(v[k])]));
|
|
1612
|
+
}
|
|
1613
|
+
return v;
|
|
1614
|
+
}
|
|
1615
|
+
function jsonText(v) {
|
|
1616
|
+
return JSON.stringify(sortKeys(v), null, 2);
|
|
1617
|
+
}
|
|
1618
|
+
// Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
|
|
1619
|
+
// case reading on purpose: `readCase` makes no network call, and neither does
|
|
1620
|
+
// the verify page's case reading, so the case verdict stays the one both
|
|
1621
|
+
// surfaces compute. This adds the half the reading names as not checked.
|
|
1622
|
+
async function onChainHalf(packs, gateways, io) {
|
|
1623
|
+
if (gateways.length === 0)
|
|
1624
|
+
return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1625
|
+
const rows = [];
|
|
1626
|
+
const answered = new Set();
|
|
1627
|
+
for (const [ref, bundle] of packs) {
|
|
1628
|
+
const result = await verifyEvidenceBundle(bundle, {
|
|
1629
|
+
gateways,
|
|
1630
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
1631
|
+
});
|
|
1632
|
+
const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
|
|
1633
|
+
const row = {
|
|
1634
|
+
ref,
|
|
1635
|
+
witnesses: witnesses.length,
|
|
1636
|
+
confirmed: 0,
|
|
1637
|
+
mismatched: 0,
|
|
1638
|
+
unreachable: 0,
|
|
1639
|
+
not_checked: 0,
|
|
1640
|
+
};
|
|
1641
|
+
for (const w of witnesses) {
|
|
1642
|
+
const rollup = w.onChain?.rollup ?? null;
|
|
1643
|
+
if (rollup === "confirm")
|
|
1644
|
+
row.confirmed++;
|
|
1645
|
+
else if (rollup === "mismatch")
|
|
1646
|
+
row.mismatched++;
|
|
1647
|
+
else if (rollup === "unreachable")
|
|
1648
|
+
row.unreachable++;
|
|
1649
|
+
else
|
|
1650
|
+
row.not_checked++;
|
|
1651
|
+
}
|
|
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
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
const verdict = rows.some((r) => r.mismatched > 0)
|
|
1662
|
+
? "failed"
|
|
1663
|
+
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1664
|
+
? "undetermined"
|
|
1665
|
+
: "confirmed";
|
|
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 };
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
1708
|
+
* a witness that does not match is a failure whatever the reading said, and an
|
|
1709
|
+
* on-chain half that could not be settled is never a clean 0.
|
|
1710
|
+
*/
|
|
1711
|
+
export function caseExitCode(verdict, onChainVerdict) {
|
|
1712
|
+
const code = exitCodeFor(verdict);
|
|
1713
|
+
if (onChainVerdict === "failed")
|
|
924
1714
|
return EXIT_FAILED;
|
|
925
|
-
if (
|
|
1715
|
+
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
|
|
926
1716
|
return EXIT_UNDETERMINED;
|
|
927
|
-
return
|
|
1717
|
+
return code;
|
|
928
1718
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
+
*/
|
|
1727
|
+
function caseReport(data) {
|
|
1728
|
+
const lines = [
|
|
1729
|
+
data.title,
|
|
1730
|
+
` ${data.summary}`,
|
|
1731
|
+
` ${data.witness.rendered}`,
|
|
1732
|
+
` ${data.reliance}`,
|
|
1733
|
+
];
|
|
1734
|
+
for (const e of data.exceptions)
|
|
1735
|
+
lines.push(` - ${e.rendered}`);
|
|
1736
|
+
for (const n of data.notes)
|
|
1737
|
+
lines.push(` note: ${n.rendered}`);
|
|
1738
|
+
lines.push(`packs ${data.packs.length}`);
|
|
1739
|
+
for (const p of data.packs) {
|
|
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/`;
|
|
1743
|
+
if (p.unaccounted.length > 0) {
|
|
1744
|
+
line +=
|
|
1745
|
+
`; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
|
|
1746
|
+
`record: ${p.unaccounted.join(", ")}`;
|
|
1747
|
+
}
|
|
1748
|
+
lines.push(line);
|
|
1749
|
+
}
|
|
1750
|
+
if (data.outside.length > 0) {
|
|
1751
|
+
lines.push(`outside ${data.outside.length} file(s) sit outside every ` +
|
|
1752
|
+
"pack, so no verdict here is about them");
|
|
1753
|
+
const links = new Set(data.not_regular);
|
|
1754
|
+
for (const path of data.outside) {
|
|
1755
|
+
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
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}`);
|
|
1767
|
+
}
|
|
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 " +
|
|
1772
|
+
"change it");
|
|
1773
|
+
for (const r of oc.packs) {
|
|
1774
|
+
lines.push(` ${r.ref} ${r.witnesses} witness(es): ${r.confirmed} ` +
|
|
1775
|
+
`confirmed, ${r.mismatched} mismatched, ${r.unreachable} ` +
|
|
1776
|
+
`unreachable, ${r.not_checked} not checked`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
return lines;
|
|
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
|
+
}
|
|
1828
|
+
function caseError(e, asJson, io) {
|
|
1829
|
+
if (asJson)
|
|
1830
|
+
outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
|
|
1831
|
+
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
1832
|
+
if (e.hint)
|
|
1833
|
+
io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
1834
|
+
return EXIT_MALFORMED;
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
|
|
1838
|
+
* command is an exit code from the table: an exception nobody expected exits 2,
|
|
1839
|
+
* never 1, because 1 would name the evidence failed over a defect in this
|
|
1840
|
+
* command.
|
|
1841
|
+
*/
|
|
1842
|
+
export async function runCase(argv, stream) {
|
|
1843
|
+
const io = guard(stream);
|
|
1844
|
+
try {
|
|
1845
|
+
return await caseCommand(argv, io);
|
|
1846
|
+
}
|
|
1847
|
+
catch (e) {
|
|
1848
|
+
const name = e instanceof Error ? e.constructor.name : typeof e;
|
|
1849
|
+
try {
|
|
1850
|
+
return caseError(new CaseInputError(`an unexpected error stopped the reading (${name}); this is a defect in the ` +
|
|
1851
|
+
"command, not a verdict on the evidence"), argv.includes("--json"), io);
|
|
1852
|
+
}
|
|
1853
|
+
catch {
|
|
1854
|
+
return EXIT_MALFORMED; // a stream that cannot be written
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
async function caseCommand(argv, io) {
|
|
1859
|
+
let asJson = false;
|
|
1860
|
+
let gateways = [];
|
|
1861
|
+
const paths = [];
|
|
1862
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1863
|
+
const arg = argv[i];
|
|
1864
|
+
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
1865
|
+
outBlock(io, CASE_USAGE);
|
|
1866
|
+
return EXIT_VERIFIED;
|
|
1867
|
+
}
|
|
1868
|
+
if (arg === "--json") {
|
|
1869
|
+
asJson = true;
|
|
1870
|
+
}
|
|
1871
|
+
else if (arg === "--gateways") {
|
|
1872
|
+
const next = argv[i + 1];
|
|
1873
|
+
if (next === undefined) {
|
|
1874
|
+
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
1875
|
+
outBlock(io, CASE_USAGE);
|
|
1876
|
+
return EXIT_MALFORMED;
|
|
1877
|
+
}
|
|
1878
|
+
gateways = next
|
|
1879
|
+
.split(",")
|
|
1880
|
+
.map((g) => g.trim())
|
|
1881
|
+
.filter((g) => g.length > 0);
|
|
1882
|
+
i++;
|
|
1883
|
+
}
|
|
1884
|
+
else if (arg.startsWith("--")) {
|
|
1885
|
+
io.err(`case: unknown option ${arg}`);
|
|
1886
|
+
outBlock(io, CASE_USAGE);
|
|
1887
|
+
return EXIT_MALFORMED;
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
paths.push(arg);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (paths.length === 0) {
|
|
1894
|
+
io.err("case: at least one pack is required");
|
|
1895
|
+
outBlock(io, CASE_USAGE);
|
|
933
1896
|
return EXIT_MALFORMED;
|
|
934
|
-
// "partial" caused specifically by an unreachable gateway — or an export
|
|
935
|
-
// source_bundle_ref whose bytes are unavailable offline (evidence-export.md
|
|
936
|
-
// §5 step 3) — maps to exit 3 (undetermined, network-dependent).
|
|
937
|
-
if (status === "partial" &&
|
|
938
|
-
errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))) {
|
|
939
|
-
return EXIT_UNDETERMINED;
|
|
940
1897
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1898
|
+
let split;
|
|
1899
|
+
let tree;
|
|
1900
|
+
let bundles;
|
|
1901
|
+
try {
|
|
1902
|
+
tree = await treeFromArguments(paths);
|
|
1903
|
+
split = await splitCase(tree, readFileEntry);
|
|
1904
|
+
bundles = split.packs.map((p) => [p, loadBundle(p)]);
|
|
1905
|
+
}
|
|
1906
|
+
catch (e) {
|
|
1907
|
+
if (e instanceof CaseInputError)
|
|
1908
|
+
return caseError(e, asJson, io);
|
|
1909
|
+
throw e;
|
|
1910
|
+
}
|
|
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
|
+
}
|
|
1921
|
+
const onChain = await onChainHalf(bundles.map(([p, bundle]) => [p.ref, bundle]), gateways, io);
|
|
1922
|
+
const witness = witnessLine(onChain);
|
|
1923
|
+
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
1924
|
+
const nil = (v) => (v === undefined ? null : v);
|
|
1925
|
+
const data = {
|
|
1926
|
+
verdict: reading.verdict,
|
|
1927
|
+
title: titleOf(reading.verdict),
|
|
1928
|
+
reliance: CASE_RELIANCE[reading.verdict],
|
|
1929
|
+
exit_code: code,
|
|
1930
|
+
summary: renderCaseSummary(reading),
|
|
1931
|
+
witness: {
|
|
1932
|
+
state: witness.state,
|
|
1933
|
+
reasons: witness.reasons,
|
|
1934
|
+
services: witness.services,
|
|
1935
|
+
rendered: renderWitnessLine(witness),
|
|
1936
|
+
},
|
|
1937
|
+
handoff_records: reading.handoffRecords,
|
|
1938
|
+
on_chain_checked: reading.onChainChecked,
|
|
1939
|
+
exceptions: reading.exceptions.map((e) => ({
|
|
1940
|
+
kind: e.kind,
|
|
1941
|
+
pack_ref: nil(e.packRef),
|
|
1942
|
+
event_id: nil(e.eventId),
|
|
1943
|
+
counterparty_key: nil(e.counterpartyKey),
|
|
1944
|
+
failed_checks: e.failedChecks ?? [],
|
|
1945
|
+
count: nil(e.count),
|
|
1946
|
+
status: nil(e.status),
|
|
1947
|
+
causes: e.causes ?? [],
|
|
1948
|
+
reasons: e.reasons ?? [],
|
|
1949
|
+
rendered: renderException(e),
|
|
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
|
+
})),
|
|
1957
|
+
records: reading.records.map((r) => ({
|
|
1958
|
+
pack_ref: r.packRef,
|
|
1959
|
+
event_id: r.eventId,
|
|
1960
|
+
verdict: nil(r.verdict),
|
|
1961
|
+
role: r.role,
|
|
1962
|
+
findings: r.findings,
|
|
1963
|
+
failed_checks: r.failedChecks,
|
|
1964
|
+
counterparts: r.counterparts,
|
|
1965
|
+
})),
|
|
1966
|
+
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
1967
|
+
packs: bundles.map(([p], i) => ({
|
|
1968
|
+
ref: p.ref,
|
|
1969
|
+
bundle_path: p.bundlePath,
|
|
1970
|
+
log_files: p.content.size,
|
|
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,
|
|
1976
|
+
})),
|
|
1977
|
+
outside: split.outside,
|
|
1978
|
+
not_regular: split.notRegular,
|
|
1979
|
+
case_file: caseFile,
|
|
1980
|
+
on_chain: onChain,
|
|
1981
|
+
};
|
|
1982
|
+
if (asJson)
|
|
1983
|
+
outJson(io, jsonText(data));
|
|
1984
|
+
else
|
|
1985
|
+
for (const line of caseReport(data))
|
|
1986
|
+
io.out(noLoneSurrogates(line));
|
|
1987
|
+
return code;
|
|
944
1988
|
}
|
|
945
1989
|
function shortTx(tx) {
|
|
946
1990
|
if (tx.length <= 16)
|
|
@@ -975,7 +2019,7 @@ if (isMain) {
|
|
|
975
2019
|
process.exitCode = code;
|
|
976
2020
|
})
|
|
977
2021
|
.catch((e) => {
|
|
978
|
-
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`);
|
|
979
2023
|
process.exitCode = EXIT_MALFORMED;
|
|
980
2024
|
});
|
|
981
2025
|
}
|