@sanning/proof 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -12
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +353 -0
- package/dist/case.d.ts +217 -26
- package/dist/case.js +535 -124
- package/dist/cli.d.ts +64 -8
- package/dist/cli.js +799 -219
- package/dist/compose.js +6 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +38 -0
- package/dist/evidence.js +238 -22
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +14 -7
- package/dist/index.js +20 -6
- package/dist/message-parts.d.ts +18 -10
- package/dist/message-parts.js +25 -15
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +63 -4
- package/dist/verifier.js +223 -15
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +422 -0
- package/src/case.ts +671 -131
- package/src/cli.ts +932 -217
- package/src/compose.ts +8 -1
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +257 -23
- package/src/exit-codes.ts +54 -0
- package/src/file-match.ts +461 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +101 -2
- package/src/message-parts.ts +25 -15
- package/src/types.ts +5 -0
- package/src/verifier.ts +225 -16
- package/src/witness-cache.ts +306 -0
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +66 -8
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +20 -5
- package/vectors/witness-cache.json +1304 -0
package/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,53 @@ 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
|
+
VERDICT_DISPLAY_TITLES,
|
|
61
|
+
WITNESS_NOT_LOOKED_UP,
|
|
62
|
+
type WitnessLine,
|
|
63
|
+
caseReliance,
|
|
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";
|
|
81
|
+
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
82
|
+
import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
|
|
83
|
+
import type { OfferedFile } from "./file-match.js";
|
|
84
|
+
import {
|
|
85
|
+
type CachedWitnessTime,
|
|
86
|
+
type WitnessTimeAnswer,
|
|
87
|
+
type WitnessTimeCheck,
|
|
88
|
+
WITNESS_CACHE_NAME,
|
|
89
|
+
checkWitnessCache,
|
|
90
|
+
readWitnessCache,
|
|
91
|
+
renderWitnessTime,
|
|
92
|
+
} from "./witness-cache.js";
|
|
56
93
|
import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
|
|
57
94
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
58
95
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
59
96
|
import type { DisclosureAccount, PackGrade } from "./grading.js";
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
import {
|
|
99
|
+
EXIT_CODES_HELP,
|
|
100
|
+
EXIT_FAILED,
|
|
101
|
+
EXIT_MALFORMED,
|
|
102
|
+
EXIT_UNDETERMINED,
|
|
103
|
+
EXIT_VERIFIED,
|
|
104
|
+
exitCodeFor,
|
|
105
|
+
} from "./exit-codes.js";
|
|
67
106
|
|
|
68
107
|
interface Cli {
|
|
69
108
|
out: (s: string) => void;
|
|
@@ -71,12 +110,78 @@ interface Cli {
|
|
|
71
110
|
fetchImpl?: typeof fetch;
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
// ── What a person reads is escaped at one choke point ──
|
|
114
|
+
//
|
|
115
|
+
// Every human-readable line this command prints passes through `guard`, which
|
|
116
|
+
// `runCli` and `runCase` apply to the streams they are handed. It escapes each
|
|
117
|
+
// control character, so a value the audited party wrote (a folder or file
|
|
118
|
+
// name, a `contents.json` field, a zip entry name, an exception built from
|
|
119
|
+
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
120
|
+
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
121
|
+
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
122
|
+
// only, which escapes its own control characters. The twin is `Io` in
|
|
123
|
+
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
124
|
+
|
|
125
|
+
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
126
|
+
// embedding, override and isolate controls.
|
|
127
|
+
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
128
|
+
|
|
129
|
+
/** Each control character in `text` as a visible escape: `\x1b` for ESC,
|
|
130
|
+
* `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
|
|
131
|
+
* backslash included, so a line with none is printed as it was. */
|
|
132
|
+
export function escapeControl(text: string): string {
|
|
133
|
+
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
134
|
+
const code = c.charCodeAt(0);
|
|
135
|
+
return code < 0x100
|
|
136
|
+
? `\\x${code.toString(16).padStart(2, "0")}`
|
|
137
|
+
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The colors this command writes on a TTY are its own control characters, and
|
|
142
|
+
// the choke point would escape them with everything else. So `paint` and
|
|
143
|
+
// `mark` write this marker instead, and the choke point turns it into ESC
|
|
144
|
+
// after escaping. It is random per process, so no input can spell it.
|
|
145
|
+
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
146
|
+
const G = `${COLOR_MARK}32m`;
|
|
147
|
+
const R = `${COLOR_MARK}31m`;
|
|
148
|
+
const Y = `${COLOR_MARK}33m`;
|
|
149
|
+
const DIM = `${COLOR_MARK}2m`;
|
|
150
|
+
const B = `${COLOR_MARK}1m`;
|
|
151
|
+
const X = `${COLOR_MARK}0m`;
|
|
152
|
+
|
|
153
|
+
function forTerminal(text: string): string {
|
|
154
|
+
return escapeControl(text).split(COLOR_MARK).join("\x1b[");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const RAW = Symbol("sanning.cli.raw");
|
|
158
|
+
type Guarded = Cli & { [RAW]: Cli };
|
|
159
|
+
|
|
160
|
+
function guard(io: Cli): Cli {
|
|
161
|
+
if (RAW in io) return io;
|
|
162
|
+
const guarded: Guarded = {
|
|
163
|
+
out: (s) => io.out(forTerminal(s)),
|
|
164
|
+
err: (s) => io.err(forTerminal(s)),
|
|
165
|
+
[RAW]: io,
|
|
166
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
167
|
+
};
|
|
168
|
+
return guarded;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rawOf(io: Cli): Cli {
|
|
172
|
+
return (io as Partial<Guarded>)[RAW] ?? io;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** JSON, printed unescaped: a script reads it, not a terminal. */
|
|
176
|
+
function outJson(io: Cli, text: string): void {
|
|
177
|
+
rawOf(io).out(text);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A block this command wrote itself, such as a usage text: its own newlines
|
|
181
|
+
* are kept, and each line is escaped as `out` escapes it. */
|
|
182
|
+
function outBlock(io: Cli, text: string): void {
|
|
183
|
+
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
184
|
+
}
|
|
80
185
|
|
|
81
186
|
function useColor(): boolean {
|
|
82
187
|
// Honor NO_COLOR; otherwise color only a TTY.
|
|
@@ -95,7 +200,8 @@ function paint(s: string, c: string): string {
|
|
|
95
200
|
return useColor() ? `${c}${s}${X}` : s;
|
|
96
201
|
}
|
|
97
202
|
|
|
98
|
-
export async function runCli(argv: string[],
|
|
203
|
+
export async function runCli(argv: string[], stream: Cli): Promise<number> {
|
|
204
|
+
const io = guard(stream);
|
|
99
205
|
const [command, ...rest] = argv;
|
|
100
206
|
|
|
101
207
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
@@ -106,11 +212,19 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
106
212
|
io.out("@sanning/proof verify CLI");
|
|
107
213
|
return EXIT_VERIFIED;
|
|
108
214
|
}
|
|
109
|
-
// `
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
215
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`). It
|
|
216
|
+
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
217
|
+
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
218
|
+
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
219
|
+
if (command === "export") {
|
|
220
|
+
if (rest.includes("--json")) {
|
|
221
|
+
return runJson(
|
|
222
|
+
rest.filter((a) => a !== "--json"),
|
|
223
|
+
io,
|
|
224
|
+
runExport,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return runExport(rest, io, null);
|
|
114
228
|
}
|
|
115
229
|
if (command === "case") {
|
|
116
230
|
return runCase(rest, io);
|
|
@@ -120,14 +234,76 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
120
234
|
printUsage(io);
|
|
121
235
|
return EXIT_MALFORMED;
|
|
122
236
|
}
|
|
237
|
+
if (rest.includes("--json")) {
|
|
238
|
+
return runJson(
|
|
239
|
+
rest.filter((a) => a !== "--json"),
|
|
240
|
+
io,
|
|
241
|
+
runVerify,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return runVerify(rest, io, null);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── `--json` on `verify` and `export` (C-03, 0.13.0) ──────────────────────
|
|
248
|
+
//
|
|
249
|
+
// `case --json` printed the reading as JSON and the other two verbs had no
|
|
250
|
+
// machine-readable form, so a script parsed the text. Each verb now runs as it
|
|
251
|
+
// does for a person, with its text discarded, and hands this one object back
|
|
252
|
+
// through `sink`: the same keys in both kernels, sorted at every depth, and
|
|
253
|
+
// printed byte for byte the same (`cross-kernel/run_cli.sh`,
|
|
254
|
+
// `cross-kernel/run_export_cli.sh`). A run that stops before it has anything to
|
|
255
|
+
// report (a bad flag, an unreadable file) prints `{ error, exit_code }`, as
|
|
256
|
+
// `case --json` does. `note:` lines still go to stderr.
|
|
257
|
+
interface JsonSink {
|
|
258
|
+
data: Record<string, unknown> | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
type JsonVerb = (rest: string[], io: Cli, sink: JsonSink | null) => Promise<number>;
|
|
123
262
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
263
|
+
async function runJson(rest: string[], io: Cli, verb: JsonVerb): Promise<number> {
|
|
264
|
+
const errs: string[] = [];
|
|
265
|
+
const sink: JsonSink = { data: null };
|
|
266
|
+
const quiet: Cli = {
|
|
267
|
+
out: () => {},
|
|
268
|
+
err: (s) => errs.push(s),
|
|
269
|
+
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
270
|
+
};
|
|
271
|
+
const code = await verb(rest, quiet, sink);
|
|
272
|
+
const notes = errs.filter((l) => l.startsWith("note: "));
|
|
273
|
+
for (const n of notes) io.err(n);
|
|
274
|
+
if (sink.data !== null) {
|
|
275
|
+
outJson(io, jsonText({ ...sink.data, exit_code: code }));
|
|
276
|
+
} else {
|
|
277
|
+
const error = errs.filter((l) => !l.startsWith("note: ")).join("\n");
|
|
278
|
+
outJson(io, jsonText({ error, exit_code: code }));
|
|
279
|
+
}
|
|
280
|
+
return code;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
284
|
+
|
|
285
|
+
// `--logs <path>` feeds disclosed raw-log bytes for content verification
|
|
286
|
+
// (the evidence-bundle path only); `--gateways <url,url>` asks for the
|
|
287
|
+
// on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
|
|
288
|
+
// second positional argument until then, and a second positional is now a
|
|
289
|
+
// usage error). The one positional is the bundle.
|
|
127
290
|
let logsPath: string | undefined;
|
|
291
|
+
let filePaths: string[] | undefined;
|
|
292
|
+
let gatewayArg: string | undefined;
|
|
128
293
|
const positional: string[] = [];
|
|
129
294
|
for (let i = 0; i < rest.length; i++) {
|
|
130
295
|
const a = rest[i]!;
|
|
296
|
+
if (a === "--gateways") {
|
|
297
|
+
const next = rest[i + 1];
|
|
298
|
+
if (next === undefined) {
|
|
299
|
+
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
300
|
+
printUsage(io);
|
|
301
|
+
return EXIT_MALFORMED;
|
|
302
|
+
}
|
|
303
|
+
gatewayArg = next;
|
|
304
|
+
i++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
131
307
|
if (a === "--logs") {
|
|
132
308
|
const next = rest[i + 1];
|
|
133
309
|
if (next === undefined) {
|
|
@@ -139,15 +315,31 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
139
315
|
i++;
|
|
140
316
|
continue;
|
|
141
317
|
}
|
|
318
|
+
if (a === "--files") {
|
|
319
|
+
const taken = takeFilePaths(rest, i + 1);
|
|
320
|
+
if (taken.length === 0) {
|
|
321
|
+
io.err("verify: --files requires at least one file path");
|
|
322
|
+
printUsage(io);
|
|
323
|
+
return EXIT_MALFORMED;
|
|
324
|
+
}
|
|
325
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
326
|
+
i += taken.length;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
142
329
|
positional.push(a);
|
|
143
330
|
}
|
|
144
331
|
|
|
145
|
-
const [bundlePath
|
|
332
|
+
const [bundlePath] = positional;
|
|
146
333
|
if (!bundlePath) {
|
|
147
334
|
io.err("verify: a bundle file path is required");
|
|
148
335
|
printUsage(io);
|
|
149
336
|
return EXIT_MALFORMED;
|
|
150
337
|
}
|
|
338
|
+
if (positional.length > 1) {
|
|
339
|
+
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
340
|
+
printUsage(io);
|
|
341
|
+
return EXIT_MALFORMED;
|
|
342
|
+
}
|
|
151
343
|
const gateways = (gatewayArg ?? "")
|
|
152
344
|
.split(",")
|
|
153
345
|
.map((g) => g.trim())
|
|
@@ -161,6 +353,16 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
161
353
|
}
|
|
162
354
|
const content = logs?.content;
|
|
163
355
|
|
|
356
|
+
let offered: OfferedFile[] | undefined;
|
|
357
|
+
if (filePaths !== undefined) {
|
|
358
|
+
const read = await readOfferedFiles(filePaths);
|
|
359
|
+
if (typeof read === "string") {
|
|
360
|
+
io.err(`verify: ${read}`);
|
|
361
|
+
return EXIT_MALFORMED;
|
|
362
|
+
}
|
|
363
|
+
offered = read;
|
|
364
|
+
}
|
|
365
|
+
|
|
164
366
|
let raw: Uint8Array;
|
|
165
367
|
try {
|
|
166
368
|
raw = new Uint8Array(await readFile(bundlePath));
|
|
@@ -178,6 +380,8 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
178
380
|
} catch (e) {
|
|
179
381
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
180
382
|
io.err(`${bundlePath} ${e.detail}; refusing to read it`);
|
|
383
|
+
} else if (e instanceof BundleReadError && e.duplicate) {
|
|
384
|
+
io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
181
385
|
} else {
|
|
182
386
|
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
183
387
|
}
|
|
@@ -195,11 +399,14 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
195
399
|
// has no such per-event content. Say so rather than silently ignoring it.
|
|
196
400
|
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
197
401
|
}
|
|
198
|
-
|
|
402
|
+
if (offered) {
|
|
403
|
+
io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
404
|
+
}
|
|
405
|
+
const result = await verifyAgentProof(parsed, {
|
|
199
406
|
gateways,
|
|
200
407
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
201
408
|
});
|
|
202
|
-
return reportAgentProof(result, bundlePath, gateways, io);
|
|
409
|
+
return reportAgentProof(result, bundlePath, gateways, io, sink);
|
|
203
410
|
}
|
|
204
411
|
|
|
205
412
|
// Default: treat anything else as an evidence bundle. verifyEvidenceBundle
|
|
@@ -228,26 +435,62 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
|
|
|
228
435
|
} catch (e) {
|
|
229
436
|
chains = { unreadable: errMsg(e) };
|
|
230
437
|
}
|
|
231
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs);
|
|
438
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
|
|
439
|
+
// Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
|
|
440
|
+
// beside the verdict, never inside it, so `exit` is untouched.
|
|
441
|
+
if (offered !== undefined) {
|
|
442
|
+
const reading = await matchFiles(parsed, offered, content);
|
|
443
|
+
for (const line of renderFileMatch(reading)) io.out(line);
|
|
444
|
+
if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
|
|
445
|
+
}
|
|
232
446
|
// Coverage is reported only once the bundle parsed far enough to have events
|
|
233
447
|
// to compare against; over a MALFORMED bundle there is nothing to say.
|
|
234
448
|
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
235
449
|
return exit;
|
|
236
450
|
}
|
|
237
451
|
|
|
238
|
-
//
|
|
239
|
-
|
|
452
|
+
// The paths after `--files`: every argument up to the next option.
|
|
453
|
+
function takeFilePaths(args: readonly string[], start: number): string[] {
|
|
454
|
+
const out: string[] = [];
|
|
455
|
+
for (const arg of args.slice(start)) {
|
|
456
|
+
if (arg.startsWith("--")) break;
|
|
457
|
+
out.push(arg);
|
|
458
|
+
}
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
|
|
463
|
+
// one error line when a path is not a readable regular file. The line names the
|
|
464
|
+
// path and not the operating system's words, which differ by kernel.
|
|
465
|
+
async function readOfferedFiles(paths: readonly string[]): Promise<OfferedFile[] | string> {
|
|
466
|
+
const out: OfferedFile[] = [];
|
|
467
|
+
for (const path of paths) {
|
|
468
|
+
try {
|
|
469
|
+
if (!(await stat(path)).isFile()) return `--files ${path} is not a readable file`;
|
|
470
|
+
out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
|
|
471
|
+
} catch {
|
|
472
|
+
return `--files ${path} is not a readable file`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
240
479
|
//
|
|
241
480
|
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
|
|
242
481
|
// source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
|
|
243
|
-
// Ed25519 key (evidence-export.md §5, composer). Writes the export to
|
|
482
|
+
// Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
|
|
244
483
|
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
245
484
|
//
|
|
246
485
|
// Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
|
|
247
486
|
// forged/mis-bound attestation — the composer refuses to emit a green artifact
|
|
248
487
|
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
249
488
|
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
250
|
-
async function
|
|
489
|
+
async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
490
|
+
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
491
|
+
usageExport(io);
|
|
492
|
+
return EXIT_VERIFIED;
|
|
493
|
+
}
|
|
251
494
|
let attestationsPath: string | undefined;
|
|
252
495
|
let keyPath: string | undefined;
|
|
253
496
|
let outPath: string | undefined;
|
|
@@ -265,15 +508,15 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
265
508
|
i++;
|
|
266
509
|
return next;
|
|
267
510
|
};
|
|
268
|
-
if (a === "--attestations"
|
|
511
|
+
if (a === "--attestations") {
|
|
269
512
|
const v = takeNext(a);
|
|
270
513
|
if (v === undefined) return usageExport(io);
|
|
271
514
|
attestationsPath = v;
|
|
272
|
-
} else if (a === "--key"
|
|
515
|
+
} else if (a === "--key") {
|
|
273
516
|
const v = takeNext(a);
|
|
274
517
|
if (v === undefined) return usageExport(io);
|
|
275
518
|
keyPath = v;
|
|
276
|
-
} else if (a === "--out"
|
|
519
|
+
} else if (a === "--out") {
|
|
277
520
|
const v = takeNext(a);
|
|
278
521
|
if (v === undefined) return usageExport(io);
|
|
279
522
|
outPath = v;
|
|
@@ -293,6 +536,12 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
293
536
|
const v = takeNext(a);
|
|
294
537
|
if (v === undefined) return usageExport(io);
|
|
295
538
|
opts.generatedAt = v;
|
|
539
|
+
} else if (a.startsWith("-")) {
|
|
540
|
+
// A flag this verb does not take is a usage error, never a path: `-o`
|
|
541
|
+
// was a short form of `--out` until 0.13.0, and read as a path it would
|
|
542
|
+
// send the export to stdout while the caller waits for a file.
|
|
543
|
+
io.err(`export: unknown option ${a}`);
|
|
544
|
+
return usageExport(io);
|
|
296
545
|
} else {
|
|
297
546
|
positional.push(a);
|
|
298
547
|
}
|
|
@@ -338,6 +587,18 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
338
587
|
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
339
588
|
// still writes the artifact so the caller can inspect it.
|
|
340
589
|
const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
|
|
590
|
+
if (sink) {
|
|
591
|
+
sink.data = {
|
|
592
|
+
status: composed.status,
|
|
593
|
+
out: outPath ?? null,
|
|
594
|
+
source_bundle_hash: composed.sourceBundleHash,
|
|
595
|
+
attestations: attestations.length,
|
|
596
|
+
attestations_bound: composed.boundAttestations,
|
|
597
|
+
// Without --out the artifact rides inside this object, since stdout
|
|
598
|
+
// carries the object and not the artifact.
|
|
599
|
+
export: outPath ? null : composed.bundle,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
341
602
|
if (outPath) {
|
|
342
603
|
try {
|
|
343
604
|
await writeFile(outPath, serialized, "utf8");
|
|
@@ -351,28 +612,34 @@ async function runBundle(rest: string[], io: Cli): Promise<number> {
|
|
|
351
612
|
`attestations ${composed.boundAttestations}/${attestations.length} bound`,
|
|
352
613
|
);
|
|
353
614
|
} else {
|
|
354
|
-
io
|
|
615
|
+
outJson(io, serialized.replace(/\n$/, ""));
|
|
355
616
|
}
|
|
356
617
|
return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
|
|
357
618
|
}
|
|
358
619
|
|
|
359
620
|
function usageExport(io: Cli): number {
|
|
360
621
|
io.out(
|
|
361
|
-
"Usage:
|
|
622
|
+
"Usage: sanning-proof export <source-bundle.json> --attestations <file> --key <file> [--out <out.json>] [--json]",
|
|
362
623
|
);
|
|
363
624
|
io.out("");
|
|
364
625
|
io.out("Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from an");
|
|
365
626
|
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>`.");
|
|
627
|
+
io.out("Ed25519 key. The output verifies with `sanning-proof verify <out.json>`.");
|
|
367
628
|
io.out("");
|
|
368
629
|
io.out("--attestations <file> JSON array of attestation records (or {attestations:[...]}).");
|
|
369
630
|
io.out("--key <file> Exporter Ed25519 key: a 32-byte hex seed, or JSON");
|
|
370
631
|
io.out(' {"privateKey":"<hex>"} / {"seed":"<hex>"}.');
|
|
371
|
-
io.out("
|
|
632
|
+
io.out("--out <file> Write the export here (default: stdout).");
|
|
372
633
|
io.out("--issuer-id <id> Wrapper issuer.issuer_id (default sanning-verify).");
|
|
373
634
|
io.out("--gateway <url> Named delivery surface (not trusted).");
|
|
374
635
|
io.out("--previous-hash <h> Custody pointer (default GENESIS).");
|
|
375
636
|
io.out("--generated-at <ts> RFC 3339 compose time (default now).");
|
|
637
|
+
io.out("--json Print a summary as JSON (with the export inside it");
|
|
638
|
+
io.out(" when --out is not given).");
|
|
639
|
+
io.out("");
|
|
640
|
+
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
641
|
+
io.out("");
|
|
642
|
+
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
376
643
|
return EXIT_MALFORMED;
|
|
377
644
|
}
|
|
378
645
|
|
|
@@ -387,8 +654,12 @@ async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
|
|
|
387
654
|
return undefined;
|
|
388
655
|
}
|
|
389
656
|
try {
|
|
390
|
-
return
|
|
657
|
+
return parseRefusingDuplicates(raw) as T;
|
|
391
658
|
} catch (e) {
|
|
659
|
+
if (e instanceof DuplicateKeyError) {
|
|
660
|
+
io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
392
663
|
io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
393
664
|
return undefined;
|
|
394
665
|
}
|
|
@@ -446,7 +717,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
446
717
|
// disclosure. This is the layout `materializeBundle` writes
|
|
447
718
|
// (bundle.json + logs/) and the layout the verify page hashes,
|
|
448
719
|
// so a pack verifies with NO side-input construction step.
|
|
449
|
-
// JSON MAP `{ event_id: <even-length
|
|
720
|
+
// JSON MAP `{ event_id: <even-length hex, either case | utf8 text> }`, the
|
|
450
721
|
// original form. Unchanged.
|
|
451
722
|
//
|
|
452
723
|
// **Why the directory form exists at all.** Handing a pack to the CLI used to
|
|
@@ -465,7 +736,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
465
736
|
// and the verify page agree about one pack.
|
|
466
737
|
interface LoadedLogs {
|
|
467
738
|
// Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
|
|
468
|
-
// is
|
|
739
|
+
// is hex in either case (map form). Those are exactly the two the verifier
|
|
469
740
|
// distinguishes.
|
|
470
741
|
//
|
|
471
742
|
// A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
|
|
@@ -518,8 +789,14 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
|
|
|
518
789
|
}
|
|
519
790
|
let logsParsed: unknown;
|
|
520
791
|
try {
|
|
521
|
-
logsParsed =
|
|
792
|
+
logsParsed = parseRefusingDuplicates(logsRaw);
|
|
522
793
|
} catch (e) {
|
|
794
|
+
if (e instanceof DuplicateKeyError) {
|
|
795
|
+
// One event id named twice hands the reader two contents for one
|
|
796
|
+
// record, and the last one silently won (proof#144).
|
|
797
|
+
io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
523
800
|
io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
|
|
524
801
|
return undefined;
|
|
525
802
|
}
|
|
@@ -658,7 +935,8 @@ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io:
|
|
|
658
935
|
if (undetermined.length > 0) {
|
|
659
936
|
io.err(
|
|
660
937
|
`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
|
|
661
|
-
`content
|
|
938
|
+
`content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
939
|
+
`hand-off record) to bind them to. ` +
|
|
662
940
|
`Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
|
|
663
941
|
);
|
|
664
942
|
}
|
|
@@ -706,32 +984,49 @@ function decodeLogValue(v: string): Uint8Array | string {
|
|
|
706
984
|
}
|
|
707
985
|
|
|
708
986
|
function printUsage(io: Cli): void {
|
|
709
|
-
io.out("Usage:
|
|
987
|
+
io.out("Usage: sanning-proof <command> [args]");
|
|
988
|
+
io.out(" (npx @sanning/proof <command> [args], without installing)");
|
|
710
989
|
io.out("");
|
|
711
990
|
io.out("Commands:");
|
|
712
|
-
io.out(" verify <bundle.json> [gateways] [--logs <path>]
|
|
713
|
-
io.out("
|
|
714
|
-
io.out("
|
|
991
|
+
io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
|
|
992
|
+
io.out(" Verify a bundle");
|
|
993
|
+
io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
|
|
994
|
+
io.out(" Create an attested export");
|
|
995
|
+
io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
|
|
996
|
+
io.out(" Read several packs as one case");
|
|
715
997
|
io.out(" (`case --help` for its exit codes)");
|
|
716
998
|
io.out("");
|
|
717
999
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
718
1000
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
719
1001
|
io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
|
|
720
|
-
io.out("fully offline.
|
|
721
|
-
io.out("
|
|
1002
|
+
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
1003
|
+
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
722
1004
|
io.out("a witness of any other kind is not checked and the run exits 3.");
|
|
723
1005
|
io.out("");
|
|
1006
|
+
io.out("--json Print the verdict, the causes and the exit code as");
|
|
1007
|
+
io.out(" JSON, for a script, instead of the report.");
|
|
1008
|
+
io.out("");
|
|
1009
|
+
io.out("--files <path>... Files handed in beside the pack, every path up to the");
|
|
1010
|
+
io.out(" next option. Each is hashed and matched, by SHA-256");
|
|
1011
|
+
io.out(" alone, against what the pack's signed records commit");
|
|
1012
|
+
io.out(" to; the report lists the files the pack vouches for,");
|
|
1013
|
+
io.out(" the files that match no record, and the commitments");
|
|
1014
|
+
io.out(" no file was offered for. Names and folders are never");
|
|
1015
|
+
io.out(" compared, and the verdict never moves.");
|
|
1016
|
+
io.out("");
|
|
724
1017
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
725
|
-
io.out(" content_hash
|
|
1018
|
+
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
1019
|
+
io.out(" Either shape works, and which one you");
|
|
726
1020
|
io.out(" passed is decided by stat(), not by the extension:");
|
|
727
1021
|
io.out("");
|
|
728
1022
|
io.out(" a DIRECTORY of <event_id>.json files — the file's");
|
|
729
1023
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
730
1024
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
731
|
-
io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
1025
|
+
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
732
1026
|
io.out("");
|
|
733
1027
|
io.out(" a JSON FILE mapping event_id → bytes (an");
|
|
734
|
-
io.out(" even-length
|
|
1028
|
+
io.out(" even-length hex value, in either case, ⇒ hex,");
|
|
1029
|
+
io.out(" else utf8).");
|
|
735
1030
|
io.out("");
|
|
736
1031
|
io.out(" In-body events[].content is verified automatically");
|
|
737
1032
|
io.out(" and takes precedence. An event with no disclosed");
|
|
@@ -741,8 +1036,10 @@ function printUsage(io: Cli): void {
|
|
|
741
1036
|
io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
|
|
742
1037
|
io.out("bundle itself is verified. Both verdicts are printed when they differ.");
|
|
743
1038
|
io.out("");
|
|
744
|
-
|
|
745
|
-
io.out("
|
|
1039
|
+
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
1040
|
+
io.out("");
|
|
1041
|
+
io.out("verify: an unreachable gateway reads partial, and a pack with a record");
|
|
1042
|
+
io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
|
|
746
1043
|
}
|
|
747
1044
|
|
|
748
1045
|
/**
|
|
@@ -790,8 +1087,10 @@ function reportEvidence(
|
|
|
790
1087
|
chains: ChainOutcome,
|
|
791
1088
|
parsed: unknown,
|
|
792
1089
|
logs: LoadedLogs | undefined,
|
|
1090
|
+
sink: JsonSink | null,
|
|
793
1091
|
): number {
|
|
794
1092
|
if (result.status === "malformed") {
|
|
1093
|
+
if (sink) sink.data = verifyJson(result, null, null, null);
|
|
795
1094
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
796
1095
|
for (const e of result.errors) io.err(` - ${e}`);
|
|
797
1096
|
return EXIT_MALFORMED;
|
|
@@ -889,7 +1188,7 @@ function reportEvidence(
|
|
|
889
1188
|
// answers `partial` and the line below says so in words, and pack grading
|
|
890
1189
|
// keeps its own verdict: every pack built before §5.4 carries no
|
|
891
1190
|
// declaration, and re-grading them all as undetermined would be a change to
|
|
892
|
-
//
|
|
1191
|
+
// their verdicts rather than a new check. A pack that DOES
|
|
893
1192
|
// carry a declaration this reader cannot read is a different case and exits
|
|
894
1193
|
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
895
1194
|
const declaration = checkDeclaration(parsed, logs?.content);
|
|
@@ -928,7 +1227,47 @@ function reportEvidence(
|
|
|
928
1227
|
);
|
|
929
1228
|
}
|
|
930
1229
|
|
|
931
|
-
|
|
1230
|
+
if (sink) sink.data = verifyJson(result, grade, declaration, chains);
|
|
1231
|
+
return mapGradeToExit(grade);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
1236
|
+
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
1237
|
+
* `chains` are null for a bundle no verdict could be rendered over.
|
|
1238
|
+
*/
|
|
1239
|
+
function verifyJson(
|
|
1240
|
+
result: EvidenceBundleResult,
|
|
1241
|
+
grade: PackGrade | null,
|
|
1242
|
+
declaration: ReturnType<typeof checkDeclaration> | null,
|
|
1243
|
+
chains: ChainOutcome | null,
|
|
1244
|
+
): Record<string, unknown> {
|
|
1245
|
+
let chainBlock: Record<string, unknown> | null = null;
|
|
1246
|
+
if (chains !== null) {
|
|
1247
|
+
chainBlock =
|
|
1248
|
+
"unreadable" in chains
|
|
1249
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1250
|
+
: {
|
|
1251
|
+
unreadable: null,
|
|
1252
|
+
chains: chains.chains.length,
|
|
1253
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
verdict: grade ? grade.verdict : result.status,
|
|
1258
|
+
bundle_status: result.status,
|
|
1259
|
+
asserted_status: result.assertedStatus ?? null,
|
|
1260
|
+
body_type: result.bodyType ?? null,
|
|
1261
|
+
on_chain_checked: result.onChainChecked,
|
|
1262
|
+
declaration: declaration ? declaration.verdict : null,
|
|
1263
|
+
declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
|
|
1264
|
+
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
1265
|
+
notes: [...result.errors],
|
|
1266
|
+
records: grade
|
|
1267
|
+
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
1268
|
+
: null,
|
|
1269
|
+
chains: chainBlock,
|
|
1270
|
+
};
|
|
932
1271
|
}
|
|
933
1272
|
|
|
934
1273
|
/**
|
|
@@ -946,8 +1285,8 @@ function reportEvidence(
|
|
|
946
1285
|
* complete" does not follow, and two customer-facing documents had already
|
|
947
1286
|
* made that leap. The limit travels with the result or it gets dropped.
|
|
948
1287
|
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
949
|
-
* the verdict surface, and the
|
|
950
|
-
*
|
|
1288
|
+
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1289
|
+
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
951
1290
|
* decision this check is entitled to make.
|
|
952
1291
|
*/
|
|
953
1292
|
function reportChains(reading: ChainOutcome, io: Cli): void {
|
|
@@ -1009,7 +1348,17 @@ function reportAgentProof(
|
|
|
1009
1348
|
path: string,
|
|
1010
1349
|
gateways: string[],
|
|
1011
1350
|
io: Cli,
|
|
1351
|
+
sink: JsonSink | null,
|
|
1012
1352
|
): number {
|
|
1353
|
+
if (sink) {
|
|
1354
|
+
sink.data = {
|
|
1355
|
+
verdict: result.status,
|
|
1356
|
+
bundle_status: result.status,
|
|
1357
|
+
spec_version: "sanning.agent.proof/v1",
|
|
1358
|
+
on_chain_checked: result.onChainChecked,
|
|
1359
|
+
notes: [...result.errors],
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1013
1362
|
if (result.status === "malformed") {
|
|
1014
1363
|
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
1015
1364
|
for (const e of result.errors) io.err(` - ${e}`);
|
|
@@ -1029,7 +1378,7 @@ function reportAgentProof(
|
|
|
1029
1378
|
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io);
|
|
1030
1379
|
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
1031
1380
|
|
|
1032
|
-
return
|
|
1381
|
+
return exitCodeFor(result.status);
|
|
1033
1382
|
}
|
|
1034
1383
|
|
|
1035
1384
|
function printRollup(
|
|
@@ -1084,34 +1433,15 @@ function printRollup(
|
|
|
1084
1433
|
}
|
|
1085
1434
|
|
|
1086
1435
|
/**
|
|
1087
|
-
* The exit code
|
|
1436
|
+
* The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
|
|
1088
1437
|
*
|
|
1089
|
-
* `incomplete`
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1438
|
+
* `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
|
|
1439
|
+
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1440
|
+
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1441
|
+
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
1092
1442
|
*/
|
|
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;
|
|
1443
|
+
function mapGradeToExit(grade: PackGrade): number {
|
|
1444
|
+
return exitCodeFor(grade.verdict);
|
|
1115
1445
|
}
|
|
1116
1446
|
|
|
1117
1447
|
// ─── What `proof case` reads (proof#141) ─────────────────────────────────────
|
|
@@ -1202,7 +1532,9 @@ const LOG_PATH = /^logs\/([^/]+)\.json$/;
|
|
|
1202
1532
|
const ZIP_NAME = /\.zip$/i;
|
|
1203
1533
|
|
|
1204
1534
|
/** The one line a `.zip` gets, as an argument or as the way through a refusal. */
|
|
1205
|
-
const UNZIP_FIRST =
|
|
1535
|
+
const UNZIP_FIRST =
|
|
1536
|
+
"Pass the .zip itself, or unzip it first (for example, unzip file.zip -d " +
|
|
1537
|
+
"case/) and pass the folder.";
|
|
1206
1538
|
|
|
1207
1539
|
/**
|
|
1208
1540
|
* The input could not be read as a case. The message is the whole report,
|
|
@@ -1220,44 +1552,27 @@ export class CaseInputError extends Error {
|
|
|
1220
1552
|
/**
|
|
1221
1553
|
* `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
|
|
1222
1554
|
* the one refusal that is not a parse error; `detail` is the parser's own words,
|
|
1223
|
-
* which differ by kernel and are printed by `verify` only.
|
|
1555
|
+
* which differ by kernel and are printed by `verify` only. `duplicate` marks the
|
|
1556
|
+
* one parse refusal whose `detail` is kernel wording, identical in both kernels
|
|
1557
|
+
* (`duplicate key "<name>"`, proof#144), so both commands print it.
|
|
1224
1558
|
*/
|
|
1225
1559
|
export class BundleReadError extends Error {
|
|
1226
1560
|
readonly tooDeep: boolean;
|
|
1227
1561
|
readonly detail: string;
|
|
1228
|
-
|
|
1562
|
+
readonly duplicate: boolean;
|
|
1563
|
+
constructor(tooDeep: boolean, detail: string, duplicate = false) {
|
|
1229
1564
|
super(detail);
|
|
1230
1565
|
this.tooDeep = tooDeep;
|
|
1231
1566
|
this.detail = detail;
|
|
1567
|
+
this.duplicate = duplicate;
|
|
1232
1568
|
}
|
|
1233
1569
|
}
|
|
1234
1570
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
}
|
|
1571
|
+
// What both commands say about bytes that repeat a member name, before the
|
|
1572
|
+
// kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
|
|
1573
|
+
// I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
|
|
1574
|
+
// would be the wrong sentence. `NOT_I_JSON` in the Python twin.
|
|
1575
|
+
export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
|
|
1261
1576
|
|
|
1262
1577
|
// `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
|
|
1263
1578
|
// `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
|
|
@@ -1268,7 +1583,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
1268
1583
|
* How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
|
|
1269
1584
|
* kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
|
|
1270
1585
|
* sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
|
|
1271
|
-
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse
|
|
1586
|
+
* MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
|
|
1587
|
+
* refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
|
|
1272
1588
|
* Throws `BundleReadError`.
|
|
1273
1589
|
*/
|
|
1274
1590
|
export function readBundleBytes(bytes: Uint8Array): unknown {
|
|
@@ -1277,8 +1593,9 @@ export function readBundleBytes(bytes: Uint8Array): unknown {
|
|
|
1277
1593
|
throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1278
1594
|
}
|
|
1279
1595
|
try {
|
|
1280
|
-
return
|
|
1596
|
+
return parseRefusingDuplicates(text);
|
|
1281
1597
|
} catch (e) {
|
|
1598
|
+
if (e instanceof DuplicateKeyError) throw new BundleReadError(false, e.message, true);
|
|
1282
1599
|
throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
|
|
1283
1600
|
}
|
|
1284
1601
|
}
|
|
@@ -1306,9 +1623,15 @@ export interface SplitCase {
|
|
|
1306
1623
|
|
|
1307
1624
|
/** One name the walk found that is not a directory. */
|
|
1308
1625
|
export interface TreeEntry {
|
|
1626
|
+
/** Where it sits on disk. Empty for an entry read out of a case-file `.zip`. */
|
|
1309
1627
|
fsPath: string;
|
|
1310
|
-
/**
|
|
1628
|
+
/**
|
|
1629
|
+
* `true` for a regular file; `false` for a link or anything else, including a
|
|
1630
|
+
* `.zip` entry the reader refused.
|
|
1631
|
+
*/
|
|
1311
1632
|
regular: boolean;
|
|
1633
|
+
/** The bytes of an entry read out of a case-file `.zip`. */
|
|
1634
|
+
data?: Uint8Array | null;
|
|
1312
1635
|
}
|
|
1313
1636
|
|
|
1314
1637
|
/**
|
|
@@ -1330,7 +1653,7 @@ export async function splitCase(
|
|
|
1330
1653
|
const hint =
|
|
1331
1654
|
zips.length > 0
|
|
1332
1655
|
? `It holds ${zips.length} .zip file(s) (${zips[0]}` +
|
|
1333
|
-
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip. ` +
|
|
1656
|
+
`${zips.length > 1 ? ", …" : ""}), and case never opens a .zip inside a folder. ` +
|
|
1334
1657
|
UNZIP_FIRST
|
|
1335
1658
|
: null;
|
|
1336
1659
|
throw new CaseInputError(NO_BUNDLE, hint);
|
|
@@ -1377,6 +1700,32 @@ export async function splitCase(
|
|
|
1377
1700
|
return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
|
|
1378
1701
|
}
|
|
1379
1702
|
|
|
1703
|
+
/**
|
|
1704
|
+
* The bytes of the `witness-cache.json` beside a pack's `bundle.json`
|
|
1705
|
+
* (evidence-bundle 5.6.6, v1.25, A30), or `null`.
|
|
1706
|
+
*
|
|
1707
|
+
* The split rule is not changed for it: the file stays outside every pack,
|
|
1708
|
+
* listed as such, and is opened here and nowhere else. It is read only where it
|
|
1709
|
+
* is a regular file the split left outside every pack, so a file under another
|
|
1710
|
+
* pack's `logs/` is that pack's, and a link is never followed. A file that
|
|
1711
|
+
* cannot be read is no cache, never a refusal of the drop.
|
|
1712
|
+
*/
|
|
1713
|
+
async function witnessCacheBytes(
|
|
1714
|
+
tree: Map<string, TreeEntry>,
|
|
1715
|
+
split: SplitCase,
|
|
1716
|
+
pack: SplitPack,
|
|
1717
|
+
): Promise<Uint8Array | null> {
|
|
1718
|
+
const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
|
|
1719
|
+
const entry = tree.get(path);
|
|
1720
|
+
if (entry === undefined || !entry.regular || !split.outside.includes(path)) return null;
|
|
1721
|
+
try {
|
|
1722
|
+
return await readFileEntry(entry, path);
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
if (e instanceof CaseInputError) return null;
|
|
1725
|
+
throw e;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1380
1729
|
/** `verify`'s reader, with the refusal named by the pack's path. */
|
|
1381
1730
|
export function loadBundle(pack: SplitPack): unknown {
|
|
1382
1731
|
try {
|
|
@@ -1385,6 +1734,9 @@ export function loadBundle(pack: SplitPack): unknown {
|
|
|
1385
1734
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
1386
1735
|
throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
|
|
1387
1736
|
}
|
|
1737
|
+
if (e instanceof BundleReadError && e.duplicate) {
|
|
1738
|
+
throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
|
|
1739
|
+
}
|
|
1388
1740
|
throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
|
|
1389
1741
|
}
|
|
1390
1742
|
}
|
|
@@ -1395,6 +1747,7 @@ export function loadBundle(pack: SplitPack): unknown {
|
|
|
1395
1747
|
* the walk listed it is refused rather than followed or waited on.
|
|
1396
1748
|
*/
|
|
1397
1749
|
export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint8Array> {
|
|
1750
|
+
if (entry.data !== undefined && entry.data !== null) return entry.data;
|
|
1398
1751
|
const flags =
|
|
1399
1752
|
fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
1400
1753
|
let handle;
|
|
@@ -1499,25 +1852,83 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
1499
1852
|
);
|
|
1500
1853
|
}
|
|
1501
1854
|
if (s.isDirectory()) continue;
|
|
1502
|
-
if (ZIP_NAME.test(arg))
|
|
1503
|
-
throw new CaseInputError(
|
|
1855
|
+
if (s.isFile() && ZIP_NAME.test(arg)) continue;
|
|
1856
|
+
throw new CaseInputError(
|
|
1857
|
+
`${arg} is not a folder or a .zip. Pass pack folders, the folder that holds them, or a case file.`,
|
|
1858
|
+
);
|
|
1504
1859
|
}
|
|
1505
1860
|
|
|
1506
1861
|
const tree = new Map<string, TreeEntry>();
|
|
1507
1862
|
const claimed = new Map<string, string>();
|
|
1508
1863
|
const seen = { n: 0 };
|
|
1509
1864
|
for (const arg of paths) {
|
|
1510
|
-
|
|
1865
|
+
let isZip = false;
|
|
1866
|
+
if (ZIP_NAME.test(arg)) {
|
|
1867
|
+
try {
|
|
1868
|
+
isZip = !(await stat(arg)).isDirectory();
|
|
1869
|
+
} catch {
|
|
1870
|
+
isZip = true;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
let name = baseName(arg);
|
|
1874
|
+
if (isZip) {
|
|
1875
|
+
// `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
|
|
1876
|
+
// the archive is read under the folder that command makes.
|
|
1877
|
+
name = name.slice(0, -".zip".length);
|
|
1878
|
+
if (name.length === 0) throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
1879
|
+
}
|
|
1511
1880
|
const earlier = claimed.get(name);
|
|
1512
1881
|
if (earlier !== undefined) {
|
|
1513
1882
|
throw new CaseInputError(`${earlier} and ${arg} would both be read as ${name}/; rename one`);
|
|
1514
1883
|
}
|
|
1515
1884
|
claimed.set(name, arg);
|
|
1516
|
-
await
|
|
1885
|
+
if (isZip) await unzip(arg, `${name}/`, tree, seen);
|
|
1886
|
+
else await walk(arg, `${name}/`, arg, tree, seen);
|
|
1517
1887
|
}
|
|
1518
1888
|
return tree;
|
|
1519
1889
|
}
|
|
1520
1890
|
|
|
1891
|
+
/**
|
|
1892
|
+
* A case file named on the command line, read as the folder `unzip` would make
|
|
1893
|
+
* of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
|
|
1894
|
+
* unpacked folder give one reading. The archive is read by `readZip` and
|
|
1895
|
+
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
1896
|
+
* read, as the walk lists a link.
|
|
1897
|
+
*/
|
|
1898
|
+
async function unzip(
|
|
1899
|
+
arg: string,
|
|
1900
|
+
prefix: string,
|
|
1901
|
+
tree: Map<string, TreeEntry>,
|
|
1902
|
+
seen: { n: number },
|
|
1903
|
+
): Promise<void> {
|
|
1904
|
+
let data: Uint8Array;
|
|
1905
|
+
try {
|
|
1906
|
+
data = new Uint8Array(await readFile(arg));
|
|
1907
|
+
} catch {
|
|
1908
|
+
throw new CaseInputError(`cannot read ${arg}`);
|
|
1909
|
+
}
|
|
1910
|
+
let entries;
|
|
1911
|
+
try {
|
|
1912
|
+
entries = readZip(data);
|
|
1913
|
+
} catch (e) {
|
|
1914
|
+
if (e instanceof ZipReadError) throw new CaseInputError(`${arg} ${e.message}`);
|
|
1915
|
+
throw e;
|
|
1916
|
+
}
|
|
1917
|
+
for (const entry of entries) {
|
|
1918
|
+
seen.n++;
|
|
1919
|
+
if (seen.n > MAX_WALK_ENTRIES) {
|
|
1920
|
+
throw new CaseInputError(
|
|
1921
|
+
`${arg} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
tree.set(`${prefix}${entry.name}`, {
|
|
1925
|
+
fsPath: "",
|
|
1926
|
+
regular: entry.refused === null,
|
|
1927
|
+
data: entry.data,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1521
1932
|
// ─── `proof case` (proof#141) ────────────────────────────────────────────────
|
|
1522
1933
|
//
|
|
1523
1934
|
// The case reading from a terminal, so an auditor holding several packs does
|
|
@@ -1531,68 +1942,70 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
1531
1942
|
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
1532
1943
|
// No color: the two commands print the same bytes.
|
|
1533
1944
|
|
|
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.
|
|
1945
|
+
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
1946
|
+
// `mixed` has its own code, 4, because each other code would say something a
|
|
1947
|
+
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
1948
|
+
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
1949
|
+
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
1950
|
+
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
1951
|
+
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
1952
|
+
|
|
1953
|
+
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
1954
|
+
|
|
1955
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
|
|
1956
|
+
|
|
1957
|
+
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
1958
|
+
several packs, or an unzipped case file. Packs are found by the
|
|
1959
|
+
verify page's splitting rule: every bundle.json is a pack, a file
|
|
1960
|
+
belongs to the deepest pack whose bundle.json or logs/ it sits
|
|
1961
|
+
in, and every other file is listed as outside every pack and
|
|
1962
|
+
never opened. A .zip inside a folder is a file like any other. A
|
|
1963
|
+
symbolic link is never followed: it is listed as outside and not
|
|
1964
|
+
read, even under a pack's logs/. Several arguments are read as
|
|
1965
|
+
one folder holding them, each under its own name. A folder
|
|
1966
|
+
nested more than 32 levels deep, more than 50000 files and
|
|
1967
|
+
folders in all, or a name that is not valid UTF-8 exits 2.
|
|
1968
|
+
<case file> a case file named as <name>.zip, read as the folder
|
|
1969
|
+
unzip <name>.zip -d <name> would make, so the two give one
|
|
1970
|
+
reading. An entry that is encrypted, a symbolic link, stored by
|
|
1971
|
+
another method, or named outside the folder is listed as outside
|
|
1972
|
+
and not read.
|
|
1973
|
+
Where the folder or case file holds contents.json at its root,
|
|
1974
|
+
the index is compared with what is there and each disagreement
|
|
1975
|
+
is listed; the index never moves the verdict.
|
|
1567
1976
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1977
|
+
looked up on the public record, pack by pack, and reported on
|
|
1978
|
+
the witness line. The lookup never changes the verdict word.
|
|
1979
|
+
--files files handed in beside the packs, every path up to the next
|
|
1980
|
+
option, matched by SHA-256 alone against what every pack's
|
|
1981
|
+
signed records commit to. A file under a pack's logs/ is
|
|
1982
|
+
offered only when it is named here. The verdict never moves.
|
|
1983
|
+
--json the full reading as JSON: the verdict, every exception and note
|
|
1984
|
+
with its rendered line, every record, every contribution, the
|
|
1985
|
+
packs, the files outside them, the case file index comparison,
|
|
1986
|
+
the witness line and the on-chain half.
|
|
1574
1987
|
|
|
1575
1988
|
Each pack's bundle.json is read as verify reads it, so the two commands
|
|
1576
1989
|
classify one bundle the same way.
|
|
1577
1990
|
|
|
1578
1991
|
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
|
|
1992
|
+
kernel and graded as a pack; every record every pack supplied is read, in one
|
|
1993
|
+
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
1994
|
+
with no counterpart among the records read is always named, and when a pack
|
|
1995
|
+
signed by the key it names is in the reading, it says that pack holds none,
|
|
1996
|
+
unless every candidate it had was accounted for by another record (A20),
|
|
1997
|
+
which reads as no counterpart and names no pack.
|
|
1998
|
+
|
|
1999
|
+
What it does not establish: that the set of packs is complete (a party can
|
|
2000
|
+
decline to hand over a pack); that a party which presented no pack under the named key
|
|
1588
2001
|
recorded nothing; that two organizations are independent of each other; or
|
|
1589
|
-
that any witness
|
|
2002
|
+
that any witness is on the public record, which the reading never looks up.
|
|
2003
|
+
|
|
2004
|
+
${EXIT_CODES_HELP}
|
|
1590
2005
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
exceptions). With --gateways, a witness that does not match on chain exits 1,
|
|
1595
|
-
and an on-chain half that could not be settled turns 0 into 3.`;
|
|
2006
|
+
case: an input that cannot be read as packs, or an unexpected error in this
|
|
2007
|
+
command, exits 2. With --gateways, a witness that does not match on chain exits
|
|
2008
|
+
1, and an on-chain half that could not be settled turns 0 into 3.`;
|
|
1596
2009
|
|
|
1597
2010
|
// What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
|
|
1598
2011
|
// rather than left to the stream, so the lines a test captures in process are
|
|
@@ -1627,9 +2040,39 @@ interface OnChainRow {
|
|
|
1627
2040
|
mismatched: number;
|
|
1628
2041
|
unreachable: number;
|
|
1629
2042
|
not_checked: number;
|
|
2043
|
+
/** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
|
|
2044
|
+
witness_times?: WitnessTimeRow[];
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
interface WitnessTimeRow {
|
|
2048
|
+
stamp_ref: string;
|
|
2049
|
+
cached: { block_height: number; block_time: number };
|
|
2050
|
+
outcome: WitnessTimeCheck["outcome"];
|
|
2051
|
+
answers: {
|
|
2052
|
+
gateway: string;
|
|
2053
|
+
outcome: WitnessTimeAnswer["outcome"];
|
|
2054
|
+
block_height: number | null;
|
|
2055
|
+
block_time: number | null;
|
|
2056
|
+
}[];
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function witnessTimeRow(t: WitnessTimeCheck): WitnessTimeRow {
|
|
2060
|
+
return {
|
|
2061
|
+
stamp_ref: t.cached.stampRef,
|
|
2062
|
+
cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
|
|
2063
|
+
outcome: t.outcome,
|
|
2064
|
+
answers: t.answers.map((a) => ({
|
|
2065
|
+
gateway: a.gateway,
|
|
2066
|
+
outcome: a.outcome,
|
|
2067
|
+
block_height: a.blockHeight,
|
|
2068
|
+
block_time: a.blockTime,
|
|
2069
|
+
})),
|
|
2070
|
+
};
|
|
1630
2071
|
}
|
|
1631
2072
|
|
|
1632
2073
|
interface OnChainHalf {
|
|
2074
|
+
/** Present only where a lookup ran: each gateway that answered, in the order given. */
|
|
2075
|
+
answered?: string[];
|
|
1633
2076
|
checked: boolean;
|
|
1634
2077
|
gateways: string[];
|
|
1635
2078
|
packs: OnChainRow[];
|
|
@@ -1641,13 +2084,14 @@ interface OnChainHalf {
|
|
|
1641
2084
|
// the verify page's case reading, so the case verdict stays the one both
|
|
1642
2085
|
// surfaces compute. This adds the half the reading names as not checked.
|
|
1643
2086
|
async function onChainHalf(
|
|
1644
|
-
packs: readonly [string, unknown][],
|
|
2087
|
+
packs: readonly [string, unknown, readonly CachedWitnessTime[]][],
|
|
1645
2088
|
gateways: string[],
|
|
1646
2089
|
io: Cli,
|
|
1647
2090
|
): Promise<OnChainHalf> {
|
|
1648
2091
|
if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
|
|
1649
2092
|
const rows: OnChainRow[] = [];
|
|
1650
|
-
|
|
2093
|
+
const answered = new Set<string>();
|
|
2094
|
+
for (const [ref, bundle, cached] of packs) {
|
|
1651
2095
|
const result = await verifyEvidenceBundle(bundle, {
|
|
1652
2096
|
gateways,
|
|
1653
2097
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
@@ -1668,14 +2112,128 @@ async function onChainHalf(
|
|
|
1668
2112
|
else if (rollup === "unreachable") row.unreachable++;
|
|
1669
2113
|
else row.not_checked++;
|
|
1670
2114
|
}
|
|
2115
|
+
// The witness times the pack's witness-cache.json gives, looked up where
|
|
2116
|
+
// it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
|
|
2117
|
+
// no request.
|
|
2118
|
+
const times = await checkWitnessCache(cached, gateways, io.fetchImpl ?? fetch);
|
|
2119
|
+
if (times.length > 0) row.witness_times = times.map(witnessTimeRow);
|
|
1671
2120
|
rows.push(row);
|
|
2121
|
+
for (const w of witnesses) {
|
|
2122
|
+
for (const outcome of w.onChain?.perGateway ?? []) {
|
|
2123
|
+
if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
|
|
2124
|
+
answered.add(outcome.gateway);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
for (const t of times) {
|
|
2129
|
+
for (const a of t.answers) {
|
|
2130
|
+
if (a.outcome === "matches" || a.outcome === "differs") answered.add(a.gateway);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
1672
2133
|
}
|
|
1673
|
-
const verdict = rows.some(
|
|
2134
|
+
const verdict = rows.some(
|
|
2135
|
+
(r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"),
|
|
2136
|
+
)
|
|
1674
2137
|
? "failed"
|
|
1675
2138
|
: rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
|
|
1676
2139
|
? "undetermined"
|
|
1677
2140
|
: "confirmed";
|
|
1678
|
-
return {
|
|
2141
|
+
return {
|
|
2142
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
2143
|
+
checked: true,
|
|
2144
|
+
gateways: [...gateways],
|
|
2145
|
+
packs: rows,
|
|
2146
|
+
verdict,
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
|
|
2152
|
+
* half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
|
|
2153
|
+
* `not_checked` with the reason, and `failed` is `differs`. It never moves the
|
|
2154
|
+
* headline.
|
|
2155
|
+
*/
|
|
2156
|
+
// Distinct `[blockHeight, blockTime]` pairs, in time order.
|
|
2157
|
+
function distinctTimes(times: Iterable<[number, number]>): [number, number][] {
|
|
2158
|
+
const seen = new Map<string, [number, number]>();
|
|
2159
|
+
for (const [h, t] of times) seen.set(`${h}:${t}`, [h, t]);
|
|
2160
|
+
return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// Only the keys that carry something: a line with no cached or recorded time
|
|
2164
|
+
// is the line 0.13.0 printed, field for field.
|
|
2165
|
+
function withTimes(
|
|
2166
|
+
line: WitnessLine,
|
|
2167
|
+
cached: [number, number][],
|
|
2168
|
+
recorded: [number, number][],
|
|
2169
|
+
): WitnessLine {
|
|
2170
|
+
return {
|
|
2171
|
+
...line,
|
|
2172
|
+
...(cached.length > 0 ? { cached } : {}),
|
|
2173
|
+
...(recorded.length > 0 ? { recorded } : {}),
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
|
|
2179
|
+
* lookup ran, each is printed labelled not checked. Where one ran, each time
|
|
2180
|
+
* the public record matched is printed as the record's, each time no lookup
|
|
2181
|
+
* settled stays labelled not checked, and a time the record contradicts is the
|
|
2182
|
+
* reason the line reads `differs`.
|
|
2183
|
+
*/
|
|
2184
|
+
export function witnessLine(onChain: OnChainHalf, cached: readonly CachedWitnessTime[] = []): WitnessLine {
|
|
2185
|
+
if (!onChain.checked) {
|
|
2186
|
+
return withTimes(
|
|
2187
|
+
{
|
|
2188
|
+
state: WITNESS_NOT_LOOKED_UP.state,
|
|
2189
|
+
reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
|
|
2190
|
+
services: [...WITNESS_NOT_LOOKED_UP.services],
|
|
2191
|
+
},
|
|
2192
|
+
distinctTimes(cached.map((c): [number, number] => [c.blockHeight, c.blockTime])),
|
|
2193
|
+
[],
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
const services = [...(onChain.answered ?? [])];
|
|
2197
|
+
const rows = onChain.packs;
|
|
2198
|
+
const times = rows.flatMap((r) => r.witness_times ?? []);
|
|
2199
|
+
const unsettled = distinctTimes(
|
|
2200
|
+
times
|
|
2201
|
+
.filter((t) => t.outcome === "not_checked")
|
|
2202
|
+
.map((t): [number, number] => [t.cached.block_height, t.cached.block_time]),
|
|
2203
|
+
);
|
|
2204
|
+
const recorded = distinctTimes(
|
|
2205
|
+
times
|
|
2206
|
+
.filter((t) => t.outcome === "matches")
|
|
2207
|
+
.flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
|
|
2208
|
+
.map((a): [number, number] => [a.block_height as number, a.block_time as number]),
|
|
2209
|
+
);
|
|
2210
|
+
const contradicted: string[] = [];
|
|
2211
|
+
for (const t of times) {
|
|
2212
|
+
if (t.outcome !== "differs") continue;
|
|
2213
|
+
const a = t.answers.find((x) => x.outcome === "differs")!;
|
|
2214
|
+
const reason =
|
|
2215
|
+
`a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
|
|
2216
|
+
`, and the public record dates it ${renderWitnessTime(a.block_height as number, a.block_time as number)}`;
|
|
2217
|
+
if (!contradicted.includes(reason)) contradicted.push(reason);
|
|
2218
|
+
}
|
|
2219
|
+
if (onChain.verdict === "failed") {
|
|
2220
|
+
return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
|
|
2221
|
+
}
|
|
2222
|
+
if (onChain.verdict === "confirmed") {
|
|
2223
|
+
return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
|
|
2224
|
+
}
|
|
2225
|
+
const reasons: string[] = [];
|
|
2226
|
+
if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
|
|
2227
|
+
if (rows.some((r) => r.unreachable > 0)) {
|
|
2228
|
+
reasons.push(
|
|
2229
|
+
"a lookup did not settle: the service could not be reached, or it " +
|
|
2230
|
+
"holds no such witness yet",
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
if (rows.some((r) => r.not_checked > 0)) {
|
|
2234
|
+
reasons.push("no lookup is made for a witness kind a pack names");
|
|
2235
|
+
}
|
|
2236
|
+
return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
|
|
1679
2237
|
}
|
|
1680
2238
|
|
|
1681
2239
|
/**
|
|
@@ -1684,28 +2242,63 @@ async function onChainHalf(
|
|
|
1684
2242
|
* on-chain half that could not be settled is never a clean 0.
|
|
1685
2243
|
*/
|
|
1686
2244
|
export function caseExitCode(verdict: string, onChainVerdict: string): number {
|
|
1687
|
-
const code =
|
|
2245
|
+
const code = exitCodeFor(verdict);
|
|
1688
2246
|
if (onChainVerdict === "failed") return EXIT_FAILED;
|
|
1689
2247
|
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
|
|
1690
2248
|
return code;
|
|
1691
2249
|
}
|
|
1692
2250
|
|
|
2251
|
+
interface CaseFileData {
|
|
2252
|
+
root: string;
|
|
2253
|
+
index: string;
|
|
2254
|
+
case_id: string | null;
|
|
2255
|
+
findings: { rendered: string }[];
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1693
2258
|
interface CaseData {
|
|
1694
|
-
|
|
2259
|
+
title: string;
|
|
2260
|
+
reliance: string;
|
|
1695
2261
|
summary: string;
|
|
2262
|
+
witness: { rendered: string };
|
|
1696
2263
|
exceptions: { rendered: string }[];
|
|
1697
|
-
|
|
2264
|
+
notes: { rendered: string }[];
|
|
2265
|
+
packs: {
|
|
2266
|
+
ref: string;
|
|
2267
|
+
log_files: number;
|
|
2268
|
+
unaccounted: string[];
|
|
2269
|
+
signing_key: string | null;
|
|
2270
|
+
grade: string | null;
|
|
2271
|
+
}[];
|
|
1698
2272
|
outside: string[];
|
|
1699
2273
|
not_regular: string[];
|
|
2274
|
+
case_file: CaseFileData[];
|
|
1700
2275
|
on_chain: OnChainHalf;
|
|
1701
2276
|
}
|
|
1702
2277
|
|
|
2278
|
+
const titleOf = (word: string): string =>
|
|
2279
|
+
Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
|
|
2280
|
+
? (VERDICT_DISPLAY_TITLES[word] as string)
|
|
2281
|
+
: word;
|
|
2282
|
+
|
|
2283
|
+
/**
|
|
2284
|
+
* The text report, one line per entry, identical in both kernels. The headline
|
|
2285
|
+
* is the verdict's one display title and nothing else sets it (5.5.6, D7); the
|
|
2286
|
+
* scope sentence and the witness line are printed with it every time.
|
|
2287
|
+
*/
|
|
1703
2288
|
function caseReport(data: CaseData): string[] {
|
|
1704
|
-
const lines = [
|
|
2289
|
+
const lines = [
|
|
2290
|
+
data.title,
|
|
2291
|
+
` ${data.summary}`,
|
|
2292
|
+
` ${data.witness.rendered}`,
|
|
2293
|
+
` ${data.reliance}`,
|
|
2294
|
+
];
|
|
1705
2295
|
for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
|
|
2296
|
+
for (const n of data.notes) lines.push(` note: ${n.rendered}`);
|
|
1706
2297
|
lines.push(`packs ${data.packs.length}`);
|
|
1707
2298
|
for (const p of data.packs) {
|
|
1708
|
-
|
|
2299
|
+
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
2300
|
+
const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
|
|
2301
|
+
let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
|
|
1709
2302
|
if (p.unaccounted.length > 0) {
|
|
1710
2303
|
line +=
|
|
1711
2304
|
`; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
|
|
@@ -1723,17 +2316,21 @@ function caseReport(data: CaseData): string[] {
|
|
|
1723
2316
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
1724
2317
|
}
|
|
1725
2318
|
}
|
|
1726
|
-
const
|
|
1727
|
-
|
|
2319
|
+
for (const c of data.case_file) {
|
|
2320
|
+
if (c.index === "absent") continue;
|
|
2321
|
+
const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
|
|
1728
2322
|
lines.push(
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
"
|
|
2323
|
+
`case file ${c.root}/${CASE_FILE_INDEX_NAME} is ${c.index}` +
|
|
2324
|
+
`${label}; ${c.findings.length} disagreement(s) with what is here, ` +
|
|
2325
|
+
"none of which moves the verdict",
|
|
1732
2326
|
);
|
|
1733
|
-
|
|
2327
|
+
for (const f of c.findings) lines.push(` - ${f.rendered}`);
|
|
2328
|
+
}
|
|
2329
|
+
const oc = data.on_chain;
|
|
2330
|
+
if (oc.checked) {
|
|
1734
2331
|
lines.push(
|
|
1735
|
-
`on-chain
|
|
1736
|
-
"pack by pack; the
|
|
2332
|
+
`on-chain looked up against ${oc.gateways.length} gateway(s), ` +
|
|
2333
|
+
"pack by pack; the reading above is offline and this does not " +
|
|
1737
2334
|
"change it",
|
|
1738
2335
|
);
|
|
1739
2336
|
for (const r of oc.packs) {
|
|
@@ -1743,13 +2340,63 @@ function caseReport(data: CaseData): string[] {
|
|
|
1743
2340
|
`unreachable, ${r.not_checked} not checked`,
|
|
1744
2341
|
);
|
|
1745
2342
|
}
|
|
1746
|
-
lines.push(`on-chain ${oc.verdict.toUpperCase()}`);
|
|
1747
2343
|
}
|
|
1748
2344
|
return lines;
|
|
1749
2345
|
}
|
|
1750
2346
|
|
|
2347
|
+
/**
|
|
2348
|
+
* The index comparison, once per argument (5.6.3): each folder or case file
|
|
2349
|
+
* whose root holds `contents.json`. Every file under `packs/` and `report/` is
|
|
2350
|
+
* hashed for it, as bytes, and nothing is parsed but the index. It is never an
|
|
2351
|
+
* input to the reading.
|
|
2352
|
+
*/
|
|
2353
|
+
async function caseFileReadings(tree: Map<string, TreeEntry>): Promise<CaseFileData[]> {
|
|
2354
|
+
const out: (CaseFileData & { findings: Record<string, unknown>[] })[] = [];
|
|
2355
|
+
const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0] as string))].sort();
|
|
2356
|
+
const keys = [...tree.keys()].sort();
|
|
2357
|
+
for (const root of roots) {
|
|
2358
|
+
const prefix = `${root}/`;
|
|
2359
|
+
const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
|
|
2360
|
+
const indexEntry = tree.get(indexKey);
|
|
2361
|
+
const indexBytes =
|
|
2362
|
+
indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
|
|
2363
|
+
const files: { path: string; sha256: string }[] = [];
|
|
2364
|
+
if (indexBytes !== null) {
|
|
2365
|
+
for (const key of keys) {
|
|
2366
|
+
const entry = tree.get(key)!;
|
|
2367
|
+
const rel = key.slice(prefix.length);
|
|
2368
|
+
if (
|
|
2369
|
+
key.startsWith(prefix) &&
|
|
2370
|
+
entry.regular &&
|
|
2371
|
+
(rel.startsWith("packs/") || rel.startsWith("report/"))
|
|
2372
|
+
) {
|
|
2373
|
+
files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
const reading = readCaseFileIndex(indexBytes, files);
|
|
2378
|
+
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
2379
|
+
out.push({
|
|
2380
|
+
root,
|
|
2381
|
+
index: reading.index,
|
|
2382
|
+
case_id: reading.caseId,
|
|
2383
|
+
findings: reading.findings.map((f) => ({
|
|
2384
|
+
kind: f.kind,
|
|
2385
|
+
ref: nil(f.ref),
|
|
2386
|
+
path: nil(f.path),
|
|
2387
|
+
listed_sha256: nil(f.listedSha256),
|
|
2388
|
+
sha256: nil(f.sha256),
|
|
2389
|
+
reason: nil(f.reason),
|
|
2390
|
+
public_key: nil(f.publicKey),
|
|
2391
|
+
rendered: renderCaseFileFinding(f),
|
|
2392
|
+
})),
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
return out as CaseFileData[];
|
|
2396
|
+
}
|
|
2397
|
+
|
|
1751
2398
|
function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
|
|
1752
|
-
if (asJson) io
|
|
2399
|
+
if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
|
|
1753
2400
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
1754
2401
|
if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
1755
2402
|
return EXIT_MALFORMED;
|
|
@@ -1761,7 +2408,8 @@ function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
|
|
|
1761
2408
|
* never 1, because 1 would name the evidence failed over a defect in this
|
|
1762
2409
|
* command.
|
|
1763
2410
|
*/
|
|
1764
|
-
export async function runCase(argv: readonly string[],
|
|
2411
|
+
export async function runCase(argv: readonly string[], stream: Cli): Promise<number> {
|
|
2412
|
+
const io = guard(stream);
|
|
1765
2413
|
try {
|
|
1766
2414
|
return await caseCommand(argv, io);
|
|
1767
2415
|
} catch (e) {
|
|
@@ -1785,10 +2433,11 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1785
2433
|
let asJson = false;
|
|
1786
2434
|
let gateways: string[] = [];
|
|
1787
2435
|
const paths: string[] = [];
|
|
2436
|
+
let filePaths: string[] | undefined;
|
|
1788
2437
|
for (let i = 0; i < argv.length; i++) {
|
|
1789
2438
|
const arg = argv[i]!;
|
|
1790
2439
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
1791
|
-
io
|
|
2440
|
+
outBlock(io, CASE_USAGE);
|
|
1792
2441
|
return EXIT_VERIFIED;
|
|
1793
2442
|
}
|
|
1794
2443
|
if (arg === "--json") {
|
|
@@ -1797,7 +2446,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1797
2446
|
const next = argv[i + 1];
|
|
1798
2447
|
if (next === undefined) {
|
|
1799
2448
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
1800
|
-
io
|
|
2449
|
+
outBlock(io, CASE_USAGE);
|
|
1801
2450
|
return EXIT_MALFORMED;
|
|
1802
2451
|
}
|
|
1803
2452
|
gateways = next
|
|
@@ -1805,9 +2454,18 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1805
2454
|
.map((g) => g.trim())
|
|
1806
2455
|
.filter((g) => g.length > 0);
|
|
1807
2456
|
i++;
|
|
2457
|
+
} else if (arg === "--files") {
|
|
2458
|
+
const taken = takeFilePaths(argv, i + 1);
|
|
2459
|
+
if (taken.length === 0) {
|
|
2460
|
+
io.err("case: --files requires at least one file path");
|
|
2461
|
+
outBlock(io, CASE_USAGE);
|
|
2462
|
+
return EXIT_MALFORMED;
|
|
2463
|
+
}
|
|
2464
|
+
filePaths = [...(filePaths ?? []), ...taken];
|
|
2465
|
+
i += taken.length;
|
|
1808
2466
|
} else if (arg.startsWith("--")) {
|
|
1809
2467
|
io.err(`case: unknown option ${arg}`);
|
|
1810
|
-
io
|
|
2468
|
+
outBlock(io, CASE_USAGE);
|
|
1811
2469
|
return EXIT_MALFORMED;
|
|
1812
2470
|
} else {
|
|
1813
2471
|
paths.push(arg);
|
|
@@ -1815,14 +2473,23 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1815
2473
|
}
|
|
1816
2474
|
if (paths.length === 0) {
|
|
1817
2475
|
io.err("case: at least one pack is required");
|
|
1818
|
-
io
|
|
2476
|
+
outBlock(io, CASE_USAGE);
|
|
1819
2477
|
return EXIT_MALFORMED;
|
|
1820
2478
|
}
|
|
1821
2479
|
|
|
2480
|
+
let offered: OfferedFile[] | undefined;
|
|
2481
|
+
if (filePaths !== undefined) {
|
|
2482
|
+
const read = await readOfferedFiles(filePaths);
|
|
2483
|
+
if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
|
|
2484
|
+
offered = read;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
1822
2487
|
let split;
|
|
2488
|
+
let tree: Map<string, TreeEntry>;
|
|
1823
2489
|
let bundles: [SplitPack, unknown][];
|
|
1824
2490
|
try {
|
|
1825
|
-
|
|
2491
|
+
tree = await treeFromArguments(paths);
|
|
2492
|
+
split = await splitCase(tree, readFileEntry);
|
|
1826
2493
|
bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
|
|
1827
2494
|
} catch (e) {
|
|
1828
2495
|
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
@@ -1832,18 +2499,40 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1832
2499
|
const reading = await readCase(
|
|
1833
2500
|
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
1834
2501
|
);
|
|
2502
|
+
let caseFile: CaseFileData[];
|
|
2503
|
+
try {
|
|
2504
|
+
caseFile = await caseFileReadings(tree);
|
|
2505
|
+
} catch (e) {
|
|
2506
|
+
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
2507
|
+
throw e;
|
|
2508
|
+
}
|
|
2509
|
+
const caches: CachedWitnessTime[][] = [];
|
|
2510
|
+
for (const [p, bundle] of bundles) {
|
|
2511
|
+
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
|
|
2512
|
+
}
|
|
1835
2513
|
const onChain = await onChainHalf(
|
|
1836
|
-
bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
|
|
2514
|
+
bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
|
|
1837
2515
|
gateways,
|
|
1838
2516
|
io,
|
|
1839
2517
|
);
|
|
2518
|
+
const witness = witnessLine(onChain, caches.flat());
|
|
1840
2519
|
const code = caseExitCode(reading.verdict, onChain.verdict);
|
|
1841
2520
|
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
1842
2521
|
|
|
1843
2522
|
const data = {
|
|
1844
2523
|
verdict: reading.verdict,
|
|
2524
|
+
title: titleOf(reading.verdict),
|
|
2525
|
+
reliance: caseReliance(reading.verdict, reading.handoffRecords),
|
|
1845
2526
|
exit_code: code,
|
|
1846
2527
|
summary: renderCaseSummary(reading),
|
|
2528
|
+
witness: {
|
|
2529
|
+
state: witness.state,
|
|
2530
|
+
reasons: witness.reasons,
|
|
2531
|
+
services: witness.services,
|
|
2532
|
+
...(witness.cached ? { cached: witness.cached } : {}),
|
|
2533
|
+
...(witness.recorded ? { recorded: witness.recorded } : {}),
|
|
2534
|
+
rendered: renderWitnessLine(witness),
|
|
2535
|
+
},
|
|
1847
2536
|
handoff_records: reading.handoffRecords,
|
|
1848
2537
|
on_chain_checked: reading.onChainChecked,
|
|
1849
2538
|
exceptions: reading.exceptions.map((e) => ({
|
|
@@ -1854,30 +2543,56 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
1854
2543
|
failed_checks: e.failedChecks ?? [],
|
|
1855
2544
|
count: nil(e.count),
|
|
1856
2545
|
status: nil(e.status),
|
|
2546
|
+
causes: e.causes ?? [],
|
|
1857
2547
|
reasons: e.reasons ?? [],
|
|
1858
2548
|
rendered: renderException(e),
|
|
1859
2549
|
})),
|
|
2550
|
+
notes: reading.notes.map((n) => ({
|
|
2551
|
+
kind: n.kind,
|
|
2552
|
+
pack_ref: n.packRef,
|
|
2553
|
+
count: nil(n.count),
|
|
2554
|
+
rendered: renderCaseNote(n),
|
|
2555
|
+
})),
|
|
1860
2556
|
records: reading.records.map((r) => ({
|
|
1861
2557
|
pack_ref: r.packRef,
|
|
1862
2558
|
event_id: r.eventId,
|
|
1863
2559
|
verdict: nil(r.verdict),
|
|
2560
|
+
role: r.role,
|
|
1864
2561
|
findings: r.findings,
|
|
1865
2562
|
failed_checks: r.failedChecks,
|
|
1866
2563
|
counterparts: r.counterparts,
|
|
1867
2564
|
})),
|
|
1868
2565
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
1869
|
-
packs: bundles.map(([p]) => ({
|
|
2566
|
+
packs: bundles.map(([p], i) => ({
|
|
1870
2567
|
ref: p.ref,
|
|
1871
2568
|
bundle_path: p.bundlePath,
|
|
1872
2569
|
log_files: p.content.size,
|
|
1873
2570
|
unaccounted: p.unaccounted,
|
|
2571
|
+
signing_key: reading.packs[i]!.signingKey,
|
|
2572
|
+
grade: reading.packs[i]!.grade,
|
|
2573
|
+
records_withheld: reading.packs[i]!.recordsWithheld,
|
|
2574
|
+
declaration_version: reading.packs[i]!.declarationVersion,
|
|
1874
2575
|
})),
|
|
1875
2576
|
outside: split.outside,
|
|
1876
2577
|
not_regular: split.notRegular,
|
|
2578
|
+
case_file: caseFile,
|
|
1877
2579
|
on_chain: onChain,
|
|
1878
2580
|
};
|
|
1879
|
-
|
|
1880
|
-
|
|
2581
|
+
// Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
|
|
2582
|
+
// logs/ is its side content and is never an offered file; only the paths
|
|
2583
|
+
// after --files are. Nothing here moves `code`.
|
|
2584
|
+
let fileLines: string[] = [];
|
|
2585
|
+
const out: Record<string, unknown> = data;
|
|
2586
|
+
if (offered !== undefined) {
|
|
2587
|
+
const fileReading = await matchCaseFiles(
|
|
2588
|
+
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
2589
|
+
offered,
|
|
2590
|
+
);
|
|
2591
|
+
out.files = fileMatchToJson(fileReading);
|
|
2592
|
+
fileLines = renderFileMatch(fileReading);
|
|
2593
|
+
}
|
|
2594
|
+
if (asJson) outJson(io, jsonText(out));
|
|
2595
|
+
else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));
|
|
1881
2596
|
return code;
|
|
1882
2597
|
}
|
|
1883
2598
|
|
|
@@ -1913,7 +2628,7 @@ if (isMain) {
|
|
|
1913
2628
|
process.exitCode = code;
|
|
1914
2629
|
})
|
|
1915
2630
|
.catch((e) => {
|
|
1916
|
-
process.stderr.write(`fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
2631
|
+
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
1917
2632
|
process.exitCode = EXIT_MALFORMED;
|
|
1918
2633
|
});
|
|
1919
2634
|
}
|