@sanning/proof 0.16.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/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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "rule": "case-file-index",
3
- "ruleVersion": 1,
4
- "statement": "How a reader compares a case file's index, contents.json, with the folder it sits at the root of (evidence-bundle.md §5.6.3 v1.20, A25). The index is never an input to the case reading, and no finding here moves a verdict, a note, a contribution or an exit code. Findings come in this order: case_index_unreadable (and nothing else), case_index_pack_missing, case_index_pack_unlisted, case_index_file_missing, case_index_file_unlisted, case_index_file_differs, case_index_names_absent; within a kind by the UTF-8 bytes of the ref or path, and names_absent in the index's own order. Only files under a listed pack's folder or under report/ are reported as unlisted.",
3
+ "ruleVersion": 2,
4
+ "statement": "How a reader compares a case file's index, contents.json, with the folder it sits at the root of (evidence-bundle.md §5.6.3 v1.20, A25). The index is never an input to the case reading, and no finding here moves a verdict, a note, a contribution or an exit code. Findings come in this order: case_index_unreadable (and nothing else), case_index_pack_missing, case_index_pack_unlisted, case_index_file_missing, case_index_file_unlisted, case_index_file_differs, case_index_file_too_large, case_index_names_absent; within a kind by the UTF-8 bytes of the ref or path, and names_absent in the index's own order. Only files under a listed pack's folder or under report/ are reported as unlisted.",
5
5
  "howToUse": "Call readCaseFileIndex / read_case_file_index with the bytes of index_hex (null: no index in the folder) and `files`, and compare the result with `expected`. A finding lists only the fields it carries.",
6
6
  "rows": [
7
7
  {
@@ -465,6 +465,92 @@
465
465
  ]
466
466
  }
467
467
  },
468
+ {
469
+ "id": "file-too-large",
470
+ "why": "A file the index lists is here and was too large for the caller to hash (the caller passes {path, too_large: true} in place of a digest). It is neither a match nor missing: one case_index_file_too_large finding, and no differs or missing for it. Every other file is compared as usual.",
471
+ "index_hex": "7b22616273656e74223a5b5d2c22636173655f6964223a22636173652d346231396465222c22636f6e74656e74735f76657273696f6e223a2273616e6e696e672e636173652d66696c652f7631222c227061636b73223a5b7b2266696c6573223a5b7b2270617468223a227061636b732f612f62756e646c652e6a736f6e222c22736861323536223a2263386261656230383636353834333538646464303634316264353938626239623862383838303935336232613135356366363235303062386338396137333463227d2c7b2270617468223a227061636b732f612f6c6f67732f782e6a736f6e222c22736861323536223a2235303431626631663731336466323034373834333533653832663661346135333539333163623634663166346234613561656166666362373230393138623232227d5d2c22726566223a2261227d2c7b2266696c6573223a5b7b2270617468223a227061636b732f622f62756e646c652e6a736f6e222c22736861323536223a2230653834326632353331333234373838636138353663616264363733306438623432663935373031616535323862313061623165643332343132356231356365227d5d2c22726566223a2262227d5d2c227265706f7274223a5b7b2270617468223a227265706f72742f524541444d452e747874222c22736861323536223a2230316563313839373665373637653831346165653962316361363966366239643331323963343336393063626234623434383463396539323331366362666437227d5d7d",
472
+ "files": [
473
+ {
474
+ "path": "HOW-TO-VERIFY.txt",
475
+ "sha256": "76b25bc6207548c6bd05b1ab2e364ff8c493f6fb04e6a69d731a6f158482b7d9"
476
+ },
477
+ {
478
+ "path": "contents.json",
479
+ "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
480
+ },
481
+ {
482
+ "path": "report/README.txt",
483
+ "sha256": "01ec18976e767e814aee9b1ca69f6b9d3129c43690cbb4b4484c9e92316cbfd7"
484
+ },
485
+ {
486
+ "path": "packs/b/bundle.json",
487
+ "sha256": "0e842f2531324788ca856cabd6730d8b42f95701ae528b10ab1ed324125b15ce"
488
+ },
489
+ {
490
+ "path": "packs/a/logs/x.json",
491
+ "too_large": true
492
+ },
493
+ {
494
+ "path": "packs/a/bundle.json",
495
+ "sha256": "c8baeb0866584358ddd0641bd598bb9b8b8880953b2a155cf62500b8c89a734c"
496
+ }
497
+ ],
498
+ "expected": {
499
+ "index": "read",
500
+ "case_id": "case-4b19de",
501
+ "findings": [
502
+ {
503
+ "kind": "case_index_file_too_large",
504
+ "path": "packs/a/logs/x.json"
505
+ }
506
+ ]
507
+ }
508
+ },
509
+ {
510
+ "id": "unlisted-file-too-large",
511
+ "why": "A file under report/ that the index does not list, too large to hash. Unlisted needs no digest, so it is reported as unlisted, exactly as a file of any size would be; nothing else is said of it.",
512
+ "index_hex": "7b22616273656e74223a5b5d2c22636173655f6964223a22636173652d346231396465222c22636f6e74656e74735f76657273696f6e223a2273616e6e696e672e636173652d66696c652f7631222c227061636b73223a5b7b2266696c6573223a5b7b2270617468223a227061636b732f612f62756e646c652e6a736f6e222c22736861323536223a2263386261656230383636353834333538646464303634316264353938626239623862383838303935336232613135356366363235303062386338396137333463227d2c7b2270617468223a227061636b732f612f6c6f67732f782e6a736f6e222c22736861323536223a2235303431626631663731336466323034373834333533653832663661346135333539333163623634663166346234613561656166666362373230393138623232227d5d2c22726566223a2261227d2c7b2266696c6573223a5b7b2270617468223a227061636b732f622f62756e646c652e6a736f6e222c22736861323536223a2230653834326632353331333234373838636138353663616264363733306438623432663935373031616535323862313061623165643332343132356231356365227d5d2c22726566223a2262227d5d2c227265706f7274223a5b7b2270617468223a227265706f72742f524541444d452e747874222c22736861323536223a2230316563313839373665373637653831346165653962316361363966366239643331323963343336393063626234623434383463396539323331366362666437227d5d7d",
513
+ "files": [
514
+ {
515
+ "path": "HOW-TO-VERIFY.txt",
516
+ "sha256": "76b25bc6207548c6bd05b1ab2e364ff8c493f6fb04e6a69d731a6f158482b7d9"
517
+ },
518
+ {
519
+ "path": "contents.json",
520
+ "sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
521
+ },
522
+ {
523
+ "path": "report/README.txt",
524
+ "sha256": "01ec18976e767e814aee9b1ca69f6b9d3129c43690cbb4b4484c9e92316cbfd7"
525
+ },
526
+ {
527
+ "path": "packs/b/bundle.json",
528
+ "sha256": "0e842f2531324788ca856cabd6730d8b42f95701ae528b10ab1ed324125b15ce"
529
+ },
530
+ {
531
+ "path": "packs/a/logs/x.json",
532
+ "sha256": "5041bf1f713df204784353e82f6a4a535931cb64f1f4b4a5aeaffcb720918b22"
533
+ },
534
+ {
535
+ "path": "packs/a/bundle.json",
536
+ "sha256": "c8baeb0866584358ddd0641bd598bb9b8b8880953b2a155cf62500b8c89a734c"
537
+ },
538
+ {
539
+ "path": "report/huge.bin",
540
+ "too_large": true
541
+ }
542
+ ],
543
+ "expected": {
544
+ "index": "read",
545
+ "case_id": "case-4b19de",
546
+ "findings": [
547
+ {
548
+ "kind": "case_index_file_unlisted",
549
+ "path": "report/huge.bin"
550
+ }
551
+ ]
552
+ }
553
+ },
468
554
  {
469
555
  "id": "no-index",
470
556
  "why": "A folder with no contents.json is a folder of packs, with no finding.",