@sanning/proof 0.16.0 → 0.18.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
@@ -44,8 +44,8 @@
44
44
 
45
45
  import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
46
46
  import { constants as fsConstants, realpathSync } from "node:fs";
47
- import { randomBytes } from "node:crypto";
48
- import { basename, join, resolve } from "node:path";
47
+ import { createHash, randomBytes } from "node:crypto";
48
+ import { basename, resolve, sep } from "node:path";
49
49
  import { fileURLToPath } from "node:url";
50
50
 
51
51
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
@@ -67,18 +67,26 @@ import {
67
67
  } from "./case.js";
68
68
  import {
69
69
  CASE_FILE_INDEX_NAME,
70
+ caseFileListedPaths,
70
71
  jsonDepthExceeds,
71
72
  readCaseFileIndex,
72
73
  renderCaseFileFinding,
74
+ unreadableCaseFileIndex,
73
75
  } from "./case-file.js";
74
- import { ZipReadError, readZip } from "./zip-reader.js";
76
+ import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./zip-reader.js";
75
77
  import { composeExport } from "./compose.js";
76
78
  import type { ComposeExportOptions } from "./compose.js";
77
79
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
78
80
  import { verifyEvidenceBundle } from "./evidence.js";
79
81
  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
+ fileMatchToJson,
84
+ matchCaseFiles,
85
+ matchFiles,
86
+ notAccountedFor,
87
+ renderFileMatch,
88
+ } from "./file-match.js";
89
+ import type { OfferedFile, SkippedFile } from "./file-match.js";
82
90
  import {
83
91
  type CachedWitnessTime,
84
92
  WITNESS_CACHE_NAME,
@@ -354,13 +362,15 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
354
362
  const content = logs?.content;
355
363
 
356
364
  let offered: OfferedFile[] | undefined;
365
+ let skipped: SkippedFile[] = [];
357
366
  if (filePaths !== undefined) {
358
367
  const read = await readOfferedFiles(filePaths);
359
368
  if (typeof read === "string") {
360
369
  io.err(`verify: ${read}`);
361
370
  return EXIT_MALFORMED;
362
371
  }
363
- offered = read;
372
+ offered = read.offered;
373
+ skipped = read.skipped;
364
374
  }
365
375
 
366
376
  let raw: Uint8Array;
@@ -439,7 +449,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
439
449
  // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
440
450
  // beside the verdict, never inside it, so `exit` is untouched.
441
451
  if (offered !== undefined) {
442
- const reading = await matchFiles(parsed, offered, content);
452
+ const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
443
453
  for (const line of renderFileMatch(reading)) io.out(line);
444
454
  if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
445
455
  }
@@ -459,20 +469,188 @@ function takeFilePaths(args: readonly string[], start: number): string[] {
459
469
  return out;
460
470
  }
461
471
 
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[] = [];
472
+ // A file is hashed in pieces this big, so a file is read without being held.
473
+ const HASH_CHUNK = 1 << 20;
474
+
475
+ // The most a command reads from the files it finds in folders it was handed, in
476
+ // all: the cap the zip reader puts on an archive. A file that would push the
477
+ // total past it is skipped as `too_large` and never read past it, so a sparse
478
+ // file of a terabyte cannot hold a command for hours (A41). A file a person
479
+ // names on the command line is the person's choice and is read at any size.
480
+ export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
481
+
482
+ // What `hashFile` returns for a file that would push the total past the cap.
483
+ export const TOO_LARGE = "too_large";
484
+
485
+ // A child of a folder, by concatenation. `path.join` collapses `link/..`
486
+ // lexically, where the operating system, and the Python command, resolve it
487
+ // on disk: the same typed path then names two different folders (proof#219).
488
+ function childPath(directory: string, name: string): string {
489
+ return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
490
+ }
491
+
492
+ // The SHA-256 of a regular file, read in pieces, or null when it cannot be
493
+ // read. Inside a folder the file is opened without following a link and
494
+ // without blocking, then checked again, so a file swapped for a link or a pipe
495
+ // after the walk listed it is skipped and never followed or waited on. A path a
496
+ // person typed (`follow`) is opened as the shell would.
497
+ //
498
+ // `budget` is the bytes still allowed across the folders of a run. A file
499
+ // larger than it returns TOO_LARGE before a byte is read, and so does a file
500
+ // that grows past it while it is read; only a file read whole is charged.
501
+ export async function hashFile(
502
+ path: string,
503
+ follow: boolean,
504
+ budget?: { n: number },
505
+ ): Promise<string | null> {
506
+ const flags =
507
+ fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (follow ? 0 : (fsConstants.O_NOFOLLOW ?? 0));
508
+ let handle;
509
+ try {
510
+ handle = await open(path, flags);
511
+ } catch {
512
+ return null;
513
+ }
514
+ try {
515
+ const st = await handle.stat();
516
+ if (!st.isFile()) return null;
517
+ if (budget !== undefined && st.size > budget.n) return TOO_LARGE;
518
+ const h = createHash("sha256");
519
+ const buf = Buffer.allocUnsafe(HASH_CHUNK);
520
+ let total = 0;
521
+ for (;;) {
522
+ const { bytesRead } = await handle.read(buf, 0, HASH_CHUNK, null);
523
+ if (bytesRead === 0) {
524
+ if (budget !== undefined) budget.n -= total;
525
+ return h.digest("hex");
526
+ }
527
+ total += bytesRead;
528
+ if (budget !== undefined && total > budget.n) return TOO_LARGE;
529
+ h.update(buf.subarray(0, bytesRead));
530
+ }
531
+ } catch {
532
+ return null;
533
+ } finally {
534
+ await handle.close();
535
+ }
536
+ }
537
+
538
+ // The regular files under a `--files` folder, recursively, hidden files
539
+ // included, into `offered` as `<folder>/<path inside>` in UTF-16 code unit
540
+ // order of that path; a link or anything that is not a regular file, and a
541
+ // file or folder that cannot be read, into `skipped`. Returns the one error
542
+ // line, or null. Iterative, bounded as the case walk is.
543
+ //
544
+ // The files are hashed once the walk is done, in the order they are listed, so
545
+ // which file a full `budget` turns away does not depend on how the walk
546
+ // happened to reach it.
547
+ async function walkOfferedFolder(
548
+ typed: string,
549
+ offered: OfferedFile[],
550
+ skipped: SkippedFile[],
551
+ seen: { n: number },
552
+ budget: { n: number },
553
+ ): Promise<string | null> {
554
+ let base = typed;
555
+ while (base.length > 0 && (base.endsWith("/") || base.endsWith(sep))) base = base.slice(0, -1);
556
+ const found: { key: string; path?: string; skip?: SkippedFile }[] = [];
557
+ const queue: [string, string, number][] = [[typed, "", 0]];
558
+ for (let head = 0; head < queue.length; head++) {
559
+ const [directory, rel, depth] = queue[head]!;
560
+ let names: Buffer[];
561
+ try {
562
+ names = await readdir(directory, { encoding: "buffer" });
563
+ } catch {
564
+ if (head === 0) return `--files ${typed} is not a readable folder`;
565
+ found.push({ key: rel.slice(0, -1), skip: { name: `${base}/${rel.slice(0, -1)}`, reason: "unreadable" } });
566
+ continue;
567
+ }
568
+ names.sort(Buffer.compare);
569
+ for (const raw of names) {
570
+ seen.n++;
571
+ if (seen.n > MAX_WALK_ENTRIES) {
572
+ return `--files ${typed} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`;
573
+ }
574
+ let name: string;
575
+ try {
576
+ name = nameDecoder.decode(raw);
577
+ } catch {
578
+ return `--files ${typed} holds a name that is not valid UTF-8; rename it, then run again`;
579
+ }
580
+ const key = `${rel}${name}`;
581
+ const label = `${base}/${key}`;
582
+ const path = childPath(directory, name);
583
+ let st;
584
+ try {
585
+ st = await lstat(path);
586
+ } catch {
587
+ found.push({ key, skip: { name: label, reason: "unreadable" } });
588
+ continue;
589
+ }
590
+ if (st.isDirectory()) {
591
+ if (depth + 1 > MAX_WALK_DEPTH) {
592
+ return `--files ${typed} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`;
593
+ }
594
+ queue.push([path, `${key}/`, depth + 1]);
595
+ } else if (st.isSymbolicLink()) {
596
+ found.push({ key, skip: { name: label, reason: "symbolic_link" } });
597
+ } else if (!st.isFile()) {
598
+ found.push({ key, skip: { name: label, reason: "not_regular" } });
599
+ } else {
600
+ found.push({ key, path });
601
+ }
602
+ }
603
+ }
604
+ found.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
605
+ for (const f of found) {
606
+ if (f.skip !== undefined) {
607
+ skipped.push(f.skip);
608
+ continue;
609
+ }
610
+ const label = `${base}/${f.key}`;
611
+ const digest = await hashFile(f.path!, false, budget);
612
+ if (digest === TOO_LARGE) skipped.push({ name: label, reason: "too_large" });
613
+ else if (digest === null) skipped.push({ name: label, reason: "unreadable" });
614
+ else offered.push({ name: label, sha256: digest });
615
+ }
616
+ return null;
617
+ }
618
+
619
+ // Each `--files` path as `{ name, sha256 }`, or the one error line.
620
+ //
621
+ // A path that is a regular file is one file, its name exactly as typed. A path
622
+ // that is a folder stands for the regular files inside it, recursively and
623
+ // hidden files included, each named `<folder as typed>/<path inside>` (A40,
624
+ // proof#214). The rest is returned as `skipped`, named, never dropped. A path
625
+ // that is neither a readable file nor a readable folder is the one error line,
626
+ // which names the path and not the operating system's words, which differ by
627
+ // kernel. A path typed is followed as a shell would; a link found inside a
628
+ // folder is not.
629
+ export async function readOfferedFiles(
630
+ paths: readonly string[],
631
+ cap: number = MAX_FOLDER_BYTES,
632
+ ): Promise<{ offered: OfferedFile[]; skipped: SkippedFile[] } | string> {
633
+ const offered: OfferedFile[] = [];
634
+ const skipped: SkippedFile[] = [];
635
+ const seen = { n: 0 };
636
+ const budget = { n: cap };
467
637
  for (const path of paths) {
638
+ let st;
468
639
  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)) });
640
+ st = await stat(path);
471
641
  } catch {
472
642
  return `--files ${path} is not a readable file`;
473
643
  }
644
+ if (st.isDirectory()) {
645
+ const err = await walkOfferedFolder(path, offered, skipped, seen, budget);
646
+ if (err !== null) return err;
647
+ continue;
648
+ }
649
+ const digest = st.isFile() ? await hashFile(path, true) : null;
650
+ if (digest === null) return `--files ${path} is not a readable file`;
651
+ offered.push({ name: path, sha256: digest });
474
652
  }
475
- return out;
653
+ return { offered, skipped };
476
654
  }
477
655
 
478
656
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
@@ -734,7 +912,7 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
734
912
  // uses as-is; only the map form's *strings* go through `decodeLogValue`. That
735
913
  // is the invariant this feature exists to hold, and it is what makes the CLI
736
914
  // and the verify page agree about one pack.
737
- interface LoadedLogs {
915
+ export interface LoadedLogs {
738
916
  // Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
739
917
  // is hex in either case (map form). Those are exactly the two the verifier
740
918
  // distinguishes.
@@ -772,7 +950,7 @@ async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefin
772
950
  try {
773
951
  isDirectory = (await stat(logsPath)).isDirectory();
774
952
  } catch (e) {
775
- io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
953
+ io.err(`cannot read --logs ${logsPath}`);
776
954
  return undefined;
777
955
  }
778
956
  return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
@@ -783,8 +961,8 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
783
961
  let logsRaw: string;
784
962
  try {
785
963
  logsRaw = await readFile(logsPath, "utf8");
786
- } catch (e) {
787
- io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
964
+ } catch {
965
+ io.err(`cannot read --logs ${logsPath}`);
788
966
  return undefined;
789
967
  }
790
968
  let logsParsed: unknown;
@@ -797,7 +975,8 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
797
975
  io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
798
976
  return undefined;
799
977
  }
800
- io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
978
+ // The parser's own words differ by kernel, so the sentence is ours.
979
+ io.err(`--logs ${logsPath} is not valid JSON`);
801
980
  return undefined;
802
981
  }
803
982
  if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
@@ -809,97 +988,213 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
809
988
  }
810
989
  const content = new Map<string, Uint8Array | string>();
811
990
  const suppliedIds: string[] = [];
991
+ const notStrings: string[] = [];
812
992
  for (const [k, v] of Object.entries(logsParsed as Record<string, unknown>)) {
813
- if (typeof v !== "string") continue;
993
+ if (typeof v !== "string") {
994
+ notStrings.push(k);
995
+ continue;
996
+ }
814
997
  content.set(k, decodeLogValue(v));
815
998
  suppliedIds.push(k);
816
999
  }
817
- return { content, suppliedIds, notes: [] };
1000
+ // Named, every name, in the one sentence the Python command writes (A42): a
1001
+ // value that is not a string is never coerced and never dropped unnamed.
1002
+ const notes =
1003
+ notStrings.length === 0
1004
+ ? []
1005
+ : [
1006
+ `--logs ${logsPath}: skipped ${count(notStrings.length, "entry", "entries")} whose ` +
1007
+ `value is not a string (${quoted(notStrings.sort())})`,
1008
+ ];
1009
+ return { content, suppliedIds, notes };
818
1010
  }
819
1011
 
820
- // The directory form. Flat, one file per event, `<event_id>.json`.
1012
+ // The directory form, read by the one rule both kernels apply
1013
+ // (evidence-bundle.md §5.1.4, A42). The twin is `_load_logs_directory` in
1014
+ // `src/sanning_proof/cli.py`, and `cross-kernel/logs_folder_cli_leg.py` holds
1015
+ // the two commands to the same stdout, stderr and exit code.
821
1016
  //
822
- // The decisions this pins, none of which is a verification failure — an absent
823
- // or unusable disclosure is UNDISCLOSED, and absence is not evidence of
824
- // tampering (evidence-bundle.md §5.1):
1017
+ // A regular file named `<event_id>.json` is that record's disclosed bytes, and
1018
+ // nothing else in the folder is read. None of the following is a verification
1019
+ // failure, because an absent or unusable disclosure is UNDISCLOSED and absence
1020
+ // is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
1021
+ // is NAMED in a note, every name, so no file is dropped without being named:
825
1022
  //
826
1023
  // - a file whose name is not a known event id → loaded, binds to nothing,
827
- // named in a post-verify note (silence is how a typo'd filename becomes a
828
- // mysteriously undisclosed event)
1024
+ // named in a post-verify note
829
1025
  // - an event with no corresponding file → undetermined (`~`), noted
830
1026
  // - an empty directory → valid; discloses nothing,
831
1027
  // noted, exit code unchanged
832
- // - a subdirectory → skipped, never recursed,
833
- // noted (this is the "you pointed at the pack root, not its logs/" case)
834
- // - anything not named `<event_id>.json`, and anything that is not a regular
835
- // file → skipped, noted
1028
+ // - a subdirectory → never entered, noted (the
1029
+ // "you pointed at the pack root, not its logs/" case)
1030
+ // - a symbolic link → never followed, whatever it
1031
+ // points at, noted; `case` never follows one under a pack's `logs/` and
1032
+ // `--files` never follows one in a folder (A40), so a link named
1033
+ // `<event_id>.json` discloses nothing here either
1034
+ // - anything that is not a regular file → never opened, noted
1035
+ // - anything else not named `<event_id>.json` → never read, noted
1036
+ //
1037
+ // The Python command read every regular file by its STEM until 0.17.0, so a
1038
+ // forged `<id>.txt` beside a genuine `<id>.json` failed the pack there and was
1039
+ // never read here, and the forged-`.json` direction read clean there and
1040
+ // failed here: whoever assembled the folder chose the verdict.
836
1041
  //
837
- // The one hard stop is a file we SELECTED but cannot read: we were pointed at
838
- // it, so a permissions error must not read as minimal disclosure. That exits 2.
839
- async function loadLogsDirectory(dirPath: string, io: Cli): Promise<LoadedLogs | undefined> {
840
- let names: string[];
1042
+ // The hard stops (exit 2): a `<event_id>.json` file that cannot be read (we were
1043
+ // pointed at it, so a permissions error must not read as minimal disclosure),
1044
+ // more than MAX_WALK_ENTRIES names, a name that is not valid UTF-8 (escaping it
1045
+ // would let two names read as one), and `<event_id>.json` files holding more
1046
+ // than MAX_LOGS_BYTES in all.
1047
+ export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
1048
+
1049
+ export class LogsTooLarge extends Error {}
1050
+
1051
+ // A `<event_id>.json` file's bytes. Opened without following a link and without
1052
+ // blocking, then checked again, so a file swapped for a link or a pipe after the
1053
+ // folder was listed is refused rather than followed or waited on; and read no
1054
+ // further than `budget` allows. Two checks, and each is reachable without the
1055
+ // other: the size `fstat` reports refuses a sparse file before a byte is read,
1056
+ // and the count of bytes read refuses a file that reports less than it holds
1057
+ // (a file in /proc reports 0) or grows while it is read. `opener` is for the
1058
+ // tests, which must reach the second check with the first one passing.
1059
+ export async function readLogFile(
1060
+ path: string,
1061
+ budget: { n: number },
1062
+ opener: typeof open = open,
1063
+ ): Promise<Uint8Array> {
1064
+ const flags =
1065
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1066
+ const handle = await opener(path, flags);
841
1067
  try {
842
- names = await readdir(dirPath);
843
- } catch (e) {
844
- io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
1068
+ const st = await handle.stat();
1069
+ if (!st.isFile()) throw new Error("not a regular file");
1070
+ if (st.size > budget.n) throw new LogsTooLarge();
1071
+ const chunks: Buffer[] = [];
1072
+ let total = 0;
1073
+ for (;;) {
1074
+ const buf = Buffer.allocUnsafe(HASH_CHUNK);
1075
+ const { bytesRead } = await handle.read(buf, 0, HASH_CHUNK, null);
1076
+ if (bytesRead === 0) break;
1077
+ total += bytesRead;
1078
+ if (total > budget.n) throw new LogsTooLarge();
1079
+ chunks.push(buf.subarray(0, bytesRead));
1080
+ }
1081
+ budget.n -= total;
1082
+ // Copied into one array the verifier owns, never a window onto a pooled block.
1083
+ return new Uint8Array(Buffer.concat(chunks, total));
1084
+ } finally {
1085
+ await handle.close();
1086
+ }
1087
+ }
1088
+
1089
+ // `limits` is for the tests, which cannot write a gigabyte or fifty thousand
1090
+ // names per case; the command always passes the defaults.
1091
+ export async function loadLogsDirectory(
1092
+ dirPath: string,
1093
+ io: Cli,
1094
+ limits: { bytes: number; entries: number } = { bytes: MAX_LOGS_BYTES, entries: MAX_WALK_ENTRIES },
1095
+ ): Promise<LoadedLogs | undefined> {
1096
+ let raws: Buffer[];
1097
+ try {
1098
+ raws = await readdir(dirPath, { encoding: "buffer" });
1099
+ } catch {
1100
+ io.err(`cannot read --logs directory ${dirPath}`);
845
1101
  return undefined;
846
1102
  }
1103
+ if (raws.length > limits.entries) {
1104
+ io.err(`--logs ${dirPath} holds more than ${limits.entries} entries; refusing to read it`);
1105
+ return undefined;
1106
+ }
1107
+ const names: string[] = [];
1108
+ for (const raw of raws) {
1109
+ try {
1110
+ names.push(nameDecoder.decode(raw));
1111
+ } catch {
1112
+ io.err(`--logs ${dirPath} holds a name that is not valid UTF-8; rename it, then run verify again`);
1113
+ return undefined;
1114
+ }
1115
+ }
847
1116
 
848
1117
  const content = new Map<string, Uint8Array | string>();
849
1118
  const suppliedIds: string[] = [];
850
- const skippedDirs: string[] = [];
851
- const skippedNames: string[] = [];
1119
+ const dirs: string[] = [];
1120
+ const links: string[] = [];
1121
+ const notRegular: string[] = [];
1122
+ const notNamed: string[] = [];
1123
+ const budget = { n: limits.bytes };
852
1124
 
853
- for (const name of [...names].sort()) {
854
- const p = join(dirPath, name);
855
- let isDirectory: boolean;
856
- let isFile: boolean;
1125
+ for (const name of names.sort()) {
1126
+ const p = childPath(dirPath, name);
1127
+ let s;
857
1128
  try {
858
- // `stat`, not the dirent kind: a symlinked log file is a log file, and a
859
- // symlink to a directory is a directory.
860
- const s = await stat(p);
861
- isDirectory = s.isDirectory();
862
- isFile = s.isFile();
863
- } catch (e) {
864
- io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
1129
+ // `lstat`, never `stat`: a link in the folder is never followed.
1130
+ s = await lstat(p);
1131
+ } catch {
1132
+ io.err(`cannot read --logs entry ${p}`);
865
1133
  return undefined;
866
1134
  }
867
- if (isDirectory) {
868
- skippedDirs.push(name);
1135
+ if (s.isSymbolicLink()) {
1136
+ links.push(name);
1137
+ continue;
1138
+ }
1139
+ if (s.isDirectory()) {
1140
+ dirs.push(name);
1141
+ continue;
1142
+ }
1143
+ if (!s.isFile()) {
1144
+ notRegular.push(name);
869
1145
  continue;
870
1146
  }
871
- // Not a regular file (device, fifo, socket), or not named
872
- // `<event_id>.json` — including a file named exactly `.json`, whose event
873
- // id would be the empty string.
874
- if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
875
- skippedNames.push(name);
1147
+ // Not named `<event_id>.json`, including a file named exactly `.json`,
1148
+ // whose event id would be the empty string.
1149
+ if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
1150
+ notNamed.push(name);
876
1151
  continue;
877
1152
  }
878
1153
  const eventId = name.slice(0, -LOG_SUFFIX.length);
879
1154
  try {
880
- // No encoding argument: bytes, exactly as they sit on disk. Copied out of
881
- // Buffer's shared pool so the array handed to the verifier owns its own
882
- // memory rather than a window onto a reused block.
883
- content.set(eventId, new Uint8Array(await readFile(p)));
1155
+ // Bytes, exactly as they sit on disk.
1156
+ content.set(eventId, await readLogFile(p, budget));
884
1157
  } catch (e) {
885
- io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
1158
+ if (e instanceof LogsTooLarge) {
1159
+ io.err(
1160
+ `--logs ${dirPath}: the <event_id>${LOG_SUFFIX} files hold more than ${limits.bytes} ` +
1161
+ `bytes in all, at ${p}; refusing to read them`,
1162
+ );
1163
+ } else {
1164
+ io.err(`cannot read --logs file ${p}`);
1165
+ }
886
1166
  return undefined;
887
1167
  }
888
1168
  suppliedIds.push(eventId);
889
1169
  }
890
1170
 
891
1171
  const notes: string[] = [];
892
- if (skippedDirs.length > 0) {
1172
+ if (dirs.length > 0) {
893
1173
  notes.push(
894
- `--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
895
- `(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
1174
+ `--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
1175
+ `(${quoted(dirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
896
1176
  `and does not recurse`,
897
1177
  );
898
1178
  }
899
- if (skippedNames.length > 0) {
1179
+ if (links.length > 0) {
900
1180
  notes.push(
901
- `--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
902
- `not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`,
1181
+ `--logs ${dirPath}: skipped ${count(links.length, "symbolic link", "symbolic links")}, ` +
1182
+ `never followed (${quoted(links)})`,
1183
+ );
1184
+ }
1185
+ if (notRegular.length > 0) {
1186
+ notes.push(
1187
+ `--logs ${dirPath}: skipped ` +
1188
+ (notRegular.length === 1
1189
+ ? "1 entry that is not a regular file"
1190
+ : `${notRegular.length} entries that are not regular files`) +
1191
+ ` (${quoted(notRegular)})`,
1192
+ );
1193
+ }
1194
+ if (notNamed.length > 0) {
1195
+ notes.push(
1196
+ `--logs ${dirPath}: skipped ${count(notNamed.length, "entry", "entries")} ` +
1197
+ `not named <event_id>${LOG_SUFFIX} (${quoted(notNamed)})`,
903
1198
  );
904
1199
  }
905
1200
  if (suppliedIds.length === 0) {
@@ -924,11 +1219,11 @@ async function loadLogsDirectory(dirPath: string, io: Cli): Promise<LoadedLogs |
924
1219
  // of a disclosure is not evidence of tampering.
925
1220
  function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io: Cli): void {
926
1221
  const eventIds = new Set(result.events.map((e) => e.eventId));
927
- const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
1222
+ const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
928
1223
  if (unmatched.length > 0) {
929
1224
  io.err(
930
1225
  `note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
931
- `in the bundle, ignored: ${sample(unmatched)}`,
1226
+ `in the bundle, ignored: ${quoted(unmatched)}`,
932
1227
  );
933
1228
  }
934
1229
  const undetermined = result.events.filter((e) => e.contentOk === null);
@@ -950,6 +1245,12 @@ function count(n: number, one: string, many: string): string {
950
1245
  return `${n} ${n === 1 ? one : many}`;
951
1246
  }
952
1247
 
1248
+ // Every name, quoted. Every one, not the first few: a file left out of a note
1249
+ // is a file dropped without being named.
1250
+ function quoted(names: string[]): string {
1251
+ return names.map((n) => JSON.stringify(n)).join(", ");
1252
+ }
1253
+
953
1254
  // First few names, quoted, with a tail count — enough to recognise the mistake
954
1255
  // without pasting a whole directory into the terminal.
955
1256
  function sample(names: string[], max = 3): string {
@@ -992,7 +1293,7 @@ function printUsage(io: Cli): void {
992
1293
  io.out(" Verify a bundle");
993
1294
  io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
994
1295
  io.out(" Create an attested export");
995
- io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
1296
+ io.out(" case <folder or .zip>... [--gateways <urls>] [--files <path>...] [--json]");
996
1297
  io.out(" Read several packs as one case");
997
1298
  io.out(" (`case --help` for its exit codes)");
998
1299
  io.out("");
@@ -1012,7 +1313,15 @@ function printUsage(io: Cli): void {
1012
1313
  io.out(" to; the report lists the files the pack vouches for,");
1013
1314
  io.out(" the files that match no record, and the commitments");
1014
1315
  io.out(" no file was offered for. Names and folders are never");
1015
- io.out(" compared, and the verdict never moves.");
1316
+ io.out(" compared, and the verdict never moves. A folder");
1317
+ io.out(" stands for the regular files inside it, found");
1318
+ io.out(" recursively, hidden files included, so one folder");
1319
+ io.out(" covers a whole download in any shell (a wildcard like");
1320
+ io.out(" evidence/* is not expanded here). A symbolic link");
1321
+ io.out(" inside a folder is never followed and is listed as");
1322
+ io.out(" skipped, as is anything that is not a regular file or");
1323
+ io.out(" cannot be read: no file is dropped without being");
1324
+ io.out(" named. Two files with one hash are both listed.");
1016
1325
  io.out("");
1017
1326
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
1018
1327
  io.out(" content_hash, or message_hash for a hand-off record.");
@@ -1023,6 +1332,9 @@ function printUsage(io: Cli): void {
1023
1332
  io.out(" raw bytes ARE the content, nothing is decoded.");
1024
1333
  io.out(" This is a materialized pack's logs/ directory:");
1025
1334
  io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
1335
+ io.out(" Nothing else in it is read: a link is never");
1336
+ io.out(" followed, a subdirectory never entered, and any");
1337
+ io.out(" other name is not a disclosure. Each is named.");
1026
1338
  io.out("");
1027
1339
  io.out(" a JSON FILE mapping event_id → bytes (an");
1028
1340
  io.out(" even-length hex value, in either case, ⇒ hex,");
@@ -1459,11 +1771,12 @@ function mapGradeToExit(grade: PackGrade): number {
1459
1771
  //
1460
1772
  // ── Directories only ──
1461
1773
  //
1462
- // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
1463
- // unzip it first and pass the folder, and a `.zip` inside a directory is a file
1464
- // like any other: outside every pack, and never opened. Reading archives in the
1465
- // kernel brought zip bombs, two zip libraries that disagree, and entry names
1466
- // that are not file names, and `unzip` already does that job.
1774
+ // A `.zip` named on the command line is read as the folder `unzip` would make,
1775
+ // by the reader in `zip-reader.ts` and nothing else, one reader restated in both
1776
+ // kernels because two zip libraries disagree on the edges. A `.zip` INSIDE a
1777
+ // folder is a file like any other: outside every pack, hashed like any other
1778
+ // file and never unzipped, because a reader does not open a zip inside a zip
1779
+ // (§5.6.1).
1467
1780
  //
1468
1781
  // ── The walk ──
1469
1782
  //
@@ -1492,7 +1805,8 @@ function mapGradeToExit(grade: PackGrade): number {
1492
1805
  // at the directory that holds it.
1493
1806
  // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1494
1807
  // if it is that root's `bundle.json` or under that root's `logs/`.
1495
- // 3. A file under no pack is OUTSIDE: listed, and never opened.
1808
+ // 3. A file under no pack is OUTSIDE: listed, never read as part of a pack, and
1809
+ // hashed to be matched against the records (§5.7.6, A41).
1496
1810
  // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1497
1811
  // `Array.prototype.sort`).
1498
1812
  // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
@@ -1505,8 +1819,7 @@ function mapGradeToExit(grade: PackGrade): number {
1505
1819
  // holding them, each under its own name, and two that would land at one name
1506
1820
  // refuse.
1507
1821
  //
1508
- // Known differences from the page: the page opens a dropped `.zip` and this
1509
- // command does not; the page drops a leading byte-order mark from
1822
+ // Known differences from the page: the page drops a leading byte-order mark from
1510
1823
  // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1511
1824
  // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1512
1825
  // where this command applies the walk's rules above; and the page keys
@@ -1549,6 +1862,14 @@ export class CaseInputError extends Error {
1549
1862
  }
1550
1863
  }
1551
1864
 
1865
+ /**
1866
+ * A file `case` reads whole that does not fit in what is left of the run's
1867
+ * 1 GiB. Always a `CaseInputError`; the type lets a caller that can carry on
1868
+ * without the file (`contents.json`, `witness-cache.json`) tell this refusal
1869
+ * from an unreadable file.
1870
+ */
1871
+ export class WholeReadRefused extends CaseInputError {}
1872
+
1552
1873
  /**
1553
1874
  * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1554
1875
  * the one refusal that is not a parse error; `detail` is the parser's own words,
@@ -1615,7 +1936,7 @@ export interface SplitPack {
1615
1936
 
1616
1937
  export interface SplitCase {
1617
1938
  packs: SplitPack[];
1618
- /** Paths that belong to no pack, sorted. Never opened. */
1939
+ /** Paths that belong to no pack, sorted. Never read as part of a pack; a regular one is hashed, and nothing else is read of it (A41). */
1619
1940
  outside: string[];
1620
1941
  /** The part of `outside` that is not a regular file. Never followed, never opened. */
1621
1942
  notRegular: string[];
@@ -1632,6 +1953,13 @@ export interface TreeEntry {
1632
1953
  regular: boolean;
1633
1954
  /** The bytes of an entry read out of a case-file `.zip`. */
1634
1955
  data?: Uint8Array | null;
1956
+ /**
1957
+ * `true` for a symbolic link, on disk or an entry of a `.zip`: the one reason
1958
+ * a link is skipped under, here and under `--files`.
1959
+ */
1960
+ link?: boolean;
1961
+ /** The `.zip` reader's own words for an entry it refused (not a link). */
1962
+ refused?: string;
1635
1963
  }
1636
1964
 
1637
1965
  /**
@@ -1705,22 +2033,30 @@ export async function splitCase(
1705
2033
  * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1706
2034
  *
1707
2035
  * 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
2036
+ * listed as such, and is read here, for its contents, and hashed with the other
2037
+ * outside files (A41). It is read as a cache only where it is a regular file the split left outside every pack, so a file under another
1710
2038
  * 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.
2039
+ * cannot be read is no cache, never a refusal of the drop. One the run's
2040
+ * whole-read total has no room for is pushed onto `refused` when it is given,
2041
+ * so the output says so.
1712
2042
  */
1713
- async function witnessCacheBytes(
2043
+ export async function witnessCacheBytes(
1714
2044
  tree: Map<string, TreeEntry>,
1715
2045
  split: SplitCase,
1716
2046
  pack: SplitPack,
2047
+ budget?: { n: number },
2048
+ refused?: string[],
1717
2049
  ): Promise<Uint8Array | null> {
1718
2050
  const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
1719
2051
  const entry = tree.get(path);
1720
2052
  if (entry === undefined || !entry.regular || !split.outside.includes(path)) return null;
1721
2053
  try {
1722
- return await readFileEntry(entry, path);
2054
+ return await readFileEntry(entry, path, budget);
1723
2055
  } catch (e) {
2056
+ if (e instanceof WholeReadRefused) {
2057
+ refused?.push(path);
2058
+ return null;
2059
+ }
1724
2060
  if (e instanceof CaseInputError) return null;
1725
2061
  throw e;
1726
2062
  }
@@ -1741,12 +2077,46 @@ export function loadBundle(pack: SplitPack): unknown {
1741
2077
  }
1742
2078
  }
1743
2079
 
2080
+ /**
2081
+ * The refusal for a file `case` would hold whole that does not fit in what is
2082
+ * left of the 1 GiB a run reads whole. The same words in both kernels.
2083
+ */
2084
+ export const wholeReadRefusal = (key: string): string =>
2085
+ `${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
2086
+
2087
+ /**
2088
+ * Why `contents.json` reads as an unreadable index when the run's whole-read
2089
+ * total has no room for it, and when it cannot be read. The same words in both
2090
+ * kernels.
2091
+ */
2092
+ export const INDEX_OVER_BUDGET =
2093
+ "it would take case past the 1 GiB it reads whole in one run, so it was not read";
2094
+ export const INDEX_NOT_READ = "it could not be read";
2095
+
2096
+ /** The note for a `witness-cache.json` the whole-read total had no room for. */
2097
+ export const witnessCacheRefusedNote = (path: string): string =>
2098
+ `${path} was not read as a witness cache: it would take case past the 1 GiB it reads whole in one run`;
2099
+
2100
+ /** The refusal for an archive file larger than MAX_ZIP_ARCHIVE_BYTES. */
2101
+ export const zipFileRefusal = (arg: string): string =>
2102
+ `${arg} is larger than 1.25 GiB on disk; refusing to read it`;
2103
+
1744
2104
  /**
1745
2105
  * A regular file's bytes. Opened without following a link and without
1746
2106
  * blocking, then checked again, so a file swapped for a link or a pipe after
1747
2107
  * the walk listed it is refused rather than followed or waited on.
2108
+ *
2109
+ * `budget` is the bytes a run may still hold whole from files on disk
2110
+ * (MAX_FOLDER_BYTES at the start). A file larger than what is left, or that
2111
+ * grows past it while it is read, is refused before it is held, so a file of a
2112
+ * terabyte under a pack's own names cannot exhaust memory. A `.zip` entry is
2113
+ * already in memory, inside the archive's own cap, and is not charged.
1748
2114
  */
1749
- export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint8Array> {
2115
+ export async function readFileEntry(
2116
+ entry: TreeEntry,
2117
+ key: string,
2118
+ budget?: { n: number },
2119
+ ): Promise<Uint8Array> {
1750
2120
  if (entry.data !== undefined && entry.data !== null) return entry.data;
1751
2121
  const flags =
1752
2122
  fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
@@ -1757,9 +2127,42 @@ export async function readFileEntry(entry: TreeEntry, key: string): Promise<Uint
1757
2127
  throw new CaseInputError(`cannot read ${key}`);
1758
2128
  }
1759
2129
  try {
1760
- if (!(await handle.stat()).isFile()) throw new CaseInputError(`cannot read ${key}`);
1761
- return new Uint8Array(await handle.readFile());
1762
- } catch {
2130
+ const st = await handle.stat();
2131
+ if (!st.isFile()) throw new CaseInputError(`cannot read ${key}`);
2132
+ if (budget === undefined) return new Uint8Array(await handle.readFile());
2133
+ if (st.size > budget.n) throw new WholeReadRefused(wholeReadRefusal(key));
2134
+ // Once, at what the file says it holds and one byte more to see it grow:
2135
+ // the bytes are read into the buffer the result is a view of, never copied.
2136
+ const want = Math.min(st.size, budget.n) + 1;
2137
+ const buf = Buffer.allocUnsafeSlow(want);
2138
+ let total = 0;
2139
+ while (total < want) {
2140
+ const { bytesRead } = await handle.read(buf, total, want - total, null);
2141
+ if (bytesRead === 0) break;
2142
+ total += bytesRead;
2143
+ }
2144
+ if (total > st.size) {
2145
+ // The file grew since it was measured: take the rest in pieces, never
2146
+ // past the budget.
2147
+ const rest: Buffer[] = [];
2148
+ let all = total;
2149
+ while (all <= budget.n) {
2150
+ const chunk = Buffer.allocUnsafe(Math.min(HASH_CHUNK, budget.n - all + 1));
2151
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
2152
+ if (bytesRead === 0) break;
2153
+ all += bytesRead;
2154
+ rest.push(chunk.subarray(0, bytesRead));
2155
+ }
2156
+ if (all > budget.n) throw new WholeReadRefused(wholeReadRefusal(key));
2157
+ budget.n -= all;
2158
+ const joined = Buffer.concat([buf.subarray(0, total), ...rest], all);
2159
+ return new Uint8Array(joined.buffer, joined.byteOffset, all);
2160
+ }
2161
+ if (total > budget.n) throw new WholeReadRefused(wholeReadRefusal(key));
2162
+ budget.n -= total;
2163
+ return new Uint8Array(buf.buffer, buf.byteOffset, total);
2164
+ } catch (e) {
2165
+ if (e instanceof CaseInputError) throw e;
1763
2166
  throw new CaseInputError(`cannot read ${key}`);
1764
2167
  } finally {
1765
2168
  await handle.close();
@@ -1803,7 +2206,7 @@ async function walk(
1803
2206
  `${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`,
1804
2207
  );
1805
2208
  }
1806
- const path = join(directory, name);
2209
+ const path = childPath(directory, name);
1807
2210
  const key = `${rel}${name}`;
1808
2211
  let s;
1809
2212
  try {
@@ -1819,7 +2222,7 @@ async function walk(
1819
2222
  }
1820
2223
  queue.push([path, `${key}/`, depth + 1]);
1821
2224
  } else {
1822
- tree.set(key, { fsPath: path, regular: s.isFile() });
2225
+ tree.set(key, { fsPath: path, regular: s.isFile(), link: s.isSymbolicLink() });
1823
2226
  }
1824
2227
  }
1825
2228
  }
@@ -1902,11 +2305,33 @@ async function unzip(
1902
2305
  seen: { n: number },
1903
2306
  ): Promise<void> {
1904
2307
  let data: Uint8Array;
2308
+ let handle;
1905
2309
  try {
1906
- data = new Uint8Array(await readFile(arg));
2310
+ handle = await open(arg, fsConstants.O_RDONLY);
1907
2311
  } catch {
1908
2312
  throw new CaseInputError(`cannot read ${arg}`);
1909
2313
  }
2314
+ try {
2315
+ const size = (await handle.stat()).size;
2316
+ if (size > MAX_ZIP_ARCHIVE_BYTES) throw new CaseInputError(zipFileRefusal(arg));
2317
+ // One more byte than the size on disk, so a file that grew is noticed; the
2318
+ // archive is read straight into this buffer, once, and never copied.
2319
+ const buf = Buffer.allocUnsafe(size + 1);
2320
+ let have = 0;
2321
+ for (;;) {
2322
+ const { bytesRead } = await handle.read(buf, have, buf.length - have, null);
2323
+ if (bytesRead === 0) break;
2324
+ have += bytesRead;
2325
+ if (have === buf.length) break;
2326
+ }
2327
+ if (have > size) throw new CaseInputError(`cannot read ${arg}`);
2328
+ data = new Uint8Array(buf.buffer, buf.byteOffset, have);
2329
+ } catch (e) {
2330
+ if (e instanceof CaseInputError) throw e;
2331
+ throw new CaseInputError(`cannot read ${arg}`);
2332
+ } finally {
2333
+ await handle.close();
2334
+ }
1910
2335
  let entries;
1911
2336
  try {
1912
2337
  entries = readZip(data);
@@ -1925,6 +2350,8 @@ async function unzip(
1925
2350
  fsPath: "",
1926
2351
  regular: entry.refused === null,
1927
2352
  data: entry.data,
2353
+ link: entry.refused === "a symbolic link",
2354
+ ...(entry.refused !== null && entry.refused !== "a symbolic link" ? { refused: entry.refused } : {}),
1928
2355
  });
1929
2356
  }
1930
2357
  }
@@ -1959,7 +2386,8 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1959
2386
  verify page's splitting rule: every bundle.json is a pack, a file
1960
2387
  belongs to the deepest pack whose bundle.json or logs/ it sits
1961
2388
  in, and every other file is listed as outside every pack and
1962
- never opened. A .zip inside a folder is a file like any other. A
2389
+ matched by hash (see below), never read as part of a pack. A
2390
+ .zip inside a folder is a file like any other. A
1963
2391
  symbolic link is never followed: it is listed as outside and not
1964
2392
  read, even under a pack's logs/. Several arguments are read as
1965
2393
  one folder holding them, each under its own name. A folder
@@ -1978,12 +2406,25 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1978
2406
  the witness line. The lookup never changes the verdict word.
1979
2407
  --files files handed in beside the packs, every path up to the next
1980
2408
  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.
2409
+ signed records commit to. A folder stands for the regular files
2410
+ inside it, found recursively, hidden files included; a link
2411
+ inside it is listed as skipped and never followed. A file under
2412
+ a pack's logs/ is offered only when it is named here. The
2413
+ verdict never moves.
1983
2414
  --json the full reading as JSON: the verdict, every exception and note
1984
- with its rendered line, every record, every contribution, the
1985
- packs, the files outside them, the case file index comparison,
1986
- the witness line and the on-chain half.
2415
+ with its rendered line, every record of every pack with its
2416
+ status (read clean, read flagged, or not read and why), every
2417
+ contribution, the packs, each with its index, the files outside
2418
+ them, the case file index comparison, the witness line and the
2419
+ on-chain half.
2420
+
2421
+ Files outside every pack are matched too, always, so
2422
+ sanning-proof case audit-file.zip checks a whole download in one command: each
2423
+ is hashed and listed as vouched for by a record in a named pack, or as matching
2424
+ no record, by SHA-256 alone, as --files does. Names and folders are never
2425
+ compared, and neither the verdict nor the exit code moves. A link, a pipe or an
2426
+ entry or file that cannot be read is listed as skipped. Nothing is printed for
2427
+ this when no file sits outside every pack and --files is not given.
1987
2428
 
1988
2429
  Each pack's bundle.json is read as verify reads it, so the two commands
1989
2430
  classify one bundle the same way.
@@ -2046,7 +2487,7 @@ export function caseExitCode(verdict: string, onChainVerdict: string): number {
2046
2487
  return code;
2047
2488
  }
2048
2489
 
2049
- interface CaseFileData {
2490
+ export interface CaseFileData {
2050
2491
  root: string;
2051
2492
  index: string;
2052
2493
  case_id: string | null;
@@ -2061,16 +2502,19 @@ interface CaseData {
2061
2502
  exceptions: { rendered: string }[];
2062
2503
  notes: { rendered: string }[];
2063
2504
  packs: {
2505
+ index: number;
2064
2506
  ref: string;
2065
2507
  log_files: number;
2066
2508
  unaccounted: string[];
2067
2509
  signing_key: string | null;
2068
2510
  grade: string | null;
2069
2511
  }[];
2512
+ records: { pack_index: number; status: string }[];
2070
2513
  outside: string[];
2071
2514
  not_regular: string[];
2072
2515
  case_file: CaseFileData[];
2073
2516
  on_chain: OnChainHalf;
2517
+ witness_cache_not_read?: string[];
2074
2518
  }
2075
2519
 
2076
2520
  const titleOf = (word: string): string =>
@@ -2096,7 +2540,15 @@ function caseReport(data: CaseData): string[] {
2096
2540
  for (const p of data.packs) {
2097
2541
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
2098
2542
  const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
2099
- let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
2543
+ // Each record's status, counted per pack (A43): the JSON lists every one.
2544
+ const mine = data.records.filter((r) => r.pack_index === p.index);
2545
+ const count = (status: string) => mine.filter((r) => r.status === status).length;
2546
+ const records =
2547
+ `records: ${count("read_clean")} read clean, ${count("read_flagged")} read flagged, ` +
2548
+ `${count("not_read")} not read`;
2549
+ let line =
2550
+ ` ${p.ref} signed by ${key}; ${grade}; ${records}; ` +
2551
+ `${p.log_files} record file(s) under logs/`;
2100
2552
  if (p.unaccounted.length > 0) {
2101
2553
  line +=
2102
2554
  `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
@@ -2114,6 +2566,9 @@ function caseReport(data: CaseData): string[] {
2114
2566
  lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
2115
2567
  }
2116
2568
  }
2569
+ for (const path of data.witness_cache_not_read ?? []) {
2570
+ lines.push(`note ${witnessCacheRefusedNote(path)}`);
2571
+ }
2117
2572
  for (const c of data.case_file) {
2118
2573
  if (c.index === "absent") continue;
2119
2574
  const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
@@ -2145,34 +2600,67 @@ function caseReport(data: CaseData): string[] {
2145
2600
  /**
2146
2601
  * The index comparison, once per argument (5.6.3): each folder or case file
2147
2602
  * whose root holds `contents.json`. Every file under `packs/` and `report/` is
2148
- * hashed for it, as bytes, and nothing is parsed but the index. It is never an
2149
- * input to the reading.
2603
+ * hashed for it, in pieces, as bytes, and nothing is parsed but the index. The
2604
+ * files share one total of 1 GiB across the run, charged in path order; a file
2605
+ * larger than what is left is reported as too large to hash, never as a match
2606
+ * and never as absent. It is never an input to the reading.
2150
2607
  */
2151
- async function caseFileReadings(tree: Map<string, TreeEntry>): Promise<CaseFileData[]> {
2608
+ export async function caseFileReadings(
2609
+ tree: Map<string, TreeEntry>,
2610
+ whole?: { n: number },
2611
+ cap: number = MAX_FOLDER_BYTES,
2612
+ ): Promise<CaseFileData[]> {
2152
2613
  const out: (CaseFileData & { findings: Record<string, unknown>[] })[] = [];
2614
+ const budget = { n: cap };
2153
2615
  const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0] as string))].sort();
2154
2616
  const keys = [...tree.keys()].sort();
2155
2617
  for (const root of roots) {
2156
2618
  const prefix = `${root}/`;
2157
2619
  const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
2158
2620
  const indexEntry = tree.get(indexKey);
2159
- const indexBytes =
2160
- indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
2161
- const files: { path: string; sha256: string }[] = [];
2162
- if (indexBytes !== null) {
2163
- for (const key of keys) {
2621
+ let indexBytes: Uint8Array | null = null;
2622
+ let refused: string | null = null;
2623
+ if (indexEntry !== undefined && indexEntry.regular) {
2624
+ try {
2625
+ indexBytes = await readFileEntry(indexEntry, indexKey, whole);
2626
+ } catch (e) {
2627
+ if (e instanceof WholeReadRefused) refused = INDEX_OVER_BUDGET;
2628
+ else if (e instanceof CaseInputError) refused = INDEX_NOT_READ;
2629
+ else throw e;
2630
+ }
2631
+ }
2632
+ const files: { path: string; sha256?: string; too_large?: boolean }[] = [];
2633
+ const listed = indexBytes !== null ? caseFileListedPaths(indexBytes) : null;
2634
+ if (listed !== null) {
2635
+ const candidates = keys
2636
+ .filter((key) => key.startsWith(prefix) && tree.get(key)!.regular)
2637
+ .map((key) => [key, key.slice(prefix.length)] as const)
2638
+ .filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
2639
+ // The files the index lists are charged first, then the rest, each group
2640
+ // in UTF-16 order, so a file it does not list cannot use up the total and
2641
+ // turn a real mismatch into `too_large`.
2642
+ for (const [key, rel] of [
2643
+ ...candidates.filter(([, rel]) => listed.has(rel)),
2644
+ ...candidates.filter(([, rel]) => !listed.has(rel)),
2645
+ ]) {
2164
2646
  const entry = tree.get(key)!;
2165
- const rel = key.slice(prefix.length);
2166
- if (
2167
- key.startsWith(prefix) &&
2168
- entry.regular &&
2169
- (rel.startsWith("packs/") || rel.startsWith("report/"))
2170
- ) {
2171
- files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
2647
+ let digest: string | null;
2648
+ if (entry.data !== undefined && entry.data !== null) {
2649
+ if (entry.data.length > budget.n) digest = TOO_LARGE;
2650
+ else {
2651
+ budget.n -= entry.data.length;
2652
+ digest = await sha256Hex(entry.data);
2653
+ }
2654
+ } else {
2655
+ digest = await hashFile(entry.fsPath, false, budget);
2172
2656
  }
2657
+ if (digest === TOO_LARGE) files.push({ path: rel, too_large: true });
2658
+ else if (digest === null) throw new CaseInputError(`cannot read ${key}`);
2659
+ else files.push({ path: rel, sha256: digest });
2173
2660
  }
2174
2661
  }
2175
- const reading = readCaseFileIndex(indexBytes, files);
2662
+ const reading =
2663
+ refused !== null ? unreadableCaseFileIndex(refused) : readCaseFileIndex(indexBytes, files);
2176
2664
  const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
2177
2665
  out.push({
2178
2666
  root,
@@ -2227,6 +2715,60 @@ export async function runCase(argv: readonly string[], stream: Cli): Promise<num
2227
2715
  }
2228
2716
  }
2229
2717
 
2718
+ // The files that sit outside every pack, as files offered (A41, proof#215).
2719
+ //
2720
+ // Every regular file the split left outside, in the split's order, hashed
2721
+ // exactly as a `--files` file is (a folder's file in pieces, a `.zip` entry
2722
+ // from the bytes the reader already holds) and named by its path in the tree,
2723
+ // `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
2724
+ // name each file alike. A link, a pipe, an entry the zip reader refused (with
2725
+ // its own words) and a file that cannot be read are returned as skipped, named
2726
+ // and never opened as a pack file. A pack's own files, `bundle.json` and what
2727
+ // is under `logs/`, are not outside and are not offered.
2728
+ //
2729
+ // The files share one total of 1 GiB, the cap the zip reader puts on an
2730
+ // archive, charged in that order. A file larger than what is left is skipped as
2731
+ // `too_large` and read no further, so a sparse file of a terabyte in a
2732
+ // downloaded folder cannot keep `case` from reaching its verdict.
2733
+ export async function looseFiles(
2734
+ tree: Map<string, TreeEntry>,
2735
+ split: SplitCase,
2736
+ cap: number = MAX_FOLDER_BYTES,
2737
+ ): Promise<{ offered: OfferedFile[]; skipped: SkippedFile[] }> {
2738
+ const offered: OfferedFile[] = [];
2739
+ const skipped: SkippedFile[] = [];
2740
+ const budget = { n: cap };
2741
+ for (const path of split.outside) {
2742
+ const entry = tree.get(path)!;
2743
+ if (entry.link === true) {
2744
+ skipped.push({ name: path, reason: "symbolic_link" });
2745
+ continue;
2746
+ }
2747
+ if (entry.refused !== undefined) {
2748
+ skipped.push({ name: path, reason: "refused", detail: entry.refused });
2749
+ continue;
2750
+ }
2751
+ if (!entry.regular) {
2752
+ skipped.push({ name: path, reason: "not_regular" });
2753
+ continue;
2754
+ }
2755
+ let digest: string | null;
2756
+ if (entry.data !== undefined && entry.data !== null) {
2757
+ if (entry.data.length > budget.n) digest = TOO_LARGE;
2758
+ else {
2759
+ budget.n -= entry.data.length;
2760
+ digest = await sha256Hex(entry.data);
2761
+ }
2762
+ } else {
2763
+ digest = await hashFile(entry.fsPath, false, budget);
2764
+ }
2765
+ if (digest === TOO_LARGE) skipped.push({ name: path, reason: "too_large" });
2766
+ else if (digest === null) skipped.push({ name: path, reason: "unreadable" });
2767
+ else offered.push({ name: path, sha256: digest });
2768
+ }
2769
+ return { offered, skipped };
2770
+ }
2771
+
2230
2772
  async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2231
2773
  let asJson = false;
2232
2774
  let gateways: string[] = [];
@@ -2276,18 +2818,21 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2276
2818
  }
2277
2819
 
2278
2820
  let offered: OfferedFile[] | undefined;
2821
+ let skipped: SkippedFile[] = [];
2279
2822
  if (filePaths !== undefined) {
2280
2823
  const read = await readOfferedFiles(filePaths);
2281
2824
  if (typeof read === "string") return caseError(new CaseInputError(read), asJson, io);
2282
- offered = read;
2825
+ offered = read.offered;
2826
+ skipped = read.skipped;
2283
2827
  }
2284
2828
 
2829
+ const whole = { n: MAX_FOLDER_BYTES };
2285
2830
  let split;
2286
2831
  let tree: Map<string, TreeEntry>;
2287
2832
  let bundles: [SplitPack, unknown][];
2288
2833
  try {
2289
2834
  tree = await treeFromArguments(paths);
2290
- split = await splitCase(tree, readFileEntry);
2835
+ split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
2291
2836
  bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
2292
2837
  } catch (e) {
2293
2838
  if (e instanceof CaseInputError) return caseError(e, asJson, io);
@@ -2299,14 +2844,15 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2299
2844
  );
2300
2845
  let caseFile: CaseFileData[];
2301
2846
  try {
2302
- caseFile = await caseFileReadings(tree);
2847
+ caseFile = await caseFileReadings(tree, whole);
2303
2848
  } catch (e) {
2304
2849
  if (e instanceof CaseInputError) return caseError(e, asJson, io);
2305
2850
  throw e;
2306
2851
  }
2307
2852
  const caches: CachedWitnessTime[][] = [];
2853
+ const cacheRefused: string[] = [];
2308
2854
  for (const [p, bundle] of bundles) {
2309
- caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
2855
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
2310
2856
  }
2311
2857
  const onChain = await onChainHalf(
2312
2858
  bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
@@ -2336,7 +2882,9 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2336
2882
  exceptions: reading.exceptions.map((e) => ({
2337
2883
  kind: e.kind,
2338
2884
  pack_ref: nil(e.packRef),
2885
+ pack_index: nil(e.packIndex),
2339
2886
  event_id: nil(e.eventId),
2887
+ record_index: nil(e.recordIndex),
2340
2888
  counterparty_key: nil(e.counterpartyKey),
2341
2889
  failed_checks: e.failedChecks ?? [],
2342
2890
  count: nil(e.count),
@@ -2348,12 +2896,18 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2348
2896
  notes: reading.notes.map((n) => ({
2349
2897
  kind: n.kind,
2350
2898
  pack_ref: n.packRef,
2899
+ pack_index: n.packIndex,
2351
2900
  count: nil(n.count),
2352
2901
  rendered: renderCaseNote(n),
2353
2902
  })),
2354
2903
  records: reading.records.map((r) => ({
2355
2904
  pack_ref: r.packRef,
2905
+ pack_index: r.packIndex,
2356
2906
  event_id: r.eventId,
2907
+ record_index: r.recordIndex,
2908
+ status: r.status,
2909
+ reason: r.reason,
2910
+ declaration_findings: [...r.declarationFindings],
2357
2911
  verdict: nil(r.verdict),
2358
2912
  role: r.role,
2359
2913
  findings: r.findings,
@@ -2362,6 +2916,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2362
2916
  })),
2363
2917
  contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
2364
2918
  packs: bundles.map(([p], i) => ({
2919
+ index: reading.packs[i]!.index,
2365
2920
  ref: p.ref,
2366
2921
  bundle_path: p.bundlePath,
2367
2922
  log_files: p.content.size,
@@ -2375,19 +2930,35 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2375
2930
  not_regular: split.notRegular,
2376
2931
  case_file: caseFile,
2377
2932
  on_chain: onChain,
2933
+ ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
2378
2934
  };
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`.
2935
+ // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2936
+ // then the files after --files. A pack's logs/ is its side content and is
2937
+ // never an offered file; only the paths after --files are. Both are matched
2938
+ // by SHA-256 alone and nothing here moves `code`. The section is printed when
2939
+ // there is a file to say something about, so a case with nothing outside
2940
+ // every pack and no --files prints what it always did.
2941
+ const loose = await looseFiles(tree, split);
2382
2942
  let fileLines: string[] = [];
2383
2943
  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
- );
2944
+ if (offered !== undefined || loose.offered.length > 0 || loose.skipped.length > 0) {
2945
+ const fileReading = {
2946
+ ...(await matchCaseFiles(
2947
+ bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
2948
+ [...loose.offered, ...(offered ?? [])],
2949
+ )),
2950
+ skipped: [...loose.skipped, ...skipped],
2951
+ };
2389
2952
  out.files = fileMatchToJson(fileReading);
2390
- fileLines = renderFileMatch(fileReading);
2953
+ // No --files: one count line for the recorded files no loose file matched,
2954
+ // not a line for each of them, and none for a record the pack's own logs
2955
+ // disclose and bind. With --files, and in --json, every one is listed.
2956
+ fileLines = renderFileMatch(
2957
+ fileReading,
2958
+ offered !== undefined
2959
+ ? undefined
2960
+ : (await notAccountedFor(fileReading, new Map(bundles.map(([p]) => [p.ref, p.content])))).length,
2961
+ );
2391
2962
  }
2392
2963
  if (asJson) outJson(io, jsonText(out));
2393
2964
  else for (const line of [...caseReport(data), ...fileLines]) io.out(noLoneSurrogates(line));