@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/README.md +49 -4
- package/dist/agent-proof.js +50 -23
- package/dist/case-file.d.ts +24 -4
- package/dist/case-file.js +62 -5
- package/dist/case.d.ts +104 -8
- package/dist/case.js +195 -53
- package/dist/cli.d.ts +105 -2
- package/dist/cli.js +664 -123
- package/dist/compose.js +32 -0
- package/dist/evidence.js +108 -38
- package/dist/file-match.d.ts +64 -5
- package/dist/file-match.js +113 -22
- package/dist/grading.d.ts +7 -0
- package/dist/grading.js +14 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/merkle.js +13 -7
- package/dist/verifier.js +24 -19
- package/dist/zip-reader.d.ts +7 -0
- package/dist/zip-reader.js +7 -0
- package/package.json +1 -1
- package/src/agent-proof.ts +53 -27
- package/src/case-file.ts +61 -8
- package/src/case.ts +293 -64
- package/src/cli.ts +703 -132
- package/src/compose.ts +33 -0
- package/src/evidence.ts +109 -43
- package/src/file-match.ts +160 -26
- package/src/grading.ts +15 -2
- package/src/index.ts +6 -0
- package/src/merkle.ts +13 -7
- package/src/verifier.ts +26 -22
- package/src/zip-reader.ts +7 -0
- package/vectors/README.md +5 -1
- package/vectors/case-file-index.json +88 -2
- package/vectors/file-match.json +135 -1
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
2130
|
-
leafCount:
|
|
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 =
|
|
2139
|
-
if (
|
|
2189
|
+
const total = out.leafCount;
|
|
2190
|
+
if (total === null) return false; // already reported by the caller
|
|
2140
2191
|
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
2441
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
/**
|
|
444
|
-
|
|
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
|
-
|
|
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
|
|
456
|
-
|
|
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}`);
|