@sanning/proof 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -43,19 +43,19 @@
43
43
  // checked, and exit 3 is how this command says "undetermined".
44
44
  import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
45
45
  import { constants as fsConstants, realpathSync } from "node:fs";
46
- import { randomBytes } from "node:crypto";
47
- import { basename, join, resolve } from "node:path";
46
+ import { createHash, randomBytes } from "node:crypto";
47
+ import { basename, resolve, sep } from "node:path";
48
48
  import { fileURLToPath } from "node:url";
49
49
  import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
50
50
  import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
51
51
  import { VERDICT_DISPLAY_TITLES, caseReliance, readCase, renderCaseNote, renderCaseSummary, renderException, renderWitnessLine, } from "./case.js";
52
- import { CASE_FILE_INDEX_NAME, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, } from "./case-file.js";
53
- import { ZipReadError, readZip } from "./zip-reader.js";
52
+ import { CASE_FILE_INDEX_NAME, caseFileListedPaths, jsonDepthExceeds, readCaseFileIndex, renderCaseFileFinding, unreadableCaseFileIndex, } from "./case-file.js";
53
+ import { MAX_ZIP_ARCHIVE_BYTES, MAX_ZIP_BYTES, ZipReadError, readZip } from "./zip-reader.js";
54
54
  import { composeExport } from "./compose.js";
55
55
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
56
56
  import { verifyEvidenceBundle } from "./evidence.js";
57
57
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
58
- import { fileMatchToJson, matchCaseFiles, matchFiles, renderFileMatch } from "./file-match.js";
58
+ import { fileMatchToJson, matchCaseFiles, matchFiles, notAccountedFor, renderFileMatch, } from "./file-match.js";
59
59
  import { WITNESS_CACHE_NAME, readWitnessCache, } from "./witness-cache.js";
60
60
  // The public-record (witness) lookup this command runs with `--gateways`:
61
61
  // moved to its own pure module (proof#200) so a consumer that is not this CLI,
@@ -273,13 +273,15 @@ async function runVerify(rest, io, sink) {
273
273
  }
274
274
  const content = logs?.content;
275
275
  let offered;
276
+ let skipped = [];
276
277
  if (filePaths !== undefined) {
277
278
  const read = await readOfferedFiles(filePaths);
278
279
  if (typeof read === "string") {
279
280
  io.err(`verify: ${read}`);
280
281
  return EXIT_MALFORMED;
281
282
  }
282
- offered = read;
283
+ offered = read.offered;
284
+ skipped = read.skipped;
283
285
  }
284
286
  let raw;
285
287
  try {
@@ -357,7 +359,7 @@ async function runVerify(rest, io, sink) {
357
359
  // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
358
360
  // beside the verdict, never inside it, so `exit` is untouched.
359
361
  if (offered !== undefined) {
360
- const reading = await matchFiles(parsed, offered, content);
362
+ const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
361
363
  for (const line of renderFileMatch(reading))
362
364
  io.out(line);
363
365
  if (sink !== null && sink.data !== null)
@@ -379,22 +381,189 @@ function takeFilePaths(args, start) {
379
381
  }
380
382
  return out;
381
383
  }
382
- // Each `--files` path as `{ name, bytes }`, the name exactly as typed, or the
383
- // one error line when a path is not a readable regular file. The line names the
384
- // path and not the operating system's words, which differ by kernel.
385
- async function readOfferedFiles(paths) {
386
- const out = [];
384
+ // A file is hashed in pieces this big, so a file is read without being held.
385
+ const HASH_CHUNK = 1 << 20;
386
+ // The most a command reads from the files it finds in folders it was handed, in
387
+ // all: the cap the zip reader puts on an archive. A file that would push the
388
+ // total past it is skipped as `too_large` and never read past it, so a sparse
389
+ // file of a terabyte cannot hold a command for hours (A41). A file a person
390
+ // names on the command line is the person's choice and is read at any size.
391
+ export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
392
+ // What `hashFile` returns for a file that would push the total past the cap.
393
+ export const TOO_LARGE = "too_large";
394
+ // A child of a folder, by concatenation. `path.join` collapses `link/..`
395
+ // lexically, where the operating system, and the Python command, resolve it
396
+ // on disk: the same typed path then names two different folders (proof#219).
397
+ function childPath(directory, name) {
398
+ return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
399
+ }
400
+ // The SHA-256 of a regular file, read in pieces, or null when it cannot be
401
+ // read. Inside a folder the file is opened without following a link and
402
+ // without blocking, then checked again, so a file swapped for a link or a pipe
403
+ // after the walk listed it is skipped and never followed or waited on. A path a
404
+ // person typed (`follow`) is opened as the shell would.
405
+ //
406
+ // `budget` is the bytes still allowed across the folders of a run. A file
407
+ // larger than it returns TOO_LARGE before a byte is read, and so does a file
408
+ // that grows past it while it is read; only a file read whole is charged.
409
+ export async function hashFile(path, follow, budget) {
410
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (follow ? 0 : (fsConstants.O_NOFOLLOW ?? 0));
411
+ let handle;
412
+ try {
413
+ handle = await open(path, flags);
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ try {
419
+ const st = await handle.stat();
420
+ if (!st.isFile())
421
+ return null;
422
+ if (budget !== undefined && st.size > budget.n)
423
+ return TOO_LARGE;
424
+ const h = createHash("sha256");
425
+ const buf = Buffer.allocUnsafe(HASH_CHUNK);
426
+ let total = 0;
427
+ for (;;) {
428
+ const { bytesRead } = await handle.read(buf, 0, HASH_CHUNK, null);
429
+ if (bytesRead === 0) {
430
+ if (budget !== undefined)
431
+ budget.n -= total;
432
+ return h.digest("hex");
433
+ }
434
+ total += bytesRead;
435
+ if (budget !== undefined && total > budget.n)
436
+ return TOO_LARGE;
437
+ h.update(buf.subarray(0, bytesRead));
438
+ }
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ finally {
444
+ await handle.close();
445
+ }
446
+ }
447
+ // The regular files under a `--files` folder, recursively, hidden files
448
+ // included, into `offered` as `<folder>/<path inside>` in UTF-16 code unit
449
+ // order of that path; a link or anything that is not a regular file, and a
450
+ // file or folder that cannot be read, into `skipped`. Returns the one error
451
+ // line, or null. Iterative, bounded as the case walk is.
452
+ //
453
+ // The files are hashed once the walk is done, in the order they are listed, so
454
+ // which file a full `budget` turns away does not depend on how the walk
455
+ // happened to reach it.
456
+ async function walkOfferedFolder(typed, offered, skipped, seen, budget) {
457
+ let base = typed;
458
+ while (base.length > 0 && (base.endsWith("/") || base.endsWith(sep)))
459
+ base = base.slice(0, -1);
460
+ const found = [];
461
+ const queue = [[typed, "", 0]];
462
+ for (let head = 0; head < queue.length; head++) {
463
+ const [directory, rel, depth] = queue[head];
464
+ let names;
465
+ try {
466
+ names = await readdir(directory, { encoding: "buffer" });
467
+ }
468
+ catch {
469
+ if (head === 0)
470
+ return `--files ${typed} is not a readable folder`;
471
+ found.push({ key: rel.slice(0, -1), skip: { name: `${base}/${rel.slice(0, -1)}`, reason: "unreadable" } });
472
+ continue;
473
+ }
474
+ names.sort(Buffer.compare);
475
+ for (const raw of names) {
476
+ seen.n++;
477
+ if (seen.n > MAX_WALK_ENTRIES) {
478
+ return `--files ${typed} holds more than ${MAX_WALK_ENTRIES} files and folders; refusing to read it`;
479
+ }
480
+ let name;
481
+ try {
482
+ name = nameDecoder.decode(raw);
483
+ }
484
+ catch {
485
+ return `--files ${typed} holds a name that is not valid UTF-8; rename it, then run again`;
486
+ }
487
+ const key = `${rel}${name}`;
488
+ const label = `${base}/${key}`;
489
+ const path = childPath(directory, name);
490
+ let st;
491
+ try {
492
+ st = await lstat(path);
493
+ }
494
+ catch {
495
+ found.push({ key, skip: { name: label, reason: "unreadable" } });
496
+ continue;
497
+ }
498
+ if (st.isDirectory()) {
499
+ if (depth + 1 > MAX_WALK_DEPTH) {
500
+ return `--files ${typed} is nested more than ${MAX_WALK_DEPTH} folders deep; refusing to read it`;
501
+ }
502
+ queue.push([path, `${key}/`, depth + 1]);
503
+ }
504
+ else if (st.isSymbolicLink()) {
505
+ found.push({ key, skip: { name: label, reason: "symbolic_link" } });
506
+ }
507
+ else if (!st.isFile()) {
508
+ found.push({ key, skip: { name: label, reason: "not_regular" } });
509
+ }
510
+ else {
511
+ found.push({ key, path });
512
+ }
513
+ }
514
+ }
515
+ found.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
516
+ for (const f of found) {
517
+ if (f.skip !== undefined) {
518
+ skipped.push(f.skip);
519
+ continue;
520
+ }
521
+ const label = `${base}/${f.key}`;
522
+ const digest = await hashFile(f.path, false, budget);
523
+ if (digest === TOO_LARGE)
524
+ skipped.push({ name: label, reason: "too_large" });
525
+ else if (digest === null)
526
+ skipped.push({ name: label, reason: "unreadable" });
527
+ else
528
+ offered.push({ name: label, sha256: digest });
529
+ }
530
+ return null;
531
+ }
532
+ // Each `--files` path as `{ name, sha256 }`, or the one error line.
533
+ //
534
+ // A path that is a regular file is one file, its name exactly as typed. A path
535
+ // that is a folder stands for the regular files inside it, recursively and
536
+ // hidden files included, each named `<folder as typed>/<path inside>` (A40,
537
+ // proof#214). The rest is returned as `skipped`, named, never dropped. A path
538
+ // that is neither a readable file nor a readable folder is the one error line,
539
+ // which names the path and not the operating system's words, which differ by
540
+ // kernel. A path typed is followed as a shell would; a link found inside a
541
+ // folder is not.
542
+ export async function readOfferedFiles(paths, cap = MAX_FOLDER_BYTES) {
543
+ const offered = [];
544
+ const skipped = [];
545
+ const seen = { n: 0 };
546
+ const budget = { n: cap };
387
547
  for (const path of paths) {
548
+ let st;
388
549
  try {
389
- if (!(await stat(path)).isFile())
390
- return `--files ${path} is not a readable file`;
391
- out.push({ name: path, bytes: new Uint8Array(await readFile(path)) });
550
+ st = await stat(path);
392
551
  }
393
552
  catch {
394
553
  return `--files ${path} is not a readable file`;
395
554
  }
555
+ if (st.isDirectory()) {
556
+ const err = await walkOfferedFolder(path, offered, skipped, seen, budget);
557
+ if (err !== null)
558
+ return err;
559
+ continue;
560
+ }
561
+ const digest = st.isFile() ? await hashFile(path, true) : null;
562
+ if (digest === null)
563
+ return `--files ${path} is not a readable file`;
564
+ offered.push({ name: path, sha256: digest });
396
565
  }
397
- return out;
566
+ return { offered, skipped };
398
567
  }
399
568
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
400
569
  //
@@ -657,7 +826,7 @@ async function loadLogs(logsPath, io) {
657
826
  isDirectory = (await stat(logsPath)).isDirectory();
658
827
  }
659
828
  catch (e) {
660
- io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
829
+ io.err(`cannot read --logs ${logsPath}`);
661
830
  return undefined;
662
831
  }
663
832
  return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
@@ -668,8 +837,8 @@ async function loadLogsMap(logsPath, io) {
668
837
  try {
669
838
  logsRaw = await readFile(logsPath, "utf8");
670
839
  }
671
- catch (e) {
672
- io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
840
+ catch {
841
+ io.err(`cannot read --logs ${logsPath}`);
673
842
  return undefined;
674
843
  }
675
844
  let logsParsed;
@@ -683,7 +852,8 @@ async function loadLogsMap(logsPath, io) {
683
852
  io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
684
853
  return undefined;
685
854
  }
686
- io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
855
+ // The parser's own words differ by kernel, so the sentence is ours.
856
+ io.err(`--logs ${logsPath} is not valid JSON`);
687
857
  return undefined;
688
858
  }
689
859
  if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
@@ -693,94 +863,198 @@ async function loadLogsMap(logsPath, io) {
693
863
  }
694
864
  const content = new Map();
695
865
  const suppliedIds = [];
866
+ const notStrings = [];
696
867
  for (const [k, v] of Object.entries(logsParsed)) {
697
- if (typeof v !== "string")
868
+ if (typeof v !== "string") {
869
+ notStrings.push(k);
698
870
  continue;
871
+ }
699
872
  content.set(k, decodeLogValue(v));
700
873
  suppliedIds.push(k);
701
874
  }
702
- return { content, suppliedIds, notes: [] };
875
+ // Named, every name, in the one sentence the Python command writes (A42): a
876
+ // value that is not a string is never coerced and never dropped unnamed.
877
+ const notes = notStrings.length === 0
878
+ ? []
879
+ : [
880
+ `--logs ${logsPath}: skipped ${count(notStrings.length, "entry", "entries")} whose ` +
881
+ `value is not a string (${quoted(notStrings.sort())})`,
882
+ ];
883
+ return { content, suppliedIds, notes };
703
884
  }
704
- // The directory form. Flat, one file per event, `<event_id>.json`.
885
+ // The directory form, read by the one rule both kernels apply
886
+ // (evidence-bundle.md §5.1.4, A42). The twin is `_load_logs_directory` in
887
+ // `src/sanning_proof/cli.py`, and `cross-kernel/logs_folder_cli_leg.py` holds
888
+ // the two commands to the same stdout, stderr and exit code.
705
889
  //
706
- // The decisions this pins, none of which is a verification failure — an absent
707
- // or unusable disclosure is UNDISCLOSED, and absence is not evidence of
708
- // tampering (evidence-bundle.md §5.1):
890
+ // A regular file named `<event_id>.json` is that record's disclosed bytes, and
891
+ // nothing else in the folder is read. None of the following is a verification
892
+ // failure, because an absent or unusable disclosure is UNDISCLOSED and absence
893
+ // is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
894
+ // is NAMED in a note, every name, so no file is dropped without being named:
709
895
  //
710
896
  // - a file whose name is not a known event id → loaded, binds to nothing,
711
- // named in a post-verify note (silence is how a typo'd filename becomes a
712
- // mysteriously undisclosed event)
897
+ // named in a post-verify note
713
898
  // - an event with no corresponding file → undetermined (`~`), noted
714
899
  // - an empty directory → valid; discloses nothing,
715
900
  // noted, exit code unchanged
716
- // - a subdirectory → skipped, never recursed,
717
- // noted (this is the "you pointed at the pack root, not its logs/" case)
718
- // - anything not named `<event_id>.json`, and anything that is not a regular
719
- // file → skipped, noted
901
+ // - a subdirectory → never entered, noted (the
902
+ // "you pointed at the pack root, not its logs/" case)
903
+ // - a symbolic link → never followed, whatever it
904
+ // points at, noted; `case` never follows one under a pack's `logs/` and
905
+ // `--files` never follows one in a folder (A40), so a link named
906
+ // `<event_id>.json` discloses nothing here either
907
+ // - anything that is not a regular file → never opened, noted
908
+ // - anything else not named `<event_id>.json` → never read, noted
720
909
  //
721
- // The one hard stop is a file we SELECTED but cannot read: we were pointed at
722
- // it, so a permissions error must not read as minimal disclosure. That exits 2.
723
- async function loadLogsDirectory(dirPath, io) {
724
- let names;
910
+ // The Python command read every regular file by its STEM until 0.17.0, so a
911
+ // forged `<id>.txt` beside a genuine `<id>.json` failed the pack there and was
912
+ // never read here, and the forged-`.json` direction read clean there and
913
+ // failed here: whoever assembled the folder chose the verdict.
914
+ //
915
+ // The hard stops (exit 2): a `<event_id>.json` file that cannot be read (we were
916
+ // pointed at it, so a permissions error must not read as minimal disclosure),
917
+ // more than MAX_WALK_ENTRIES names, a name that is not valid UTF-8 (escaping it
918
+ // would let two names read as one), and `<event_id>.json` files holding more
919
+ // than MAX_LOGS_BYTES in all.
920
+ export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
921
+ export class LogsTooLarge extends Error {
922
+ }
923
+ // A `<event_id>.json` file's bytes. Opened without following a link and without
924
+ // blocking, then checked again, so a file swapped for a link or a pipe after the
925
+ // folder was listed is refused rather than followed or waited on; and read no
926
+ // further than `budget` allows. Two checks, and each is reachable without the
927
+ // other: the size `fstat` reports refuses a sparse file before a byte is read,
928
+ // and the count of bytes read refuses a file that reports less than it holds
929
+ // (a file in /proc reports 0) or grows while it is read. `opener` is for the
930
+ // tests, which must reach the second check with the first one passing.
931
+ export async function readLogFile(path, budget, opener = open) {
932
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
933
+ const handle = await opener(path, flags);
725
934
  try {
726
- names = await readdir(dirPath);
935
+ const st = await handle.stat();
936
+ if (!st.isFile())
937
+ throw new Error("not a regular file");
938
+ if (st.size > budget.n)
939
+ throw new LogsTooLarge();
940
+ const chunks = [];
941
+ let total = 0;
942
+ for (;;) {
943
+ const buf = Buffer.allocUnsafe(HASH_CHUNK);
944
+ const { bytesRead } = await handle.read(buf, 0, HASH_CHUNK, null);
945
+ if (bytesRead === 0)
946
+ break;
947
+ total += bytesRead;
948
+ if (total > budget.n)
949
+ throw new LogsTooLarge();
950
+ chunks.push(buf.subarray(0, bytesRead));
951
+ }
952
+ budget.n -= total;
953
+ // Copied into one array the verifier owns, never a window onto a pooled block.
954
+ return new Uint8Array(Buffer.concat(chunks, total));
727
955
  }
728
- catch (e) {
729
- io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
956
+ finally {
957
+ await handle.close();
958
+ }
959
+ }
960
+ // `limits` is for the tests, which cannot write a gigabyte or fifty thousand
961
+ // names per case; the command always passes the defaults.
962
+ export async function loadLogsDirectory(dirPath, io, limits = { bytes: MAX_LOGS_BYTES, entries: MAX_WALK_ENTRIES }) {
963
+ let raws;
964
+ try {
965
+ raws = await readdir(dirPath, { encoding: "buffer" });
966
+ }
967
+ catch {
968
+ io.err(`cannot read --logs directory ${dirPath}`);
969
+ return undefined;
970
+ }
971
+ if (raws.length > limits.entries) {
972
+ io.err(`--logs ${dirPath} holds more than ${limits.entries} entries; refusing to read it`);
730
973
  return undefined;
731
974
  }
975
+ const names = [];
976
+ for (const raw of raws) {
977
+ try {
978
+ names.push(nameDecoder.decode(raw));
979
+ }
980
+ catch {
981
+ io.err(`--logs ${dirPath} holds a name that is not valid UTF-8; rename it, then run verify again`);
982
+ return undefined;
983
+ }
984
+ }
732
985
  const content = new Map();
733
986
  const suppliedIds = [];
734
- const skippedDirs = [];
735
- const skippedNames = [];
736
- for (const name of [...names].sort()) {
737
- const p = join(dirPath, name);
738
- let isDirectory;
739
- let isFile;
987
+ const dirs = [];
988
+ const links = [];
989
+ const notRegular = [];
990
+ const notNamed = [];
991
+ const budget = { n: limits.bytes };
992
+ for (const name of names.sort()) {
993
+ const p = childPath(dirPath, name);
994
+ let s;
740
995
  try {
741
- // `stat`, not the dirent kind: a symlinked log file is a log file, and a
742
- // symlink to a directory is a directory.
743
- const s = await stat(p);
744
- isDirectory = s.isDirectory();
745
- isFile = s.isFile();
996
+ // `lstat`, never `stat`: a link in the folder is never followed.
997
+ s = await lstat(p);
746
998
  }
747
- catch (e) {
748
- io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
999
+ catch {
1000
+ io.err(`cannot read --logs entry ${p}`);
749
1001
  return undefined;
750
1002
  }
751
- if (isDirectory) {
752
- skippedDirs.push(name);
1003
+ if (s.isSymbolicLink()) {
1004
+ links.push(name);
1005
+ continue;
1006
+ }
1007
+ if (s.isDirectory()) {
1008
+ dirs.push(name);
1009
+ continue;
1010
+ }
1011
+ if (!s.isFile()) {
1012
+ notRegular.push(name);
753
1013
  continue;
754
1014
  }
755
- // Not a regular file (device, fifo, socket), or not named
756
- // `<event_id>.json` — including a file named exactly `.json`, whose event
757
- // id would be the empty string.
758
- if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
759
- skippedNames.push(name);
1015
+ // Not named `<event_id>.json`, including a file named exactly `.json`,
1016
+ // whose event id would be the empty string.
1017
+ if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
1018
+ notNamed.push(name);
760
1019
  continue;
761
1020
  }
762
1021
  const eventId = name.slice(0, -LOG_SUFFIX.length);
763
1022
  try {
764
- // No encoding argument: bytes, exactly as they sit on disk. Copied out of
765
- // Buffer's shared pool so the array handed to the verifier owns its own
766
- // memory rather than a window onto a reused block.
767
- content.set(eventId, new Uint8Array(await readFile(p)));
1023
+ // Bytes, exactly as they sit on disk.
1024
+ content.set(eventId, await readLogFile(p, budget));
768
1025
  }
769
1026
  catch (e) {
770
- io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
1027
+ if (e instanceof LogsTooLarge) {
1028
+ io.err(`--logs ${dirPath}: the <event_id>${LOG_SUFFIX} files hold more than ${limits.bytes} ` +
1029
+ `bytes in all, at ${p}; refusing to read them`);
1030
+ }
1031
+ else {
1032
+ io.err(`cannot read --logs file ${p}`);
1033
+ }
771
1034
  return undefined;
772
1035
  }
773
1036
  suppliedIds.push(eventId);
774
1037
  }
775
1038
  const notes = [];
776
- if (skippedDirs.length > 0) {
777
- notes.push(`--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
778
- `(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
1039
+ if (dirs.length > 0) {
1040
+ notes.push(`--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
1041
+ `(${quoted(dirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
779
1042
  `and does not recurse`);
780
1043
  }
781
- if (skippedNames.length > 0) {
782
- notes.push(`--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
783
- `not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`);
1044
+ if (links.length > 0) {
1045
+ notes.push(`--logs ${dirPath}: skipped ${count(links.length, "symbolic link", "symbolic links")}, ` +
1046
+ `never followed (${quoted(links)})`);
1047
+ }
1048
+ if (notRegular.length > 0) {
1049
+ notes.push(`--logs ${dirPath}: skipped ` +
1050
+ (notRegular.length === 1
1051
+ ? "1 entry that is not a regular file"
1052
+ : `${notRegular.length} entries that are not regular files`) +
1053
+ ` (${quoted(notRegular)})`);
1054
+ }
1055
+ if (notNamed.length > 0) {
1056
+ notes.push(`--logs ${dirPath}: skipped ${count(notNamed.length, "entry", "entries")} ` +
1057
+ `not named <event_id>${LOG_SUFFIX} (${quoted(notNamed)})`);
784
1058
  }
785
1059
  if (suppliedIds.length === 0) {
786
1060
  notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
@@ -801,10 +1075,10 @@ async function loadLogsDirectory(dirPath, io) {
801
1075
  // of a disclosure is not evidence of tampering.
802
1076
  function reportLogsCoverage(logs, result, io) {
803
1077
  const eventIds = new Set(result.events.map((e) => e.eventId));
804
- const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
1078
+ const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
805
1079
  if (unmatched.length > 0) {
806
1080
  io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
807
- `in the bundle, ignored: ${sample(unmatched)}`);
1081
+ `in the bundle, ignored: ${quoted(unmatched)}`);
808
1082
  }
809
1083
  const undetermined = result.events.filter((e) => e.contentOk === null);
810
1084
  if (undetermined.length > 0) {
@@ -820,6 +1094,11 @@ function errMsg(e) {
820
1094
  function count(n, one, many) {
821
1095
  return `${n} ${n === 1 ? one : many}`;
822
1096
  }
1097
+ // Every name, quoted. Every one, not the first few: a file left out of a note
1098
+ // is a file dropped without being named.
1099
+ function quoted(names) {
1100
+ return names.map((n) => JSON.stringify(n)).join(", ");
1101
+ }
823
1102
  // First few names, quoted, with a tail count — enough to recognise the mistake
824
1103
  // without pasting a whole directory into the terminal.
825
1104
  function sample(names, max = 3) {
@@ -861,7 +1140,7 @@ function printUsage(io) {
861
1140
  io.out(" Verify a bundle");
862
1141
  io.out(" export <source.json> --attestations <f> --key <f> [--out <f>] [--json]");
863
1142
  io.out(" Create an attested export");
864
- io.out(" case <folder>... [--gateways <urls>] [--files <path>...] [--json]");
1143
+ io.out(" case <folder or .zip>... [--gateways <urls>] [--files <path>...] [--json]");
865
1144
  io.out(" Read several packs as one case");
866
1145
  io.out(" (`case --help` for its exit codes)");
867
1146
  io.out("");
@@ -881,7 +1160,15 @@ function printUsage(io) {
881
1160
  io.out(" to; the report lists the files the pack vouches for,");
882
1161
  io.out(" the files that match no record, and the commitments");
883
1162
  io.out(" no file was offered for. Names and folders are never");
884
- io.out(" compared, and the verdict never moves.");
1163
+ io.out(" compared, and the verdict never moves. A folder");
1164
+ io.out(" stands for the regular files inside it, found");
1165
+ io.out(" recursively, hidden files included, so one folder");
1166
+ io.out(" covers a whole download in any shell (a wildcard like");
1167
+ io.out(" evidence/* is not expanded here). A symbolic link");
1168
+ io.out(" inside a folder is never followed and is listed as");
1169
+ io.out(" skipped, as is anything that is not a regular file or");
1170
+ io.out(" cannot be read: no file is dropped without being");
1171
+ io.out(" named. Two files with one hash are both listed.");
885
1172
  io.out("");
886
1173
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
887
1174
  io.out(" content_hash, or message_hash for a hand-off record.");
@@ -892,6 +1179,9 @@ function printUsage(io) {
892
1179
  io.out(" raw bytes ARE the content, nothing is decoded.");
893
1180
  io.out(" This is a materialized pack's logs/ directory:");
894
1181
  io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
1182
+ io.out(" Nothing else in it is read: a link is never");
1183
+ io.out(" followed, a subdirectory never entered, and any");
1184
+ io.out(" other name is not a disclosure. Each is named.");
895
1185
  io.out("");
896
1186
  io.out(" a JSON FILE mapping event_id → bytes (an");
897
1187
  io.out(" even-length hex value, in either case, ⇒ hex,");
@@ -1241,11 +1531,12 @@ function mapGradeToExit(grade) {
1241
1531
  //
1242
1532
  // ── Directories only ──
1243
1533
  //
1244
- // `case` reads no archive. A `.zip` argument exits 2 with one line that says to
1245
- // unzip it first and pass the folder, and a `.zip` inside a directory is a file
1246
- // like any other: outside every pack, and never opened. Reading archives in the
1247
- // kernel brought zip bombs, two zip libraries that disagree, and entry names
1248
- // that are not file names, and `unzip` already does that job.
1534
+ // A `.zip` named on the command line is read as the folder `unzip` would make,
1535
+ // by the reader in `zip-reader.ts` and nothing else, one reader restated in both
1536
+ // kernels because two zip libraries disagree on the edges. A `.zip` INSIDE a
1537
+ // folder is a file like any other: outside every pack, hashed like any other
1538
+ // file and never unzipped, because a reader does not open a zip inside a zip
1539
+ // (§5.6.1).
1249
1540
  //
1250
1541
  // ── The walk ──
1251
1542
  //
@@ -1274,7 +1565,8 @@ function mapGradeToExit(grade) {
1274
1565
  // at the directory that holds it.
1275
1566
  // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1276
1567
  // if it is that root's `bundle.json` or under that root's `logs/`.
1277
- // 3. A file under no pack is OUTSIDE: listed, and never opened.
1568
+ // 3. A file under no pack is OUTSIDE: listed, never read as part of a pack, and
1569
+ // hashed to be matched against the records (§5.7.6, A41).
1278
1570
  // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1279
1571
  // `Array.prototype.sort`).
1280
1572
  // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
@@ -1287,8 +1579,7 @@ function mapGradeToExit(grade) {
1287
1579
  // holding them, each under its own name, and two that would land at one name
1288
1580
  // refuse.
1289
1581
  //
1290
- // Known differences from the page: the page opens a dropped `.zip` and this
1291
- // command does not; the page drops a leading byte-order mark from
1582
+ // Known differences from the page: the page drops a leading byte-order mark from
1292
1583
  // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1293
1584
  // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1294
1585
  // where this command applies the walk's rules above; and the page keys
@@ -1323,6 +1614,14 @@ export class CaseInputError extends Error {
1323
1614
  this.hint = hint;
1324
1615
  }
1325
1616
  }
1617
+ /**
1618
+ * A file `case` reads whole that does not fit in what is left of the run's
1619
+ * 1 GiB. Always a `CaseInputError`; the type lets a caller that can carry on
1620
+ * without the file (`contents.json`, `witness-cache.json`) tell this refusal
1621
+ * from an unreadable file.
1622
+ */
1623
+ export class WholeReadRefused extends CaseInputError {
1624
+ }
1326
1625
  /**
1327
1626
  * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1328
1627
  * the one refusal that is not a parse error; `detail` is the parser's own words,
@@ -1441,20 +1740,26 @@ export async function splitCase(tree, read) {
1441
1740
  * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
1442
1741
  *
1443
1742
  * The split rule is not changed for it: the file stays outside every pack,
1444
- * listed as such, and is opened here and nowhere else. It is read only where it
1445
- * is a regular file the split left outside every pack, so a file under another
1743
+ * listed as such, and is read here, for its contents, and hashed with the other
1744
+ * 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
1446
1745
  * pack's `logs/` is that pack's, and a link is never followed. A file that
1447
- * cannot be read is no cache, never a refusal of the drop.
1746
+ * cannot be read is no cache, never a refusal of the drop. One the run's
1747
+ * whole-read total has no room for is pushed onto `refused` when it is given,
1748
+ * so the output says so.
1448
1749
  */
1449
- async function witnessCacheBytes(tree, split, pack) {
1750
+ export async function witnessCacheBytes(tree, split, pack, budget, refused) {
1450
1751
  const path = pack.bundlePath.slice(0, -"bundle.json".length) + WITNESS_CACHE_NAME;
1451
1752
  const entry = tree.get(path);
1452
1753
  if (entry === undefined || !entry.regular || !split.outside.includes(path))
1453
1754
  return null;
1454
1755
  try {
1455
- return await readFileEntry(entry, path);
1756
+ return await readFileEntry(entry, path, budget);
1456
1757
  }
1457
1758
  catch (e) {
1759
+ if (e instanceof WholeReadRefused) {
1760
+ refused?.push(path);
1761
+ return null;
1762
+ }
1458
1763
  if (e instanceof CaseInputError)
1459
1764
  return null;
1460
1765
  throw e;
@@ -1475,12 +1780,34 @@ export function loadBundle(pack) {
1475
1780
  throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1476
1781
  }
1477
1782
  }
1783
+ /**
1784
+ * The refusal for a file `case` would hold whole that does not fit in what is
1785
+ * left of the 1 GiB a run reads whole. The same words in both kernels.
1786
+ */
1787
+ export const wholeReadRefusal = (key) => `${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
1788
+ /**
1789
+ * Why `contents.json` reads as an unreadable index when the run's whole-read
1790
+ * total has no room for it, and when it cannot be read. The same words in both
1791
+ * kernels.
1792
+ */
1793
+ export const INDEX_OVER_BUDGET = "it would take case past the 1 GiB it reads whole in one run, so it was not read";
1794
+ export const INDEX_NOT_READ = "it could not be read";
1795
+ /** The note for a `witness-cache.json` the whole-read total had no room for. */
1796
+ export const witnessCacheRefusedNote = (path) => `${path} was not read as a witness cache: it would take case past the 1 GiB it reads whole in one run`;
1797
+ /** The refusal for an archive file larger than MAX_ZIP_ARCHIVE_BYTES. */
1798
+ export const zipFileRefusal = (arg) => `${arg} is larger than 1.25 GiB on disk; refusing to read it`;
1478
1799
  /**
1479
1800
  * A regular file's bytes. Opened without following a link and without
1480
1801
  * blocking, then checked again, so a file swapped for a link or a pipe after
1481
1802
  * the walk listed it is refused rather than followed or waited on.
1803
+ *
1804
+ * `budget` is the bytes a run may still hold whole from files on disk
1805
+ * (MAX_FOLDER_BYTES at the start). A file larger than what is left, or that
1806
+ * grows past it while it is read, is refused before it is held, so a file of a
1807
+ * terabyte under a pack's own names cannot exhaust memory. A `.zip` entry is
1808
+ * already in memory, inside the archive's own cap, and is not charged.
1482
1809
  */
1483
- export async function readFileEntry(entry, key) {
1810
+ export async function readFileEntry(entry, key, budget) {
1484
1811
  if (entry.data !== undefined && entry.data !== null)
1485
1812
  return entry.data;
1486
1813
  const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
@@ -1492,11 +1819,51 @@ export async function readFileEntry(entry, key) {
1492
1819
  throw new CaseInputError(`cannot read ${key}`);
1493
1820
  }
1494
1821
  try {
1495
- if (!(await handle.stat()).isFile())
1822
+ const st = await handle.stat();
1823
+ if (!st.isFile())
1496
1824
  throw new CaseInputError(`cannot read ${key}`);
1497
- return new Uint8Array(await handle.readFile());
1825
+ if (budget === undefined)
1826
+ return new Uint8Array(await handle.readFile());
1827
+ if (st.size > budget.n)
1828
+ throw new WholeReadRefused(wholeReadRefusal(key));
1829
+ // Once, at what the file says it holds and one byte more to see it grow:
1830
+ // the bytes are read into the buffer the result is a view of, never copied.
1831
+ const want = Math.min(st.size, budget.n) + 1;
1832
+ const buf = Buffer.allocUnsafeSlow(want);
1833
+ let total = 0;
1834
+ while (total < want) {
1835
+ const { bytesRead } = await handle.read(buf, total, want - total, null);
1836
+ if (bytesRead === 0)
1837
+ break;
1838
+ total += bytesRead;
1839
+ }
1840
+ if (total > st.size) {
1841
+ // The file grew since it was measured: take the rest in pieces, never
1842
+ // past the budget.
1843
+ const rest = [];
1844
+ let all = total;
1845
+ while (all <= budget.n) {
1846
+ const chunk = Buffer.allocUnsafe(Math.min(HASH_CHUNK, budget.n - all + 1));
1847
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
1848
+ if (bytesRead === 0)
1849
+ break;
1850
+ all += bytesRead;
1851
+ rest.push(chunk.subarray(0, bytesRead));
1852
+ }
1853
+ if (all > budget.n)
1854
+ throw new WholeReadRefused(wholeReadRefusal(key));
1855
+ budget.n -= all;
1856
+ const joined = Buffer.concat([buf.subarray(0, total), ...rest], all);
1857
+ return new Uint8Array(joined.buffer, joined.byteOffset, all);
1858
+ }
1859
+ if (total > budget.n)
1860
+ throw new WholeReadRefused(wholeReadRefusal(key));
1861
+ budget.n -= total;
1862
+ return new Uint8Array(buf.buffer, buf.byteOffset, total);
1498
1863
  }
1499
- catch {
1864
+ catch (e) {
1865
+ if (e instanceof CaseInputError)
1866
+ throw e;
1500
1867
  throw new CaseInputError(`cannot read ${key}`);
1501
1868
  }
1502
1869
  finally {
@@ -1532,7 +1899,7 @@ async function walk(root, prefix, arg, tree, seen) {
1532
1899
  catch {
1533
1900
  throw new CaseInputError(`${rel.slice(0, -1)} holds a name that is not valid UTF-8; rename it, then run case again`);
1534
1901
  }
1535
- const path = join(directory, name);
1902
+ const path = childPath(directory, name);
1536
1903
  const key = `${rel}${name}`;
1537
1904
  let s;
1538
1905
  try {
@@ -1548,7 +1915,7 @@ async function walk(root, prefix, arg, tree, seen) {
1548
1915
  queue.push([path, `${key}/`, depth + 1]);
1549
1916
  }
1550
1917
  else {
1551
- tree.set(key, { fsPath: path, regular: s.isFile() });
1918
+ tree.set(key, { fsPath: path, regular: s.isFile(), link: s.isSymbolicLink() });
1552
1919
  }
1553
1920
  }
1554
1921
  }
@@ -1626,12 +1993,41 @@ export async function treeFromArguments(paths) {
1626
1993
  */
1627
1994
  async function unzip(arg, prefix, tree, seen) {
1628
1995
  let data;
1996
+ let handle;
1629
1997
  try {
1630
- data = new Uint8Array(await readFile(arg));
1998
+ handle = await open(arg, fsConstants.O_RDONLY);
1631
1999
  }
1632
2000
  catch {
1633
2001
  throw new CaseInputError(`cannot read ${arg}`);
1634
2002
  }
2003
+ try {
2004
+ const size = (await handle.stat()).size;
2005
+ if (size > MAX_ZIP_ARCHIVE_BYTES)
2006
+ throw new CaseInputError(zipFileRefusal(arg));
2007
+ // One more byte than the size on disk, so a file that grew is noticed; the
2008
+ // archive is read straight into this buffer, once, and never copied.
2009
+ const buf = Buffer.allocUnsafe(size + 1);
2010
+ let have = 0;
2011
+ for (;;) {
2012
+ const { bytesRead } = await handle.read(buf, have, buf.length - have, null);
2013
+ if (bytesRead === 0)
2014
+ break;
2015
+ have += bytesRead;
2016
+ if (have === buf.length)
2017
+ break;
2018
+ }
2019
+ if (have > size)
2020
+ throw new CaseInputError(`cannot read ${arg}`);
2021
+ data = new Uint8Array(buf.buffer, buf.byteOffset, have);
2022
+ }
2023
+ catch (e) {
2024
+ if (e instanceof CaseInputError)
2025
+ throw e;
2026
+ throw new CaseInputError(`cannot read ${arg}`);
2027
+ }
2028
+ finally {
2029
+ await handle.close();
2030
+ }
1635
2031
  let entries;
1636
2032
  try {
1637
2033
  entries = readZip(data);
@@ -1650,6 +2046,8 @@ async function unzip(arg, prefix, tree, seen) {
1650
2046
  fsPath: "",
1651
2047
  regular: entry.refused === null,
1652
2048
  data: entry.data,
2049
+ link: entry.refused === "a symbolic link",
2050
+ ...(entry.refused !== null && entry.refused !== "a symbolic link" ? { refused: entry.refused } : {}),
1653
2051
  });
1654
2052
  }
1655
2053
  }
@@ -1681,7 +2079,8 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1681
2079
  verify page's splitting rule: every bundle.json is a pack, a file
1682
2080
  belongs to the deepest pack whose bundle.json or logs/ it sits
1683
2081
  in, and every other file is listed as outside every pack and
1684
- never opened. A .zip inside a folder is a file like any other. A
2082
+ matched by hash (see below), never read as part of a pack. A
2083
+ .zip inside a folder is a file like any other. A
1685
2084
  symbolic link is never followed: it is listed as outside and not
1686
2085
  read, even under a pack's logs/. Several arguments are read as
1687
2086
  one folder holding them, each under its own name. A folder
@@ -1700,12 +2099,25 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
1700
2099
  the witness line. The lookup never changes the verdict word.
1701
2100
  --files files handed in beside the packs, every path up to the next
1702
2101
  option, matched by SHA-256 alone against what every pack's
1703
- signed records commit to. A file under a pack's logs/ is
1704
- offered only when it is named here. The verdict never moves.
2102
+ signed records commit to. A folder stands for the regular files
2103
+ inside it, found recursively, hidden files included; a link
2104
+ inside it is listed as skipped and never followed. A file under
2105
+ a pack's logs/ is offered only when it is named here. The
2106
+ verdict never moves.
1705
2107
  --json the full reading as JSON: the verdict, every exception and note
1706
- with its rendered line, every record, every contribution, the
1707
- packs, the files outside them, the case file index comparison,
1708
- the witness line and the on-chain half.
2108
+ with its rendered line, every record of every pack with its
2109
+ status (read clean, read flagged, or not read and why), every
2110
+ contribution, the packs, each with its index, the files outside
2111
+ them, the case file index comparison, the witness line and the
2112
+ on-chain half.
2113
+
2114
+ Files outside every pack are matched too, always, so
2115
+ sanning-proof case audit-file.zip checks a whole download in one command: each
2116
+ is hashed and listed as vouched for by a record in a named pack, or as matching
2117
+ no record, by SHA-256 alone, as --files does. Names and folders are never
2118
+ compared, and neither the verdict nor the exit code moves. A link, a pipe or an
2119
+ entry or file that cannot be read is listed as skipped. Nothing is printed for
2120
+ this when no file sits outside every pack and --files is not given.
1709
2121
 
1710
2122
  Each pack's bundle.json is read as verify reads it, so the two commands
1711
2123
  classify one bundle the same way.
@@ -1786,7 +2198,13 @@ function caseReport(data) {
1786
2198
  for (const p of data.packs) {
1787
2199
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
1788
2200
  const grade = p.grade !== null ? titleOf(p.grade) : "not graded";
1789
- let line = ` ${p.ref} signed by ${key}; ${grade}; ${p.log_files} record file(s) under logs/`;
2201
+ // Each record's status, counted per pack (A43): the JSON lists every one.
2202
+ const mine = data.records.filter((r) => r.pack_index === p.index);
2203
+ const count = (status) => mine.filter((r) => r.status === status).length;
2204
+ const records = `records: ${count("read_clean")} read clean, ${count("read_flagged")} read flagged, ` +
2205
+ `${count("not_read")} not read`;
2206
+ let line = ` ${p.ref} signed by ${key}; ${grade}; ${records}; ` +
2207
+ `${p.log_files} record file(s) under logs/`;
1790
2208
  if (p.unaccounted.length > 0) {
1791
2209
  line +=
1792
2210
  `; ${p.unaccounted.length} file(s) under logs/ that cannot be a ` +
@@ -1802,6 +2220,9 @@ function caseReport(data) {
1802
2220
  lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
1803
2221
  }
1804
2222
  }
2223
+ for (const path of data.witness_cache_not_read ?? []) {
2224
+ lines.push(`note ${witnessCacheRefusedNote(path)}`);
2225
+ }
1805
2226
  for (const c of data.case_file) {
1806
2227
  if (c.index === "absent")
1807
2228
  continue;
@@ -1828,31 +2249,71 @@ function caseReport(data) {
1828
2249
  /**
1829
2250
  * The index comparison, once per argument (5.6.3): each folder or case file
1830
2251
  * whose root holds `contents.json`. Every file under `packs/` and `report/` is
1831
- * hashed for it, as bytes, and nothing is parsed but the index. It is never an
1832
- * input to the reading.
2252
+ * hashed for it, in pieces, as bytes, and nothing is parsed but the index. The
2253
+ * files share one total of 1 GiB across the run, charged in path order; a file
2254
+ * larger than what is left is reported as too large to hash, never as a match
2255
+ * and never as absent. It is never an input to the reading.
1833
2256
  */
1834
- async function caseFileReadings(tree) {
2257
+ export async function caseFileReadings(tree, whole, cap = MAX_FOLDER_BYTES) {
1835
2258
  const out = [];
2259
+ const budget = { n: cap };
1836
2260
  const roots = [...new Set([...tree.keys()].map((k) => k.split("/", 1)[0]))].sort();
1837
2261
  const keys = [...tree.keys()].sort();
1838
2262
  for (const root of roots) {
1839
2263
  const prefix = `${root}/`;
1840
2264
  const indexKey = `${prefix}${CASE_FILE_INDEX_NAME}`;
1841
2265
  const indexEntry = tree.get(indexKey);
1842
- const indexBytes = indexEntry !== undefined && indexEntry.regular ? await readFileEntry(indexEntry, indexKey) : null;
2266
+ let indexBytes = null;
2267
+ let refused = null;
2268
+ if (indexEntry !== undefined && indexEntry.regular) {
2269
+ try {
2270
+ indexBytes = await readFileEntry(indexEntry, indexKey, whole);
2271
+ }
2272
+ catch (e) {
2273
+ if (e instanceof WholeReadRefused)
2274
+ refused = INDEX_OVER_BUDGET;
2275
+ else if (e instanceof CaseInputError)
2276
+ refused = INDEX_NOT_READ;
2277
+ else
2278
+ throw e;
2279
+ }
2280
+ }
1843
2281
  const files = [];
1844
- if (indexBytes !== null) {
1845
- for (const key of keys) {
2282
+ const listed = indexBytes !== null ? caseFileListedPaths(indexBytes) : null;
2283
+ if (listed !== null) {
2284
+ const candidates = keys
2285
+ .filter((key) => key.startsWith(prefix) && tree.get(key).regular)
2286
+ .map((key) => [key, key.slice(prefix.length)])
2287
+ .filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
2288
+ // The files the index lists are charged first, then the rest, each group
2289
+ // in UTF-16 order, so a file it does not list cannot use up the total and
2290
+ // turn a real mismatch into `too_large`.
2291
+ for (const [key, rel] of [
2292
+ ...candidates.filter(([, rel]) => listed.has(rel)),
2293
+ ...candidates.filter(([, rel]) => !listed.has(rel)),
2294
+ ]) {
1846
2295
  const entry = tree.get(key);
1847
- const rel = key.slice(prefix.length);
1848
- if (key.startsWith(prefix) &&
1849
- entry.regular &&
1850
- (rel.startsWith("packs/") || rel.startsWith("report/"))) {
1851
- files.push({ path: rel, sha256: await sha256Hex(await readFileEntry(entry, key)) });
2296
+ let digest;
2297
+ if (entry.data !== undefined && entry.data !== null) {
2298
+ if (entry.data.length > budget.n)
2299
+ digest = TOO_LARGE;
2300
+ else {
2301
+ budget.n -= entry.data.length;
2302
+ digest = await sha256Hex(entry.data);
2303
+ }
2304
+ }
2305
+ else {
2306
+ digest = await hashFile(entry.fsPath, false, budget);
1852
2307
  }
2308
+ if (digest === TOO_LARGE)
2309
+ files.push({ path: rel, too_large: true });
2310
+ else if (digest === null)
2311
+ throw new CaseInputError(`cannot read ${key}`);
2312
+ else
2313
+ files.push({ path: rel, sha256: digest });
1853
2314
  }
1854
2315
  }
1855
- const reading = readCaseFileIndex(indexBytes, files);
2316
+ const reading = refused !== null ? unreadableCaseFileIndex(refused) : readCaseFileIndex(indexBytes, files);
1856
2317
  const nil = (v) => (v === undefined ? null : v);
1857
2318
  out.push({
1858
2319
  root,
@@ -1902,6 +2363,60 @@ export async function runCase(argv, stream) {
1902
2363
  }
1903
2364
  }
1904
2365
  }
2366
+ // The files that sit outside every pack, as files offered (A41, proof#215).
2367
+ //
2368
+ // Every regular file the split left outside, in the split's order, hashed
2369
+ // exactly as a `--files` file is (a folder's file in pieces, a `.zip` entry
2370
+ // from the bytes the reader already holds) and named by its path in the tree,
2371
+ // `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
2372
+ // name each file alike. A link, a pipe, an entry the zip reader refused (with
2373
+ // its own words) and a file that cannot be read are returned as skipped, named
2374
+ // and never opened as a pack file. A pack's own files, `bundle.json` and what
2375
+ // is under `logs/`, are not outside and are not offered.
2376
+ //
2377
+ // The files share one total of 1 GiB, the cap the zip reader puts on an
2378
+ // archive, charged in that order. A file larger than what is left is skipped as
2379
+ // `too_large` and read no further, so a sparse file of a terabyte in a
2380
+ // downloaded folder cannot keep `case` from reaching its verdict.
2381
+ export async function looseFiles(tree, split, cap = MAX_FOLDER_BYTES) {
2382
+ const offered = [];
2383
+ const skipped = [];
2384
+ const budget = { n: cap };
2385
+ for (const path of split.outside) {
2386
+ const entry = tree.get(path);
2387
+ if (entry.link === true) {
2388
+ skipped.push({ name: path, reason: "symbolic_link" });
2389
+ continue;
2390
+ }
2391
+ if (entry.refused !== undefined) {
2392
+ skipped.push({ name: path, reason: "refused", detail: entry.refused });
2393
+ continue;
2394
+ }
2395
+ if (!entry.regular) {
2396
+ skipped.push({ name: path, reason: "not_regular" });
2397
+ continue;
2398
+ }
2399
+ let digest;
2400
+ if (entry.data !== undefined && entry.data !== null) {
2401
+ if (entry.data.length > budget.n)
2402
+ digest = TOO_LARGE;
2403
+ else {
2404
+ budget.n -= entry.data.length;
2405
+ digest = await sha256Hex(entry.data);
2406
+ }
2407
+ }
2408
+ else {
2409
+ digest = await hashFile(entry.fsPath, false, budget);
2410
+ }
2411
+ if (digest === TOO_LARGE)
2412
+ skipped.push({ name: path, reason: "too_large" });
2413
+ else if (digest === null)
2414
+ skipped.push({ name: path, reason: "unreadable" });
2415
+ else
2416
+ offered.push({ name: path, sha256: digest });
2417
+ }
2418
+ return { offered, skipped };
2419
+ }
1905
2420
  async function caseCommand(argv, io) {
1906
2421
  let asJson = false;
1907
2422
  let gateways = [];
@@ -1954,18 +2469,21 @@ async function caseCommand(argv, io) {
1954
2469
  return EXIT_MALFORMED;
1955
2470
  }
1956
2471
  let offered;
2472
+ let skipped = [];
1957
2473
  if (filePaths !== undefined) {
1958
2474
  const read = await readOfferedFiles(filePaths);
1959
2475
  if (typeof read === "string")
1960
2476
  return caseError(new CaseInputError(read), asJson, io);
1961
- offered = read;
2477
+ offered = read.offered;
2478
+ skipped = read.skipped;
1962
2479
  }
2480
+ const whole = { n: MAX_FOLDER_BYTES };
1963
2481
  let split;
1964
2482
  let tree;
1965
2483
  let bundles;
1966
2484
  try {
1967
2485
  tree = await treeFromArguments(paths);
1968
- split = await splitCase(tree, readFileEntry);
2486
+ split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
1969
2487
  bundles = split.packs.map((p) => [p, loadBundle(p)]);
1970
2488
  }
1971
2489
  catch (e) {
@@ -1976,7 +2494,7 @@ async function caseCommand(argv, io) {
1976
2494
  const reading = await readCase(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })));
1977
2495
  let caseFile;
1978
2496
  try {
1979
- caseFile = await caseFileReadings(tree);
2497
+ caseFile = await caseFileReadings(tree, whole);
1980
2498
  }
1981
2499
  catch (e) {
1982
2500
  if (e instanceof CaseInputError)
@@ -1984,8 +2502,9 @@ async function caseCommand(argv, io) {
1984
2502
  throw e;
1985
2503
  }
1986
2504
  const caches = [];
2505
+ const cacheRefused = [];
1987
2506
  for (const [p, bundle] of bundles) {
1988
- caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p)));
2507
+ caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
1989
2508
  }
1990
2509
  const onChain = await onChainHalf(bundles.map(([p, bundle], i) => [p.ref, bundle, caches[i]]), gateways, { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) });
1991
2510
  const witness = witnessLine(onChain, caches.flat());
@@ -2010,7 +2529,9 @@ async function caseCommand(argv, io) {
2010
2529
  exceptions: reading.exceptions.map((e) => ({
2011
2530
  kind: e.kind,
2012
2531
  pack_ref: nil(e.packRef),
2532
+ pack_index: nil(e.packIndex),
2013
2533
  event_id: nil(e.eventId),
2534
+ record_index: nil(e.recordIndex),
2014
2535
  counterparty_key: nil(e.counterpartyKey),
2015
2536
  failed_checks: e.failedChecks ?? [],
2016
2537
  count: nil(e.count),
@@ -2022,12 +2543,18 @@ async function caseCommand(argv, io) {
2022
2543
  notes: reading.notes.map((n) => ({
2023
2544
  kind: n.kind,
2024
2545
  pack_ref: n.packRef,
2546
+ pack_index: n.packIndex,
2025
2547
  count: nil(n.count),
2026
2548
  rendered: renderCaseNote(n),
2027
2549
  })),
2028
2550
  records: reading.records.map((r) => ({
2029
2551
  pack_ref: r.packRef,
2552
+ pack_index: r.packIndex,
2030
2553
  event_id: r.eventId,
2554
+ record_index: r.recordIndex,
2555
+ status: r.status,
2556
+ reason: r.reason,
2557
+ declaration_findings: [...r.declarationFindings],
2031
2558
  verdict: nil(r.verdict),
2032
2559
  role: r.role,
2033
2560
  findings: r.findings,
@@ -2036,6 +2563,7 @@ async function caseCommand(argv, io) {
2036
2563
  })),
2037
2564
  contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
2038
2565
  packs: bundles.map(([p], i) => ({
2566
+ index: reading.packs[i].index,
2039
2567
  ref: p.ref,
2040
2568
  bundle_path: p.bundlePath,
2041
2569
  log_files: p.content.size,
@@ -2049,16 +2577,29 @@ async function caseCommand(argv, io) {
2049
2577
  not_regular: split.notRegular,
2050
2578
  case_file: caseFile,
2051
2579
  on_chain: onChain,
2580
+ ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
2052
2581
  };
2053
- // Files handed in beside the packs (evidence-bundle.md 5.7, A29). A pack's
2054
- // logs/ is its side content and is never an offered file; only the paths
2055
- // after --files are. Nothing here moves `code`.
2582
+ // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
2583
+ // then the files after --files. A pack's logs/ is its side content and is
2584
+ // never an offered file; only the paths after --files are. Both are matched
2585
+ // by SHA-256 alone and nothing here moves `code`. The section is printed when
2586
+ // there is a file to say something about, so a case with nothing outside
2587
+ // every pack and no --files prints what it always did.
2588
+ const loose = await looseFiles(tree, split);
2056
2589
  let fileLines = [];
2057
2590
  const out = data;
2058
- if (offered !== undefined) {
2059
- const fileReading = await matchCaseFiles(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), offered);
2591
+ if (offered !== undefined || loose.offered.length > 0 || loose.skipped.length > 0) {
2592
+ const fileReading = {
2593
+ ...(await matchCaseFiles(bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })), [...loose.offered, ...(offered ?? [])])),
2594
+ skipped: [...loose.skipped, ...skipped],
2595
+ };
2060
2596
  out.files = fileMatchToJson(fileReading);
2061
- fileLines = renderFileMatch(fileReading);
2597
+ // No --files: one count line for the recorded files no loose file matched,
2598
+ // not a line for each of them, and none for a record the pack's own logs
2599
+ // disclose and bind. With --files, and in --json, every one is listed.
2600
+ fileLines = renderFileMatch(fileReading, offered !== undefined
2601
+ ? undefined
2602
+ : (await notAccountedFor(fileReading, new Map(bundles.map(([p]) => [p.ref, p.content])))).length);
2062
2603
  }
2063
2604
  if (asJson)
2064
2605
  outJson(io, jsonText(out));