@sanning/proof 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -12
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +349 -0
- package/dist/case.d.ts +173 -26
- package/dist/case.js +469 -123
- package/dist/cli.d.ts +38 -5
- package/dist/cli.js +557 -208
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.js +134 -19
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +10 -7
- package/dist/index.js +13 -6
- package/dist/message-parts.d.ts +11 -9
- package/dist/message-parts.js +11 -9
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +9 -2
- package/dist/verifier.js +28 -12
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +419 -0
- package/src/case.ts +586 -132
- package/src/cli.ts +651 -206
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +142 -21
- package/src/exit-codes.ts +54 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +58 -2
- package/src/message-parts.ts +11 -9
- package/src/types.ts +5 -0
- package/src/verifier.ts +35 -13
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +23 -5
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +3 -3
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
2
|
+
// sanning-proof verify <bundle.json> [--gateways <url,url>]
|
|
3
|
+
// (`npx @sanning/proof verify ...` without installing: npm runs a package's
|
|
4
|
+
// only binary whatever it is called.)
|
|
3
5
|
//
|
|
4
6
|
// One turnkey verifier for both producer bundles in the stack:
|
|
5
7
|
// - sanning.evidence/v1: bodies sanning.anchor.trace/v3, sanning.stamp.trace/v1,
|
|
@@ -7,12 +9,16 @@
|
|
|
7
9
|
// - sanning.agent.proof/v1: the agent inclusion proof
|
|
8
10
|
// Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
|
|
9
11
|
//
|
|
10
|
-
//
|
|
11
|
-
// 0 verified
|
|
12
|
-
// 1
|
|
13
|
-
// 2 malformed
|
|
14
|
-
// 3 UNDETERMINED:
|
|
15
|
-
//
|
|
12
|
+
// Exit codes: one table for every verb, `./exit-codes.ts` (0.13.0):
|
|
13
|
+
// 0 verified, or the export was written
|
|
14
|
+
// 1 failed (bad signature / tamper / broken inclusion / on-chain mismatch)
|
|
15
|
+
// 2 usage or malformed (unparseable, unknown spec major, body_hash mismatch)
|
|
16
|
+
// 3 UNDETERMINED: partial, pending, incomplete or not_found. A gateway that
|
|
17
|
+
// could not be reached reads `partial`; a pack whose records nobody hashed
|
|
18
|
+
// reads `incomplete` (see below)
|
|
19
|
+
// 4 mixed
|
|
20
|
+
// `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
|
|
21
|
+
// exited 0 on `partial`; it moves with this one.
|
|
16
22
|
//
|
|
17
23
|
// ── The headline is the PACK verdict, not the bundle status (proof#110) ──
|
|
18
24
|
//
|
|
@@ -25,9 +31,10 @@
|
|
|
25
31
|
// terminal: two answers to one question, from one kernel, which is the exact
|
|
26
32
|
// defect `grading.ts` was brought into the kernel to end.
|
|
27
33
|
//
|
|
28
|
-
// The two
|
|
29
|
-
// comments, and `evidence-bundle.md` §3
|
|
30
|
-
// BUNDLE status is still reported, unchanged, on
|
|
34
|
+
// The two layers stay distinct, deliberately (`grading.ts` says so in its own
|
|
35
|
+
// comments, and `incomplete` is a pack verdict in `evidence-bundle.md` §3,
|
|
36
|
+
// never a bundle status): the BUNDLE status is still reported, unchanged, on
|
|
37
|
+
// its own line. What changed is
|
|
31
38
|
// which of the two is the headline and which one the exit code follows.
|
|
32
39
|
//
|
|
33
40
|
// `payloadHashOk === null` (a withheld record) is semantics-undetermined and
|
|
@@ -37,10 +44,11 @@
|
|
|
37
44
|
|
|
38
45
|
import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
39
46
|
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
47
|
+
import { randomBytes } from "node:crypto";
|
|
40
48
|
import { basename, join, resolve } from "node:path";
|
|
41
49
|
import { fileURLToPath } from "node:url";
|
|
42
50
|
|
|
43
|
-
import { isAgentProofSpec,
|
|
51
|
+
import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
|
|
44
52
|
import type { AgentProofResult } from "./agent-proof.js";
|
|
45
53
|
import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
|
|
46
54
|
|
|
@@ -48,22 +56,41 @@ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js"
|
|
|
48
56
|
// named statement that one could not be produced. Three states, not two.
|
|
49
57
|
type ChainOutcome = ChainReading | { unreadable: string };
|
|
50
58
|
import type { ChainReading } from "./chain.js";
|
|
51
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
CASE_RELIANCE,
|
|
61
|
+
VERDICT_DISPLAY_TITLES,
|
|
62
|
+
WITNESS_NOT_LOOKED_UP,
|
|
63
|
+
type WitnessLine,
|
|
64
|
+
readCase,
|
|
65
|
+
renderCaseNote,
|
|
66
|
+
renderCaseSummary,
|
|
67
|
+
renderException,
|
|
68
|
+
renderWitnessLine,
|
|
69
|
+
} from "./case.js";
|
|
70
|
+
import {
|
|
71
|
+
CASE_FILE_INDEX_NAME,
|
|
72
|
+
jsonDepthExceeds,
|
|
73
|
+
readCaseFileIndex,
|
|
74
|
+
renderCaseFileFinding,
|
|
75
|
+
} from "./case-file.js";
|
|
76
|
+
import { ZipReadError, readZip } from "./zip-reader.js";
|
|
52
77
|
import { composeExport } from "./compose.js";
|
|
53
78
|
import type { ComposeExportOptions } from "./compose.js";
|
|
54
|
-
import { isHexField, utf8 } from "./crypto.js";
|
|
79
|
+
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
55
80
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
56
81
|
import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
|
|
57
82
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
58
83
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
59
84
|
import type { DisclosureAccount, PackGrade } from "./grading.js";
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
86
|
+
import {
|
|
87
|
+
EXIT_CODES_HELP,
|
|
88
|
+
EXIT_FAILED,
|
|
89
|
+
EXIT_MALFORMED,
|
|
90
|
+
EXIT_UNDETERMINED,
|
|
91
|
+
EXIT_VERIFIED,
|
|
92
|
+
exitCodeFor,
|
|
93
|
+
} from "./exit-codes.js";
|
|
67
94
|
|
|
68
95
|
interface Cli {
|
|
69
96
|
out: (s: string) => void;
|
|
@@ -71,12 +98,78 @@ interface Cli {
|
|
|
71
98
|
fetchImpl?: typeof fetch;
|
|
72
99
|
}
|
|
73
100
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
101
|
+
// ── What a person reads is escaped at one choke point ──
|
|
102
|
+
//
|
|
103
|
+
// Every human-readable line this command prints passes through `guard`, which
|
|
104
|
+
// `runCli` and `runCase` apply to the streams they are handed. It escapes each
|
|
105
|
+
// control character, so a value the audited party wrote (a folder or file
|
|
106
|
+
// name, a `contents.json` field, a zip entry name, an exception built from
|
|
107
|
+
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
108
|
+
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
109
|
+
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
110
|
+
// only, which escapes its own control characters. The twin is `Io` in
|
|
111
|
+
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
112
|
+
|
|
113
|
+
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
114
|
+
// embedding, override and isolate controls.
|
|
115
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
116
|
+
|
|
117
|
+
/** Each control character in `text` as a visible escape: `\x1b` for ESC,
|
|
118
|
+
* `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
|
|
119
|
+
* backslash included, so a line with none is printed as it was. */
|
|
120
|
+
export function escapeControl(text: string): string {
|
|
121
|
+
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
122
|
+
const code = c.charCodeAt(0);
|
|
123
|
+
return code < 0x100
|
|
124
|
+
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
125
|
+
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The colors this command writes on a TTY are its own control characters, and
|
|
130
|
+
// the choke point would escape them with everything else. So `paint` and
|
|
131
|
+
// `mark` write this marker instead, and the choke point turns it into ESC
|
|
132
|
+
// after escaping. It is random per process, so no input can spell it.
|
|
133
|
+
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
134
|
+
const G = `${COLOR_MARK}32m`;
|
|
135
|
+
const R = `${COLOR_MARK}31m`;
|
|
136
|
+
const Y = `${COLOR_MARK}33m`;
|
|
137
|
+
const DIM = `${COLOR_MARK}2m`;
|
|
138
|
+
const B = `${COLOR_MARK}1m`;
|
|
139
|
+
const X = `${COLOR_MARK}0m`;
|
|
140
|
+
|
|
141
|
+
function forTerminal(text: string): string {
|
|
142
|
+
return escapeControl(text).split(COLOR_MARK).join("\x1b[");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const RAW = Symbol("sanning.cli.raw");
|
|
146
|
+
type Guarded = Cli & { [RAW]: Cli };
|
|
147
|
+
|
|
148
|
+
function guard(io: Cli): Cli {
|
|
149
|
+
if (RAW in io) return io;
|
|
150
|
+
const guarded: Guarded = {
|
|
151
|
+
out: (s) => io.out(forTerminal(s)),
|
|
152
|
+
err: (s) => io.err(forTerminal(s)),
|
|
153
|
+
[RAW]: io,
|
|
154
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
155
|
+
};
|
|
156
|
+
return guarded;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rawOf(io: Cli): Cli {
|
|
160
|
+
return (io as Partial<Guarded>)[RAW] ?? io;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** JSON, printed unescaped: a script reads it, not a terminal. */
|
|
164
|
+
function outJson(io: Cli, text: string): void {
|
|
165
|
+
rawOf(io).out(text);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A block this command wrote itself, such as a usage text: its own newlines
|
|
169
|
+
* are kept, and each line is escaped as `out` escapes it. */
|
|
170
|
+
function outBlock(io: Cli, text: string): void {
|
|
171
|
+
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
172
|
+
}
|
|
80
173
|
|
|
81
174
|
function useColor(): boolean {
|
|
82
175
|
// Honor NO_COLOR; otherwise color only a TTY.
|
|
@@ -95,7 +188,8 @@ function paint(s: string, c: string): string {
|
|
|
95
188
|
return useColor() ? `${c}${s}${X}` : s;
|
|
96
189
|
}
|
|
97
190
|
|
|
98
|
-
export async function runCli(argv: string[],
|
|
191
|
+
export async function runCli(argv: string[], stream: Cli): Promise<number> {
|
|
192
|
+
const io = guard(stream);
|
|
99
193
|
const [command, ...rest] = argv;
|
|
100
194
|
|
|
101
195
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
@@ -106,11 +200,19 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
106
200
|
io.out("@sanning/proof verify CLI");
|
|
107
201
|
return EXIT_VERIFIED;
|
|
108
202
|
}
|
|
109
|
-
// `
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
203
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`). It
|
|
204
|
+
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
205
|
+
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
206
|
+
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
207
|
+
if (command === "export") {
|
|
208
|
+
if (rest.includes("--json")) {
|
|
209
|
+
return runJson(
|
|
210
|
+
rest.filter((a) => a !== "--json"),
|
|
211
|
+
io,
|
|
212
|
+
runExport,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return runExport(rest, io, null);
|
|
114
216
|
}
|
|
115
217
|
if (command === "case") {
|
|
116
218
|
return runCase(rest, io);
|
|
@@ -120,14 +222,75 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
120
222
|
printUsage(io);
|
|
121
223
|
return EXIT_MALFORMED;
|
|
122
224
|
}
|
|
225
|
+
if (rest.includes("--json")) {
|
|
226
|
+
return runJson(
|
|
227
|
+
rest.filter((a) => a !== "--json"),
|
|
228
|
+
io,
|
|
229
|
+
runVerify,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return runVerify(rest, io, null);
|
|
233
|
+
}
|
|
123
234
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
235
|
+
// ─── `--json` on `verify` and `export` (C-03, 0.13.0) ──────────────────────
|
|
236
|
+
//
|
|
237
|
+
// `case --json` printed the reading as JSON and the other two verbs had no
|
|
238
|
+
// machine-readable form, so a script parsed the text. Each verb now runs as it
|
|
239
|
+
// does for a person, with its text discarded, and hands this one object back
|
|
240
|
+
// through `sink`: the same keys in both kernels, sorted at every depth, and
|
|
241
|
+
// printed byte for byte the same (`cross-kernel/run_cli.sh`,
|
|
242
|
+
// `cross-kernel/run_export_cli.sh`). A run that stops before it has anything to
|
|
243
|
+
// report (a bad flag, an unreadable file) prints `{ error, exit_code }`, as
|
|
244
|
+
// `case --json` does. `note:` lines still go to stderr.
|
|
245
|
+
interface JsonSink {
|
|
246
|
+
data: Record<string, unknown> | null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type JsonVerb = (rest: string[], io: Cli, sink: JsonSink | null) => Promise<number>;
|
|
250
|
+
|
|
251
|
+
async function runJson(rest: string[], io: Cli, verb: JsonVerb): Promise<number> {
|
|
252
|
+
const errs: string[] = [];
|
|
253
|
+
const sink: JsonSink = { data: null };
|
|
254
|
+
const quiet: Cli = {
|
|
255
|
+
out: () => {},
|
|
256
|
+
err: (s) => errs.push(s),
|
|
257
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
258
|
+
};
|
|
259
|
+
const code = await verb(rest, quiet, sink);
|
|
260
|
+
const notes = errs.filter((l) => l.startsWith("note: "));
|
|
261
|
+
for (const n of notes) io.err(n);
|
|
262
|
+
if (sink.data !== null) {
|
|
263
|
+
outJson(io, jsonText({ ...sink.data, exit_code: code }));
|
|
264
|
+
} else {
|
|
265
|
+
const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
|
|
266
|
+
outJson(io, jsonText({ error, exit_code: code }));
|
|
267
|
+
}
|
|
268
|
+
return code;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
272
|
+
|
|
273
|
+
// `--logs <path>` feeds disclosed raw-log bytes for content verification
|
|
274
|
+
// (the evidence-bundle path only); `--gateways <url,url>` asks for the
|
|
275
|
+
// on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
|
|
276
|
+
// second positional argument until then, and a second positional is now a
|
|
277
|
+
// usage error). The one positional is the bundle.
|
|
127
278
|
let logsPath: string | undefined;
|
|
279
|
+
let gatewayArg: string | undefined;
|
|
128
280
|
const positional: string[] = [];
|
|
129
281
|
for (let i = 0; i < rest.length; i++) {
|
|
130
282
|
const a = rest[i]!;
|
|
283
|
+
if (a === "--gateways") {
|
|
284
|
+
const next = rest[i + 1];
|
|
285
|
+
if (next === undefined) {
|
|
286
|
+
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
287
|
+
printUsage(io);
|
|
288
|
+
return EXIT_MALFORMED;
|
|
289
|
+
}
|
|
290
|
+
gatewayArg = next;
|
|
291
|
+
i++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
131
294
|
if (a === "--logs") {
|
|
132
295
|
const next = rest[i + 1];
|
|
133
296
|
if (next === undefined) {
|
|
@@ -142,12 +305,17 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
142
305
|
positional.push(a);
|
|
143
306
|
}
|
|
144
307
|
|
|
145
|
-
const [bundlePath
|
|
308
|
+
const [bundlePath] = positional;
|
|
146
309
|
if (!bundlePath) {
|
|
147
310
|
io.err("verify: a bundle file path is required");
|
|
148
311
|
printUsage(io);
|
|
149
312
|
return EXIT_MALFORMED;
|
|
150
313
|
}
|
|
314
|
+
if (positional.length > 1) {
|
|
315
|
+
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
316
|
+
printUsage(io);
|
|
317
|
+
return EXIT_MALFORMED;
|
|
318
|
+
}
|
|
151
319
|
const gateways = (gatewayArg ?? "")
|
|
152
320
|
.split(",")
|
|
153
321
|
.map((g) => g.trim())
|
|
@@ -195,11 +363,11 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
195
363
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
196
364
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
197
365
|
}
|
|
198
|
-
const result = await
|
|
366
|
+
const result = await verifyAgentProof(parsed, {
|
|
199
367
|
gateways,
|
|
200
368
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
201
369
|
});
|
|
202
|
-
return reportAgentProof(result, bundlePath, gateways, io);
|
|
370
|
+
return reportAgentProof(result, bundlePath, gateways, io, sink);
|
|
203
371
|
}
|
|
204
372
|
|
|
205
373
|
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
@@ -228,26 +396,29 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
228
396
|
} catch (e) {
|
|
229
397
|
chains = { unreadable: errMsg(e) };
|
|
230
398
|
}
|
|
231
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
|
|
399
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
232
400
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
233
401
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
234
402
|
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
235
403
|
return exit;
|
|
236
404
|
}
|
|
237
405
|
|
|
238
|
-
// `proof
|
|
239
|
-
// (`proof export` still reaches this, undocumented — see the dispatch above.)
|
|
406
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
240
407
|
//
|
|
241
408
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
242
409
|
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
243
|
-
// Ed25519 key (evidence-export.md §5, composer). Writes the export to
|
|
410
|
+
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
|
|
244
411
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
245
412
|
//
|
|
246
413
|
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
247
414
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
248
415
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
249
416
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
250
|
-
async function
|
|
417
|
+
async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
418
|
+
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
419
|
+
usageExport(io);
|
|
420
|
+
return EXIT_VERIFIED;
|
|
421
|
+
}
|
|
251
422
|
let attestationsPath: string | undefined;
|
|
252
423
|
let keyPath: string | undefined;
|
|
253
424
|
let outPath: string | undefined;
|
|
@@ -265,15 +436,15 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
265
436
|
i++;
|
|
266
437
|
return next;
|
|
267
438
|
};
|
|
268
|
-
if (a === "--attestations"
|
|
439
|
+
if (a === "--attestations") {
|
|
269
440
|
const v = takeNext(a);
|
|
270
441
|
if (v === undefined) return usageExport(io);
|
|
271
442
|
attestationsPath = v;
|
|
272
|
-
} else if (a === "--key"
|
|
443
|
+
} else if (a === "--key") {
|
|
273
444
|
const v = takeNext(a);
|
|
274
445
|
if (v === undefined) return usageExport(io);
|
|
275
446
|
keyPath = v;
|
|
276
|
-
} else if (a === "--out"
|
|
447
|
+
} else if (a === "--out") {
|
|
277
448
|
const v = takeNext(a);
|
|
278
449
|
if (v === undefined) return usageExport(io);
|
|
279
450
|
outPath = v;
|
|
@@ -293,6 +464,12 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
293
464
|
const v = takeNext(a);
|
|
294
465
|
if (v === undefined) return usageExport(io);
|
|
295
466
|
opts.generatedAt = v;
|
|
467
|
+
} else if (a.startsWith("-")) {
|
|
468
|
+
// A flag this verb does not take is a usage error, never a path: `-o`
|
|
469
|
+
// was a short form of `--out` until 0.13.0, and read as a path it would
|
|
470
|
+
// send the export to stdout while the caller waits for a file.
|
|
471
|
+
io.err(`export: unknown option ${a}`);
|
|
472
|
+
return usageExport(io);
|
|
296
473
|
} else {
|
|
297
474
|
positional.push(a);
|
|
298
475
|
}
|
|
@@ -338,6 +515,18 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
338
515
|
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
339
516
|
// still writes the artifact so the caller can inspect it.
|
|
340
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
|
+
}
|
|
341
530
|
if (outPath) {
|
|
342
531
|
try {
|
|
343
532
|
await writeFile(outPath, serialized, "utf8");
|
|
@@ -351,28 +540,34 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
351
540
|
`attestations ${composed.boundAttestations}/${attestations.length} bound`,
|
|
352
541
|
);
|
|
353
542
|
} else {
|
|
354
|
-
io
|
|
543
|
+
outJson(io, serialized.replace(/\n$/, ""));
|
|
355
544
|
}
|
|
356
545
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
357
546
|
}
|
|
358
547
|
|
|
359
548
|
function usageExport(io: Cli): number {
|
|
360
549
|
io.out(
|
|
361
|
-
"Usage:
|
|
550
|
+
"Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]",
|
|
362
551
|
);
|
|
363
552
|
io.out("");
|
|
364
553
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
365
554
|
io.out("sanning.anchor.trace/v3 source bundle + operator attestation records + an exporter");
|
|
366
|
-
io.out("Ed25519 key. The output verifies with `proof verify <out.json>`.");
|
|
555
|
+
io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
|
|
367
556
|
io.out("");
|
|
368
557
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
369
558
|
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
370
559
|
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
371
|
-
io.out("
|
|
560
|
+
io.out("--out <file> Write the export here (default: stdout).");
|
|
372
561
|
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
373
562
|
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
374
563
|
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
375
564
|
io.out("--generated-at <ts> RFC 3339 compose time (default now).");
|
|
565
|
+
io.out("--json Print a summary as JSON (with the export inside it");
|
|
566
|
+
io.out(" when --out is not given).");
|
|
567
|
+
io.out("");
|
|
568
|
+
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
569
|
+
io.out("");
|
|
570
|
+
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
376
571
|
return EXIT_MALFORMED;
|
|
377
572
|
}
|
|
378
573
|
|
|
@@ -446,7 +641,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
446
641
|
// disclosure. This is the layout `materializeBundle` writes
|
|
447
642
|
// (bundle.json + logs/) and the layout the verify page hashes,
|
|
448
643
|
// so a pack verifies with NO side-input construction step.
|
|
449
|
-
// JSON MAP `{ event_id: <even-length
|
|
644
|
+
// JSON MAP `{ event_id: <even-length hex, either case | utf8 text> }`, the
|
|
450
645
|
// original form. Unchanged.
|
|
451
646
|
//
|
|
452
647
|
// **Why the directory form exists at all.** Handing a pack to the CLI used to
|
|
@@ -465,7 +660,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
465
660
|
// and the verify page agree about one pack.
|
|
466
661
|
interface LoadedLogs {
|
|
467
662
|
// Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
|
|
468
|
-
// is
|
|
663
|
+
// is hex in either case (map form). Those are exactly the two the verifier
|
|
469
664
|
// distinguishes.
|
|
470
665
|
//
|
|
471
666
|
// A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
|
|
@@ -658,7 +853,8 @@ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io:
|
|
|
658
853
|
if (undetermined.length > 0) {
|
|
659
854
|
io.err(
|
|
660
855
|
`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
661
|
-
`content
|
|
856
|
+
`content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
857
|
+
`hand-off record) to bind them to. ` +
|
|
662
858
|
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
|
|
663
859
|
);
|
|
664
860
|
}
|
|
@@ -706,32 +902,39 @@ function decodeLogValue(v: string): Uint8Array | string {
|
|
|
706
902
|
}
|
|
707
903
|
|
|
708
904
|
function printUsage(io: Cli): void {
|
|
709
|
-
io.out("Usage:
|
|
905
|
+
io.out("Usage: sanning-proof <command> [args]");
|
|
906
|
+
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
710
907
|
io.out("");
|
|
711
908
|
io.out("Commands:");
|
|
712
|
-
io.out(" verify <bundle.json> [gateways] [--logs <path>]
|
|
713
|
-
io.out("
|
|
909
|
+
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
|
|
910
|
+
io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
|
|
911
|
+
io.out(" Create an attested export");
|
|
714
912
|
io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
|
|
715
913
|
io.out(" (`case --help` for its exit codes)");
|
|
716
914
|
io.out("");
|
|
717
915
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
718
916
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
719
917
|
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
720
|
-
io.out("fully offline.
|
|
721
|
-
io.out("
|
|
918
|
+
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
919
|
+
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
722
920
|
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
723
921
|
io.out("");
|
|
922
|
+
io.out("--json Print the verdict, the causes and the exit code as");
|
|
923
|
+
io.out(" JSON, for a script, instead of the report.");
|
|
924
|
+
io.out("");
|
|
724
925
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
725
|
-
io.out(" content_hash
|
|
926
|
+
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
927
|
+
io.out(" Either shape works, and which one you");
|
|
726
928
|
io.out(" passed is decided by stat(), not by the extension:");
|
|
727
929
|
io.out("");
|
|
728
930
|
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
729
931
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
730
932
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
731
|
-
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
933
|
+
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
732
934
|
io.out("");
|
|
733
935
|
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
734
|
-
io.out(" even-length
|
|
936
|
+
io.out(" even-length hex value, in either case, ⇒ hex,");
|
|
937
|
+
io.out(" else utf8).");
|
|
735
938
|
io.out("");
|
|
736
939
|
io.out(" In-body events[].content is verified automatically");
|
|
737
940
|
io.out(" and takes precedence. An event with no disclosed");
|
|
@@ -741,8 +944,10 @@ function printUsage(io: Cli): void {
|
|
|
741
944
|
io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
|
|
742
945
|
io.out("bundle itself is verified. Both verdicts are printed when they differ.");
|
|
743
946
|
io.out("");
|
|
744
|
-
|
|
745
|
-
io.out("
|
|
947
|
+
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
948
|
+
io.out("");
|
|
949
|
+
io.out("verify: an unreachable gateway reads partial, and a pack with a record");
|
|
950
|
+
io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
|
|
746
951
|
}
|
|
747
952
|
|
|
748
953
|
/**
|
|
@@ -790,8 +995,10 @@ function reportEvidence(
|
|
|
790
995
|
chains: ChainOutcome,
|
|
791
996
|
parsed: unknown,
|
|
792
997
|
logs: LoadedLogs | undefined,
|
|
998
|
+
sink: JsonSink | null,
|
|
793
999
|
): number {
|
|
794
1000
|
if (result.status === "malformed") {
|
|
1001
|
+
if (sink) sink.data = verifyJson(result, null, null, null);
|
|
795
1002
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
796
1003
|
for (const e of result.errors) io.err(` - ${e}`);
|
|
797
1004
|
return EXIT_MALFORMED;
|
|
@@ -889,7 +1096,7 @@ function reportEvidence(
|
|
|
889
1096
|
// answers `partial` and the line below says so in words, and pack grading
|
|
890
1097
|
// keeps its own verdict: every pack built before §5.4 carries no
|
|
891
1098
|
// declaration, and re-grading them all as undetermined would be a change to
|
|
892
|
-
//
|
|
1099
|
+
// their verdicts rather than a new check. A pack that DOES
|
|
893
1100
|
// carry a declaration this reader cannot read is a different case and exits
|
|
894
1101
|
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
895
1102
|
const declaration = checkDeclaration(parsed, logs?.content);
|
|
@@ -928,7 +1135,47 @@ function reportEvidence(
|
|
|
928
1135
|
);
|
|
929
1136
|
}
|
|
930
1137
|
|
|
931
|
-
|
|
1138
|
+
if (sink) sink.data = verifyJson(result, grade, declaration, chains);
|
|
1139
|
+
return mapGradeToExit(grade);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
1144
|
+
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
1145
|
+
* `chains` are null for a bundle no verdict could be rendered over.
|
|
1146
|
+
*/
|
|
1147
|
+
function verifyJson(
|
|
1148
|
+
result: EvidenceBundleResult,
|
|
1149
|
+
grade: PackGrade | null,
|
|
1150
|
+
declaration: ReturnType<typeof checkDeclaration> | null,
|
|
1151
|
+
chains: ChainOutcome | null,
|
|
1152
|
+
): Record<string, unknown> {
|
|
1153
|
+
let chainBlock: Record<string, unknown> | null = null;
|
|
1154
|
+
if (chains !== null) {
|
|
1155
|
+
chainBlock =
|
|
1156
|
+
"unreadable" in chains
|
|
1157
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1158
|
+
: {
|
|
1159
|
+
unreadable: null,
|
|
1160
|
+
chains: chains.chains.length,
|
|
1161
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
return {
|
|
1165
|
+
verdict: grade ? grade.verdict : result.status,
|
|
1166
|
+
bundle_status: result.status,
|
|
1167
|
+
asserted_status: result.assertedStatus ?? null,
|
|
1168
|
+
body_type: result.bodyType ?? null,
|
|
1169
|
+
on_chain_checked: result.onChainChecked,
|
|
1170
|
+
declaration: declaration ? declaration.verdict : null,
|
|
1171
|
+
declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
|
|
1172
|
+
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
1173
|
+
notes: [...result.errors],
|
|
1174
|
+
records: grade
|
|
1175
|
+
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
1176
|
+
: null,
|
|
1177
|
+
chains: chainBlock,
|
|
1178
|
+
};
|
|
932
1179
|
}
|
|
933
1180
|
|
|
934
1181
|
/**
|
|
@@ -946,8 +1193,8 @@ function reportEvidence(
|
|
|
946
1193
|
* complete" does not follow, and two customer-facing documents had already
|
|
947
1194
|
* made that leap. The limit travels with the result or it gets dropped.
|
|
948
1195
|
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
949
|
-
* the verdict surface, and the
|
|
950
|
-
*
|
|
1196
|
+
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1197
|
+
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
951
1198
|
* decision this check is entitled to make.
|
|
952
1199
|
*/
|
|
953
1200
|
function reportChains(reading: ChainOutcome, io: Cli): void {
|
|
@@ -1009,7 +1256,17 @@ function reportAgentProof(
|
|
|
1009
1256
|
path: string,
|
|
1010
1257
|
gateways: string[],
|
|
1011
1258
|
io: Cli,
|
|
1259
|
+
sink: JsonSink | null,
|
|
1012
1260
|
): number {
|
|
1261
|
+
if (sink) {
|
|
1262
|
+
sink.data = {
|
|
1263
|
+
verdict: result.status,
|
|
1264
|
+
bundle_status: result.status,
|
|
1265
|
+
spec_version: "sanning.agent.proof/v1",
|
|
1266
|
+
on_chain_checked: result.onChainChecked,
|
|
1267
|
+
notes: [...result.errors],
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1013
1270
|
if (result.status === "malformed") {
|
|
1014
1271
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
1015
1272
|
for (const e of result.errors) io.err(` - ${e}`);
|
|
@@ -1029,7 +1286,7 @@ function reportAgentProof(
|
|
|
1029
1286
|
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
|
|
1030
1287
|
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
1031
1288
|
|
|
1032
|
-
return
|
|
1289
|
+
return exitCodeFor(result.status);
|
|
1033
1290
|
}
|
|
1034
1291
|
|
|
1035
1292
|
function printRollup(
|
|
@@ -1084,34 +1341,15 @@ function printRollup(
|
|
|
1084
1341
|
}
|
|
1085
1342
|
|
|
1086
1343
|
/**
|
|
1087
|
-
* The exit code
|
|
1344
|
+
* The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
|
|
1088
1345
|
*
|
|
1089
|
-
* `incomplete`
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1346
|
+
* `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
|
|
1347
|
+
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1348
|
+
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1349
|
+
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
1092
1350
|
*/
|
|
1093
|
-
function mapGradeToExit(grade: PackGrade
|
|
1094
|
-
|
|
1095
|
-
if (grade.verdict === "failed") return EXIT_FAILED;
|
|
1096
|
-
if (grade.verdict === "incomplete") return EXIT_UNDETERMINED;
|
|
1097
|
-
return mapStatusToExit(result.status, result.errors);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function mapStatusToExit(status: string, errors: string[]): number {
|
|
1101
|
-
if (status === "failed") return EXIT_FAILED;
|
|
1102
|
-
if (status === "malformed") return EXIT_MALFORMED;
|
|
1103
|
-
// "partial" caused specifically by an unreachable gateway — or an export
|
|
1104
|
-
// source_bundle_ref whose bytes are unavailable offline (evidence-export.md
|
|
1105
|
-
// §5 step 3) — maps to exit 3 (undetermined, network-dependent).
|
|
1106
|
-
if (
|
|
1107
|
-
status === "partial" &&
|
|
1108
|
-
errors.some((e) => /unreachable|could not be re-fetched|unavailable offline/.test(e))
|
|
1109
|
-
) {
|
|
1110
|
-
return EXIT_UNDETERMINED;
|
|
1111
|
-
}
|
|
1112
|
-
// "verified" → 0. "partial" from a withheld record (semantics-undetermined,
|
|
1113
|
-
// not a failure) also exits 0 — the bundle is cryptographically sound.
|
|
1114
|
-
return EXIT_VERIFIED;
|
|
1351
|
+
function mapGradeToExit(grade: PackGrade): number {
|
|
1352
|
+
return exitCodeFor(grade.verdict);
|
|
1115
1353
|
}
|
|
1116
1354
|
|
|
1117
1355
|
// ─── What `proof case` reads (proof#141) ─────────────────────────────────────
|
|
@@ -1202,7 +1440,9 @@ const LOG_PATH = /^logs\/([^/]+)\.json$/;
|
|
|
1202
1440
|
const ZIP_NAME = /\.zip$/i;
|
|
1203
1441
|
|
|
1204
1442
|
/** The one line a `.zip` gets, as an argument or as the way through a refusal. */
|
|
1205
|
-
const UNZIP_FIRST =
|
|
1443
|
+
const UNZIP_FIRST =
|
|
1444
|
+
"Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
|
|
1445
|
+
"case/) and pass the folder.";
|
|
1206
1446
|
|
|
1207
1447
|
/**
|
|
1208
1448
|
* The input could not be read as a case. The message is the whole report,
|
|
@@ -1232,33 +1472,6 @@ export class BundleReadError extends Error {
|
|
|
1232
1472
|
}
|
|
1233
1473
|
}
|
|
1234
1474
|
|
|
1235
|
-
/**
|
|
1236
|
-
* Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
|
|
1237
|
-
* no recursion; the same scan as `_json_depth_exceeds` in the Python twin, so
|
|
1238
|
-
* both kernels refuse the same text.
|
|
1239
|
-
*/
|
|
1240
|
-
function jsonDepthExceeds(text: string, limit: number): boolean {
|
|
1241
|
-
let depth = 0;
|
|
1242
|
-
let inString = false;
|
|
1243
|
-
let escaped = false;
|
|
1244
|
-
for (let i = 0; i < text.length; i++) {
|
|
1245
|
-
const ch = text[i];
|
|
1246
|
-
if (inString) {
|
|
1247
|
-
if (escaped) escaped = false;
|
|
1248
|
-
else if (ch === "\\") escaped = true;
|
|
1249
|
-
else if (ch === '"') inString = false;
|
|
1250
|
-
} else if (ch === '"') {
|
|
1251
|
-
inString = true;
|
|
1252
|
-
} else if (ch === "[" || ch === "{") {
|
|
1253
|
-
depth++;
|
|
1254
|
-
if (depth > limit) return true;
|
|
1255
|
-
} else if (ch === "]" || ch === "}") {
|
|
1256
|
-
depth--;
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
return false;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
1475
|
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1263
1476
|
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
1264
1477
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
@@ -1306,9 +1519,15 @@ export interface SplitCase {
|
|
|
1306
1519
|
|
|
1307
1520
|
/** One name the walk found that is not a directory. */
|
|
1308
1521
|
export interface TreeEntry {
|
|
1522
|
+
/** Where it sits on disk. Empty for an entry read out of a case-file `.zip`. */
|
|
1309
1523
|
fsPath: string;
|
|
1310
|
-
/**
|
|
1524
|
+
/**
|
|
1525
|
+
* `true` for a regular file; `false` for a link or anything else, including a
|
|
1526
|
+
* `.zip` entry the reader refused.
|
|
1527
|
+
*/
|
|
1311
1528
|
regular: boolean;
|
|
1529
|
+
/** The bytes of an entry read out of a case-file `.zip`. */
|
|
1530
|
+
data?: Uint8Array | null;
|
|
1312
1531
|
}
|
|
1313
1532
|
|
|
1314
1533
|
/**
|
|
@@ -1330,7 +1549,7 @@ export async function splitCase(
|
|
|
1330
1549
|
const hint =
|
|
1331
1550
|
zips.length > 0
|
|
1332
1551
|
? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
|
|
1333
|
-
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
|
|
1552
|
+
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
|
|
1334
1553
|
UNZIP_FIRST
|
|
1335
1554
|
: null;
|
|
1336
1555
|
throw new CaseInputError(NO_BUNDLE, hint);
|
|
@@ -1395,6 +1614,7 @@ export function loadBundle(pack: SplitPack): unknown {
|
|
|
1395
1614
|
* the walk listed it is refused rather than followed or waited on.
|
|
1396
1615
|
*/
|
|
1397
1616
|
export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint8Array> {
|
|
1617
|
+
if (entry.data !== undefined && entry.data !== null) return entry.data;
|
|
1398
1618
|
const flags =
|
|
1399
1619
|
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1400
1620
|
let handle;
|
|
@@ -1499,25 +1719,83 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
1499
1719
|
);
|
|
1500
1720
|
}
|
|
1501
1721
|
if (s.isDirectory()) continue;
|
|
1502
|
-
if (ZIP_NAME.test(arg))
|
|
1503
|
-
throw new CaseInputError(
|
|
1722
|
+
if (s.isFile() && ZIP_NAME.test(arg)) continue;
|
|
1723
|
+
throw new CaseInputError(
|
|
1724
|
+
`${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`,
|
|
1725
|
+
);
|
|
1504
1726
|
}
|
|
1505
1727
|
|
|
1506
1728
|
const tree = new Map<string, TreeEntry>();
|
|
1507
1729
|
const claimed = new Map<string, string>();
|
|
1508
1730
|
const seen = { n: 0 };
|
|
1509
1731
|
for (const arg of paths) {
|
|
1510
|
-
|
|
1732
|
+
let isZip = false;
|
|
1733
|
+
if (ZIP_NAME.test(arg)) {
|
|
1734
|
+
try {
|
|
1735
|
+
isZip = !(await stat(arg)).isDirectory();
|
|
1736
|
+
} catch {
|
|
1737
|
+
isZip = true;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
let name = baseName(arg);
|
|
1741
|
+
if (isZip) {
|
|
1742
|
+
// `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
|
|
1743
|
+
// the archive is read under the folder that command makes.
|
|
1744
|
+
name = name.slice(0, -".zip".length);
|
|
1745
|
+
if (name.length === 0) throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
1746
|
+
}
|
|
1511
1747
|
const earlier = claimed.get(name);
|
|
1512
1748
|
if (earlier !== undefined) {
|
|
1513
1749
|
throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
|
|
1514
1750
|
}
|
|
1515
1751
|
claimed.set(name, arg);
|
|
1516
|
-
await
|
|
1752
|
+
if (isZip) await unzip(arg, `${name}/`, tree, seen);
|
|
1753
|
+
else await walk(arg, `${name}/`, arg, tree, seen);
|
|
1517
1754
|
}
|
|
1518
1755
|
return tree;
|
|
1519
1756
|
}
|
|
1520
1757
|
|
|
1758
|
+
/**
|
|
1759
|
+
* A case file named on the command line, read as the folder `unzip` would make
|
|
1760
|
+
* of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
|
|
1761
|
+
* unpacked folder give one reading. The archive is read by `readZip` and
|
|
1762
|
+
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
1763
|
+
* read, as the walk lists a link.
|
|
1764
|
+
*/
|
|
1765
|
+
async function unzip(
|
|
1766
|
+
arg: string,
|
|
1767
|
+
prefix: string,
|
|
1768
|
+
tree: Map<string, TreeEntry>,
|
|
1769
|
+
seen: { n: number },
|
|
1770
|
+
): Promise<void> {
|
|
1771
|
+
let data: Uint8Array;
|
|
1772
|
+
try {
|
|
1773
|
+
data = new Uint8Array(await readFile(arg));
|
|
1774
|
+
} catch {
|
|
1775
|
+
throw new CaseInputError(`cannot read ${arg}`);
|
|
1776
|
+
}
|
|
1777
|
+
let entries;
|
|
1778
|
+
try {
|
|
1779
|
+
entries = readZip(data);
|
|
1780
|
+
} catch (e) {
|
|
1781
|
+
if (e instanceof ZipReadError) throw new CaseInputError(`${arg} ${e.message}`);
|
|
1782
|
+
throw e;
|
|
1783
|
+
}
|
|
1784
|
+
for (const entry of entries) {
|
|
1785
|
+
seen.n++;
|
|
1786
|
+
if (seen.n > MAX_WALK_ENTRIES) {
|
|
1787
|
+
throw new CaseInputError(
|
|
1788
|
+
`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`,
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
tree.set(`${prefix}${entry.name}`, {
|
|
1792
|
+
fsPath: "",
|
|
1793
|
+
regular: entry.refused === null,
|
|
1794
|
+
data: entry.data,
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1521
1799
|
// ─── `proof case` (proof#141) ────────────────────────────────────────────────
|
|
1522
1800
|
//
|
|
1523
1801
|
// The case reading from a terminal, so an auditor holding several packs does
|
|
@@ -1531,68 +1809,66 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
1531
1809
|
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
1532
1810
|
// No color: the two commands print the same bytes.
|
|
1533
1811
|
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
50000 files and folders in all, or a name that is not valid UTF-8
|
|
1566
|
-
exits 2.
|
|
1812
|
+
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
1813
|
+
// `mixed` has its own code, 4, because each other code would say something a
|
|
1814
|
+
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
1815
|
+
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
1816
|
+
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
1817
|
+
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
1818
|
+
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
1819
|
+
|
|
1820
|
+
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
1821
|
+
|
|
1822
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
|
|
1823
|
+
|
|
1824
|
+
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
1825
|
+
several packs, or an unzipped case file. Packs are found by the
|
|
1826
|
+
verify page's splitting rule: every bundle.json is a pack, a file
|
|
1827
|
+
belongs to the deepest pack whose bundle.json or logs/ it sits
|
|
1828
|
+
in, and every other file is listed as outside every pack and
|
|
1829
|
+
never opened. A .zip inside a folder is a file like any other. A
|
|
1830
|
+
symbolic link is never followed: it is listed as outside and not
|
|
1831
|
+
read, even under a pack's logs/. Several arguments are read as
|
|
1832
|
+
one folder holding them, each under its own name. A folder
|
|
1833
|
+
nested more than 32 levels deep, more than 50000 files and
|
|
1834
|
+
folders in all, or a name that is not valid UTF-8 exits 2.
|
|
1835
|
+
<case file> a case file named as <name>.zip, read as the folder
|
|
1836
|
+
unzip <name>.zip -d <name> would make, so the two give one
|
|
1837
|
+
reading. An entry that is encrypted, a symbolic link, stored by
|
|
1838
|
+
another method, or named outside the folder is listed as outside
|
|
1839
|
+
and not read.
|
|
1840
|
+
Where the folder or case file holds contents.json at its root,
|
|
1841
|
+
the index is compared with what is there and each disagreement
|
|
1842
|
+
is listed; the index never moves the verdict.
|
|
1567
1843
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1844
|
+
looked up on the public record, pack by pack, and reported on
|
|
1845
|
+
the witness line. The lookup never changes the verdict word.
|
|
1846
|
+
--json the full reading as JSON: the verdict, every exception and note
|
|
1847
|
+
with its rendered line, every record, every contribution, the
|
|
1848
|
+
packs, the files outside them, the case file index comparison,
|
|
1849
|
+
the witness line and the on-chain half.
|
|
1574
1850
|
|
|
1575
1851
|
Each pack's bundle.json is read as verify reads it, so the two commands
|
|
1576
1852
|
classify one bundle the same way.
|
|
1577
1853
|
|
|
1578
1854
|
What a case reading establishes: every pack is verified offline by this
|
|
1579
|
-
kernel; every record every pack supplied is read, in one
|
|
1580
|
-
hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1581
|
-
counterpart among the
|
|
1582
|
-
the key it names is in the
|
|
1583
|
-
every candidate it had was accounted for by another record (A20),
|
|
1584
|
-
as no counterpart and names no pack.
|
|
1585
|
-
|
|
1586
|
-
What it does not establish: that the
|
|
1587
|
-
hand over a pack); that a party which presented no pack under the named key
|
|
1855
|
+
kernel and graded as a pack; every record every pack supplied is read, in one
|
|
1856
|
+
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1857
|
+
with no counterpart among the records read is always named, and when a pack
|
|
1858
|
+
signed by the key it names is in the reading, it says that pack holds none,
|
|
1859
|
+
unless every candidate it had was accounted for by another record (A20),
|
|
1860
|
+
which reads as no counterpart and names no pack.
|
|
1861
|
+
|
|
1862
|
+
What it does not establish: that the set of packs is complete (a party can
|
|
1863
|
+
decline to hand over a pack); that a party which presented no pack under the named key
|
|
1588
1864
|
recorded nothing; that two organizations are independent of each other; or
|
|
1589
|
-
that any witness
|
|
1865
|
+
that any witness is on the public record, which the reading never looks up.
|
|
1590
1866
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1867
|
+
${EXIT_CODES_HELP}
|
|
1868
|
+
|
|
1869
|
+
case: an input that cannot be read as packs, or an unexpected error in this
|
|
1870
|
+
command, exits 2. With --gateways, a witness that does not match on chain exits
|
|
1871
|
+
1, and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1596
1872
|
|
|
1597
1873
|
// What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
|
|
1598
1874
|
// rather than left to the stream, so the lines a test captures in process are
|
|
@@ -1630,6 +1906,8 @@ interface OnChainRow {
|
|
|
1630
1906
|
}
|
|
1631
1907
|
|
|
1632
1908
|
interface OnChainHalf {
|
|
1909
|
+
/** Present only where a lookup ran: each gateway that answered, in the order given. */
|
|
1910
|
+
answered?: string[];
|
|
1633
1911
|
checked: boolean;
|
|
1634
1912
|
gateways: string[];
|
|
1635
1913
|
packs: OnChainRow[];
|
|
@@ -1647,6 +1925,7 @@ async function onChainHalf(
|
|
|
1647
1925
|
): Promise<OnChainHalf> {
|
|
1648
1926
|
if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1649
1927
|
const rows: OnChainRow[] = [];
|
|
1928
|
+
const answered = new Set<string>();
|
|
1650
1929
|
for (const [ref, bundle] of packs) {
|
|
1651
1930
|
const result = await verifyEvidenceBundle(bundle, {
|
|
1652
1931
|
gateways,
|
|
@@ -1669,13 +1948,58 @@ async function onChainHalf(
|
|
|
1669
1948
|
else row.not_checked++;
|
|
1670
1949
|
}
|
|
1671
1950
|
rows.push(row);
|
|
1951
|
+
for (const w of witnesses) {
|
|
1952
|
+
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
1953
|
+
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
1954
|
+
answered.add(outcome.gateway);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1672
1958
|
}
|
|
1673
1959
|
const verdict = rows.some((r) => r.mismatched > 0)
|
|
1674
1960
|
? "failed"
|
|
1675
1961
|
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1676
1962
|
? "undetermined"
|
|
1677
1963
|
: "confirmed";
|
|
1678
|
-
return {
|
|
1964
|
+
return {
|
|
1965
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
1966
|
+
checked: true,
|
|
1967
|
+
gateways: [...gateways],
|
|
1968
|
+
packs: rows,
|
|
1969
|
+
verdict,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
|
|
1975
|
+
* half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
|
|
1976
|
+
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
1977
|
+
* headline.
|
|
1978
|
+
*/
|
|
1979
|
+
export function witnessLine(onChain: OnChainHalf): WitnessLine {
|
|
1980
|
+
if (!onChain.checked) {
|
|
1981
|
+
return {
|
|
1982
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
1983
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
1984
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
const services = [...(onChain.answered ?? [])];
|
|
1988
|
+
if (onChain.verdict === "failed") return { state: "differs", reasons: [], services };
|
|
1989
|
+
if (onChain.verdict === "confirmed") return { state: "confirmed", reasons: [], services };
|
|
1990
|
+
const rows = onChain.packs;
|
|
1991
|
+
const reasons: string[] = [];
|
|
1992
|
+
if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
|
|
1993
|
+
if (rows.some((r) => r.unreachable > 0)) {
|
|
1994
|
+
reasons.push(
|
|
1995
|
+
"a lookup did not settle: the service could not be reached, or it " +
|
|
1996
|
+
"holds no such witness yet",
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
if (rows.some((r) => r.not_checked > 0)) {
|
|
2000
|
+
reasons.push("no lookup is made for a witness kind a pack names");
|
|
2001
|
+
}
|
|
2002
|
+
return { state: "not_checked", reasons, services };
|
|
1679
2003
|
}
|
|
1680
2004
|
|
|
1681
2005
|
/**
|
|
@@ -1684,28 +2008,63 @@ async function onChainHalf(
|
|
|
1684
2008
|
* on-chain half that could not be settled is never a clean 0.
|
|
1685
2009
|
*/
|
|
1686
2010
|
export function caseExitCode(verdict: string, onChainVerdict: string): number {
|
|
1687
|
-
const code =
|
|
2011
|
+
const code = exitCodeFor(verdict);
|
|
1688
2012
|
if (onChainVerdict === "failed") return EXIT_FAILED;
|
|
1689
2013
|
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
|
|
1690
2014
|
return code;
|
|
1691
2015
|
}
|
|
1692
2016
|
|
|
2017
|
+
interface CaseFileData {
|
|
2018
|
+
root: string;
|
|
2019
|
+
index: string;
|
|
2020
|
+
case_id: string | null;
|
|
2021
|
+
findings: { rendered: string }[];
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1693
2024
|
interface CaseData {
|
|
1694
|
-
|
|
2025
|
+
title: string;
|
|
2026
|
+
reliance: string;
|
|
1695
2027
|
summary: string;
|
|
2028
|
+
witness: { rendered: string };
|
|
1696
2029
|
exceptions: { rendered: string }[];
|
|
1697
|
-
|
|
2030
|
+
notes: { rendered: string }[];
|
|
2031
|
+
packs: {
|
|
2032
|
+
ref: string;
|
|
2033
|
+
log_files: number;
|
|
2034
|
+
unaccounted: string[];
|
|
2035
|
+
signing_key: string | null;
|
|
2036
|
+
grade: string | null;
|
|
2037
|
+
}[];
|
|
1698
2038
|
outside: string[];
|
|
1699
2039
|
not_regular: string[];
|
|
2040
|
+
case_file: CaseFileData[];
|
|
1700
2041
|
on_chain: OnChainHalf;
|
|
1701
2042
|
}
|
|
1702
2043
|
|
|
2044
|
+
const titleOf = (word: string): string =>
|
|
2045
|
+
Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
|
|
2046
|
+
? (VERDICT_DISPLAY_TITLES[word] as string)
|
|
2047
|
+
: word;
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* The text report, one line per entry, identical in both kernels. The headline
|
|
2051
|
+
* is the verdict's one display title and nothing else sets it (5.5.6, D7); the
|
|
2052
|
+
* scope sentence and the witness line are printed with it every time.
|
|
2053
|
+
*/
|
|
1703
2054
|
function caseReport(data: CaseData): string[] {
|
|
1704
|
-
const lines = [
|
|
2055
|
+
const lines = [
|
|
2056
|
+
data.title,
|
|
2057
|
+
` ${data.summary}`,
|
|
2058
|
+
` ${data.witness.rendered}`,
|
|
2059
|
+
` ${data.reliance}`,
|
|
2060
|
+
];
|
|
1705
2061
|
for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
|
|
2062
|
+
for (const n of data.notes) lines.push(` note: ${n.rendered}`);
|
|
1706
2063
|
lines.push(`packs ${data.packs.length}`);
|
|
1707
2064
|
for (const p of data.packs) {
|
|
1708
|
-
|
|
2065
|
+
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
2066
|
+
const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
|
|
2067
|
+
let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
|
|
1709
2068
|
if (p.unaccounted.length > 0) {
|
|
1710
2069
|
line +=
|
|
1711
2070
|
`; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
|
|
@@ -1723,17 +2082,21 @@ function caseReport(data: CaseData): string[] {
|
|
|
1723
2082
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
1724
2083
|
}
|
|
1725
2084
|
}
|
|
1726
|
-
const
|
|
1727
|
-
|
|
2085
|
+
for (const c of data.case_file) {
|
|
2086
|
+
if (c.index === "absent") continue;
|
|
2087
|
+
const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
|
|
1728
2088
|
lines.push(
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
"
|
|
2089
|
+
`case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
|
|
2090
|
+
`${label}; ${c.findings.length} disagreement(s) with what is here, ` +
|
|
2091
|
+
"none of which moves the verdict",
|
|
1732
2092
|
);
|
|
1733
|
-
|
|
2093
|
+
for (const f of c.findings) lines.push(` - ${f.rendered}`);
|
|
2094
|
+
}
|
|
2095
|
+
const oc = data.on_chain;
|
|
2096
|
+
if (oc.checked) {
|
|
1734
2097
|
lines.push(
|
|
1735
|
-
`on-chain
|
|
1736
|
-
"pack by pack; the
|
|
2098
|
+
`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
|
|
2099
|
+
"pack by pack; the reading above is offline and this does not " +
|
|
1737
2100
|
"change it",
|
|
1738
2101
|
);
|
|
1739
2102
|
for (const r of oc.packs) {
|
|
@@ -1743,13 +2106,63 @@ function caseReport(data: CaseData): string[] {
|
|
|
1743
2106
|
`unreachable, ${r.not_checked} not checked`,
|
|
1744
2107
|
);
|
|
1745
2108
|
}
|
|
1746
|
-
lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
|
|
1747
2109
|
}
|
|
1748
2110
|
return lines;
|
|
1749
2111
|
}
|
|
1750
2112
|
|
|
2113
|
+
/**
|
|
2114
|
+
* The index comparison, once per argument (5.6.3): each folder or case file
|
|
2115
|
+
* whose root holds `contents.json`. Every file under `packs/` and `report/` is
|
|
2116
|
+
* hashed for it, as bytes, and nothing is parsed but the index. It is never an
|
|
2117
|
+
* input to the reading.
|
|
2118
|
+
*/
|
|
2119
|
+
async function caseFileReadings(tree: Map<string, TreeEntry>): Promise<CaseFileData[]> {
|
|
2120
|
+
const out: (CaseFileData & { findings: Record<string, unknown>[] })[] = [];
|
|
2121
|
+
const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0] as string))].sort();
|
|
2122
|
+
const keys = [...tree.keys()].sort();
|
|
2123
|
+
for (const root of roots) {
|
|
2124
|
+
const prefix = `${root}/`;
|
|
2125
|
+
const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
|
|
2126
|
+
const indexEntry = tree.get(indexKey);
|
|
2127
|
+
const indexBytes =
|
|
2128
|
+
indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
|
|
2129
|
+
const files: { path: string; sha256: string }[] = [];
|
|
2130
|
+
if (indexBytes !== null) {
|
|
2131
|
+
for (const key of keys) {
|
|
2132
|
+
const entry = tree.get(key)!;
|
|
2133
|
+
const rel = key.slice(prefix.length);
|
|
2134
|
+
if (
|
|
2135
|
+
key.startsWith(prefix) &&
|
|
2136
|
+
entry.regular &&
|
|
2137
|
+
(rel.startsWith("packs/") || rel.startsWith("report/"))
|
|
2138
|
+
) {
|
|
2139
|
+
files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
const reading = readCaseFileIndex(indexBytes, files);
|
|
2144
|
+
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
2145
|
+
out.push({
|
|
2146
|
+
root,
|
|
2147
|
+
index: reading.index,
|
|
2148
|
+
case_id: reading.caseId,
|
|
2149
|
+
findings: reading.findings.map((f) => ({
|
|
2150
|
+
kind: f.kind,
|
|
2151
|
+
ref: nil(f.ref),
|
|
2152
|
+
path: nil(f.path),
|
|
2153
|
+
listed_sha256: nil(f.listedSha256),
|
|
2154
|
+
sha256: nil(f.sha256),
|
|
2155
|
+
reason: nil(f.reason),
|
|
2156
|
+
public_key: nil(f.publicKey),
|
|
2157
|
+
rendered: renderCaseFileFinding(f),
|
|
2158
|
+
})),
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
return out as CaseFileData[];
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1751
2164
|
function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
|
|
1752
|
-
if (asJson) io
|
|
2165
|
+
if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
|
|
1753
2166
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
1754
2167
|
if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
1755
2168
|
return EXIT_MALFORMED;
|
|
@@ -1761,7 +2174,8 @@ function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
|
|
|
1761
2174
|
* never 1, because 1 would name the evidence failed over a defect in this
|
|
1762
2175
|
* command.
|
|
1763
2176
|
*/
|
|
1764
|
-
export async function runCase(argv: readonly string[],
|
|
2177
|
+
export async function runCase(argv: readonly string[], stream: Cli): Promise<number> {
|
|
2178
|
+
const io = guard(stream);
|
|
1765
2179
|
try {
|
|
1766
2180
|
return await caseCommand(argv, io);
|
|
1767
2181
|
} catch (e) {
|
|
@@ -1788,7 +2202,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1788
2202
|
for (let i = 0; i < argv.length; i++) {
|
|
1789
2203
|
const arg = argv[i]!;
|
|
1790
2204
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
1791
|
-
io
|
|
2205
|
+
outBlock(io, CASE_USAGE);
|
|
1792
2206
|
return EXIT_VERIFIED;
|
|
1793
2207
|
}
|
|
1794
2208
|
if (arg === "--json") {
|
|
@@ -1797,7 +2211,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1797
2211
|
const next = argv[i + 1];
|
|
1798
2212
|
if (next === undefined) {
|
|
1799
2213
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
1800
|
-
io
|
|
2214
|
+
outBlock(io, CASE_USAGE);
|
|
1801
2215
|
return EXIT_MALFORMED;
|
|
1802
2216
|
}
|
|
1803
2217
|
gateways = next
|
|
@@ -1807,7 +2221,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1807
2221
|
i++;
|
|
1808
2222
|
} else if (arg.startsWith("--")) {
|
|
1809
2223
|
io.err(`case: unknown option ${arg}`);
|
|
1810
|
-
io
|
|
2224
|
+
outBlock(io, CASE_USAGE);
|
|
1811
2225
|
return EXIT_MALFORMED;
|
|
1812
2226
|
} else {
|
|
1813
2227
|
paths.push(arg);
|
|
@@ -1815,14 +2229,16 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1815
2229
|
}
|
|
1816
2230
|
if (paths.length === 0) {
|
|
1817
2231
|
io.err("case: at least one pack is required");
|
|
1818
|
-
io
|
|
2232
|
+
outBlock(io, CASE_USAGE);
|
|
1819
2233
|
return EXIT_MALFORMED;
|
|
1820
2234
|
}
|
|
1821
2235
|
|
|
1822
2236
|
let split;
|
|
2237
|
+
let tree: Map<string, TreeEntry>;
|
|
1823
2238
|
let bundles: [SplitPack, unknown][];
|
|
1824
2239
|
try {
|
|
1825
|
-
|
|
2240
|
+
tree = await treeFromArguments(paths);
|
|
2241
|
+
split = await splitCase(tree, readFileEntry);
|
|
1826
2242
|
bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
|
|
1827
2243
|
} catch (e) {
|
|
1828
2244
|
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
@@ -1832,18 +2248,34 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1832
2248
|
const reading = await readCase(
|
|
1833
2249
|
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
1834
2250
|
);
|
|
2251
|
+
let caseFile: CaseFileData[];
|
|
2252
|
+
try {
|
|
2253
|
+
caseFile = await caseFileReadings(tree);
|
|
2254
|
+
} catch (e) {
|
|
2255
|
+
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
2256
|
+
throw e;
|
|
2257
|
+
}
|
|
1835
2258
|
const onChain = await onChainHalf(
|
|
1836
2259
|
bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
|
|
1837
2260
|
gateways,
|
|
1838
2261
|
io,
|
|
1839
2262
|
);
|
|
2263
|
+
const witness = witnessLine(onChain);
|
|
1840
2264
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
1841
2265
|
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
1842
2266
|
|
|
1843
2267
|
const data = {
|
|
1844
2268
|
verdict: reading.verdict,
|
|
2269
|
+
title: titleOf(reading.verdict),
|
|
2270
|
+
reliance: CASE_RELIANCE[reading.verdict],
|
|
1845
2271
|
exit_code: code,
|
|
1846
2272
|
summary: renderCaseSummary(reading),
|
|
2273
|
+
witness: {
|
|
2274
|
+
state: witness.state,
|
|
2275
|
+
reasons: witness.reasons,
|
|
2276
|
+
services: witness.services,
|
|
2277
|
+
rendered: renderWitnessLine(witness),
|
|
2278
|
+
},
|
|
1847
2279
|
handoff_records: reading.handoffRecords,
|
|
1848
2280
|
on_chain_checked: reading.onChainChecked,
|
|
1849
2281
|
exceptions: reading.exceptions.map((e) => ({
|
|
@@ -1854,29 +2286,42 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1854
2286
|
failed_checks: e.failedChecks ?? [],
|
|
1855
2287
|
count: nil(e.count),
|
|
1856
2288
|
status: nil(e.status),
|
|
2289
|
+
causes: e.causes ?? [],
|
|
1857
2290
|
reasons: e.reasons ?? [],
|
|
1858
2291
|
rendered: renderException(e),
|
|
1859
2292
|
})),
|
|
2293
|
+
notes: reading.notes.map((n) => ({
|
|
2294
|
+
kind: n.kind,
|
|
2295
|
+
pack_ref: n.packRef,
|
|
2296
|
+
count: nil(n.count),
|
|
2297
|
+
rendered: renderCaseNote(n),
|
|
2298
|
+
})),
|
|
1860
2299
|
records: reading.records.map((r) => ({
|
|
1861
2300
|
pack_ref: r.packRef,
|
|
1862
2301
|
event_id: r.eventId,
|
|
1863
2302
|
verdict: nil(r.verdict),
|
|
2303
|
+
role: r.role,
|
|
1864
2304
|
findings: r.findings,
|
|
1865
2305
|
failed_checks: r.failedChecks,
|
|
1866
2306
|
counterparts: r.counterparts,
|
|
1867
2307
|
})),
|
|
1868
2308
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
1869
|
-
packs: bundles.map(([p]) => ({
|
|
2309
|
+
packs: bundles.map(([p], i) => ({
|
|
1870
2310
|
ref: p.ref,
|
|
1871
2311
|
bundle_path: p.bundlePath,
|
|
1872
2312
|
log_files: p.content.size,
|
|
1873
2313
|
unaccounted: p.unaccounted,
|
|
2314
|
+
signing_key: reading.packs[i]!.signingKey,
|
|
2315
|
+
grade: reading.packs[i]!.grade,
|
|
2316
|
+
records_withheld: reading.packs[i]!.recordsWithheld,
|
|
2317
|
+
declaration_version: reading.packs[i]!.declarationVersion,
|
|
1874
2318
|
})),
|
|
1875
2319
|
outside: split.outside,
|
|
1876
2320
|
not_regular: split.notRegular,
|
|
2321
|
+
case_file: caseFile,
|
|
1877
2322
|
on_chain: onChain,
|
|
1878
2323
|
};
|
|
1879
|
-
if (asJson) io
|
|
2324
|
+
if (asJson) outJson(io, jsonText(data));
|
|
1880
2325
|
else for (const line of caseReport(data)) io.out(noLoneSurrogates(line));
|
|
1881
2326
|
return code;
|
|
1882
2327
|
}
|
|
@@ -1913,7 +2358,7 @@ if (isMain) {
|
|
|
1913
2358
|
process.exitCode = code;
|
|
1914
2359
|
})
|
|
1915
2360
|
.catch((e) => {
|
|
1916
|
-
process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
2361
|
+
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
1917
2362
|
process.exitCode = EXIT_MALFORMED;
|
|
1918
2363
|
});
|
|
1919
2364
|
}
|