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