@mikeargento/bitgraph-audit 0.8.0 → 0.10.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 +8 -2
- package/dist/audit.d.ts +9 -6
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +34 -8
- package/dist/audit.js.map +1 -1
- package/dist/ceilings.d.ts.map +1 -1
- package/dist/ceilings.js +1 -0
- package/dist/ceilings.js.map +1 -1
- package/dist/cli.js +109 -21
- 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 +374 -0
- package/dist/exports.js.map +1 -0
- package/dist/floors.d.ts +23 -0
- package/dist/floors.d.ts.map +1 -0
- package/dist/floors.js +203 -0
- package/dist/floors.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ingest.d.ts +2 -0
- package/dist/ingest.d.ts.map +1 -1
- package/dist/ingest.js +193 -19
- package/dist/ingest.js.map +1 -1
- package/dist/report-json.d.ts.map +1 -1
- package/dist/report-json.js +30 -0
- package/dist/report-json.js.map +1 -1
- package/dist/report-md.d.ts.map +1 -1
- package/dist/report-md.js +315 -25
- package/dist/report-md.js.map +1 -1
- package/dist/temporal.d.ts +12 -0
- package/dist/temporal.d.ts.map +1 -1
- package/dist/temporal.js +245 -41
- package/dist/temporal.js.map +1 -1
- package/dist/types.d.ts +348 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/base-floor.test.ts +405 -0
- package/src/audit.ts +36 -8
- package/src/ceilings.ts +1 -0
- package/src/cli.ts +117 -22
- package/src/exports.ts +482 -0
- package/src/floors.ts +242 -0
- package/src/index.ts +20 -0
- package/src/ingest.ts +207 -18
- package/src/report-json.ts +30 -0
- package/src/report-md.ts +353 -29
- package/src/temporal.ts +279 -48
- package/src/types.ts +341 -6
package/src/ingest.ts
CHANGED
|
@@ -14,9 +14,20 @@
|
|
|
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
|
+
*
|
|
23
|
+
* A floor header file (bitgraph-floor-header/1, found by its version field)
|
|
24
|
+
* is evidence too: the header of the Base block a proof signs as its floor
|
|
25
|
+
* (commit.slotFloor, enclave v10), listed for the temporal stage.
|
|
26
|
+
*
|
|
17
27
|
* Memory: archives are never unpacked to disk and never loaded whole.
|
|
18
28
|
* Every entry is hashed incrementally as it streams. Only small JSON
|
|
19
|
-
* candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing
|
|
29
|
+
* candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing,
|
|
30
|
+
* and entries that open an export object (at most maxExportJsonBytes);
|
|
20
31
|
* artifact bytes are hashed and dropped. What ingest retains scales with
|
|
21
32
|
* the number of entries and the total size of the proof JSONs, not with
|
|
22
33
|
* artifact payload sizes.
|
|
@@ -37,18 +48,20 @@ import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
|
37
48
|
import { join } from "node:path";
|
|
38
49
|
import { createGunzip } from "node:zlib";
|
|
39
50
|
import { sha256 } from "@noble/hashes/sha256";
|
|
40
|
-
import { computeProofHash, computeChainHash } from "@mikeargento/bitgraph-verify";
|
|
51
|
+
import { computeProofHash, computeChainHash, EXPORT_FORMAT, parseExport } from "@mikeargento/bitgraph-verify";
|
|
41
52
|
import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
|
|
42
53
|
import { readTarEntries } from "./tar.js";
|
|
43
54
|
import { combineEntryDigests } from "./contents-hash.js";
|
|
44
55
|
import type {
|
|
45
56
|
AnchorWitnessFile,
|
|
46
57
|
CeilingFile,
|
|
58
|
+
FloorHeaderFile,
|
|
47
59
|
ArtifactRecord,
|
|
48
60
|
AuditFinding,
|
|
49
61
|
BundleManifest,
|
|
50
62
|
ContainerKind,
|
|
51
63
|
EmbeddedProofHashStatus,
|
|
64
|
+
ExportFile,
|
|
52
65
|
IngestCounts,
|
|
53
66
|
IngestLimits,
|
|
54
67
|
IngestResult,
|
|
@@ -67,6 +80,20 @@ import type {
|
|
|
67
80
|
*/
|
|
68
81
|
const MAX_CANDIDATE_JSON_BYTES = 8 * 1024 * 1024;
|
|
69
82
|
|
|
83
|
+
/**
|
|
84
|
+
* An export's JSON may run past MAX_CANDIDATE_JSON_BYTES: the owner's export
|
|
85
|
+
* of a large tree lists every leaf (65 bytes each, base64) and a name per
|
|
86
|
+
* leaf, up to 1,000,000 leaves. An entry whose opening bytes open an export
|
|
87
|
+
* object ({"format": "bitgraph-export/...) is buffered up to this cap
|
|
88
|
+
* instead (IngestLimits.maxExportJsonBytes); a larger one is reported as an
|
|
89
|
+
* export too large to check, never silently taken for an ordinary file.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_EXPORT_JSON_BYTES = 192 * 1024 * 1024;
|
|
92
|
+
/** How many opening bytes decide whether an entry opens an export object. */
|
|
93
|
+
const EXPORT_SNIFF_BYTES = 256;
|
|
94
|
+
/** Producers write format first (buildExport); whitespace and one BOM are tolerated. */
|
|
95
|
+
const EXPORT_OPENING = /^\s*\{\s*"format"\s*:\s*"(bitgraph-export\/[^"\\]{0,64})"/;
|
|
96
|
+
|
|
70
97
|
/**
|
|
71
98
|
* Default resource caps for untrusted .tar / .tar.gz ingest. Chosen well
|
|
72
99
|
* above any legitimate bundle (the 50k-proof benchmark bundle decompresses
|
|
@@ -77,6 +104,7 @@ export const DEFAULT_INGEST_LIMITS: IngestLimits = {
|
|
|
77
104
|
maxTotalBytes: 2 * 1024 * 1024 * 1024, // 2 GiB decompressed
|
|
78
105
|
maxEntryCount: 1_000_000,
|
|
79
106
|
maxMetadataEntryBytes: 8 * 1024 * 1024, // 8 MiB per PAX/GNU metadata entry
|
|
107
|
+
maxExportJsonBytes: MAX_EXPORT_JSON_BYTES, // 192 MiB for one export's JSON, every container
|
|
80
108
|
};
|
|
81
109
|
|
|
82
110
|
const NUL = new Uint8Array([0]);
|
|
@@ -85,6 +113,10 @@ const BUNDLE_VERSION = "bitgraph-bundle/1";
|
|
|
85
113
|
const WITNESS_VERSION = "bitgraph-anchor-witness/1";
|
|
86
114
|
const CEILING_VERSION_TAG = "bitgraph-ceiling/1";
|
|
87
115
|
const CEILING_STATUS_VERSION_TAG = "bitgraph-ceiling-status/1";
|
|
116
|
+
/** A Base floor block's header, beside the proof that signs it (the carrier/3 unpacker writes these). */
|
|
117
|
+
export const FLOOR_HEADER_VERSION = "bitgraph-floor-header/1";
|
|
118
|
+
/** Every export format starts with this; only EXPORT_FORMAT ("bitgraph-export/1") is checked. */
|
|
119
|
+
const EXPORT_FORMAT_PREFIX = "bitgraph-export/";
|
|
88
120
|
|
|
89
121
|
// ---------------------------------------------------------------------------
|
|
90
122
|
// Public API
|
|
@@ -111,6 +143,7 @@ export async function ingestBundle(
|
|
|
111
143
|
const findings: AuditFinding[] = [];
|
|
112
144
|
let skippedUnsafePaths = 0;
|
|
113
145
|
let entriesScanned = 0;
|
|
146
|
+
const exportCap = limits.maxExportJsonBytes ?? MAX_EXPORT_JSON_BYTES;
|
|
114
147
|
|
|
115
148
|
// ---------------------------------------------------------------------
|
|
116
149
|
// Scan: stream every entry once, hashing incrementally.
|
|
@@ -122,7 +155,7 @@ export async function ingestBundle(
|
|
|
122
155
|
for await (const file of walkDirectory(bundlePath)) {
|
|
123
156
|
entriesScanned++;
|
|
124
157
|
const stream = createReadStream(file.absPath);
|
|
125
|
-
const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream);
|
|
158
|
+
const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream, exportCap);
|
|
126
159
|
scanned.push(makeScannedEntry(file.relPath, hashed));
|
|
127
160
|
}
|
|
128
161
|
} else {
|
|
@@ -147,7 +180,7 @@ export async function ingestBundle(
|
|
|
147
180
|
}
|
|
148
181
|
const slash = normalized.path.indexOf("/");
|
|
149
182
|
const strippedVariant = slash === -1 ? undefined : normalized.path.slice(slash + 1);
|
|
150
|
-
const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body);
|
|
183
|
+
const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body, exportCap);
|
|
151
184
|
scanned.push(makeScannedEntry(normalized.path, hashed));
|
|
152
185
|
}
|
|
153
186
|
} finally {
|
|
@@ -235,7 +268,8 @@ export async function ingestEntries(
|
|
|
235
268
|
normalized.path,
|
|
236
269
|
undefined,
|
|
237
270
|
undefined,
|
|
238
|
-
openAsChunks(entry.open())
|
|
271
|
+
openAsChunks(entry.open()),
|
|
272
|
+
MAX_EXPORT_JSON_BYTES
|
|
239
273
|
);
|
|
240
274
|
scanned.push(makeScannedEntry(normalized.path, hashed));
|
|
241
275
|
sources.set(normalized.path, entry);
|
|
@@ -319,6 +353,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
319
353
|
byteLength: entry.byteLength,
|
|
320
354
|
entryDigest,
|
|
321
355
|
...(entry.json !== undefined ? { json: entry.json } : {}),
|
|
356
|
+
...(entry.exportTooLarge !== undefined ? { exportTooLarge: entry.exportTooLarge } : {}),
|
|
322
357
|
});
|
|
323
358
|
}
|
|
324
359
|
const finalEntries = Array.from(finalByPath.values());
|
|
@@ -341,6 +376,8 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
341
376
|
const witnesses: AnchorWitnessFile[] = [];
|
|
342
377
|
const ceilings: CeilingFile[] = [];
|
|
343
378
|
const ceilingStatuses: CeilingFile[] = [];
|
|
379
|
+
const floorHeaders: FloorHeaderFile[] = [];
|
|
380
|
+
const exportFiles: ExportFile[] = [];
|
|
344
381
|
const artifactsByHex = new Map<string, ArtifactRecord>();
|
|
345
382
|
let manifest: ManifestReport | undefined;
|
|
346
383
|
let proofFiles = 0;
|
|
@@ -353,6 +390,19 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
353
390
|
continue;
|
|
354
391
|
}
|
|
355
392
|
|
|
393
|
+
// An export too large to parse is still an export: reported, never an
|
|
394
|
+
// artifact, and never silently checked as nothing.
|
|
395
|
+
if (entry.exportTooLarge !== undefined) {
|
|
396
|
+
exportFiles.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, format: entry.exportTooLarge, status: "too-large", json: {} });
|
|
397
|
+
findings.push({
|
|
398
|
+
code: "export-too-large",
|
|
399
|
+
path: entry.path,
|
|
400
|
+
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`,
|
|
401
|
+
details: { byteLength: entry.byteLength },
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
356
406
|
// A Frame (profile bitgraph-fuse/1) is the shipped carrier of a fused
|
|
357
407
|
// recording: { type: "bitgraph-fuse/1", manifest, fusePayload?, proof }.
|
|
358
408
|
// The nested proof is the member; the wrapper is never an artifact.
|
|
@@ -386,6 +436,45 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
386
436
|
continue;
|
|
387
437
|
}
|
|
388
438
|
|
|
439
|
+
// An export (bitgraph-export/1) is one JSON file that, with the file it
|
|
440
|
+
// covers, checks a tree/1 BitGraph: { format, spec, proof, tree, floor,
|
|
441
|
+
// ceiling, settlement }. Found by its format field, never by name. It is
|
|
442
|
+
// evidence, never an artifact; the proof it carries joins the proof
|
|
443
|
+
// analysis like any other (counters, chain links, partitions, attestation).
|
|
444
|
+
const exportFormat = exportFormatOf(parsed);
|
|
445
|
+
if (exportFormat !== undefined) {
|
|
446
|
+
const record = classifyExport(entry, parsed as Record<string, unknown>, exportFormat, findings);
|
|
447
|
+
exportFiles.push(record);
|
|
448
|
+
const carried = record.status === "ok" ? (parsed as Record<string, unknown>)["proof"] : undefined;
|
|
449
|
+
if (carried !== undefined && isProofShaped(carried)) {
|
|
450
|
+
const inner = carried as Record<string, unknown>;
|
|
451
|
+
const version = inner["version"] as string;
|
|
452
|
+
if (version === "bitgraph/1") {
|
|
453
|
+
proofFiles++;
|
|
454
|
+
const outcome = recordMemberProof(entry, inner, proofsByHash, proofs, findings);
|
|
455
|
+
if (outcome === "exact-duplicate") exactDuplicates++;
|
|
456
|
+
if (outcome === "semantic-duplicate") semanticDuplicates++;
|
|
457
|
+
record.proofHash = computeProofHash(inner);
|
|
458
|
+
} else {
|
|
459
|
+
// The same version policy as any proof-shaped input; the export
|
|
460
|
+
// itself is still checked (its proof claim fails).
|
|
461
|
+
unsupportedVersions.push({
|
|
462
|
+
code: "unsupported-version",
|
|
463
|
+
path: entry.path,
|
|
464
|
+
version,
|
|
465
|
+
fileSha256Hex: entry.sha256Hex,
|
|
466
|
+
});
|
|
467
|
+
findings.push({
|
|
468
|
+
code: "unsupported-version",
|
|
469
|
+
path: entry.path,
|
|
470
|
+
message: `the proof inside this export is rejected: version "${version}" is not "bitgraph/1"`,
|
|
471
|
+
details: { version },
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
389
478
|
if (
|
|
390
479
|
parsed !== undefined &&
|
|
391
480
|
(parsed as Record<string, unknown>)["version"] === WITNESS_VERSION
|
|
@@ -400,6 +489,11 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
400
489
|
|
|
401
490
|
// A ceiling in time travels beside its proof: evidence, never an artifact.
|
|
402
491
|
const pv = parsed !== undefined ? (parsed as Record<string, unknown>)["version"] : undefined;
|
|
492
|
+
// So does a Base floor block's header: evidence for the temporal stage.
|
|
493
|
+
if (pv === FLOOR_HEADER_VERSION) {
|
|
494
|
+
floorHeaders.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, json: parsed as Record<string, unknown> });
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
403
497
|
if (pv === CEILING_VERSION_TAG || pv === CEILING_STATUS_VERSION_TAG) {
|
|
404
498
|
(pv === CEILING_VERSION_TAG ? ceilings : ceilingStatuses).push({
|
|
405
499
|
path: entry.path,
|
|
@@ -436,7 +530,9 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
436
530
|
unsupportedVersion: unsupportedVersions.length,
|
|
437
531
|
artifacts: artifacts.length,
|
|
438
532
|
witnesses: witnesses.length,
|
|
533
|
+
...(floorHeaders.length > 0 ? { floorHeaders: floorHeaders.length } : {}),
|
|
439
534
|
ceilings: ceilings.length + ceilingStatuses.length,
|
|
535
|
+
exports: exportFiles.length,
|
|
440
536
|
skippedUnsafePaths,
|
|
441
537
|
};
|
|
442
538
|
|
|
@@ -449,8 +545,10 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
|
449
545
|
unsupportedVersions,
|
|
450
546
|
artifacts,
|
|
451
547
|
witnesses,
|
|
548
|
+
...(floorHeaders.length > 0 ? { floorHeaders } : {}),
|
|
452
549
|
ceilings,
|
|
453
550
|
ceilingStatuses,
|
|
551
|
+
exports: exportFiles,
|
|
454
552
|
...(manifest !== undefined ? { manifest } : {}),
|
|
455
553
|
computedContentsHashB64,
|
|
456
554
|
findings,
|
|
@@ -668,6 +766,8 @@ interface ScannedEntry {
|
|
|
668
766
|
entryDigestFull: Uint8Array;
|
|
669
767
|
entryDigestStripped?: Uint8Array;
|
|
670
768
|
json?: unknown;
|
|
769
|
+
/** The format an entry opening an export declared, when it ran past the export JSON cap. */
|
|
770
|
+
exportTooLarge?: string;
|
|
671
771
|
}
|
|
672
772
|
|
|
673
773
|
interface FinalEntry {
|
|
@@ -676,6 +776,7 @@ interface FinalEntry {
|
|
|
676
776
|
byteLength: number;
|
|
677
777
|
entryDigest: Uint8Array;
|
|
678
778
|
json?: unknown;
|
|
779
|
+
exportTooLarge?: string;
|
|
679
780
|
}
|
|
680
781
|
|
|
681
782
|
interface HashedEntry {
|
|
@@ -684,6 +785,7 @@ interface HashedEntry {
|
|
|
684
785
|
entryDigestFull: Uint8Array;
|
|
685
786
|
entryDigestStripped?: Uint8Array;
|
|
686
787
|
buffered?: Uint8Array;
|
|
788
|
+
exportTooLarge?: string;
|
|
687
789
|
}
|
|
688
790
|
|
|
689
791
|
/**
|
|
@@ -693,13 +795,14 @@ interface HashedEntry {
|
|
|
693
795
|
* - for tar entries below a potentially stripped root, the same entry
|
|
694
796
|
* digest under the stripped path variant,
|
|
695
797
|
* while buffering the content only if it stays within the JSON candidacy
|
|
696
|
-
* cap.
|
|
798
|
+
* cap, or, for an entry that opens an export object, within the export cap.
|
|
697
799
|
*/
|
|
698
800
|
async function hashEntryStream(
|
|
699
801
|
path: string,
|
|
700
802
|
strippedPathVariant: string | undefined,
|
|
701
803
|
declaredSize: number | undefined,
|
|
702
|
-
chunks: AsyncIterable<Uint8Array
|
|
804
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
805
|
+
exportCap: number
|
|
703
806
|
): Promise<HashedEntry> {
|
|
704
807
|
const encoder = new TextEncoder();
|
|
705
808
|
const contentHasher = sha256.create();
|
|
@@ -712,36 +815,72 @@ async function hashEntryStream(
|
|
|
712
815
|
strippedHasher.update(NUL);
|
|
713
816
|
}
|
|
714
817
|
|
|
715
|
-
|
|
818
|
+
// json: buffer within the ordinary cap (past it, keep going only if the
|
|
819
|
+
// entry opens an export). sniff: declared past the ordinary cap, so only an
|
|
820
|
+
// export is worth buffering; decided on the opening bytes. export: buffer
|
|
821
|
+
// within the export cap. off: hash only.
|
|
822
|
+
let mode: "json" | "sniff" | "export" | "off" =
|
|
823
|
+
declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES ? "json" : "sniff";
|
|
824
|
+
let exportTooLarge: string | undefined;
|
|
825
|
+
const head = new Uint8Array(EXPORT_SNIFF_BYTES);
|
|
826
|
+
let headLength = 0;
|
|
716
827
|
const buffered: Uint8Array[] = [];
|
|
717
828
|
let total = 0;
|
|
829
|
+
const stop = (): "off" => {
|
|
830
|
+
buffered.length = 0;
|
|
831
|
+
return "off";
|
|
832
|
+
};
|
|
833
|
+
const tooLarge = (): "off" => {
|
|
834
|
+
exportTooLarge = exportOpening(head.subarray(0, headLength)) ?? "bitgraph-export/";
|
|
835
|
+
return stop();
|
|
836
|
+
};
|
|
718
837
|
|
|
719
838
|
for await (const chunk of chunks) {
|
|
720
839
|
contentHasher.update(chunk);
|
|
721
840
|
fullHasher.update(chunk);
|
|
722
841
|
strippedHasher?.update(chunk);
|
|
723
842
|
total += chunk.length;
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
} else {
|
|
729
|
-
// Copy: tar body chunks are views into the container's stream
|
|
730
|
-
// buffers and are not stable after iteration continues.
|
|
731
|
-
buffered.push(chunk.slice());
|
|
732
|
-
}
|
|
843
|
+
if (headLength < head.length) {
|
|
844
|
+
const take = Math.min(head.length - headLength, chunk.length);
|
|
845
|
+
head.set(chunk.subarray(0, take), headLength);
|
|
846
|
+
headLength += take;
|
|
733
847
|
}
|
|
848
|
+
if (mode === "off") continue;
|
|
849
|
+
if (mode === "sniff" && headLength === head.length) {
|
|
850
|
+
mode = exportOpening(head) === undefined ? stop() : declaredSize !== undefined && declaredSize > exportCap ? tooLarge() : "export";
|
|
851
|
+
if (mode === "off") continue;
|
|
852
|
+
}
|
|
853
|
+
// Copy: tar body chunks are views into the container's stream
|
|
854
|
+
// buffers and are not stable after iteration continues.
|
|
855
|
+
buffered.push(chunk.slice());
|
|
856
|
+
if (mode === "json" && total > MAX_CANDIDATE_JSON_BYTES) {
|
|
857
|
+
mode = exportOpening(head.subarray(0, headLength)) === undefined ? stop() : "export";
|
|
858
|
+
}
|
|
859
|
+
if (mode === "export" && total > exportCap) mode = tooLarge();
|
|
860
|
+
}
|
|
861
|
+
if (mode === "sniff") {
|
|
862
|
+
// Shorter than the sniff window although declared past the ordinary cap.
|
|
863
|
+
mode = exportOpening(head.subarray(0, headLength)) === undefined || total > exportCap ? stop() : "export";
|
|
734
864
|
}
|
|
735
865
|
|
|
866
|
+
const keep = mode === "json" || mode === "export";
|
|
736
867
|
return {
|
|
737
868
|
sha256Hex: toHex(contentHasher.digest()),
|
|
738
869
|
byteLength: total,
|
|
739
870
|
entryDigestFull: fullHasher.digest(),
|
|
740
871
|
...(strippedHasher !== undefined ? { entryDigestStripped: strippedHasher.digest() } : {}),
|
|
741
|
-
...(
|
|
872
|
+
...(keep ? { buffered: concatBytes(buffered, total) } : {}),
|
|
873
|
+
...(exportTooLarge !== undefined ? { exportTooLarge } : {}),
|
|
742
874
|
};
|
|
743
875
|
}
|
|
744
876
|
|
|
877
|
+
/** The export format an entry's opening bytes declare ({"format": "bitgraph-export/..."), or undefined. */
|
|
878
|
+
function exportOpening(head: Uint8Array): string | undefined {
|
|
879
|
+
const start = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
|
|
880
|
+
const match = EXPORT_OPENING.exec(Buffer.from(head.subarray(start)).toString("latin1"));
|
|
881
|
+
return match?.[1];
|
|
882
|
+
}
|
|
883
|
+
|
|
745
884
|
function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
|
|
746
885
|
const json = hashed.buffered === undefined ? undefined : tryParseJsonObject(hashed.buffered);
|
|
747
886
|
return {
|
|
@@ -753,6 +892,7 @@ function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
|
|
|
753
892
|
? { entryDigestStripped: hashed.entryDigestStripped }
|
|
754
893
|
: {}),
|
|
755
894
|
...(json !== undefined ? { json } : {}),
|
|
895
|
+
...(hashed.exportTooLarge !== undefined ? { exportTooLarge: hashed.exportTooLarge } : {}),
|
|
756
896
|
};
|
|
757
897
|
}
|
|
758
898
|
|
|
@@ -873,6 +1013,55 @@ function isProofShaped(value: unknown): boolean {
|
|
|
873
1013
|
);
|
|
874
1014
|
}
|
|
875
1015
|
|
|
1016
|
+
// ---------------------------------------------------------------------------
|
|
1017
|
+
// Export classification (bitgraph-export/1)
|
|
1018
|
+
// ---------------------------------------------------------------------------
|
|
1019
|
+
|
|
1020
|
+
/** The format string of an export-shaped value ("bitgraph-export/..."), or undefined. */
|
|
1021
|
+
function exportFormatOf(value: unknown): string | undefined {
|
|
1022
|
+
if (!isPlainObject(value)) return undefined;
|
|
1023
|
+
const format = value["format"];
|
|
1024
|
+
return typeof format === "string" && format.startsWith(EXPORT_FORMAT_PREFIX) ? format : undefined;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Classify one export-shaped file. Structure is decided by bitgraph-verify's
|
|
1029
|
+
* parseExport, so the audit and the verifier agree on what an export is. A
|
|
1030
|
+
* file that declares an export format but is not one is recorded with a
|
|
1031
|
+
* finding and is never an artifact.
|
|
1032
|
+
*/
|
|
1033
|
+
function classifyExport(
|
|
1034
|
+
entry: FinalEntry,
|
|
1035
|
+
parsed: Record<string, unknown>,
|
|
1036
|
+
format: string,
|
|
1037
|
+
findings: AuditFinding[]
|
|
1038
|
+
): ExportFile {
|
|
1039
|
+
const base = { path: entry.path, fileSha256Hex: entry.sha256Hex, format, json: parsed };
|
|
1040
|
+
if (format !== EXPORT_FORMAT) {
|
|
1041
|
+
findings.push({
|
|
1042
|
+
code: "export-unsupported-format",
|
|
1043
|
+
path: entry.path,
|
|
1044
|
+
message: `export-shaped file not checked: format "${format}" is not "${EXPORT_FORMAT}"`,
|
|
1045
|
+
details: { format },
|
|
1046
|
+
});
|
|
1047
|
+
return { ...base, status: "unsupported-format" };
|
|
1048
|
+
}
|
|
1049
|
+
if (parseExport(parsed) === null) {
|
|
1050
|
+
const tree = parsed["tree"];
|
|
1051
|
+
const missing = !isPlainObject(parsed["proof"])
|
|
1052
|
+
? "a proof object"
|
|
1053
|
+
: "tree.rootDocument (a string)";
|
|
1054
|
+
findings.push({
|
|
1055
|
+
code: "export-malformed",
|
|
1056
|
+
path: entry.path,
|
|
1057
|
+
message: `declares ${EXPORT_FORMAT} but lacks ${missing}; not checked`,
|
|
1058
|
+
details: { hasProof: isPlainObject(parsed["proof"]), hasTree: isPlainObject(tree) },
|
|
1059
|
+
});
|
|
1060
|
+
return { ...base, status: "malformed" };
|
|
1061
|
+
}
|
|
1062
|
+
return { ...base, status: "ok" };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
876
1065
|
type DuplicateKind = "new" | "exact-duplicate" | "semantic-duplicate";
|
|
877
1066
|
|
|
878
1067
|
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,
|
|
@@ -127,6 +128,8 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
|
|
|
127
128
|
segments: result.temporal.segments,
|
|
128
129
|
verifiedAnchorProofHashes: result.temporal.verifiedAnchorProofHashes,
|
|
129
130
|
unverifiedAnchorProofHashes: result.temporal.unverifiedAnchorProofHashes,
|
|
131
|
+
...(result.temporal.signedFloors !== undefined ? { signedFloors: result.temporal.signedFloors } : {}),
|
|
132
|
+
...(result.temporal.floorProblems !== undefined ? { floorProblems: result.temporal.floorProblems } : {}),
|
|
130
133
|
},
|
|
131
134
|
summary,
|
|
132
135
|
};
|
|
@@ -286,11 +289,15 @@ function buildSummary(
|
|
|
286
289
|
if (proof.measurement !== undefined) measurements.add(proof.measurement);
|
|
287
290
|
}
|
|
288
291
|
|
|
292
|
+
const floors = result.temporal.signedFloors ?? [];
|
|
293
|
+
const floorProblems = result.temporal.floorProblems ?? [];
|
|
289
294
|
const segmentCount = (status: TemporalSegmentStatus): number =>
|
|
290
295
|
result.temporal.segments.filter((s) => s.status === status).length;
|
|
291
296
|
|
|
292
297
|
const partitionsIntact = partitions.filter((p) => p.intact).length;
|
|
293
298
|
const exit = computeExitFlags(result);
|
|
299
|
+
const exportChecks = result.exports?.checks ?? [];
|
|
300
|
+
const checkedExports = exportChecks.filter((e) => e.status === "checked");
|
|
294
301
|
|
|
295
302
|
return {
|
|
296
303
|
proofsObserved: result.ingest.counts.observed,
|
|
@@ -330,7 +337,30 @@ function buildSummary(
|
|
|
330
337
|
segmentsLowerBounded: segmentCount("lower-bounded"),
|
|
331
338
|
segmentsUpperBounded: segmentCount("upper-bounded"),
|
|
332
339
|
segmentsUnanchored: segmentCount("ordered-but-unanchored"),
|
|
340
|
+
...(floors.length > 0 || floorProblems.length > 0
|
|
341
|
+
? {
|
|
342
|
+
baseFloors: {
|
|
343
|
+
signed: floors.length,
|
|
344
|
+
bounding: floors.filter((f) => f.bound === "not-before").length,
|
|
345
|
+
headersChecked: floors.filter((f) => f.header === "checked").length,
|
|
346
|
+
withheld: floors.filter((f) => f.bound === "withheld").length,
|
|
347
|
+
problems: floorProblems.length,
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
333
351
|
},
|
|
352
|
+
...(exportChecks.length > 0
|
|
353
|
+
? {
|
|
354
|
+
exports: {
|
|
355
|
+
files: exportChecks.length,
|
|
356
|
+
verdictTrue: checkedExports.filter((e) => e.verdict === "TRUE").length,
|
|
357
|
+
verdictFalse: checkedExports.filter((e) => e.verdict === "FALSE").length,
|
|
358
|
+
verdictUndetermined: checkedExports.filter((e) => e.verdict === "UNDETERMINED").length,
|
|
359
|
+
rejected: exportChecks.length - checkedExports.length,
|
|
360
|
+
coveredFilesChecked: checkedExports.reduce((n, e) => n + e.runs.filter((r) => r.file !== null).length, 0),
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
: {}),
|
|
334
364
|
exit,
|
|
335
365
|
};
|
|
336
366
|
}
|