@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/compose.ts CHANGED
@@ -105,6 +105,23 @@ function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
105
105
  return cloned;
106
106
  }
107
107
 
108
+ // True when `value` holds a number that is NaN or +-Infinity. Iterative, so a
109
+ // deeply nested input cannot overflow the stack before `jcs` bounds it.
110
+ function hasNonFiniteNumber(value: unknown): boolean {
111
+ const stack: unknown[] = [value];
112
+ while (stack.length > 0) {
113
+ const v = stack.pop();
114
+ if (typeof v === "number") {
115
+ if (!Number.isFinite(v)) return true;
116
+ } else if (Array.isArray(v)) {
117
+ for (const x of v) stack.push(x);
118
+ } else if (v !== null && typeof v === "object") {
119
+ for (const x of Object.values(v)) stack.push(x);
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
108
125
  // Compose an attested evidence export (evidence-export.md §5).
109
126
  //
110
127
  // 1. Normalize + transcode the attestation records (boundary hex, above).
@@ -133,6 +150,22 @@ export async function composeExport(
133
150
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
134
151
  }
135
152
 
153
+ // An over-long integer reads as Infinity (proof#217), which `jcs` refuses in
154
+ // the runtime's own words. Say it once, in the kernel's, before any
155
+ // verification: both kernels write this sentence, with no function-name prefix, so the CLI line is byte-identical.
156
+ if (hasNonFiniteNumber(sourceBundle)) {
157
+ throw new Error(
158
+ "the source bundle carries a number that is not a finite JSON number " +
159
+ "(an integer past the 1.8e308 range, or too long to read)",
160
+ );
161
+ }
162
+ if (hasNonFiniteNumber(attestations)) {
163
+ throw new Error(
164
+ "the attestations carry a number that is not a finite JSON number " +
165
+ "(an integer past the 1.8e308 range, or too long to read)",
166
+ );
167
+ }
168
+
136
169
  if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
137
170
  // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
138
171
  // A31), so composing one would sign an export this kernel rejects.
package/src/evidence.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  utf8,
30
30
  verifyRsaPssSha256,
31
31
  } from "./crypto.js";
32
+ import { safeInteger, signedLeafCount } from "./grading.js";
32
33
  import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
33
34
  import { leafHash, verifyInclusion } from "./merkle.js";
34
35
  import {
@@ -1316,6 +1317,7 @@ async function verifyAnchorTrace(
1316
1317
  const cpErrors: string[] = [];
1317
1318
  let envelopeOk = false;
1318
1319
  let merkleRootOk = false;
1320
+ let leafCountOk = false;
1319
1321
 
1320
1322
  // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
1321
1323
  // is an input to identity, not just to the payload binding below.
@@ -1406,6 +1408,14 @@ async function verifyAnchorTrace(
1406
1408
  `committed=${jsonStr(committedRoot)}`,
1407
1409
  );
1408
1410
  }
1411
+ // §5.1, A38 (proof#197), decided by Phil 2026-10-06: fail closed. The
1412
+ // count the record signs is what every inclusion bound to this
1413
+ // checkpoint is held to, so a record that signs no integer count fails
1414
+ // the checkpoint, as a record that signs no merkle_root does. Read by
1415
+ // value through the one reader the inclusion compare and the
1416
+ // completeness notes share, so `3.0` and `3e0` are 3.
1417
+ leafCountOk = signedLeafCount(cp as { event_bytes?: unknown }) !== null;
1418
+ if (!leafCountOk) cpErrors.push("checkpoint record leaf_count is not an integer");
1409
1419
  } catch (e) {
1410
1420
  cpErrors.push(`checkpoint record is not JSON${thrownDetail(e)}`);
1411
1421
  }
@@ -1424,7 +1434,7 @@ async function verifyAnchorTrace(
1424
1434
  // standing rule is that malformed is a failure while absent is
1425
1435
  // undetermined (the same rule disclosed `content` follows). Without
1426
1436
  // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
1427
- ok: envelopeOk && merkleRootOk && keyed.errors.length === 0 && !duplicateKey,
1437
+ ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
1428
1438
  errors: cpErrors,
1429
1439
  });
1430
1440
  if (keyed.anchorRefMalformed) {
@@ -1554,36 +1564,66 @@ async function verifyAnchorTrace(
1554
1564
  const checkpointBound = cp !== undefined;
1555
1565
 
1556
1566
  let inclusionOk = false;
1567
+ // §5.1, A38 (proof#197): the tree walked must be the tree the checkpoint
1568
+ // record signed, the same rule and the same sentence as the stamp path's
1569
+ // rule 3. RFC 9162's walk reads one audit path as valid at more than one
1570
+ // (leaf_index, leaf_count), so without this a real proof at (2, 3) also
1571
+ // verified as (1, 2) or (4, 5). A signed count that cannot be read is not
1572
+ // compared here: it has already failed its checkpoint (above), so the
1573
+ // bundle cannot verify. One reader of the signed count, shared with the
1574
+ // completeness notes, so the verdict and the grade never read two
1575
+ // different numbers.
1576
+ //
1577
+ // Every number here is read BY VALUE (`Number.isSafeInteger`), the model
1578
+ // JCS signs, and the Python kernel reads the same way (`_safe_integer`):
1579
+ // JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
1580
+ // same wrapper signature. A value that is not a safe integer is malformed,
1581
+ // with one sentence that prints no value, because the two kernels cannot
1582
+ // print `2.5`, `1e400` or `9007199254740993` the same way.
1583
+ const signedCount = cp ? signedLeafCount(cp as { event_bytes?: unknown }) : null;
1557
1584
  if (incl && checkpointBound && cp) {
1558
- try {
1559
- inclusionOk = await verifyInclusion(
1560
- hexToBytes(incl.leaf_hash),
1561
- incl.leaf_index,
1562
- incl.leaf_count,
1563
- (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1564
- // No `?? ""` fallback. A checkpoint that claims NO root must not be
1565
- // handed an invented empty one: the Python kernel refuses the same
1566
- // value and reports the decode failure, so substituting "" here had
1567
- // the two kernels say different things about one bundle (TS: "the
1568
- // proof did not reconstruct the checkpoint root", which is not what
1569
- // happened: there was no claimed root to reconstruct).
1570
- hexToBytes(cp.merkle_root as string),
1585
+ const statedCount = safeInteger(incl.leaf_count);
1586
+ const statedIndex = safeInteger(incl.leaf_index);
1587
+ if (statedCount === null) {
1588
+ evErrors.push("inclusion leaf_count is not an integer");
1589
+ } else if (statedIndex === null) {
1590
+ evErrors.push("inclusion leaf_index is not an integer");
1591
+ } else if (signedCount !== null && statedCount !== signedCount) {
1592
+ evErrors.push(
1593
+ `inclusion leaf_count ${statedCount} does not equal ` +
1594
+ `the checkpoint's committed leaf_count ${signedCount}`,
1571
1595
  );
1572
- if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1573
-
1574
- // Defense in depth: the claimed leaf_hash MUST be the hash of the
1575
- // event's own signed envelope bytes (profile §6) — otherwise a valid
1576
- // inclusion proof for an UNRELATED leaf could be smuggled in.
1577
- const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
1578
- const expectedLeafHex = toHex(expectedLeaf);
1579
- if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1580
- inclusionOk = false;
1581
- evErrors.push(
1582
- `inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`,
1596
+ } else {
1597
+ try {
1598
+ inclusionOk = await verifyInclusion(
1599
+ hexToBytes(incl.leaf_hash),
1600
+ statedIndex,
1601
+ statedCount,
1602
+ (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1603
+ // No `?? ""` fallback. A checkpoint that claims NO root must not be
1604
+ // handed an invented empty one: the Python kernel refuses the same
1605
+ // value and reports the decode failure, so substituting "" here had
1606
+ // the two kernels say different things about one bundle (TS: "the
1607
+ // proof did not reconstruct the checkpoint root", which is not what
1608
+ // happened: there was no claimed root to reconstruct).
1609
+ hexToBytes(cp.merkle_root as string),
1583
1610
  );
1611
+ if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1612
+
1613
+ // Defense in depth: the claimed leaf_hash MUST be the hash of the
1614
+ // event's own signed envelope bytes (profile §6) — otherwise a valid
1615
+ // inclusion proof for an UNRELATED leaf could be smuggled in.
1616
+ const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
1617
+ const expectedLeafHex = toHex(expectedLeaf);
1618
+ if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1619
+ inclusionOk = false;
1620
+ evErrors.push(
1621
+ `inclusion leaf_hash does not match SHA-256(0x00 || JCS(event envelope))`,
1622
+ );
1623
+ }
1624
+ } catch (e) {
1625
+ evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1584
1626
  }
1585
- } catch (e) {
1586
- evErrors.push(`inclusion verify error${thrownDetail(e)}`);
1587
1627
  }
1588
1628
  }
1589
1629
 
@@ -1784,8 +1824,16 @@ function isLowerHex(s: unknown, nibbles?: number): s is string {
1784
1824
  return /^[0-9a-f]+$/.test(s);
1785
1825
  }
1786
1826
 
1787
- function isNonNegInt(n: unknown): n is number {
1788
- return typeof n === "number" && Number.isInteger(n) && n >= 0;
1827
+ /**
1828
+ * A JSON number read BY VALUE as a non-negative safe integer, or null.
1829
+ * `safeInteger`, the reader every leaf count and leaf index in this kernel
1830
+ * goes through (A38), mirrored by `_non_neg_integer` in
1831
+ * `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
1832
+ * JCS's own spelling of a count past 2**53 - 1, which Python refused.
1833
+ */
1834
+ function nonNegInteger(n: unknown): number | null {
1835
+ const v = safeInteger(n);
1836
+ return v !== null && v >= 0 ? v : null;
1789
1837
  }
1790
1838
 
1791
1839
  /**
@@ -2060,11 +2108,12 @@ async function verifyStampEntry(
2060
2108
  } else {
2061
2109
  base.merkleRoot = body.merkle_root;
2062
2110
  }
2063
- if (!isNonNegInt(body.leaf_count)) {
2111
+ const stampCount = nonNegInteger(body.leaf_count);
2112
+ if (stampCount === null) {
2064
2113
  errors.push("stamp body leaf_count is not a non-negative integer");
2065
2114
  base.bodyOk = false;
2066
2115
  } else {
2067
- base.leafCount = body.leaf_count;
2116
+ base.leafCount = stampCount;
2068
2117
  }
2069
2118
 
2070
2119
  // --- the sub-tree ranges --------------------------------------------------
@@ -2108,14 +2157,16 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2108
2157
  continue;
2109
2158
  }
2110
2159
  const s = v as Partial<StampSubtree>;
2111
- if (!isNonNegInt(s.first_leaf)) {
2160
+ const firstLeaf = nonNegInteger(s.first_leaf);
2161
+ const subCount = nonNegInteger(s.leaf_count);
2162
+ if (firstLeaf === null) {
2112
2163
  errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
2113
2164
  shapeOk = false;
2114
2165
  }
2115
2166
  // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
2116
2167
  // an arbitrary number of empty ranges sit inside a tiling that still adds
2117
2168
  // up.
2118
- if (!isNonNegInt(s.leaf_count) || s.leaf_count === 0) {
2169
+ if (subCount === null || subCount === 0) {
2119
2170
  errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
2120
2171
  shapeOk = false;
2121
2172
  }
@@ -2126,8 +2177,8 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2126
2177
  if (!shapeOk) continue;
2127
2178
  subtrees.push({
2128
2179
  ref,
2129
- firstLeaf: s.first_leaf as number,
2130
- leafCount: s.leaf_count as number,
2180
+ firstLeaf: firstLeaf as number,
2181
+ leafCount: subCount as number,
2131
2182
  root: s.root as string,
2132
2183
  });
2133
2184
  }
@@ -2135,11 +2186,14 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2135
2186
  out.subtrees = subtrees;
2136
2187
  if (!shapeOk) return false;
2137
2188
 
2138
- const total = body.leaf_count;
2139
- if (!isNonNegInt(total)) return false; // already reported by the caller
2189
+ const total = out.leafCount;
2190
+ if (total === null) return false; // already reported by the caller
2140
2191
 
2141
- const sum = subtrees.reduce((n, s) => n + s.leafCount, 0);
2142
- if (sum !== total) {
2192
+ // Summed exactly. Each count is a safe integer but their sum need not be,
2193
+ // and a float sum past 2**53 prints a different number than Python's
2194
+ // unbounded one in the sentence below.
2195
+ const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
2196
+ if (sum !== BigInt(total)) {
2143
2197
  errors.push(
2144
2198
  `sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`,
2145
2199
  );
@@ -2428,17 +2482,29 @@ async function verifyStampEvidence(
2428
2482
  // Rule 3: the tree walked must be the tree the stamp committed to. A
2429
2483
  // proof walked against a different leaf_count is a proof about a
2430
2484
  // different tree, and RFC 9162's path shape depends on the size.
2431
- if (st.leafCount !== null && incl.leaf_count !== st.leafCount) {
2485
+ //
2486
+ // Read BY VALUE, as the anchor-trace path reads them (A38), and as the
2487
+ // Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
2488
+ // which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
2489
+ // signature. Anything that is not a safe integer is malformed, with a
2490
+ // sentence that prints no value.
2491
+ const statedCount = safeInteger(incl.leaf_count);
2492
+ const statedIndex = safeInteger(incl.leaf_index);
2493
+ if (statedCount === null) {
2494
+ evErrors.push("inclusion leaf_count is not an integer");
2495
+ } else if (statedIndex === null) {
2496
+ evErrors.push("inclusion leaf_index is not an integer");
2497
+ } else if (st.leafCount !== null && statedCount !== st.leafCount) {
2432
2498
  evErrors.push(
2433
- `inclusion leaf_count ${incl.leaf_count} does not equal the stamp's committed ` +
2499
+ `inclusion leaf_count ${statedCount} does not equal the stamp's committed ` +
2434
2500
  `leaf_count ${st.leafCount}`,
2435
2501
  );
2436
2502
  } else {
2437
2503
  try {
2438
2504
  inclusionOk = await verifyInclusion(
2439
2505
  hexToBytes(incl.leaf_hash),
2440
- incl.leaf_index,
2441
- incl.leaf_count,
2506
+ statedIndex,
2507
+ statedCount,
2442
2508
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
2443
2509
  hexToBytes(st.merkleRoot ?? ""),
2444
2510
  );
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}`);