@sanning/proof 0.19.1 → 0.20.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 +30 -29
- package/dist/agent-record.d.ts +62 -0
- package/dist/agent-record.js +527 -0
- package/dist/agent-schemas.generated.d.ts +15 -0
- package/dist/agent-schemas.generated.js +5759 -0
- package/dist/case-file.d.ts +32 -89
- package/dist/case-file.js +41 -93
- package/dist/case.d.ts +339 -822
- package/dist/case.js +667 -1160
- package/dist/chain.d.ts +19 -47
- package/dist/chain.js +78 -205
- package/dist/claims.d.ts +211 -0
- package/dist/claims.js +1081 -0
- package/dist/cli.d.ts +45 -68
- package/dist/cli.js +618 -1022
- package/dist/compose.js +23 -65
- package/dist/crypto.d.ts +7 -8
- package/dist/crypto.js +60 -147
- package/dist/declaration.d.ts +68 -194
- package/dist/declaration.js +175 -518
- package/dist/environment.d.ts +9 -30
- package/dist/environment.js +17 -66
- package/dist/evidence.d.ts +119 -160
- package/dist/evidence.js +744 -1103
- package/dist/exit-codes.d.ts +11 -27
- package/dist/exit-codes.js +24 -59
- package/dist/file-match.d.ts +33 -79
- package/dist/file-match.js +33 -69
- package/dist/grading.d.ts +98 -135
- package/dist/grading.js +224 -380
- package/dist/handoff.d.ts +62 -160
- package/dist/handoff.js +153 -393
- package/dist/index.d.ts +14 -10
- package/dist/index.js +60 -167
- package/dist/json-schema.d.ts +29 -0
- package/dist/json-schema.js +298 -0
- package/dist/merkle.js +16 -28
- package/dist/message-parts.d.ts +56 -212
- package/dist/message-parts.js +142 -502
- package/dist/side-content.d.ts +11 -22
- package/dist/side-content.js +22 -65
- package/dist/types.d.ts +17 -0
- package/dist/types.js +19 -5
- package/dist/verifier.d.ts +40 -114
- package/dist/verifier.js +196 -535
- package/dist/witness-cache.d.ts +24 -45
- package/dist/witness-cache.js +38 -81
- package/dist/witness-lookup.d.ts +36 -57
- package/dist/witness-lookup.js +89 -139
- package/dist/zip-reader.d.ts +18 -33
- package/dist/zip-reader.js +19 -34
- package/package.json +11 -3
- package/src/agent-record.ts +608 -0
- package/src/agent-schemas.generated.ts +5764 -0
- package/src/case-file.ts +45 -109
- package/src/case.ts +814 -1328
- package/src/chain.ts +85 -224
- package/src/claims.ts +1253 -0
- package/src/cli.ts +714 -1176
- package/src/compose.ts +33 -79
- package/src/crypto.ts +60 -147
- package/src/declaration.ts +219 -656
- package/src/environment.ts +17 -65
- package/src/evidence.ts +958 -1381
- package/src/exit-codes.ts +24 -63
- package/src/file-match.ts +40 -89
- package/src/grading.ts +287 -421
- package/src/handoff.ts +175 -427
- package/src/index.ts +131 -177
- package/src/json-schema.ts +295 -0
- package/src/merkle.ts +16 -28
- package/src/message-parts.ts +155 -533
- package/src/side-content.ts +25 -68
- package/src/types.ts +55 -36
- package/src/verifier.ts +202 -526
- package/src/witness-cache.ts +45 -94
- package/src/witness-lookup.ts +107 -177
- package/src/zip-reader.ts +20 -38
- package/vectors/README.md +52 -34
- package/vectors/agent-record.json +2175 -0
- package/vectors/case-file-index.json +1 -1
- package/vectors/case-split.json +2 -2
- package/vectors/chain-reading.json +3 -3
- package/vectors/file-match.json +285 -148
- package/vectors/jcs-reader.json +6 -6
- package/vectors/pack-grading.json +102 -25
- package/vectors/sandbox-evidence.json +573 -581
- package/vectors/stamp-on-chain.json +18 -18
- package/vectors/witness-cache.json +49 -49
- package/dist/agent-proof.d.ts +0 -28
- package/dist/agent-proof.js +0 -226
- package/dist/run-adapters.d.ts +0 -10
- package/dist/run-adapters.js +0 -35
- package/dist/run.d.ts +0 -209
- package/dist/run.js +0 -1405
- package/src/agent-proof.ts +0 -264
- package/src/run-adapters.ts +0 -44
- package/src/run.ts +0 -1598
package/dist/cli.js
CHANGED
|
@@ -1,54 +1,24 @@
|
|
|
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.)
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// sanning.stamp.evidence/v2 and sanning.evidence.export/v2
|
|
9
|
-
// - sanning.agent.proof/v1: the agent inclusion proof
|
|
10
|
-
// Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
|
|
4
|
+
// Verifies sanning.evidence/v1 bundles (bodies sanning.anchor.trace/v3,
|
|
5
|
+
// sanning.stamp.trace/v1, sanning.stamp.evidence/v2, sanning.evidence.export/v2).
|
|
11
6
|
//
|
|
12
|
-
// Exit codes
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
// 5 sandbox: a record is signed environment dev; would be 0, or only a
|
|
21
|
-
// simulated witness is unchecked (`sandboxExit`)
|
|
22
|
-
// `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
|
|
23
|
-
// exited 0 on `partial`; it moves with this one.
|
|
7
|
+
// Exit codes, one table for every verb (`./exit-codes.ts`): 0 verified, 1 failed,
|
|
8
|
+
// 2 incomplete, 3 verified sandbox evidence (a record is signed environment dev),
|
|
9
|
+
// 4 could not run. 2 is not a failure: a withheld record or bytes nobody handed
|
|
10
|
+
// over are not proven, never disproven.
|
|
24
11
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// the verdict the rest of the product prints. `gradePack` in `./grading.ts` is
|
|
30
|
-
// the kernel's one rule for what a verification SURFACE may claim about a pack,
|
|
31
|
-
// the browser verify page calls it, and this command referenced it zero times.
|
|
32
|
-
// So the same file read `Incomplete` on the page and `VERIFIED`, exit 0, at the
|
|
33
|
-
// terminal: two answers to one question, from one kernel, which is the exact
|
|
34
|
-
// defect `grading.ts` was brought into the kernel to end.
|
|
35
|
-
//
|
|
36
|
-
// The two layers stay distinct, deliberately (`grading.ts` says so in its own
|
|
37
|
-
// comments, and `incomplete` is a pack verdict in `evidence-bundle.md` §3,
|
|
38
|
-
// never a bundle status): the BUNDLE status is still reported, unchanged, on
|
|
39
|
-
// its own line. What changed is
|
|
40
|
-
// which of the two is the headline and which one the exit code follows.
|
|
41
|
-
//
|
|
42
|
-
// `payloadHashOk === null` (a withheld record) is semantics-undetermined and
|
|
43
|
-
// surfaces as a per-event note, and it does NOT fail the run. It does hold the
|
|
44
|
-
// PACK at `incomplete`, because a record nobody hashed is a record nobody
|
|
45
|
-
// checked, and exit 3 is how this command says "undetermined".
|
|
12
|
+
// The headline is the pack verdict, read off the six claims (`claims.ts`), the
|
|
13
|
+
// same rule the verify page prints. The report prints the person layer first
|
|
14
|
+
// (the title, one line per claim that is not proven, the next action, the notes,
|
|
15
|
+
// the sandbox line and the witness line), then the detail.
|
|
46
16
|
import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
47
17
|
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
48
18
|
import { createHash, randomBytes } from "node:crypto";
|
|
49
19
|
import { basename, resolve, sep } from "node:path";
|
|
50
20
|
import { fileURLToPath } from "node:url";
|
|
51
|
-
import {
|
|
21
|
+
import { renderAgentReading } from "./agent-record.js";
|
|
52
22
|
import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
|
|
53
23
|
import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
|
|
54
24
|
import { CASE_FILE_INDEX_NAME, caseFileListedPaths, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, unreadableCaseFileIndex, } from "./case-file.js";
|
|
@@ -56,38 +26,27 @@ import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./z
|
|
|
56
26
|
import { composeExport } from "./compose.js";
|
|
57
27
|
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
58
28
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
59
|
-
import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
|
|
60
|
-
import { loadInstalledAdapters } from "./run-adapters.js";
|
|
61
29
|
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
62
30
|
import { fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
|
|
63
31
|
import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
|
|
64
|
-
|
|
65
|
-
// moved to its own pure module (proof#200) so a consumer that is not this CLI,
|
|
66
|
-
// the verify page, can call the SAME implementation. `onChainHalf` and
|
|
67
|
-
// `witnessLine` are this command's own two building blocks, unchanged; see
|
|
68
|
-
// `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
|
|
69
|
-
import { onChainHalf, witnessLine } from "./witness-lookup.js";
|
|
32
|
+
import { onChainHalf, onChainOutcome, onChainRow, witnessLine, } from "./witness-lookup.js";
|
|
70
33
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
71
34
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
72
|
-
import { EXIT_CODES_HELP,
|
|
73
|
-
import { environmentMarker,
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
81
|
-
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
82
|
-
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
83
|
-
// only, which escapes its own control characters. The twin is `Io` in
|
|
84
|
-
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
35
|
+
import { EXIT_CODES_HELP, EXIT_COULD_NOT_RUN, EXIT_FAILED, EXIT_VERIFIED, exitCodeFor, } from "./exit-codes.js";
|
|
36
|
+
import { environmentMarker, simulatedOnlyRun } from "./environment.js";
|
|
37
|
+
import { VERDICT_TITLES, bundleProblems, claimsOf, note, orderedNotes, personText, problem, renderFindingNote, renderPerson, restOnSigned, verdictOf, } from "./claims.js";
|
|
38
|
+
// Every human-readable line passes through `guard`, which escapes control
|
|
39
|
+
// characters, so a value the audited party wrote (a file name, a zip entry, a
|
|
40
|
+
// field) cannot move the cursor or overwrite a headline. `outJson` is the one
|
|
41
|
+
// way around it, and JSON escapes its own control characters. Python's `Io` in
|
|
42
|
+
// `cli.py` prints byte-identical escapes.
|
|
85
43
|
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
86
44
|
// embedding, override and isolate controls.
|
|
87
45
|
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
46
|
+
/**
|
|
47
|
+
* Each control character in `text` as a visible escape (`\x1b`, ``).
|
|
48
|
+
* Every other character, a backslash included, is unchanged.
|
|
49
|
+
*/
|
|
91
50
|
export function escapeControl(text) {
|
|
92
51
|
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
93
52
|
const code = c.charCodeAt(0);
|
|
@@ -96,16 +55,12 @@ export function escapeControl(text) {
|
|
|
96
55
|
: `\\u${code.toString(16).padStart(4, "0")}`;
|
|
97
56
|
});
|
|
98
57
|
}
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// after escaping. It is random per process, so no input can spell it.
|
|
58
|
+
// `paint` and `mark` write this marker instead of ESC, so the escaping choke
|
|
59
|
+
// point does not escape our own colors; it becomes ESC after escaping. Random
|
|
60
|
+
// per process, so no input can spell it.
|
|
103
61
|
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
104
62
|
const G = `${COLOR_MARK}32m`;
|
|
105
63
|
const R = `${COLOR_MARK}31m`;
|
|
106
|
-
const Y = `${COLOR_MARK}33m`;
|
|
107
|
-
const DIM = `${COLOR_MARK}2m`;
|
|
108
|
-
const B = `${COLOR_MARK}1m`;
|
|
109
64
|
const X = `${COLOR_MARK}0m`;
|
|
110
65
|
function forTerminal(text) {
|
|
111
66
|
return escapeControl(text).split(COLOR_MARK).join("\x1b[");
|
|
@@ -129,8 +84,7 @@ function rawOf(io) {
|
|
|
129
84
|
function outJson(io, text) {
|
|
130
85
|
rawOf(io).out(text);
|
|
131
86
|
}
|
|
132
|
-
/** A block this command wrote itself
|
|
133
|
-
* are kept, and each line is escaped as `out` escapes it. */
|
|
87
|
+
/** A block this command wrote itself: its newlines are kept, each line escaped. */
|
|
134
88
|
function outBlock(io, text) {
|
|
135
89
|
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
136
90
|
}
|
|
@@ -140,14 +94,6 @@ function useColor() {
|
|
|
140
94
|
return false;
|
|
141
95
|
return Boolean(process.stdout.isTTY);
|
|
142
96
|
}
|
|
143
|
-
function mark(ok) {
|
|
144
|
-
const c = useColor();
|
|
145
|
-
if (ok === null)
|
|
146
|
-
return c ? `${Y}~${X}` : "~";
|
|
147
|
-
if (ok)
|
|
148
|
-
return c ? `${G}✓${X}` : "✓";
|
|
149
|
-
return c ? `${R}✗${X}` : "✗";
|
|
150
|
-
}
|
|
151
97
|
function paint(s, c) {
|
|
152
98
|
return useColor() ? `${c}${s}${X}` : s;
|
|
153
99
|
}
|
|
@@ -156,16 +102,13 @@ export async function runCli(argv, stream) {
|
|
|
156
102
|
const [command, ...rest] = argv;
|
|
157
103
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
158
104
|
printUsage(io);
|
|
159
|
-
return command === undefined ?
|
|
105
|
+
return command === undefined ? EXIT_COULD_NOT_RUN : EXIT_VERIFIED;
|
|
160
106
|
}
|
|
161
107
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
162
108
|
io.out("@sanning/proof verify CLI");
|
|
163
109
|
return EXIT_VERIFIED;
|
|
164
110
|
}
|
|
165
|
-
// `export` composes an attested export (`sanning.evidence.export/v2`).
|
|
166
|
-
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
167
|
-
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
168
|
-
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
111
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`).
|
|
169
112
|
if (command === "export") {
|
|
170
113
|
if (rest.includes("--json")) {
|
|
171
114
|
return runJson(rest.filter((a) => a !== "--json"), io, runExport);
|
|
@@ -178,7 +121,7 @@ export async function runCli(argv, stream) {
|
|
|
178
121
|
if (command !== "verify") {
|
|
179
122
|
io.err(`unknown command: ${command}`);
|
|
180
123
|
printUsage(io);
|
|
181
|
-
return
|
|
124
|
+
return EXIT_COULD_NOT_RUN;
|
|
182
125
|
}
|
|
183
126
|
if (rest.includes("--json")) {
|
|
184
127
|
return runJson(rest.filter((a) => a !== "--json"), io, runVerify);
|
|
@@ -207,23 +150,25 @@ async function runJson(rest, io, verb) {
|
|
|
207
150
|
return code;
|
|
208
151
|
}
|
|
209
152
|
async function runVerify(rest, io, sink) {
|
|
210
|
-
// `--logs <path>`
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// second positional argument until then, and a second positional is now a
|
|
214
|
-
// usage error). The one positional is the bundle.
|
|
153
|
+
// `--logs <path>` supplies disclosed raw-log bytes (evidence-bundle path
|
|
154
|
+
// only); `--gateways <url,url>` asks for the on-chain re-fetch. The one
|
|
155
|
+
// positional is the bundle.
|
|
215
156
|
let logsPath;
|
|
216
157
|
let filePaths;
|
|
217
158
|
let gatewayArg;
|
|
218
159
|
const positional = [];
|
|
219
160
|
for (let i = 0; i < rest.length; i++) {
|
|
220
161
|
const a = rest[i];
|
|
162
|
+
if ((a === "--help" || a === "-h") && positional.length === 0) {
|
|
163
|
+
printUsage(io);
|
|
164
|
+
return EXIT_VERIFIED;
|
|
165
|
+
}
|
|
221
166
|
if (a === "--gateways") {
|
|
222
167
|
const next = rest[i + 1];
|
|
223
168
|
if (next === undefined) {
|
|
224
169
|
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
225
170
|
printUsage(io);
|
|
226
|
-
return
|
|
171
|
+
return EXIT_COULD_NOT_RUN;
|
|
227
172
|
}
|
|
228
173
|
gatewayArg = next;
|
|
229
174
|
i++;
|
|
@@ -234,7 +179,7 @@ async function runVerify(rest, io, sink) {
|
|
|
234
179
|
if (next === undefined) {
|
|
235
180
|
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
236
181
|
printUsage(io);
|
|
237
|
-
return
|
|
182
|
+
return EXIT_COULD_NOT_RUN;
|
|
238
183
|
}
|
|
239
184
|
logsPath = next;
|
|
240
185
|
i++;
|
|
@@ -245,7 +190,7 @@ async function runVerify(rest, io, sink) {
|
|
|
245
190
|
if (taken.length === 0) {
|
|
246
191
|
io.err("verify: --files requires at least one file path");
|
|
247
192
|
printUsage(io);
|
|
248
|
-
return
|
|
193
|
+
return EXIT_COULD_NOT_RUN;
|
|
249
194
|
}
|
|
250
195
|
filePaths = [...(filePaths ?? []), ...taken];
|
|
251
196
|
i += taken.length;
|
|
@@ -257,12 +202,12 @@ async function runVerify(rest, io, sink) {
|
|
|
257
202
|
if (!bundlePath) {
|
|
258
203
|
io.err("verify: a bundle file path is required");
|
|
259
204
|
printUsage(io);
|
|
260
|
-
return
|
|
205
|
+
return EXIT_COULD_NOT_RUN;
|
|
261
206
|
}
|
|
262
207
|
if (positional.length > 1) {
|
|
263
208
|
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
264
209
|
printUsage(io);
|
|
265
|
-
return
|
|
210
|
+
return EXIT_COULD_NOT_RUN;
|
|
266
211
|
}
|
|
267
212
|
const gateways = (gatewayArg ?? "")
|
|
268
213
|
.split(",")
|
|
@@ -272,7 +217,7 @@ async function runVerify(rest, io, sink) {
|
|
|
272
217
|
if (logsPath !== undefined) {
|
|
273
218
|
logs = await loadLogs(logsPath, io);
|
|
274
219
|
if (logs === undefined)
|
|
275
|
-
return
|
|
220
|
+
return EXIT_COULD_NOT_RUN;
|
|
276
221
|
for (const n of logs.notes)
|
|
277
222
|
io.err(`note: ${n}`);
|
|
278
223
|
}
|
|
@@ -283,7 +228,7 @@ async function runVerify(rest, io, sink) {
|
|
|
283
228
|
const read = await readOfferedFiles(filePaths);
|
|
284
229
|
if (typeof read === "string") {
|
|
285
230
|
io.err(`verify: ${read}`);
|
|
286
|
-
return
|
|
231
|
+
return EXIT_COULD_NOT_RUN;
|
|
287
232
|
}
|
|
288
233
|
offered = read.offered;
|
|
289
234
|
skipped = read.skipped;
|
|
@@ -294,11 +239,9 @@ async function runVerify(rest, io, sink) {
|
|
|
294
239
|
}
|
|
295
240
|
catch (e) {
|
|
296
241
|
io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
297
|
-
return
|
|
242
|
+
return EXIT_COULD_NOT_RUN;
|
|
298
243
|
}
|
|
299
|
-
// The
|
|
300
|
-
// bundle.json through it too, so the two commands classify one bundle the
|
|
301
|
-
// same way (see `readBundleBytes`).
|
|
244
|
+
// The same reader `case` uses, so both commands classify a bundle alike.
|
|
302
245
|
let parsed;
|
|
303
246
|
try {
|
|
304
247
|
parsed = readBundleBytes(raw);
|
|
@@ -313,49 +256,26 @@ async function runVerify(rest, io, sink) {
|
|
|
313
256
|
else {
|
|
314
257
|
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
315
258
|
}
|
|
316
|
-
return
|
|
259
|
+
return EXIT_COULD_NOT_RUN;
|
|
317
260
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
:
|
|
321
|
-
|
|
322
|
-
if (content) {
|
|
323
|
-
// --logs binds raw logs to anchor-trace events; an agent inclusion proof
|
|
324
|
-
// has no such per-event content. Say so rather than silently ignoring it.
|
|
325
|
-
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
326
|
-
}
|
|
327
|
-
if (offered) {
|
|
328
|
-
io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
329
|
-
}
|
|
330
|
-
const result = await verifyAgentProof(parsed, {
|
|
331
|
-
gateways,
|
|
332
|
-
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
333
|
-
});
|
|
334
|
-
return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
|
|
261
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
262
|
+
// Not a pack at all: nothing in it names a claim to read.
|
|
263
|
+
io.err(`${bundlePath} is not an evidence bundle: not a JSON object`);
|
|
264
|
+
return EXIT_COULD_NOT_RUN;
|
|
335
265
|
}
|
|
336
|
-
//
|
|
337
|
-
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
338
|
-
// exits 2 with a clear message.
|
|
266
|
+
// An evidence bundle; an unknown major is unreadable, and incomplete.
|
|
339
267
|
const result = await verifyEvidenceBundle(parsed, {
|
|
340
268
|
gateways,
|
|
341
269
|
...(content ? { content } : {}),
|
|
342
270
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
343
271
|
});
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
|
|
353
|
-
// a throw out of `readChains` would have escaped the command entirely and
|
|
354
|
-
// exited 1, which is the code for "failed" and would have named the
|
|
355
|
-
// customer's evidence tampered over a fault in this reader. The Python twin
|
|
356
|
-
// had the opposite half of the same defect and swallowed the throw into "no
|
|
357
|
-
// event chain was found to follow in this bundle" (the gate on proof#154).
|
|
358
|
-
// Neither kernel is entitled to either answer, so both now print the third.
|
|
272
|
+
// The same pack read without the bytes handed in beside it, so a disproof that only those
|
|
273
|
+
// bytes make reads as not proven (evidence-bundle.md section 5.1).
|
|
274
|
+
// Offline: what the public record holds does not turn on the bytes beside the pack.
|
|
275
|
+
const bare = content ? await verifyEvidenceBundle(parsed) : null;
|
|
276
|
+
// Chain findings are evidence, not a verdict: they move `exit` only through a
|
|
277
|
+
// v3 declaration's chain claim (rule 11). A reading that cannot be produced
|
|
278
|
+
// says so, rather than exiting 1 (failed) over a fault in this reader.
|
|
359
279
|
let chains;
|
|
360
280
|
try {
|
|
361
281
|
chains = await readChains(parsed);
|
|
@@ -363,9 +283,8 @@ async function runVerify(rest, io, sink) {
|
|
|
363
283
|
catch (e) {
|
|
364
284
|
chains = { unreadable: errMsg(e) };
|
|
365
285
|
}
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// claim UNKNOWN and never clean.
|
|
286
|
+
// Links for rule 11. Unreadable links are none, which reads a claim unknown,
|
|
287
|
+
// never clean.
|
|
369
288
|
let links;
|
|
370
289
|
try {
|
|
371
290
|
links = "unreadable" in chains ? null : await readChainLinks(parsed);
|
|
@@ -373,9 +292,9 @@ async function runVerify(rest, io, sink) {
|
|
|
373
292
|
catch {
|
|
374
293
|
links = null;
|
|
375
294
|
}
|
|
376
|
-
const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
|
|
377
|
-
// Files handed in beside the pack (evidence-bundle.md 5.7
|
|
378
|
-
//
|
|
295
|
+
const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink, bare);
|
|
296
|
+
// Files handed in beside the pack (evidence-bundle.md §5.7): findings beside
|
|
297
|
+
// the verdict, never inside it.
|
|
379
298
|
if (offered !== undefined) {
|
|
380
299
|
const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
|
|
381
300
|
for (const line of renderFileMatch(reading))
|
|
@@ -383,9 +302,8 @@ async function runVerify(rest, io, sink) {
|
|
|
383
302
|
if (sink !== null && sink.data !== null)
|
|
384
303
|
sink.data.files = fileMatchToJson(reading);
|
|
385
304
|
}
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
if (logs && result.status !== "malformed")
|
|
305
|
+
// Over a malformed bundle there are no events to compare against.
|
|
306
|
+
if (logs && result.unreadable !== true)
|
|
389
307
|
reportLogsCoverage(logs, result, io);
|
|
390
308
|
return exit;
|
|
391
309
|
}
|
|
@@ -399,31 +317,26 @@ function takeFilePaths(args, start) {
|
|
|
399
317
|
}
|
|
400
318
|
return out;
|
|
401
319
|
}
|
|
402
|
-
// A file is hashed in pieces this big, so a file is read without being held.
|
|
403
320
|
const HASH_CHUNK = 1 << 20;
|
|
404
|
-
// The
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
// names on the command line is the person's choice and is read at any size.
|
|
321
|
+
// The total a command reads from files found in folders it was handed. A file
|
|
322
|
+
// that would pass it is skipped as `too_large` unread, so a sparse terabyte
|
|
323
|
+
// cannot stall the command. A file named on the command line is read at any
|
|
324
|
+
// size.
|
|
409
325
|
export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
|
|
410
|
-
// What `hashFile` returns for a file that would push the total past the cap.
|
|
411
326
|
export const TOO_LARGE = "too_large";
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// on disk: the same typed path then names two different folders (proof#219).
|
|
327
|
+
// By concatenation: `path.join` collapses `link/..` lexically, while the OS and
|
|
328
|
+
// the Python command resolve it on disk.
|
|
415
329
|
function childPath(directory, name) {
|
|
416
330
|
return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
|
|
417
331
|
}
|
|
418
332
|
// The SHA-256 of a regular file, read in pieces, or null when it cannot be
|
|
419
|
-
// read. Inside a folder the file is opened without following a link
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// person typed (`follow`) is opened as the shell would.
|
|
333
|
+
// read. Inside a folder the file is opened without following a link or
|
|
334
|
+
// blocking, then rechecked, so a file swapped for a link or pipe is skipped. A
|
|
335
|
+
// typed path (`follow`) is opened as the shell would.
|
|
423
336
|
//
|
|
424
|
-
// `budget` is the bytes still allowed across the folders
|
|
425
|
-
//
|
|
426
|
-
//
|
|
337
|
+
// `budget` is the bytes still allowed across the run's folders. A file larger
|
|
338
|
+
// than it, or one that grows past it while read, returns TOO_LARGE; only a file
|
|
339
|
+
// read whole is charged.
|
|
427
340
|
export async function hashFile(path, follow, budget) {
|
|
428
341
|
const flags = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (follow ? 0 : (fsConstants.O_NOFOLLOW ?? 0));
|
|
429
342
|
let handle;
|
|
@@ -462,15 +375,11 @@ export async function hashFile(path, follow, budget) {
|
|
|
462
375
|
await handle.close();
|
|
463
376
|
}
|
|
464
377
|
}
|
|
465
|
-
// The regular files under a `--files` folder, recursively, hidden
|
|
466
|
-
// included, into `offered` as `<folder>/<path inside>` in UTF-16
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// The files are hashed once the walk is done, in the order they are listed, so
|
|
472
|
-
// which file a full `budget` turns away does not depend on how the walk
|
|
473
|
-
// happened to reach it.
|
|
378
|
+
// The regular files under a `--files` folder, recursively, hidden ones
|
|
379
|
+
// included, into `offered` as `<folder>/<path inside>` in UTF-16 order; links,
|
|
380
|
+
// non-regular and unreadable entries into `skipped`. Returns the one error line,
|
|
381
|
+
// or null. Files are hashed after the walk, in listed order, so which file the
|
|
382
|
+
// budget turns away does not depend on walk order.
|
|
474
383
|
async function walkOfferedFolder(typed, offered, skipped, seen, budget) {
|
|
475
384
|
let base = typed;
|
|
476
385
|
while (base.length > 0 && (base.endsWith("/") || base.endsWith(sep)))
|
|
@@ -547,16 +456,12 @@ async function walkOfferedFolder(typed, offered, skipped, seen, budget) {
|
|
|
547
456
|
}
|
|
548
457
|
return null;
|
|
549
458
|
}
|
|
550
|
-
// Each `--files` path as `{ name, sha256 }`, or the one error line.
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// that is neither a readable file nor a readable folder is the one error line,
|
|
557
|
-
// which names the path and not the operating system's words, which differ by
|
|
558
|
-
// kernel. A path typed is followed as a shell would; a link found inside a
|
|
559
|
-
// folder is not.
|
|
459
|
+
// Each `--files` path as `{ name, sha256 }`, or the one error line. A file is
|
|
460
|
+
// named as typed; a folder stands for its regular files, recursively, named
|
|
461
|
+
// `<folder as typed>/<path inside>`. The rest is returned as `skipped`, never
|
|
462
|
+
// dropped. A typed path is followed as a shell would; a link inside a folder is
|
|
463
|
+
// not. The error line names the path, not the OS's words, which differ by
|
|
464
|
+
// kernel.
|
|
560
465
|
export async function readOfferedFiles(paths, cap = MAX_FOLDER_BYTES) {
|
|
561
466
|
const offered = [];
|
|
562
467
|
const skipped = [];
|
|
@@ -585,15 +490,12 @@ export async function readOfferedFiles(paths, cap = MAX_FOLDER_BYTES) {
|
|
|
585
490
|
}
|
|
586
491
|
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
587
492
|
//
|
|
588
|
-
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
493
|
+
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
|
|
494
|
+
// (evidence-export.md §5). Writes to `--out`, or stdout so it pipes into
|
|
495
|
+
// `proof verify`.
|
|
592
496
|
//
|
|
593
|
-
// Exit codes: 0 composed
|
|
594
|
-
//
|
|
595
|
-
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
596
|
-
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
497
|
+
// Exit codes: 0 composed; 1 the recomputed export is `failed` (the composer never
|
|
498
|
+
// emits a green artifact over failing evidence); 2 usage or malformed input.
|
|
597
499
|
async function runExport(rest, io, sink) {
|
|
598
500
|
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
599
501
|
usageExport(io);
|
|
@@ -658,9 +560,7 @@ async function runExport(rest, io, sink) {
|
|
|
658
560
|
opts.generatedAt = v;
|
|
659
561
|
}
|
|
660
562
|
else if (a.startsWith("-")) {
|
|
661
|
-
//
|
|
662
|
-
// was a short form of `--out` until 0.13.0, and read as a path it would
|
|
663
|
-
// send the export to stdout while the caller waits for a file.
|
|
563
|
+
// An unknown flag is a usage error, never a path.
|
|
664
564
|
io.err(`export: unknown option ${a}`);
|
|
665
565
|
return usageExport(io);
|
|
666
566
|
}
|
|
@@ -683,30 +583,28 @@ async function runExport(rest, io, sink) {
|
|
|
683
583
|
}
|
|
684
584
|
const sourceBundle = await readJson(bundlePath, io);
|
|
685
585
|
if (sourceBundle === undefined)
|
|
686
|
-
return
|
|
586
|
+
return EXIT_COULD_NOT_RUN;
|
|
687
587
|
const attestationsRaw = await readJson(attestationsPath, io);
|
|
688
588
|
if (attestationsRaw === undefined)
|
|
689
|
-
return
|
|
589
|
+
return EXIT_COULD_NOT_RUN;
|
|
690
590
|
const attestations = coerceAttestations(attestationsRaw);
|
|
691
591
|
if (attestations === undefined) {
|
|
692
592
|
io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
|
|
693
593
|
`(or an object with an "attestations" array)`);
|
|
694
|
-
return
|
|
594
|
+
return EXIT_COULD_NOT_RUN;
|
|
695
595
|
}
|
|
696
596
|
const privateKey = await readExporterKey(keyPath, io);
|
|
697
597
|
if (privateKey === undefined)
|
|
698
|
-
return
|
|
598
|
+
return EXIT_COULD_NOT_RUN;
|
|
699
599
|
let composed;
|
|
700
600
|
try {
|
|
701
601
|
composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
|
|
702
602
|
}
|
|
703
603
|
catch (e) {
|
|
704
|
-
io.err(`export: cannot compose
|
|
705
|
-
return
|
|
604
|
+
io.err(`export: cannot compose: ${e instanceof Error ? e.message : String(e)}`);
|
|
605
|
+
return EXIT_COULD_NOT_RUN;
|
|
706
606
|
}
|
|
707
|
-
//
|
|
708
|
-
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
709
|
-
// still writes the artifact so the caller can inspect it.
|
|
607
|
+
// A `failed` recompute exits 1 and still writes the artifact for inspection.
|
|
710
608
|
const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
|
|
711
609
|
if (sink) {
|
|
712
610
|
sink.data = {
|
|
@@ -715,8 +613,7 @@ async function runExport(rest, io, sink) {
|
|
|
715
613
|
source_bundle_hash: composed.sourceBundleHash,
|
|
716
614
|
attestations: attestations.length,
|
|
717
615
|
attestations_bound: composed.boundAttestations,
|
|
718
|
-
// Without --out the artifact rides inside
|
|
719
|
-
// carries the object and not the artifact.
|
|
616
|
+
// Without --out, stdout carries this object, so the artifact rides inside it.
|
|
720
617
|
export: outPath ? null : composed.bundle,
|
|
721
618
|
};
|
|
722
619
|
}
|
|
@@ -726,7 +623,7 @@ async function runExport(rest, io, sink) {
|
|
|
726
623
|
}
|
|
727
624
|
catch (e) {
|
|
728
625
|
io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
729
|
-
return
|
|
626
|
+
return EXIT_COULD_NOT_RUN;
|
|
730
627
|
}
|
|
731
628
|
io.out(`${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
|
|
732
629
|
`status ${composed.status} ` +
|
|
@@ -759,10 +656,9 @@ function usageExport(io) {
|
|
|
759
656
|
io.out(line);
|
|
760
657
|
io.out("");
|
|
761
658
|
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
762
|
-
return
|
|
659
|
+
return EXIT_COULD_NOT_RUN;
|
|
763
660
|
}
|
|
764
|
-
// Read
|
|
765
|
-
// failure (mirrors the verify path's read/parse error handling → exit 2).
|
|
661
|
+
// Read and parse a JSON file, or report the error and return undefined (exit 4).
|
|
766
662
|
async function readJson(path, io) {
|
|
767
663
|
let raw;
|
|
768
664
|
try {
|
|
@@ -784,8 +680,7 @@ async function readJson(path, io) {
|
|
|
784
680
|
return undefined;
|
|
785
681
|
}
|
|
786
682
|
}
|
|
787
|
-
//
|
|
788
|
-
// under `attestations`. Returns undefined for anything else.
|
|
683
|
+
// A bare array of attestation records, or one under `attestations`.
|
|
789
684
|
function coerceAttestations(raw) {
|
|
790
685
|
if (Array.isArray(raw))
|
|
791
686
|
return raw;
|
|
@@ -796,9 +691,8 @@ function coerceAttestations(raw) {
|
|
|
796
691
|
}
|
|
797
692
|
return undefined;
|
|
798
693
|
}
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
// `private_key`. Validates the 64-hex-char (32-byte) shape.
|
|
694
|
+
// The exporter Ed25519 key: a bare 32-byte hex seed, or a JSON object carrying
|
|
695
|
+
// it under `privateKey`, `seed` or `private_key`.
|
|
802
696
|
async function readExporterKey(path, io) {
|
|
803
697
|
let raw;
|
|
804
698
|
try {
|
|
@@ -830,14 +724,10 @@ async function readExporterKey(path, io) {
|
|
|
830
724
|
}
|
|
831
725
|
return seed.toLowerCase();
|
|
832
726
|
}
|
|
833
|
-
//
|
|
834
|
-
// what `materializeBundle` writes. Anything else is skipped and NAMED rather
|
|
835
|
-
// than guessed at, so a `.JSON` or extensionless file shows up as a note
|
|
836
|
-
// instead of a mysteriously undisclosed event.
|
|
727
|
+
// Matched exactly, lowercase. Anything else is skipped and named in a note.
|
|
837
728
|
const LOG_SUFFIX = ".json";
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// name. `stat` follows symlinks, so a symlinked logs directory works.
|
|
729
|
+
// The shape is decided by `stat`, never by extension. `stat` follows symlinks,
|
|
730
|
+
// so a symlinked logs directory works.
|
|
841
731
|
async function loadLogs(logsPath, io) {
|
|
842
732
|
let isDirectory;
|
|
843
733
|
try {
|
|
@@ -849,7 +739,7 @@ async function loadLogs(logsPath, io) {
|
|
|
849
739
|
}
|
|
850
740
|
return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
|
|
851
741
|
}
|
|
852
|
-
// The JSON-map form
|
|
742
|
+
// The JSON-map form: `{ event_id: <hex | utf8 text> }`.
|
|
853
743
|
async function loadLogsMap(logsPath, io) {
|
|
854
744
|
let logsRaw;
|
|
855
745
|
try {
|
|
@@ -865,8 +755,7 @@ async function loadLogsMap(logsPath, io) {
|
|
|
865
755
|
}
|
|
866
756
|
catch (e) {
|
|
867
757
|
if (e instanceof DuplicateKeyError) {
|
|
868
|
-
//
|
|
869
|
-
// record, and the last one silently won (proof#144).
|
|
758
|
+
// A repeated event id would hand the reader two contents for one record.
|
|
870
759
|
io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
|
|
871
760
|
return undefined;
|
|
872
761
|
}
|
|
@@ -890,8 +779,7 @@ async function loadLogsMap(logsPath, io) {
|
|
|
890
779
|
content.set(k, decodeLogValue(v));
|
|
891
780
|
suppliedIds.push(k);
|
|
892
781
|
}
|
|
893
|
-
//
|
|
894
|
-
// value that is not a string is never coerced and never dropped unnamed.
|
|
782
|
+
// Every non-string value is named, never coerced or dropped.
|
|
895
783
|
const notes = notStrings.length === 0
|
|
896
784
|
? []
|
|
897
785
|
: [
|
|
@@ -900,52 +788,23 @@ async function loadLogsMap(logsPath, io) {
|
|
|
900
788
|
];
|
|
901
789
|
return { content, suppliedIds, notes };
|
|
902
790
|
}
|
|
903
|
-
// The directory form
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
// A regular file named `<event_id>.json` is that record's disclosed bytes, and
|
|
909
|
-
// nothing else in the folder is read. None of the following is a verification
|
|
910
|
-
// failure, because an absent or unusable disclosure is UNDISCLOSED and absence
|
|
911
|
-
// is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
|
|
912
|
-
// is NAMED in a note, every name, so no file is dropped without being named:
|
|
913
|
-
//
|
|
914
|
-
// - a file whose name is not a known event id → loaded, binds to nothing,
|
|
915
|
-
// named in a post-verify note
|
|
916
|
-
// - an event with no corresponding file → undetermined (`~`), noted
|
|
917
|
-
// - an empty directory → valid; discloses nothing,
|
|
918
|
-
// noted, exit code unchanged
|
|
919
|
-
// - a subdirectory → never entered, noted (the
|
|
920
|
-
// "you pointed at the pack root, not its logs/" case)
|
|
921
|
-
// - a symbolic link → never followed, whatever it
|
|
922
|
-
// points at, noted; `case` never follows one under a pack's `logs/` and
|
|
923
|
-
// `--files` never follows one in a folder (A40), so a link named
|
|
924
|
-
// `<event_id>.json` discloses nothing here either
|
|
925
|
-
// - anything that is not a regular file → never opened, noted
|
|
926
|
-
// - anything else not named `<event_id>.json` → never read, noted
|
|
927
|
-
//
|
|
928
|
-
// The Python command read every regular file by its STEM until 0.17.0, so a
|
|
929
|
-
// forged `<id>.txt` beside a genuine `<id>.json` failed the pack there and was
|
|
930
|
-
// never read here, and the forged-`.json` direction read clean there and
|
|
931
|
-
// failed here: whoever assembled the folder chose the verdict.
|
|
791
|
+
// The directory form (evidence-bundle.md §5.1.4; Python's twin is
|
|
792
|
+
// `_load_logs_directory`). Only a regular file named `<event_id>.json` is read:
|
|
793
|
+
// links are never followed and subdirectories never entered. Everything else,
|
|
794
|
+
// and every event with no file, is named in a note, because an absent or
|
|
795
|
+
// unusable disclosure is undisclosed, not a failure (§5.1).
|
|
932
796
|
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
// would let two names read as one), and `<event_id>.json` files holding more
|
|
937
|
-
// than MAX_LOGS_BYTES in all.
|
|
797
|
+
// Hard stops (exit 4): an unreadable `<event_id>.json` (a permissions error must
|
|
798
|
+
// not read as minimal disclosure), more than MAX_WALK_ENTRIES names, a name that
|
|
799
|
+
// is not valid UTF-8, or more than MAX_LOGS_BYTES in all.
|
|
938
800
|
export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
|
|
939
801
|
export class LogsTooLarge extends Error {
|
|
940
802
|
}
|
|
941
|
-
// A `<event_id>.json` file's bytes
|
|
942
|
-
// blocking
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
// and the count of bytes read refuses a file that reports less than it holds
|
|
947
|
-
// (a file in /proc reports 0) or grows while it is read. `opener` is for the
|
|
948
|
-
// tests, which must reach the second check with the first one passing.
|
|
803
|
+
// A `<event_id>.json` file's bytes, opened without following a link or
|
|
804
|
+
// blocking and then rechecked, so a swapped-in link or pipe is refused. Two
|
|
805
|
+
// independent size checks: `fstat` refuses a sparse file before reading, and
|
|
806
|
+
// the byte count refuses a file that under-reports (as /proc does) or grows.
|
|
807
|
+
// `opener` lets tests reach the second check.
|
|
949
808
|
export async function readLogFile(path, budget, opener = open) {
|
|
950
809
|
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
|
|
951
810
|
const handle = await opener(path, flags);
|
|
@@ -975,8 +834,7 @@ export async function readLogFile(path, budget, opener = open) {
|
|
|
975
834
|
await handle.close();
|
|
976
835
|
}
|
|
977
836
|
}
|
|
978
|
-
// `limits` is for
|
|
979
|
-
// names per case; the command always passes the defaults.
|
|
837
|
+
// `limits` is for tests; the command always passes the defaults.
|
|
980
838
|
export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_BYTES, entries: MAX_WALK_ENTRIES }) {
|
|
981
839
|
let raws;
|
|
982
840
|
try {
|
|
@@ -1011,7 +869,7 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
|
|
|
1011
869
|
const p = childPath(dirPath, name);
|
|
1012
870
|
let s;
|
|
1013
871
|
try {
|
|
1014
|
-
// `lstat
|
|
872
|
+
// `lstat`: a link in the folder is never followed.
|
|
1015
873
|
s = await lstat(p);
|
|
1016
874
|
}
|
|
1017
875
|
catch {
|
|
@@ -1030,15 +888,13 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
|
|
|
1030
888
|
notRegular.push(name);
|
|
1031
889
|
continue;
|
|
1032
890
|
}
|
|
1033
|
-
//
|
|
1034
|
-
// whose event id would be the empty string.
|
|
891
|
+
// Includes a file named exactly `.json`, whose event id would be empty.
|
|
1035
892
|
if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
|
|
1036
893
|
notNamed.push(name);
|
|
1037
894
|
continue;
|
|
1038
895
|
}
|
|
1039
896
|
const eventId = name.slice(0, -LOG_SUFFIX.length);
|
|
1040
897
|
try {
|
|
1041
|
-
// Bytes, exactly as they sit on disk.
|
|
1042
898
|
content.set(eventId, await readLogFile(p, budget));
|
|
1043
899
|
}
|
|
1044
900
|
catch (e) {
|
|
@@ -1056,7 +912,7 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
|
|
|
1056
912
|
const notes = [];
|
|
1057
913
|
if (dirs.length > 0) {
|
|
1058
914
|
notes.push(`--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
|
|
1059
|
-
`(${quoted(dirs)})
|
|
915
|
+
`(${quoted(dirs)}); the directory form reads <event_id>${LOG_SUFFIX} files ` +
|
|
1060
916
|
`and does not recurse`);
|
|
1061
917
|
}
|
|
1062
918
|
if (links.length > 0) {
|
|
@@ -1075,22 +931,18 @@ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_
|
|
|
1075
931
|
`not named <event_id>${LOG_SUFFIX} (${quoted(notNamed)})`);
|
|
1076
932
|
}
|
|
1077
933
|
if (suppliedIds.length === 0) {
|
|
1078
|
-
notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files)
|
|
1079
|
-
`stays undisclosed, which is
|
|
934
|
+
notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files); every event ` +
|
|
935
|
+
`stays undisclosed, which is not checked, not failed`);
|
|
1080
936
|
}
|
|
1081
937
|
return { content, suppliedIds, notes };
|
|
1082
938
|
}
|
|
1083
|
-
// What the side input covered,
|
|
1084
|
-
//
|
|
1085
|
-
// code — and both exist because the silent version of each has already cost
|
|
1086
|
-
// somebody an afternoon:
|
|
939
|
+
// What the side input covered, once the bundle's event ids are known. Advisory
|
|
940
|
+
// only:
|
|
1087
941
|
//
|
|
1088
|
-
// - an entry matching no event binds to nothing
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
// That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
|
|
1093
|
-
// of a disclosure is not evidence of tampering.
|
|
942
|
+
// - an entry matching no event binds to nothing (a typo or the wrong
|
|
943
|
+
// directory otherwise looks like minimal disclosure).
|
|
944
|
+
// - an event whose content stayed undetermined disclosed no usable bytes:
|
|
945
|
+
// undisclosed, never a failure (evidence-bundle.md §5.1).
|
|
1094
946
|
function reportLogsCoverage(logs, result, io) {
|
|
1095
947
|
const eventIds = new Set(result.events.map((e) => e.eventId));
|
|
1096
948
|
const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
|
|
@@ -1098,12 +950,12 @@ function reportLogsCoverage(logs, result, io) {
|
|
|
1098
950
|
io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
|
|
1099
951
|
`in the bundle, ignored: ${quoted(unmatched)}`);
|
|
1100
952
|
}
|
|
1101
|
-
const
|
|
1102
|
-
if (
|
|
1103
|
-
io.err(`note: ${
|
|
1104
|
-
`
|
|
953
|
+
const unchecked = result.events.filter((e) => e.contentOk === null);
|
|
954
|
+
if (unchecked.length > 0) {
|
|
955
|
+
io.err(`note: ${unchecked.length} of ${result.events.length} event(s) have content that ` +
|
|
956
|
+
`was not checked: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
1105
957
|
`hand-off record) to bind them to. ` +
|
|
1106
|
-
`Undisclosed, not failed: ${sample(
|
|
958
|
+
`Undisclosed, not failed: ${sample(unchecked.map((e) => e.eventId))}`);
|
|
1107
959
|
}
|
|
1108
960
|
}
|
|
1109
961
|
function errMsg(e) {
|
|
@@ -1112,42 +964,23 @@ function errMsg(e) {
|
|
|
1112
964
|
function count(n, one, many) {
|
|
1113
965
|
return `${n} ${n === 1 ? one : many}`;
|
|
1114
966
|
}
|
|
1115
|
-
// Every name, quoted
|
|
1116
|
-
// is a file dropped without being named.
|
|
967
|
+
// Every name, quoted: a file left out of a note is a file dropped unnamed.
|
|
1117
968
|
function quoted(names) {
|
|
1118
969
|
return names.map((n) => JSON.stringify(n)).join(", ");
|
|
1119
970
|
}
|
|
1120
|
-
//
|
|
1121
|
-
// without pasting a whole directory into the terminal.
|
|
971
|
+
// The first few names, quoted, with a count of the rest.
|
|
1122
972
|
function sample(names, max = 3) {
|
|
1123
973
|
const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
|
|
1124
974
|
return names.length > max ? `${shown}, +${names.length - max} more` : shown;
|
|
1125
975
|
}
|
|
1126
|
-
// The
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
// never reaches this: its bytes are already bytes.)
|
|
1131
|
-
//
|
|
1132
|
-
// EITHER CASE, through the one charset rule (proof#146). This test was
|
|
1133
|
-
// `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
|
|
1134
|
-
// in either kernel that answered differently from the others: the Python CLI
|
|
1135
|
-
// passes every string to the kernel, and the kernel decodes it with the
|
|
1136
|
-
// case-open `hexToBytes` on BOTH sides. So a disclosure map written in
|
|
1137
|
-
// upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
|
|
1138
|
-
// here, and this command told the auditor their genuine, correctly disclosed
|
|
1139
|
-
// evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
|
|
1140
|
-
// 1 here, on one pack and one map. Telling an auditor their evidence is forged
|
|
1141
|
-
// is the worst direction to be wrong in, which is the argument the Python
|
|
1142
|
-
// twin's own docstring already makes about this exact lane.
|
|
1143
|
-
//
|
|
1144
|
-
// Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
|
|
1145
|
-
// lowercase lane that existed before and only ever exercised the one spelling
|
|
1146
|
-
// the two commands happened to agree on.
|
|
976
|
+
// The map form's encoding: a non-empty, even-length hex string (either case,
|
|
977
|
+
// through the shared charset rule) is hex; anything else is UTF-8 text. Python
|
|
978
|
+
// decodes the same way, so a map in upper-case hex is evidence in both
|
|
979
|
+
// kernels, never text in one and a failure in the other.
|
|
1147
980
|
function decodeLogValue(v) {
|
|
1148
981
|
if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length))
|
|
1149
|
-
return v;
|
|
1150
|
-
return utf8(v);
|
|
982
|
+
return v;
|
|
983
|
+
return utf8(v);
|
|
1151
984
|
}
|
|
1152
985
|
function printUsage(io) {
|
|
1153
986
|
io.out("Usage: sanning-proof <command> [args]");
|
|
@@ -1164,10 +997,10 @@ function printUsage(io) {
|
|
|
1164
997
|
io.out("");
|
|
1165
998
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
1166
999
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
1167
|
-
io.out("sanning.evidence.export/v2)
|
|
1000
|
+
io.out("sanning.evidence.export/v2),");
|
|
1168
1001
|
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
1169
1002
|
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
1170
|
-
io.out("a witness of any other kind is not checked and the
|
|
1003
|
+
io.out("a witness of any other kind is not checked, and the witness line says so.");
|
|
1171
1004
|
io.out("");
|
|
1172
1005
|
io.out("--json Print the verdict, the causes and the exit code as");
|
|
1173
1006
|
io.out(" JSON, for a script, instead of the report.");
|
|
@@ -1191,9 +1024,9 @@ function printUsage(io) {
|
|
|
1191
1024
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
1192
1025
|
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
1193
1026
|
io.out(" Either shape works, and which one you");
|
|
1194
|
-
io.out(" passed is
|
|
1027
|
+
io.out(" passed is determined by stat(), not by the extension:");
|
|
1195
1028
|
io.out("");
|
|
1196
|
-
io.out(" a DIRECTORY of <event_id>.json files
|
|
1029
|
+
io.out(" a DIRECTORY of <event_id>.json files; the file's");
|
|
1197
1030
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
1198
1031
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
1199
1032
|
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
@@ -1207,36 +1040,27 @@ function printUsage(io) {
|
|
|
1207
1040
|
io.out("");
|
|
1208
1041
|
io.out(" In-body events[].content is verified automatically");
|
|
1209
1042
|
io.out(" and takes precedence. An event with no disclosed");
|
|
1210
|
-
io.out(" bytes is
|
|
1043
|
+
io.out(" bytes is not checked, never a failure.");
|
|
1211
1044
|
io.out("");
|
|
1212
|
-
io.out("The headline is the
|
|
1213
|
-
io.out("
|
|
1214
|
-
io.out("
|
|
1045
|
+
io.out("The headline is the pack verdict, the same one the verify page prints, read");
|
|
1046
|
+
io.out("off six claims: signed, unchanged, witnessed, complete, agreed and follows the");
|
|
1047
|
+
io.out("rules. A record nobody hashed leaves the pack incomplete even when the signed");
|
|
1048
|
+
io.out("bundle itself verifies. The report prints one line for each claim that is");
|
|
1049
|
+
io.out("disproven or not proven, and one next action.");
|
|
1215
1050
|
io.out("");
|
|
1216
1051
|
for (const line of EXIT_CODES_HELP.split("\n"))
|
|
1217
1052
|
io.out(line);
|
|
1218
1053
|
io.out("");
|
|
1219
|
-
io.out("verify:
|
|
1220
|
-
io.out("
|
|
1054
|
+
io.out("verify: a pack with a record nobody hashed reads incomplete and exits 2, which");
|
|
1055
|
+
io.out("is not a failure. A gateway that cannot be reached moves nothing: the witness");
|
|
1056
|
+
io.out("line says so.");
|
|
1221
1057
|
}
|
|
1222
1058
|
/**
|
|
1223
|
-
* The disclosure account
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
* `
|
|
1227
|
-
*
|
|
1228
|
-
* only, and this command has a second disclosure lane the side map cannot see:
|
|
1229
|
-
* `events[].content`, carried INSIDE the bundle and bound by the verifier
|
|
1230
|
-
* without any `--logs` at all. Accounting off the side map would file every
|
|
1231
|
-
* in-body disclosure as `missing` and print a shortfall the pack does not have,
|
|
1232
|
-
* which is the shape of manufactured accusation `grading.ts` keeps warning
|
|
1233
|
-
* about.
|
|
1234
|
-
*
|
|
1235
|
-
* So `present` is "the verifier reached a determination for this record's
|
|
1236
|
-
* content" and `missing` is "it could not", which is the same tri-state that
|
|
1237
|
-
* decides the verdict, so the causes printed can never contradict the headline.
|
|
1238
|
-
* `extra` still comes from the side input, because bytes that bound to nothing
|
|
1239
|
-
* are a fact about the pack that only the loader knows.
|
|
1059
|
+
* The disclosure account, from what the verifier determined rather than what
|
|
1060
|
+
* the loader read. `accountDisclosure` sees only the side map and would file
|
|
1061
|
+
* every in-body `events[].content` disclosure as missing. Here `present` and
|
|
1062
|
+
* `missing` follow the same tri-state as the verdict, so the causes printed
|
|
1063
|
+
* cannot contradict the headline; `extra` comes from the side input.
|
|
1240
1064
|
*/
|
|
1241
1065
|
function cliDisclosureAccount(result, logs) {
|
|
1242
1066
|
const expected = result.events.map((e) => e.eventId);
|
|
@@ -1245,398 +1069,309 @@ function cliDisclosureAccount(result, logs) {
|
|
|
1245
1069
|
expected,
|
|
1246
1070
|
present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
|
|
1247
1071
|
missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
|
|
1248
|
-
// The loader hard-stops
|
|
1249
|
-
//
|
|
1250
|
-
// unusable disclosure bound to a named record.
|
|
1072
|
+
// The loader hard-stops on an unreadable selected file and names every
|
|
1073
|
+
// skip, so nothing unusable reaches here.
|
|
1251
1074
|
unreadable: [],
|
|
1252
1075
|
extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
|
|
1253
1076
|
};
|
|
1254
1077
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1078
|
+
// The chain reading could not be produced, which is not a reading of nothing.
|
|
1079
|
+
function chainsUnreadableText(reason) {
|
|
1080
|
+
return ("this kernel could not read the event chains in this bundle " +
|
|
1081
|
+
`(${reason}), so the chain evidence is unknown here. That is ` +
|
|
1082
|
+
"not the same as there being none to read.");
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* One note per record-layer finding (agent-events.md section 10.1), in the order each
|
|
1086
|
+
* is first met, counted over the records that carry it, its kind the finding's name. A
|
|
1087
|
+
* finding never moves a verdict.
|
|
1088
|
+
*/
|
|
1089
|
+
function findingNotes(readings) {
|
|
1090
|
+
const counts = new Map();
|
|
1091
|
+
for (const reading of readings) {
|
|
1092
|
+
if (reading === null)
|
|
1093
|
+
continue;
|
|
1094
|
+
for (const finding of new Set(reading.findings)) {
|
|
1095
|
+
counts.set(finding, (counts.get(finding) ?? 0) + 1);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return [...counts].map(([f, n]) => note(f, renderFindingNote(f, n)));
|
|
1099
|
+
}
|
|
1100
|
+
/** The witness line without its `Witness:` label, which the person layer prints. */
|
|
1101
|
+
function witnessText(line) {
|
|
1102
|
+
return renderWitnessLine(line).slice("Witness: ".length);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* The witness line of one pack read by `verify`: the lookup `verifyEvidenceBundle` ran
|
|
1106
|
+
* with the gateways, in the words the case reading uses.
|
|
1107
|
+
*/
|
|
1108
|
+
function verifyWitness(result, gateways) {
|
|
1109
|
+
// No lookup ran: none was asked for, or the run read no witness to look up (a wrapper that
|
|
1110
|
+
// does not hold, a body this kernel does not read).
|
|
1111
|
+
if (gateways.length === 0 || result.checkpoints.length + (result.stamps ?? []).length === 0) {
|
|
1112
|
+
return witnessLine({ checked: false, gateways: [], packs: [], verdict: "not_checked" });
|
|
1113
|
+
}
|
|
1114
|
+
const { row, answered } = onChainRow("", result);
|
|
1115
|
+
return witnessLine({
|
|
1116
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
1117
|
+
checked: true,
|
|
1118
|
+
gateways: [...gateways],
|
|
1119
|
+
packs: [row],
|
|
1120
|
+
verdict: onChainOutcome([row]),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
function reportEvidence(result, path, gateways, io, chains, links, parsed, logs, sink, bare = null) {
|
|
1124
|
+
// What the pack says it holds, against what it does (evidence-bundle.md section
|
|
1125
|
+
// 5.4). A pack with no declaration leaves `complete` out of scope, and a note says
|
|
1126
|
+
// so. A pack that carries a declaration this reader cannot read is incomplete: the
|
|
1127
|
+
// holder chose that field, so unknown is the honest answer. A v3 chain claim is
|
|
1128
|
+
// checked against this command's links (rule 11); unreadable links read the claim
|
|
1129
|
+
// unknown.
|
|
1130
|
+
const declaration = checkDeclaration(parsed, logs?.content, links);
|
|
1131
|
+
const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
|
|
1132
|
+
// Sandbox evidence: read from the signed envelopes the pack holds. A label, never a
|
|
1133
|
+
// verdict: a sandbox pack that verifies exits 3.
|
|
1134
|
+
const marker = environmentMarker([parsed]);
|
|
1135
|
+
const agents = result.events.filter((ev) => ev.agent).map((ev) => ev.agent);
|
|
1136
|
+
const inScope = [];
|
|
1137
|
+
const notInScope = { agreed: "no_handoff" };
|
|
1138
|
+
if (declaration.present)
|
|
1139
|
+
inScope.push("complete");
|
|
1140
|
+
else
|
|
1141
|
+
notInScope.complete = "no_declaration";
|
|
1142
|
+
if (agents.length > 0)
|
|
1143
|
+
inScope.push("follows_rules");
|
|
1144
|
+
else
|
|
1145
|
+
notInScope.follows_rules = "no_agent_records";
|
|
1146
|
+
let problems = [...grade.problems];
|
|
1147
|
+
if (result.unreadable === true) {
|
|
1148
|
+
// A pack this reader cannot read says nothing about the bytes beside it: its own
|
|
1149
|
+
// problem is the one line, as `case` reads it.
|
|
1150
|
+
problems = bundleProblems(result, 0);
|
|
1151
|
+
}
|
|
1152
|
+
else if (bare !== null && grade.verdict === "failed") {
|
|
1153
|
+
// No disproof rests on bytes nobody signed: `bare` is the same pack read without the
|
|
1154
|
+
// bytes handed in beside it, as `case` reads it.
|
|
1155
|
+
const signed = gradePack(bare, cliDisclosureAccount(bare, undefined), completenessNotes(parsed), checkDeclaration(parsed, undefined, links));
|
|
1156
|
+
problems = restOnSigned(problems, signed.problems);
|
|
1157
|
+
}
|
|
1158
|
+
const eventIds = result.events.map((ev) => ev.eventId);
|
|
1159
|
+
const claims = claimsOf(problems, inScope, {
|
|
1160
|
+
notInScope,
|
|
1161
|
+
labels: (_pack, record) => [null, record !== null ? (eventIds[record] ?? null) : null],
|
|
1162
|
+
});
|
|
1163
|
+
const verdict = verdictOf(claims);
|
|
1164
|
+
const exit = exitCodeFor(verdict.status, marker);
|
|
1165
|
+
const unreadable = "unreadable" in chains ? chains.unreadable : null;
|
|
1166
|
+
const summaries = "unreadable" in chains ? [] : chains.chains;
|
|
1167
|
+
const findings = "unreadable" in chains ? [] : chains.findings;
|
|
1168
|
+
const notes = (result.notes ?? []).map((n) => note("co_signatures_present", n));
|
|
1169
|
+
if (!declaration.present && result.unreadable !== true) {
|
|
1170
|
+
notes.push(note("pack_undeclared", renderDeclarationSummary(declaration)));
|
|
1171
|
+
}
|
|
1172
|
+
notes.push(...declaration.notes.map((n) => note(n.kind, renderNote(n))));
|
|
1173
|
+
if (unreadable !== null)
|
|
1174
|
+
notes.push(note("chains_unreadable", chainsUnreadableText(unreadable)));
|
|
1175
|
+
notes.push(...findings.map((f) => note(f.kind, renderChainFinding(f, "this bundle"))));
|
|
1176
|
+
notes.push(...findingNotes(agents));
|
|
1177
|
+
const witness = verifyWitness(result, gateways);
|
|
1178
|
+
const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
|
|
1179
|
+
for (const line of personText(person))
|
|
1180
|
+
io.out(line);
|
|
1181
|
+
io.out("");
|
|
1182
|
+
io.out(`Evidence bundle ${path}`);
|
|
1183
|
+
io.out(` body_type ${result.bodyType || "<unknown>"}`);
|
|
1184
|
+
// Both verdicts, whenever they differ: a pack can be incomplete over an impeccable
|
|
1185
|
+
// bundle, and the headline alone would read it as broken.
|
|
1186
|
+
if (verdict.status !== result.status) {
|
|
1187
|
+
io.out(` bundle the signed bundle is "${result.status}"; the pack ` +
|
|
1188
|
+
`around it is "${verdict.status}"`);
|
|
1189
|
+
}
|
|
1267
1190
|
if (result.checkpoints.length > 0) {
|
|
1268
1191
|
io.out("");
|
|
1269
|
-
io.out(
|
|
1192
|
+
io.out(" Checkpoints");
|
|
1270
1193
|
for (const cp of result.checkpoints) {
|
|
1271
|
-
|
|
1272
|
-
io.out(` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
|
|
1194
|
+
io.out(` ${cp.ok ? "OK" : "XX"} ${cp.checkpointRef || "<no ref>"}`);
|
|
1273
1195
|
for (const e of cp.errors)
|
|
1274
|
-
io.out(
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
//
|
|
1283
|
-
// The on-chain column distinguishes all four states in the same three
|
|
1284
|
-
// characters the checkpoint block uses, plus its absence: `✓` confirmed,
|
|
1285
|
-
// `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
|
|
1286
|
-
// nothing was checked. A reader must never have to infer "not checked" from
|
|
1287
|
-
// a symbol that also means "could not be reached".
|
|
1288
|
-
if (result.stamps && result.stamps.length > 0) {
|
|
1196
|
+
io.out(` - ${e}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
// The stamps. For a stamp body the witness is the entire independent part (section 5.2.4).
|
|
1200
|
+
// No on-chain column when nothing was checked: "not checked" must not be inferred
|
|
1201
|
+
// from a word that also means "we asked and nobody answered".
|
|
1202
|
+
const stamps = result.stamps ?? [];
|
|
1203
|
+
if (stamps.length > 0) {
|
|
1289
1204
|
io.out("");
|
|
1290
|
-
io.out(
|
|
1291
|
-
for (const st of
|
|
1292
|
-
const witness = st.anchorRef === null
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`);
|
|
1205
|
+
io.out(" Stamps");
|
|
1206
|
+
for (const st of stamps) {
|
|
1207
|
+
const witness = st.anchorRef === null ? "no witness" : `witness ${st.anchorRef.kind}:${st.anchorRef.id}`;
|
|
1208
|
+
const onChain = st.onChain === null ? "" : ` on-chain ${st.onChain.rollup}`;
|
|
1209
|
+
io.out(` ${st.ok ? "OK" : "XX"} ${st.stampRef || "<no stamp_ref>"} ` +
|
|
1210
|
+
`bytes ${st.stampRefOk && st.bodyOk ? "OK" : "XX"} ` +
|
|
1211
|
+
`ranges ${st.subtreesOk ? "OK" : "XX"} ${witness}${onChain}`);
|
|
1298
1212
|
for (const e of st.errors)
|
|
1299
|
-
io.out(
|
|
1213
|
+
io.out(` - ${e}`);
|
|
1300
1214
|
}
|
|
1301
1215
|
}
|
|
1302
1216
|
if (result.events.length > 0) {
|
|
1303
1217
|
io.out("");
|
|
1304
|
-
io.out(
|
|
1218
|
+
io.out(" Events");
|
|
1305
1219
|
for (const ev of result.events) {
|
|
1306
|
-
|
|
1307
|
-
// This read "record withheld", which is a statement about the PRODUCER:
|
|
1308
|
-
// it says the customer chose minimum disclosure and kept these bytes
|
|
1309
|
-
// back, which is legitimate and expected. An event whose envelope was
|
|
1310
|
-
// never an envelope produces the same `null` on this dimension, and
|
|
1311
|
-
// there the sentence is simply untrue: the record may be sitting in the
|
|
1312
|
-
// bundle, and nothing about the producer has been established either
|
|
1313
|
-
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
1314
|
-
// states what the verifier did and leaves WHY to the error lines below,
|
|
1315
|
-
// which now name the malformed envelope instead of a signature check
|
|
1316
|
-
// that never ran (proof#76).
|
|
1317
|
-
const binding = ev.payloadBindingOk === null
|
|
1318
|
-
? paint("~ record not checked", Y)
|
|
1319
|
-
: `record ${mark(ev.payloadBindingOk)}`;
|
|
1320
|
-
// Show the content (logs) segment only when content was disclosed and
|
|
1321
|
-
// evaluated (non-null) — undisclosed content is the default and would just
|
|
1322
|
-
// be noise on every line.
|
|
1323
|
-
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
1324
|
-
io.out(` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`);
|
|
1220
|
+
io.out(` ${ev.ok ? "OK" : "XX"} ${ev.eventId || "<no event_id>"}`);
|
|
1325
1221
|
for (const e of ev.errors)
|
|
1326
|
-
io.out(
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
// §5.4). A pack that deviates from its own declaration FAILS here and exits
|
|
1335
|
-
// 1, which is the point: every other check in this command is a check on a
|
|
1336
|
-
// record that is present, so a trimmed hand-over passes all of them.
|
|
1337
|
-
//
|
|
1338
|
-
// A pack with NO declaration exits exactly as it did before §5.4. The check
|
|
1339
|
-
// answers `partial` and the line below says so in words, and pack grading
|
|
1340
|
-
// keeps its own verdict: every pack built before §5.4 carries no
|
|
1341
|
-
// declaration, and re-grading them all as undetermined would be a change to
|
|
1342
|
-
// their verdicts rather than a new check. A pack that DOES
|
|
1343
|
-
// carry a declaration this reader cannot read is a different case and exits
|
|
1344
|
-
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
1345
|
-
//
|
|
1346
|
-
// A v3 declaration that claims a chain is checked against this command's
|
|
1347
|
-
// own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
|
|
1348
|
-
// that could not be read are passed as none, which reads the claim UNKNOWN
|
|
1349
|
-
// and never clean.
|
|
1350
|
-
const declaration = checkDeclaration(parsed, logs?.content, links);
|
|
1351
|
-
const grade = gradePack(result, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration);
|
|
1352
|
-
// Sandbox evidence: read from the signed envelopes the pack holds.
|
|
1353
|
-
const marker = environmentMarker([parsed]);
|
|
1354
|
-
// A pack whose records are all signed `dev`, undetermined only because a
|
|
1355
|
-
// simulated witness cannot be looked up, would grade `verified` with that
|
|
1356
|
-
// witness accepted: it is sandbox evidence, exit 5, never 3.
|
|
1357
|
-
const simulatedOnly = grade.verdict === "partial" &&
|
|
1358
|
-
onlySimulatedUnsettled(result) &&
|
|
1359
|
-
gradePack({ ...result, status: "verified" }, cliDisclosureAccount(result, logs), completenessNotes(parsed), declaration).verdict === "verified";
|
|
1360
|
-
const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
|
|
1361
|
-
io.out("");
|
|
1362
|
-
printRollup(grade.verdict, result.status, result.assertedStatus, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX, simulatedOnlyRun(result, gateways));
|
|
1363
|
-
io.out(paint(` declaration: ${renderDeclarationSummary(declaration)}`, declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y));
|
|
1364
|
-
for (const n of declaration.notes)
|
|
1365
|
-
io.out(paint(` note: ${renderNote(n)}`, Y));
|
|
1222
|
+
io.out(` - ${e}`);
|
|
1223
|
+
// The agent reading, with the pack's other records held beside it. Its findings
|
|
1224
|
+
// are notes and move nothing; a reading that is not verified is also a cause
|
|
1225
|
+
// of the pack verdict below.
|
|
1226
|
+
if (ev.agent !== null)
|
|
1227
|
+
io.out(` agent ${renderAgentReading(ev.agent)}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1366
1230
|
for (const e of result.errors)
|
|
1367
|
-
io.
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
io.out(
|
|
1373
|
-
// The fraction describes the PACK, not the disclosure. It used to divide by
|
|
1374
|
-
// the number of records that happened to disclose bytes, so a pack that
|
|
1375
|
-
// disclosed two of three records printed `logs: 2/2`, a full score for a
|
|
1376
|
-
// partial hand-over, and the one line a reader skims for exactly this.
|
|
1377
|
-
if (result.events.length > 0) {
|
|
1378
|
-
io.out(paint(` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`, DIM));
|
|
1231
|
+
io.err(` - ${e}`);
|
|
1232
|
+
// The chain block: the chains walked (their findings are notes, printed above); no
|
|
1233
|
+
// chain to follow; or a reading that could not be produced, printed as itself.
|
|
1234
|
+
io.out("");
|
|
1235
|
+
if (unreadable !== null) {
|
|
1236
|
+
io.out(` Chain ${chainsUnreadableText(unreadable)}`);
|
|
1379
1237
|
}
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
/**
|
|
1385
|
-
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
1386
|
-
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
1387
|
-
* `chains` are null for a bundle no verdict could be rendered over.
|
|
1388
|
-
*/
|
|
1389
|
-
function verifyJson(result, grade, declaration, chains, marker = null) {
|
|
1390
|
-
let chainBlock = null;
|
|
1391
|
-
if (chains !== null) {
|
|
1392
|
-
chainBlock =
|
|
1393
|
-
"unreadable" in chains
|
|
1394
|
-
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1395
|
-
: {
|
|
1396
|
-
unreadable: null,
|
|
1397
|
-
chains: chains.chains.length,
|
|
1398
|
-
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1399
|
-
};
|
|
1238
|
+
else if (summaries.length === 0) {
|
|
1239
|
+
io.out(" Chain no event chain was found to follow in this " +
|
|
1240
|
+
"bundle, so nothing was checked here");
|
|
1400
1241
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
1410
|
-
notes: [...result.errors],
|
|
1411
|
-
records: grade
|
|
1412
|
-
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
1413
|
-
: null,
|
|
1414
|
-
chains: chainBlock,
|
|
1415
|
-
// Present only where the pack holds an envelope signed `dev`
|
|
1416
|
-
// (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
|
|
1417
|
-
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1420
|
-
/**
|
|
1421
|
-
* The chain block: what following each record's pointer to the record before it
|
|
1422
|
-
* showed, over the records THIS bundle presents.
|
|
1423
|
-
*
|
|
1424
|
-
* Three deliberate choices, all of them about not overclaiming.
|
|
1425
|
-
*
|
|
1426
|
-
* 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
|
|
1427
|
-
* report" and silence are different statements, and a reader that quietly
|
|
1428
|
-
* matched no records at all would produce the second one while covering
|
|
1429
|
-
* nothing.
|
|
1430
|
-
* 2. It prints the scope sentence whenever it read a chain, findings or not.
|
|
1431
|
-
* "Delete an event and the next pointer dangles" is true; "so the bundle is
|
|
1432
|
-
* complete" does not follow, and two customer-facing documents had already
|
|
1433
|
-
* made that leap. The limit travels with the result or it gets dropped.
|
|
1434
|
-
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
1435
|
-
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1436
|
-
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
1437
|
-
* decision this check is entitled to make.
|
|
1438
|
-
*/
|
|
1439
|
-
function reportChains(reading, io) {
|
|
1440
|
-
if ("unreadable" in reading) {
|
|
1441
|
-
io.out("");
|
|
1442
|
-
io.out(paint(` Event chains this kernel could not read the event chains in this bundle ` +
|
|
1443
|
-
`(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
|
|
1444
|
-
`the same as there being none to read.`, Y));
|
|
1445
|
-
return;
|
|
1242
|
+
else {
|
|
1243
|
+
io.out(" Event chains");
|
|
1244
|
+
for (const c of summaries) {
|
|
1245
|
+
const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
|
|
1246
|
+
io.out(` ${c.chainKey} ${c.events} record${c.events === 1 ? "" : "s"} present` +
|
|
1247
|
+
` (linked in ${where})`);
|
|
1248
|
+
}
|
|
1249
|
+
io.out(` ${CHAIN_READING_SCOPE}`);
|
|
1446
1250
|
}
|
|
1447
|
-
if (
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
io.out(paint(" Event chains", DIM));
|
|
1451
|
-
for (const c of reading.chains) {
|
|
1452
|
-
const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
|
|
1453
|
-
io.out(paint(` ${c.chainKey} ${count(c.events, "record", "records")} present (linked in ${where})`, DIM));
|
|
1251
|
+
if (simulatedOnlyRun(result, gateways)) {
|
|
1252
|
+
io.out(` Gateways ${gateways.join(", ")}; a simulated witness is not ` +
|
|
1253
|
+
"on a chain, so nothing was looked up");
|
|
1454
1254
|
}
|
|
1455
|
-
|
|
1456
|
-
io.out(
|
|
1255
|
+
else if (gateways.length > 0) {
|
|
1256
|
+
io.out(` Gateways ${gateways.join(", ")}`);
|
|
1457
1257
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
for (const a of exp.attestations) {
|
|
1470
|
-
const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
|
|
1471
|
-
io.out(` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
|
|
1472
|
-
` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`);
|
|
1473
|
-
for (const e of a.errors)
|
|
1474
|
-
io.out(paint(` - ${e}`, R));
|
|
1475
|
-
}
|
|
1258
|
+
else {
|
|
1259
|
+
io.out(" Gateways none supplied; the on-chain half was not checked");
|
|
1260
|
+
}
|
|
1261
|
+
io.out(` declaration ${renderDeclarationSummary(declaration)}`);
|
|
1262
|
+
// Every reason the pack is not verified, in the kernel's own words.
|
|
1263
|
+
for (const c of grade.causes)
|
|
1264
|
+
io.out(` - ${renderCause(c, "the pack")}`);
|
|
1265
|
+
// The fraction describes the pack, not the disclosure.
|
|
1266
|
+
if (result.events.length > 0) {
|
|
1267
|
+
io.out(` logs ${grade.recordsVerified}/${result.events.length} ` +
|
|
1268
|
+
"record(s) content-verified");
|
|
1476
1269
|
}
|
|
1477
|
-
}
|
|
1478
|
-
function reportAgentProof(result, path, gateways, io, sink, parsed) {
|
|
1479
|
-
const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
|
|
1480
1270
|
if (sink) {
|
|
1481
|
-
sink.data =
|
|
1482
|
-
verdict: result.status,
|
|
1483
|
-
bundle_status: result.status,
|
|
1484
|
-
spec_version: "sanning.agent.proof/v1",
|
|
1485
|
-
on_chain_checked: result.onChainChecked,
|
|
1486
|
-
notes: [...result.errors],
|
|
1487
|
-
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1488
|
-
};
|
|
1271
|
+
sink.data = verifyJson(result, grade, declaration, chains, marker, verdict, claims, person);
|
|
1489
1272
|
}
|
|
1490
|
-
if (result.status === "malformed") {
|
|
1491
|
-
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
1492
|
-
for (const e of result.errors)
|
|
1493
|
-
io.err(` - ${e}`);
|
|
1494
|
-
return EXIT_MALFORMED;
|
|
1495
|
-
}
|
|
1496
|
-
io.out(paint("Agent inclusion proof", B) + ` ${path}`);
|
|
1497
|
-
io.out(` spec_version sanning.agent.proof/v1`);
|
|
1498
|
-
io.out(` checkpoint ${shortTx(result.checkpointTxId ?? "")} envelope ${mark(result.checkpointOk)}`);
|
|
1499
|
-
const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
|
|
1500
|
-
io.out(` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`);
|
|
1501
|
-
io.out("");
|
|
1502
|
-
// An agent inclusion proof carries no records and no disclosed bytes, so
|
|
1503
|
-
// there is no pack to grade: the bundle status IS the headline here.
|
|
1504
|
-
const exit = sandboxExit(exitCodeFor(result.status), marker);
|
|
1505
|
-
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
|
|
1506
|
-
for (const e of result.errors)
|
|
1507
|
-
io.out(paint(` note: ${e}`, DIM));
|
|
1508
1273
|
return exit;
|
|
1509
1274
|
}
|
|
1510
|
-
function
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
? "verified offline; a simulated witness is not on a chain, so nothing was looked up"
|
|
1521
|
-
: "verified offline (no gateway re-fetch requested)";
|
|
1522
|
-
let head;
|
|
1523
|
-
switch (sandbox ? "sandbox" : headline) {
|
|
1524
|
-
// Sandbox evidence (exit 5) never prints VERIFIED.
|
|
1525
|
-
case "sandbox":
|
|
1526
|
-
head = paint(SANDBOX_HEADLINE, Y);
|
|
1527
|
-
break;
|
|
1528
|
-
case "verified":
|
|
1529
|
-
head = paint("VERIFIED", G);
|
|
1530
|
-
break;
|
|
1531
|
-
// Undetermined, in both of its words. Neither is an accusation, so neither
|
|
1532
|
-
// is printed in the color this command uses for one.
|
|
1533
|
-
case "partial":
|
|
1534
|
-
case "pending":
|
|
1535
|
-
case "incomplete":
|
|
1536
|
-
head = paint(headline.toUpperCase(), Y);
|
|
1537
|
-
break;
|
|
1538
|
-
case "failed":
|
|
1539
|
-
head = paint("FAILED", R);
|
|
1540
|
-
break;
|
|
1541
|
-
default:
|
|
1542
|
-
head = paint(headline.toUpperCase(), R);
|
|
1543
|
-
}
|
|
1544
|
-
io.out(`${head} ${paint(scope, DIM)}`);
|
|
1545
|
-
if (marker !== null)
|
|
1546
|
-
io.out(paint(` ${SANDBOX_LINE}`, Y));
|
|
1547
|
-
// Both verdicts, whenever they differ, because they answer different
|
|
1548
|
-
// questions: §3 grades the signed bundle, the pack verdict grades the bundle
|
|
1549
|
-
// plus whatever bytes travelled beside it. A pack can be incomplete while the
|
|
1550
|
-
// bundle it carries is impeccable, and a reader told only the headline would
|
|
1551
|
-
// read a sound bundle as a broken one.
|
|
1552
|
-
if (headline !== bundleStatus) {
|
|
1553
|
-
io.out(paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM));
|
|
1554
|
-
}
|
|
1555
|
-
if (asserted && asserted !== bundleStatus) {
|
|
1556
|
-
io.out(paint(` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
|
|
1557
|
-
`and the recomputed verdict wins)`, DIM));
|
|
1558
|
-
}
|
|
1275
|
+
function agentJson(reading, where = {}) {
|
|
1276
|
+
return {
|
|
1277
|
+
...where,
|
|
1278
|
+
verdict: reading.verdict,
|
|
1279
|
+
reason: reading.reason ?? null,
|
|
1280
|
+
at: reading.at ?? null,
|
|
1281
|
+
reasons: (reading.reasons ?? []).map((r) => ({ reason: r.reason, at: r.at })),
|
|
1282
|
+
findings: [...reading.findings],
|
|
1283
|
+
rendered: renderAgentReading(reading),
|
|
1284
|
+
};
|
|
1559
1285
|
}
|
|
1286
|
+
const CAUSE_FIELDS = [
|
|
1287
|
+
["checkpoint_ref", "checkpointRef"],
|
|
1288
|
+
["signed", "signed"],
|
|
1289
|
+
["presented", "presented"],
|
|
1290
|
+
["count", "count"],
|
|
1291
|
+
["of", "of"],
|
|
1292
|
+
["first_event_id", "firstEventId"],
|
|
1293
|
+
["reason", "reason"],
|
|
1294
|
+
["deviation", "deviation"],
|
|
1295
|
+
["at", "at"],
|
|
1296
|
+
["listed_event_id", "listedEventId"],
|
|
1297
|
+
];
|
|
1560
1298
|
/**
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
* `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
|
|
1564
|
-
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1565
|
-
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1566
|
-
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
1299
|
+
* One pack grade cause in `--json`: its kind, every value it carries, and the sentence
|
|
1300
|
+
* the report prints for it.
|
|
1567
1301
|
*/
|
|
1568
|
-
function
|
|
1569
|
-
|
|
1302
|
+
function causeJson(cause) {
|
|
1303
|
+
const out = { kind: cause.kind };
|
|
1304
|
+
for (const [name, field] of CAUSE_FIELDS) {
|
|
1305
|
+
const value = cause[field];
|
|
1306
|
+
if (value !== undefined && value !== null)
|
|
1307
|
+
out[name] = value;
|
|
1308
|
+
}
|
|
1309
|
+
out.rendered = renderCause(cause, "the pack");
|
|
1310
|
+
return out;
|
|
1570
1311
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1312
|
+
/**
|
|
1313
|
+
* What `verify --json` prints for an evidence bundle: the same keys and words as
|
|
1314
|
+
* Python's `_verify_json`. Every reason and count the person layer prints is here, so a
|
|
1315
|
+
* surface can render `person` from this object alone (`renderPerson`).
|
|
1316
|
+
*/
|
|
1317
|
+
function verifyJson(result, grade, declaration, chains, marker, verdict, claims, person) {
|
|
1318
|
+
const chainBlock = "unreadable" in chains
|
|
1319
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1320
|
+
: {
|
|
1321
|
+
unreadable: null,
|
|
1322
|
+
chains: chains.chains.length,
|
|
1323
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1324
|
+
};
|
|
1325
|
+
return {
|
|
1326
|
+
// Present only for a pack with an envelope signed `dev` (envelope-spec section 2).
|
|
1327
|
+
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1328
|
+
verdict,
|
|
1329
|
+
title: VERDICT_TITLES[verdict.status],
|
|
1330
|
+
claims,
|
|
1331
|
+
person,
|
|
1332
|
+
notes: [...person.notes],
|
|
1333
|
+
sandbox: marker !== null,
|
|
1334
|
+
witness: person.witness,
|
|
1335
|
+
witness_state: person.witness_state,
|
|
1336
|
+
bundle_status: result.status,
|
|
1337
|
+
bundle_errors: [...result.errors],
|
|
1338
|
+
body_type: result.bodyType ?? null,
|
|
1339
|
+
on_chain_checked: result.onChainChecked,
|
|
1340
|
+
declaration: declaration.verdict,
|
|
1341
|
+
declaration_notes: declaration.notes.map(renderNote),
|
|
1342
|
+
causes: grade.causes.map(causeJson),
|
|
1343
|
+
records: { presented: result.events.length, content_verified: grade.recordsVerified },
|
|
1344
|
+
chains: chainBlock,
|
|
1345
|
+
events: result.events.map((ev) => ({ event_id: ev.eventId, ok: ev.ok, errors: [...ev.errors] })),
|
|
1346
|
+
agent_records: result.events
|
|
1347
|
+
.filter((ev) => ev.agent !== null)
|
|
1348
|
+
.map((ev) => agentJson(ev.agent, { event_id: ev.eventId })),
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
// `proof case` input: directories (or a `.zip` read as the folder `unzip`
|
|
1352
|
+
// makes) split into packs, held to `vectors/case-split.json` in both kernels.
|
|
1353
|
+
// A `.zip` inside a folder is a file like any other (§5.6.1).
|
|
1607
1354
|
//
|
|
1608
|
-
//
|
|
1355
|
+
// The walk is iterative and bytewise-sorted, never follows a link, opens only
|
|
1356
|
+
// regular files and directories, and refuses the whole input (exit 4) past
|
|
1357
|
+
// MAX_WALK_DEPTH or MAX_WALK_ENTRIES or on a name that is not valid UTF-8.
|
|
1609
1358
|
//
|
|
1610
|
-
// The
|
|
1611
|
-
// `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
|
|
1612
|
-
// apply the same splitting rule to the same unpacked tree. The page's rule
|
|
1613
|
-
// lives in the page, so neither kernel can call it; it is restated here and in
|
|
1614
|
-
// the Python twin, and the shared table pins it:
|
|
1359
|
+
// The split restates the verify page's rule:
|
|
1615
1360
|
//
|
|
1616
|
-
// 1. Every regular file
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
// `Array.prototype.sort`).
|
|
1624
|
-
// 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
|
|
1625
|
-
// and any other file under `logs/` is UNACCOUNTED.
|
|
1361
|
+
// 1. Every regular file at `.../bundle.json` is a pack rooted at its folder.
|
|
1362
|
+
// 2. A file belongs to the pack with the longest root it sits under, only if
|
|
1363
|
+
// it is that root's `bundle.json` or under its `logs/`.
|
|
1364
|
+
// 3. Any other file is outside: listed, and hashed to match against records.
|
|
1365
|
+
// 4. Roots and outside paths sort by UTF-16 code unit.
|
|
1366
|
+
// 5. In a pack, `logs/<event_id>.json` (one level) is disclosed content; any
|
|
1367
|
+
// other file under `logs/` is unaccounted.
|
|
1626
1368
|
// 6. A pack's `ref` is its root without the trailing slash.
|
|
1627
1369
|
// 7. No `bundle.json` anywhere refuses.
|
|
1628
1370
|
//
|
|
1629
|
-
// One directory argument
|
|
1630
|
-
//
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
//
|
|
1634
|
-
// Known differences from the page: the page drops a leading byte-order mark from
|
|
1635
|
-
// `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
|
|
1636
|
-
// browser hands it for a link, a name that is not UTF-8 or a very deep tree,
|
|
1637
|
-
// where this command applies the walk's rules above; and the page keys
|
|
1638
|
-
// disclosed content by a plain object, so it loses a record named `__proto__`,
|
|
1639
|
-
// which this command keeps.
|
|
1371
|
+
// One directory argument reads as `<its name>/<path inside>`; several read as
|
|
1372
|
+
// one folder holding them, and two landing at one name refuse. Unlike the page,
|
|
1373
|
+
// this command refuses a leading BOM in `bundle.json` and keeps a record named
|
|
1374
|
+
// `__proto__`.
|
|
1640
1375
|
/** The error text for an input with no pack in it, word for word the page's. */
|
|
1641
1376
|
export const NO_BUNDLE = "No bundle.json found. An evidence pack contains bundle.json plus logs/.";
|
|
1642
1377
|
/** A directory nested deeper than this below an argument refuses the input. */
|
|
@@ -1656,7 +1391,7 @@ const UNZIP_FIRST = "Pass the .zip itself, or unzip it first (for example, unzip
|
|
|
1656
1391
|
"case/) and pass the folder.";
|
|
1657
1392
|
/**
|
|
1658
1393
|
* The input could not be read as a case. The message is the whole report,
|
|
1659
|
-
* identical in both kernels, and the command exits
|
|
1394
|
+
* identical in both kernels, and the command exits 4 on it. `hint` is an
|
|
1660
1395
|
* optional second line naming a way through.
|
|
1661
1396
|
*/
|
|
1662
1397
|
export class CaseInputError extends Error {
|
|
@@ -1675,11 +1410,10 @@ export class CaseInputError extends Error {
|
|
|
1675
1410
|
export class WholeReadRefused extends CaseInputError {
|
|
1676
1411
|
}
|
|
1677
1412
|
/**
|
|
1678
|
-
* `bundle.json` bytes
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
* (`duplicate key "<name>"`, proof#144), so both commands print it.
|
|
1413
|
+
* `bundle.json` bytes `readBundleBytes` will not read. `tooDeep` marks the one
|
|
1414
|
+
* refusal that is not a parse error. `detail` is the parser's words, which
|
|
1415
|
+
* differ by kernel, so only `verify` prints it; `duplicate` marks the one
|
|
1416
|
+
* `detail` both kernels word identically.
|
|
1683
1417
|
*/
|
|
1684
1418
|
export class BundleReadError extends Error {
|
|
1685
1419
|
tooDeep;
|
|
@@ -1692,22 +1426,20 @@ export class BundleReadError extends Error {
|
|
|
1692
1426
|
this.duplicate = duplicate;
|
|
1693
1427
|
}
|
|
1694
1428
|
}
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
// I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
|
|
1698
|
-
// would be the wrong sentence. `NOT_I_JSON` in the Python twin.
|
|
1429
|
+
// The prefix for a repeated member name. JCS is defined over I-JSON (RFC 7493),
|
|
1430
|
+
// which forbids it, so "not valid JSON" would be the wrong sentence.
|
|
1699
1431
|
export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
|
|
1700
|
-
// `ignoreBOM: true`
|
|
1701
|
-
//
|
|
1432
|
+
// `ignoreBOM: true` keeps a byte-order mark in the text, so a leading one is
|
|
1433
|
+
// not JSON.
|
|
1702
1434
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
1703
1435
|
const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
1704
1436
|
/**
|
|
1705
|
-
* How
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1437
|
+
* How `verify` and `case` turn `bundle.json` bytes into JSON, in both kernels:
|
|
1438
|
+
* UTF-8 with ill-formed sequences as U+FFFD and a BOM kept; nesting past
|
|
1439
|
+
* MAX_JSON_DEPTH refused before parsing; then `JSON.parse`, refusing a repeated
|
|
1440
|
+
* member name.
|
|
1441
|
+
*
|
|
1442
|
+
* @throws BundleReadError
|
|
1711
1443
|
*/
|
|
1712
1444
|
export function readBundleBytes(bytes) {
|
|
1713
1445
|
const text = bundleDecoder.decode(bytes);
|
|
@@ -1724,9 +1456,8 @@ export function readBundleBytes(bytes) {
|
|
|
1724
1456
|
}
|
|
1725
1457
|
}
|
|
1726
1458
|
/**
|
|
1727
|
-
* The page's `splitCase`
|
|
1728
|
-
*
|
|
1729
|
-
* nothing else.
|
|
1459
|
+
* The page's `splitCase` then `assemble` per pack, over the walk's tree. `read`
|
|
1460
|
+
* is called only for the files a pack owns.
|
|
1730
1461
|
*/
|
|
1731
1462
|
export async function splitCase(tree, read) {
|
|
1732
1463
|
const roots = [...tree]
|
|
@@ -1789,15 +1520,10 @@ export async function splitCase(tree, read) {
|
|
|
1789
1520
|
}
|
|
1790
1521
|
/**
|
|
1791
1522
|
* The bytes of the `witness-cache.json` beside a pack's `bundle.json`
|
|
1792
|
-
* (evidence-bundle 5.6.6
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
* outside files (A41). It is read as a cache only where it is a regular file the split left outside every pack, so a file under another
|
|
1797
|
-
* pack's `logs/` is that pack's, and a link is never followed. A file that
|
|
1798
|
-
* cannot be read is no cache, never a refusal of the drop. One the run's
|
|
1799
|
-
* whole-read total has no room for is pushed onto `refused` when it is given,
|
|
1800
|
-
* so the output says so.
|
|
1523
|
+
* (evidence-bundle.md §5.6.6), or `null`. Read only where it is a regular file
|
|
1524
|
+
* the split left outside every pack, and it stays outside. An unreadable file
|
|
1525
|
+
* is no cache, never a refusal; one the whole-read total has no room for is
|
|
1526
|
+
* pushed onto `refused` when given.
|
|
1801
1527
|
*/
|
|
1802
1528
|
export async function witnessCacheBytes(tree, split, pack, budget, refused) {
|
|
1803
1529
|
const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
|
|
@@ -1817,31 +1543,28 @@ export async function witnessCacheBytes(tree, split, pack, budget, refused) {
|
|
|
1817
1543
|
throw e;
|
|
1818
1544
|
}
|
|
1819
1545
|
}
|
|
1820
|
-
/**
|
|
1546
|
+
/**
|
|
1547
|
+
* `verify`'s reader over one pack's `bundle.json`: the bundle, or an empty object and why
|
|
1548
|
+
* it could not be read. One pack that cannot be read is that pack's problem, never the
|
|
1549
|
+
* case's: every other pack is still read.
|
|
1550
|
+
*/
|
|
1821
1551
|
export function loadBundle(pack) {
|
|
1822
1552
|
try {
|
|
1823
|
-
return readBundleBytes(pack.bundleBytes);
|
|
1553
|
+
return [readBundleBytes(pack.bundleBytes), null];
|
|
1824
1554
|
}
|
|
1825
1555
|
catch (e) {
|
|
1826
1556
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
1827
|
-
|
|
1557
|
+
return [{}, `nests deeper than ${MAX_JSON_DEPTH} levels`];
|
|
1828
1558
|
}
|
|
1829
1559
|
if (e instanceof BundleReadError && e.duplicate) {
|
|
1830
|
-
|
|
1560
|
+
return [{}, `${NOT_I_JSON}: ${e.detail}`];
|
|
1831
1561
|
}
|
|
1832
|
-
|
|
1562
|
+
return [{}, "is not valid JSON"];
|
|
1833
1563
|
}
|
|
1834
1564
|
}
|
|
1835
|
-
/**
|
|
1836
|
-
* The refusal for a file `case` would hold whole that does not fit in what is
|
|
1837
|
-
* left of the 1 GiB a run reads whole. The same words in both kernels.
|
|
1838
|
-
*/
|
|
1565
|
+
/** The refusal for a file `case` would read whole past the run's 1 GiB. Same words in both kernels. */
|
|
1839
1566
|
export const wholeReadRefusal = (key) => `${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
|
|
1840
|
-
/**
|
|
1841
|
-
* Why `contents.json` reads as an unreadable index when the run's whole-read
|
|
1842
|
-
* total has no room for it, and when it cannot be read. The same words in both
|
|
1843
|
-
* kernels.
|
|
1844
|
-
*/
|
|
1567
|
+
/** Why `contents.json` reads as unreadable when it is over budget or cannot be read. */
|
|
1845
1568
|
export const INDEX_OVER_BUDGET = "it would take case past the 1 GiB it reads whole in one run, so it was not read";
|
|
1846
1569
|
export const INDEX_NOT_READ = "it could not be read";
|
|
1847
1570
|
/** The note for a `witness-cache.json` the whole-read total had no room for. */
|
|
@@ -1849,15 +1572,13 @@ export const witnessCacheRefusedNote = (path) => `${path} was not read as a witn
|
|
|
1849
1572
|
/** The refusal for an archive file larger than MAX_ZIP_ARCHIVE_BYTES. */
|
|
1850
1573
|
export const zipFileRefusal = (arg) => `${arg} is larger than 1.25 GiB on disk; refusing to read it`;
|
|
1851
1574
|
/**
|
|
1852
|
-
* A regular file's bytes
|
|
1853
|
-
*
|
|
1854
|
-
* the walk listed it is refused rather than followed or waited on.
|
|
1575
|
+
* A regular file's bytes, opened without following a link or blocking, then
|
|
1576
|
+
* rechecked, so a swapped-in link or pipe is refused.
|
|
1855
1577
|
*
|
|
1856
|
-
* `budget` is the bytes a run may still hold whole
|
|
1857
|
-
*
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
1860
|
-
* already in memory, inside the archive's own cap, and is not charged.
|
|
1578
|
+
* `budget` is the bytes a run may still hold whole (MAX_FOLDER_BYTES at the
|
|
1579
|
+
* start). A file larger than that, or that grows past it, is refused before it
|
|
1580
|
+
* is held. A `.zip` entry is already in memory under the archive cap and is not
|
|
1581
|
+
* charged.
|
|
1861
1582
|
*/
|
|
1862
1583
|
export async function readFileEntry(entry, key, budget) {
|
|
1863
1584
|
if (entry.data !== undefined && entry.data !== null)
|
|
@@ -1878,8 +1599,8 @@ export async function readFileEntry(entry, key, budget) {
|
|
|
1878
1599
|
return new Uint8Array(await handle.readFile());
|
|
1879
1600
|
if (st.size > budget.n)
|
|
1880
1601
|
throw new WholeReadRefused(wholeReadRefusal(key));
|
|
1881
|
-
//
|
|
1882
|
-
//
|
|
1602
|
+
// Ask for one byte more than the size, to see it grow; read in place, never
|
|
1603
|
+
// copied.
|
|
1883
1604
|
const want = Math.min(st.size, budget.n) + 1;
|
|
1884
1605
|
const buf = Buffer.allocUnsafeSlow(want);
|
|
1885
1606
|
let total = 0;
|
|
@@ -1890,8 +1611,7 @@ export async function readFileEntry(entry, key, budget) {
|
|
|
1890
1611
|
total += bytesRead;
|
|
1891
1612
|
}
|
|
1892
1613
|
if (total > st.size) {
|
|
1893
|
-
//
|
|
1894
|
-
// past the budget.
|
|
1614
|
+
// It grew: read the rest in pieces, never past the budget.
|
|
1895
1615
|
const rest = [];
|
|
1896
1616
|
let all = total;
|
|
1897
1617
|
while (all <= budget.n) {
|
|
@@ -1923,9 +1643,8 @@ export async function readFileEntry(entry, key, budget) {
|
|
|
1923
1643
|
}
|
|
1924
1644
|
}
|
|
1925
1645
|
/**
|
|
1926
|
-
* Every name under `root`, into `tree` at `<prefix><path inside
|
|
1927
|
-
* first
|
|
1928
|
-
* names listed so far, across every argument.
|
|
1646
|
+
* Every name under `root`, into `tree` at `<prefix><path inside>`, breadth
|
|
1647
|
+
* first, without recursion. `seen` counts names across every argument.
|
|
1929
1648
|
*/
|
|
1930
1649
|
async function walk(root, prefix, arg, tree, seen) {
|
|
1931
1650
|
const queue = [[root, prefix, 0]];
|
|
@@ -1972,8 +1691,7 @@ async function walk(root, prefix, arg, tree, seen) {
|
|
|
1972
1691
|
}
|
|
1973
1692
|
}
|
|
1974
1693
|
}
|
|
1975
|
-
// The
|
|
1976
|
-
// absolute path, so `.` and `pack/` name the directory itself.
|
|
1694
|
+
// The last segment of the absolute path, so `.` and `pack/` name the directory.
|
|
1977
1695
|
function baseName(path) {
|
|
1978
1696
|
return basename(resolve(path));
|
|
1979
1697
|
}
|
|
@@ -2018,8 +1736,7 @@ export async function treeFromArguments(paths) {
|
|
|
2018
1736
|
}
|
|
2019
1737
|
let name = baseName(arg);
|
|
2020
1738
|
if (isZip) {
|
|
2021
|
-
// `unzip <name>.zip -d <name>`
|
|
2022
|
-
// the archive is read under the folder that command makes.
|
|
1739
|
+
// Read under the folder `unzip <name>.zip -d <name>` makes.
|
|
2023
1740
|
name = name.slice(0, -".zip".length);
|
|
2024
1741
|
if (name.length === 0)
|
|
2025
1742
|
throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
@@ -2037,11 +1754,9 @@ export async function treeFromArguments(paths) {
|
|
|
2037
1754
|
return tree;
|
|
2038
1755
|
}
|
|
2039
1756
|
/**
|
|
2040
|
-
* A case file named on the command line, read as the folder `unzip` would make
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2043
|
-
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
2044
|
-
* read, as the walk lists a link.
|
|
1757
|
+
* A case file named on the command line, read as the folder `unzip` would make:
|
|
1758
|
+
* `<name>.zip` reads at `<name>/<entry>` (§5.6.4). An entry `readZip` refuses
|
|
1759
|
+
* is listed outside every pack and never read.
|
|
2045
1760
|
*/
|
|
2046
1761
|
async function unzip(arg, prefix, tree, seen) {
|
|
2047
1762
|
let data;
|
|
@@ -2056,8 +1771,7 @@ async function unzip(arg, prefix, tree, seen) {
|
|
|
2056
1771
|
const size = (await handle.stat()).size;
|
|
2057
1772
|
if (size > MAX_ZIP_ARCHIVE_BYTES)
|
|
2058
1773
|
throw new CaseInputError(zipFileRefusal(arg));
|
|
2059
|
-
// One
|
|
2060
|
-
// archive is read straight into this buffer, once, and never copied.
|
|
1774
|
+
// One byte past the size, to notice growth; read in place, never copied.
|
|
2061
1775
|
const buf = Buffer.allocUnsafe(size + 1);
|
|
2062
1776
|
let have = 0;
|
|
2063
1777
|
for (;;) {
|
|
@@ -2103,28 +1817,17 @@ async function unzip(arg, prefix, tree, seen) {
|
|
|
2103
1817
|
});
|
|
2104
1818
|
}
|
|
2105
1819
|
}
|
|
2106
|
-
//
|
|
1820
|
+
// `proof case`: the case reading from a terminal, so an auditor holding several
|
|
1821
|
+
// packs does not depend on Sanning's page. Python's twin is `run_case`;
|
|
1822
|
+
// `cross-kernel/run_case_cli.sh` holds both to the same stdout, `--json` and
|
|
1823
|
+
// exit code. No color, so both print the same bytes.
|
|
2107
1824
|
//
|
|
2108
|
-
//
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
// the two commands are compared as child processes by
|
|
2112
|
-
// `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
|
|
2113
|
-
// over every `case/` vector.
|
|
2114
|
-
//
|
|
2115
|
-
// The reading is `readCase`, entire. This command splits the input into packs
|
|
2116
|
-
// (the section above, the verify page's rule) and prints what the kernel said.
|
|
2117
|
-
// No color: the two commands print the same bytes.
|
|
2118
|
-
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
2119
|
-
// `mixed` has its own code, 4, because each other code would say something a
|
|
2120
|
-
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
2121
|
-
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
2122
|
-
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
2123
|
-
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
2124
|
-
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
1825
|
+
// `mixed` exits 4 because every other code would claim something unproven: 0
|
|
1826
|
+
// softens an unsettled outcome, 3 hides the settled ones, 1 accuses. A proven
|
|
1827
|
+
// failure keeps the headline (evidence-bundle.md §5.5.4) and exits 1.
|
|
2125
1828
|
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
2126
1829
|
|
|
2127
|
-
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--
|
|
1830
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
|
|
2128
1831
|
|
|
2129
1832
|
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
2130
1833
|
several packs, or an unzipped case file. Packs are found by the
|
|
@@ -2137,7 +1840,7 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2137
1840
|
read, even under a pack's logs/. Several arguments are read as
|
|
2138
1841
|
one folder holding them, each under its own name. A folder
|
|
2139
1842
|
nested more than 32 levels deep, more than 50000 files and
|
|
2140
|
-
folders in all, or a name that is not valid UTF-8 exits
|
|
1843
|
+
folders in all, or a name that is not valid UTF-8 exits 4.
|
|
2141
1844
|
<case file> a case file named as <name>.zip, read as the folder
|
|
2142
1845
|
unzip <name>.zip -d <name> would make, so the two give one
|
|
2143
1846
|
reading. An entry that is encrypted, a symbolic link, stored by
|
|
@@ -2148,7 +1851,9 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2148
1851
|
is listed; the index never moves the verdict.
|
|
2149
1852
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
2150
1853
|
looked up on the public record, pack by pack, and reported on
|
|
2151
|
-
the witness line.
|
|
1854
|
+
the witness line. Only a lookup that finds other bytes, or
|
|
1855
|
+
another witness time, changes the verdict: it disproves
|
|
1856
|
+
witnessed, and the case reads failed.
|
|
2152
1857
|
--files files handed in beside the packs, every path up to the next
|
|
2153
1858
|
option, matched by SHA-256 alone against what every pack's
|
|
2154
1859
|
signed records commit to. A folder stands for the regular files
|
|
@@ -2156,23 +1861,13 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2156
1861
|
inside it is listed as skipped and never followed. A file under
|
|
2157
1862
|
a pack's logs/ is offered only when it is named here. The
|
|
2158
1863
|
verdict never moves.
|
|
2159
|
-
--json the full reading as JSON: the verdict,
|
|
1864
|
+
--json the full reading as JSON: the verdict, the six claims, the
|
|
1865
|
+
person layer, every exception and note
|
|
2160
1866
|
with its rendered line, every record of every pack with its
|
|
2161
1867
|
status (read clean, read flagged, or not read and why), every
|
|
2162
1868
|
contribution, the packs, each with its index, the files outside
|
|
2163
1869
|
them, the case file index comparison, the witness line and the
|
|
2164
1870
|
on-chain half.
|
|
2165
|
-
--enclave-policy
|
|
2166
|
-
a sanning.enclave-policy/v1 file: what this reader accepts of
|
|
2167
|
-
an enclave attestation. Where the packs hold a runner's run, an
|
|
2168
|
-
enclave attestation line and a run-account line print beside
|
|
2169
|
-
the witness line (DRAFT); neither moves the verdict or the exit
|
|
2170
|
-
code. Without a policy, or without an adapter for the provider
|
|
2171
|
-
installed, the line reads not_checked.
|
|
2172
|
-
--enclave-rpc
|
|
2173
|
-
a chain JSON-RPC endpoint: a flare-fcc run also gets the online
|
|
2174
|
-
line, read from the contracts the policy names. It moves
|
|
2175
|
-
nothing either.
|
|
2176
1871
|
|
|
2177
1872
|
Files outside every pack are matched too, always, so
|
|
2178
1873
|
sanning-proof case audit-file.zip checks a whole download in one command: each
|
|
@@ -2190,7 +1885,7 @@ kernel and graded as a pack; every record every pack supplied is read, in one
|
|
|
2190
1885
|
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
2191
1886
|
with no counterpart among the records read is always named, and when a pack
|
|
2192
1887
|
signed by the key it names is in the reading, it says that pack holds none,
|
|
2193
|
-
unless every candidate it had was accounted for by another record
|
|
1888
|
+
unless every candidate it had was accounted for by another record,
|
|
2194
1889
|
which reads as no counterpart and names no pack.
|
|
2195
1890
|
|
|
2196
1891
|
What it does not establish: that the set of packs is complete (a party can
|
|
@@ -2201,17 +1896,16 @@ that any witness is on the public record, which the reading never looks up.
|
|
|
2201
1896
|
${EXIT_CODES_HELP}
|
|
2202
1897
|
|
|
2203
1898
|
case: an input that cannot be read as packs, or an unexpected error in this
|
|
2204
|
-
command, exits
|
|
2205
|
-
1
|
|
2206
|
-
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
1899
|
+
command, exits 4. With --gateways, a witness that does not match on chain
|
|
1900
|
+
exits 1; a lookup that could not be settled moves nothing, and the witness line
|
|
1901
|
+
says so.`;
|
|
1902
|
+
// Node writes U+FFFD for a lone surrogate; doing it here makes captured lines
|
|
1903
|
+
// match what a reader sees.
|
|
2209
1904
|
function noLoneSurrogates(text) {
|
|
2210
1905
|
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
|
|
2211
1906
|
}
|
|
2212
|
-
// Keys sorted at every depth, arrays
|
|
2213
|
-
//
|
|
2214
|
-
// that, byte for byte.
|
|
1907
|
+
// Keys sorted at every depth, arrays in order: byte for byte Python's
|
|
1908
|
+
// `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)`.
|
|
2215
1909
|
function sortKeys(v) {
|
|
2216
1910
|
if (Array.isArray(v))
|
|
2217
1911
|
return v.map(sortKeys);
|
|
@@ -2225,56 +1919,30 @@ function sortKeys(v) {
|
|
|
2225
1919
|
function jsonText(v) {
|
|
2226
1920
|
return JSON.stringify(sortKeys(v), null, 2);
|
|
2227
1921
|
}
|
|
2228
|
-
/**
|
|
2229
|
-
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
2230
|
-
* a witness that does not match is a failure whatever the reading said, and an
|
|
2231
|
-
* on-chain half that could not be settled is never a clean 0.
|
|
2232
|
-
*/
|
|
2233
|
-
export function caseExitCode(verdict, onChainVerdict) {
|
|
2234
|
-
const code = exitCodeFor(verdict);
|
|
2235
|
-
if (onChainVerdict === "failed")
|
|
2236
|
-
return EXIT_FAILED;
|
|
2237
|
-
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED)
|
|
2238
|
-
return EXIT_UNDETERMINED;
|
|
2239
|
-
return code;
|
|
2240
|
-
}
|
|
2241
1922
|
const titleOf = (word) => Object.prototype.hasOwnProperty.call(VERDICT_DISPLAY_TITLES, word)
|
|
2242
1923
|
? VERDICT_DISPLAY_TITLES[word]
|
|
2243
1924
|
: word;
|
|
2244
1925
|
/**
|
|
2245
|
-
* The text report, one line per entry, identical in both kernels. The
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
1926
|
+
* The text report, one line per list entry, identical in both kernels. The person
|
|
1927
|
+
* layer comes first (the title, one line per claim that is not proven, the next action,
|
|
1928
|
+
* the notes, the sandbox line and the witness line), then the scope sentence (section
|
|
1929
|
+
* 5.5.6), what a reader may rely on, and the detail.
|
|
2248
1930
|
*/
|
|
2249
1931
|
function caseReport(data) {
|
|
2250
|
-
const lines =
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
lines.push(` ${SANDBOX_LINE}`);
|
|
2254
|
-
lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
|
|
2255
|
-
// Where the packs hold a runner's run: its enclave attestation line and its
|
|
2256
|
-
// run-account line, beside the witness line.
|
|
2257
|
-
const enclave = data.enclave_attestation ?? [];
|
|
2258
|
-
const account = data.run_account ?? [];
|
|
2259
|
-
for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
|
|
2260
|
-
for (const line of enclave[i].rendered)
|
|
2261
|
-
lines.push(` ${line}`);
|
|
2262
|
-
lines.push(` ${account[i].rendered}`);
|
|
2263
|
-
}
|
|
2264
|
-
lines.push(` ${data.reliance}`);
|
|
1932
|
+
const lines = personText(data.person);
|
|
1933
|
+
lines.push("");
|
|
1934
|
+
lines.push(data.summary, data.reliance);
|
|
2265
1935
|
for (const e of data.exceptions)
|
|
2266
1936
|
lines.push(` - ${e.rendered}`);
|
|
2267
|
-
for (const n of data.notes)
|
|
2268
|
-
lines.push(` note: ${n.rendered}`);
|
|
2269
1937
|
lines.push(`packs ${data.packs.length}`);
|
|
2270
1938
|
for (const p of data.packs) {
|
|
2271
1939
|
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
2272
1940
|
const grade = p.grade === null
|
|
2273
1941
|
? "not graded"
|
|
2274
1942
|
: p.grade === "verified" && p.environment_marker !== undefined
|
|
2275
|
-
?
|
|
1943
|
+
? "Verified, sandbox"
|
|
2276
1944
|
: titleOf(p.grade);
|
|
2277
|
-
//
|
|
1945
|
+
// Counted per pack; the JSON lists every record.
|
|
2278
1946
|
const mine = data.records.filter((r) => r.pack_index === p.index);
|
|
2279
1947
|
const count = (status) => mine.filter((r) => r.status === status).length;
|
|
2280
1948
|
const records = `records: ${count("read_clean")} read clean, ${count("read_flagged")} read flagged, ` +
|
|
@@ -2288,6 +1956,16 @@ function caseReport(data) {
|
|
|
2288
1956
|
}
|
|
2289
1957
|
lines.push(line);
|
|
2290
1958
|
}
|
|
1959
|
+
// Every agent record that did not read verified, or carries a finding. A
|
|
1960
|
+
// finding is reported beside its record and moves no verdict.
|
|
1961
|
+
const agents = data.records.filter((r) => r.agent !== null);
|
|
1962
|
+
if (agents.length > 0) {
|
|
1963
|
+
const shown = agents.filter((r) => r.agent.verdict !== "verified" || r.agent.findings.length > 0);
|
|
1964
|
+
lines.push(`agent records ${agents.length} read across every pack, ` +
|
|
1965
|
+
`${agents.length - shown.length} verified with no finding`);
|
|
1966
|
+
for (const r of shown)
|
|
1967
|
+
lines.push(` ${r.pack_ref}/${r.event_id} ${r.agent.rendered}`);
|
|
1968
|
+
}
|
|
2291
1969
|
if (data.outside.length > 0) {
|
|
2292
1970
|
lines.push(`outside ${data.outside.length} file(s) sit outside every ` +
|
|
2293
1971
|
"pack, so no verdict here is about them");
|
|
@@ -2296,9 +1974,6 @@ function caseReport(data) {
|
|
|
2296
1974
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
2297
1975
|
}
|
|
2298
1976
|
}
|
|
2299
|
-
for (const path of data.witness_cache_not_read ?? []) {
|
|
2300
|
-
lines.push(`note ${witnessCacheRefusedNote(path)}`);
|
|
2301
|
-
}
|
|
2302
1977
|
for (const c of data.case_file) {
|
|
2303
1978
|
if (c.index === "absent")
|
|
2304
1979
|
continue;
|
|
@@ -2311,8 +1986,7 @@ function caseReport(data) {
|
|
|
2311
1986
|
}
|
|
2312
1987
|
const oc = data.on_chain;
|
|
2313
1988
|
if (oc.checked) {
|
|
2314
|
-
//
|
|
2315
|
-
// chain, and the header says so rather than "looked up against".
|
|
1989
|
+
// Only simulated witnesses: nothing was looked up on a chain.
|
|
2316
1990
|
const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
|
|
2317
1991
|
const onlySimulated = simulated > 0 &&
|
|
2318
1992
|
oc.packs.every((r) => (r.simulated ?? 0) === r.witnesses && (r.witness_times ?? []).length === 0);
|
|
@@ -2333,12 +2007,10 @@ function caseReport(data) {
|
|
|
2333
2007
|
return lines;
|
|
2334
2008
|
}
|
|
2335
2009
|
/**
|
|
2336
|
-
* The index comparison, once per argument
|
|
2337
|
-
*
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2340
|
-
* larger than what is left is reported as too large to hash, never as a match
|
|
2341
|
-
* and never as absent. It is never an input to the reading.
|
|
2010
|
+
* The index comparison, once per argument with a `contents.json` at its root
|
|
2011
|
+
* (§5.6.3). Files under `packs/` and `report/` are hashed in pieces, sharing a
|
|
2012
|
+
* 1 GiB total in path order; a file past what is left reports as too large,
|
|
2013
|
+
* never as a match or absent. Never an input to the reading.
|
|
2342
2014
|
*/
|
|
2343
2015
|
export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
|
|
2344
2016
|
const out = [];
|
|
@@ -2371,9 +2043,8 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
|
|
|
2371
2043
|
.filter((key) => key.startsWith(prefix) && tree.get(key).regular)
|
|
2372
2044
|
.map((key) => [key, key.slice(prefix.length)])
|
|
2373
2045
|
.filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
|
|
2374
|
-
//
|
|
2375
|
-
//
|
|
2376
|
-
// turn a real mismatch into `too_large`.
|
|
2046
|
+
// Listed files are charged first, so an unlisted file cannot use up the
|
|
2047
|
+
// total and turn a real mismatch into `too_large`.
|
|
2377
2048
|
for (const [key, rel] of [
|
|
2378
2049
|
...candidates.filter(([, rel]) => listed.has(rel)),
|
|
2379
2050
|
...candidates.filter(([, rel]) => !listed.has(rel)),
|
|
@@ -2419,25 +2090,23 @@ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
|
|
|
2419
2090
|
}
|
|
2420
2091
|
return out;
|
|
2421
2092
|
}
|
|
2422
|
-
//
|
|
2423
|
-
// the pack holds an envelope signed `dev`.
|
|
2093
|
+
// The pack's environment marker, present only for a pack with a `dev` envelope.
|
|
2424
2094
|
function packMarker(bundle) {
|
|
2425
2095
|
const m = environmentMarker([bundle]);
|
|
2426
2096
|
return m !== null ? { environment_marker: m } : {};
|
|
2427
2097
|
}
|
|
2428
2098
|
function caseError(e, asJson, io) {
|
|
2429
2099
|
if (asJson)
|
|
2430
|
-
outJson(io, jsonText({ error: e.message, exit_code:
|
|
2100
|
+
outJson(io, jsonText({ error: e.message, exit_code: EXIT_COULD_NOT_RUN, hint: e.hint }));
|
|
2431
2101
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
2432
2102
|
if (e.hint)
|
|
2433
2103
|
io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
2434
|
-
return
|
|
2104
|
+
return EXIT_COULD_NOT_RUN;
|
|
2435
2105
|
}
|
|
2436
2106
|
/**
|
|
2437
|
-
* `proof case <folder>... [--gateways <urls>] [--json]`.
|
|
2438
|
-
*
|
|
2439
|
-
*
|
|
2440
|
-
* command.
|
|
2107
|
+
* `proof case <folder>... [--gateways <urls>] [--json]`. An unexpected
|
|
2108
|
+
* exception exits 4, never 1: 1 would call the evidence failed over a defect in
|
|
2109
|
+
* this command.
|
|
2441
2110
|
*/
|
|
2442
2111
|
export async function runCase(argv, stream) {
|
|
2443
2112
|
const io = guard(stream);
|
|
@@ -2451,25 +2120,15 @@ export async function runCase(argv, stream) {
|
|
|
2451
2120
|
"command, not a verdict on the evidence"), argv.includes("--json"), io);
|
|
2452
2121
|
}
|
|
2453
2122
|
catch {
|
|
2454
|
-
return
|
|
2123
|
+
return EXIT_COULD_NOT_RUN; // the stream cannot be written
|
|
2455
2124
|
}
|
|
2456
2125
|
}
|
|
2457
2126
|
}
|
|
2458
|
-
// The files
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
//
|
|
2462
|
-
//
|
|
2463
|
-
// `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
|
|
2464
|
-
// name each file alike. A link, a pipe, an entry the zip reader refused (with
|
|
2465
|
-
// its own words) and a file that cannot be read are returned as skipped, named
|
|
2466
|
-
// and never opened as a pack file. A pack's own files, `bundle.json` and what
|
|
2467
|
-
// is under `logs/`, are not outside and are not offered.
|
|
2468
|
-
//
|
|
2469
|
-
// The files share one total of 1 GiB, the cap the zip reader puts on an
|
|
2470
|
-
// archive, charged in that order. A file larger than what is left is skipped as
|
|
2471
|
-
// `too_large` and read no further, so a sparse file of a terabyte in a
|
|
2472
|
-
// downloaded folder cannot keep `case` from reaching its verdict.
|
|
2127
|
+
// The files outside every pack, as files offered (§5.7), in split order. Each
|
|
2128
|
+
// is hashed as a `--files` file is and named `<case name>/<path inside>`, so a
|
|
2129
|
+
// `.zip` and its unzipped folder name files alike. Links, pipes, refused zip
|
|
2130
|
+
// entries and unreadable files are returned as skipped. They share the 1 GiB
|
|
2131
|
+
// total; a file past it is `too_large` and read no further.
|
|
2473
2132
|
export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
|
|
2474
2133
|
const offered = [];
|
|
2475
2134
|
const skipped = [];
|
|
@@ -2509,69 +2168,11 @@ export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
|
|
|
2509
2168
|
}
|
|
2510
2169
|
return { offered, skipped };
|
|
2511
2170
|
}
|
|
2512
|
-
// A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
|
|
2513
|
-
const POLICY_NOT_JSON = Symbol("policy not JSON");
|
|
2514
|
-
async function readEnclavePolicy(path) {
|
|
2515
|
-
if (path === null)
|
|
2516
|
-
return null;
|
|
2517
|
-
let raw;
|
|
2518
|
-
try {
|
|
2519
|
-
if (!(await stat(path)).isFile())
|
|
2520
|
-
throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
|
|
2521
|
-
raw = new Uint8Array(await readFile(path));
|
|
2522
|
-
}
|
|
2523
|
-
catch (e) {
|
|
2524
|
-
if (e instanceof CaseInputError)
|
|
2525
|
-
throw e;
|
|
2526
|
-
throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
|
|
2527
|
-
}
|
|
2528
|
-
try {
|
|
2529
|
-
return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
|
|
2530
|
-
}
|
|
2531
|
-
catch {
|
|
2532
|
-
return POLICY_NOT_JSON;
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
/** The two line members of `--json`: one entry per run, with the lines it prints. */
|
|
2536
|
-
function runLines(runs) {
|
|
2537
|
-
const enclave = runs.map((r) => ({
|
|
2538
|
-
pack_ref: r.packRef,
|
|
2539
|
-
pack_index: r.packIndex,
|
|
2540
|
-
event_id: r.eventId,
|
|
2541
|
-
runner_public_key: r.runnerPublicKey,
|
|
2542
|
-
agent_public_key: r.agentPublicKey,
|
|
2543
|
-
state: r.enclave.state,
|
|
2544
|
-
kinds: r.enclave.kinds,
|
|
2545
|
-
notes: r.enclave.notes,
|
|
2546
|
-
reason: r.enclave.reason,
|
|
2547
|
-
provider: r.enclave.provider,
|
|
2548
|
-
evaluation: r.enclave.evaluation,
|
|
2549
|
-
evaluated_at: r.enclave.evaluatedAt,
|
|
2550
|
-
token_checked: r.enclave.tokenChecked,
|
|
2551
|
-
online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
|
|
2552
|
-
rendered: renderEnclaveLines(r).map(escapeControl),
|
|
2553
|
-
}));
|
|
2554
|
-
const account = runs.map((r) => ({
|
|
2555
|
-
pack_ref: r.packRef,
|
|
2556
|
-
pack_index: r.packIndex,
|
|
2557
|
-
event_id: r.eventId,
|
|
2558
|
-
runner_public_key: r.runnerPublicKey,
|
|
2559
|
-
state: r.account.state,
|
|
2560
|
-
findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
|
|
2561
|
-
notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
|
|
2562
|
-
closing_event_id: r.account.closingEventId,
|
|
2563
|
-
reason: r.account.reason,
|
|
2564
|
-
rendered: escapeControl(renderAccountLine(r)),
|
|
2565
|
-
}));
|
|
2566
|
-
return [enclave, account];
|
|
2567
|
-
}
|
|
2568
2171
|
async function caseCommand(argv, io) {
|
|
2569
2172
|
let asJson = false;
|
|
2570
2173
|
let gateways = [];
|
|
2571
2174
|
const paths = [];
|
|
2572
2175
|
let filePaths;
|
|
2573
|
-
let enclavePolicy = null;
|
|
2574
|
-
let enclaveRpc = null;
|
|
2575
2176
|
for (let i = 0; i < argv.length; i++) {
|
|
2576
2177
|
const arg = argv[i];
|
|
2577
2178
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
@@ -2586,7 +2187,7 @@ async function caseCommand(argv, io) {
|
|
|
2586
2187
|
if (next === undefined) {
|
|
2587
2188
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
2588
2189
|
outBlock(io, CASE_USAGE);
|
|
2589
|
-
return
|
|
2190
|
+
return EXIT_COULD_NOT_RUN;
|
|
2590
2191
|
}
|
|
2591
2192
|
gateways = next
|
|
2592
2193
|
.split(",")
|
|
@@ -2599,28 +2200,15 @@ async function caseCommand(argv, io) {
|
|
|
2599
2200
|
if (taken.length === 0) {
|
|
2600
2201
|
io.err("case: --files requires at least one file path");
|
|
2601
2202
|
outBlock(io, CASE_USAGE);
|
|
2602
|
-
return
|
|
2203
|
+
return EXIT_COULD_NOT_RUN;
|
|
2603
2204
|
}
|
|
2604
2205
|
filePaths = [...(filePaths ?? []), ...taken];
|
|
2605
2206
|
i += taken.length;
|
|
2606
2207
|
}
|
|
2607
|
-
else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
|
|
2608
|
-
const next = argv[i + 1];
|
|
2609
|
-
if (next === undefined) {
|
|
2610
|
-
io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
|
|
2611
|
-
outBlock(io, CASE_USAGE);
|
|
2612
|
-
return EXIT_MALFORMED;
|
|
2613
|
-
}
|
|
2614
|
-
if (arg === "--enclave-policy")
|
|
2615
|
-
enclavePolicy = next;
|
|
2616
|
-
else
|
|
2617
|
-
enclaveRpc = next;
|
|
2618
|
-
i++;
|
|
2619
|
-
}
|
|
2620
2208
|
else if (arg.startsWith("--")) {
|
|
2621
2209
|
io.err(`case: unknown option ${arg}`);
|
|
2622
2210
|
outBlock(io, CASE_USAGE);
|
|
2623
|
-
return
|
|
2211
|
+
return EXIT_COULD_NOT_RUN;
|
|
2624
2212
|
}
|
|
2625
2213
|
else {
|
|
2626
2214
|
paths.push(arg);
|
|
@@ -2629,7 +2217,7 @@ async function caseCommand(argv, io) {
|
|
|
2629
2217
|
if (paths.length === 0) {
|
|
2630
2218
|
io.err("case: at least one pack is required");
|
|
2631
2219
|
outBlock(io, CASE_USAGE);
|
|
2632
|
-
return
|
|
2220
|
+
return EXIT_COULD_NOT_RUN;
|
|
2633
2221
|
}
|
|
2634
2222
|
let offered;
|
|
2635
2223
|
let skipped = [];
|
|
@@ -2640,30 +2228,22 @@ async function caseCommand(argv, io) {
|
|
|
2640
2228
|
offered = read.offered;
|
|
2641
2229
|
skipped = read.skipped;
|
|
2642
2230
|
}
|
|
2643
|
-
let policy;
|
|
2644
|
-
try {
|
|
2645
|
-
policy = await readEnclavePolicy(enclavePolicy);
|
|
2646
|
-
}
|
|
2647
|
-
catch (e) {
|
|
2648
|
-
if (e instanceof CaseInputError)
|
|
2649
|
-
return caseError(e, asJson, io);
|
|
2650
|
-
throw e;
|
|
2651
|
-
}
|
|
2652
2231
|
const whole = { n: MAX_FOLDER_BYTES };
|
|
2653
2232
|
let split;
|
|
2654
2233
|
let tree;
|
|
2655
|
-
let
|
|
2234
|
+
let loaded;
|
|
2656
2235
|
try {
|
|
2657
2236
|
tree = await treeFromArguments(paths);
|
|
2658
2237
|
split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
|
|
2659
|
-
|
|
2238
|
+
loaded = split.packs.map((p) => [p, loadBundle(p)]);
|
|
2660
2239
|
}
|
|
2661
2240
|
catch (e) {
|
|
2662
2241
|
if (e instanceof CaseInputError)
|
|
2663
2242
|
return caseError(e, asJson, io);
|
|
2664
2243
|
throw e;
|
|
2665
2244
|
}
|
|
2666
|
-
const
|
|
2245
|
+
const bundles = loaded.map(([p, [bundle]]) => [p, bundle]);
|
|
2246
|
+
const reading = await readCase(loaded.map(([p, [bundle, why]]) => ({ ref: p.ref, bundle, content: p.content, unreadable: why })));
|
|
2667
2247
|
let caseFile;
|
|
2668
2248
|
try {
|
|
2669
2249
|
caseFile = await caseFileReadings(tree, whole);
|
|
@@ -2678,25 +2258,63 @@ async function caseCommand(argv, io) {
|
|
|
2678
2258
|
for (const [p, bundle] of bundles) {
|
|
2679
2259
|
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
|
|
2680
2260
|
}
|
|
2681
|
-
const
|
|
2682
|
-
const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) }, confirmedStamps);
|
|
2261
|
+
const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
|
|
2683
2262
|
const witness = witnessLine(onChain, caches.flat());
|
|
2684
|
-
// Sandbox evidence
|
|
2685
|
-
//
|
|
2263
|
+
// Sandbox evidence, over every pack and per pack for its row. A label, never a
|
|
2264
|
+
// verdict: a case that verifies over a `dev` record exits 3.
|
|
2686
2265
|
const marker = environmentMarker(bundles.map(([, b]) => b));
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2266
|
+
// The lookup is outside the reading (it makes network calls the reading never does).
|
|
2267
|
+
// Only a lookup that finds other bytes, or another time, disproves `witnessed`.
|
|
2268
|
+
const problems = [...reading.problems];
|
|
2269
|
+
onChain.packs.forEach((row, index) => {
|
|
2270
|
+
if (row.mismatched > 0)
|
|
2271
|
+
problems.push(problem("witness_differs", [[index, null]]));
|
|
2272
|
+
if ((row.witness_times ?? []).some((t) => t.outcome === "differs")) {
|
|
2273
|
+
problems.push(problem("witness_time_differs", [[index, null]]));
|
|
2274
|
+
}
|
|
2275
|
+
});
|
|
2276
|
+
const inScope = [];
|
|
2277
|
+
const notInScope = {};
|
|
2278
|
+
if (reading.handoffRecords > 0 || reading.packs.some((p) => p.declarationVersion !== null)) {
|
|
2279
|
+
inScope.push("complete");
|
|
2280
|
+
}
|
|
2281
|
+
else {
|
|
2282
|
+
notInScope.complete = "no_declaration_and_no_handoff";
|
|
2283
|
+
}
|
|
2284
|
+
if (reading.handoffRecords > 0)
|
|
2285
|
+
inScope.push("agreed");
|
|
2286
|
+
else
|
|
2287
|
+
notInScope.agreed = "no_handoff";
|
|
2288
|
+
if (reading.handoffRecords > 0 || reading.records.some((r) => r.agent !== null)) {
|
|
2289
|
+
inScope.push("follows_rules");
|
|
2290
|
+
}
|
|
2291
|
+
else {
|
|
2292
|
+
notInScope.follows_rules = "no_agent_or_handoff_records";
|
|
2293
|
+
}
|
|
2294
|
+
const packRefs = reading.packs.map((p) => p.ref);
|
|
2295
|
+
const recordIds = new Map(reading.records.map((r) => [`${r.packIndex}/${r.recordIndex}`, r.eventId]));
|
|
2296
|
+
const claims = claimsOf(problems, inScope, {
|
|
2297
|
+
notInScope,
|
|
2298
|
+
labels: (pack, record) => [
|
|
2299
|
+
pack !== null ? (packRefs[pack] ?? null) : null,
|
|
2300
|
+
recordIds.get(`${pack}/${record}`) ?? null,
|
|
2301
|
+
],
|
|
2302
|
+
});
|
|
2303
|
+
const verdict = verdictOf(claims);
|
|
2304
|
+
const code = exitCodeFor(verdict.status, marker);
|
|
2305
|
+
const notes = reading.notes.map((n) => note(n.kind, renderCaseNote(n)));
|
|
2306
|
+
notes.push(...findingNotes(reading.records.map((r) => r.agent)));
|
|
2307
|
+
notes.push(...caseFile.flatMap((c) => c.findings.map((f) => note(f.kind, f.rendered))));
|
|
2308
|
+
notes.push(...cacheRefused.map((p) => note("witness_cache_refused", witnessCacheRefusedNote(p))));
|
|
2309
|
+
const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
|
|
2695
2310
|
const nil = (v) => (v === undefined ? null : v);
|
|
2696
2311
|
const data = {
|
|
2697
|
-
verdict
|
|
2698
|
-
title:
|
|
2699
|
-
reliance: caseReliance(
|
|
2312
|
+
verdict,
|
|
2313
|
+
title: titleOf(verdict.status),
|
|
2314
|
+
reliance: caseReliance(verdict.status, reading.handoffRecords, marker, verdict.reason_kind ?? null),
|
|
2315
|
+
claims,
|
|
2316
|
+
person,
|
|
2317
|
+
sandbox: marker !== null,
|
|
2700
2318
|
exit_code: code,
|
|
2701
2319
|
summary: renderCaseSummary(reading),
|
|
2702
2320
|
witness: {
|
|
@@ -2743,6 +2361,7 @@ async function caseCommand(argv, io) {
|
|
|
2743
2361
|
findings: r.findings,
|
|
2744
2362
|
failed_checks: r.failedChecks,
|
|
2745
2363
|
counterparts: r.counterparts,
|
|
2364
|
+
agent: r.agent === null ? null : agentJson(r.agent),
|
|
2746
2365
|
})),
|
|
2747
2366
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
2748
2367
|
packs: bundles.map(([p], i) => ({
|
|
@@ -2764,20 +2383,9 @@ async function caseCommand(argv, io) {
|
|
|
2764
2383
|
...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
|
|
2765
2384
|
...(marker !== null ? { environment_marker: marker } : {}),
|
|
2766
2385
|
};
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
// no run prints and writes exactly what it did before.
|
|
2771
|
-
const runs = (await readRuns(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), { policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps })).runs;
|
|
2772
|
-
const withRuns = data;
|
|
2773
|
-
if (runs.length > 0)
|
|
2774
|
-
[withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
|
|
2775
|
-
// The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
|
|
2776
|
-
// then the files after --files. A pack's logs/ is its side content and is
|
|
2777
|
-
// never an offered file; only the paths after --files are. Both are matched
|
|
2778
|
-
// by SHA-256 alone and nothing here moves `code`. The section is printed when
|
|
2779
|
-
// there is a file to say something about, so a case with nothing outside
|
|
2780
|
-
// every pack and no --files prints what it always did.
|
|
2386
|
+
// Files outside every pack, then the `--files` paths (evidence-bundle.md
|
|
2387
|
+
// §5.7). A pack's `logs/` is never offered. Matched by SHA-256 alone; nothing
|
|
2388
|
+
// here moves `code`. Printed only when there is something to say.
|
|
2781
2389
|
const loose = await looseFiles(tree, split);
|
|
2782
2390
|
let fileLines = [];
|
|
2783
2391
|
const out = data;
|
|
@@ -2787,9 +2395,8 @@ async function caseCommand(argv, io) {
|
|
|
2787
2395
|
skipped: [...loose.skipped, ...skipped],
|
|
2788
2396
|
};
|
|
2789
2397
|
out.files = fileMatchToJson(fileReading);
|
|
2790
|
-
//
|
|
2791
|
-
//
|
|
2792
|
-
// disclose and bind. With --files, and in --json, every one is listed.
|
|
2398
|
+
// Without --files, one count line for unmatched recorded files; with
|
|
2399
|
+
// --files or --json, every one is listed.
|
|
2793
2400
|
fileLines = renderFileMatch(fileReading, offered !== undefined
|
|
2794
2401
|
? undefined
|
|
2795
2402
|
: (await notAccountedFor(fileReading, new Map(bundles.map(([p]) => [p.ref, p.content])))).length);
|
|
@@ -2801,24 +2408,13 @@ async function caseCommand(argv, io) {
|
|
|
2801
2408
|
io.out(noLoneSurrogates(line));
|
|
2802
2409
|
return code;
|
|
2803
2410
|
}
|
|
2804
|
-
|
|
2805
|
-
if (tx.length <= 16)
|
|
2806
|
-
return tx;
|
|
2807
|
-
return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
|
|
2808
|
-
}
|
|
2809
|
-
function shortId(id) {
|
|
2810
|
-
if (id.length <= 12)
|
|
2811
|
-
return id;
|
|
2812
|
-
return `${id.slice(0, 8)}…`;
|
|
2813
|
-
}
|
|
2814
|
-
// Entry point when run as a binary (not when imported by tests).
|
|
2411
|
+
// Entry point when run as a binary, not when imported.
|
|
2815
2412
|
const isMain = (() => {
|
|
2816
2413
|
try {
|
|
2817
2414
|
if (!process.argv[1])
|
|
2818
2415
|
return false;
|
|
2819
|
-
//
|
|
2820
|
-
//
|
|
2821
|
-
// so a naive compare skips main(). Resolving both to the real path makes them match.
|
|
2416
|
+
// Resolve both sides: under a bin symlink, argv[1] is the link and
|
|
2417
|
+
// import.meta.url the target.
|
|
2822
2418
|
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
2823
2419
|
}
|
|
2824
2420
|
catch {
|
|
@@ -2835,6 +2431,6 @@ if (isMain) {
|
|
|
2835
2431
|
})
|
|
2836
2432
|
.catch((e) => {
|
|
2837
2433
|
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
2838
|
-
process.exitCode =
|
|
2434
|
+
process.exitCode = EXIT_COULD_NOT_RUN;
|
|
2839
2435
|
});
|
|
2840
2436
|
}
|