@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
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // gateway/transport concerns are consumer-layer logic composed above it.
13
13
 
14
14
  // ACCEPTED_SPEC_MAJORS is exported for parity with the sibling kernels'
15
- // registries (Python ACCEPTED_SPEC_VERSIONS, Go SupportedSpecMajor): a consumer
15
+ // registries (Python ACCEPTED_SPEC_MAJORS, Go SupportedSpecMajor): a consumer
16
16
  // can see which profiles this build admits without inferring it from probe
17
17
  // calls. It is frozen at its definition (`Object.freeze`, typed
18
18
  // `readonly string[]`) because the verifier reads that same array — an exported
@@ -60,6 +60,7 @@ export type { ContentRole, Envelope, Subject, VerificationResult, VerifyOptions
60
60
  // bytes, and only for events whose verification bound them to the committed
61
61
  // content_hash. Additive; verifyEvidenceBundle's behavior is unchanged.
62
62
  export {
63
+ ACCEPTED_EVIDENCE_MAJORS,
63
64
  ANCHOR_TRACE_BODY_TYPE,
64
65
  EXPORT_BODY_TYPE,
65
66
  STAMP_BODY_VERSION,
@@ -128,6 +129,8 @@ export type {
128
129
  // derivation to it, and diff its copy against ours. Additive — no existing
129
130
  // export changes.
130
131
  export {
132
+ PACK_GRADE_CAUSE_KINDS,
133
+ PACK_VERDICTS,
131
134
  accountDisclosure,
132
135
  completenessNotes,
133
136
  gradeCauses,
@@ -162,7 +165,14 @@ export type {
162
165
  // optional fourth argument, so a surface gets one verdict; omitting it is the
163
166
  // pre-§5.4 behavior, unchanged. Additive: no existing export changes.
164
167
  export {
168
+ DECLARATION_CONTENT_KINDS,
169
+ DECLARATION_DEVIATION_KINDS,
170
+ DECLARATION_NOTE_KINDS,
171
+ DECLARATION_ROLES,
172
+ DECLARATION_VERDICTS,
165
173
  DECLARATION_VERSION,
174
+ DECLARATION_VERSION_V2,
175
+ DECLARATION_VERSIONS,
166
176
  checkDeclaration,
167
177
  declarationClaims,
168
178
  packRecords,
@@ -180,6 +190,7 @@ export type {
180
190
  DeclarationDeviationKind,
181
191
  DeclarationNote,
182
192
  DeclarationNoteKind,
193
+ DeclarationRole,
183
194
  DeclarationVerdict,
184
195
  PackRecord,
185
196
  } from "./declaration.js";
@@ -206,20 +217,53 @@ export {
206
217
  CASE_EXCEPTION_KINDS,
207
218
  CASE_QUARANTINED_PROFILES,
208
219
  CASE_RECORD_PROFILES,
220
+ CASE_NOTE_KINDS,
221
+ CASE_RELIANCE,
209
222
  CASE_VERDICTS,
223
+ CONTENT_WITHHELD_BY_DECLARATION,
224
+ VERDICT_DISPLAY_TITLES,
225
+ WITNESS_NOT_LOOKED_UP,
226
+ WITNESS_STATES,
210
227
  readCase,
228
+ renderCaseNote,
211
229
  renderCaseSummary,
212
230
  renderException,
231
+ renderWitnessLine,
213
232
  } from "./case.js";
214
233
  export type {
215
234
  CaseException,
216
235
  CaseExceptionKind,
236
+ CaseNote,
237
+ CaseNoteKind,
217
238
  CasePack,
239
+ CasePackReading,
218
240
  CaseReading,
219
241
  CaseRecordReading,
220
242
  CaseVerdict,
243
+ WitnessLine,
244
+ WitnessState,
221
245
  } from "./case.js";
222
246
 
247
+ // The case file's index (src/case-file.ts, evidence-bundle.md §5.6, A25):
248
+ // compared with the drop it describes, with NO verdict contribution. The index
249
+ // is never an input to `readCase`. Pure: bytes and a file list in.
250
+ export {
251
+ CASE_FILE_FINDING_KINDS,
252
+ CASE_FILE_INDEX_NAME,
253
+ CASE_FILE_VERSION,
254
+ HOW_TO_VERIFY_NAME,
255
+ REPORT_README_FIRST_LINE,
256
+ howToVerifyText,
257
+ isPackRef,
258
+ readCaseFileIndex,
259
+ renderCaseFileFinding,
260
+ } from "./case-file.js";
261
+ export type {
262
+ CaseFileFinding,
263
+ CaseFileFindingKind,
264
+ CaseFileIndexReading,
265
+ } from "./case-file.js";
266
+
223
267
  // Chain reading (src/chain.ts): following the pointer every sanning.events/v2
224
268
  // record carries to the record before it, over the records ONE bundle presents.
225
269
  //
@@ -258,13 +302,25 @@ export type {
258
302
  // signed, offline-verifiable sanning.evidence.export/v2 artifact from a source
259
303
  // anchor-trace bundle + operator attestations + an exporter Ed25519 key. The
260
304
  // inverse of verifyEvidenceBundle's verifyExportBody dispatch.
305
+ // One exit-code table for every verb (0.13.0): the verdict word to the exit
306
+ // code, and the help text every verb prints. Additive.
307
+ export {
308
+ EXIT_CODES,
309
+ EXIT_CODES_HELP,
310
+ EXIT_FAILED,
311
+ EXIT_MALFORMED,
312
+ EXIT_MIXED,
313
+ EXIT_UNDETERMINED,
314
+ EXIT_VERIFIED,
315
+ exitCodeFor,
316
+ } from "./exit-codes.js";
261
317
  export { attestationSignatureToHex, composeExport } from "./compose.js";
262
318
  export type { ComposeExportOptions, ComposeExportResult, ExporterKey } from "./compose.js";
263
319
 
264
320
  // Agent inclusion-proof bundle verification: the
265
321
  // sanning.agent.proof/v1 bundle. The CLI sniffs spec_version and routes here so
266
322
  // one verifier covers both agent and anchor bundles.
267
- export { AGENT_PROOF_SPEC_PREFIX, isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
323
+ export { AGENT_PROOF_SPEC_VERSION, isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
268
324
  export type {
269
325
  AgentProofBundle,
270
326
  AgentProofResult,
@@ -278,17 +278,19 @@ const HELD_UNCHECKED_RESULTS: readonly string[] = [
278
278
 
279
279
  /**
280
280
  * The keys that make a part carry its content INLINE, in the order they are
281
- * read. Named rather than inferred: `bytes` is A2A's spelling of an inline file
282
- * part, and `data` and `content` are the two other spellings in the wild. The
283
- * FIRST of them present with a value that is not `null` is the inline content,
284
- * so a part spelling it twice reads one way in both kernels.
281
+ * read. Named rather than inferred: `bytes`, `data` and `content` are the
282
+ * spellings in the wild. None is A2A 1.0.0's: its `Part` has no `kind` and
283
+ * carries a file as `raw` or `url`, so a literal A2A part reads
284
+ * `part_not_a_file` (envelope-spec §4.1, A19). The FIRST of them present with
285
+ * a value that is not `null` is the inline content, so a part spelling it
286
+ * twice reads one way in both kernels.
285
287
  *
286
288
  * A key present with `null` is an ABSENT inline value, on A14's ruling that an
287
- * absent value and one written as `null` name one thing. That matters: an A2A
288
- * `FilePart` with `uri` set carries `"bytes": null`, and so does proto3 JSON,
289
- * and reading those as inline made a conformant DESCRIPTOR part uncheckable
290
- * (proof#156 gate). They fall through to the descriptor rule, which is what
291
- * they are.
289
+ * absent value and one written as `null` name one thing. That matters: a
290
+ * proto3 JSON encoder can write an unset field as `"bytes": null`, and reading
291
+ * that as inline made a conformant DESCRIPTOR part uncheckable
292
+ * (proof#156 gate). Such a part falls through to the descriptor rule, which is
293
+ * what it is.
292
294
  */
293
295
  // A conformant DESCRIPTOR part that also carries a `content` or `data` key
294
296
  // meaning something else (a filename, a metadata blob) is reclassified by this
package/src/types.ts CHANGED
@@ -48,6 +48,11 @@ export interface VerifyOptions {
48
48
  // Content bind: the caller's own hash of an artifact (e.g. the in-browser
49
49
  // hash of a user's file) to match against the hashes the envelope commits to.
50
50
  expectedContentHash?: string;
51
+ // NARROWS the accept-set for this call and never widens it: the set used is
52
+ // `ACCEPTED_SPEC_MAJORS` intersected with this list, so a caller cannot
53
+ // admit a profile this kernel does not. The Python twin is
54
+ // `accepted_spec_majors` (P-10, 0.13.0).
55
+ acceptedSpecMajors?: readonly string[];
51
56
  }
52
57
 
53
58
  export interface VerificationResult {
package/src/verifier.ts CHANGED
@@ -23,7 +23,7 @@ import type { ContentRole, Envelope, VerificationResult, VerifyOptions } from ".
23
23
  // profile-specific dialect (no underscore strip, that is mlflow-only).
24
24
  //
25
25
  // Exported for parity with the sibling kernels' registries — Python
26
- // `ACCEPTED_SPEC_VERSIONS`, Go `SupportedSpecMajor` — so the accept-set is
26
+ // `ACCEPTED_SPEC_MAJORS`, Go `SupportedSpecMajor` — so the accept-set is
27
27
  // assertable rather than inferable: test/accept-set.test.ts holds it to the
28
28
  // ratified §4/§5 set transcribed in cross-kernel/accept-set.json, and the
29
29
  // cross-kernel gate probes one signed envelope per identifier. This entry list
@@ -231,6 +231,16 @@ const JCS_BOM = "";
231
231
  * Throws for anything that is not conformant; every call site treats a throw as
232
232
  * "this reader cannot read these bytes" and names the check that failed.
233
233
  */
234
+ /**
235
+ * Bytes as strict UTF-8, a leading byte-order mark KEPT as a character.
236
+ * `utf8_strict` in the Python twin's `canonicalize.py` is the same rule, and
237
+ * `jcsParse` below performs the identical decode. Throws on bytes that are not
238
+ * UTF-8.
239
+ */
240
+ export function utf8Strict(bytes: Uint8Array): string {
241
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
242
+ }
243
+
234
244
  export function jcsParse(bytes: Uint8Array): unknown {
235
245
  const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
236
246
  if (text.startsWith(JCS_BOM)) throw new Error("jcsParse: leading byte-order mark");
@@ -311,9 +321,17 @@ function hasLoneSurrogate(s: string): boolean {
311
321
  // `<major>.<minor>` where minor is `[0-9]+`. A non-numeric minor suffix is
312
322
  // malformed, not a tolerated future version — matching the Python kernel's
313
323
  // 0.1.1 semantics (the TS/Go fail-open lenience was closed deliberately).
314
- export function specVersionSupported(specVersion: string): boolean {
324
+ //
325
+ // `accepted` NARROWS the registry and never widens it, as the Python twin's
326
+ // `spec_version_supported(spec_version, accepted)` does (P-10, 0.13.0).
327
+ export function specVersionSupported(
328
+ specVersion: string,
329
+ accepted?: readonly string[],
330
+ ): boolean {
315
331
  if (typeof specVersion !== "string" || specVersion === "") return false;
316
- return ACCEPTED_SPEC_MAJORS.some((m) => {
332
+ const registry =
333
+ accepted === undefined ? ACCEPTED_SPEC_MAJORS : ACCEPTED_SPEC_MAJORS.filter((m) => accepted.includes(m));
334
+ return registry.some((m) => {
317
335
  if (specVersion === m) return true;
318
336
  if (!specVersion.startsWith(`${m}.`)) return false;
319
337
  const minor = specVersion.slice(m.length + 1);
@@ -456,22 +474,26 @@ export function contentHashes(env: Envelope): { role: ContentRole; hash: string
456
474
  // signature, so stripping it to fake "external" — or injecting one to fake
457
475
  // "inline" — breaks the signature).
458
476
  //
459
- // The second argument accepts the legacy `expectedContentHash` string form
460
- // or a VerifyOptions object ({ payloadBytes, expectedContentHash }).
477
+ // The second argument is a VerifyOptions object ({ payloadBytes,
478
+ // expectedContentHash, acceptedSpecMajors }). Until 0.13.0 it could also be a
479
+ // bare `expectedContentHash` string; that overload is gone (P-10), so the two
480
+ // kernels take one options shape: write `{ expectedContentHash: hash }`.
461
481
  export async function verifyEnvelope(
462
482
  env: Envelope,
463
- optionsOrContentHash?: string | VerifyOptions,
483
+ options?: VerifyOptions,
464
484
  ): Promise<VerificationResult> {
465
- const opts: VerifyOptions =
466
- typeof optionsOrContentHash === "string"
467
- ? { expectedContentHash: optionsOrContentHash }
468
- : (optionsOrContentHash ?? {});
485
+ if (typeof options === "string") {
486
+ throw new TypeError(
487
+ "verifyEnvelope: pass { expectedContentHash } rather than a bare string (0.13.0)",
488
+ );
489
+ }
490
+ const opts: VerifyOptions = options ?? {};
469
491
  // REFUSE an unknown option key rather than ignore it. `expected_content_hash`
470
492
  // — the Python kernel's spelling — previously fell through as `undefined`, so
471
493
  // the content bind silently never ran and the caller got a clean pass with no
472
494
  // errors at all. A check that quietly does not happen is worse than one that
473
495
  // fails: the caller believes they verified an artifact they never checked.
474
- const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash"]);
496
+ const KNOWN_VERIFY_OPTIONS = new Set(["payloadBytes", "expectedContentHash", "acceptedSpecMajors"]);
475
497
  const unknownOptions = Object.keys(opts).filter((k) => !KNOWN_VERIFY_OPTIONS.has(k));
476
498
  if (unknownOptions.length > 0) {
477
499
  throw new TypeError(
@@ -551,7 +573,7 @@ async function verifyEnvelopeChecked(
551
573
  };
552
574
  }
553
575
 
554
- const specVersionOk = specVersionSupported(env.spec_version);
576
+ const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
555
577
  if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
556
578
 
557
579
  // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
@@ -625,7 +647,7 @@ async function verifyEnvelopeChecked(
625
647
  let signatureOk = false;
626
648
  try {
627
649
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
628
- signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
650
+ signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
629
651
  if (!signatureOk) errors.push("Ed25519 signature verification failed");
630
652
  } catch (e) {
631
653
  errors.push(`signature verification error: ${stringifyErr(e)}`);
@@ -0,0 +1,229 @@
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
+
27
+ import { inflateRawSync } from "node:zlib";
28
+
29
+ import { utf8Strict } from "./verifier.js";
30
+
31
+ /** More entries than this refuse the archive: the walk's own bound on names. */
32
+ export const MAX_ZIP_ENTRIES = 50_000;
33
+ /** More uncompressed bytes than this, across every entry, refuse the archive. */
34
+ export const MAX_ZIP_BYTES = 2 ** 30;
35
+ /** A name nested deeper than this many folders refuses the archive. */
36
+ export const MAX_ZIP_DEPTH = 32;
37
+
38
+ const EOCD = 0x06054b50;
39
+ const CENTRAL = 0x02014b50;
40
+ const LOCAL = 0x04034b50;
41
+ const S_IFMT = 0o170000;
42
+ const S_IFLNK = 0o120000;
43
+ const UNIX = 3;
44
+
45
+ /** The archive cannot be read as a case file. `message` is the whole report, identical in both kernels. */
46
+ export class ZipReadError extends Error {
47
+ constructor(message: string) {
48
+ super(message);
49
+ this.name = "ZipReadError";
50
+ }
51
+ }
52
+
53
+ /**
54
+ * One file entry. `refused` is why the entry is reported and not read, or
55
+ * `null`; `data` is its bytes where it was read.
56
+ */
57
+ export interface ZipEntry {
58
+ name: string;
59
+ refused: string | null;
60
+ data: Uint8Array | null;
61
+ }
62
+
63
+ const CRC_TABLE = (() => {
64
+ const table = new Uint32Array(256);
65
+ for (let n = 0; n < 256; n++) {
66
+ let c = n;
67
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
68
+ table[n] = c >>> 0;
69
+ }
70
+ return table;
71
+ })();
72
+
73
+ function crc32(bytes: Uint8Array): number {
74
+ let c = 0xffffffff;
75
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
76
+ return (c ^ 0xffffffff) >>> 0;
77
+ }
78
+
79
+ function refusal(name: string): string | null {
80
+ if (name.startsWith("/")) return "an absolute name";
81
+ if (name.includes("\\")) return "a name with a backslash";
82
+ if (name.includes("\u0000")) return "a name with a NUL";
83
+ for (const segment of name.split("/")) {
84
+ if (segment === "" || segment === "." || segment === "..") {
85
+ return "a name with an empty, . or .. segment";
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
92
+ if (a.length !== b.length) return false;
93
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Every FILE entry of a ZIP archive, in central-directory order. A directory
99
+ * entry (a name ending in `/`) is not a file and is not returned. Throws
100
+ * `ZipReadError`.
101
+ */
102
+ export function readZip(data: Uint8Array): ZipEntry[] {
103
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
104
+ const n = data.length;
105
+ const u32 = (at: number) => view.getUint32(at, true);
106
+ const u16 = (at: number) => view.getUint16(at, true);
107
+ // The end-of-central-directory record: 22 bytes plus a comment of at most
108
+ // 65535, searched backwards so the LAST signature wins.
109
+ let eocd = -1;
110
+ const lowest = Math.max(0, n - 22 - 0xffff);
111
+ for (let at = n - 22; at >= lowest; at--) {
112
+ if (u32(at) === EOCD && at + 22 + u16(at + 20) === n) {
113
+ eocd = at;
114
+ break;
115
+ }
116
+ }
117
+ if (eocd < 0) throw new ZipReadError("is not a readable .zip archive");
118
+ const disk = u16(eocd + 4);
119
+ const cdDisk = u16(eocd + 6);
120
+ const hereCount = u16(eocd + 8);
121
+ const count = u16(eocd + 10);
122
+ const cdSize = u32(eocd + 12);
123
+ const cdOffset = u32(eocd + 16);
124
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
125
+ throw new ZipReadError("is a ZIP64 archive, which case does not read");
126
+ }
127
+ if (disk !== 0 || cdDisk !== 0 || hereCount !== count) {
128
+ throw new ZipReadError("spans more than one disk, which case does not read");
129
+ }
130
+ if (count > MAX_ZIP_ENTRIES) {
131
+ throw new ZipReadError(`holds more than ${MAX_ZIP_ENTRIES} entries; refusing to read it`);
132
+ }
133
+ if (cdOffset + cdSize > eocd) throw new ZipReadError("is not a readable .zip archive");
134
+
135
+ const entries: ZipEntry[] = [];
136
+ const names = new Set<string>();
137
+ let total = 0;
138
+ let at = cdOffset;
139
+ for (let e = 0; e < count; e++) {
140
+ if (at + 46 > eocd || u32(at) !== CENTRAL) {
141
+ throw new ZipReadError("is not a readable .zip archive");
142
+ }
143
+ const madeBy = u16(at + 4);
144
+ const flags = u16(at + 8);
145
+ const method = u16(at + 10);
146
+ const crc = u32(at + 16);
147
+ const csize = u32(at + 20);
148
+ const usize = u32(at + 24);
149
+ const nameLen = u16(at + 28);
150
+ const extraLen = u16(at + 30);
151
+ const commentLen = u16(at + 32);
152
+ const external = u32(at + 38);
153
+ const localAt = u32(at + 42);
154
+ const rawName = data.subarray(at + 46, at + 46 + nameLen);
155
+ at += 46 + nameLen + extraLen + commentLen;
156
+ if (at > eocd) throw new ZipReadError("is not a readable .zip archive");
157
+ let name: string;
158
+ try {
159
+ name = utf8Strict(rawName);
160
+ } catch {
161
+ throw new ZipReadError(
162
+ "holds a name that is not valid UTF-8; rename it, then run case again",
163
+ );
164
+ }
165
+ if (names.has(name)) throw new ZipReadError(`holds ${name} twice`);
166
+ names.add(name);
167
+ if (name.endsWith("/")) continue;
168
+ if (name.split("/").length - 1 > MAX_ZIP_DEPTH) {
169
+ throw new ZipReadError(
170
+ `is nested more than ${MAX_ZIP_DEPTH} folders deep; refusing to read it`,
171
+ );
172
+ }
173
+ let refused = refusal(name);
174
+ if (refused === null && flags & 0x1) refused = "an encrypted entry";
175
+ if (
176
+ refused === null &&
177
+ madeBy >> 8 === UNIX &&
178
+ ((external >>> 16) & S_IFMT) === S_IFLNK
179
+ ) {
180
+ refused = "a symbolic link";
181
+ }
182
+ if (refused === null && method !== 0 && method !== 8) {
183
+ refused = "a compression method other than stored or deflate";
184
+ }
185
+ if (refused !== null) {
186
+ entries.push({ name, refused, data: null });
187
+ continue;
188
+ }
189
+
190
+ total += usize;
191
+ if (total > MAX_ZIP_BYTES) {
192
+ throw new ZipReadError(
193
+ `holds more than ${MAX_ZIP_BYTES} bytes uncompressed; refusing to read it`,
194
+ );
195
+ }
196
+ const unreadable = () => new ZipReadError(`holds ${name}, which cannot be read`);
197
+ if (localAt + 30 > cdOffset || u32(localAt) !== LOCAL) throw unreadable();
198
+ const localNameLen = u16(localAt + 26);
199
+ const localExtraLen = u16(localAt + 28);
200
+ if (!sameBytes(data.subarray(localAt + 30, localAt + 30 + localNameLen), rawName)) {
201
+ throw unreadable();
202
+ }
203
+ const start = localAt + 30 + localNameLen + localExtraLen;
204
+ if (start + csize > cdOffset) throw unreadable();
205
+ const packed = data.subarray(start, start + csize);
206
+ let body: Uint8Array;
207
+ if (method === 0) {
208
+ body = packed;
209
+ } else {
210
+ // `info: true` hands back the engine, whose `bytesWritten` is how many
211
+ // input bytes the stream consumed. Anything left over is data after the
212
+ // end of the deflate stream, which Python's `unused_data` refuses too.
213
+ let result: { buffer: Buffer; engine: { bytesWritten: number } };
214
+ try {
215
+ result = inflateRawSync(packed, {
216
+ info: true,
217
+ maxOutputLength: usize + 1,
218
+ }) as unknown as { buffer: Buffer; engine: { bytesWritten: number } };
219
+ } catch {
220
+ throw unreadable();
221
+ }
222
+ if (result.engine.bytesWritten !== packed.length) throw unreadable();
223
+ body = new Uint8Array(result.buffer.buffer, result.buffer.byteOffset, result.buffer.length);
224
+ }
225
+ if (body.length !== usize || crc32(body) !== crc) throw unreadable();
226
+ entries.push({ name, refused: null, data: body.slice() });
227
+ }
228
+ return entries;
229
+ }
package/vectors/README.md CHANGED
@@ -107,17 +107,34 @@ happened, and the reason this file moved here.
107
107
 
108
108
  | Key | Meaning |
109
109
  |---|---|
110
- | `ruleVersion` | Bumped when a case's expectation changes **or a case is added**. An implementation asserts the version it was written against, so a table it does not understand fails loudly rather than passing vacuously. Currently **3**. |
111
- | `kernelEventClasses` | The four per-event outcome classes a verification result can carry, named so a case can be written as counts rather than as a hand-built bundle. |
110
+ | `ruleVersion` | Bumped when a case's expectation changes **or a case is added**. An implementation asserts the version it was written against, so a table it does not understand fails loudly rather than passing vacuously. Currently **6**: `evidence-bundle.md` §5.4.5, where a record withheld by a readable declaration is counted apart and holds the pack at nothing. |
111
+ | `kernelEventClasses` | The six per-event outcome classes a verification result can carry, named so a case can be written as counts rather than as a hand-built bundle. The two added at rule version 6, `withheldBound` and `withheldUnbound`, carry an `eventId` and a `payloadBindingOk`; `howToUse` says how an adapter builds them. |
112
112
  | `cases[].kernel` | The verification result to grade, as class counts. |
113
113
  | `cases[].logs` | The disclosure account: how many records the bundle presents, and how many disclosed usable bytes. |
114
114
  | `cases[].omissions` | Completeness notes — a checkpoint's signed `leaf_count` against the records the pack presents for it. |
115
- | `cases[].expect` | The verdict, the three counts, and how many causes the grade must carry. |
115
+ | `cases[].expect` | The verdict, the three counts, how many causes the grade must carry, and, from rule version 6, `recordsWithheld` where a case pins it. |
116
116
 
117
117
  Nothing in a case names a transport. A directory read and a browser drop produce
118
118
  the same account and must produce the same verdict; a table that encoded one
119
119
  surface's mechanism would let the other one drift while staying green.
120
120
 
121
+ ## `case-file-index.json`
122
+
123
+ How a reader compares a case file's index, `contents.json`, with the folder it
124
+ sits at the root of (`evidence-bundle.md` §5.6.3, v1.20 A25). One pure function
125
+ in both kernels, `readCaseFileIndex` in [`../src/case-file.ts`](../src/case-file.ts)
126
+ and `read_case_file_index` in `src/sanning_proof/case_file.py`, asserted against
127
+ this table by [`../test/case-file-index.test.ts`](../test/case-file-index.test.ts)
128
+ **and by the Python kernel against the same bytes**, in
129
+ `tests/test_case_file_index.py`. Each row gives the index bytes as hex (or
130
+ `null` for a folder with no index) and every file in the folder with its
131
+ SHA-256, and expects the index state, its `case_id` label and the findings in
132
+ the pinned order. **No finding moves a verdict**: the index is never an input
133
+ to the case reading, and a surface that took one from it would be trusting
134
+ whoever assembled the drop. Written by
135
+ `tools/gen-vectors/gen_case_file_index_vectors.py`, whose expectations are
136
+ literals.
137
+
121
138
  ## `case-split.json`
122
139
 
123
140
  How `case` turns the folders it was handed into packs: the verify page's
@@ -130,8 +147,9 @@ because the page's code lives in the page. Two tables and the limits:
130
147
  given as bytes, or a folder filled with N empty files), the arguments, and
131
148
  the packs, the outside paths and the paths that are not regular files, or
132
149
  the refusal. The rows include the decision file unpacked, a symbolic link
133
- under `logs/`, each side of the depth and entry limits, and names that are
134
- not UTF-8.
150
+ under `logs/`, each side of the depth and entry limits, names that are not
151
+ UTF-8, and (v1.20, A25) a case file unpacked and as a `.zip` argument, with
152
+ the entries a reader lists and never reads and the archives it refuses.
135
153
  - `parses`: how `bundle.json` bytes become JSON through the reader `verify`
136
154
  and `case` share, including each side of the nesting limit.
137
155
  - `limits`: the walk depth (32), the entry count (50,000) and the JSON nesting