@sanning/proof 0.15.0 → 0.17.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/src/file-match.ts CHANGED
@@ -70,6 +70,30 @@ export const NOT_READ_REASONS = [
70
70
 
71
71
  export type NotReadReason = (typeof NOT_READ_REASONS)[number];
72
72
 
73
+ /**
74
+ * Why a file was named in the report and never hashed (5.7.5). A skipped file
75
+ * is listed, never dropped: every file a command found is matched, unmatched or
76
+ * skipped, so "no match" is never read as "no file was there".
77
+ */
78
+ export const SKIPPED_REASONS = [
79
+ "symbolic_link",
80
+ "not_regular",
81
+ "unreadable",
82
+ "too_large",
83
+ "refused",
84
+ ] as const;
85
+
86
+ export type SkippedReason = (typeof SKIPPED_REASONS)[number];
87
+
88
+ const SKIPPED_TEXT: Record<SkippedReason, string> = {
89
+ symbolic_link: "a symbolic link: not followed, not read",
90
+ not_regular: "not a regular file: not read",
91
+ unreadable: "could not be read",
92
+ too_large: "past the 1 GiB read from folders in one run: not read",
93
+ // Followed by the reader's own words, `renderSkipped`.
94
+ refused: "a .zip entry the reader refused",
95
+ };
96
+
73
97
  const REASON_TEXT: Record<NotReadReason, string> = {
74
98
  no_records: "this bundle carries no records this reading can read",
75
99
  record_unreadable: "its record bytes cannot be read",
@@ -94,6 +118,14 @@ export interface FileCommitment {
94
118
  contentHash: string;
95
119
  signer: string;
96
120
  signedAt: string | null;
121
+ /**
122
+ * The pack, by its position in the packs `matchCaseFiles` was handed (the
123
+ * same position the case reading's `packIndex` names), or `null` from
124
+ * `matchFiles`, which reads one pack (A43).
125
+ */
126
+ packIndex: number | null;
127
+ /** The record, by its position in the events the pack presents (A43). */
128
+ recordIndex: number;
97
129
  }
98
130
 
99
131
  export interface MatchedFile {
@@ -107,10 +139,25 @@ export interface UnmatchedFile {
107
139
  sha256: string;
108
140
  }
109
141
 
142
+ /** A file a command found and did not hash, named with the reason. */
143
+ export interface SkippedFile {
144
+ name: string;
145
+ reason: SkippedReason;
146
+ /** The reader's own words, for a `.zip` entry it refused (`refused`). */
147
+ detail?: string;
148
+ }
149
+
110
150
  export interface NotRead {
111
151
  packRef: string | null;
112
152
  eventId: string | null;
113
153
  reason: NotReadReason;
154
+ /** As on `FileCommitment` (A43). */
155
+ packIndex: number | null;
156
+ /**
157
+ * As on `FileCommitment`, or `null` where the pack presents no events at all
158
+ * (`no_records`).
159
+ */
160
+ recordIndex: number | null;
114
161
  }
115
162
 
116
163
  export interface FileMatchReading {
@@ -120,13 +167,22 @@ export interface FileMatchReading {
120
167
  notOffered: FileCommitment[];
121
168
  notRead: NotRead[];
122
169
  scope: string;
170
+ /**
171
+ * Files found and not hashed. The matching functions leave it empty: it is
172
+ * the caller's, because only a caller that reads a folder knows a link.
173
+ */
174
+ skipped?: SkippedFile[];
123
175
  }
124
176
 
125
- /** A file handed in beside a pack. `name` is a label and is never compared. */
126
- export interface OfferedFile {
127
- name: string;
128
- bytes: Uint8Array;
129
- }
177
+ /**
178
+ * A file handed in beside a pack. `name` is a label and is never compared. The
179
+ * file is its `bytes`, or its `sha256` (64 lowercase hex characters) where the
180
+ * caller has already hashed it: a command streams a file of any size rather
181
+ * than hold it.
182
+ */
183
+ export type OfferedFile =
184
+ | { name: string; bytes: Uint8Array; sha256?: undefined }
185
+ | { name: string; sha256: string; bytes?: undefined };
130
186
 
131
187
  /** One pack of a case, for `matchCaseFiles`. */
132
188
  export interface FileMatchPack {
@@ -197,17 +253,18 @@ async function readPack(
197
253
  bundle: unknown,
198
254
  content: SideContentMap | null | undefined,
199
255
  packRef: string | null,
256
+ packIndex: number | null,
200
257
  ): Promise<{ commitments: FileCommitment[]; notRead: NotRead[] }> {
201
258
  const commitments: FileCommitment[] = [];
202
259
  const notRead: NotRead[] = [];
203
260
  const events = resolveTraceEvents(bundle) as unknown[] | undefined;
204
261
  if (events === undefined) {
205
- notRead.push({ packRef, eventId: null, reason: "no_records" });
262
+ notRead.push({ packRef, eventId: null, reason: "no_records", packIndex, recordIndex: null });
206
263
  return { commitments, notRead };
207
264
  }
208
- for (const ev of events) {
265
+ for (const [recordIndex, ev] of events.entries()) {
209
266
  if (!isObject(ev)) {
210
- notRead.push({ packRef, eventId: null, reason: "record_unreadable" });
267
+ notRead.push({ packRef, eventId: null, reason: "record_unreadable", packIndex, recordIndex });
211
268
  continue;
212
269
  }
213
270
  const envelope = own(ev, "envelope");
@@ -218,7 +275,7 @@ async function readPack(
218
275
  if (rawBytes !== undefined && rawBytes !== null) {
219
276
  eventBytes = typeof rawBytes === "string" ? decode(rawBytes) : undefined;
220
277
  if (eventBytes === undefined) {
221
- notRead.push({ packRef, eventId, reason: "record_unreadable" });
278
+ notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
222
279
  continue;
223
280
  }
224
281
  }
@@ -228,15 +285,15 @@ async function readPack(
228
285
  acceptedSpecMajors: RECORD_PROFILES,
229
286
  });
230
287
  if (!result.ok || !isObject(envelope) || !envelopeSpelled(envelope)) {
231
- notRead.push({ packRef, eventId, reason: "record_not_verified" });
288
+ notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
232
289
  continue;
233
290
  }
234
291
  if (eventBytes === undefined) {
235
- notRead.push({ packRef, eventId, reason: "record_withheld" });
292
+ notRead.push({ packRef, eventId, reason: "record_withheld", packIndex, recordIndex });
236
293
  continue;
237
294
  }
238
295
  if (result.payloadHashOk !== true) {
239
- notRead.push({ packRef, eventId, reason: "record_not_verified" });
296
+ notRead.push({ packRef, eventId, reason: "record_not_verified", packIndex, recordIndex });
240
297
  continue;
241
298
  }
242
299
  let record: unknown;
@@ -246,7 +303,7 @@ async function readPack(
246
303
  record = undefined;
247
304
  }
248
305
  if (!isObject(record)) {
249
- notRead.push({ packRef, eventId, reason: "record_unreadable" });
306
+ notRead.push({ packRef, eventId, reason: "record_unreadable", packIndex, recordIndex });
250
307
  continue;
251
308
  }
252
309
 
@@ -264,12 +321,12 @@ async function readPack(
264
321
  if (isHandoffNamespace(held)) {
265
322
  const messageHash = own(event, "message_hash");
266
323
  if (!isConformantHandoffRecord(held) || !isHexField(messageHash, 64, true)) {
267
- notRead.push({ packRef, eventId, reason: "handoff_record_malformed" });
324
+ notRead.push({ packRef, eventId, reason: "handoff_record_malformed", packIndex, recordIndex });
268
325
  continue;
269
326
  }
270
327
  const message = await messageOf(ev, eventId, messageHash, content);
271
328
  if (message === ABSENT) {
272
- notRead.push({ packRef, eventId, reason: "message_withheld" });
329
+ notRead.push({ packRef, eventId, reason: "message_withheld", packIndex, recordIndex });
273
330
  continue;
274
331
  }
275
332
  const parts = isObject(message) ? own(message, "parts") : undefined;
@@ -284,6 +341,8 @@ async function readPack(
284
341
  contentHash: ((part as { content_hash: string }).content_hash).toLowerCase(),
285
342
  signer,
286
343
  signedAt,
344
+ packIndex,
345
+ recordIndex,
287
346
  });
288
347
  });
289
348
  }
@@ -292,7 +351,7 @@ async function readPack(
292
351
 
293
352
  const contentHash = own(event, "content_hash");
294
353
  if (!isHexField(contentHash, 64)) {
295
- notRead.push({ packRef, eventId, reason: "no_content_hash" });
354
+ notRead.push({ packRef, eventId, reason: "no_content_hash", packIndex, recordIndex });
296
355
  continue;
297
356
  }
298
357
  commitments.push({
@@ -303,6 +362,8 @@ async function readPack(
303
362
  contentHash: contentHash.toLowerCase(),
304
363
  signer,
305
364
  signedAt,
365
+ packIndex,
366
+ recordIndex,
306
367
  });
307
368
  }
308
369
  return { commitments, notRead };
@@ -314,8 +375,17 @@ async function offeredDigests(files: readonly OfferedFile[]): Promise<[string, s
314
375
  const out: [string, string][] = [];
315
376
  for (const entry of files) {
316
377
  if (!isObject(entry)) throw new TypeError("matchFiles: each file is an object {name, bytes}");
317
- const { name, bytes } = entry as { name?: unknown; bytes?: unknown };
378
+ const { name, bytes, sha256 } = entry as { name?: unknown; bytes?: unknown; sha256?: unknown };
318
379
  if (typeof name !== "string") throw new TypeError("matchFiles: a file's name is a string");
380
+ if (sha256 !== undefined && sha256 !== null) {
381
+ if ((bytes !== undefined && bytes !== null) || !isHexField(sha256, 64, true)) {
382
+ throw new TypeError(
383
+ "matchFiles: a file is {name, bytes} or {name, sha256}, and a sha256 is 64 lowercase hex characters",
384
+ );
385
+ }
386
+ out.push([name, sha256]);
387
+ continue;
388
+ }
319
389
  if (!(bytes instanceof Uint8Array)) {
320
390
  throw new TypeError("matchFiles: a file's bytes are a Uint8Array");
321
391
  }
@@ -352,6 +422,7 @@ async function match(
352
422
  notOffered: commitments.filter((_, i) => !used.has(i)),
353
423
  notRead,
354
424
  scope: FILE_MATCH_SCOPE,
425
+ skipped: [],
355
426
  };
356
427
  }
357
428
 
@@ -366,7 +437,7 @@ export async function matchFiles(
366
437
  files: readonly OfferedFile[],
367
438
  content?: SideContentMap | null,
368
439
  ): Promise<FileMatchReading> {
369
- const { commitments, notRead } = await readPack(bundle, content, null);
440
+ const { commitments, notRead } = await readPack(bundle, content, null, null);
370
441
  return match(commitments, notRead, files);
371
442
  }
372
443
 
@@ -377,10 +448,10 @@ export async function matchCaseFiles(
377
448
  ): Promise<FileMatchReading> {
378
449
  const commitments: FileCommitment[] = [];
379
450
  const notRead: NotRead[] = [];
380
- for (const pack of packs) {
451
+ for (const [packIndex, pack] of packs.entries()) {
381
452
  if (!isObject(pack)) throw new TypeError("matchCaseFiles: each pack is an object {ref, bundle}");
382
453
  if (typeof pack.ref !== "string") throw new TypeError("matchCaseFiles: a pack's ref is a string");
383
- const r = await readPack(pack.bundle, pack.content, pack.ref);
454
+ const r = await readPack(pack.bundle, pack.content, pack.ref, packIndex);
384
455
  commitments.push(...r.commitments);
385
456
  notRead.push(...r.notRead);
386
457
  }
@@ -390,7 +461,9 @@ export async function matchCaseFiles(
390
461
  function commitmentJson(c: FileCommitment): Record<string, unknown> {
391
462
  return {
392
463
  pack_ref: c.packRef,
464
+ pack_index: c.packIndex,
393
465
  event_id: c.eventId,
466
+ record_index: c.recordIndex,
394
467
  kind: c.kind,
395
468
  part_index: c.partIndex,
396
469
  content_hash: c.contentHash,
@@ -401,6 +474,7 @@ function commitmentJson(c: FileCommitment): Record<string, unknown> {
401
474
 
402
475
  /** The reading as the `files` member of `--json`, identical in both kernels. */
403
476
  export function fileMatchToJson(reading: FileMatchReading): Record<string, unknown> {
477
+ const skipped = reading.skipped ?? [];
404
478
  return {
405
479
  offered: reading.offered,
406
480
  matched: reading.matched.map((m) => ({
@@ -412,14 +486,33 @@ export function fileMatchToJson(reading: FileMatchReading): Record<string, unkno
412
486
  not_offered: reading.notOffered.map(commitmentJson),
413
487
  not_read: reading.notRead.map((n) => ({
414
488
  pack_ref: n.packRef,
489
+ pack_index: n.packIndex,
415
490
  event_id: n.eventId,
491
+ record_index: n.recordIndex,
416
492
  reason: n.reason,
417
493
  rendered: renderNotRead(n),
418
494
  })),
495
+ // Present only when something was skipped, so a reading with no skipped
496
+ // file is the object it was before a folder could be offered (A40).
497
+ ...(skipped.length > 0
498
+ ? {
499
+ skipped: skipped.map((k) => ({
500
+ name: k.name,
501
+ reason: k.reason,
502
+ ...(k.detail !== undefined ? { detail: k.detail } : {}),
503
+ rendered: renderSkipped(k),
504
+ })),
505
+ }
506
+ : {}),
419
507
  scope: reading.scope,
420
508
  };
421
509
  }
422
510
 
511
+ function renderSkipped(k: SkippedFile): string {
512
+ if (k.detail !== undefined) return `${k.name} (${SKIPPED_TEXT[k.reason]}: ${k.detail}; not read)`;
513
+ return `${k.name} (${SKIPPED_TEXT[k.reason]})`;
514
+ }
515
+
423
516
  function recordName(eventId: string | null): string {
424
517
  return eventId !== null ? `record ${eventId}` : "a record with no event_id";
425
518
  }
@@ -440,20 +533,61 @@ function renderNotRead(n: NotRead): string {
440
533
  return `${recordName(n.eventId)}${where} not read: ${REASON_TEXT[n.reason]}`;
441
534
  }
442
535
 
443
- /** The text report's lines for the reading, identical in both kernels. */
444
- export function renderFileMatch(reading: FileMatchReading): string[] {
536
+ /**
537
+ * The commitments no offered file matched and no disclosed log answers.
538
+ *
539
+ * `contents` is each pack's side content by `pack_ref`. A record whose own pack
540
+ * discloses bytes that hash to its `content_hash` is accounted for by the pack,
541
+ * and is not returned. Only the text report's count reads this; the reading, and
542
+ * `--json`, still list every commitment no file matched.
543
+ */
544
+ export async function notAccountedFor(
545
+ reading: FileMatchReading,
546
+ contents: ReadonlyMap<string, SideContentMap | null | undefined>,
547
+ ): Promise<FileCommitment[]> {
548
+ const out: FileCommitment[] = [];
549
+ for (const c of reading.notOffered) {
550
+ const held = c.packRef !== null ? contents.get(c.packRef) : undefined;
551
+ const raw =
552
+ c.kind === "record" && held !== undefined && held !== null && c.eventId !== null
553
+ ? decode(sideContentEntry(held, c.eventId).value)
554
+ : undefined;
555
+ if (raw !== undefined && (await sha256Hex(raw)) === c.contentHash) continue;
556
+ out.push(c);
557
+ }
558
+ return out;
559
+ }
560
+
561
+ /**
562
+ * The text report's lines for the reading, identical in both kernels.
563
+ *
564
+ * By default every commitment no file matched is a line. `notAmong` is the case
565
+ * command's report when no `--files` was given: one count line for the recorded
566
+ * files no loose file matched, and no line for each (A41).
567
+ */
568
+ export function renderFileMatch(reading: FileMatchReading, notAmong?: number): string[] {
569
+ const skipped = reading.skipped ?? [];
445
570
  const lines = [
446
571
  `files ${reading.offered} offered: ${reading.matched.length} vouched for, ` +
447
- `${reading.unmatched.length} match no record; ` +
448
- `${reading.notOffered.length} commitment(s) had no file offered`,
572
+ `${reading.unmatched.length} match no record` +
573
+ (notAmong === undefined ? `; ${reading.notOffered.length} commitment(s) had no file offered` : "") +
574
+ (skipped.length > 0 ? `; ${skipped.length} skipped, not read` : ""),
449
575
  ];
450
576
  for (const m of reading.matched) {
451
577
  lines.push(` vouched for ${m.name} sha256 ${m.sha256}`);
452
578
  for (const c of m.records) lines.push(` by ${renderCommitment(c)}`);
453
579
  }
454
580
  for (const u of reading.unmatched) lines.push(` no match ${u.name} sha256 ${u.sha256}`);
455
- for (const c of reading.notOffered) {
456
- lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
581
+ for (const k of skipped) lines.push(` skipped ${renderSkipped(k)}`);
582
+ if (notAmong === undefined) {
583
+ for (const c of reading.notOffered) {
584
+ lines.push(` not offered ${c.contentHash}, ${renderCommitment(c)}`);
585
+ }
586
+ } else if (notAmong > 0) {
587
+ lines.push(
588
+ ` ${notAmong} recorded commitment(s) matched no loose file and no log in their own pack ` +
589
+ "(--json lists every commitment no file matched, these included)",
590
+ );
457
591
  }
458
592
  for (const n of reading.notRead) lines.push(` not read ${renderNotRead(n)}`);
459
593
  lines.push(` ${reading.scope}`);
package/src/grading.ts CHANGED
@@ -476,6 +476,17 @@ export function accountDisclosure(
476
476
  return { expected, present, missing, unreadable, extra };
477
477
  }
478
478
 
479
+ /**
480
+ * A JSON number read by its value: an integer within 2**53 - 1, or null.
481
+ * `Number.isSafeInteger`, mirrored by `_js_safe_integer` in
482
+ * `src/sanning_proof/chain.py`, so `2.0` is 2 in both kernels and
483
+ * `9007199254740993`, which this parser rounds, is unreadable in both.
484
+ */
485
+ export function safeInteger(value: unknown): number | null {
486
+ // `+ 0` folds -0 into 0, which Python's `int(-0.0)` already is.
487
+ return typeof value === "number" && Number.isSafeInteger(value) ? value + 0 : null;
488
+ }
489
+
479
490
  /** The `leaf_count` a checkpoint's SIGNED record commits to, or null if unreadable. */
480
491
  export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | undefined): number | null {
481
492
  const hex = checkpoint?.event_bytes;
@@ -490,8 +501,10 @@ export function signedLeafCount(checkpoint: { event_bytes?: unknown } | null | u
490
501
  const record = jcsParse(hexToBytes(hex)) as {
491
502
  event?: { leaf_count?: unknown };
492
503
  };
493
- const n = record?.event?.leaf_count;
494
- return Number.isInteger(n) ? (n as number) : null;
504
+ // Read BY VALUE, the model JCS signs: a record spelling `3.0`, `3e0` or
505
+ // `30e-1` commits to 3, and since A38 (proof#197) this count decides a
506
+ // verdict, not only a note. Past 2**53 - 1 it is unreadable, as in Python.
507
+ return safeInteger(record?.event?.leaf_count);
495
508
  } catch {
496
509
  return null;
497
510
  }
package/src/index.ts CHANGED
@@ -219,6 +219,7 @@ export {
219
219
  CASE_EXCEPTION_KINDS,
220
220
  CASE_QUARANTINED_PROFILES,
221
221
  CASE_RECORD_PROFILES,
222
+ CASE_RECORD_STATUSES,
222
223
  CASE_NOTE_KINDS,
223
224
  CASE_RELIANCE,
224
225
  CASE_RELIANCE_HANDOFF_CLAUSE,
@@ -285,6 +286,7 @@ export type {
285
286
  CasePackReading,
286
287
  CaseReading,
287
288
  CaseRecordReading,
289
+ CaseRecordStatus,
288
290
  CaseVerdict,
289
291
  WitnessLine,
290
292
  WitnessState,
@@ -316,9 +318,11 @@ export type {
316
318
  export {
317
319
  FILE_MATCH_SCOPE,
318
320
  NOT_READ_REASONS,
321
+ SKIPPED_REASONS,
319
322
  fileMatchToJson,
320
323
  matchCaseFiles,
321
324
  matchFiles,
325
+ notAccountedFor,
322
326
  renderFileMatch,
323
327
  } from "./file-match.js";
324
328
  export type {
@@ -329,6 +333,8 @@ export type {
329
333
  NotRead,
330
334
  NotReadReason,
331
335
  OfferedFile,
336
+ SkippedFile,
337
+ SkippedReason,
332
338
  UnmatchedFile,
333
339
  } from "./file-match.js";
334
340
 
package/src/merkle.ts CHANGED
@@ -78,25 +78,31 @@ export async function verifyInclusion(
78
78
  }
79
79
  if (totalLeaves === 1) return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
80
80
 
81
+ // Arithmetic, not bitwise: JavaScript's `&` and `>>=` truncate to 32 bits,
82
+ // so a tree past 2**31 leaves walked a different tree here than in the
83
+ // Python kernel, whose integers are unbounded. `% 2` and a halving floor
84
+ // are exact for every safe integer, and the stated index and count reach
85
+ // this walk as safe integers.
81
86
  let fn = leafIndex;
82
87
  let sn = totalLeaves - 1;
83
88
  let r = leaf;
89
+ const half = (v: number): number => Math.floor(v / 2);
84
90
 
85
91
  for (const p of auditPath) {
86
92
  if (sn === 0) return false; // path longer than the tree depth — malformed
87
- if ((fn & 1) === 1 || fn === sn) {
93
+ if (fn % 2 === 1 || fn === sn) {
88
94
  r = await nodeHash(p, r);
89
- if ((fn & 1) === 0) {
90
- while ((fn & 1) === 0 && fn !== 0) {
91
- fn >>= 1;
92
- sn >>= 1;
95
+ if (fn % 2 === 0) {
96
+ while (fn % 2 === 0 && fn !== 0) {
97
+ fn = half(fn);
98
+ sn = half(sn);
93
99
  }
94
100
  }
95
101
  } else {
96
102
  r = await nodeHash(r, p);
97
103
  }
98
- fn >>= 1;
99
- sn >>= 1;
104
+ fn = half(fn);
105
+ sn = half(sn);
100
106
  }
101
107
 
102
108
  return sn === 0 && bytesEqual(r, expectedRoot);
package/src/verifier.ts CHANGED
@@ -708,7 +708,7 @@ export async function verifyEnvelope(
708
708
  // exotic object.
709
709
  try {
710
710
  return await verifyEnvelopeChecked(env, opts);
711
- } catch (e) {
711
+ } catch {
712
712
  return {
713
713
  ok: false,
714
714
  specVersionOk: false,
@@ -718,7 +718,7 @@ export async function verifyEnvelope(
718
718
  signatureOk: false,
719
719
  contentHashOk: opts.expectedContentHash === undefined ? null : false,
720
720
  contentRole: null,
721
- errors: [`envelope could not be read: ${stringifyErr(e)}`],
721
+ errors: ["envelope could not be read"],
722
722
  };
723
723
  }
724
724
  }
@@ -761,7 +761,15 @@ async function verifyEnvelopeChecked(
761
761
  }
762
762
 
763
763
  const specVersionOk = specVersionSupported(env.spec_version, opts.acceptedSpecMajors);
764
- if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
764
+ if (!specVersionOk) {
765
+ // Absent or null reads "missing", as in the Python kernel. Everything else
766
+ // is quoted the way both kernels quote a value in a finding.
767
+ errors.push(
768
+ env.spec_version === undefined || env.spec_version === null
769
+ ? "missing spec_version"
770
+ : `unsupported spec_version: ${JSON.stringify(env.spec_version)}`,
771
+ );
772
+ }
765
773
 
766
774
  // Skeleton field format (envelope-spec §2, proof#95): a present `signed_at`
767
775
  // or `environment` that is not what §2 says fails the verdict. There is no
@@ -791,8 +799,8 @@ async function verifyEnvelopeChecked(
791
799
  // line, or an ANSI escape that paints `VERIFIED` green, into what the
792
800
  // verifier prints. No other field in this file echoes its value, the Python
793
801
  // twin names the same failure without one, and the value is in the envelope
794
- // the auditor already holds. The RECOMPUTED digest stays: this kernel
795
- // produced it, so it is 64 characters of lowercase hex by construction.
802
+ // the auditor already holds. Neither is the recomputed digest: the reason is
803
+ // the Python kernel's sentence, word for word (proof#150).
796
804
  let payloadHashOk: boolean | null;
797
805
  if (typeof env.payload_hash !== "string") {
798
806
  payloadHashOk = false;
@@ -803,26 +811,22 @@ async function verifyEnvelopeChecked(
803
811
  try {
804
812
  const recomputed = await sha256Hex(utf8(jcs(env.payload)));
805
813
  checks.push(recomputed === env.payload_hash);
806
- if (recomputed !== env.payload_hash) {
807
- errors.push(
808
- `payload_hash mismatch: the envelope's payload_hash is not SHA-256(JCS(payload)), recomputed=${recomputed}`,
809
- );
810
- }
811
- } catch (e) {
814
+ } catch {
812
815
  checks.push(false);
813
- errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
816
+ errors.push("payload is not JCS-canonicalizable");
814
817
  }
815
818
  }
816
819
  if (opts.payloadBytes !== undefined) {
817
820
  const external = await sha256Hex(opts.payloadBytes);
818
821
  checks.push(external === env.payload_hash);
819
- if (external !== env.payload_hash) {
820
- errors.push(
821
- `payload_hash does not match the committed bytes: bytes=${external}`,
822
- );
823
- }
824
822
  }
825
823
  payloadHashOk = checks.length > 0 ? checks.every(Boolean) : null;
824
+ // One sentence for a mismatch against the inline payload, the supplied
825
+ // bytes or both, and none when the payload could not be canonicalized: the
826
+ // Python kernel's rule, word for word (proof#150).
827
+ if (payloadHashOk === false && !errors.some((e) => e.startsWith("payload is not"))) {
828
+ errors.push("payload_hash does not match the committed bytes");
829
+ }
826
830
  }
827
831
 
828
832
  // Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
@@ -836,8 +840,11 @@ async function verifyEnvelopeChecked(
836
840
  const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
837
841
  signatureOk = await ed25519Verify(utf8(jcs(envelopeForSig)), env.signature, env.public_key);
838
842
  if (!signatureOk) errors.push("Ed25519 signature verification failed");
839
- } catch (e) {
840
- errors.push(`signature verification error: ${stringifyErr(e)}`);
843
+ } catch {
844
+ // The sentence the Python kernel writes for the same failure. What was
845
+ // thrown is the host's wording (`jcs: input nesting exceeds ...`), and a
846
+ // public result carries the kernel's own words only (proof#150).
847
+ errors.push("Ed25519 signature verification failed");
841
848
  }
842
849
 
843
850
  // Check 3 (optional) — Content Bind: the user's bytes match a hash this
@@ -895,6 +902,3 @@ async function verifyEnvelopeChecked(
895
902
  };
896
903
  }
897
904
 
898
- function stringifyErr(e: unknown): string {
899
- return e instanceof Error ? e.message : String(e);
900
- }
package/src/zip-reader.ts CHANGED
@@ -32,6 +32,13 @@ import { utf8Strict } from "./verifier.js";
32
32
  export const MAX_ZIP_ENTRIES = 50_000;
33
33
  /** More uncompressed bytes than this, across every entry, refuse the archive. */
34
34
  export const MAX_ZIP_BYTES = 2 ** 30;
35
+ /**
36
+ * An archive file larger than this is refused before it is read: the
37
+ * uncompressed cap, and room for the headers, the central directory and the
38
+ * deflate framing of an archive that holds that much. The caller checks the
39
+ * size on disk, so the archive is never read whole to find out it is too big.
40
+ */
41
+ export const MAX_ZIP_ARCHIVE_BYTES = MAX_ZIP_BYTES + 256 * 2 ** 20;
35
42
  /** A name nested deeper than this many folders refuses the archive. */
36
43
  export const MAX_ZIP_DEPTH = 32;
37
44
 
package/vectors/README.md CHANGED
@@ -173,7 +173,11 @@ matched by its SHA-256 and by nothing else**, and **no match moves a verdict**:
173
173
  a surface that matched by name, or took a hash from a field no record's key
174
174
  signed, would vouch for a file the pack never committed to. Written by
175
175
  `tools/gen-vectors/gen_file_match_vectors.py`, whose expectations are written
176
- by hand from what it built.
176
+ by hand from what it built. Rule version 2 (A43, proof#204) adds `pack_index`
177
+ and `record_index` to every commitment and every `not_read` entry: the pack's
178
+ position among the row's packs in `mode: case` (`null` in `mode: pack`) and
179
+ the record's position among the events the pack presents, both read off the
180
+ packs the row names.
177
181
 
178
182
  ## `case-split.json`
179
183