@sanning/proof 0.13.0 → 0.15.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,8 @@ 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
- WITNESS_NOT_LOOKED_UP,
63
- type WitnessLine,
61
+ caseReliance,
64
62
  readCase,
65
63
  renderCaseNote,
66
64
  renderCaseSummary,
@@ -78,6 +76,20 @@ import { composeExport } from "./compose.js";
78
76
  import type { ComposeExportOptions } from "./compose.js";
79
77
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
80
78
  import { verifyEvidenceBundle } from "./evidence.js";
79
+ import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
80
+ import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
81
+ import type { OfferedFile } from "./file-match.js";
82
+ import {
83
+ type CachedWitnessTime,
84
+ WITNESS_CACHE_NAME,
85
+ readWitnessCache,
86
+ } from "./witness-cache.js";
87
+ // The public-record (witness) lookup this command runs with `--gateways`:
88
+ // moved to its own pure module (proof#200) so a consumer that is not this CLI,
89
+ // the verify page, can call the SAME implementation. `onChainHalf` and
90
+ // `witnessLine` are this command's own two building blocks, unchanged; see
91
+ // `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
92
+ import { type OnChainHalf, onChainHalf, witnessLine } from "./witness-lookup.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,
@@ -1896,111 +2033,6 @@ function jsonText(v: unknown): string {
1896
2033
  return JSON.stringify(sortKeys(v), null, 2);
1897
2034
  }
1898
2035
 
1899
- interface OnChainRow {
1900
- ref: string;
1901
- witnesses: number;
1902
- confirmed: number;
1903
- mismatched: number;
1904
- unreachable: number;
1905
- not_checked: number;
1906
- }
1907
-
1908
- interface OnChainHalf {
1909
- /** Present only where a lookup ran: each gateway that answered, in the order given. */
1910
- answered?: string[];
1911
- checked: boolean;
1912
- gateways: string[];
1913
- packs: OnChainRow[];
1914
- verdict: "not_checked" | "confirmed" | "failed" | "undetermined";
1915
- }
1916
-
1917
- // Each pack's witnesses, re-fetched through `verifyEvidenceBundle`. Outside the
1918
- // case reading on purpose: `readCase` makes no network call, and neither does
1919
- // the verify page's case reading, so the case verdict stays the one both
1920
- // surfaces compute. This adds the half the reading names as not checked.
1921
- async function onChainHalf(
1922
- packs: readonly [string, unknown][],
1923
- gateways: string[],
1924
- io: Cli,
1925
- ): Promise<OnChainHalf> {
1926
- if (gateways.length === 0) return { checked: false, gateways: [], packs: [], verdict: "not_checked" };
1927
- const rows: OnChainRow[] = [];
1928
- const answered = new Set<string>();
1929
- for (const [ref, bundle] of packs) {
1930
- const result = await verifyEvidenceBundle(bundle, {
1931
- gateways,
1932
- ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
1933
- });
1934
- const witnesses = [...result.checkpoints, ...(result.stamps ?? [])];
1935
- const row: OnChainRow = {
1936
- ref,
1937
- witnesses: witnesses.length,
1938
- confirmed: 0,
1939
- mismatched: 0,
1940
- unreachable: 0,
1941
- not_checked: 0,
1942
- };
1943
- for (const w of witnesses) {
1944
- const rollup = w.onChain?.rollup ?? null;
1945
- if (rollup === "confirm") row.confirmed++;
1946
- else if (rollup === "mismatch") row.mismatched++;
1947
- else if (rollup === "unreachable") row.unreachable++;
1948
- else row.not_checked++;
1949
- }
1950
- rows.push(row);
1951
- for (const w of witnesses) {
1952
- for (const outcome of w.onChain?.perGateway ?? []) {
1953
- if (outcome.outcome === "confirm" || outcome.outcome === "mismatch") {
1954
- answered.add(outcome.gateway);
1955
- }
1956
- }
1957
- }
1958
- }
1959
- const verdict = rows.some((r) => r.mismatched > 0)
1960
- ? "failed"
1961
- : rows.some((r) => r.witnesses === 0 || r.unreachable > 0 || r.not_checked > 0)
1962
- ? "undetermined"
1963
- : "confirmed";
1964
- return {
1965
- answered: gateways.filter((g) => answered.has(g)),
1966
- checked: true,
1967
- gateways: [...gateways],
1968
- packs: rows,
1969
- verdict,
1970
- };
1971
- }
1972
-
1973
- /**
1974
- * The witness line (evidence-bundle 5.5.6 v1.20, A24, D7) from the on-chain
1975
- * half: `confirmed` is `confirmed`, `not_checked` and `undetermined` are
1976
- * `not_checked` with the reason, and `failed` is `differs`. It never moves the
1977
- * headline.
1978
- */
1979
- export function witnessLine(onChain: OnChainHalf): WitnessLine {
1980
- 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
- };
1986
- }
1987
- 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
- const rows = onChain.packs;
1991
- const reasons: string[] = [];
1992
- if (rows.some((r) => r.witnesses === 0)) reasons.push("a pack names no witness");
1993
- if (rows.some((r) => r.unreachable > 0)) {
1994
- reasons.push(
1995
- "a lookup did not settle: the service could not be reached, or it " +
1996
- "holds no such witness yet",
1997
- );
1998
- }
1999
- if (rows.some((r) => r.not_checked > 0)) {
2000
- reasons.push("no lookup is made for a witness kind a pack names");
2001
- }
2002
- return { state: "not_checked", reasons, services };
2003
- }
2004
2036
 
2005
2037
  /**
2006
2038
  * The case verdict's code, moved only by an on-chain half that was asked for:
@@ -2199,6 +2231,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2199
2231
  let asJson = false;
2200
2232
  let gateways: string[] = [];
2201
2233
  const paths: string[] = [];
2234
+ let filePaths: string[] | undefined;
2202
2235
  for (let i = 0; i < argv.length; i++) {
2203
2236
  const arg = argv[i]!;
2204
2237
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2219,6 +2252,15 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2219
2252
  .map((g) => g.trim())
2220
2253
  .filter((g) => g.length > 0);
2221
2254
  i++;
2255
+ } else if (arg === "--files") {
2256
+ const taken = takeFilePaths(argv, i + 1);
2257
+ if (taken.length === 0) {
2258
+ io.err("case: --files requires at least one file path");
2259
+ outBlock(io, CASE_USAGE);
2260
+ return EXIT_MALFORMED;
2261
+ }
2262
+ filePaths = [...(filePaths ?? []), ...taken];
2263
+ i += taken.length;
2222
2264
  } else if (arg.startsWith("--")) {
2223
2265
  io.err(`case: unknown option ${arg}`);
2224
2266
  outBlock(io, CASE_USAGE);
@@ -2233,6 +2275,13 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2233
2275
  return EXIT_MALFORMED;
2234
2276
  }
2235
2277
 
2278
+ let offered: OfferedFile[] | undefined;
2279
+ if (filePaths !== undefined) {
2280
+ const read = await readOfferedFiles(filePaths);
2281
+ if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
2282
+ offered = read;
2283
+ }
2284
+
2236
2285
  let split;
2237
2286
  let tree: Map<string, TreeEntry>;
2238
2287
  let bundles: [SplitPack, unknown][];
@@ -2255,25 +2304,31 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2255
2304
  if (e instanceof CaseInputError) return caseError(e, asJson, io);
2256
2305
  throw e;
2257
2306
  }
2307
+ const caches: CachedWitnessTime[][] = [];
2308
+ for (const [p, bundle] of bundles) {
2309
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
2310
+ }
2258
2311
  const onChain = await onChainHalf(
2259
- bundles.map(([p, bundle]): [string, unknown] => [p.ref, bundle]),
2312
+ bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
2260
2313
  gateways,
2261
- io,
2314
+ { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) },
2262
2315
  );
2263
- const witness = witnessLine(onChain);
2316
+ const witness = witnessLine(onChain, caches.flat());
2264
2317
  const code = caseExitCode(reading.verdict, onChain.verdict);
2265
2318
  const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2266
2319
 
2267
2320
  const data = {
2268
2321
  verdict: reading.verdict,
2269
2322
  title: titleOf(reading.verdict),
2270
- reliance: CASE_RELIANCE[reading.verdict],
2323
+ reliance: caseReliance(reading.verdict, reading.handoffRecords),
2271
2324
  exit_code: code,
2272
2325
  summary: renderCaseSummary(reading),
2273
2326
  witness: {
2274
2327
  state: witness.state,
2275
2328
  reasons: witness.reasons,
2276
2329
  services: witness.services,
2330
+ ...(witness.cached ? { cached: witness.cached } : {}),
2331
+ ...(witness.recorded ? { recorded: witness.recorded } : {}),
2277
2332
  rendered: renderWitnessLine(witness),
2278
2333
  },
2279
2334
  handoff_records: reading.handoffRecords,
@@ -2321,8 +2376,21 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2321
2376
  case_file: caseFile,
2322
2377
  on_chain: onChain,
2323
2378
  };
2324
- if (asJson) outJson(io, jsonText(data));
2325
- else for (const line of caseReport(data)) io.out(noLoneSurrogates(line));
2379
+ // Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
2380
+ // logs/ is its side content and is never an offered file; only the paths
2381
+ // after --files are. Nothing here moves `code`.
2382
+ let fileLines: string[] = [];
2383
+ const out: Record<string, unknown> = data;
2384
+ if (offered !== undefined) {
2385
+ const fileReading = await matchCaseFiles(
2386
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
2387
+ offered,
2388
+ );
2389
+ out.files = fileMatchToJson(fileReading);
2390
+ fileLines = renderFileMatch(fileReading);
2391
+ }
2392
+ if (asJson) outJson(io, jsonText(out));
2393
+ else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));
2326
2394
  return code;
2327
2395
  }
2328
2396
 
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";