@sanning/proof 0.13.0 → 0.14.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/cli.ts CHANGED
@@ -57,10 +57,10 @@ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js"
57
57
  type ChainOutcome = ChainReading | { unreadable: string };
58
58
  import type { ChainReading } from "./chain.js";
59
59
  import {
60
- CASE_RELIANCE,
61
60
  VERDICT_DISPLAY_TITLES,
62
61
  WITNESS_NOT_LOOKED_UP,
63
62
  type WitnessLine,
63
+ caseReliance,
64
64
  readCase,
65
65
  renderCaseNote,
66
66
  renderCaseSummary,
@@ -78,6 +78,18 @@ import { composeExport } from "./compose.js";
78
78
  import type { ComposeExportOptions } from "./compose.js";
79
79
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
80
80
  import { verifyEvidenceBundle } from "./evidence.js";
81
+ import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
82
+ import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
83
+ import type { OfferedFile } from "./file-match.js";
84
+ import {
85
+ type CachedWitnessTime,
86
+ type WitnessTimeAnswer,
87
+ type WitnessTimeCheck,
88
+ WITNESS_CACHE_NAME,
89
+ checkWitnessCache,
90
+ readWitnessCache,
91
+ renderWitnessTime,
92
+ } from "./witness-cache.js";
81
93
  import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
82
94
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
83
95
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
@@ -276,6 +288,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
276
288
  // second positional argument until then, and a second positional is now a
277
289
  // usage error). The one positional is the bundle.
278
290
  let logsPath: string | undefined;
291
+ let filePaths: string[] | undefined;
279
292
  let gatewayArg: string | undefined;
280
293
  const positional: string[] = [];
281
294
  for (let i = 0; i < rest.length; i++) {
@@ -302,6 +315,17 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
302
315
  i++;
303
316
  continue;
304
317
  }
318
+ if (a === "--files") {
319
+ const taken = takeFilePaths(rest, i + 1);
320
+ if (taken.length === 0) {
321
+ io.err("verify: --files requires at least one file path");
322
+ printUsage(io);
323
+ return EXIT_MALFORMED;
324
+ }
325
+ filePaths = [...(filePaths ?? []), ...taken];
326
+ i += taken.length;
327
+ continue;
328
+ }
305
329
  positional.push(a);
306
330
  }
307
331
 
@@ -329,6 +353,16 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
329
353
  }
330
354
  const content = logs?.content;
331
355
 
356
+ let offered: OfferedFile[] | undefined;
357
+ if (filePaths !== undefined) {
358
+ const read = await readOfferedFiles(filePaths);
359
+ if (typeof read === "string") {
360
+ io.err(`verify: ${read}`);
361
+ return EXIT_MALFORMED;
362
+ }
363
+ offered = read;
364
+ }
365
+
332
366
  let raw: Uint8Array;
333
367
  try {
334
368
  raw = new Uint8Array(await readFile(bundlePath));
@@ -346,6 +380,8 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
346
380
  } catch (e) {
347
381
  if (e instanceof BundleReadError && e.tooDeep) {
348
382
  io.err(`${bundlePath} ${e.detail}; refusing to read it`);
383
+ } else if (e instanceof BundleReadError && e.duplicate) {
384
+ io.err(`${bundlePath} ${NOT_I_JSON}: ${e.detail}`);
349
385
  } else {
350
386
  io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
351
387
  }
@@ -363,6 +399,9 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
363
399
  // has no such per-event content. Say so rather than silently ignoring it.
364
400
  io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
365
401
  }
402
+ if (offered) {
403
+ io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
404
+ }
366
405
  const result = await verifyAgentProof(parsed, {
367
406
  gateways,
368
407
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
@@ -397,12 +436,45 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
397
436
  chains = { unreadable: errMsg(e) };
398
437
  }
399
438
  const exit = reportEvidence(result, bundlePath, gateways, io, chains, parsed, logs, sink);
439
+ // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
440
+ // beside the verdict, never inside it, so `exit` is untouched.
441
+ if (offered !== undefined) {
442
+ const reading = await matchFiles(parsed, offered, content);
443
+ for (const line of renderFileMatch(reading)) io.out(line);
444
+ if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
445
+ }
400
446
  // Coverage is reported only once the bundle parsed far enough to have events
401
447
  // to compare against; over a MALFORMED bundle there is nothing to say.
402
448
  if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
403
449
  return exit;
404
450
  }
405
451
 
452
+ // The paths after `--files`: every argument up to the next option.
453
+ function takeFilePaths(args: readonly string[], start: number): string[] {
454
+ const out: string[] = [];
455
+ for (const arg of args.slice(start)) {
456
+ if (arg.startsWith("--")) break;
457
+ out.push(arg);
458
+ }
459
+ return out;
460
+ }
461
+
462
+ // Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
463
+ // one error line when a path is not a readable regular file. The line names the
464
+ // path and not the operating system's words, which differ by kernel.
465
+ async function readOfferedFiles(paths: readonly string[]): Promise<OfferedFile[] | string> {
466
+ const out: OfferedFile[] = [];
467
+ for (const path of paths) {
468
+ try {
469
+ if (!(await stat(path)).isFile()) return `--files ${path} is not a readable file`;
470
+ out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
471
+ } catch {
472
+ return `--files ${path} is not a readable file`;
473
+ }
474
+ }
475
+ return out;
476
+ }
477
+
406
478
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
407
479
  //
408
480
  // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
@@ -582,8 +654,12 @@ async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
582
654
  return undefined;
583
655
  }
584
656
  try {
585
- return JSON.parse(raw) as T;
657
+ return parseRefusingDuplicates(raw) as T;
586
658
  } catch (e) {
659
+ if (e instanceof DuplicateKeyError) {
660
+ io.err(`${path} ${NOT_I_JSON}: ${e.message}`);
661
+ return undefined;
662
+ }
587
663
  io.err(`${path} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
588
664
  return undefined;
589
665
  }
@@ -713,8 +789,14 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
713
789
  }
714
790
  let logsParsed: unknown;
715
791
  try {
716
- logsParsed = JSON.parse(logsRaw);
792
+ logsParsed = parseRefusingDuplicates(logsRaw);
717
793
  } catch (e) {
794
+ if (e instanceof DuplicateKeyError) {
795
+ // One event id named twice hands the reader two contents for one
796
+ // record, and the last one silently won (proof#144).
797
+ io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
798
+ return undefined;
799
+ }
718
800
  io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
719
801
  return undefined;
720
802
  }
@@ -906,10 +988,12 @@ function printUsage(io: Cli): void {
906
988
  io.out(" (npx @sanning/proof <command> [args], without installing)");
907
989
  io.out("");
908
990
  io.out("Commands:");
909
- io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--json] Verify a bundle");
991
+ io.out(" verify <bundle.json> [--gateways <urls>] [--logs <path>] [--files <path>...] [--json]");
992
+ io.out(" Verify a bundle");
910
993
  io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
911
994
  io.out(" Create an attested export");
912
- io.out(" case <folder>... [--gateways <urls>] [--json] Read several packs as one case");
995
+ io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
996
+ io.out(" Read several packs as one case");
913
997
  io.out(" (`case --help` for its exit codes)");
914
998
  io.out("");
915
999
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
@@ -922,6 +1006,14 @@ function printUsage(io: Cli): void {
922
1006
  io.out("--json Print the verdict, the causes and the exit code as");
923
1007
  io.out(" JSON, for a script, instead of the report.");
924
1008
  io.out("");
1009
+ io.out("--files <path>... Files handed in beside the pack, every path up to the");
1010
+ io.out(" next option. Each is hashed and matched, by SHA-256");
1011
+ io.out(" alone, against what the pack's signed records commit");
1012
+ io.out(" to; the report lists the files the pack vouches for,");
1013
+ io.out(" the files that match no record, and the commitments");
1014
+ io.out(" no file was offered for. Names and folders are never");
1015
+ io.out(" compared, and the verdict never moves.");
1016
+ io.out("");
925
1017
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
926
1018
  io.out(" content_hash, or message_hash for a hand-off record.");
927
1019
  io.out(" Either shape works, and which one you");
@@ -1460,18 +1552,28 @@ export class CaseInputError extends Error {
1460
1552
  /**
1461
1553
  * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1462
1554
  * the one refusal that is not a parse error; `detail` is the parser's own words,
1463
- * which differ by kernel and are printed by `verify` only.
1555
+ * which differ by kernel and are printed by `verify` only. `duplicate` marks the
1556
+ * one parse refusal whose `detail` is kernel wording, identical in both kernels
1557
+ * (`duplicate key "<name>"`, proof#144), so both commands print it.
1464
1558
  */
1465
1559
  export class BundleReadError extends Error {
1466
1560
  readonly tooDeep: boolean;
1467
1561
  readonly detail: string;
1468
- constructor(tooDeep: boolean, detail: string) {
1562
+ readonly duplicate: boolean;
1563
+ constructor(tooDeep: boolean, detail: string, duplicate = false) {
1469
1564
  super(detail);
1470
1565
  this.tooDeep = tooDeep;
1471
1566
  this.detail = detail;
1567
+ this.duplicate = duplicate;
1472
1568
  }
1473
1569
  }
1474
1570
 
1571
+ // What both commands say about bytes that repeat a member name, before the
1572
+ // kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
1573
+ // I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
1574
+ // would be the wrong sentence. `NOT_I_JSON` in the Python twin.
1575
+ export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
1576
+
1475
1577
  // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1476
1578
  // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1477
1579
  const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
@@ -1481,7 +1583,8 @@ const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1481
1583
  * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1482
1584
  * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1483
1585
  * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1484
- * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`.
1586
+ * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
1587
+ * refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
1485
1588
  * Throws `BundleReadError`.
1486
1589
  */
1487
1590
  export function readBundleBytes(bytes: Uint8Array): unknown {
@@ -1490,8 +1593,9 @@ export function readBundleBytes(bytes: Uint8Array): unknown {
1490
1593
  throw new BundleReadError(true, `nests deeper than ${MAX_JSON_DEPTH} levels`);
1491
1594
  }
1492
1595
  try {
1493
- return JSON.parse(text);
1596
+ return parseRefusingDuplicates(text);
1494
1597
  } catch (e) {
1598
+ if (e instanceof DuplicateKeyError) throw new BundleReadError(false, e.message, true);
1495
1599
  throw new BundleReadError(false, e instanceof Error ? e.message : String(e));
1496
1600
  }
1497
1601
  }
@@ -1596,6 +1700,32 @@ export async function splitCase(
1596
1700
  return { packs, outside: outside.sort(), notRegular: notRegular.sort() };
1597
1701
  }
1598
1702
 
1703
+ /**
1704
+ * The bytes of the `witness-cache.json` beside a pack's `bundle.json`
1705
+ * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1706
+ *
1707
+ * The split rule is not changed for it: the file stays outside every pack,
1708
+ * listed as such, and is opened here and nowhere else. It is read only where it
1709
+ * is a regular file the split left outside every pack, so a file under another
1710
+ * pack's `logs/` is that pack's, and a link is never followed. A file that
1711
+ * cannot be read is no cache, never a refusal of the drop.
1712
+ */
1713
+ async function witnessCacheBytes(
1714
+ tree: Map<string, TreeEntry>,
1715
+ split: SplitCase,
1716
+ pack: SplitPack,
1717
+ ): Promise<Uint8Array | null> {
1718
+ const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
1719
+ const entry = tree.get(path);
1720
+ if (entry === undefined || !entry.regular || !split.outside.includes(path)) return null;
1721
+ try {
1722
+ return await readFileEntry(entry, path);
1723
+ } catch (e) {
1724
+ if (e instanceof CaseInputError) return null;
1725
+ throw e;
1726
+ }
1727
+ }
1728
+
1599
1729
  /** `verify`'s reader, with the refusal named by the pack's path. */
1600
1730
  export function loadBundle(pack: SplitPack): unknown {
1601
1731
  try {
@@ -1604,6 +1734,9 @@ export function loadBundle(pack: SplitPack): unknown {
1604
1734
  if (e instanceof BundleReadError && e.tooDeep) {
1605
1735
  throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1606
1736
  }
1737
+ if (e instanceof BundleReadError && e.duplicate) {
1738
+ throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
1739
+ }
1607
1740
  throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1608
1741
  }
1609
1742
  }
@@ -1819,7 +1952,7 @@ async function unzip(
1819
1952
 
1820
1953
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
1821
1954
 
1822
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--json]
1955
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
1823
1956
 
1824
1957
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
1825
1958
  several packs, or an unzipped case file. Packs are found by the
@@ -1843,6 +1976,10 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1843
1976
  --gateways comma-separated gateway URLs. Each pack's witnesses are then
1844
1977
  looked up on the public record, pack by pack, and reported on
1845
1978
  the witness line. The lookup never changes the verdict word.
1979
+ --files files handed in beside the packs, every path up to the next
1980
+ option, matched by SHA-256 alone against what every pack's
1981
+ signed records commit to. A file under a pack's logs/ is
1982
+ offered only when it is named here. The verdict never moves.
1846
1983
  --json the full reading as JSON: the verdict, every exception and note
1847
1984
  with its rendered line, every record, every contribution, the
1848
1985
  packs, the files outside them, the case file index comparison,
@@ -1903,6 +2040,34 @@ interface OnChainRow {
1903
2040
  mismatched: number;
1904
2041
  unreachable: number;
1905
2042
  not_checked: number;
2043
+ /** Present only where the pack caches a witness time (5.6.6, v1.25, A30). */
2044
+ witness_times?: WitnessTimeRow[];
2045
+ }
2046
+
2047
+ interface WitnessTimeRow {
2048
+ stamp_ref: string;
2049
+ cached: { block_height: number; block_time: number };
2050
+ outcome: WitnessTimeCheck["outcome"];
2051
+ answers: {
2052
+ gateway: string;
2053
+ outcome: WitnessTimeAnswer["outcome"];
2054
+ block_height: number | null;
2055
+ block_time: number | null;
2056
+ }[];
2057
+ }
2058
+
2059
+ function witnessTimeRow(t: WitnessTimeCheck): WitnessTimeRow {
2060
+ return {
2061
+ stamp_ref: t.cached.stampRef,
2062
+ cached: { block_height: t.cached.blockHeight, block_time: t.cached.blockTime },
2063
+ outcome: t.outcome,
2064
+ answers: t.answers.map((a) => ({
2065
+ gateway: a.gateway,
2066
+ outcome: a.outcome,
2067
+ block_height: a.blockHeight,
2068
+ block_time: a.blockTime,
2069
+ })),
2070
+ };
1906
2071
  }
1907
2072
 
1908
2073
  interface OnChainHalf {
@@ -1919,14 +2084,14 @@ interface OnChainHalf {
1919
2084
  // the verify page's case reading, so the case verdict stays the one both
1920
2085
  // surfaces compute. This adds the half the reading names as not checked.
1921
2086
  async function onChainHalf(
1922
- packs: readonly [string, unknown][],
2087
+ packs: readonly [string, unknown, readonly CachedWitnessTime[]][],
1923
2088
  gateways: string[],
1924
2089
  io: Cli,
1925
2090
  ): Promise<OnChainHalf> {
1926
2091
  if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1927
2092
  const rows: OnChainRow[] = [];
1928
2093
  const answered = new Set<string>();
1929
- for (const [ref, bundle] of packs) {
2094
+ for (const [ref, bundle, cached] of packs) {
1930
2095
  const result = await verifyEvidenceBundle(bundle, {
1931
2096
  gateways,
1932
2097
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
@@ -1947,6 +2112,11 @@ async function onChainHalf(
1947
2112
  else if (rollup === "unreachable") row.unreachable++;
1948
2113
  else row.not_checked++;
1949
2114
  }
2115
+ // The witness times the pack's witness-cache.json gives, looked up where
2116
+ // it gives any (5.6.6, v1.25, A30). A pack with no cache gets no key and
2117
+ // no request.
2118
+ const times = await checkWitnessCache(cached, gateways, io.fetchImpl ?? fetch);
2119
+ if (times.length > 0) row.witness_times = times.map(witnessTimeRow);
1950
2120
  rows.push(row);
1951
2121
  for (const w of witnesses) {
1952
2122
  for (const outcome of w.onChain?.perGateway ?? []) {
@@ -1955,8 +2125,15 @@ async function onChainHalf(
1955
2125
  }
1956
2126
  }
1957
2127
  }
2128
+ for (const t of times) {
2129
+ for (const a of t.answers) {
2130
+ if (a.outcome === "matches" || a.outcome === "differs") answered.add(a.gateway);
2131
+ }
2132
+ }
1958
2133
  }
1959
- const verdict = rows.some((r) => r.mismatched > 0)
2134
+ const verdict = rows.some(
2135
+ (r) => r.mismatched > 0 || (r.witness_times ?? []).some((t) => t.outcome === "differs"),
2136
+ )
1960
2137
  ? "failed"
1961
2138
  : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1962
2139
  ? "undetermined"
@@ -1976,18 +2153,75 @@ async function onChainHalf(
1976
2153
  * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1977
2154
  * headline.
1978
2155
  */
1979
- export function witnessLine(onChain: OnChainHalf): WitnessLine {
2156
+ // Distinct `[blockHeight, blockTime]` pairs, in time order.
2157
+ function distinctTimes(times: Iterable<[number, number]>): [number, number][] {
2158
+ const seen = new Map<string, [number, number]>();
2159
+ for (const [h, t] of times) seen.set(`${h}:${t}`, [h, t]);
2160
+ return [...seen.values()].sort((a, b) => a[1] - b[1] || a[0] - b[0]);
2161
+ }
2162
+
2163
+ // Only the keys that carry something: a line with no cached or recorded time
2164
+ // is the line 0.13.0 printed, field for field.
2165
+ function withTimes(
2166
+ line: WitnessLine,
2167
+ cached: [number, number][],
2168
+ recorded: [number, number][],
2169
+ ): WitnessLine {
2170
+ return {
2171
+ ...line,
2172
+ ...(cached.length > 0 ? { cached } : {}),
2173
+ ...(recorded.length > 0 ? { recorded } : {}),
2174
+ };
2175
+ }
2176
+
2177
+ /**
2178
+ * `cached` is every witness time the packs cache (5.6.6, v1.25, A30). Where no
2179
+ * lookup ran, each is printed labelled not checked. Where one ran, each time
2180
+ * the public record matched is printed as the record's, each time no lookup
2181
+ * settled stays labelled not checked, and a time the record contradicts is the
2182
+ * reason the line reads `differs`.
2183
+ */
2184
+ export function witnessLine(onChain: OnChainHalf, cached: readonly CachedWitnessTime[] = []): WitnessLine {
1980
2185
  if (!onChain.checked) {
1981
- return {
1982
- state: WITNESS_NOT_LOOKED_UP.state,
1983
- reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
1984
- services: [...WITNESS_NOT_LOOKED_UP.services],
1985
- };
2186
+ return withTimes(
2187
+ {
2188
+ state: WITNESS_NOT_LOOKED_UP.state,
2189
+ reasons: [...WITNESS_NOT_LOOKED_UP.reasons],
2190
+ services: [...WITNESS_NOT_LOOKED_UP.services],
2191
+ },
2192
+ distinctTimes(cached.map((c): [number, number] => [c.blockHeight, c.blockTime])),
2193
+ [],
2194
+ );
1986
2195
  }
1987
2196
  const services = [...(onChain.answered ?? [])];
1988
- if (onChain.verdict === "failed") return { state: "differs", reasons: [], services };
1989
- if (onChain.verdict === "confirmed") return { state: "confirmed", reasons: [], services };
1990
2197
  const rows = onChain.packs;
2198
+ const times = rows.flatMap((r) => r.witness_times ?? []);
2199
+ const unsettled = distinctTimes(
2200
+ times
2201
+ .filter((t) => t.outcome === "not_checked")
2202
+ .map((t): [number, number] => [t.cached.block_height, t.cached.block_time]),
2203
+ );
2204
+ const recorded = distinctTimes(
2205
+ times
2206
+ .filter((t) => t.outcome === "matches")
2207
+ .flatMap((t) => t.answers.filter((a) => a.outcome === "matches"))
2208
+ .map((a): [number, number] => [a.block_height as number, a.block_time as number]),
2209
+ );
2210
+ const contradicted: string[] = [];
2211
+ for (const t of times) {
2212
+ if (t.outcome !== "differs") continue;
2213
+ const a = t.answers.find((x) => x.outcome === "differs")!;
2214
+ const reason =
2215
+ `a pack dates the witness ${renderWitnessTime(t.cached.block_height, t.cached.block_time)}` +
2216
+ `, and the public record dates it ${renderWitnessTime(a.block_height as number, a.block_time as number)}`;
2217
+ if (!contradicted.includes(reason)) contradicted.push(reason);
2218
+ }
2219
+ if (onChain.verdict === "failed") {
2220
+ return withTimes({ state: "differs", reasons: contradicted, services }, unsettled, recorded);
2221
+ }
2222
+ if (onChain.verdict === "confirmed") {
2223
+ return withTimes({ state: "confirmed", reasons: [], services }, unsettled, recorded);
2224
+ }
1991
2225
  const reasons: string[] = [];
1992
2226
  if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
1993
2227
  if (rows.some((r) => r.unreachable > 0)) {
@@ -1999,7 +2233,7 @@ export function witnessLine(onChain: OnChainHalf): WitnessLine {
1999
2233
  if (rows.some((r) => r.not_checked > 0)) {
2000
2234
  reasons.push("no lookup is made for a witness kind a pack names");
2001
2235
  }
2002
- return { state: "not_checked", reasons, services };
2236
+ return withTimes({ state: "not_checked", reasons, services }, unsettled, recorded);
2003
2237
  }
2004
2238
 
2005
2239
  /**
@@ -2199,6 +2433,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2199
2433
  let asJson = false;
2200
2434
  let gateways: string[] = [];
2201
2435
  const paths: string[] = [];
2436
+ let filePaths: string[] | undefined;
2202
2437
  for (let i = 0; i < argv.length; i++) {
2203
2438
  const arg = argv[i]!;
2204
2439
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2219,6 +2454,15 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2219
2454
  .map((g) => g.trim())
2220
2455
  .filter((g) => g.length > 0);
2221
2456
  i++;
2457
+ } else if (arg === "--files") {
2458
+ const taken = takeFilePaths(argv, i + 1);
2459
+ if (taken.length === 0) {
2460
+ io.err("case: --files requires at least one file path");
2461
+ outBlock(io, CASE_USAGE);
2462
+ return EXIT_MALFORMED;
2463
+ }
2464
+ filePaths = [...(filePaths ?? []), ...taken];
2465
+ i += taken.length;
2222
2466
  } else if (arg.startsWith("--")) {
2223
2467
  io.err(`case: unknown option ${arg}`);
2224
2468
  outBlock(io, CASE_USAGE);
@@ -2233,6 +2477,13 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2233
2477
  return EXIT_MALFORMED;
2234
2478
  }
2235
2479
 
2480
+ let offered: OfferedFile[] | undefined;
2481
+ if (filePaths !== undefined) {
2482
+ const read = await readOfferedFiles(filePaths);
2483
+ if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
2484
+ offered = read;
2485
+ }
2486
+
2236
2487
  let split;
2237
2488
  let tree: Map<string, TreeEntry>;
2238
2489
  let bundles: [SplitPack, unknown][];
@@ -2255,25 +2506,31 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2255
2506
  if (e instanceof CaseInputError) return caseError(e, asJson, io);
2256
2507
  throw e;
2257
2508
  }
2509
+ const caches: CachedWitnessTime[][] = [];
2510
+ for (const [p, bundle] of bundles) {
2511
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
2512
+ }
2258
2513
  const onChain = await onChainHalf(
2259
- bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
2514
+ bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
2260
2515
  gateways,
2261
2516
  io,
2262
2517
  );
2263
- const witness = witnessLine(onChain);
2518
+ const witness = witnessLine(onChain, caches.flat());
2264
2519
  const code = caseExitCode(reading.verdict, onChain.verdict);
2265
2520
  const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2266
2521
 
2267
2522
  const data = {
2268
2523
  verdict: reading.verdict,
2269
2524
  title: titleOf(reading.verdict),
2270
- reliance: CASE_RELIANCE[reading.verdict],
2525
+ reliance: caseReliance(reading.verdict, reading.handoffRecords),
2271
2526
  exit_code: code,
2272
2527
  summary: renderCaseSummary(reading),
2273
2528
  witness: {
2274
2529
  state: witness.state,
2275
2530
  reasons: witness.reasons,
2276
2531
  services: witness.services,
2532
+ ...(witness.cached ? { cached: witness.cached } : {}),
2533
+ ...(witness.recorded ? { recorded: witness.recorded } : {}),
2277
2534
  rendered: renderWitnessLine(witness),
2278
2535
  },
2279
2536
  handoff_records: reading.handoffRecords,
@@ -2321,8 +2578,21 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2321
2578
  case_file: caseFile,
2322
2579
  on_chain: onChain,
2323
2580
  };
2324
- if (asJson) outJson(io, jsonText(data));
2325
- else for (const line of caseReport(data)) io.out(noLoneSurrogates(line));
2581
+ // Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
2582
+ // logs/ is its side content and is never an offered file; only the paths
2583
+ // after --files are. Nothing here moves `code`.
2584
+ let fileLines: string[] = [];
2585
+ const out: Record<string, unknown> = data;
2586
+ if (offered !== undefined) {
2587
+ const fileReading = await matchCaseFiles(
2588
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
2589
+ offered,
2590
+ );
2591
+ out.files = fileMatchToJson(fileReading);
2592
+ fileLines = renderFileMatch(fileReading);
2593
+ }
2594
+ if (asJson) outJson(io, jsonText(out));
2595
+ else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));
2326
2596
  return code;
2327
2597
  }
2328
2598
 
package/src/compose.ts CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  sha256Hex,
40
40
  utf8,
41
41
  } from "./crypto.js";
42
- import { jcs } from "./verifier.js";
42
+ import { isRfc3339UtcInstant, jcs } from "./verifier.js";
43
43
 
44
44
  // The exporter's Ed25519 wrapper-signing key. `privateKey` is a 32-byte seed as
45
45
  // lowercase hex (the @noble/ed25519 / stack convention, e.g. the fixtures'
@@ -133,6 +133,13 @@ export async function composeExport(
133
133
  throw new Error("composeExport: exporterKey.privateKey (32-byte hex seed) is required");
134
134
  }
135
135
 
136
+ if (options.generatedAt !== undefined && !isRfc3339UtcInstant(options.generatedAt)) {
137
+ // The verifier reads this as `malformed generated_at` (evidence-bundle §2,
138
+ // A31), so composing one would sign an export this kernel rejects.
139
+ throw new Error(
140
+ "composeExport: generatedAt is not an RFC 3339 UTC date-time with Z naming a real date and time",
141
+ );
142
+ }
136
143
  const generatedAt = options.generatedAt ?? new Date().toISOString();
137
144
  const previousHash = options.previousHash ?? GENESIS;
138
145
  const issuerId = options.issuerId ?? "sanning-verify";