@mikeargento/bitgraph-audit 0.8.0 → 0.9.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 +4 -0
- package/dist/audit.d.ts +8 -6
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +28 -8
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +38 -3
- package/dist/cli.js.map +1 -1
- package/dist/exports.d.ts +39 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/exports.js +371 -0
- package/dist/exports.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ingest.d.ts.map +1 -1
- package/dist/ingest.js +179 -19
- package/dist/ingest.js.map +1 -1
- package/dist/report-json.d.ts.map +1 -1
- package/dist/report-json.js +15 -0
- package/dist/report-json.js.map +1 -1
- package/dist/report-md.d.ts.map +1 -1
- package/dist/report-md.js +222 -0
- package/dist/report-md.js.map +1 -1
- package/dist/types.d.ts +213 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/audit.ts +30 -8
- package/src/cli.ts +38 -3
- package/src/exports.ts +478 -0
- package/src/index.ts +12 -0
- package/src/ingest.ts +192 -18
- package/src/report-json.ts +15 -0
- package/src/report-md.ts +246 -0
- package/src/types.ts +203 -2
package/src/ingest.ts
CHANGED
|
@@ -14,9 +14,16 @@
|
|
|
14
14
|
* with the stable code "unsupported-version", counted, listed, and excluded
|
|
15
15
|
* from verification, chain reconstruction, and anomaly analysis.
|
|
16
16
|
*
|
|
17
|
+
* An export (bitgraph-export/1, found by its format field) is evidence,
|
|
18
|
+
* never an artifact: it is listed for the export stage (exports.ts), and the
|
|
19
|
+
* proof it carries is recorded as an observed proof from the export's path,
|
|
20
|
+
* exactly like a proof file. A file that declares another bitgraph-export
|
|
21
|
+
* format, or lacks an export's structure, is listed as rejected.
|
|
22
|
+
*
|
|
17
23
|
* Memory: archives are never unpacked to disk and never loaded whole.
|
|
18
24
|
* Every entry is hashed incrementally as it streams. Only small JSON
|
|
19
|
-
* candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing
|
|
25
|
+
* candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing,
|
|
26
|
+
* and entries that open an export object (at most maxExportJsonBytes);
|
|
20
27
|
* artifact bytes are hashed and dropped. What ingest retains scales with
|
|
21
28
|
* the number of entries and the total size of the proof JSONs, not with
|
|
22
29
|
* artifact payload sizes.
|
|
@@ -37,7 +44,7 @@ import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
|
37
44
|
import { join } from "node:path";
|
|
38
45
|
import { createGunzip } from "node:zlib";
|
|
39
46
|
import { sha256 } from "@noble/hashes/sha256";
|
|
40
|
-
import { computeProofHash, computeChainHash } from "@mikeargento/bitgraph-verify";
|
|
47
|
+
import { computeProofHash, computeChainHash, EXPORT_FORMAT, parseExport } from "@mikeargento/bitgraph-verify";
|
|
41
48
|
import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
|
|
42
49
|
import { readTarEntries } from "./tar.js";
|
|
43
50
|
import { combineEntryDigests } from "./contents-hash.js";
|
|
@@ -49,6 +56,7 @@ import type {
|
|
|
49
56
|
BundleManifest,
|
|
50
57
|
ContainerKind,
|
|
51
58
|
EmbeddedProofHashStatus,
|
|
59
|
+
ExportFile,
|
|
52
60
|
IngestCounts,
|
|
53
61
|
IngestLimits,
|
|
54
62
|
IngestResult,
|
|
@@ -67,6 +75,20 @@ import type {
|
|
|
67
75
|
*/
|
|
68
76
|
const MAX_CANDIDATE_JSON_BYTES = 8 * 1024 * 1024;
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* An export's JSON may run past MAX_CANDIDATE_JSON_BYTES: the owner's export
|
|
80
|
+
* of a large tree lists every leaf (65 bytes each, base64) and a name per
|
|
81
|
+
* leaf, up to 1,000,000 leaves. An entry whose opening bytes open an export
|
|
82
|
+
* object ({"format": "bitgraph-export/...) is buffered up to this cap
|
|
83
|
+
* instead (IngestLimits.maxExportJsonBytes); a larger one is reported as an
|
|
84
|
+
* export too large to check, never silently taken for an ordinary file.
|
|
85
|
+
*/
|
|
86
|
+
const MAX_EXPORT_JSON_BYTES = 192 * 1024 * 1024;
|
|
87
|
+
/** How many opening bytes decide whether an entry opens an export object. */
|
|
88
|
+
const EXPORT_SNIFF_BYTES = 256;
|
|
89
|
+
/** Producers write format first (buildExport); whitespace and one BOM are tolerated. */
|
|
90
|
+
const EXPORT_OPENING = /^\s*\{\s*"format"\s*:\s*"(bitgraph-export\/[^"\\]{0,64})"/;
|
|
91
|
+
|
|
70
92
|
/**
|
|
71
93
|
* Default resource caps for untrusted .tar / .tar.gz ingest. Chosen well
|
|
72
94
|
* above any legitimate bundle (the 50k-proof benchmark bundle decompresses
|
|
@@ -77,6 +99,7 @@ export const DEFAULT_INGEST_LIMITS: IngestLimits = {
|
|
|
77
99
|
maxTotalBytes: 2 * 1024 * 1024 * 1024, // 2 GiB decompressed
|
|
78
100
|
maxEntryCount: 1_000_000,
|
|
79
101
|
maxMetadataEntryBytes: 8 * 1024 * 1024, // 8 MiB per PAX/GNU metadata entry
|
|
102
|
+
maxExportJsonBytes: MAX_EXPORT_JSON_BYTES, // 192 MiB for one export's JSON, every container
|
|
80
103
|
};
|
|
81
104
|
|
|
82
105
|
const NUL = new Uint8Array([0]);
|
|
@@ -85,6 +108,8 @@ const BUNDLE_VERSION = "bitgraph-bundle/1";
|
|
|
85
108
|
const WITNESS_VERSION = "bitgraph-anchor-witness/1";
|
|
86
109
|
const CEILING_VERSION_TAG = "bitgraph-ceiling/1";
|
|
87
110
|
const CEILING_STATUS_VERSION_TAG = "bitgraph-ceiling-status/1";
|
|
111
|
+
/** Every export format starts with this; only EXPORT_FORMAT ("bitgraph-export/1") is checked. */
|
|
112
|
+
const EXPORT_FORMAT_PREFIX = "bitgraph-export/";
|
|
88
113
|
|
|
89
114
|
// ---------------------------------------------------------------------------
|
|
90
115
|
// Public API
|
|
@@ -111,6 +136,7 @@ export async function ingestBundle(
|
|
|
111
136
|
const findings: AuditFinding[] = [];
|
|
112
137
|
let skippedUnsafePaths = 0;
|
|
113
138
|
let entriesScanned = 0;
|
|
139
|
+
const exportCap = limits.maxExportJsonBytes ?? MAX_EXPORT_JSON_BYTES;
|
|
114
140
|
|
|
115
141
|
// ---------------------------------------------------------------------
|
|
116
142
|
// Scan: stream every entry once, hashing incrementally.
|
|
@@ -122,7 +148,7 @@ export async function ingestBundle(
|
|
|
122
148
|
for await (const file of walkDirectory(bundlePath)) {
|
|
123
149
|
entriesScanned++;
|
|
124
150
|
const stream = createReadStream(file.absPath);
|
|
125
|
-
const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream);
|
|
151
|
+
const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream, exportCap);
|
|
126
152
|
scanned.push(makeScannedEntry(file.relPath, hashed));
|
|
127
153
|
}
|
|
128
154
|
} else {
|
|
@@ -147,7 +173,7 @@ export async function ingestBundle(
|
|
|
147
173
|
}
|
|
148
174
|
const slash = normalized.path.indexOf("/");
|
|
149
175
|
const strippedVariant = slash === -1 ? undefined : normalized.path.slice(slash + 1);
|
|
150
|
-
const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body);
|
|
176
|
+
const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body, exportCap);
|
|
151
177
|
scanned.push(makeScannedEntry(normalized.path, hashed));
|
|
152
178
|
}
|
|
153
179
|
} finally {
|
|
@@ -235,7 +261,8 @@ export async function ingestEntries(
|
|
|
235
261
|
normalized.path,
|
|
236
262
|
undefined,
|
|
237
263
|
undefined,
|
|
238
|
-
openAsChunks(entry.open())
|
|
264
|
+
openAsChunks(entry.open()),
|
|
265
|
+
MAX_EXPORT_JSON_BYTES
|
|
239
266
|
);
|
|
240
267
|
scanned.push(makeScannedEntry(normalized.path, hashed));
|
|
241
268
|
sources.set(normalized.path, entry);
|
|
@@ -319,6 +346,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
319
346
|
byteLength: entry.byteLength,
|
|
320
347
|
entryDigest,
|
|
321
348
|
...(entry.json !== undefined ? { json: entry.json } : {}),
|
|
349
|
+
...(entry.exportTooLarge !== undefined ? { exportTooLarge: entry.exportTooLarge } : {}),
|
|
322
350
|
});
|
|
323
351
|
}
|
|
324
352
|
const finalEntries = Array.from(finalByPath.values());
|
|
@@ -341,6 +369,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
341
369
|
const witnesses: AnchorWitnessFile[] = [];
|
|
342
370
|
const ceilings: CeilingFile[] = [];
|
|
343
371
|
const ceilingStatuses: CeilingFile[] = [];
|
|
372
|
+
const exportFiles: ExportFile[] = [];
|
|
344
373
|
const artifactsByHex = new Map<string, ArtifactRecord>();
|
|
345
374
|
let manifest: ManifestReport | undefined;
|
|
346
375
|
let proofFiles = 0;
|
|
@@ -353,6 +382,19 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
353
382
|
continue;
|
|
354
383
|
}
|
|
355
384
|
|
|
385
|
+
// An export too large to parse is still an export: reported, never an
|
|
386
|
+
// artifact, and never silently checked as nothing.
|
|
387
|
+
if (entry.exportTooLarge !== undefined) {
|
|
388
|
+
exportFiles.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, format: entry.exportTooLarge, status: "too-large", json: {} });
|
|
389
|
+
findings.push({
|
|
390
|
+
code: "export-too-large",
|
|
391
|
+
path: entry.path,
|
|
392
|
+
message: `the file opens an export (${entry.exportTooLarge}) but is ${entry.byteLength} bytes, past the audit's cap for one export's JSON; not checked`,
|
|
393
|
+
details: { byteLength: entry.byteLength },
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
356
398
|
// A Frame (profile bitgraph-fuse/1) is the shipped carrier of a fused
|
|
357
399
|
// recording: { type: "bitgraph-fuse/1", manifest, fusePayload?, proof }.
|
|
358
400
|
// The nested proof is the member; the wrapper is never an artifact.
|
|
@@ -386,6 +428,45 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
386
428
|
continue;
|
|
387
429
|
}
|
|
388
430
|
|
|
431
|
+
// An export (bitgraph-export/1) is one JSON file that, with the file it
|
|
432
|
+
// covers, checks a tree/1 BitGraph: { format, spec, proof, tree, floor,
|
|
433
|
+
// ceiling, settlement }. Found by its format field, never by name. It is
|
|
434
|
+
// evidence, never an artifact; the proof it carries joins the proof
|
|
435
|
+
// analysis like any other (counters, chain links, partitions, attestation).
|
|
436
|
+
const exportFormat = exportFormatOf(parsed);
|
|
437
|
+
if (exportFormat !== undefined) {
|
|
438
|
+
const record = classifyExport(entry, parsed as Record<string, unknown>, exportFormat, findings);
|
|
439
|
+
exportFiles.push(record);
|
|
440
|
+
const carried = record.status === "ok" ? (parsed as Record<string, unknown>)["proof"] : undefined;
|
|
441
|
+
if (carried !== undefined && isProofShaped(carried)) {
|
|
442
|
+
const inner = carried as Record<string, unknown>;
|
|
443
|
+
const version = inner["version"] as string;
|
|
444
|
+
if (version === "bitgraph/1") {
|
|
445
|
+
proofFiles++;
|
|
446
|
+
const outcome = recordMemberProof(entry, inner, proofsByHash, proofs, findings);
|
|
447
|
+
if (outcome === "exact-duplicate") exactDuplicates++;
|
|
448
|
+
if (outcome === "semantic-duplicate") semanticDuplicates++;
|
|
449
|
+
record.proofHash = computeProofHash(inner);
|
|
450
|
+
} else {
|
|
451
|
+
// The same version policy as any proof-shaped input; the export
|
|
452
|
+
// itself is still checked (its proof claim fails).
|
|
453
|
+
unsupportedVersions.push({
|
|
454
|
+
code: "unsupported-version",
|
|
455
|
+
path: entry.path,
|
|
456
|
+
version,
|
|
457
|
+
fileSha256Hex: entry.sha256Hex,
|
|
458
|
+
});
|
|
459
|
+
findings.push({
|
|
460
|
+
code: "unsupported-version",
|
|
461
|
+
path: entry.path,
|
|
462
|
+
message: `the proof inside this export is rejected: version "${version}" is not "bitgraph/1"`,
|
|
463
|
+
details: { version },
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
389
470
|
if (
|
|
390
471
|
parsed !== undefined &&
|
|
391
472
|
(parsed as Record<string, unknown>)["version"] === WITNESS_VERSION
|
|
@@ -437,6 +518,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
437
518
|
artifacts: artifacts.length,
|
|
438
519
|
witnesses: witnesses.length,
|
|
439
520
|
ceilings: ceilings.length + ceilingStatuses.length,
|
|
521
|
+
exports: exportFiles.length,
|
|
440
522
|
skippedUnsafePaths,
|
|
441
523
|
};
|
|
442
524
|
|
|
@@ -451,6 +533,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
451
533
|
witnesses,
|
|
452
534
|
ceilings,
|
|
453
535
|
ceilingStatuses,
|
|
536
|
+
exports: exportFiles,
|
|
454
537
|
...(manifest !== undefined ? { manifest } : {}),
|
|
455
538
|
computedContentsHashB64,
|
|
456
539
|
findings,
|
|
@@ -668,6 +751,8 @@ interface ScannedEntry {
|
|
|
668
751
|
entryDigestFull: Uint8Array;
|
|
669
752
|
entryDigestStripped?: Uint8Array;
|
|
670
753
|
json?: unknown;
|
|
754
|
+
/** The format an entry opening an export declared, when it ran past the export JSON cap. */
|
|
755
|
+
exportTooLarge?: string;
|
|
671
756
|
}
|
|
672
757
|
|
|
673
758
|
interface FinalEntry {
|
|
@@ -676,6 +761,7 @@ interface FinalEntry {
|
|
|
676
761
|
byteLength: number;
|
|
677
762
|
entryDigest: Uint8Array;
|
|
678
763
|
json?: unknown;
|
|
764
|
+
exportTooLarge?: string;
|
|
679
765
|
}
|
|
680
766
|
|
|
681
767
|
interface HashedEntry {
|
|
@@ -684,6 +770,7 @@ interface HashedEntry {
|
|
|
684
770
|
entryDigestFull: Uint8Array;
|
|
685
771
|
entryDigestStripped?: Uint8Array;
|
|
686
772
|
buffered?: Uint8Array;
|
|
773
|
+
exportTooLarge?: string;
|
|
687
774
|
}
|
|
688
775
|
|
|
689
776
|
/**
|
|
@@ -693,13 +780,14 @@ interface HashedEntry {
|
|
|
693
780
|
* - for tar entries below a potentially stripped root, the same entry
|
|
694
781
|
* digest under the stripped path variant,
|
|
695
782
|
* while buffering the content only if it stays within the JSON candidacy
|
|
696
|
-
* cap.
|
|
783
|
+
* cap, or, for an entry that opens an export object, within the export cap.
|
|
697
784
|
*/
|
|
698
785
|
async function hashEntryStream(
|
|
699
786
|
path: string,
|
|
700
787
|
strippedPathVariant: string | undefined,
|
|
701
788
|
declaredSize: number | undefined,
|
|
702
|
-
chunks: AsyncIterable<Uint8Array
|
|
789
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
790
|
+
exportCap: number
|
|
703
791
|
): Promise<HashedEntry> {
|
|
704
792
|
const encoder = new TextEncoder();
|
|
705
793
|
const contentHasher = sha256.create();
|
|
@@ -712,36 +800,72 @@ async function hashEntryStream(
|
|
|
712
800
|
strippedHasher.update(NUL);
|
|
713
801
|
}
|
|
714
802
|
|
|
715
|
-
|
|
803
|
+
// json: buffer within the ordinary cap (past it, keep going only if the
|
|
804
|
+
// entry opens an export). sniff: declared past the ordinary cap, so only an
|
|
805
|
+
// export is worth buffering; decided on the opening bytes. export: buffer
|
|
806
|
+
// within the export cap. off: hash only.
|
|
807
|
+
let mode: "json" | "sniff" | "export" | "off" =
|
|
808
|
+
declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES ? "json" : "sniff";
|
|
809
|
+
let exportTooLarge: string | undefined;
|
|
810
|
+
const head = new Uint8Array(EXPORT_SNIFF_BYTES);
|
|
811
|
+
let headLength = 0;
|
|
716
812
|
const buffered: Uint8Array[] = [];
|
|
717
813
|
let total = 0;
|
|
814
|
+
const stop = (): "off" => {
|
|
815
|
+
buffered.length = 0;
|
|
816
|
+
return "off";
|
|
817
|
+
};
|
|
818
|
+
const tooLarge = (): "off" => {
|
|
819
|
+
exportTooLarge = exportOpening(head.subarray(0, headLength)) ?? "bitgraph-export/";
|
|
820
|
+
return stop();
|
|
821
|
+
};
|
|
718
822
|
|
|
719
823
|
for await (const chunk of chunks) {
|
|
720
824
|
contentHasher.update(chunk);
|
|
721
825
|
fullHasher.update(chunk);
|
|
722
826
|
strippedHasher?.update(chunk);
|
|
723
827
|
total += chunk.length;
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
828
|
+
if (headLength < head.length) {
|
|
829
|
+
const take = Math.min(head.length - headLength, chunk.length);
|
|
830
|
+
head.set(chunk.subarray(0, take), headLength);
|
|
831
|
+
headLength += take;
|
|
832
|
+
}
|
|
833
|
+
if (mode === "off") continue;
|
|
834
|
+
if (mode === "sniff" && headLength === head.length) {
|
|
835
|
+
mode = exportOpening(head) === undefined ? stop() : declaredSize !== undefined && declaredSize > exportCap ? tooLarge() : "export";
|
|
836
|
+
if (mode === "off") continue;
|
|
733
837
|
}
|
|
838
|
+
// Copy: tar body chunks are views into the container's stream
|
|
839
|
+
// buffers and are not stable after iteration continues.
|
|
840
|
+
buffered.push(chunk.slice());
|
|
841
|
+
if (mode === "json" && total > MAX_CANDIDATE_JSON_BYTES) {
|
|
842
|
+
mode = exportOpening(head.subarray(0, headLength)) === undefined ? stop() : "export";
|
|
843
|
+
}
|
|
844
|
+
if (mode === "export" && total > exportCap) mode = tooLarge();
|
|
845
|
+
}
|
|
846
|
+
if (mode === "sniff") {
|
|
847
|
+
// Shorter than the sniff window although declared past the ordinary cap.
|
|
848
|
+
mode = exportOpening(head.subarray(0, headLength)) === undefined || total > exportCap ? stop() : "export";
|
|
734
849
|
}
|
|
735
850
|
|
|
851
|
+
const keep = mode === "json" || mode === "export";
|
|
736
852
|
return {
|
|
737
853
|
sha256Hex: toHex(contentHasher.digest()),
|
|
738
854
|
byteLength: total,
|
|
739
855
|
entryDigestFull: fullHasher.digest(),
|
|
740
856
|
...(strippedHasher !== undefined ? { entryDigestStripped: strippedHasher.digest() } : {}),
|
|
741
|
-
...(
|
|
857
|
+
...(keep ? { buffered: concatBytes(buffered, total) } : {}),
|
|
858
|
+
...(exportTooLarge !== undefined ? { exportTooLarge } : {}),
|
|
742
859
|
};
|
|
743
860
|
}
|
|
744
861
|
|
|
862
|
+
/** The export format an entry's opening bytes declare ({"format": "bitgraph-export/..."), or undefined. */
|
|
863
|
+
function exportOpening(head: Uint8Array): string | undefined {
|
|
864
|
+
const start = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
|
|
865
|
+
const match = EXPORT_OPENING.exec(Buffer.from(head.subarray(start)).toString("latin1"));
|
|
866
|
+
return match?.[1];
|
|
867
|
+
}
|
|
868
|
+
|
|
745
869
|
function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
|
|
746
870
|
const json = hashed.buffered === undefined ? undefined : tryParseJsonObject(hashed.buffered);
|
|
747
871
|
return {
|
|
@@ -753,6 +877,7 @@ function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
|
|
|
753
877
|
? { entryDigestStripped: hashed.entryDigestStripped }
|
|
754
878
|
: {}),
|
|
755
879
|
...(json !== undefined ? { json } : {}),
|
|
880
|
+
...(hashed.exportTooLarge !== undefined ? { exportTooLarge: hashed.exportTooLarge } : {}),
|
|
756
881
|
};
|
|
757
882
|
}
|
|
758
883
|
|
|
@@ -873,6 +998,55 @@ function isProofShaped(value: unknown): boolean {
|
|
|
873
998
|
);
|
|
874
999
|
}
|
|
875
1000
|
|
|
1001
|
+
// ---------------------------------------------------------------------------
|
|
1002
|
+
// Export classification (bitgraph-export/1)
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
|
|
1005
|
+
/** The format string of an export-shaped value ("bitgraph-export/..."), or undefined. */
|
|
1006
|
+
function exportFormatOf(value: unknown): string | undefined {
|
|
1007
|
+
if (!isPlainObject(value)) return undefined;
|
|
1008
|
+
const format = value["format"];
|
|
1009
|
+
return typeof format === "string" && format.startsWith(EXPORT_FORMAT_PREFIX) ? format : undefined;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Classify one export-shaped file. Structure is decided by bitgraph-verify's
|
|
1014
|
+
* parseExport, so the audit and the verifier agree on what an export is. A
|
|
1015
|
+
* file that declares an export format but is not one is recorded with a
|
|
1016
|
+
* finding and is never an artifact.
|
|
1017
|
+
*/
|
|
1018
|
+
function classifyExport(
|
|
1019
|
+
entry: FinalEntry,
|
|
1020
|
+
parsed: Record<string, unknown>,
|
|
1021
|
+
format: string,
|
|
1022
|
+
findings: AuditFinding[]
|
|
1023
|
+
): ExportFile {
|
|
1024
|
+
const base = { path: entry.path, fileSha256Hex: entry.sha256Hex, format, json: parsed };
|
|
1025
|
+
if (format !== EXPORT_FORMAT) {
|
|
1026
|
+
findings.push({
|
|
1027
|
+
code: "export-unsupported-format",
|
|
1028
|
+
path: entry.path,
|
|
1029
|
+
message: `export-shaped file not checked: format "${format}" is not "${EXPORT_FORMAT}"`,
|
|
1030
|
+
details: { format },
|
|
1031
|
+
});
|
|
1032
|
+
return { ...base, status: "unsupported-format" };
|
|
1033
|
+
}
|
|
1034
|
+
if (parseExport(parsed) === null) {
|
|
1035
|
+
const tree = parsed["tree"];
|
|
1036
|
+
const missing = !isPlainObject(parsed["proof"])
|
|
1037
|
+
? "a proof object"
|
|
1038
|
+
: "tree.rootDocument (a string)";
|
|
1039
|
+
findings.push({
|
|
1040
|
+
code: "export-malformed",
|
|
1041
|
+
path: entry.path,
|
|
1042
|
+
message: `declares ${EXPORT_FORMAT} but lacks ${missing}; not checked`,
|
|
1043
|
+
details: { hasProof: isPlainObject(parsed["proof"]), hasTree: isPlainObject(tree) },
|
|
1044
|
+
});
|
|
1045
|
+
return { ...base, status: "malformed" };
|
|
1046
|
+
}
|
|
1047
|
+
return { ...base, status: "ok" };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
876
1050
|
type DuplicateKind = "new" | "exact-duplicate" | "semantic-duplicate";
|
|
877
1051
|
|
|
878
1052
|
function recordMemberProof(
|
package/src/report-json.ts
CHANGED
|
@@ -112,6 +112,7 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
|
|
|
112
112
|
...(result.ceilings && (result.ceilings.checks.length > 0 || result.ceilings.statuses.length > 0)
|
|
113
113
|
? { ceilings: result.ceilings }
|
|
114
114
|
: {}),
|
|
115
|
+
...(result.exports && result.exports.checks.length > 0 ? { exports: result.exports } : {}),
|
|
115
116
|
attestations: {
|
|
116
117
|
records: result.attestations.records,
|
|
117
118
|
counts: result.attestations.counts,
|
|
@@ -291,6 +292,8 @@ function buildSummary(
|
|
|
291
292
|
|
|
292
293
|
const partitionsIntact = partitions.filter((p) => p.intact).length;
|
|
293
294
|
const exit = computeExitFlags(result);
|
|
295
|
+
const exportChecks = result.exports?.checks ?? [];
|
|
296
|
+
const checkedExports = exportChecks.filter((e) => e.status === "checked");
|
|
294
297
|
|
|
295
298
|
return {
|
|
296
299
|
proofsObserved: result.ingest.counts.observed,
|
|
@@ -331,6 +334,18 @@ function buildSummary(
|
|
|
331
334
|
segmentsUpperBounded: segmentCount("upper-bounded"),
|
|
332
335
|
segmentsUnanchored: segmentCount("ordered-but-unanchored"),
|
|
333
336
|
},
|
|
337
|
+
...(exportChecks.length > 0
|
|
338
|
+
? {
|
|
339
|
+
exports: {
|
|
340
|
+
files: exportChecks.length,
|
|
341
|
+
verdictTrue: checkedExports.filter((e) => e.verdict === "TRUE").length,
|
|
342
|
+
verdictFalse: checkedExports.filter((e) => e.verdict === "FALSE").length,
|
|
343
|
+
verdictUndetermined: checkedExports.filter((e) => e.verdict === "UNDETERMINED").length,
|
|
344
|
+
rejected: exportChecks.length - checkedExports.length,
|
|
345
|
+
coveredFilesChecked: checkedExports.reduce((n, e) => n + e.runs.filter((r) => r.file !== null).length, 0),
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
334
349
|
exit,
|
|
335
350
|
};
|
|
336
351
|
}
|