@sanning/proof 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +173 -26
  7. package/dist/case.js +469 -123
  8. package/dist/cli.d.ts +38 -5
  9. package/dist/cli.js +557 -208
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/index.d.ts +10 -7
  21. package/dist/index.js +13 -6
  22. package/dist/message-parts.d.ts +11 -9
  23. package/dist/message-parts.js +11 -9
  24. package/dist/types.d.ts +1 -0
  25. package/dist/verifier.d.ts +9 -2
  26. package/dist/verifier.js +28 -12
  27. package/dist/zip-reader.d.ts +50 -0
  28. package/dist/zip-reader.js +220 -0
  29. package/package.json +2 -2
  30. package/src/agent-proof.ts +4 -4
  31. package/src/case-file.ts +419 -0
  32. package/src/case.ts +586 -132
  33. package/src/cli.ts +651 -206
  34. package/src/crypto.ts +5 -1
  35. package/src/declaration.ts +248 -52
  36. package/src/evidence.ts +142 -21
  37. package/src/exit-codes.ts +54 -0
  38. package/src/grading.ts +109 -52
  39. package/src/index.ts +58 -2
  40. package/src/message-parts.ts +11 -9
  41. package/src/types.ts +5 -0
  42. package/src/verifier.ts +35 -13
  43. package/src/zip-reader.ts +229 -0
  44. package/vectors/README.md +23 -5
  45. package/vectors/case-file-index.json +668 -0
  46. package/vectors/case-split.json +218 -9
  47. package/vectors/pack-grading.json +68 -4
  48. package/vectors/stamp-on-chain.json +3 -3
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The one ZIP reader `case` uses, for a case file named on the command line.
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/zip_reader.py`. Node has no ZIP
5
+ * reader, and Python's `zipfile` is not used, so the two kernels read one
6
+ * archive by one rule rather than by two libraries that disagree on the edges:
7
+ * a central directory that contradicts a local header, a name that is not
8
+ * UTF-8, data after the end of a deflate stream. Where either library would
9
+ * have made a choice, this module refuses instead.
10
+ *
11
+ * What it reads (evidence-bundle.md 5.6.1): the central directory, and each
12
+ * entry's data through its local header. Stored and deflate only. What it
13
+ * REFUSES, as a whole archive: no end-of-central-directory record, ZIP64, more
14
+ * than one disk, an entry the central directory places outside the archive, a
15
+ * local header that does not match its central entry, a size or CRC-32 that
16
+ * does not match the data, two entries with one name, a name that is not
17
+ * UTF-8, more than MAX_ZIP_ENTRIES entries or MAX_ZIP_BYTES bytes in all, and
18
+ * a name nested deeper than MAX_ZIP_DEPTH folders. What it REPORTS and never
19
+ * reads, entry by entry: an encrypted entry, a symbolic link, a method other
20
+ * than stored or deflate, and a name that is absolute, holds a backslash or a
21
+ * NUL, or has an empty, `.` or `..` segment.
22
+ *
23
+ * Pure: bytes in, entries out. The caller turns them into the tree `case`
24
+ * splits.
25
+ */
26
+ import { inflateRawSync } from "node:zlib";
27
+ import { utf8Strict } from "./verifier.js";
28
+ /** More entries than this refuse the archive: the walk's own bound on names. */
29
+ export const MAX_ZIP_ENTRIES = 50_000;
30
+ /** More uncompressed bytes than this, across every entry, refuse the archive. */
31
+ export const MAX_ZIP_BYTES = 2 ** 30;
32
+ /** A name nested deeper than this many folders refuses the archive. */
33
+ export const MAX_ZIP_DEPTH = 32;
34
+ const EOCD = 0x06054b50;
35
+ const CENTRAL = 0x02014b50;
36
+ const LOCAL = 0x04034b50;
37
+ const S_IFMT = 0o170000;
38
+ const S_IFLNK = 0o120000;
39
+ const UNIX = 3;
40
+ /** The archive cannot be read as a case file. `message` is the whole report, identical in both kernels. */
41
+ export class ZipReadError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "ZipReadError";
45
+ }
46
+ }
47
+ const CRC_TABLE = (() => {
48
+ const table = new Uint32Array(256);
49
+ for (let n = 0; n < 256; n++) {
50
+ let c = n;
51
+ for (let k = 0; k < 8; k++)
52
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
53
+ table[n] = c >>> 0;
54
+ }
55
+ return table;
56
+ })();
57
+ function crc32(bytes) {
58
+ let c = 0xffffffff;
59
+ for (let i = 0; i < bytes.length; i++)
60
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
61
+ return (c ^ 0xffffffff) >>> 0;
62
+ }
63
+ function refusal(name) {
64
+ if (name.startsWith("/"))
65
+ return "an absolute name";
66
+ if (name.includes("\\"))
67
+ return "a name with a backslash";
68
+ if (name.includes("\u0000"))
69
+ return "a name with a NUL";
70
+ for (const segment of name.split("/")) {
71
+ if (segment === "" || segment === "." || segment === "..") {
72
+ return "a name with an empty, . or .. segment";
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ function sameBytes(a, b) {
78
+ if (a.length !== b.length)
79
+ return false;
80
+ for (let i = 0; i < a.length; i++)
81
+ if (a[i] !== b[i])
82
+ return false;
83
+ return true;
84
+ }
85
+ /**
86
+ * Every FILE entry of a ZIP archive, in central-directory order. A directory
87
+ * entry (a name ending in `/`) is not a file and is not returned. Throws
88
+ * `ZipReadError`.
89
+ */
90
+ export function readZip(data) {
91
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
92
+ const n = data.length;
93
+ const u32 = (at) => view.getUint32(at, true);
94
+ const u16 = (at) => view.getUint16(at, true);
95
+ // The end-of-central-directory record: 22 bytes plus a comment of at most
96
+ // 65535, searched backwards so the LAST signature wins.
97
+ let eocd = -1;
98
+ const lowest = Math.max(0, n - 22 - 0xffff);
99
+ for (let at = n - 22; at >= lowest; at--) {
100
+ if (u32(at) === EOCD && at + 22 + u16(at + 20) === n) {
101
+ eocd = at;
102
+ break;
103
+ }
104
+ }
105
+ if (eocd < 0)
106
+ throw new ZipReadError("is not a readable .zip archive");
107
+ const disk = u16(eocd + 4);
108
+ const cdDisk = u16(eocd + 6);
109
+ const hereCount = u16(eocd + 8);
110
+ const count = u16(eocd + 10);
111
+ const cdSize = u32(eocd + 12);
112
+ const cdOffset = u32(eocd + 16);
113
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
114
+ throw new ZipReadError("is a ZIP64 archive, which case does not read");
115
+ }
116
+ if (disk !== 0 || cdDisk !== 0 || hereCount !== count) {
117
+ throw new ZipReadError("spans more than one disk, which case does not read");
118
+ }
119
+ if (count > MAX_ZIP_ENTRIES) {
120
+ throw new ZipReadError(`holds more than ${MAX_ZIP_ENTRIES} entries; refusing to read it`);
121
+ }
122
+ if (cdOffset + cdSize > eocd)
123
+ throw new ZipReadError("is not a readable .zip archive");
124
+ const entries = [];
125
+ const names = new Set();
126
+ let total = 0;
127
+ let at = cdOffset;
128
+ for (let e = 0; e < count; e++) {
129
+ if (at + 46 > eocd || u32(at) !== CENTRAL) {
130
+ throw new ZipReadError("is not a readable .zip archive");
131
+ }
132
+ const madeBy = u16(at + 4);
133
+ const flags = u16(at + 8);
134
+ const method = u16(at + 10);
135
+ const crc = u32(at + 16);
136
+ const csize = u32(at + 20);
137
+ const usize = u32(at + 24);
138
+ const nameLen = u16(at + 28);
139
+ const extraLen = u16(at + 30);
140
+ const commentLen = u16(at + 32);
141
+ const external = u32(at + 38);
142
+ const localAt = u32(at + 42);
143
+ const rawName = data.subarray(at + 46, at + 46 + nameLen);
144
+ at += 46 + nameLen + extraLen + commentLen;
145
+ if (at > eocd)
146
+ throw new ZipReadError("is not a readable .zip archive");
147
+ let name;
148
+ try {
149
+ name = utf8Strict(rawName);
150
+ }
151
+ catch {
152
+ throw new ZipReadError("holds a name that is not valid UTF-8; rename it, then run case again");
153
+ }
154
+ if (names.has(name))
155
+ throw new ZipReadError(`holds ${name} twice`);
156
+ names.add(name);
157
+ if (name.endsWith("/"))
158
+ continue;
159
+ if (name.split("/").length - 1 > MAX_ZIP_DEPTH) {
160
+ throw new ZipReadError(`is nested more than ${MAX_ZIP_DEPTH} folders deep; refusing to read it`);
161
+ }
162
+ let refused = refusal(name);
163
+ if (refused === null && flags & 0x1)
164
+ refused = "an encrypted entry";
165
+ if (refused === null &&
166
+ madeBy >> 8 === UNIX &&
167
+ ((external >>> 16) & S_IFMT) === S_IFLNK) {
168
+ refused = "a symbolic link";
169
+ }
170
+ if (refused === null && method !== 0 && method !== 8) {
171
+ refused = "a compression method other than stored or deflate";
172
+ }
173
+ if (refused !== null) {
174
+ entries.push({ name, refused, data: null });
175
+ continue;
176
+ }
177
+ total += usize;
178
+ if (total > MAX_ZIP_BYTES) {
179
+ throw new ZipReadError(`holds more than ${MAX_ZIP_BYTES} bytes uncompressed; refusing to read it`);
180
+ }
181
+ const unreadable = () => new ZipReadError(`holds ${name}, which cannot be read`);
182
+ if (localAt + 30 > cdOffset || u32(localAt) !== LOCAL)
183
+ throw unreadable();
184
+ const localNameLen = u16(localAt + 26);
185
+ const localExtraLen = u16(localAt + 28);
186
+ if (!sameBytes(data.subarray(localAt + 30, localAt + 30 + localNameLen), rawName)) {
187
+ throw unreadable();
188
+ }
189
+ const start = localAt + 30 + localNameLen + localExtraLen;
190
+ if (start + csize > cdOffset)
191
+ throw unreadable();
192
+ const packed = data.subarray(start, start + csize);
193
+ let body;
194
+ if (method === 0) {
195
+ body = packed;
196
+ }
197
+ else {
198
+ // `info: true` hands back the engine, whose `bytesWritten` is how many
199
+ // input bytes the stream consumed. Anything left over is data after the
200
+ // end of the deflate stream, which Python's `unused_data` refuses too.
201
+ let result;
202
+ try {
203
+ result = inflateRawSync(packed, {
204
+ info: true,
205
+ maxOutputLength: usize + 1,
206
+ });
207
+ }
208
+ catch {
209
+ throw unreadable();
210
+ }
211
+ if (result.engine.bytesWritten !== packed.length)
212
+ throw unreadable();
213
+ body = new Uint8Array(result.buffer.buffer, result.buffer.byteOffset, result.buffer.length);
214
+ }
215
+ if (body.length !== usize || crc32(body) !== crc)
216
+ throw unreadable();
217
+ entries.push({ name, refused: null, data: body.slice() });
218
+ }
219
+ return entries;
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanning/proof",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "TypeScript kernel of the Sanning verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes. Full-family verifier (inline + external commitment); accepts sanning.agent/v1 and sanning.events/v2. Conformance-gated byte-for-byte against the test-vectors corpus.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "main": "./dist/index.js",
15
15
  "types": "./dist/index.d.ts",
16
16
  "bin": {
17
- "proof": "./dist/cli.js"
17
+ "sanning-proof": "./dist/cli.js"
18
18
  },
19
19
  "files": [
20
20
  "src",
@@ -1,4 +1,4 @@
1
- // Agent inclusion-proof bundle verification.
1
+ // Agent inclusion proof verification (`sanning.agent.proof/v1`).
2
2
  //
3
3
  // The `sanning.agent.proof/v1` bundle is the agent's ~1.4 KB self-contained
4
4
  // inclusion proof: a signed checkpoint envelope (sanning.agent/v1, INLINE payload)
@@ -19,7 +19,7 @@ import { leafHash, verifyInclusion } from "./merkle.js";
19
19
  import { MAX_CANONICAL_DEPTH, exceedsDepth, jcs, jcsParse, verifyEnvelope } from "./verifier.js";
20
20
  import type { Envelope } from "./types.js";
21
21
 
22
- export const AGENT_PROOF_SPEC_PREFIX = "sanning.agent.proof/v1";
22
+ export const AGENT_PROOF_SPEC_VERSION = "sanning.agent.proof/v1";
23
23
 
24
24
  export interface AgentProofBundle {
25
25
  spec_version: string;
@@ -63,10 +63,10 @@ function toHex(bytes: Uint8Array): string {
63
63
 
64
64
  export function isAgentProofSpec(specVersion: unknown): boolean {
65
65
  if (typeof specVersion !== "string") return false;
66
- return specVersion === AGENT_PROOF_SPEC_PREFIX || specVersion.startsWith(`${AGENT_PROOF_SPEC_PREFIX}.`);
66
+ return specVersion === AGENT_PROOF_SPEC_VERSION || specVersion.startsWith(`${AGENT_PROOF_SPEC_VERSION}.`);
67
67
  }
68
68
 
69
- export async function verifyAgentProofBundle(
69
+ export async function verifyAgentProof(
70
70
  bundle: unknown,
71
71
  options: VerifyAgentProofOptions = {},
72
72
  ): Promise<AgentProofResult> {
@@ -0,0 +1,419 @@
1
+ /**
2
+ * The case file's index, compared with the drop it describes (A25).
3
+ *
4
+ * The TypeScript half of `src/sanning_proof/case_file.py`. Both kernels are
5
+ * held to one table, `vectors/case-file-index.json`, so neither can pass by
6
+ * agreeing with the other, and `cross-kernel/run_case_cli.sh` holds the two
7
+ * commands to the same `case_file` output.
8
+ *
9
+ * WHAT A CASE FILE IS (evidence-bundle.md 5.6 v1.20). A ZIP archive holding one
10
+ * case's packs, each unpacked under `packs/<ref>/`, an index (`contents.json`),
11
+ * the one command that checks them (`HOW-TO-VERIFY.txt`) and an optional
12
+ * `report/` of files no record commits to. **Nobody signs it.** Each pack in it
13
+ * is byte for byte the pack its signer produced, and a reader reads the packs,
14
+ * never the file.
15
+ *
16
+ * WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO. It compares the index with
17
+ * the files the reader was handed and returns findings with NO verdict
18
+ * contribution. **The index is never an input to the case reading**
19
+ * (`readCase`): adding, removing or editing `contents.json` moves no verdict,
20
+ * no exception, no note, no contribution and no exit code. An assembler can lie
21
+ * in every member of the index, and each lie is a finding here and nothing
22
+ * more; it can leave the index out, which is no finding at all.
23
+ *
24
+ * Pure: bytes and a file list in, a reading out. No filesystem, no zip, no
25
+ * network. How the files were listed and hashed is the caller's.
26
+ */
27
+
28
+ import { jcsParse } from "./verifier.js";
29
+
30
+ /** The index's dispatch key (5.6.2). An absent or unknown value makes the index unreadable. */
31
+ export const CASE_FILE_VERSION = "sanning.case-file/v1";
32
+
33
+ /** The index, at the root of the case file. */
34
+ export const CASE_FILE_INDEX_NAME = "contents.json";
35
+
36
+ /** The one command that checks the file, at its root (5.6.4). */
37
+ export const HOW_TO_VERIFY_NAME = "HOW-TO-VERIFY.txt";
38
+
39
+ /** The first line of `report/README.txt`, exactly (5.6.1). */
40
+ export const REPORT_README_FIRST_LINE =
41
+ "Not evidence: no record in this case file commits to the files in this folder.";
42
+
43
+ /**
44
+ * How deep `[` and `{` may nest in the index before it is refused unread. The
45
+ * same limit `bundle.json` is read under, so a hostile index cannot make one
46
+ * kernel's parser recurse where the other's does not.
47
+ */
48
+ export const MAX_INDEX_DEPTH = 512;
49
+
50
+ /**
51
+ * Every finding this comparison can report, in the order it reports them
52
+ * (5.6.3). None of them is a verdict word and none contributes to one.
53
+ */
54
+ export const CASE_FILE_FINDING_KINDS = [
55
+ "case_index_unreadable",
56
+ "case_index_pack_missing",
57
+ "case_index_pack_unlisted",
58
+ "case_index_file_missing",
59
+ "case_index_file_unlisted",
60
+ "case_index_file_differs",
61
+ "case_index_names_absent",
62
+ ] as const;
63
+ export type CaseFileFindingKind = (typeof CASE_FILE_FINDING_KINDS)[number];
64
+
65
+ // `<ref>`: 1 to 128 characters from A-Z, a-z, 0-9, `.`, `_` and `-`, and not
66
+ // `.` or `..` (5.6.1). The class holds no newline, so `$` cannot match before
67
+ // one here.
68
+ const REF = /^[A-Za-z0-9._-]{1,128}$/;
69
+
70
+ /**
71
+ * Whether `ref` is a legal pack folder name in a case file. For a producer; a
72
+ * reader takes whatever folders it finds and names them.
73
+ */
74
+ export function isPackRef(ref: unknown): boolean {
75
+ return typeof ref === "string" && REF.test(ref) && ref !== "." && ref !== "..";
76
+ }
77
+
78
+ /**
79
+ * `HOW-TO-VERIFY.txt`, exactly (5.6.4): ASCII, LF line endings, with `name` the
80
+ * case file's name without `.zip`.
81
+ */
82
+ export function howToVerifyText(name: string): string {
83
+ return (
84
+ "This case file holds evidence packs. Each pack is signed by its own key.\n" +
85
+ "Nobody signed this file, contents.json, or anything under report/.\n" +
86
+ "\n" +
87
+ "To check it offline, on your own machine:\n" +
88
+ "\n" +
89
+ ` unzip ${name}.zip -d ${name}\n` +
90
+ ` npx @sanning/proof case ${name}\n` +
91
+ "\n" +
92
+ "or, with the Python kernel (pip install sanning-proof):\n" +
93
+ "\n" +
94
+ ` sanning-proof case ${name}\n` +
95
+ "\n" +
96
+ "Exit codes: 0 verified, 1 failed, 2 not readable or a usage error,\n" +
97
+ "3 could not be settled (partial, pending, incomplete, not found), 4 mixed.\n" +
98
+ "\n" +
99
+ "To also look each witness up on the public record, add --gateways <url,url>.\n" +
100
+ "The lookup is reported on its own line and never changes the verdict word.\n" +
101
+ "With it, a witness that does not match exits 1.\n"
102
+ );
103
+ }
104
+
105
+ /**
106
+ * One way the index and the drop disagree, as data. What the index SAYS
107
+ * against what the folder HOLDS, and never a statement about any party.
108
+ */
109
+ export interface CaseFileFinding {
110
+ kind: CaseFileFindingKind;
111
+ /** The pack folder name, for the two pack kinds. */
112
+ ref?: string;
113
+ /** The file, from the case file's root, for the three file kinds. */
114
+ path?: string;
115
+ /** What the index lists for the file, for `case_index_file_differs`. */
116
+ listedSha256?: string;
117
+ /** What the file in the folder hashes to, for `case_index_file_differs`. */
118
+ sha256?: string;
119
+ /**
120
+ * Why the index is unreadable, or the assembler's reason a pack is absent,
121
+ * for `case_index_unreadable` and `case_index_names_absent`.
122
+ */
123
+ reason?: string;
124
+ /**
125
+ * The key the assembler says the absent pack would be signed by, as the index
126
+ * writes it, for `case_index_names_absent`.
127
+ */
128
+ publicKey?: string;
129
+ }
130
+
131
+ export interface CaseFileIndexReading {
132
+ /** `absent` (no index was handed in), `unreadable` or `read`. */
133
+ index: "absent" | "unreadable" | "read";
134
+ /**
135
+ * The index's `case_id`, a label the assembler wrote and nothing is compared
136
+ * with, or `null` where the index was not read.
137
+ */
138
+ caseId: string | null;
139
+ findings: CaseFileFinding[];
140
+ }
141
+
142
+ /**
143
+ * Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
144
+ * no recursion; the same scan as `json_depth_exceeds` in the Python twin, so
145
+ * both kernels refuse the same text. Given bytes, it reads each byte as the
146
+ * character of that code, which is the same answer for UTF-8: the six
147
+ * characters it reads are single bytes and no multi-byte sequence holds one.
148
+ * The CLI's `bundle.json` reader uses it too.
149
+ */
150
+ export function jsonDepthExceeds(text: string | Uint8Array, limit: number): boolean {
151
+ let depth = 0;
152
+ let inString = false;
153
+ let escaped = false;
154
+ const n = text.length;
155
+ for (let i = 0; i < n; i++) {
156
+ const ch =
157
+ typeof text === "string" ? text.charCodeAt(i) : (text as Uint8Array)[i];
158
+ if (inString) {
159
+ if (escaped) escaped = false;
160
+ else if (ch === 0x5c) escaped = true;
161
+ else if (ch === 0x22) inString = false;
162
+ } else if (ch === 0x22) {
163
+ inString = true;
164
+ } else if (ch === 0x5b || ch === 0x7b) {
165
+ depth++;
166
+ if (depth > limit) return true;
167
+ } else if (ch === 0x5d || ch === 0x7d) {
168
+ depth--;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
174
+ const utf8 = new TextEncoder();
175
+
176
+ /** Sorted by the UTF-8 bytes of the value (5.6.2), never by UTF-16 code unit. */
177
+ function byUtf8(a: string, b: string): number {
178
+ const x = utf8.encode(a);
179
+ const y = utf8.encode(b);
180
+ const n = Math.min(x.length, y.length);
181
+ for (let i = 0; i < n; i++) {
182
+ if (x[i] !== y[i]) return x[i] - y[i];
183
+ }
184
+ return x.length - y.length;
185
+ }
186
+
187
+ class Unreadable extends Error {
188
+ constructor(readonly reason: string) {
189
+ super(reason);
190
+ }
191
+ }
192
+
193
+ const has = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
194
+
195
+ const isObject = (v: unknown): v is Record<string, unknown> =>
196
+ typeof v === "object" && v !== null && !Array.isArray(v);
197
+
198
+ /** An own member, and never one the prototype chain answers for. */
199
+ const own = (o: Record<string, unknown>, k: string): unknown => (has(o, k) ? o[k] : undefined);
200
+
201
+ function fileList(value: unknown, where: string): [string, string][] {
202
+ if (!Array.isArray(value)) throw new Unreadable(`${where} is not an array`);
203
+ const out: [string, string][] = [];
204
+ value.forEach((entry, i) => {
205
+ if (
206
+ !(
207
+ isObject(entry) &&
208
+ typeof own(entry, "path") === "string" &&
209
+ typeof own(entry, "sha256") === "string"
210
+ )
211
+ ) {
212
+ throw new Unreadable(`${where}[${i}] is not an object with a string path and sha256`);
213
+ }
214
+ out.push([entry["path"] as string, entry["sha256"] as string]);
215
+ });
216
+ return out;
217
+ }
218
+
219
+ interface ReadIndex {
220
+ caseId: string;
221
+ packs: [string, [string, string][]][];
222
+ absent: [string, string | null][];
223
+ report: [string, string][];
224
+ }
225
+
226
+ /**
227
+ * The index, read under the JSON rule 5.4.2 rule 7 pins, and checked for every
228
+ * REQUIRED member and its type. JCS is not required and an unknown member is
229
+ * ignored (5.6.2). The FIRST problem is the reason.
230
+ */
231
+ function readIndex(raw: Uint8Array): ReadIndex {
232
+ // Scanned as BYTES, before any decode, so both kernels refuse the same index
233
+ // in the same order whatever it holds.
234
+ if (jsonDepthExceeds(raw, MAX_INDEX_DEPTH)) {
235
+ throw new Unreadable(`nests deeper than ${MAX_INDEX_DEPTH} levels`);
236
+ }
237
+ let value: unknown;
238
+ try {
239
+ value = jcsParse(raw);
240
+ } catch {
241
+ throw new Unreadable("not readable as JSON");
242
+ }
243
+ if (!isObject(value)) throw new Unreadable("not a JSON object");
244
+ if (own(value, "contents_version") !== CASE_FILE_VERSION) {
245
+ throw new Unreadable(`contents_version is not ${CASE_FILE_VERSION}`);
246
+ }
247
+ const caseId = own(value, "case_id");
248
+ if (typeof caseId !== "string") throw new Unreadable("case_id is not a string");
249
+ const packs = own(value, "packs");
250
+ if (!Array.isArray(packs)) throw new Unreadable("packs is not an array");
251
+ const listedPacks: [string, [string, string][]][] = [];
252
+ packs.forEach((entry, i) => {
253
+ if (!(isObject(entry) && typeof own(entry, "ref") === "string")) {
254
+ throw new Unreadable(`packs[${i}] is not an object with a string ref`);
255
+ }
256
+ listedPacks.push([entry["ref"] as string, fileList(own(entry, "files"), `packs[${i}].files`)]);
257
+ });
258
+ const absent = own(value, "absent");
259
+ if (!Array.isArray(absent)) throw new Unreadable("absent is not an array");
260
+ const listedAbsent: [string, string | null][] = [];
261
+ absent.forEach((entry, i) => {
262
+ if (!(isObject(entry) && typeof own(entry, "reason") === "string")) {
263
+ throw new Unreadable(`absent[${i}] is not an object with a string reason`);
264
+ }
265
+ const key = own(entry, "public_key");
266
+ if (has(entry, "public_key") && typeof key !== "string") {
267
+ throw new Unreadable(`absent[${i}].public_key is not a string`);
268
+ }
269
+ listedAbsent.push([entry["reason"] as string, has(entry, "public_key") ? (key as string) : null]);
270
+ });
271
+ const report = fileList(own(value, "report"), "report");
272
+ return { caseId, packs: listedPacks, absent: listedAbsent, report };
273
+ }
274
+
275
+ /**
276
+ * Compare a case file's index with the folder it sits at the root of.
277
+ *
278
+ * `index` is the bytes of `contents.json`, or `null` where the folder holds
279
+ * none. `files` is every regular file in the folder, as `{ path, sha256 }`
280
+ * with `path` from the folder's root, `/`-separated; only the files under
281
+ * `packs/` and `report/` are compared, so a caller may list the rest or not. A
282
+ * folder with no index is a folder of packs, with no finding.
283
+ *
284
+ * Findings come in the order of `CASE_FILE_FINDING_KINDS`, and within a kind in
285
+ * the order of their `ref` or `path` by UTF-8 bytes; `case_index_names_absent`
286
+ * is in the index's own order, one per `absent[]` entry. An unreadable index is
287
+ * one finding and no other comparison runs.
288
+ *
289
+ * Nothing here is a verdict, and nothing here is ever an input to `readCase`.
290
+ */
291
+ export function readCaseFileIndex(
292
+ index: Uint8Array | null,
293
+ files: readonly { path: string; sha256: string }[],
294
+ ): CaseFileIndexReading {
295
+ if (index === null || index === undefined) {
296
+ return { index: "absent", caseId: null, findings: [] };
297
+ }
298
+ let read: ReadIndex;
299
+ try {
300
+ read = readIndex(index);
301
+ } catch (e) {
302
+ if (!(e instanceof Unreadable)) throw e;
303
+ return {
304
+ index: "unreadable",
305
+ caseId: null,
306
+ findings: [{ kind: "case_index_unreadable", reason: e.reason }],
307
+ };
308
+ }
309
+
310
+ const here = new Map<string, string>();
311
+ for (const entry of files) {
312
+ const path = isObject(entry) ? own(entry, "path") : undefined;
313
+ const digest = isObject(entry) ? own(entry, "sha256") : undefined;
314
+ if (typeof path === "string" && typeof digest === "string" && !here.has(path)) {
315
+ here.set(path, digest);
316
+ }
317
+ }
318
+
319
+ const listedRefs = new Set(read.packs.map(([ref]) => ref));
320
+ // The first listing of a path wins, in index order: packs in order, each
321
+ // pack's files in order, then report/. A path listed twice is compared once.
322
+ const listed = new Map<string, string>();
323
+ for (const [, packFiles] of read.packs) {
324
+ for (const [path, digest] of packFiles) if (!listed.has(path)) listed.set(path, digest);
325
+ }
326
+ for (const [path, digest] of read.report) if (!listed.has(path)) listed.set(path, digest);
327
+
328
+ const findings: CaseFileFinding[] = [];
329
+
330
+ for (const ref of [...listedRefs].sort(byUtf8)) {
331
+ if (!here.has(`packs/${ref}/bundle.json`)) {
332
+ findings.push({ kind: "case_index_pack_missing", ref });
333
+ }
334
+ }
335
+
336
+ const presentRefs = new Set<string>();
337
+ for (const path of here.keys()) {
338
+ const parts = path.split("/");
339
+ if (parts.length === 3 && parts[0] === "packs" && parts[2] === "bundle.json") {
340
+ presentRefs.add(parts[1]);
341
+ }
342
+ }
343
+ for (const ref of [...presentRefs].filter((r) => !listedRefs.has(r)).sort(byUtf8)) {
344
+ findings.push({ kind: "case_index_pack_unlisted", ref });
345
+ }
346
+
347
+ const listedPaths = [...listed.keys()].sort(byUtf8);
348
+ for (const path of listedPaths) {
349
+ if (!here.has(path)) findings.push({ kind: "case_index_file_missing", path });
350
+ }
351
+
352
+ const compared = (path: string): boolean => {
353
+ if (path.startsWith("report/")) return true;
354
+ const parts = path.split("/");
355
+ return parts.length >= 3 && parts[0] === "packs" && listedRefs.has(parts[1]);
356
+ };
357
+ for (const path of [...here.keys()].sort(byUtf8)) {
358
+ if (!listed.has(path) && compared(path)) {
359
+ findings.push({ kind: "case_index_file_unlisted", path });
360
+ }
361
+ }
362
+
363
+ for (const path of listedPaths) {
364
+ const actual = here.get(path);
365
+ if (actual !== undefined && actual !== listed.get(path)) {
366
+ findings.push({
367
+ kind: "case_index_file_differs",
368
+ path,
369
+ listedSha256: listed.get(path) as string,
370
+ sha256: actual,
371
+ });
372
+ }
373
+ }
374
+
375
+ for (const [reason, key] of read.absent) {
376
+ findings.push(
377
+ key === null
378
+ ? { kind: "case_index_names_absent", reason }
379
+ : { kind: "case_index_names_absent", reason, publicKey: key },
380
+ );
381
+ }
382
+
383
+ return { index: "read", caseId: read.caseId, findings };
384
+ }
385
+
386
+ /**
387
+ * The house wording for one finding: what the index says against what the
388
+ * folder holds, and the assembler's own statement for an absent pack. Never a
389
+ * sentence about a party. Both kernels return this text.
390
+ */
391
+ export function renderCaseFileFinding(finding: CaseFileFinding): string {
392
+ switch (finding.kind) {
393
+ case "case_index_unreadable":
394
+ return `contents.json is not an index this reader can read: ${finding.reason}`;
395
+ case "case_index_pack_missing":
396
+ return `the index lists pack ${finding.ref}; it is not here`;
397
+ case "case_index_pack_unlisted":
398
+ return `pack ${finding.ref} is here; the index does not list it`;
399
+ case "case_index_file_missing":
400
+ return `the index lists ${finding.path}; it is not here`;
401
+ case "case_index_file_unlisted":
402
+ return `${finding.path} is here; the index does not list it`;
403
+ case "case_index_file_differs":
404
+ return (
405
+ `the index lists ${finding.path} with SHA-256 ${finding.listedSha256}; ` +
406
+ `the file here hashes to ${finding.sha256}`
407
+ );
408
+ case "case_index_names_absent":
409
+ if (finding.publicKey !== undefined) {
410
+ return (
411
+ "the assembler says a pack signed by key " +
412
+ `${finding.publicKey} is absent: ${finding.reason}`
413
+ );
414
+ }
415
+ return `the assembler says a pack is absent: ${finding.reason}`;
416
+ default:
417
+ return (finding as CaseFileFinding).kind;
418
+ }
419
+ }