@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/README.md +49 -4
- package/dist/agent-proof.js +50 -23
- package/dist/case-file.d.ts +24 -4
- package/dist/case-file.js +62 -5
- package/dist/case.d.ts +104 -8
- package/dist/case.js +195 -53
- package/dist/cli.d.ts +105 -2
- package/dist/cli.js +664 -123
- package/dist/compose.js +32 -0
- package/dist/crypto.d.ts +17 -0
- package/dist/crypto.js +116 -16
- package/dist/evidence.js +108 -38
- package/dist/file-match.d.ts +64 -5
- package/dist/file-match.js +113 -22
- package/dist/grading.d.ts +7 -0
- package/dist/grading.js +14 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/merkle.js +13 -7
- package/dist/verifier.js +32 -23
- package/dist/zip-reader.d.ts +7 -0
- package/dist/zip-reader.js +7 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +53 -27
- package/src/case-file.ts +61 -8
- package/src/case.ts +293 -64
- package/src/cli.ts +703 -132
- package/src/compose.ts +33 -0
- package/src/crypto.ts +125 -16
- package/src/evidence.ts +109 -43
- package/src/file-match.ts +160 -26
- package/src/grading.ts +15 -2
- package/src/index.ts +7 -0
- package/src/merkle.ts +13 -7
- package/src/verifier.ts +33 -25
- package/src/zip-reader.ts +7 -0
- package/vectors/README.md +5 -1
- package/vectors/case-file-index.json +88 -2
- package/vectors/file-match.json +135 -1
package/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,
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
672
|
-
io.err(`cannot read --logs ${logsPath}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
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
|
|
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 →
|
|
717
|
-
//
|
|
718
|
-
// -
|
|
719
|
-
//
|
|
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
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
// `
|
|
742
|
-
|
|
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
|
|
748
|
-
io.err(`cannot read --logs entry ${p}
|
|
999
|
+
catch {
|
|
1000
|
+
io.err(`cannot read --logs entry ${p}`);
|
|
749
1001
|
return undefined;
|
|
750
1002
|
}
|
|
751
|
-
if (
|
|
752
|
-
|
|
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
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
//
|
|
765
|
-
|
|
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
|
-
|
|
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 (
|
|
777
|
-
notes.push(`--logs ${dirPath}: skipped ${count(
|
|
778
|
-
`(${
|
|
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 (
|
|
782
|
-
notes.push(`--logs ${dirPath}: skipped ${count(
|
|
783
|
-
`
|
|
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: ${
|
|
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
|
-
// `
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
1822
|
+
const st = await handle.stat();
|
|
1823
|
+
if (!st.isFile())
|
|
1496
1824
|
throw new CaseInputError(`cannot read ${key}`);
|
|
1497
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1704
|
-
|
|
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
|
|
1707
|
-
|
|
1708
|
-
the
|
|
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
|
-
|
|
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.
|
|
1832
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
1848
|
-
if (
|
|
1849
|
-
entry.
|
|
1850
|
-
|
|
1851
|
-
|
|
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
|
-
//
|
|
2054
|
-
// logs/ is its side content and is
|
|
2055
|
-
// after --files are.
|
|
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 =
|
|
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
|
-
|
|
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));
|