@sanning/proof 0.19.0 → 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 +343 -810
- package/dist/case.js +693 -1151
- 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 +848 -1322
- 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 +132 -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 +597 -580
- 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/src/cli.ts
CHANGED
|
@@ -1,48 +1,18 @@
|
|
|
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
|
|
|
47
17
|
import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
48
18
|
import { constants as fsConstants, realpathSync } from "node:fs";
|
|
@@ -50,12 +20,11 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
50
20
|
import { basename, resolve, sep } from "node:path";
|
|
51
21
|
import { fileURLToPath } from "node:url";
|
|
52
22
|
|
|
53
|
-
import {
|
|
54
|
-
|
|
23
|
+
import { renderAgentReading, type AgentRecordReading } from "./agent-record.js";
|
|
24
|
+
|
|
55
25
|
import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
|
|
56
26
|
|
|
57
|
-
//
|
|
58
|
-
// named statement that one could not be produced. Three states, not two.
|
|
27
|
+
// The reading, or a named statement that one could not be produced.
|
|
59
28
|
type ChainOutcome = ChainReading | { unreadable: string };
|
|
60
29
|
import type { ChainLink, ChainReading } from "./chain.js";
|
|
61
30
|
import {
|
|
@@ -66,6 +35,7 @@ import {
|
|
|
66
35
|
renderCaseSummary,
|
|
67
36
|
renderException,
|
|
68
37
|
renderWitnessLine,
|
|
38
|
+
type WitnessLine,
|
|
69
39
|
} from "./case.js";
|
|
70
40
|
import {
|
|
71
41
|
CASE_FILE_INDEX_NAME,
|
|
@@ -80,9 +50,6 @@ import { composeExport } from "./compose.js";
|
|
|
80
50
|
import type { ComposeExportOptions } from "./compose.js";
|
|
81
51
|
import { isHexField, sha256Hex, utf8 } from "./crypto.js";
|
|
82
52
|
import { verifyEvidenceBundle } from "./evidence.js";
|
|
83
|
-
import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
|
|
84
|
-
import type { RunReading } from "./run.js";
|
|
85
|
-
import { loadInstalledAdapters } from "./run-adapters.js";
|
|
86
53
|
import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
|
|
87
54
|
import {
|
|
88
55
|
fileMatchToJson,
|
|
@@ -97,36 +64,46 @@ import {
|
|
|
97
64
|
WITNESS_CACHE_NAME,
|
|
98
65
|
readWitnessCache,
|
|
99
66
|
} from "./witness-cache.js";
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
67
|
+
import {
|
|
68
|
+
type OnChainHalf,
|
|
69
|
+
onChainHalf,
|
|
70
|
+
onChainOutcome,
|
|
71
|
+
onChainRow,
|
|
72
|
+
witnessLine,
|
|
73
|
+
} from "./witness-lookup.js";
|
|
74
|
+
import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult } from "./evidence.js";
|
|
107
75
|
import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
|
|
108
76
|
import { completenessNotes, gradePack, renderCause } from "./grading.js";
|
|
109
|
-
import type { DisclosureAccount, PackGrade } from "./grading.js";
|
|
77
|
+
import type { DisclosureAccount, PackGrade, PackGradeCause } from "./grading.js";
|
|
110
78
|
|
|
111
79
|
import {
|
|
112
80
|
EXIT_CODES_HELP,
|
|
81
|
+
EXIT_COULD_NOT_RUN,
|
|
113
82
|
EXIT_FAILED,
|
|
114
|
-
EXIT_MALFORMED,
|
|
115
|
-
EXIT_SANDBOX,
|
|
116
|
-
EXIT_UNDETERMINED,
|
|
117
83
|
EXIT_VERIFIED,
|
|
118
84
|
exitCodeFor,
|
|
119
|
-
sandboxExit,
|
|
120
85
|
} from "./exit-codes.js";
|
|
86
|
+
import { type EnvironmentMarker, environmentMarker, simulatedOnlyRun } from "./environment.js";
|
|
121
87
|
import {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
88
|
+
VERDICT_TITLES,
|
|
89
|
+
bundleProblems,
|
|
90
|
+
claimsOf,
|
|
91
|
+
note,
|
|
92
|
+
type NoteEntry,
|
|
93
|
+
type Claim,
|
|
94
|
+
type NotInScopeReason,
|
|
95
|
+
orderedNotes,
|
|
96
|
+
personText,
|
|
97
|
+
problem,
|
|
98
|
+
renderFindingNote,
|
|
99
|
+
renderPerson,
|
|
100
|
+
restOnSigned,
|
|
101
|
+
verdictOf,
|
|
102
|
+
type ClaimEntry,
|
|
103
|
+
type Person,
|
|
104
|
+
type Problem,
|
|
105
|
+
type VerdictJson,
|
|
106
|
+
} from "./claims.js";
|
|
130
107
|
|
|
131
108
|
interface Cli {
|
|
132
109
|
out: (s: string) => void;
|
|
@@ -134,25 +111,20 @@ interface Cli {
|
|
|
134
111
|
fetchImpl?: typeof fetch;
|
|
135
112
|
}
|
|
136
113
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// name, a `contents.json` field, a zip entry name, an exception built from
|
|
143
|
-
// one) cannot move the cursor, clear the screen or reorder the line. Before
|
|
144
|
-
// this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
|
|
145
|
-
// a FAILED headline. `outJson` is the one way around it, and it carries JSON
|
|
146
|
-
// only, which escapes its own control characters. The twin is `Io` in
|
|
147
|
-
// `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
|
|
114
|
+
// Every human-readable line passes through `guard`, which escapes control
|
|
115
|
+
// characters, so a value the audited party wrote (a file name, a zip entry, a
|
|
116
|
+
// field) cannot move the cursor or overwrite a headline. `outJson` is the one
|
|
117
|
+
// way around it, and JSON escapes its own control characters. Python's `Io` in
|
|
118
|
+
// `cli.py` prints byte-identical escapes.
|
|
148
119
|
|
|
149
120
|
// C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
|
|
150
121
|
// embedding, override and isolate controls.
|
|
151
122
|
const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
152
123
|
|
|
153
|
-
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
124
|
+
/**
|
|
125
|
+
* Each control character in `text` as a visible escape (`\x1b`, ``).
|
|
126
|
+
* Every other character, a backslash included, is unchanged.
|
|
127
|
+
*/
|
|
156
128
|
export function escapeControl(text: string): string {
|
|
157
129
|
return text.replace(CONTROL_CHARACTERS, (c) => {
|
|
158
130
|
const code = c.charCodeAt(0);
|
|
@@ -162,16 +134,12 @@ export function escapeControl(text: string): string {
|
|
|
162
134
|
});
|
|
163
135
|
}
|
|
164
136
|
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// after escaping. It is random per process, so no input can spell it.
|
|
137
|
+
// `paint` and `mark` write this marker instead of ESC, so the escaping choke
|
|
138
|
+
// point does not escape our own colors; it becomes ESC after escaping. Random
|
|
139
|
+
// per process, so no input can spell it.
|
|
169
140
|
const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
|
|
170
141
|
const G = `${COLOR_MARK}32m`;
|
|
171
142
|
const R = `${COLOR_MARK}31m`;
|
|
172
|
-
const Y = `${COLOR_MARK}33m`;
|
|
173
|
-
const DIM = `${COLOR_MARK}2m`;
|
|
174
|
-
const B = `${COLOR_MARK}1m`;
|
|
175
143
|
const X = `${COLOR_MARK}0m`;
|
|
176
144
|
|
|
177
145
|
function forTerminal(text: string): string {
|
|
@@ -201,8 +169,7 @@ function outJson(io: Cli, text: string): void {
|
|
|
201
169
|
rawOf(io).out(text);
|
|
202
170
|
}
|
|
203
171
|
|
|
204
|
-
/** A block this command wrote itself
|
|
205
|
-
* are kept, and each line is escaped as `out` escapes it. */
|
|
172
|
+
/** A block this command wrote itself: its newlines are kept, each line escaped. */
|
|
206
173
|
function outBlock(io: Cli, text: string): void {
|
|
207
174
|
rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
|
|
208
175
|
}
|
|
@@ -213,13 +180,6 @@ function useColor(): boolean {
|
|
|
213
180
|
return Boolean(process.stdout.isTTY);
|
|
214
181
|
}
|
|
215
182
|
|
|
216
|
-
function mark(ok: boolean | null): string {
|
|
217
|
-
const c = useColor();
|
|
218
|
-
if (ok === null) return c ? `${Y}~${X}` : "~";
|
|
219
|
-
if (ok) return c ? `${G}✓${X}` : "✓";
|
|
220
|
-
return c ? `${R}✗${X}` : "✗";
|
|
221
|
-
}
|
|
222
|
-
|
|
223
183
|
function paint(s: string, c: string): string {
|
|
224
184
|
return useColor() ? `${c}${s}${X}` : s;
|
|
225
185
|
}
|
|
@@ -230,16 +190,13 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
|
|
|
230
190
|
|
|
231
191
|
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
232
192
|
printUsage(io);
|
|
233
|
-
return command === undefined ?
|
|
193
|
+
return command === undefined ? EXIT_COULD_NOT_RUN : EXIT_VERIFIED;
|
|
234
194
|
}
|
|
235
195
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
236
196
|
io.out("@sanning/proof verify CLI");
|
|
237
197
|
return EXIT_VERIFIED;
|
|
238
198
|
}
|
|
239
|
-
// `export` composes an attested export (`sanning.evidence.export/v2`).
|
|
240
|
-
// was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
|
|
241
|
-
// builds a pack (`anchor bundle`, DL-59), so this command no longer answers
|
|
242
|
-
// to it: `proof bundle` is an unknown command and exits 2, with no alias.
|
|
199
|
+
// `export` composes an attested export (`sanning.evidence.export/v2`).
|
|
243
200
|
if (command === "export") {
|
|
244
201
|
if (rest.includes("--json")) {
|
|
245
202
|
return runJson(
|
|
@@ -256,7 +213,7 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
|
|
|
256
213
|
if (command !== "verify") {
|
|
257
214
|
io.err(`unknown command: ${command}`);
|
|
258
215
|
printUsage(io);
|
|
259
|
-
return
|
|
216
|
+
return EXIT_COULD_NOT_RUN;
|
|
260
217
|
}
|
|
261
218
|
if (rest.includes("--json")) {
|
|
262
219
|
return runJson(
|
|
@@ -268,16 +225,10 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
|
|
|
268
225
|
return runVerify(rest, io, null);
|
|
269
226
|
}
|
|
270
227
|
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// does for a person, with its text discarded, and hands this one object back
|
|
276
|
-
// through `sink`: the same keys in both kernels, sorted at every depth, and
|
|
277
|
-
// printed byte for byte the same (`cross-kernel/run_cli.sh`,
|
|
278
|
-
// `cross-kernel/run_export_cli.sh`). A run that stops before it has anything to
|
|
279
|
-
// report (a bad flag, an unreadable file) prints `{ error, exit_code }`, as
|
|
280
|
-
// `case --json` does. `note:` lines still go to stderr.
|
|
228
|
+
// `--json` on `verify` and `export`: the verb runs as for a person with its
|
|
229
|
+
// text discarded, and hands one object back through `sink`. Same keys in both
|
|
230
|
+
// kernels, sorted at every depth, printed byte for byte the same. A run that
|
|
231
|
+
// stops early prints `{ error, exit_code }`. `note:` lines still go to stderr.
|
|
281
232
|
interface JsonSink {
|
|
282
233
|
data: Record<string, unknown> | null;
|
|
283
234
|
}
|
|
@@ -306,23 +257,25 @@ async function runJson(rest: string[], io: Cli, verb: JsonVerb): Promise<number>
|
|
|
306
257
|
|
|
307
258
|
async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
308
259
|
|
|
309
|
-
// `--logs <path>`
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// second positional argument until then, and a second positional is now a
|
|
313
|
-
// usage error). The one positional is the bundle.
|
|
260
|
+
// `--logs <path>` supplies disclosed raw-log bytes (evidence-bundle path
|
|
261
|
+
// only); `--gateways <url,url>` asks for the on-chain re-fetch. The one
|
|
262
|
+
// positional is the bundle.
|
|
314
263
|
let logsPath: string | undefined;
|
|
315
264
|
let filePaths: string[] | undefined;
|
|
316
265
|
let gatewayArg: string | undefined;
|
|
317
266
|
const positional: string[] = [];
|
|
318
267
|
for (let i = 0; i < rest.length; i++) {
|
|
319
268
|
const a = rest[i]!;
|
|
269
|
+
if ((a === "--help" || a === "-h") && positional.length === 0) {
|
|
270
|
+
printUsage(io);
|
|
271
|
+
return EXIT_VERIFIED;
|
|
272
|
+
}
|
|
320
273
|
if (a === "--gateways") {
|
|
321
274
|
const next = rest[i + 1];
|
|
322
275
|
if (next === undefined) {
|
|
323
276
|
io.err("verify: --gateways requires a comma-separated list of URLs");
|
|
324
277
|
printUsage(io);
|
|
325
|
-
return
|
|
278
|
+
return EXIT_COULD_NOT_RUN;
|
|
326
279
|
}
|
|
327
280
|
gatewayArg = next;
|
|
328
281
|
i++;
|
|
@@ -333,7 +286,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
333
286
|
if (next === undefined) {
|
|
334
287
|
io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
|
|
335
288
|
printUsage(io);
|
|
336
|
-
return
|
|
289
|
+
return EXIT_COULD_NOT_RUN;
|
|
337
290
|
}
|
|
338
291
|
logsPath = next;
|
|
339
292
|
i++;
|
|
@@ -344,7 +297,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
344
297
|
if (taken.length === 0) {
|
|
345
298
|
io.err("verify: --files requires at least one file path");
|
|
346
299
|
printUsage(io);
|
|
347
|
-
return
|
|
300
|
+
return EXIT_COULD_NOT_RUN;
|
|
348
301
|
}
|
|
349
302
|
filePaths = [...(filePaths ?? []), ...taken];
|
|
350
303
|
i += taken.length;
|
|
@@ -357,12 +310,12 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
357
310
|
if (!bundlePath) {
|
|
358
311
|
io.err("verify: a bundle file path is required");
|
|
359
312
|
printUsage(io);
|
|
360
|
-
return
|
|
313
|
+
return EXIT_COULD_NOT_RUN;
|
|
361
314
|
}
|
|
362
315
|
if (positional.length > 1) {
|
|
363
316
|
io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
|
|
364
317
|
printUsage(io);
|
|
365
|
-
return
|
|
318
|
+
return EXIT_COULD_NOT_RUN;
|
|
366
319
|
}
|
|
367
320
|
const gateways = (gatewayArg ?? "")
|
|
368
321
|
.split(",")
|
|
@@ -372,7 +325,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
372
325
|
let logs: LoadedLogs | undefined;
|
|
373
326
|
if (logsPath !== undefined) {
|
|
374
327
|
logs = await loadLogs(logsPath, io);
|
|
375
|
-
if (logs === undefined) return
|
|
328
|
+
if (logs === undefined) return EXIT_COULD_NOT_RUN;
|
|
376
329
|
for (const n of logs.notes) io.err(`note: ${n}`);
|
|
377
330
|
}
|
|
378
331
|
const content = logs?.content;
|
|
@@ -383,7 +336,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
383
336
|
const read = await readOfferedFiles(filePaths);
|
|
384
337
|
if (typeof read === "string") {
|
|
385
338
|
io.err(`verify: ${read}`);
|
|
386
|
-
return
|
|
339
|
+
return EXIT_COULD_NOT_RUN;
|
|
387
340
|
}
|
|
388
341
|
offered = read.offered;
|
|
389
342
|
skipped = read.skipped;
|
|
@@ -394,12 +347,10 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
394
347
|
raw = new Uint8Array(await readFile(bundlePath));
|
|
395
348
|
} catch (e) {
|
|
396
349
|
io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
397
|
-
return
|
|
350
|
+
return EXIT_COULD_NOT_RUN;
|
|
398
351
|
}
|
|
399
352
|
|
|
400
|
-
// The
|
|
401
|
-
// bundle.json through it too, so the two commands classify one bundle the
|
|
402
|
-
// same way (see `readBundleBytes`).
|
|
353
|
+
// The same reader `case` uses, so both commands classify a bundle alike.
|
|
403
354
|
let parsed: unknown;
|
|
404
355
|
try {
|
|
405
356
|
parsed = readBundleBytes(raw);
|
|
@@ -411,79 +362,62 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
411
362
|
} else {
|
|
412
363
|
io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
413
364
|
}
|
|
414
|
-
return
|
|
365
|
+
return EXIT_COULD_NOT_RUN;
|
|
415
366
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
: undefined;
|
|
421
|
-
|
|
422
|
-
if (isAgentProofSpec(specVersion)) {
|
|
423
|
-
if (content) {
|
|
424
|
-
// --logs binds raw logs to anchor-trace events; an agent inclusion proof
|
|
425
|
-
// has no such per-event content. Say so rather than silently ignoring it.
|
|
426
|
-
io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
427
|
-
}
|
|
428
|
-
if (offered) {
|
|
429
|
-
io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
|
|
430
|
-
}
|
|
431
|
-
const result = await verifyAgentProof(parsed, {
|
|
432
|
-
gateways,
|
|
433
|
-
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
434
|
-
});
|
|
435
|
-
return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
|
|
367
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
368
|
+
// Not a pack at all: nothing in it names a claim to read.
|
|
369
|
+
io.err(`${bundlePath} is not an evidence bundle: not a JSON object`);
|
|
370
|
+
return EXIT_COULD_NOT_RUN;
|
|
436
371
|
}
|
|
437
372
|
|
|
438
|
-
//
|
|
439
|
-
// rejects an unknown major as "malformed" so a genuinely unknown shape still
|
|
440
|
-
// exits 2 with a clear message.
|
|
373
|
+
// An evidence bundle; an unknown major is unreadable, and incomplete.
|
|
441
374
|
const result = await verifyEvidenceBundle(parsed, {
|
|
442
375
|
gateways,
|
|
443
376
|
...(content ? { content } : {}),
|
|
444
377
|
...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
|
|
445
378
|
});
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
|
|
455
|
-
// a throw out of `readChains` would have escaped the command entirely and
|
|
456
|
-
// exited 1, which is the code for "failed" and would have named the
|
|
457
|
-
// customer's evidence tampered over a fault in this reader. The Python twin
|
|
458
|
-
// had the opposite half of the same defect and swallowed the throw into "no
|
|
459
|
-
// event chain was found to follow in this bundle" (the gate on proof#154).
|
|
460
|
-
// Neither kernel is entitled to either answer, so both now print the third.
|
|
379
|
+
// The same pack read without the bytes handed in beside it, so a disproof that only those
|
|
380
|
+
// bytes make reads as not proven (evidence-bundle.md section 5.1).
|
|
381
|
+
// Offline: what the public record holds does not turn on the bytes beside the pack.
|
|
382
|
+
const bare = content ? await verifyEvidenceBundle(parsed) : null;
|
|
383
|
+
// Chain findings are evidence, not a verdict: they move `exit` only through a
|
|
384
|
+
// v3 declaration's chain claim (rule 11). A reading that cannot be produced
|
|
385
|
+
// says so, rather than exiting 1 (failed) over a fault in this reader.
|
|
461
386
|
let chains: ChainOutcome;
|
|
462
387
|
try {
|
|
463
388
|
chains = await readChains(parsed);
|
|
464
389
|
} catch (e) {
|
|
465
390
|
chains = { unreadable: errMsg(e) };
|
|
466
391
|
}
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
// claim UNKNOWN and never clean.
|
|
392
|
+
// Links for rule 11. Unreadable links are none, which reads a claim unknown,
|
|
393
|
+
// never clean.
|
|
470
394
|
let links: ChainLink[] | null;
|
|
471
395
|
try {
|
|
472
396
|
links = "unreadable" in chains ? null : await readChainLinks(parsed);
|
|
473
397
|
} catch {
|
|
474
398
|
links = null;
|
|
475
399
|
}
|
|
476
|
-
const exit = reportEvidence(
|
|
477
|
-
|
|
478
|
-
|
|
400
|
+
const exit = reportEvidence(
|
|
401
|
+
result,
|
|
402
|
+
bundlePath,
|
|
403
|
+
gateways,
|
|
404
|
+
io,
|
|
405
|
+
chains,
|
|
406
|
+
links,
|
|
407
|
+
parsed,
|
|
408
|
+
logs,
|
|
409
|
+
sink,
|
|
410
|
+
bare,
|
|
411
|
+
);
|
|
412
|
+
// Files handed in beside the pack (evidence-bundle.md §5.7): findings beside
|
|
413
|
+
// the verdict, never inside it.
|
|
479
414
|
if (offered !== undefined) {
|
|
480
415
|
const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
|
|
481
416
|
for (const line of renderFileMatch(reading)) io.out(line);
|
|
482
417
|
if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
|
|
483
418
|
}
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
|
|
419
|
+
// Over a malformed bundle there are no events to compare against.
|
|
420
|
+
if (logs && result.unreadable !== true) reportLogsCoverage(logs, result, io);
|
|
487
421
|
return exit;
|
|
488
422
|
}
|
|
489
423
|
|
|
@@ -497,35 +431,30 @@ function takeFilePaths(args: readonly string[], start: number): string[] {
|
|
|
497
431
|
return out;
|
|
498
432
|
}
|
|
499
433
|
|
|
500
|
-
// A file is hashed in pieces this big, so a file is read without being held.
|
|
501
434
|
const HASH_CHUNK = 1 << 20;
|
|
502
435
|
|
|
503
|
-
// The
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
// names on the command line is the person's choice and is read at any size.
|
|
436
|
+
// The total a command reads from files found in folders it was handed. A file
|
|
437
|
+
// that would pass it is skipped as `too_large` unread, so a sparse terabyte
|
|
438
|
+
// cannot stall the command. A file named on the command line is read at any
|
|
439
|
+
// size.
|
|
508
440
|
export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
|
|
509
441
|
|
|
510
|
-
// What `hashFile` returns for a file that would push the total past the cap.
|
|
511
442
|
export const TOO_LARGE = "too_large";
|
|
512
443
|
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// on disk: the same typed path then names two different folders (proof#219).
|
|
444
|
+
// By concatenation: `path.join` collapses `link/..` lexically, while the OS and
|
|
445
|
+
// the Python command resolve it on disk.
|
|
516
446
|
function childPath(directory: string, name: string): string {
|
|
517
447
|
return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
|
|
518
448
|
}
|
|
519
449
|
|
|
520
450
|
// The SHA-256 of a regular file, read in pieces, or null when it cannot be
|
|
521
|
-
// read. Inside a folder the file is opened without following a link
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
// person typed (`follow`) is opened as the shell would.
|
|
451
|
+
// read. Inside a folder the file is opened without following a link or
|
|
452
|
+
// blocking, then rechecked, so a file swapped for a link or pipe is skipped. A
|
|
453
|
+
// typed path (`follow`) is opened as the shell would.
|
|
525
454
|
//
|
|
526
|
-
// `budget` is the bytes still allowed across the folders
|
|
527
|
-
//
|
|
528
|
-
//
|
|
455
|
+
// `budget` is the bytes still allowed across the run's folders. A file larger
|
|
456
|
+
// than it, or one that grows past it while read, returns TOO_LARGE; only a file
|
|
457
|
+
// read whole is charged.
|
|
529
458
|
export async function hashFile(
|
|
530
459
|
path: string,
|
|
531
460
|
follow: boolean,
|
|
@@ -563,15 +492,11 @@ export async function hashFile(
|
|
|
563
492
|
}
|
|
564
493
|
}
|
|
565
494
|
|
|
566
|
-
// The regular files under a `--files` folder, recursively, hidden
|
|
567
|
-
// included, into `offered` as `<folder>/<path inside>` in UTF-16
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
// The files are hashed once the walk is done, in the order they are listed, so
|
|
573
|
-
// which file a full `budget` turns away does not depend on how the walk
|
|
574
|
-
// happened to reach it.
|
|
495
|
+
// The regular files under a `--files` folder, recursively, hidden ones
|
|
496
|
+
// included, into `offered` as `<folder>/<path inside>` in UTF-16 order; links,
|
|
497
|
+
// non-regular and unreadable entries into `skipped`. Returns the one error line,
|
|
498
|
+
// or null. Files are hashed after the walk, in listed order, so which file the
|
|
499
|
+
// budget turns away does not depend on walk order.
|
|
575
500
|
async function walkOfferedFolder(
|
|
576
501
|
typed: string,
|
|
577
502
|
offered: OfferedFile[],
|
|
@@ -644,16 +569,12 @@ async function walkOfferedFolder(
|
|
|
644
569
|
return null;
|
|
645
570
|
}
|
|
646
571
|
|
|
647
|
-
// Each `--files` path as `{ name, sha256 }`, or the one error line.
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
// that is neither a readable file nor a readable folder is the one error line,
|
|
654
|
-
// which names the path and not the operating system's words, which differ by
|
|
655
|
-
// kernel. A path typed is followed as a shell would; a link found inside a
|
|
656
|
-
// folder is not.
|
|
572
|
+
// Each `--files` path as `{ name, sha256 }`, or the one error line. A file is
|
|
573
|
+
// named as typed; a folder stands for its regular files, recursively, named
|
|
574
|
+
// `<folder as typed>/<path inside>`. The rest is returned as `skipped`, never
|
|
575
|
+
// dropped. A typed path is followed as a shell would; a link inside a folder is
|
|
576
|
+
// not. The error line names the path, not the OS's words, which differ by
|
|
577
|
+
// kernel.
|
|
657
578
|
export async function readOfferedFiles(
|
|
658
579
|
paths: readonly string[],
|
|
659
580
|
cap: number = MAX_FOLDER_BYTES,
|
|
@@ -683,15 +604,12 @@ export async function readOfferedFiles(
|
|
|
683
604
|
|
|
684
605
|
// `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
|
|
685
606
|
//
|
|
686
|
-
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
// (or stdout when omitted, so it can be piped straight into `proof verify`).
|
|
607
|
+
// Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
|
|
608
|
+
// (evidence-export.md §5). Writes to `--out`, or stdout so it pipes into
|
|
609
|
+
// `proof verify`.
|
|
690
610
|
//
|
|
691
|
-
// Exit codes: 0 composed
|
|
692
|
-
//
|
|
693
|
-
// over failing evidence) · 2 usage / malformed input (missing flag, unreadable
|
|
694
|
-
// or non-JSON input, bad exporter key, malformed attestation record).
|
|
611
|
+
// Exit codes: 0 composed; 1 the recomputed export is `failed` (the composer never
|
|
612
|
+
// emits a green artifact over failing evidence); 2 usage or malformed input.
|
|
695
613
|
async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
|
|
696
614
|
if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
|
|
697
615
|
usageExport(io);
|
|
@@ -743,9 +661,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
743
661
|
if (v === undefined) return usageExport(io);
|
|
744
662
|
opts.generatedAt = v;
|
|
745
663
|
} else if (a.startsWith("-")) {
|
|
746
|
-
//
|
|
747
|
-
// was a short form of `--out` until 0.13.0, and read as a path it would
|
|
748
|
-
// send the export to stdout while the caller waits for a file.
|
|
664
|
+
// An unknown flag is a usage error, never a path.
|
|
749
665
|
io.err(`export: unknown option ${a}`);
|
|
750
666
|
return usageExport(io);
|
|
751
667
|
} else {
|
|
@@ -768,30 +684,28 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
768
684
|
}
|
|
769
685
|
|
|
770
686
|
const sourceBundle = await readJson<EvidenceBundle>(bundlePath, io);
|
|
771
|
-
if (sourceBundle === undefined) return
|
|
687
|
+
if (sourceBundle === undefined) return EXIT_COULD_NOT_RUN;
|
|
772
688
|
const attestationsRaw = await readJson<unknown>(attestationsPath, io);
|
|
773
|
-
if (attestationsRaw === undefined) return
|
|
689
|
+
if (attestationsRaw === undefined) return EXIT_COULD_NOT_RUN;
|
|
774
690
|
const attestations = coerceAttestations(attestationsRaw);
|
|
775
691
|
if (attestations === undefined) {
|
|
776
692
|
io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
|
|
777
693
|
`(or an object with an "attestations" array)`);
|
|
778
|
-
return
|
|
694
|
+
return EXIT_COULD_NOT_RUN;
|
|
779
695
|
}
|
|
780
696
|
|
|
781
697
|
const privateKey = await readExporterKey(keyPath, io);
|
|
782
|
-
if (privateKey === undefined) return
|
|
698
|
+
if (privateKey === undefined) return EXIT_COULD_NOT_RUN;
|
|
783
699
|
|
|
784
700
|
let composed;
|
|
785
701
|
try {
|
|
786
702
|
composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
|
|
787
703
|
} catch (e) {
|
|
788
|
-
io.err(`export: cannot compose
|
|
789
|
-
return
|
|
704
|
+
io.err(`export: cannot compose: ${e instanceof Error ? e.message : String(e)}`);
|
|
705
|
+
return EXIT_COULD_NOT_RUN;
|
|
790
706
|
}
|
|
791
707
|
|
|
792
|
-
//
|
|
793
|
-
// FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
|
|
794
|
-
// still writes the artifact so the caller can inspect it.
|
|
708
|
+
// A `failed` recompute exits 1 and still writes the artifact for inspection.
|
|
795
709
|
const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
|
|
796
710
|
if (sink) {
|
|
797
711
|
sink.data = {
|
|
@@ -800,8 +714,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
800
714
|
source_bundle_hash: composed.sourceBundleHash,
|
|
801
715
|
attestations: attestations.length,
|
|
802
716
|
attestations_bound: composed.boundAttestations,
|
|
803
|
-
// Without --out the artifact rides inside
|
|
804
|
-
// carries the object and not the artifact.
|
|
717
|
+
// Without --out, stdout carries this object, so the artifact rides inside it.
|
|
805
718
|
export: outPath ? null : composed.bundle,
|
|
806
719
|
};
|
|
807
720
|
}
|
|
@@ -810,7 +723,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
|
|
|
810
723
|
await writeFile(outPath, serialized, "utf8");
|
|
811
724
|
} catch (e) {
|
|
812
725
|
io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
813
|
-
return
|
|
726
|
+
return EXIT_COULD_NOT_RUN;
|
|
814
727
|
}
|
|
815
728
|
io.out(
|
|
816
729
|
`${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
|
|
@@ -846,11 +759,10 @@ function usageExport(io: Cli): number {
|
|
|
846
759
|
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
847
760
|
io.out("");
|
|
848
761
|
io.out("export: 1 means the recomputed export is failed; it is still written.");
|
|
849
|
-
return
|
|
762
|
+
return EXIT_COULD_NOT_RUN;
|
|
850
763
|
}
|
|
851
764
|
|
|
852
|
-
// Read
|
|
853
|
-
// failure (mirrors the verify path's read/parse error handling → exit 2).
|
|
765
|
+
// Read and parse a JSON file, or report the error and return undefined (exit 4).
|
|
854
766
|
async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
|
|
855
767
|
let raw: string;
|
|
856
768
|
try {
|
|
@@ -871,8 +783,7 @@ async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
|
|
|
871
783
|
}
|
|
872
784
|
}
|
|
873
785
|
|
|
874
|
-
//
|
|
875
|
-
// under `attestations`. Returns undefined for anything else.
|
|
786
|
+
// A bare array of attestation records, or one under `attestations`.
|
|
876
787
|
function coerceAttestations(raw: unknown): AttestationRecord[] | undefined {
|
|
877
788
|
if (Array.isArray(raw)) return raw as AttestationRecord[];
|
|
878
789
|
if (raw !== null && typeof raw === "object") {
|
|
@@ -882,9 +793,8 @@ function coerceAttestations(raw: unknown): AttestationRecord[] | undefined {
|
|
|
882
793
|
return undefined;
|
|
883
794
|
}
|
|
884
795
|
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
// `private_key`. Validates the 64-hex-char (32-byte) shape.
|
|
796
|
+
// The exporter Ed25519 key: a bare 32-byte hex seed, or a JSON object carrying
|
|
797
|
+
// it under `privateKey`, `seed` or `private_key`.
|
|
888
798
|
async function readExporterKey(path: string, io: Cli): Promise<string | undefined> {
|
|
889
799
|
let raw: string;
|
|
890
800
|
try {
|
|
@@ -915,64 +825,28 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
|
|
|
915
825
|
return seed.toLowerCase();
|
|
916
826
|
}
|
|
917
827
|
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
// DIRECTORY `logs/<event_id>.json`, each file's RAW BYTES being the
|
|
923
|
-
// disclosure. This is the layout `materializeBundle` writes
|
|
924
|
-
// (bundle.json + logs/) and the layout the verify page hashes,
|
|
925
|
-
// so a pack verifies with NO side-input construction step.
|
|
926
|
-
// JSON MAP `{ event_id: <even-length hex, either case | utf8 text> }`, the
|
|
927
|
-
// original form. Unchanged.
|
|
928
|
-
//
|
|
929
|
-
// **Why the directory form exists at all.** Handing a pack to the CLI used to
|
|
930
|
-
// require the caller to read `logs/` and hex-encode it into a map, and that
|
|
931
|
-
// read-and-encode step has been got wrong twice in shipped code: claims-demo#9
|
|
932
|
-
// wrote the hex TEXT into the log files (SHA-256 of hex text is not SHA-256 of
|
|
933
|
-
// the bytes it encodes, so genuine evidence failed), and a runbook handed the
|
|
934
|
-
// directory straight to `--logs` and got EISDIR, exit 2. The directory form
|
|
935
|
-
// removes the encoding decision from the caller entirely — bytes in, bytes
|
|
936
|
-
// hashed — so that whole class stops being reachable from this direction.
|
|
937
|
-
//
|
|
938
|
-
// **Nothing is re-encoded, re-parsed or re-serialised on the way in.** A log
|
|
939
|
-
// file's bytes are passed to the verifier as a `Uint8Array`, which the verifier
|
|
940
|
-
// uses as-is; only the map form's *strings* go through `decodeLogValue`. That
|
|
941
|
-
// is the invariant this feature exists to hold, and it is what makes the CLI
|
|
942
|
-
// and the verify page agree about one pack.
|
|
828
|
+
// `--logs` takes a directory of `logs/<event_id>.json` files, each file's raw
|
|
829
|
+
// bytes being the disclosure (the layout `materializeBundle` writes), or a JSON
|
|
830
|
+
// map `{ event_id: <even-length hex, either case | utf8 text> }`. File bytes go
|
|
831
|
+
// to the verifier as-is; only map strings go through `decodeLogValue`.
|
|
943
832
|
export interface LoadedLogs {
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
// An event id is producer-chosen — here it arrives as a FILE NAME or a JSON
|
|
950
|
-
// key — and `content["__proto__"] = <bytes>` on an object literal does not
|
|
951
|
-
// store anything: it REPLACES the map's prototype with the Uint8Array. The
|
|
952
|
-
// disclosed bytes are then silently lost (the holder disclosed them and we
|
|
953
|
-
// report the record undisclosed), and every later lookup on that map resolves
|
|
954
|
-
// through `Uint8Array.prototype`, so an event named `slice` or `set` finds an
|
|
955
|
-
// inherited function and an event named `byteLength` or `buffer` THROWS a
|
|
956
|
-
// TypeError out of the verifier. A Map keys on the string itself and has none
|
|
957
|
-
// of that.
|
|
833
|
+
// By event id: `Uint8Array` is raw bytes (directory), `string` is hex (map).
|
|
834
|
+
// A `Map`, not an object: an event id is producer-chosen, and
|
|
835
|
+
// `obj["__proto__"] = bytes` replaces the prototype instead of storing the
|
|
836
|
+
// disclosure, after which lookups resolve through `Uint8Array.prototype` and
|
|
837
|
+
// can throw.
|
|
958
838
|
content: Map<string, Uint8Array | string>;
|
|
959
|
-
// Event ids the side input
|
|
960
|
-
// cross-check, which needs to know what was offered, not just what bound.
|
|
839
|
+
// Event ids the side input supplied, for the post-verify coverage check.
|
|
961
840
|
suppliedIds: string[];
|
|
962
|
-
// Advisory lines about the
|
|
963
|
-
// disclosed nothing). None of them moves a verdict or an exit code.
|
|
841
|
+
// Advisory lines about the input itself. None moves a verdict or exit code.
|
|
964
842
|
notes: string[];
|
|
965
843
|
}
|
|
966
844
|
|
|
967
|
-
//
|
|
968
|
-
// what `materializeBundle` writes. Anything else is skipped and NAMED rather
|
|
969
|
-
// than guessed at, so a `.JSON` or extensionless file shows up as a note
|
|
970
|
-
// instead of a mysteriously undisclosed event.
|
|
845
|
+
// Matched exactly, lowercase. Anything else is skipped and named in a note.
|
|
971
846
|
const LOG_SUFFIX = ".json";
|
|
972
847
|
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
// name. `stat` follows symlinks, so a symlinked logs directory works.
|
|
848
|
+
// The shape is decided by `stat`, never by extension. `stat` follows symlinks,
|
|
849
|
+
// so a symlinked logs directory works.
|
|
976
850
|
async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
|
|
977
851
|
let isDirectory: boolean;
|
|
978
852
|
try {
|
|
@@ -984,7 +858,7 @@ async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefin
|
|
|
984
858
|
return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
|
|
985
859
|
}
|
|
986
860
|
|
|
987
|
-
// The JSON-map form
|
|
861
|
+
// The JSON-map form: `{ event_id: <hex | utf8 text> }`.
|
|
988
862
|
async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
|
|
989
863
|
let logsRaw: string;
|
|
990
864
|
try {
|
|
@@ -998,8 +872,7 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
|
|
|
998
872
|
logsParsed = parseRefusingDuplicates(logsRaw);
|
|
999
873
|
} catch (e) {
|
|
1000
874
|
if (e instanceof DuplicateKeyError) {
|
|
1001
|
-
//
|
|
1002
|
-
// record, and the last one silently won (proof#144).
|
|
875
|
+
// A repeated event id would hand the reader two contents for one record.
|
|
1003
876
|
io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
|
|
1004
877
|
return undefined;
|
|
1005
878
|
}
|
|
@@ -1025,8 +898,7 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
|
|
|
1025
898
|
content.set(k, decodeLogValue(v));
|
|
1026
899
|
suppliedIds.push(k);
|
|
1027
900
|
}
|
|
1028
|
-
//
|
|
1029
|
-
// value that is not a string is never coerced and never dropped unnamed.
|
|
901
|
+
// Every non-string value is named, never coerced or dropped.
|
|
1030
902
|
const notes =
|
|
1031
903
|
notStrings.length === 0
|
|
1032
904
|
? []
|
|
@@ -1037,53 +909,24 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
|
|
|
1037
909
|
return { content, suppliedIds, notes };
|
|
1038
910
|
}
|
|
1039
911
|
|
|
1040
|
-
// The directory form
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
// A regular file named `<event_id>.json` is that record's disclosed bytes, and
|
|
1046
|
-
// nothing else in the folder is read. None of the following is a verification
|
|
1047
|
-
// failure, because an absent or unusable disclosure is UNDISCLOSED and absence
|
|
1048
|
-
// is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
|
|
1049
|
-
// is NAMED in a note, every name, so no file is dropped without being named:
|
|
1050
|
-
//
|
|
1051
|
-
// - a file whose name is not a known event id → loaded, binds to nothing,
|
|
1052
|
-
// named in a post-verify note
|
|
1053
|
-
// - an event with no corresponding file → undetermined (`~`), noted
|
|
1054
|
-
// - an empty directory → valid; discloses nothing,
|
|
1055
|
-
// noted, exit code unchanged
|
|
1056
|
-
// - a subdirectory → never entered, noted (the
|
|
1057
|
-
// "you pointed at the pack root, not its logs/" case)
|
|
1058
|
-
// - a symbolic link → never followed, whatever it
|
|
1059
|
-
// points at, noted; `case` never follows one under a pack's `logs/` and
|
|
1060
|
-
// `--files` never follows one in a folder (A40), so a link named
|
|
1061
|
-
// `<event_id>.json` discloses nothing here either
|
|
1062
|
-
// - anything that is not a regular file → never opened, noted
|
|
1063
|
-
// - anything else not named `<event_id>.json` → never read, noted
|
|
912
|
+
// The directory form (evidence-bundle.md §5.1.4; Python's twin is
|
|
913
|
+
// `_load_logs_directory`). Only a regular file named `<event_id>.json` is read:
|
|
914
|
+
// links are never followed and subdirectories never entered. Everything else,
|
|
915
|
+
// and every event with no file, is named in a note, because an absent or
|
|
916
|
+
// unusable disclosure is undisclosed, not a failure (§5.1).
|
|
1064
917
|
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
// failed here: whoever assembled the folder chose the verdict.
|
|
1069
|
-
//
|
|
1070
|
-
// The hard stops (exit 2): a `<event_id>.json` file that cannot be read (we were
|
|
1071
|
-
// pointed at it, so a permissions error must not read as minimal disclosure),
|
|
1072
|
-
// more than MAX_WALK_ENTRIES names, a name that is not valid UTF-8 (escaping it
|
|
1073
|
-
// would let two names read as one), and `<event_id>.json` files holding more
|
|
1074
|
-
// than MAX_LOGS_BYTES in all.
|
|
918
|
+
// Hard stops (exit 4): an unreadable `<event_id>.json` (a permissions error must
|
|
919
|
+
// not read as minimal disclosure), more than MAX_WALK_ENTRIES names, a name that
|
|
920
|
+
// is not valid UTF-8, or more than MAX_LOGS_BYTES in all.
|
|
1075
921
|
export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
|
|
1076
922
|
|
|
1077
923
|
export class LogsTooLarge extends Error {}
|
|
1078
924
|
|
|
1079
|
-
// A `<event_id>.json` file's bytes
|
|
1080
|
-
// blocking
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
// and the count of bytes read refuses a file that reports less than it holds
|
|
1085
|
-
// (a file in /proc reports 0) or grows while it is read. `opener` is for the
|
|
1086
|
-
// tests, which must reach the second check with the first one passing.
|
|
925
|
+
// A `<event_id>.json` file's bytes, opened without following a link or
|
|
926
|
+
// blocking and then rechecked, so a swapped-in link or pipe is refused. Two
|
|
927
|
+
// independent size checks: `fstat` refuses a sparse file before reading, and
|
|
928
|
+
// the byte count refuses a file that under-reports (as /proc does) or grows.
|
|
929
|
+
// `opener` lets tests reach the second check.
|
|
1087
930
|
export async function readLogFile(
|
|
1088
931
|
path: string,
|
|
1089
932
|
budget: { n: number },
|
|
@@ -1114,8 +957,7 @@ export async function readLogFile(
|
|
|
1114
957
|
}
|
|
1115
958
|
}
|
|
1116
959
|
|
|
1117
|
-
// `limits` is for
|
|
1118
|
-
// names per case; the command always passes the defaults.
|
|
960
|
+
// `limits` is for tests; the command always passes the defaults.
|
|
1119
961
|
export async function loadLogsDirectory(
|
|
1120
962
|
dirPath: string,
|
|
1121
963
|
io: Cli,
|
|
@@ -1154,7 +996,7 @@ export async function loadLogsDirectory(
|
|
|
1154
996
|
const p = childPath(dirPath, name);
|
|
1155
997
|
let s;
|
|
1156
998
|
try {
|
|
1157
|
-
// `lstat
|
|
999
|
+
// `lstat`: a link in the folder is never followed.
|
|
1158
1000
|
s = await lstat(p);
|
|
1159
1001
|
} catch {
|
|
1160
1002
|
io.err(`cannot read --logs entry ${p}`);
|
|
@@ -1172,15 +1014,13 @@ export async function loadLogsDirectory(
|
|
|
1172
1014
|
notRegular.push(name);
|
|
1173
1015
|
continue;
|
|
1174
1016
|
}
|
|
1175
|
-
//
|
|
1176
|
-
// whose event id would be the empty string.
|
|
1017
|
+
// Includes a file named exactly `.json`, whose event id would be empty.
|
|
1177
1018
|
if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
|
|
1178
1019
|
notNamed.push(name);
|
|
1179
1020
|
continue;
|
|
1180
1021
|
}
|
|
1181
1022
|
const eventId = name.slice(0, -LOG_SUFFIX.length);
|
|
1182
1023
|
try {
|
|
1183
|
-
// Bytes, exactly as they sit on disk.
|
|
1184
1024
|
content.set(eventId, await readLogFile(p, budget));
|
|
1185
1025
|
} catch (e) {
|
|
1186
1026
|
if (e instanceof LogsTooLarge) {
|
|
@@ -1200,7 +1040,7 @@ export async function loadLogsDirectory(
|
|
|
1200
1040
|
if (dirs.length > 0) {
|
|
1201
1041
|
notes.push(
|
|
1202
1042
|
`--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
|
|
1203
|
-
`(${quoted(dirs)})
|
|
1043
|
+
`(${quoted(dirs)}); the directory form reads <event_id>${LOG_SUFFIX} files ` +
|
|
1204
1044
|
`and does not recurse`,
|
|
1205
1045
|
);
|
|
1206
1046
|
}
|
|
@@ -1227,24 +1067,20 @@ export async function loadLogsDirectory(
|
|
|
1227
1067
|
}
|
|
1228
1068
|
if (suppliedIds.length === 0) {
|
|
1229
1069
|
notes.push(
|
|
1230
|
-
`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files)
|
|
1231
|
-
`stays undisclosed, which is
|
|
1070
|
+
`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files); every event ` +
|
|
1071
|
+
`stays undisclosed, which is not checked, not failed`,
|
|
1232
1072
|
);
|
|
1233
1073
|
}
|
|
1234
1074
|
return { content, suppliedIds, notes };
|
|
1235
1075
|
}
|
|
1236
1076
|
|
|
1237
|
-
// What the side input covered,
|
|
1238
|
-
//
|
|
1239
|
-
// code — and both exist because the silent version of each has already cost
|
|
1240
|
-
// somebody an afternoon:
|
|
1077
|
+
// What the side input covered, once the bundle's event ids are known. Advisory
|
|
1078
|
+
// only:
|
|
1241
1079
|
//
|
|
1242
|
-
// - an entry matching no event binds to nothing
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
// That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
|
|
1247
|
-
// of a disclosure is not evidence of tampering.
|
|
1080
|
+
// - an entry matching no event binds to nothing (a typo or the wrong
|
|
1081
|
+
// directory otherwise looks like minimal disclosure).
|
|
1082
|
+
// - an event whose content stayed undetermined disclosed no usable bytes:
|
|
1083
|
+
// undisclosed, never a failure (evidence-bundle.md §5.1).
|
|
1248
1084
|
function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io: Cli): void {
|
|
1249
1085
|
const eventIds = new Set(result.events.map((e) => e.eventId));
|
|
1250
1086
|
const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
|
|
@@ -1254,13 +1090,13 @@ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io:
|
|
|
1254
1090
|
`in the bundle, ignored: ${quoted(unmatched)}`,
|
|
1255
1091
|
);
|
|
1256
1092
|
}
|
|
1257
|
-
const
|
|
1258
|
-
if (
|
|
1093
|
+
const unchecked = result.events.filter((e) => e.contentOk === null);
|
|
1094
|
+
if (unchecked.length > 0) {
|
|
1259
1095
|
io.err(
|
|
1260
|
-
`note: ${
|
|
1261
|
-
`
|
|
1096
|
+
`note: ${unchecked.length} of ${result.events.length} event(s) have content that ` +
|
|
1097
|
+
`was not checked: no bytes disclosed, or no committed content_hash (message_hash for a ` +
|
|
1262
1098
|
`hand-off record) to bind them to. ` +
|
|
1263
|
-
`Undisclosed, not failed: ${sample(
|
|
1099
|
+
`Undisclosed, not failed: ${sample(unchecked.map((e) => e.eventId))}`,
|
|
1264
1100
|
);
|
|
1265
1101
|
}
|
|
1266
1102
|
}
|
|
@@ -1273,43 +1109,24 @@ function count(n: number, one: string, many: string): string {
|
|
|
1273
1109
|
return `${n} ${n === 1 ? one : many}`;
|
|
1274
1110
|
}
|
|
1275
1111
|
|
|
1276
|
-
// Every name, quoted
|
|
1277
|
-
// is a file dropped without being named.
|
|
1112
|
+
// Every name, quoted: a file left out of a note is a file dropped unnamed.
|
|
1278
1113
|
function quoted(names: string[]): string {
|
|
1279
1114
|
return names.map((n) => JSON.stringify(n)).join(", ");
|
|
1280
1115
|
}
|
|
1281
1116
|
|
|
1282
|
-
//
|
|
1283
|
-
// without pasting a whole directory into the terminal.
|
|
1117
|
+
// The first few names, quoted, with a count of the rest.
|
|
1284
1118
|
function sample(names: string[], max = 3): string {
|
|
1285
1119
|
const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
|
|
1286
1120
|
return names.length > max ? `${shown}, +${names.length - max} more` : shown;
|
|
1287
1121
|
}
|
|
1288
1122
|
|
|
1289
|
-
// The
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
// never reaches this: its bytes are already bytes.)
|
|
1294
|
-
//
|
|
1295
|
-
// EITHER CASE, through the one charset rule (proof#146). This test was
|
|
1296
|
-
// `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
|
|
1297
|
-
// in either kernel that answered differently from the others: the Python CLI
|
|
1298
|
-
// passes every string to the kernel, and the kernel decodes it with the
|
|
1299
|
-
// case-open `hexToBytes` on BOTH sides. So a disclosure map written in
|
|
1300
|
-
// upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
|
|
1301
|
-
// here, and this command told the auditor their genuine, correctly disclosed
|
|
1302
|
-
// evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
|
|
1303
|
-
// 1 here, on one pack and one map. Telling an auditor their evidence is forged
|
|
1304
|
-
// is the worst direction to be wrong in, which is the argument the Python
|
|
1305
|
-
// twin's own docstring already makes about this exact lane.
|
|
1306
|
-
//
|
|
1307
|
-
// Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
|
|
1308
|
-
// lowercase lane that existed before and only ever exercised the one spelling
|
|
1309
|
-
// the two commands happened to agree on.
|
|
1123
|
+
// The map form's encoding: a non-empty, even-length hex string (either case,
|
|
1124
|
+
// through the shared charset rule) is hex; anything else is UTF-8 text. Python
|
|
1125
|
+
// decodes the same way, so a map in upper-case hex is evidence in both
|
|
1126
|
+
// kernels, never text in one and a failure in the other.
|
|
1310
1127
|
function decodeLogValue(v: string): Uint8Array | string {
|
|
1311
|
-
if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v;
|
|
1312
|
-
return utf8(v);
|
|
1128
|
+
if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v;
|
|
1129
|
+
return utf8(v);
|
|
1313
1130
|
}
|
|
1314
1131
|
|
|
1315
1132
|
function printUsage(io: Cli): void {
|
|
@@ -1327,10 +1144,10 @@ function printUsage(io: Cli): void {
|
|
|
1327
1144
|
io.out("");
|
|
1328
1145
|
io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
|
|
1329
1146
|
io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
|
|
1330
|
-
io.out("sanning.evidence.export/v2)
|
|
1147
|
+
io.out("sanning.evidence.export/v2),");
|
|
1331
1148
|
io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
|
|
1332
1149
|
io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
|
|
1333
|
-
io.out("a witness of any other kind is not checked and the
|
|
1150
|
+
io.out("a witness of any other kind is not checked, and the witness line says so.");
|
|
1334
1151
|
io.out("");
|
|
1335
1152
|
io.out("--json Print the verdict, the causes and the exit code as");
|
|
1336
1153
|
io.out(" JSON, for a script, instead of the report.");
|
|
@@ -1354,9 +1171,9 @@ function printUsage(io: Cli): void {
|
|
|
1354
1171
|
io.out("--logs <path> Bind disclosed raw logs to each event's committed");
|
|
1355
1172
|
io.out(" content_hash, or message_hash for a hand-off record.");
|
|
1356
1173
|
io.out(" Either shape works, and which one you");
|
|
1357
|
-
io.out(" passed is
|
|
1174
|
+
io.out(" passed is determined by stat(), not by the extension:");
|
|
1358
1175
|
io.out("");
|
|
1359
|
-
io.out(" a DIRECTORY of <event_id>.json files
|
|
1176
|
+
io.out(" a DIRECTORY of <event_id>.json files; the file's");
|
|
1360
1177
|
io.out(" raw bytes ARE the content, nothing is decoded.");
|
|
1361
1178
|
io.out(" This is a materialized pack's logs/ directory:");
|
|
1362
1179
|
io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
|
|
@@ -1370,36 +1187,27 @@ function printUsage(io: Cli): void {
|
|
|
1370
1187
|
io.out("");
|
|
1371
1188
|
io.out(" In-body events[].content is verified automatically");
|
|
1372
1189
|
io.out(" and takes precedence. An event with no disclosed");
|
|
1373
|
-
io.out(" bytes is
|
|
1190
|
+
io.out(" bytes is not checked, never a failure.");
|
|
1374
1191
|
io.out("");
|
|
1375
|
-
io.out("The headline is the
|
|
1376
|
-
io.out("
|
|
1377
|
-
io.out("
|
|
1192
|
+
io.out("The headline is the pack verdict, the same one the verify page prints, read");
|
|
1193
|
+
io.out("off six claims: signed, unchanged, witnessed, complete, agreed and follows the");
|
|
1194
|
+
io.out("rules. A record nobody hashed leaves the pack incomplete even when the signed");
|
|
1195
|
+
io.out("bundle itself verifies. The report prints one line for each claim that is");
|
|
1196
|
+
io.out("disproven or not proven, and one next action.");
|
|
1378
1197
|
io.out("");
|
|
1379
1198
|
for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
|
|
1380
1199
|
io.out("");
|
|
1381
|
-
io.out("verify:
|
|
1382
|
-
io.out("
|
|
1200
|
+
io.out("verify: a pack with a record nobody hashed reads incomplete and exits 2, which");
|
|
1201
|
+
io.out("is not a failure. A gateway that cannot be reached moves nothing: the witness");
|
|
1202
|
+
io.out("line says so.");
|
|
1383
1203
|
}
|
|
1384
1204
|
|
|
1385
1205
|
/**
|
|
1386
|
-
* The disclosure account
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
* `
|
|
1390
|
-
*
|
|
1391
|
-
* only, and this command has a second disclosure lane the side map cannot see:
|
|
1392
|
-
* `events[].content`, carried INSIDE the bundle and bound by the verifier
|
|
1393
|
-
* without any `--logs` at all. Accounting off the side map would file every
|
|
1394
|
-
* in-body disclosure as `missing` and print a shortfall the pack does not have,
|
|
1395
|
-
* which is the shape of manufactured accusation `grading.ts` keeps warning
|
|
1396
|
-
* about.
|
|
1397
|
-
*
|
|
1398
|
-
* So `present` is "the verifier reached a determination for this record's
|
|
1399
|
-
* content" and `missing` is "it could not", which is the same tri-state that
|
|
1400
|
-
* decides the verdict, so the causes printed can never contradict the headline.
|
|
1401
|
-
* `extra` still comes from the side input, because bytes that bound to nothing
|
|
1402
|
-
* are a fact about the pack that only the loader knows.
|
|
1206
|
+
* The disclosure account, from what the verifier determined rather than what
|
|
1207
|
+
* the loader read. `accountDisclosure` sees only the side map and would file
|
|
1208
|
+
* every in-body `events[].content` disclosure as missing. Here `present` and
|
|
1209
|
+
* `missing` follow the same tri-state as the verdict, so the causes printed
|
|
1210
|
+
* cannot contradict the headline; `extra` comes from the side input.
|
|
1403
1211
|
*/
|
|
1404
1212
|
function cliDisclosureAccount(
|
|
1405
1213
|
result: EvidenceBundleResult,
|
|
@@ -1411,14 +1219,63 @@ function cliDisclosureAccount(
|
|
|
1411
1219
|
expected,
|
|
1412
1220
|
present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
|
|
1413
1221
|
missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
|
|
1414
|
-
// The loader hard-stops
|
|
1415
|
-
//
|
|
1416
|
-
// unusable disclosure bound to a named record.
|
|
1222
|
+
// The loader hard-stops on an unreadable selected file and names every
|
|
1223
|
+
// skip, so nothing unusable reaches here.
|
|
1417
1224
|
unreadable: [],
|
|
1418
1225
|
extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
|
|
1419
1226
|
};
|
|
1420
1227
|
}
|
|
1421
1228
|
|
|
1229
|
+
// The chain reading could not be produced, which is not a reading of nothing.
|
|
1230
|
+
function chainsUnreadableText(reason: string): string {
|
|
1231
|
+
return (
|
|
1232
|
+
"this kernel could not read the event chains in this bundle " +
|
|
1233
|
+
`(${reason}), so the chain evidence is unknown here. That is ` +
|
|
1234
|
+
"not the same as there being none to read."
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* One note per record-layer finding (agent-events.md section 10.1), in the order each
|
|
1240
|
+
* is first met, counted over the records that carry it, its kind the finding's name. A
|
|
1241
|
+
* finding never moves a verdict.
|
|
1242
|
+
*/
|
|
1243
|
+
function findingNotes(readings: Iterable<AgentRecordReading | null>): NoteEntry[] {
|
|
1244
|
+
const counts = new Map<string, number>();
|
|
1245
|
+
for (const reading of readings) {
|
|
1246
|
+
if (reading === null) continue;
|
|
1247
|
+
for (const finding of new Set(reading.findings)) {
|
|
1248
|
+
counts.set(finding, (counts.get(finding) ?? 0) + 1);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return [...counts].map(([f, n]) => note(f, renderFindingNote(f, n)));
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** The witness line without its `Witness:` label, which the person layer prints. */
|
|
1255
|
+
function witnessText(line: WitnessLine): string {
|
|
1256
|
+
return renderWitnessLine(line).slice("Witness: ".length);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* The witness line of one pack read by `verify`: the lookup `verifyEvidenceBundle` ran
|
|
1261
|
+
* with the gateways, in the words the case reading uses.
|
|
1262
|
+
*/
|
|
1263
|
+
function verifyWitness(result: EvidenceBundleResult, gateways: readonly string[]): WitnessLine {
|
|
1264
|
+
// No lookup ran: none was asked for, or the run read no witness to look up (a wrapper that
|
|
1265
|
+
// does not hold, a body this kernel does not read).
|
|
1266
|
+
if (gateways.length === 0 || result.checkpoints.length + (result.stamps ?? []).length === 0) {
|
|
1267
|
+
return witnessLine({ checked: false, gateways: [], packs: [], verdict: "not_checked" });
|
|
1268
|
+
}
|
|
1269
|
+
const { row, answered } = onChainRow("", result);
|
|
1270
|
+
return witnessLine({
|
|
1271
|
+
answered: gateways.filter((g) => answered.has(g)),
|
|
1272
|
+
checked: true,
|
|
1273
|
+
gateways: [...gateways],
|
|
1274
|
+
packs: [row],
|
|
1275
|
+
verdict: onChainOutcome([row]),
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1422
1279
|
function reportEvidence(
|
|
1423
1280
|
result: EvidenceBundleResult,
|
|
1424
1281
|
path: string,
|
|
@@ -1429,486 +1286,302 @@ function reportEvidence(
|
|
|
1429
1286
|
parsed: unknown,
|
|
1430
1287
|
logs: LoadedLogs | undefined,
|
|
1431
1288
|
sink: JsonSink | null,
|
|
1289
|
+
bare: EvidenceBundleResult | null = null,
|
|
1432
1290
|
): number {
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1291
|
+
// What the pack says it holds, against what it does (evidence-bundle.md section
|
|
1292
|
+
// 5.4). A pack with no declaration leaves `complete` out of scope, and a note says
|
|
1293
|
+
// so. A pack that carries a declaration this reader cannot read is incomplete: the
|
|
1294
|
+
// holder chose that field, so unknown is the honest answer. A v3 chain claim is
|
|
1295
|
+
// checked against this command's links (rule 11); unreadable links read the claim
|
|
1296
|
+
// unknown.
|
|
1297
|
+
const declaration = checkDeclaration(parsed, logs?.content, links);
|
|
1298
|
+
const grade = gradePack(
|
|
1299
|
+
result,
|
|
1300
|
+
cliDisclosureAccount(result, logs),
|
|
1301
|
+
completenessNotes(parsed),
|
|
1302
|
+
declaration,
|
|
1444
1303
|
);
|
|
1445
1304
|
|
|
1305
|
+
// Sandbox evidence: read from the signed envelopes the pack holds. A label, never a
|
|
1306
|
+
// verdict: a sandbox pack that verifies exits 3.
|
|
1307
|
+
const marker = environmentMarker([parsed]);
|
|
1308
|
+
const agents = result.events.filter((ev) => ev.agent).map((ev) => ev.agent);
|
|
1309
|
+
const inScope: string[] = [];
|
|
1310
|
+
const notInScope: Partial<Record<Claim, NotInScopeReason>> = { agreed: "no_handoff" };
|
|
1311
|
+
if (declaration.present) inScope.push("complete");
|
|
1312
|
+
else notInScope.complete = "no_declaration";
|
|
1313
|
+
if (agents.length > 0) inScope.push("follows_rules");
|
|
1314
|
+
else notInScope.follows_rules = "no_agent_records";
|
|
1315
|
+
let problems = [...grade.problems];
|
|
1316
|
+
if (result.unreadable === true) {
|
|
1317
|
+
// A pack this reader cannot read says nothing about the bytes beside it: its own
|
|
1318
|
+
// problem is the one line, as `case` reads it.
|
|
1319
|
+
problems = bundleProblems(result, 0);
|
|
1320
|
+
} else if (bare !== null && grade.verdict === "failed") {
|
|
1321
|
+
// No disproof rests on bytes nobody signed: `bare` is the same pack read without the
|
|
1322
|
+
// bytes handed in beside it, as `case` reads it.
|
|
1323
|
+
const signed = gradePack(
|
|
1324
|
+
bare,
|
|
1325
|
+
cliDisclosureAccount(bare, undefined),
|
|
1326
|
+
completenessNotes(parsed),
|
|
1327
|
+
checkDeclaration(parsed, undefined, links),
|
|
1328
|
+
);
|
|
1329
|
+
problems = restOnSigned(problems, signed.problems);
|
|
1330
|
+
}
|
|
1331
|
+
const eventIds = result.events.map((ev) => ev.eventId);
|
|
1332
|
+
const claims = claimsOf(problems, inScope, {
|
|
1333
|
+
notInScope,
|
|
1334
|
+
labels: (_pack, record) => [null, record !== null ? (eventIds[record] ?? null) : null],
|
|
1335
|
+
});
|
|
1336
|
+
const verdict = verdictOf(claims);
|
|
1337
|
+
const exit = exitCodeFor(verdict.status, marker);
|
|
1338
|
+
|
|
1339
|
+
const unreadable = "unreadable" in chains ? chains.unreadable : null;
|
|
1340
|
+
const summaries = "unreadable" in chains ? [] : chains.chains;
|
|
1341
|
+
const findings = "unreadable" in chains ? [] : chains.findings;
|
|
1342
|
+
const notes = (result.notes ?? []).map((n) => note("co_signatures_present", n));
|
|
1343
|
+
if (!declaration.present && result.unreadable !== true) {
|
|
1344
|
+
notes.push(note("pack_undeclared", renderDeclarationSummary(declaration)));
|
|
1345
|
+
}
|
|
1346
|
+
notes.push(...declaration.notes.map((n) => note(n.kind, renderNote(n))));
|
|
1347
|
+
if (unreadable !== null) notes.push(note("chains_unreadable", chainsUnreadableText(unreadable)));
|
|
1348
|
+
notes.push(...findings.map((f) => note(f.kind, renderChainFinding(f, "this bundle"))));
|
|
1349
|
+
notes.push(...findingNotes(agents));
|
|
1350
|
+
const witness = verifyWitness(result, gateways);
|
|
1351
|
+
const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
|
|
1352
|
+
|
|
1353
|
+
for (const line of personText(person)) io.out(line);
|
|
1354
|
+
io.out("");
|
|
1355
|
+
io.out(`Evidence bundle ${path}`);
|
|
1356
|
+
io.out(` body_type ${result.bodyType || "<unknown>"}`);
|
|
1357
|
+
// Both verdicts, whenever they differ: a pack can be incomplete over an impeccable
|
|
1358
|
+
// bundle, and the headline alone would read it as broken.
|
|
1359
|
+
if (verdict.status !== result.status) {
|
|
1360
|
+
io.out(
|
|
1361
|
+
` bundle the signed bundle is "${result.status}"; the pack ` +
|
|
1362
|
+
`around it is "${verdict.status}"`,
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1446
1366
|
if (result.checkpoints.length > 0) {
|
|
1447
1367
|
io.out("");
|
|
1448
|
-
io.out(
|
|
1368
|
+
io.out(" Checkpoints");
|
|
1449
1369
|
for (const cp of result.checkpoints) {
|
|
1450
|
-
|
|
1451
|
-
io.out(
|
|
1452
|
-
` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
|
|
1453
|
-
);
|
|
1454
|
-
for (const e of cp.errors) io.out(paint(` - ${e}`, R));
|
|
1370
|
+
io.out(` ${cp.ok ? "OK" : "XX"} ${cp.checkpointRef || "<no ref>"}`);
|
|
1371
|
+
for (const e of cp.errors) io.out(` - ${e}`);
|
|
1455
1372
|
}
|
|
1456
1373
|
}
|
|
1457
1374
|
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
//
|
|
1464
|
-
// The on-chain column distinguishes all four states in the same three
|
|
1465
|
-
// characters the checkpoint block uses, plus its absence: `✓` confirmed,
|
|
1466
|
-
// `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
|
|
1467
|
-
// nothing was checked. A reader must never have to infer "not checked" from
|
|
1468
|
-
// a symbol that also means "could not be reached".
|
|
1469
|
-
if (result.stamps && result.stamps.length > 0) {
|
|
1375
|
+
// The stamps. For a stamp body the witness is the entire independent part (section 5.2.4).
|
|
1376
|
+
// No on-chain column when nothing was checked: "not checked" must not be inferred
|
|
1377
|
+
// from a word that also means "we asked and nobody answered".
|
|
1378
|
+
const stamps = result.stamps ?? [];
|
|
1379
|
+
if (stamps.length > 0) {
|
|
1470
1380
|
io.out("");
|
|
1471
|
-
io.out(
|
|
1472
|
-
for (const st of
|
|
1381
|
+
io.out(" Stamps");
|
|
1382
|
+
for (const st of stamps) {
|
|
1473
1383
|
const witness =
|
|
1474
|
-
st.anchorRef === null
|
|
1475
|
-
|
|
1476
|
-
: `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
|
|
1477
|
-
const onChain =
|
|
1478
|
-
st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
|
|
1384
|
+
st.anchorRef === null ? "no witness" : `witness ${st.anchorRef.kind}:${st.anchorRef.id}`;
|
|
1385
|
+
const onChain = st.onChain === null ? "" : ` on-chain ${st.onChain.rollup}`;
|
|
1479
1386
|
io.out(
|
|
1480
|
-
` ${
|
|
1481
|
-
`
|
|
1387
|
+
` ${st.ok ? "OK" : "XX"} ${st.stampRef || "<no stamp_ref>"} ` +
|
|
1388
|
+
`bytes ${st.stampRefOk && st.bodyOk ? "OK" : "XX"} ` +
|
|
1389
|
+
`ranges ${st.subtreesOk ? "OK" : "XX"} ${witness}${onChain}`,
|
|
1482
1390
|
);
|
|
1483
|
-
for (const e of st.errors) io.out(
|
|
1391
|
+
for (const e of st.errors) io.out(` - ${e}`);
|
|
1484
1392
|
}
|
|
1485
1393
|
}
|
|
1486
1394
|
|
|
1487
1395
|
if (result.events.length > 0) {
|
|
1488
1396
|
io.out("");
|
|
1489
|
-
io.out(
|
|
1397
|
+
io.out(" Events");
|
|
1490
1398
|
for (const ev of result.events) {
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1497
|
-
// bundle, and nothing about the producer has been established either
|
|
1498
|
-
// way. A renderer cannot tell the two apart from a tri-state, so it
|
|
1499
|
-
// states what the verifier did and leaves WHY to the error lines below,
|
|
1500
|
-
// which now name the malformed envelope instead of a signature check
|
|
1501
|
-
// that never ran (proof#76).
|
|
1502
|
-
const binding =
|
|
1503
|
-
ev.payloadBindingOk === null
|
|
1504
|
-
? paint("~ record not checked", Y)
|
|
1505
|
-
: `record ${mark(ev.payloadBindingOk)}`;
|
|
1506
|
-
// Show the content (logs) segment only when content was disclosed and
|
|
1507
|
-
// evaluated (non-null) — undisclosed content is the default and would just
|
|
1508
|
-
// be noise on every line.
|
|
1509
|
-
const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
|
|
1510
|
-
io.out(
|
|
1511
|
-
` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
|
|
1512
|
-
);
|
|
1513
|
-
for (const e of ev.errors) io.out(paint(` - ${e}`, R));
|
|
1399
|
+
io.out(` ${ev.ok ? "OK" : "XX"} ${ev.eventId || "<no event_id>"}`);
|
|
1400
|
+
for (const e of ev.errors) io.out(` - ${e}`);
|
|
1401
|
+
// The agent reading, with the pack's other records held beside it. Its findings
|
|
1402
|
+
// are notes and move nothing; a reading that is not verified is also a cause
|
|
1403
|
+
// of the pack verdict below.
|
|
1404
|
+
if (ev.agent !== null) io.out(` agent ${renderAgentReading(ev.agent)}`);
|
|
1514
1405
|
}
|
|
1515
1406
|
}
|
|
1516
1407
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
if (result.export) {
|
|
1520
|
-
reportExport(result.export, io);
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
// What the pack SAYS it holds, against what it does (evidence-bundle.md
|
|
1524
|
-
// §5.4). A pack that deviates from its own declaration FAILS here and exits
|
|
1525
|
-
// 1, which is the point: every other check in this command is a check on a
|
|
1526
|
-
// record that is present, so a trimmed hand-over passes all of them.
|
|
1527
|
-
//
|
|
1528
|
-
// A pack with NO declaration exits exactly as it did before §5.4. The check
|
|
1529
|
-
// answers `partial` and the line below says so in words, and pack grading
|
|
1530
|
-
// keeps its own verdict: every pack built before §5.4 carries no
|
|
1531
|
-
// declaration, and re-grading them all as undetermined would be a change to
|
|
1532
|
-
// their verdicts rather than a new check. A pack that DOES
|
|
1533
|
-
// carry a declaration this reader cannot read is a different case and exits
|
|
1534
|
-
// 3: the holder chose that field, so UNKNOWN is the honest answer.
|
|
1535
|
-
//
|
|
1536
|
-
// A v3 declaration that claims a chain is checked against this command's
|
|
1537
|
-
// own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
|
|
1538
|
-
// that could not be read are passed as none, which reads the claim UNKNOWN
|
|
1539
|
-
// and never clean.
|
|
1540
|
-
const declaration = checkDeclaration(parsed, logs?.content, links);
|
|
1541
|
-
const grade = gradePack(
|
|
1542
|
-
result,
|
|
1543
|
-
cliDisclosureAccount(result, logs),
|
|
1544
|
-
completenessNotes(parsed),
|
|
1545
|
-
declaration,
|
|
1546
|
-
);
|
|
1547
|
-
|
|
1548
|
-
// Sandbox evidence: read from the signed envelopes the pack holds.
|
|
1549
|
-
const marker = environmentMarker([parsed]);
|
|
1550
|
-
// A pack whose records are all signed `dev`, undetermined only because a
|
|
1551
|
-
// simulated witness cannot be looked up, would grade `verified` with that
|
|
1552
|
-
// witness accepted: it is sandbox evidence, exit 5, never 3.
|
|
1553
|
-
const simulatedOnly =
|
|
1554
|
-
grade.verdict === "partial" &&
|
|
1555
|
-
onlySimulatedUnsettled(result) &&
|
|
1556
|
-
gradePack(
|
|
1557
|
-
{ ...result, status: "verified" },
|
|
1558
|
-
cliDisclosureAccount(result, logs),
|
|
1559
|
-
completenessNotes(parsed),
|
|
1560
|
-
declaration,
|
|
1561
|
-
).verdict === "verified";
|
|
1562
|
-
const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
|
|
1408
|
+
for (const e of result.errors) io.err(` - ${e}`);
|
|
1563
1409
|
|
|
1410
|
+
// The chain block: the chains walked (their findings are notes, printed above); no
|
|
1411
|
+
// chain to follow; or a reading that could not be produced, printed as itself.
|
|
1564
1412
|
io.out("");
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
result.assertedStatus,
|
|
1569
|
-
gateways,
|
|
1570
|
-
result.onChainChecked,
|
|
1571
|
-
io,
|
|
1572
|
-
marker,
|
|
1573
|
-
exit === EXIT_SANDBOX,
|
|
1574
|
-
simulatedOnlyRun(result, gateways),
|
|
1575
|
-
);
|
|
1576
|
-
io.out(
|
|
1577
|
-
paint(
|
|
1578
|
-
` declaration: ${renderDeclarationSummary(declaration)}`,
|
|
1579
|
-
declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y,
|
|
1580
|
-
),
|
|
1581
|
-
);
|
|
1582
|
-
for (const n of declaration.notes) io.out(paint(` note: ${renderNote(n)}`, Y));
|
|
1583
|
-
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
1584
|
-
// Every reason the pack is not verified, in the kernel's own words. An
|
|
1585
|
-
// `incomplete` with no cause is a surface saying "unknown" and unable to say
|
|
1586
|
-
// why, which `grading.ts` calls out by name as a defect of its own.
|
|
1587
|
-
for (const c of grade.causes) io.out(paint(` - ${renderCause(c, "the pack")}`, Y));
|
|
1588
|
-
|
|
1589
|
-
// The fraction describes the PACK, not the disclosure. It used to divide by
|
|
1590
|
-
// the number of records that happened to disclose bytes, so a pack that
|
|
1591
|
-
// disclosed two of three records printed `logs: 2/2`, a full score for a
|
|
1592
|
-
// partial hand-over, and the one line a reader skims for exactly this.
|
|
1593
|
-
if (result.events.length > 0) {
|
|
1413
|
+
if (unreadable !== null) {
|
|
1414
|
+
io.out(` Chain ${chainsUnreadableText(unreadable)}`);
|
|
1415
|
+
} else if (summaries.length === 0) {
|
|
1594
1416
|
io.out(
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
DIM,
|
|
1598
|
-
),
|
|
1417
|
+
" Chain no event chain was found to follow in this " +
|
|
1418
|
+
"bundle, so nothing was checked here",
|
|
1599
1419
|
);
|
|
1420
|
+
} else {
|
|
1421
|
+
io.out(" Event chains");
|
|
1422
|
+
for (const c of summaries) {
|
|
1423
|
+
const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
|
|
1424
|
+
io.out(
|
|
1425
|
+
` ${c.chainKey} ${c.events} record${c.events === 1 ? "" : "s"} present` +
|
|
1426
|
+
` (linked in ${where})`,
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
io.out(` ${CHAIN_READING_SCOPE}`);
|
|
1600
1430
|
}
|
|
1601
1431
|
|
|
1602
|
-
if (
|
|
1603
|
-
return exit;
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
/**
|
|
1607
|
-
* What `verify --json` prints for an evidence bundle, the same keys and the
|
|
1608
|
-
* same words as `_verify_json` in the Python twin. `grade`, `declaration` and
|
|
1609
|
-
* `chains` are null for a bundle no verdict could be rendered over.
|
|
1610
|
-
*/
|
|
1611
|
-
function verifyJson(
|
|
1612
|
-
result: EvidenceBundleResult,
|
|
1613
|
-
grade: PackGrade | null,
|
|
1614
|
-
declaration: ReturnType<typeof checkDeclaration> | null,
|
|
1615
|
-
chains: ChainOutcome | null,
|
|
1616
|
-
marker: EnvironmentMarker | null = null,
|
|
1617
|
-
): Record<string, unknown> {
|
|
1618
|
-
let chainBlock: Record<string, unknown> | null = null;
|
|
1619
|
-
if (chains !== null) {
|
|
1620
|
-
chainBlock =
|
|
1621
|
-
"unreadable" in chains
|
|
1622
|
-
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1623
|
-
: {
|
|
1624
|
-
unreadable: null,
|
|
1625
|
-
chains: chains.chains.length,
|
|
1626
|
-
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1627
|
-
};
|
|
1628
|
-
}
|
|
1629
|
-
return {
|
|
1630
|
-
verdict: grade ? grade.verdict : result.status,
|
|
1631
|
-
bundle_status: result.status,
|
|
1632
|
-
asserted_status: result.assertedStatus ?? null,
|
|
1633
|
-
body_type: result.bodyType ?? null,
|
|
1634
|
-
on_chain_checked: result.onChainChecked,
|
|
1635
|
-
declaration: declaration ? declaration.verdict : null,
|
|
1636
|
-
declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
|
|
1637
|
-
causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
|
|
1638
|
-
notes: [...result.errors],
|
|
1639
|
-
records: grade
|
|
1640
|
-
? { presented: result.events.length, content_verified: grade.recordsVerified }
|
|
1641
|
-
: null,
|
|
1642
|
-
chains: chainBlock,
|
|
1643
|
-
// Present only where the pack holds an envelope signed `dev`
|
|
1644
|
-
// (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
|
|
1645
|
-
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1646
|
-
};
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
/**
|
|
1650
|
-
* The chain block: what following each record's pointer to the record before it
|
|
1651
|
-
* showed, over the records THIS bundle presents.
|
|
1652
|
-
*
|
|
1653
|
-
* Three deliberate choices, all of them about not overclaiming.
|
|
1654
|
-
*
|
|
1655
|
-
* 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
|
|
1656
|
-
* report" and silence are different statements, and a reader that quietly
|
|
1657
|
-
* matched no records at all would produce the second one while covering
|
|
1658
|
-
* nothing.
|
|
1659
|
-
* 2. It prints the scope sentence whenever it read a chain, findings or not.
|
|
1660
|
-
* "Delete an event and the next pointer dangles" is true; "so the bundle is
|
|
1661
|
-
* complete" does not follow, and two customer-facing documents had already
|
|
1662
|
-
* made that leap. The limit travels with the result or it gets dropped.
|
|
1663
|
-
* 3. It does not touch the exit code. Findings are evidence; the exit code is
|
|
1664
|
-
* the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
|
|
1665
|
-
* the verdict and nothing else. A dangling link is something for a person to look at, not a machine
|
|
1666
|
-
* decision this check is entitled to make.
|
|
1667
|
-
*/
|
|
1668
|
-
function reportChains(reading: ChainOutcome, io: Cli): void {
|
|
1669
|
-
if ("unreadable" in reading) {
|
|
1670
|
-
io.out("");
|
|
1432
|
+
if (simulatedOnlyRun(result, gateways)) {
|
|
1671
1433
|
io.out(
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
`(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
|
|
1675
|
-
`the same as there being none to read.`,
|
|
1676
|
-
Y,
|
|
1677
|
-
),
|
|
1434
|
+
` Gateways ${gateways.join(", ")}; a simulated witness is not ` +
|
|
1435
|
+
"on a chain, so nothing was looked up",
|
|
1678
1436
|
);
|
|
1679
|
-
|
|
1437
|
+
} else if (gateways.length > 0) {
|
|
1438
|
+
io.out(` Gateways ${gateways.join(", ")}`);
|
|
1439
|
+
} else {
|
|
1440
|
+
io.out(" Gateways none supplied; the on-chain half was not checked");
|
|
1680
1441
|
}
|
|
1681
|
-
if (reading.chains.length === 0) return;
|
|
1682
1442
|
|
|
1683
|
-
io.out(
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1443
|
+
io.out(` declaration ${renderDeclarationSummary(declaration)}`);
|
|
1444
|
+
|
|
1445
|
+
// Every reason the pack is not verified, in the kernel's own words.
|
|
1446
|
+
for (const c of grade.causes) io.out(` - ${renderCause(c, "the pack")}`);
|
|
1447
|
+
|
|
1448
|
+
// The fraction describes the pack, not the disclosure.
|
|
1449
|
+
if (result.events.length > 0) {
|
|
1687
1450
|
io.out(
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
DIM,
|
|
1691
|
-
),
|
|
1451
|
+
` logs ${grade.recordsVerified}/${result.events.length} ` +
|
|
1452
|
+
"record(s) content-verified",
|
|
1692
1453
|
);
|
|
1693
1454
|
}
|
|
1694
|
-
for (const f of reading.findings) {
|
|
1695
|
-
io.out(paint(` ! ${renderChainFinding(f, "this bundle")}`, Y));
|
|
1696
|
-
}
|
|
1697
|
-
io.out(paint(` ${CHAIN_READING_SCOPE}`, DIM));
|
|
1698
|
-
}
|
|
1699
1455
|
|
|
1700
|
-
// Render the export-specific block (evidence-export.md §5): source-bundle
|
|
1701
|
-
// linkage, cached-vs-recomputed verdict agreement, and each embedded operator
|
|
1702
|
-
// attestation (RSA-PSS sig · operator-address binding · data_hash binding).
|
|
1703
|
-
function reportExport(exp: ExportResult, io: Cli): void {
|
|
1704
|
-
io.out("");
|
|
1705
|
-
io.out(paint(" Attested export", DIM));
|
|
1706
|
-
io.out(
|
|
1707
|
-
` source linkage ${mark(exp.sourceLinkageOk)} verdict agreement ${mark(exp.verdictAgreementOk)}`,
|
|
1708
|
-
);
|
|
1709
|
-
if (exp.attestations.length > 0) {
|
|
1710
|
-
io.out(paint(" Operator attestations", DIM));
|
|
1711
|
-
for (const a of exp.attestations) {
|
|
1712
|
-
const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
|
|
1713
|
-
io.out(
|
|
1714
|
-
` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
|
|
1715
|
-
` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`,
|
|
1716
|
-
);
|
|
1717
|
-
for (const e of a.errors) io.out(paint(` - ${e}`, R));
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
function reportAgentProof(
|
|
1723
|
-
result: AgentProofResult,
|
|
1724
|
-
path: string,
|
|
1725
|
-
gateways: string[],
|
|
1726
|
-
io: Cli,
|
|
1727
|
-
sink: JsonSink | null,
|
|
1728
|
-
parsed: unknown,
|
|
1729
|
-
): number {
|
|
1730
|
-
const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
|
|
1731
1456
|
if (sink) {
|
|
1732
|
-
sink.data =
|
|
1733
|
-
verdict: result.status,
|
|
1734
|
-
bundle_status: result.status,
|
|
1735
|
-
spec_version: "sanning.agent.proof/v1",
|
|
1736
|
-
on_chain_checked: result.onChainChecked,
|
|
1737
|
-
notes: [...result.errors],
|
|
1738
|
-
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1739
|
-
};
|
|
1740
|
-
}
|
|
1741
|
-
if (result.status === "malformed") {
|
|
1742
|
-
io.err(paint("MALFORMED", R) + ` ${path}`);
|
|
1743
|
-
for (const e of result.errors) io.err(` - ${e}`);
|
|
1744
|
-
return EXIT_MALFORMED;
|
|
1457
|
+
sink.data = verifyJson(result, grade, declaration, chains, marker, verdict, claims, person);
|
|
1745
1458
|
}
|
|
1459
|
+
return exit;
|
|
1460
|
+
}
|
|
1746
1461
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
// there is no pack to grade: the bundle status IS the headline here.
|
|
1757
|
-
const exit = sandboxExit(exitCodeFor(result.status), marker);
|
|
1758
|
-
printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
|
|
1759
|
-
for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
|
|
1462
|
+
/** One agent reading in `--json`: the same keys in `verify` and `case` and in both kernels. */
|
|
1463
|
+
interface AgentJson {
|
|
1464
|
+
verdict: string | null;
|
|
1465
|
+
reason: string | null;
|
|
1466
|
+
at: string | null;
|
|
1467
|
+
reasons: { reason: string; at: string }[];
|
|
1468
|
+
findings: string[];
|
|
1469
|
+
rendered: string;
|
|
1470
|
+
}
|
|
1760
1471
|
|
|
1761
|
-
|
|
1472
|
+
function agentJson(reading: AgentRecordReading, where: Record<string, unknown> = {}): AgentJson {
|
|
1473
|
+
return {
|
|
1474
|
+
...where,
|
|
1475
|
+
verdict: reading.verdict,
|
|
1476
|
+
reason: reading.reason ?? null,
|
|
1477
|
+
at: reading.at ?? null,
|
|
1478
|
+
reasons: (reading.reasons ?? []).map((r) => ({ reason: r.reason, at: r.at })),
|
|
1479
|
+
findings: [...reading.findings],
|
|
1480
|
+
rendered: renderAgentReading(reading),
|
|
1481
|
+
};
|
|
1762
1482
|
}
|
|
1763
1483
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
let head: string;
|
|
1787
|
-
switch (sandbox ? "sandbox" : headline) {
|
|
1788
|
-
// Sandbox evidence (exit 5) never prints VERIFIED.
|
|
1789
|
-
case "sandbox":
|
|
1790
|
-
head = paint(SANDBOX_HEADLINE, Y);
|
|
1791
|
-
break;
|
|
1792
|
-
case "verified":
|
|
1793
|
-
head = paint("VERIFIED", G);
|
|
1794
|
-
break;
|
|
1795
|
-
// Undetermined, in both of its words. Neither is an accusation, so neither
|
|
1796
|
-
// is printed in the color this command uses for one.
|
|
1797
|
-
case "partial":
|
|
1798
|
-
case "pending":
|
|
1799
|
-
case "incomplete":
|
|
1800
|
-
head = paint(headline.toUpperCase(), Y);
|
|
1801
|
-
break;
|
|
1802
|
-
case "failed":
|
|
1803
|
-
head = paint("FAILED", R);
|
|
1804
|
-
break;
|
|
1805
|
-
default:
|
|
1806
|
-
head = paint(headline.toUpperCase(), R);
|
|
1807
|
-
}
|
|
1808
|
-
io.out(`${head} ${paint(scope, DIM)}`);
|
|
1809
|
-
if (marker !== null) io.out(paint(` ${SANDBOX_LINE}`, Y));
|
|
1810
|
-
// Both verdicts, whenever they differ, because they answer different
|
|
1811
|
-
// questions: §3 grades the signed bundle, the pack verdict grades the bundle
|
|
1812
|
-
// plus whatever bytes travelled beside it. A pack can be incomplete while the
|
|
1813
|
-
// bundle it carries is impeccable, and a reader told only the headline would
|
|
1814
|
-
// read a sound bundle as a broken one.
|
|
1815
|
-
if (headline !== bundleStatus) {
|
|
1816
|
-
io.out(
|
|
1817
|
-
paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM),
|
|
1818
|
-
);
|
|
1819
|
-
}
|
|
1820
|
-
if (asserted && asserted !== bundleStatus) {
|
|
1821
|
-
io.out(
|
|
1822
|
-
paint(
|
|
1823
|
-
` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
|
|
1824
|
-
`and the recomputed verdict wins)`,
|
|
1825
|
-
DIM,
|
|
1826
|
-
),
|
|
1827
|
-
);
|
|
1484
|
+
const CAUSE_FIELDS = [
|
|
1485
|
+
["checkpoint_ref", "checkpointRef"],
|
|
1486
|
+
["signed", "signed"],
|
|
1487
|
+
["presented", "presented"],
|
|
1488
|
+
["count", "count"],
|
|
1489
|
+
["of", "of"],
|
|
1490
|
+
["first_event_id", "firstEventId"],
|
|
1491
|
+
["reason", "reason"],
|
|
1492
|
+
["deviation", "deviation"],
|
|
1493
|
+
["at", "at"],
|
|
1494
|
+
["listed_event_id", "listedEventId"],
|
|
1495
|
+
] as const;
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* One pack grade cause in `--json`: its kind, every value it carries, and the sentence
|
|
1499
|
+
* the report prints for it.
|
|
1500
|
+
*/
|
|
1501
|
+
function causeJson(cause: PackGradeCause): Record<string, unknown> {
|
|
1502
|
+
const out: Record<string, unknown> = { kind: cause.kind };
|
|
1503
|
+
for (const [name, field] of CAUSE_FIELDS) {
|
|
1504
|
+
const value = cause[field];
|
|
1505
|
+
if (value !== undefined && value !== null) out[name] = value;
|
|
1828
1506
|
}
|
|
1507
|
+
out.rendered = renderCause(cause, "the pack");
|
|
1508
|
+
return out;
|
|
1829
1509
|
}
|
|
1830
1510
|
|
|
1831
1511
|
/**
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
* unreachable gateway too, and emphatically not 1: the bytes being absent is
|
|
1836
|
-
* never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
|
|
1837
|
-
* exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
|
|
1512
|
+
* What `verify --json` prints for an evidence bundle: the same keys and words as
|
|
1513
|
+
* Python's `_verify_json`. Every reason and count the person layer prints is here, so a
|
|
1514
|
+
* surface can render `person` from this object alone (`renderPerson`).
|
|
1838
1515
|
*/
|
|
1839
|
-
function
|
|
1840
|
-
|
|
1516
|
+
function verifyJson(
|
|
1517
|
+
result: EvidenceBundleResult,
|
|
1518
|
+
grade: PackGrade,
|
|
1519
|
+
declaration: ReturnType<typeof checkDeclaration>,
|
|
1520
|
+
chains: ChainOutcome,
|
|
1521
|
+
marker: EnvironmentMarker | null,
|
|
1522
|
+
verdict: VerdictJson,
|
|
1523
|
+
claims: ClaimEntry[],
|
|
1524
|
+
person: Person,
|
|
1525
|
+
): Record<string, unknown> {
|
|
1526
|
+
const chainBlock =
|
|
1527
|
+
"unreadable" in chains
|
|
1528
|
+
? { unreadable: chains.unreadable, chains: 0, findings: [] }
|
|
1529
|
+
: {
|
|
1530
|
+
unreadable: null,
|
|
1531
|
+
chains: chains.chains.length,
|
|
1532
|
+
findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
|
|
1533
|
+
};
|
|
1534
|
+
return {
|
|
1535
|
+
// Present only for a pack with an envelope signed `dev` (envelope-spec section 2).
|
|
1536
|
+
...(marker !== null ? { environment_marker: marker } : {}),
|
|
1537
|
+
verdict,
|
|
1538
|
+
title: VERDICT_TITLES[verdict.status],
|
|
1539
|
+
claims,
|
|
1540
|
+
person,
|
|
1541
|
+
notes: [...person.notes],
|
|
1542
|
+
sandbox: marker !== null,
|
|
1543
|
+
witness: person.witness,
|
|
1544
|
+
witness_state: person.witness_state,
|
|
1545
|
+
bundle_status: result.status,
|
|
1546
|
+
bundle_errors: [...result.errors],
|
|
1547
|
+
body_type: result.bodyType ?? null,
|
|
1548
|
+
on_chain_checked: result.onChainChecked,
|
|
1549
|
+
declaration: declaration.verdict,
|
|
1550
|
+
declaration_notes: declaration.notes.map(renderNote),
|
|
1551
|
+
causes: grade.causes.map(causeJson),
|
|
1552
|
+
records: { presented: result.events.length, content_verified: grade.recordsVerified },
|
|
1553
|
+
chains: chainBlock,
|
|
1554
|
+
events: result.events.map((ev) => ({ event_id: ev.eventId, ok: ev.ok, errors: [...ev.errors] })),
|
|
1555
|
+
agent_records: result.events
|
|
1556
|
+
.filter((ev) => ev.agent !== null)
|
|
1557
|
+
.map((ev) => agentJson(ev.agent!, { event_id: ev.eventId })),
|
|
1558
|
+
};
|
|
1841
1559
|
}
|
|
1842
1560
|
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
// in a kernel module, because it is the command's own read of files a person
|
|
1847
|
-
// named on the command line, the same class of read as `verify`'s read of
|
|
1848
|
-
// `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
|
|
1849
|
-
// `readBundleBytes`.
|
|
1850
|
-
//
|
|
1851
|
-
// The case READING is `readCase`, and nothing here touches it. This turns the
|
|
1852
|
-
// directories a reader typed into a list of packs. Its twin is the same section
|
|
1853
|
-
// of `src/sanning_proof/cli.py`, and both are held to one table,
|
|
1854
|
-
// `vectors/case-split.json`, whose rows are directory trees.
|
|
1855
|
-
//
|
|
1856
|
-
// ── Directories only ──
|
|
1857
|
-
//
|
|
1858
|
-
// A `.zip` named on the command line is read as the folder `unzip` would make,
|
|
1859
|
-
// by the reader in `zip-reader.ts` and nothing else, one reader restated in both
|
|
1860
|
-
// kernels because two zip libraries disagree on the edges. A `.zip` INSIDE a
|
|
1861
|
-
// folder is a file like any other: outside every pack, hashed like any other
|
|
1862
|
-
// file and never unzipped, because a reader does not open a zip inside a zip
|
|
1863
|
-
// (§5.6.1).
|
|
1561
|
+
// `proof case` input: directories (or a `.zip` read as the folder `unzip`
|
|
1562
|
+
// makes) split into packs, held to `vectors/case-split.json` in both kernels.
|
|
1563
|
+
// A `.zip` inside a folder is a file like any other (§5.6.1).
|
|
1864
1564
|
//
|
|
1865
|
-
//
|
|
1565
|
+
// The walk is iterative and bytewise-sorted, never follows a link, opens only
|
|
1566
|
+
// regular files and directories, and refuses the whole input (exit 4) past
|
|
1567
|
+
// MAX_WALK_DEPTH or MAX_WALK_ENTRIES or on a name that is not valid UTF-8.
|
|
1866
1568
|
//
|
|
1867
|
-
//
|
|
1569
|
+
// The split restates the verify page's rule:
|
|
1868
1570
|
//
|
|
1869
|
-
//
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1875
|
-
//
|
|
1876
|
-
// else that is not a regular file or a directory is never opened. Each is
|
|
1877
|
-
// listed as outside every pack and not read, even when it sits under a
|
|
1878
|
-
// pack's `logs/` or is named `bundle.json`.
|
|
1879
|
-
//
|
|
1880
|
-
// ── The split: the verify page's rule over the tree ──
|
|
1881
|
-
//
|
|
1882
|
-
// The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
|
|
1883
|
-
// `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
|
|
1884
|
-
// apply the same splitting rule to the same unpacked tree. The page's rule
|
|
1885
|
-
// lives in the page, so neither kernel can call it; it is restated here and in
|
|
1886
|
-
// the Python twin, and the shared table pins it:
|
|
1887
|
-
//
|
|
1888
|
-
// 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
|
|
1889
|
-
// at the directory that holds it.
|
|
1890
|
-
// 2. A file belongs to the pack with the LONGEST root it sits under, and only
|
|
1891
|
-
// if it is that root's `bundle.json` or under that root's `logs/`.
|
|
1892
|
-
// 3. A file under no pack is OUTSIDE: listed, never read as part of a pack, and
|
|
1893
|
-
// hashed to be matched against the records (§5.7.6, A41).
|
|
1894
|
-
// 4. Roots and outside paths are sorted by UTF-16 code unit (the default
|
|
1895
|
-
// `Array.prototype.sort`).
|
|
1896
|
-
// 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
|
|
1897
|
-
// and any other file under `logs/` is UNACCOUNTED.
|
|
1571
|
+
// 1. Every regular file at `.../bundle.json` is a pack rooted at its folder.
|
|
1572
|
+
// 2. A file belongs to the pack with the longest root it sits under, only if
|
|
1573
|
+
// it is that root's `bundle.json` or under its `logs/`.
|
|
1574
|
+
// 3. Any other file is outside: listed, and hashed to match against records.
|
|
1575
|
+
// 4. Roots and outside paths sort by UTF-16 code unit.
|
|
1576
|
+
// 5. In a pack, `logs/<event_id>.json` (one level) is disclosed content; any
|
|
1577
|
+
// other file under `logs/` is unaccounted.
|
|
1898
1578
|
// 6. A pack's `ref` is its root without the trailing slash.
|
|
1899
1579
|
// 7. No `bundle.json` anywhere refuses.
|
|
1900
1580
|
//
|
|
1901
|
-
// One directory argument
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
// Known differences from the page: the page drops a leading byte-order mark from
|
|
1907
|
-
// `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
|
|
1908
|
-
// browser hands it for a link, a name that is not UTF-8 or a very deep tree,
|
|
1909
|
-
// where this command applies the walk's rules above; and the page keys
|
|
1910
|
-
// disclosed content by a plain object, so it loses a record named `__proto__`,
|
|
1911
|
-
// which this command keeps.
|
|
1581
|
+
// One directory argument reads as `<its name>/<path inside>`; several read as
|
|
1582
|
+
// one folder holding them, and two landing at one name refuse. Unlike the page,
|
|
1583
|
+
// this command refuses a leading BOM in `bundle.json` and keeps a record named
|
|
1584
|
+
// `__proto__`.
|
|
1912
1585
|
|
|
1913
1586
|
/** The error text for an input with no pack in it, word for word the page's. */
|
|
1914
1587
|
export const NO_BUNDLE =
|
|
@@ -1935,7 +1608,7 @@ const UNZIP_FIRST =
|
|
|
1935
1608
|
|
|
1936
1609
|
/**
|
|
1937
1610
|
* The input could not be read as a case. The message is the whole report,
|
|
1938
|
-
* identical in both kernels, and the command exits
|
|
1611
|
+
* identical in both kernels, and the command exits 4 on it. `hint` is an
|
|
1939
1612
|
* optional second line naming a way through.
|
|
1940
1613
|
*/
|
|
1941
1614
|
export class CaseInputError extends Error {
|
|
@@ -1955,11 +1628,10 @@ export class CaseInputError extends Error {
|
|
|
1955
1628
|
export class WholeReadRefused extends CaseInputError {}
|
|
1956
1629
|
|
|
1957
1630
|
/**
|
|
1958
|
-
* `bundle.json` bytes
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
* (`duplicate key "<name>"`, proof#144), so both commands print it.
|
|
1631
|
+
* `bundle.json` bytes `readBundleBytes` will not read. `tooDeep` marks the one
|
|
1632
|
+
* refusal that is not a parse error. `detail` is the parser's words, which
|
|
1633
|
+
* differ by kernel, so only `verify` prints it; `duplicate` marks the one
|
|
1634
|
+
* `detail` both kernels word identically.
|
|
1963
1635
|
*/
|
|
1964
1636
|
export class BundleReadError extends Error {
|
|
1965
1637
|
readonly tooDeep: boolean;
|
|
@@ -1973,24 +1645,22 @@ export class BundleReadError extends Error {
|
|
|
1973
1645
|
}
|
|
1974
1646
|
}
|
|
1975
1647
|
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
1978
|
-
// I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
|
|
1979
|
-
// would be the wrong sentence. `NOT_I_JSON` in the Python twin.
|
|
1648
|
+
// The prefix for a repeated member name. JCS is defined over I-JSON (RFC 7493),
|
|
1649
|
+
// which forbids it, so "not valid JSON" would be the wrong sentence.
|
|
1980
1650
|
export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
|
|
1981
1651
|
|
|
1982
|
-
// `ignoreBOM: true`
|
|
1983
|
-
//
|
|
1652
|
+
// `ignoreBOM: true` keeps a byte-order mark in the text, so a leading one is
|
|
1653
|
+
// not JSON.
|
|
1984
1654
|
const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
1985
1655
|
const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
1986
1656
|
|
|
1987
1657
|
/**
|
|
1988
|
-
* How
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1658
|
+
* How `verify` and `case` turn `bundle.json` bytes into JSON, in both kernels:
|
|
1659
|
+
* UTF-8 with ill-formed sequences as U+FFFD and a BOM kept; nesting past
|
|
1660
|
+
* MAX_JSON_DEPTH refused before parsing; then `JSON.parse`, refusing a repeated
|
|
1661
|
+
* member name.
|
|
1662
|
+
*
|
|
1663
|
+
* @throws BundleReadError
|
|
1994
1664
|
*/
|
|
1995
1665
|
export function readBundleBytes(bytes: Uint8Array): unknown {
|
|
1996
1666
|
const text = bundleDecoder.decode(bytes);
|
|
@@ -2010,9 +1680,8 @@ export interface SplitPack {
|
|
|
2010
1680
|
bundlePath: string;
|
|
2011
1681
|
bundleBytes: Uint8Array;
|
|
2012
1682
|
/**
|
|
2013
|
-
* `logs/<event_id>.json` bytes by event id. A `Map
|
|
2014
|
-
*
|
|
2015
|
-
* replaces the prototype instead of storing the disclosure.
|
|
1683
|
+
* `logs/<event_id>.json` bytes by event id. A `Map`, because an event id is
|
|
1684
|
+
* written by the audited party and `obj["__proto__"]` would not store it.
|
|
2016
1685
|
*/
|
|
2017
1686
|
content: Map<string, Uint8Array>;
|
|
2018
1687
|
unaccounted: string[];
|
|
@@ -2020,7 +1689,7 @@ export interface SplitPack {
|
|
|
2020
1689
|
|
|
2021
1690
|
export interface SplitCase {
|
|
2022
1691
|
packs: SplitPack[];
|
|
2023
|
-
/** Paths that belong to no pack, sorted. Never read as part of a pack; a regular one is hashed
|
|
1692
|
+
/** Paths that belong to no pack, sorted. Never read as part of a pack; a regular one is hashed. */
|
|
2024
1693
|
outside: string[];
|
|
2025
1694
|
/** The part of `outside` that is not a regular file. Never followed, never opened. */
|
|
2026
1695
|
notRegular: string[];
|
|
@@ -2037,19 +1706,15 @@ export interface TreeEntry {
|
|
|
2037
1706
|
regular: boolean;
|
|
2038
1707
|
/** The bytes of an entry read out of a case-file `.zip`. */
|
|
2039
1708
|
data?: Uint8Array | null;
|
|
2040
|
-
/**
|
|
2041
|
-
* `true` for a symbolic link, on disk or an entry of a `.zip`: the one reason
|
|
2042
|
-
* a link is skipped under, here and under `--files`.
|
|
2043
|
-
*/
|
|
1709
|
+
/** `true` for a symbolic link, on disk or in a `.zip`. */
|
|
2044
1710
|
link?: boolean;
|
|
2045
1711
|
/** The `.zip` reader's own words for an entry it refused (not a link). */
|
|
2046
1712
|
refused?: string;
|
|
2047
1713
|
}
|
|
2048
1714
|
|
|
2049
1715
|
/**
|
|
2050
|
-
* The page's `splitCase`
|
|
2051
|
-
*
|
|
2052
|
-
* nothing else.
|
|
1716
|
+
* The page's `splitCase` then `assemble` per pack, over the walk's tree. `read`
|
|
1717
|
+
* is called only for the files a pack owns.
|
|
2053
1718
|
*/
|
|
2054
1719
|
export async function splitCase(
|
|
2055
1720
|
tree: Map<string, TreeEntry>,
|
|
@@ -2114,15 +1779,10 @@ export async function splitCase(
|
|
|
2114
1779
|
|
|
2115
1780
|
/**
|
|
2116
1781
|
* The bytes of the `witness-cache.json` beside a pack's `bundle.json`
|
|
2117
|
-
* (evidence-bundle 5.6.6
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
* 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
|
|
2122
|
-
* pack's `logs/` is that pack's, and a link is never followed. A file that
|
|
2123
|
-
* cannot be read is no cache, never a refusal of the drop. One the run's
|
|
2124
|
-
* whole-read total has no room for is pushed onto `refused` when it is given,
|
|
2125
|
-
* so the output says so.
|
|
1782
|
+
* (evidence-bundle.md §5.6.6), or `null`. Read only where it is a regular file
|
|
1783
|
+
* the split left outside every pack, and it stays outside. An unreadable file
|
|
1784
|
+
* is no cache, never a refusal; one the whole-read total has no room for is
|
|
1785
|
+
* pushed onto `refused` when given.
|
|
2126
1786
|
*/
|
|
2127
1787
|
export async function witnessCacheBytes(
|
|
2128
1788
|
tree: Map<string, TreeEntry>,
|
|
@@ -2146,33 +1806,30 @@ export async function witnessCacheBytes(
|
|
|
2146
1806
|
}
|
|
2147
1807
|
}
|
|
2148
1808
|
|
|
2149
|
-
/**
|
|
2150
|
-
|
|
1809
|
+
/**
|
|
1810
|
+
* `verify`'s reader over one pack's `bundle.json`: the bundle, or an empty object and why
|
|
1811
|
+
* it could not be read. One pack that cannot be read is that pack's problem, never the
|
|
1812
|
+
* case's: every other pack is still read.
|
|
1813
|
+
*/
|
|
1814
|
+
export function loadBundle(pack: SplitPack): [unknown, string | null] {
|
|
2151
1815
|
try {
|
|
2152
|
-
return readBundleBytes(pack.bundleBytes);
|
|
1816
|
+
return [readBundleBytes(pack.bundleBytes), null];
|
|
2153
1817
|
} catch (e) {
|
|
2154
1818
|
if (e instanceof BundleReadError && e.tooDeep) {
|
|
2155
|
-
|
|
1819
|
+
return [{}, `nests deeper than ${MAX_JSON_DEPTH} levels`];
|
|
2156
1820
|
}
|
|
2157
1821
|
if (e instanceof BundleReadError && e.duplicate) {
|
|
2158
|
-
|
|
1822
|
+
return [{}, `${NOT_I_JSON}: ${e.detail}`];
|
|
2159
1823
|
}
|
|
2160
|
-
|
|
1824
|
+
return [{}, "is not valid JSON"];
|
|
2161
1825
|
}
|
|
2162
1826
|
}
|
|
2163
1827
|
|
|
2164
|
-
/**
|
|
2165
|
-
* The refusal for a file `case` would hold whole that does not fit in what is
|
|
2166
|
-
* left of the 1 GiB a run reads whole. The same words in both kernels.
|
|
2167
|
-
*/
|
|
1828
|
+
/** The refusal for a file `case` would read whole past the run's 1 GiB. Same words in both kernels. */
|
|
2168
1829
|
export const wholeReadRefusal = (key: string): string =>
|
|
2169
1830
|
`${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
|
|
2170
1831
|
|
|
2171
|
-
/**
|
|
2172
|
-
* Why `contents.json` reads as an unreadable index when the run's whole-read
|
|
2173
|
-
* total has no room for it, and when it cannot be read. The same words in both
|
|
2174
|
-
* kernels.
|
|
2175
|
-
*/
|
|
1832
|
+
/** Why `contents.json` reads as unreadable when it is over budget or cannot be read. */
|
|
2176
1833
|
export const INDEX_OVER_BUDGET =
|
|
2177
1834
|
"it would take case past the 1 GiB it reads whole in one run, so it was not read";
|
|
2178
1835
|
export const INDEX_NOT_READ = "it could not be read";
|
|
@@ -2186,15 +1843,13 @@ export const zipFileRefusal = (arg: string): string =>
|
|
|
2186
1843
|
`${arg} is larger than 1.25 GiB on disk; refusing to read it`;
|
|
2187
1844
|
|
|
2188
1845
|
/**
|
|
2189
|
-
* A regular file's bytes
|
|
2190
|
-
*
|
|
2191
|
-
* the walk listed it is refused rather than followed or waited on.
|
|
1846
|
+
* A regular file's bytes, opened without following a link or blocking, then
|
|
1847
|
+
* rechecked, so a swapped-in link or pipe is refused.
|
|
2192
1848
|
*
|
|
2193
|
-
* `budget` is the bytes a run may still hold whole
|
|
2194
|
-
*
|
|
2195
|
-
*
|
|
2196
|
-
*
|
|
2197
|
-
* already in memory, inside the archive's own cap, and is not charged.
|
|
1849
|
+
* `budget` is the bytes a run may still hold whole (MAX_FOLDER_BYTES at the
|
|
1850
|
+
* start). A file larger than that, or that grows past it, is refused before it
|
|
1851
|
+
* is held. A `.zip` entry is already in memory under the archive cap and is not
|
|
1852
|
+
* charged.
|
|
2198
1853
|
*/
|
|
2199
1854
|
export async function readFileEntry(
|
|
2200
1855
|
entry: TreeEntry,
|
|
@@ -2215,8 +1870,8 @@ export async function readFileEntry(
|
|
|
2215
1870
|
if (!st.isFile()) throw new CaseInputError(`cannot read ${key}`);
|
|
2216
1871
|
if (budget === undefined) return new Uint8Array(await handle.readFile());
|
|
2217
1872
|
if (st.size > budget.n) throw new WholeReadRefused(wholeReadRefusal(key));
|
|
2218
|
-
//
|
|
2219
|
-
//
|
|
1873
|
+
// Ask for one byte more than the size, to see it grow; read in place, never
|
|
1874
|
+
// copied.
|
|
2220
1875
|
const want = Math.min(st.size, budget.n) + 1;
|
|
2221
1876
|
const buf = Buffer.allocUnsafeSlow(want);
|
|
2222
1877
|
let total = 0;
|
|
@@ -2226,8 +1881,7 @@ export async function readFileEntry(
|
|
|
2226
1881
|
total += bytesRead;
|
|
2227
1882
|
}
|
|
2228
1883
|
if (total > st.size) {
|
|
2229
|
-
//
|
|
2230
|
-
// past the budget.
|
|
1884
|
+
// It grew: read the rest in pieces, never past the budget.
|
|
2231
1885
|
const rest: Buffer[] = [];
|
|
2232
1886
|
let all = total;
|
|
2233
1887
|
while (all <= budget.n) {
|
|
@@ -2254,9 +1908,8 @@ export async function readFileEntry(
|
|
|
2254
1908
|
}
|
|
2255
1909
|
|
|
2256
1910
|
/**
|
|
2257
|
-
* Every name under `root`, into `tree` at `<prefix><path inside
|
|
2258
|
-
* first
|
|
2259
|
-
* names listed so far, across every argument.
|
|
1911
|
+
* Every name under `root`, into `tree` at `<prefix><path inside>`, breadth
|
|
1912
|
+
* first, without recursion. `seen` counts names across every argument.
|
|
2260
1913
|
*/
|
|
2261
1914
|
async function walk(
|
|
2262
1915
|
root: string,
|
|
@@ -2312,8 +1965,7 @@ async function walk(
|
|
|
2312
1965
|
}
|
|
2313
1966
|
}
|
|
2314
1967
|
|
|
2315
|
-
// The
|
|
2316
|
-
// absolute path, so `.` and `pack/` name the directory itself.
|
|
1968
|
+
// The last segment of the absolute path, so `.` and `pack/` name the directory.
|
|
2317
1969
|
function baseName(path: string): string {
|
|
2318
1970
|
return basename(resolve(path));
|
|
2319
1971
|
}
|
|
@@ -2359,8 +2011,7 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
2359
2011
|
}
|
|
2360
2012
|
let name = baseName(arg);
|
|
2361
2013
|
if (isZip) {
|
|
2362
|
-
// `unzip <name>.zip -d <name>`
|
|
2363
|
-
// the archive is read under the folder that command makes.
|
|
2014
|
+
// Read under the folder `unzip <name>.zip -d <name>` makes.
|
|
2364
2015
|
name = name.slice(0, -".zip".length);
|
|
2365
2016
|
if (name.length === 0) throw new CaseInputError(`${arg} has no name before .zip; rename it`);
|
|
2366
2017
|
}
|
|
@@ -2376,11 +2027,9 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
|
|
|
2376
2027
|
}
|
|
2377
2028
|
|
|
2378
2029
|
/**
|
|
2379
|
-
* A case file named on the command line, read as the folder `unzip` would make
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2382
|
-
* nothing else; an entry it refuses is listed as outside every pack and never
|
|
2383
|
-
* read, as the walk lists a link.
|
|
2030
|
+
* A case file named on the command line, read as the folder `unzip` would make:
|
|
2031
|
+
* `<name>.zip` reads at `<name>/<entry>` (§5.6.4). An entry `readZip` refuses
|
|
2032
|
+
* is listed outside every pack and never read.
|
|
2384
2033
|
*/
|
|
2385
2034
|
async function unzip(
|
|
2386
2035
|
arg: string,
|
|
@@ -2398,8 +2047,7 @@ async function unzip(
|
|
|
2398
2047
|
try {
|
|
2399
2048
|
const size = (await handle.stat()).size;
|
|
2400
2049
|
if (size > MAX_ZIP_ARCHIVE_BYTES) throw new CaseInputError(zipFileRefusal(arg));
|
|
2401
|
-
// One
|
|
2402
|
-
// archive is read straight into this buffer, once, and never copied.
|
|
2050
|
+
// One byte past the size, to notice growth; read in place, never copied.
|
|
2403
2051
|
const buf = Buffer.allocUnsafe(size + 1);
|
|
2404
2052
|
let have = 0;
|
|
2405
2053
|
for (;;) {
|
|
@@ -2440,30 +2088,18 @@ async function unzip(
|
|
|
2440
2088
|
}
|
|
2441
2089
|
}
|
|
2442
2090
|
|
|
2443
|
-
//
|
|
2444
|
-
//
|
|
2445
|
-
//
|
|
2446
|
-
//
|
|
2447
|
-
// write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
|
|
2448
|
-
// the two commands are compared as child processes by
|
|
2449
|
-
// `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
|
|
2450
|
-
// over every `case/` vector.
|
|
2091
|
+
// `proof case`: the case reading from a terminal, so an auditor holding several
|
|
2092
|
+
// packs does not depend on Sanning's page. Python's twin is `run_case`;
|
|
2093
|
+
// `cross-kernel/run_case_cli.sh` holds both to the same stdout, `--json` and
|
|
2094
|
+
// exit code. No color, so both print the same bytes.
|
|
2451
2095
|
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2455
|
-
|
|
2456
|
-
// The exit code of a case verdict is the one table's (`./exit-codes.ts`).
|
|
2457
|
-
// `mixed` has its own code, 4, because each other code would say something a
|
|
2458
|
-
// mixed case has not established: 0 would soften a case with an unsettled or
|
|
2459
|
-
// unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
|
|
2460
|
-
// would accuse a case in which nothing was proven wrong. A case holding a
|
|
2461
|
-
// failure never exits 4: a proven failure keeps the headline (evidence-bundle
|
|
2462
|
-
// 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
|
|
2096
|
+
// `mixed` exits 4 because every other code would claim something unproven: 0
|
|
2097
|
+
// softens an unsettled outcome, 3 hides the settled ones, 1 accuses. A proven
|
|
2098
|
+
// failure keeps the headline (evidence-bundle.md §5.5.4) and exits 1.
|
|
2463
2099
|
|
|
2464
2100
|
export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
|
|
2465
2101
|
|
|
2466
|
-
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--
|
|
2102
|
+
sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
|
|
2467
2103
|
|
|
2468
2104
|
<folder> a pack folder (bundle.json plus logs/), a folder that holds
|
|
2469
2105
|
several packs, or an unzipped case file. Packs are found by the
|
|
@@ -2476,7 +2112,7 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2476
2112
|
read, even under a pack's logs/. Several arguments are read as
|
|
2477
2113
|
one folder holding them, each under its own name. A folder
|
|
2478
2114
|
nested more than 32 levels deep, more than 50000 files and
|
|
2479
|
-
folders in all, or a name that is not valid UTF-8 exits
|
|
2115
|
+
folders in all, or a name that is not valid UTF-8 exits 4.
|
|
2480
2116
|
<case file> a case file named as <name>.zip, read as the folder
|
|
2481
2117
|
unzip <name>.zip -d <name> would make, so the two give one
|
|
2482
2118
|
reading. An entry that is encrypted, a symbolic link, stored by
|
|
@@ -2487,7 +2123,9 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2487
2123
|
is listed; the index never moves the verdict.
|
|
2488
2124
|
--gateways comma-separated gateway URLs. Each pack's witnesses are then
|
|
2489
2125
|
looked up on the public record, pack by pack, and reported on
|
|
2490
|
-
the witness line.
|
|
2126
|
+
the witness line. Only a lookup that finds other bytes, or
|
|
2127
|
+
another witness time, changes the verdict: it disproves
|
|
2128
|
+
witnessed, and the case reads failed.
|
|
2491
2129
|
--files files handed in beside the packs, every path up to the next
|
|
2492
2130
|
option, matched by SHA-256 alone against what every pack's
|
|
2493
2131
|
signed records commit to. A folder stands for the regular files
|
|
@@ -2495,23 +2133,13 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
|
|
|
2495
2133
|
inside it is listed as skipped and never followed. A file under
|
|
2496
2134
|
a pack's logs/ is offered only when it is named here. The
|
|
2497
2135
|
verdict never moves.
|
|
2498
|
-
--json the full reading as JSON: the verdict,
|
|
2136
|
+
--json the full reading as JSON: the verdict, the six claims, the
|
|
2137
|
+
person layer, every exception and note
|
|
2499
2138
|
with its rendered line, every record of every pack with its
|
|
2500
2139
|
status (read clean, read flagged, or not read and why), every
|
|
2501
2140
|
contribution, the packs, each with its index, the files outside
|
|
2502
2141
|
them, the case file index comparison, the witness line and the
|
|
2503
2142
|
on-chain half.
|
|
2504
|
-
--enclave-policy
|
|
2505
|
-
a sanning.enclave-policy/v1 file: what this reader accepts of
|
|
2506
|
-
an enclave attestation. Where the packs hold a runner's run, an
|
|
2507
|
-
enclave attestation line and a run-account line print beside
|
|
2508
|
-
the witness line (DRAFT); neither moves the verdict or the exit
|
|
2509
|
-
code. Without a policy, or without an adapter for the provider
|
|
2510
|
-
installed, the line reads not_checked.
|
|
2511
|
-
--enclave-rpc
|
|
2512
|
-
a chain JSON-RPC endpoint: a flare-fcc run also gets the online
|
|
2513
|
-
line, read from the contracts the policy names. It moves
|
|
2514
|
-
nothing either.
|
|
2515
2143
|
|
|
2516
2144
|
Files outside every pack are matched too, always, so
|
|
2517
2145
|
sanning-proof case audit-file.zip checks a whole download in one command: each
|
|
@@ -2529,7 +2157,7 @@ kernel and graded as a pack; every record every pack supplied is read, in one
|
|
|
2529
2157
|
pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
|
|
2530
2158
|
with no counterpart among the records read is always named, and when a pack
|
|
2531
2159
|
signed by the key it names is in the reading, it says that pack holds none,
|
|
2532
|
-
unless every candidate it had was accounted for by another record
|
|
2160
|
+
unless every candidate it had was accounted for by another record,
|
|
2533
2161
|
which reads as no counterpart and names no pack.
|
|
2534
2162
|
|
|
2535
2163
|
What it does not establish: that the set of packs is complete (a party can
|
|
@@ -2540,19 +2168,18 @@ that any witness is on the public record, which the reading never looks up.
|
|
|
2540
2168
|
${EXIT_CODES_HELP}
|
|
2541
2169
|
|
|
2542
2170
|
case: an input that cannot be read as packs, or an unexpected error in this
|
|
2543
|
-
command, exits
|
|
2544
|
-
1
|
|
2171
|
+
command, exits 4. With --gateways, a witness that does not match on chain
|
|
2172
|
+
exits 1; a lookup that could not be settled moves nothing, and the witness line
|
|
2173
|
+
says so.`;
|
|
2545
2174
|
|
|
2546
|
-
//
|
|
2547
|
-
//
|
|
2548
|
-
// the lines a reader sees.
|
|
2175
|
+
// Node writes U+FFFD for a lone surrogate; doing it here makes captured lines
|
|
2176
|
+
// match what a reader sees.
|
|
2549
2177
|
function noLoneSurrogates(text: string): string {
|
|
2550
2178
|
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
|
|
2551
2179
|
}
|
|
2552
2180
|
|
|
2553
|
-
// Keys sorted at every depth, arrays
|
|
2554
|
-
//
|
|
2555
|
-
// that, byte for byte.
|
|
2181
|
+
// Keys sorted at every depth, arrays in order: byte for byte Python's
|
|
2182
|
+
// `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)`.
|
|
2556
2183
|
function sortKeys(v: unknown): unknown {
|
|
2557
2184
|
if (Array.isArray(v)) return v.map(sortKeys);
|
|
2558
2185
|
if (v !== null && typeof v === "object") {
|
|
@@ -2570,32 +2197,19 @@ function jsonText(v: unknown): string {
|
|
|
2570
2197
|
}
|
|
2571
2198
|
|
|
2572
2199
|
|
|
2573
|
-
/**
|
|
2574
|
-
* The case verdict's code, moved only by an on-chain half that was asked for:
|
|
2575
|
-
* a witness that does not match is a failure whatever the reading said, and an
|
|
2576
|
-
* on-chain half that could not be settled is never a clean 0.
|
|
2577
|
-
*/
|
|
2578
|
-
export function caseExitCode(verdict: string, onChainVerdict: string): number {
|
|
2579
|
-
const code = exitCodeFor(verdict);
|
|
2580
|
-
if (onChainVerdict === "failed") return EXIT_FAILED;
|
|
2581
|
-
if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
|
|
2582
|
-
return code;
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
2200
|
export interface CaseFileData {
|
|
2586
2201
|
root: string;
|
|
2587
2202
|
index: string;
|
|
2588
2203
|
case_id: string | null;
|
|
2589
|
-
findings: { rendered: string }[];
|
|
2204
|
+
findings: { kind: string; rendered: string }[];
|
|
2590
2205
|
}
|
|
2591
2206
|
|
|
2592
2207
|
interface CaseData {
|
|
2593
2208
|
title: string;
|
|
2209
|
+
person: Person;
|
|
2594
2210
|
reliance: string;
|
|
2595
2211
|
summary: string;
|
|
2596
2212
|
witness: { rendered: string };
|
|
2597
|
-
enclave_attestation?: { rendered: string[] }[];
|
|
2598
|
-
run_account?: { rendered: string }[];
|
|
2599
2213
|
exceptions: { rendered: string }[];
|
|
2600
2214
|
notes: { rendered: string }[];
|
|
2601
2215
|
packs: {
|
|
@@ -2607,7 +2221,13 @@ interface CaseData {
|
|
|
2607
2221
|
grade: string | null;
|
|
2608
2222
|
environment_marker?: EnvironmentMarker;
|
|
2609
2223
|
}[];
|
|
2610
|
-
records: {
|
|
2224
|
+
records: {
|
|
2225
|
+
pack_index: number;
|
|
2226
|
+
status: string;
|
|
2227
|
+
pack_ref: string;
|
|
2228
|
+
event_id: string;
|
|
2229
|
+
agent: AgentJson | null;
|
|
2230
|
+
}[];
|
|
2611
2231
|
environment_marker?: EnvironmentMarker;
|
|
2612
2232
|
outside: string[];
|
|
2613
2233
|
not_regular: string[];
|
|
@@ -2622,26 +2242,16 @@ const titleOf = (word: string): string =>
|
|
|
2622
2242
|
: word;
|
|
2623
2243
|
|
|
2624
2244
|
/**
|
|
2625
|
-
* The text report, one line per entry, identical in both kernels. The
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2245
|
+
* The text report, one line per list entry, identical in both kernels. The person
|
|
2246
|
+
* layer comes first (the title, one line per claim that is not proven, the next action,
|
|
2247
|
+
* the notes, the sandbox line and the witness line), then the scope sentence (section
|
|
2248
|
+
* 5.5.6), what a reader may rely on, and the detail.
|
|
2628
2249
|
*/
|
|
2629
2250
|
function caseReport(data: CaseData): string[] {
|
|
2630
|
-
const lines =
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
|
|
2634
|
-
// Where the packs hold a runner's run: its enclave attestation line and its
|
|
2635
|
-
// run-account line, beside the witness line.
|
|
2636
|
-
const enclave = data.enclave_attestation ?? [];
|
|
2637
|
-
const account = data.run_account ?? [];
|
|
2638
|
-
for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
|
|
2639
|
-
for (const line of enclave[i]!.rendered) lines.push(` ${line}`);
|
|
2640
|
-
lines.push(` ${account[i]!.rendered}`);
|
|
2641
|
-
}
|
|
2642
|
-
lines.push(` ${data.reliance}`);
|
|
2251
|
+
const lines = personText(data.person);
|
|
2252
|
+
lines.push("");
|
|
2253
|
+
lines.push(data.summary, data.reliance);
|
|
2643
2254
|
for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
|
|
2644
|
-
for (const n of data.notes) lines.push(` note: ${n.rendered}`);
|
|
2645
2255
|
lines.push(`packs ${data.packs.length}`);
|
|
2646
2256
|
for (const p of data.packs) {
|
|
2647
2257
|
const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
|
|
@@ -2649,9 +2259,9 @@ function caseReport(data: CaseData): string[] {
|
|
|
2649
2259
|
p.grade === null
|
|
2650
2260
|
? "not graded"
|
|
2651
2261
|
: p.grade === "verified" && p.environment_marker !== undefined
|
|
2652
|
-
?
|
|
2262
|
+
? "Verified, sandbox"
|
|
2653
2263
|
: titleOf(p.grade);
|
|
2654
|
-
//
|
|
2264
|
+
// Counted per pack; the JSON lists every record.
|
|
2655
2265
|
const mine = data.records.filter((r) => r.pack_index === p.index);
|
|
2656
2266
|
const count = (status: string) => mine.filter((r) => r.status === status).length;
|
|
2657
2267
|
const records =
|
|
@@ -2667,6 +2277,17 @@ function caseReport(data: CaseData): string[] {
|
|
|
2667
2277
|
}
|
|
2668
2278
|
lines.push(line);
|
|
2669
2279
|
}
|
|
2280
|
+
// Every agent record that did not read verified, or carries a finding. A
|
|
2281
|
+
// finding is reported beside its record and moves no verdict.
|
|
2282
|
+
const agents = data.records.filter((r) => r.agent !== null);
|
|
2283
|
+
if (agents.length > 0) {
|
|
2284
|
+
const shown = agents.filter((r) => r.agent!.verdict !== "verified" || r.agent!.findings.length > 0);
|
|
2285
|
+
lines.push(
|
|
2286
|
+
`agent records ${agents.length} read across every pack, ` +
|
|
2287
|
+
`${agents.length - shown.length} verified with no finding`,
|
|
2288
|
+
);
|
|
2289
|
+
for (const r of shown) lines.push(` ${r.pack_ref}/${r.event_id} ${r.agent!.rendered}`);
|
|
2290
|
+
}
|
|
2670
2291
|
if (data.outside.length > 0) {
|
|
2671
2292
|
lines.push(
|
|
2672
2293
|
`outside ${data.outside.length} file(s) sit outside every ` +
|
|
@@ -2677,9 +2298,6 @@ function caseReport(data: CaseData): string[] {
|
|
|
2677
2298
|
lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
|
|
2678
2299
|
}
|
|
2679
2300
|
}
|
|
2680
|
-
for (const path of data.witness_cache_not_read ?? []) {
|
|
2681
|
-
lines.push(`note ${witnessCacheRefusedNote(path)}`);
|
|
2682
|
-
}
|
|
2683
2301
|
for (const c of data.case_file) {
|
|
2684
2302
|
if (c.index === "absent") continue;
|
|
2685
2303
|
const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
|
|
@@ -2692,8 +2310,7 @@ function caseReport(data: CaseData): string[] {
|
|
|
2692
2310
|
}
|
|
2693
2311
|
const oc = data.on_chain;
|
|
2694
2312
|
if (oc.checked) {
|
|
2695
|
-
//
|
|
2696
|
-
// chain, and the header says so rather than "looked up against".
|
|
2313
|
+
// Only simulated witnesses: nothing was looked up on a chain.
|
|
2697
2314
|
const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
|
|
2698
2315
|
const onlySimulated =
|
|
2699
2316
|
simulated > 0 &&
|
|
@@ -2720,12 +2337,10 @@ function caseReport(data: CaseData): string[] {
|
|
|
2720
2337
|
}
|
|
2721
2338
|
|
|
2722
2339
|
/**
|
|
2723
|
-
* The index comparison, once per argument
|
|
2724
|
-
*
|
|
2725
|
-
*
|
|
2726
|
-
*
|
|
2727
|
-
* larger than what is left is reported as too large to hash, never as a match
|
|
2728
|
-
* and never as absent. It is never an input to the reading.
|
|
2340
|
+
* The index comparison, once per argument with a `contents.json` at its root
|
|
2341
|
+
* (§5.6.3). Files under `packs/` and `report/` are hashed in pieces, sharing a
|
|
2342
|
+
* 1 GiB total in path order; a file past what is left reports as too large,
|
|
2343
|
+
* never as a match or absent. Never an input to the reading.
|
|
2729
2344
|
*/
|
|
2730
2345
|
export async function caseFileReadings(
|
|
2731
2346
|
tree: Map<string, TreeEntry>,
|
|
@@ -2758,9 +2373,8 @@ export async function caseFileReadings(
|
|
|
2758
2373
|
.filter((key) => key.startsWith(prefix) && tree.get(key)!.regular)
|
|
2759
2374
|
.map((key) => [key, key.slice(prefix.length)] as const)
|
|
2760
2375
|
.filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
// turn a real mismatch into `too_large`.
|
|
2376
|
+
// Listed files are charged first, so an unlisted file cannot use up the
|
|
2377
|
+
// total and turn a real mismatch into `too_large`.
|
|
2764
2378
|
for (const [key, rel] of [
|
|
2765
2379
|
...candidates.filter(([, rel]) => listed.has(rel)),
|
|
2766
2380
|
...candidates.filter(([, rel]) => !listed.has(rel)),
|
|
@@ -2803,25 +2417,23 @@ export async function caseFileReadings(
|
|
|
2803
2417
|
return out as CaseFileData[];
|
|
2804
2418
|
}
|
|
2805
2419
|
|
|
2806
|
-
//
|
|
2807
|
-
// the pack holds an envelope signed `dev`.
|
|
2420
|
+
// The pack's environment marker, present only for a pack with a `dev` envelope.
|
|
2808
2421
|
function packMarker(bundle: unknown): { environment_marker?: EnvironmentMarker } {
|
|
2809
2422
|
const m = environmentMarker([bundle]);
|
|
2810
2423
|
return m !== null ? { environment_marker: m } : {};
|
|
2811
2424
|
}
|
|
2812
2425
|
|
|
2813
2426
|
function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
|
|
2814
|
-
if (asJson) outJson(io, jsonText({ error: e.message, exit_code:
|
|
2427
|
+
if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_COULD_NOT_RUN, hint: e.hint }));
|
|
2815
2428
|
io.err(noLoneSurrogates(`case: ${e.message}`));
|
|
2816
2429
|
if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
|
|
2817
|
-
return
|
|
2430
|
+
return EXIT_COULD_NOT_RUN;
|
|
2818
2431
|
}
|
|
2819
2432
|
|
|
2820
2433
|
/**
|
|
2821
|
-
* `proof case <folder>... [--gateways <urls>] [--json]`.
|
|
2822
|
-
*
|
|
2823
|
-
*
|
|
2824
|
-
* command.
|
|
2434
|
+
* `proof case <folder>... [--gateways <urls>] [--json]`. An unexpected
|
|
2435
|
+
* exception exits 4, never 1: 1 would call the evidence failed over a defect in
|
|
2436
|
+
* this command.
|
|
2825
2437
|
*/
|
|
2826
2438
|
export async function runCase(argv: readonly string[], stream: Cli): Promise<number> {
|
|
2827
2439
|
const io = guard(stream);
|
|
@@ -2839,26 +2451,16 @@ export async function runCase(argv: readonly string[], stream: Cli): Promise<num
|
|
|
2839
2451
|
io,
|
|
2840
2452
|
);
|
|
2841
2453
|
} catch {
|
|
2842
|
-
return
|
|
2454
|
+
return EXIT_COULD_NOT_RUN; // the stream cannot be written
|
|
2843
2455
|
}
|
|
2844
2456
|
}
|
|
2845
2457
|
}
|
|
2846
2458
|
|
|
2847
|
-
// The files
|
|
2848
|
-
//
|
|
2849
|
-
//
|
|
2850
|
-
//
|
|
2851
|
-
//
|
|
2852
|
-
// `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
|
|
2853
|
-
// name each file alike. A link, a pipe, an entry the zip reader refused (with
|
|
2854
|
-
// its own words) and a file that cannot be read are returned as skipped, named
|
|
2855
|
-
// and never opened as a pack file. A pack's own files, `bundle.json` and what
|
|
2856
|
-
// is under `logs/`, are not outside and are not offered.
|
|
2857
|
-
//
|
|
2858
|
-
// The files share one total of 1 GiB, the cap the zip reader puts on an
|
|
2859
|
-
// archive, charged in that order. A file larger than what is left is skipped as
|
|
2860
|
-
// `too_large` and read no further, so a sparse file of a terabyte in a
|
|
2861
|
-
// downloaded folder cannot keep `case` from reaching its verdict.
|
|
2459
|
+
// The files outside every pack, as files offered (§5.7), in split order. Each
|
|
2460
|
+
// is hashed as a `--files` file is and named `<case name>/<path inside>`, so a
|
|
2461
|
+
// `.zip` and its unzipped folder name files alike. Links, pipes, refused zip
|
|
2462
|
+
// entries and unreadable files are returned as skipped. They share the 1 GiB
|
|
2463
|
+
// total; a file past it is `too_large` and read no further.
|
|
2862
2464
|
export async function looseFiles(
|
|
2863
2465
|
tree: Map<string, TreeEntry>,
|
|
2864
2466
|
split: SplitCase,
|
|
@@ -2898,67 +2500,11 @@ export async function looseFiles(
|
|
|
2898
2500
|
return { offered, skipped };
|
|
2899
2501
|
}
|
|
2900
2502
|
|
|
2901
|
-
// A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
|
|
2902
|
-
const POLICY_NOT_JSON = Symbol("policy not JSON");
|
|
2903
|
-
|
|
2904
|
-
async function readEnclavePolicy(path: string | null): Promise<unknown> {
|
|
2905
|
-
if (path === null) return null;
|
|
2906
|
-
let raw: Uint8Array;
|
|
2907
|
-
try {
|
|
2908
|
-
if (!(await stat(path)).isFile()) throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
|
|
2909
|
-
raw = new Uint8Array(await readFile(path));
|
|
2910
|
-
} catch (e) {
|
|
2911
|
-
if (e instanceof CaseInputError) throw e;
|
|
2912
|
-
throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
|
|
2913
|
-
}
|
|
2914
|
-
try {
|
|
2915
|
-
return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
|
|
2916
|
-
} catch {
|
|
2917
|
-
return POLICY_NOT_JSON;
|
|
2918
|
-
}
|
|
2919
|
-
}
|
|
2920
|
-
|
|
2921
|
-
/** The two line members of `--json`: one entry per run, with the lines it prints. */
|
|
2922
|
-
function runLines(runs: readonly RunReading[]): [Record<string, unknown>[], Record<string, unknown>[]] {
|
|
2923
|
-
const enclave = runs.map((r) => ({
|
|
2924
|
-
pack_ref: r.packRef,
|
|
2925
|
-
pack_index: r.packIndex,
|
|
2926
|
-
event_id: r.eventId,
|
|
2927
|
-
runner_public_key: r.runnerPublicKey,
|
|
2928
|
-
agent_public_key: r.agentPublicKey,
|
|
2929
|
-
state: r.enclave.state,
|
|
2930
|
-
kinds: r.enclave.kinds,
|
|
2931
|
-
notes: r.enclave.notes,
|
|
2932
|
-
reason: r.enclave.reason,
|
|
2933
|
-
provider: r.enclave.provider,
|
|
2934
|
-
evaluation: r.enclave.evaluation,
|
|
2935
|
-
evaluated_at: r.enclave.evaluatedAt,
|
|
2936
|
-
token_checked: r.enclave.tokenChecked,
|
|
2937
|
-
online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
|
|
2938
|
-
rendered: renderEnclaveLines(r).map(escapeControl),
|
|
2939
|
-
}));
|
|
2940
|
-
const account = runs.map((r) => ({
|
|
2941
|
-
pack_ref: r.packRef,
|
|
2942
|
-
pack_index: r.packIndex,
|
|
2943
|
-
event_id: r.eventId,
|
|
2944
|
-
runner_public_key: r.runnerPublicKey,
|
|
2945
|
-
state: r.account.state,
|
|
2946
|
-
findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
|
|
2947
|
-
notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
|
|
2948
|
-
closing_event_id: r.account.closingEventId,
|
|
2949
|
-
reason: r.account.reason,
|
|
2950
|
-
rendered: escapeControl(renderAccountLine(r)),
|
|
2951
|
-
}));
|
|
2952
|
-
return [enclave, account];
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
2503
|
async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
2956
2504
|
let asJson = false;
|
|
2957
2505
|
let gateways: string[] = [];
|
|
2958
2506
|
const paths: string[] = [];
|
|
2959
2507
|
let filePaths: string[] | undefined;
|
|
2960
|
-
let enclavePolicy: string | null = null;
|
|
2961
|
-
let enclaveRpc: string | null = null;
|
|
2962
2508
|
for (let i = 0; i < argv.length; i++) {
|
|
2963
2509
|
const arg = argv[i]!;
|
|
2964
2510
|
if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
|
|
@@ -2972,7 +2518,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2972
2518
|
if (next === undefined) {
|
|
2973
2519
|
io.err("case: --gateways requires a comma-separated list of URLs");
|
|
2974
2520
|
outBlock(io, CASE_USAGE);
|
|
2975
|
-
return
|
|
2521
|
+
return EXIT_COULD_NOT_RUN;
|
|
2976
2522
|
}
|
|
2977
2523
|
gateways = next
|
|
2978
2524
|
.split(",")
|
|
@@ -2984,24 +2530,14 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
2984
2530
|
if (taken.length === 0) {
|
|
2985
2531
|
io.err("case: --files requires at least one file path");
|
|
2986
2532
|
outBlock(io, CASE_USAGE);
|
|
2987
|
-
return
|
|
2533
|
+
return EXIT_COULD_NOT_RUN;
|
|
2988
2534
|
}
|
|
2989
2535
|
filePaths = [...(filePaths ?? []), ...taken];
|
|
2990
2536
|
i += taken.length;
|
|
2991
|
-
} else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
|
|
2992
|
-
const next = argv[i + 1];
|
|
2993
|
-
if (next === undefined) {
|
|
2994
|
-
io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
|
|
2995
|
-
outBlock(io, CASE_USAGE);
|
|
2996
|
-
return EXIT_MALFORMED;
|
|
2997
|
-
}
|
|
2998
|
-
if (arg === "--enclave-policy") enclavePolicy = next;
|
|
2999
|
-
else enclaveRpc = next;
|
|
3000
|
-
i++;
|
|
3001
2537
|
} else if (arg.startsWith("--")) {
|
|
3002
2538
|
io.err(`case: unknown option ${arg}`);
|
|
3003
2539
|
outBlock(io, CASE_USAGE);
|
|
3004
|
-
return
|
|
2540
|
+
return EXIT_COULD_NOT_RUN;
|
|
3005
2541
|
} else {
|
|
3006
2542
|
paths.push(arg);
|
|
3007
2543
|
}
|
|
@@ -3009,7 +2545,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3009
2545
|
if (paths.length === 0) {
|
|
3010
2546
|
io.err("case: at least one pack is required");
|
|
3011
2547
|
outBlock(io, CASE_USAGE);
|
|
3012
|
-
return
|
|
2548
|
+
return EXIT_COULD_NOT_RUN;
|
|
3013
2549
|
}
|
|
3014
2550
|
|
|
3015
2551
|
let offered: OfferedFile[] | undefined;
|
|
@@ -3020,29 +2556,23 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3020
2556
|
offered = read.offered;
|
|
3021
2557
|
skipped = read.skipped;
|
|
3022
2558
|
}
|
|
3023
|
-
let policy: unknown;
|
|
3024
|
-
try {
|
|
3025
|
-
policy = await readEnclavePolicy(enclavePolicy);
|
|
3026
|
-
} catch (e) {
|
|
3027
|
-
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
3028
|
-
throw e;
|
|
3029
|
-
}
|
|
3030
2559
|
|
|
3031
2560
|
const whole = { n: MAX_FOLDER_BYTES };
|
|
3032
2561
|
let split;
|
|
3033
2562
|
let tree: Map<string, TreeEntry>;
|
|
3034
|
-
let
|
|
2563
|
+
let loaded: [SplitPack, [unknown, string | null]][];
|
|
3035
2564
|
try {
|
|
3036
2565
|
tree = await treeFromArguments(paths);
|
|
3037
2566
|
split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
|
|
3038
|
-
|
|
2567
|
+
loaded = split.packs.map((p): [SplitPack, [unknown, string | null]] => [p, loadBundle(p)]);
|
|
3039
2568
|
} catch (e) {
|
|
3040
2569
|
if (e instanceof CaseInputError) return caseError(e, asJson, io);
|
|
3041
2570
|
throw e;
|
|
3042
2571
|
}
|
|
2572
|
+
const bundles = loaded.map(([p, [bundle]]): [SplitPack, unknown] => [p, bundle]);
|
|
3043
2573
|
|
|
3044
2574
|
const reading = await readCase(
|
|
3045
|
-
|
|
2575
|
+
loaded.map(([p, [bundle, why]]) => ({ ref: p.ref, bundle, content: p.content, unreadable: why })),
|
|
3046
2576
|
);
|
|
3047
2577
|
let caseFile: CaseFileData[];
|
|
3048
2578
|
try {
|
|
@@ -3056,34 +2586,68 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3056
2586
|
for (const [p, bundle] of bundles) {
|
|
3057
2587
|
caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
|
|
3058
2588
|
}
|
|
3059
|
-
const confirmedStamps: string[][] = [];
|
|
3060
2589
|
const onChain = await onChainHalf(
|
|
3061
2590
|
bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
|
|
3062
2591
|
gateways,
|
|
3063
2592
|
{ ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) },
|
|
3064
|
-
confirmedStamps,
|
|
3065
2593
|
);
|
|
3066
2594
|
const witness = witnessLine(onChain, caches.flat());
|
|
3067
|
-
// Sandbox evidence
|
|
3068
|
-
//
|
|
2595
|
+
// Sandbox evidence, over every pack and per pack for its row. A label, never a
|
|
2596
|
+
// verdict: a case that verifies over a `dev` record exits 3.
|
|
3069
2597
|
const marker = environmentMarker(bundles.map(([, b]) => b));
|
|
3070
|
-
//
|
|
3071
|
-
//
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
const
|
|
2598
|
+
// The lookup is outside the reading (it makes network calls the reading never does).
|
|
2599
|
+
// Only a lookup that finds other bytes, or another time, disproves `witnessed`.
|
|
2600
|
+
const problems: Problem[] = [...reading.problems];
|
|
2601
|
+
onChain.packs.forEach((row, index) => {
|
|
2602
|
+
if (row.mismatched > 0) problems.push(problem("witness_differs", [[index, null]]));
|
|
2603
|
+
if ((row.witness_times ?? []).some((t) => t.outcome === "differs")) {
|
|
2604
|
+
problems.push(problem("witness_time_differs", [[index, null]]));
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
const inScope: string[] = [];
|
|
2608
|
+
const notInScope: Partial<Record<Claim, NotInScopeReason>> = {};
|
|
2609
|
+
if (reading.handoffRecords > 0 || reading.packs.some((p) => p.declarationVersion !== null)) {
|
|
2610
|
+
inScope.push("complete");
|
|
2611
|
+
} else {
|
|
2612
|
+
notInScope.complete = "no_declaration_and_no_handoff";
|
|
2613
|
+
}
|
|
2614
|
+
if (reading.handoffRecords > 0) inScope.push("agreed");
|
|
2615
|
+
else notInScope.agreed = "no_handoff";
|
|
2616
|
+
if (reading.handoffRecords > 0 || reading.records.some((r) => r.agent !== null)) {
|
|
2617
|
+
inScope.push("follows_rules");
|
|
2618
|
+
} else {
|
|
2619
|
+
notInScope.follows_rules = "no_agent_or_handoff_records";
|
|
2620
|
+
}
|
|
2621
|
+
const packRefs = reading.packs.map((p) => p.ref);
|
|
2622
|
+
const recordIds = new Map(reading.records.map((r) => [`${r.packIndex}/${r.recordIndex}`, r.eventId]));
|
|
2623
|
+
const claims = claimsOf(problems, inScope, {
|
|
2624
|
+
notInScope,
|
|
2625
|
+
labels: (pack, record) => [
|
|
2626
|
+
pack !== null ? (packRefs[pack] ?? null) : null,
|
|
2627
|
+
recordIds.get(`${pack}/${record}`) ?? null,
|
|
2628
|
+
],
|
|
2629
|
+
});
|
|
2630
|
+
const verdict = verdictOf(claims);
|
|
2631
|
+
const code = exitCodeFor(verdict.status, marker);
|
|
2632
|
+
const notes = reading.notes.map((n) => note(n.kind, renderCaseNote(n)));
|
|
2633
|
+
notes.push(...findingNotes(reading.records.map((r) => r.agent)));
|
|
2634
|
+
notes.push(...caseFile.flatMap((c) => c.findings.map((f) => note(f.kind, f.rendered))));
|
|
2635
|
+
notes.push(...cacheRefused.map((p) => note("witness_cache_refused", witnessCacheRefusedNote(p))));
|
|
2636
|
+
const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
|
|
3081
2637
|
const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
|
|
3082
2638
|
|
|
3083
2639
|
const data = {
|
|
3084
|
-
verdict
|
|
3085
|
-
title:
|
|
3086
|
-
reliance: caseReliance(
|
|
2640
|
+
verdict,
|
|
2641
|
+
title: titleOf(verdict.status),
|
|
2642
|
+
reliance: caseReliance(
|
|
2643
|
+
verdict.status,
|
|
2644
|
+
reading.handoffRecords,
|
|
2645
|
+
marker,
|
|
2646
|
+
verdict.reason_kind ?? null,
|
|
2647
|
+
),
|
|
2648
|
+
claims,
|
|
2649
|
+
person,
|
|
2650
|
+
sandbox: marker !== null,
|
|
3087
2651
|
exit_code: code,
|
|
3088
2652
|
summary: renderCaseSummary(reading),
|
|
3089
2653
|
witness: {
|
|
@@ -3130,6 +2694,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3130
2694
|
findings: r.findings,
|
|
3131
2695
|
failed_checks: r.failedChecks,
|
|
3132
2696
|
counterparts: r.counterparts,
|
|
2697
|
+
agent: r.agent === null ? null : agentJson(r.agent),
|
|
3133
2698
|
})),
|
|
3134
2699
|
contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
|
|
3135
2700
|
packs: bundles.map(([p], i) => ({
|
|
@@ -3151,24 +2716,9 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3151
2716
|
...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
|
|
3152
2717
|
...(marker !== null ? { environment_marker: marker } : {}),
|
|
3153
2718
|
};
|
|
3154
|
-
//
|
|
3155
|
-
//
|
|
3156
|
-
//
|
|
3157
|
-
// no run prints and writes exactly what it did before.
|
|
3158
|
-
const runs = (
|
|
3159
|
-
await readRuns(
|
|
3160
|
-
bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
|
|
3161
|
-
{ policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps },
|
|
3162
|
-
)
|
|
3163
|
-
).runs;
|
|
3164
|
-
const withRuns: Record<string, unknown> = data;
|
|
3165
|
-
if (runs.length > 0) [withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
|
|
3166
|
-
// The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
|
|
3167
|
-
// then the files after --files. A pack's logs/ is its side content and is
|
|
3168
|
-
// never an offered file; only the paths after --files are. Both are matched
|
|
3169
|
-
// by SHA-256 alone and nothing here moves `code`. The section is printed when
|
|
3170
|
-
// there is a file to say something about, so a case with nothing outside
|
|
3171
|
-
// every pack and no --files prints what it always did.
|
|
2719
|
+
// Files outside every pack, then the `--files` paths (evidence-bundle.md
|
|
2720
|
+
// §5.7). A pack's `logs/` is never offered. Matched by SHA-256 alone; nothing
|
|
2721
|
+
// here moves `code`. Printed only when there is something to say.
|
|
3172
2722
|
const loose = await looseFiles(tree, split);
|
|
3173
2723
|
let fileLines: string[] = [];
|
|
3174
2724
|
const out: Record<string, unknown> = data;
|
|
@@ -3181,9 +2731,8 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3181
2731
|
skipped: [...loose.skipped, ...skipped],
|
|
3182
2732
|
};
|
|
3183
2733
|
out.files = fileMatchToJson(fileReading);
|
|
3184
|
-
//
|
|
3185
|
-
//
|
|
3186
|
-
// disclose and bind. With --files, and in --json, every one is listed.
|
|
2734
|
+
// Without --files, one count line for unmatched recorded files; with
|
|
2735
|
+
// --files or --json, every one is listed.
|
|
3187
2736
|
fileLines = renderFileMatch(
|
|
3188
2737
|
fileReading,
|
|
3189
2738
|
offered !== undefined
|
|
@@ -3196,23 +2745,12 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
|
|
|
3196
2745
|
return code;
|
|
3197
2746
|
}
|
|
3198
2747
|
|
|
3199
|
-
|
|
3200
|
-
if (tx.length <= 16) return tx;
|
|
3201
|
-
return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
function shortId(id: string): string {
|
|
3205
|
-
if (id.length <= 12) return id;
|
|
3206
|
-
return `${id.slice(0, 8)}…`;
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
// Entry point when run as a binary (not when imported by tests).
|
|
2748
|
+
// Entry point when run as a binary, not when imported.
|
|
3210
2749
|
const isMain = (() => {
|
|
3211
2750
|
try {
|
|
3212
2751
|
if (!process.argv[1]) return false;
|
|
3213
|
-
//
|
|
3214
|
-
//
|
|
3215
|
-
// so a naive compare skips main(). Resolving both to the real path makes them match.
|
|
2752
|
+
// Resolve both sides: under a bin symlink, argv[1] is the link and
|
|
2753
|
+
// import.meta.url the target.
|
|
3216
2754
|
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
3217
2755
|
} catch {
|
|
3218
2756
|
return false;
|
|
@@ -3229,6 +2767,6 @@ if (isMain) {
|
|
|
3229
2767
|
})
|
|
3230
2768
|
.catch((e) => {
|
|
3231
2769
|
process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
|
|
3232
|
-
process.exitCode =
|
|
2770
|
+
process.exitCode = EXIT_COULD_NOT_RUN;
|
|
3233
2771
|
});
|
|
3234
2772
|
}
|