@mikeargento/bitgraph-audit 0.1.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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/dist/anchors.d.ts +31 -0
  4. package/dist/anchors.d.ts.map +1 -0
  5. package/dist/anchors.js +116 -0
  6. package/dist/anchors.js.map +1 -0
  7. package/dist/anomalies.d.ts +42 -0
  8. package/dist/anomalies.d.ts.map +1 -0
  9. package/dist/anomalies.js +642 -0
  10. package/dist/anomalies.js.map +1 -0
  11. package/dist/attestation.d.ts +20 -0
  12. package/dist/attestation.d.ts.map +1 -0
  13. package/dist/attestation.js +795 -0
  14. package/dist/attestation.js.map +1 -0
  15. package/dist/audit.d.ts +19 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +116 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/authority.d.ts +37 -0
  20. package/dist/authority.d.ts.map +1 -0
  21. package/dist/authority.js +126 -0
  22. package/dist/authority.js.map +1 -0
  23. package/dist/aws-nitro-root-ca.d.ts +24 -0
  24. package/dist/aws-nitro-root-ca.d.ts.map +1 -0
  25. package/dist/aws-nitro-root-ca.js +44 -0
  26. package/dist/aws-nitro-root-ca.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +257 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/contents-hash.d.ts +30 -0
  32. package/dist/contents-hash.d.ts.map +1 -0
  33. package/dist/contents-hash.js +73 -0
  34. package/dist/contents-hash.js.map +1 -0
  35. package/dist/export.d.ts +64 -0
  36. package/dist/export.d.ts.map +1 -0
  37. package/dist/export.js +192 -0
  38. package/dist/export.js.map +1 -0
  39. package/dist/index.d.ts +31 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +17 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ingest.d.ts +36 -0
  44. package/dist/ingest.d.ts.map +1 -0
  45. package/dist/ingest.js +828 -0
  46. package/dist/ingest.js.map +1 -0
  47. package/dist/reconstruct.d.ts +40 -0
  48. package/dist/reconstruct.d.ts.map +1 -0
  49. package/dist/reconstruct.js +400 -0
  50. package/dist/reconstruct.js.map +1 -0
  51. package/dist/report-json.d.ts +3 -0
  52. package/dist/report-json.d.ts.map +1 -0
  53. package/dist/report-json.js +290 -0
  54. package/dist/report-json.js.map +1 -0
  55. package/dist/report-md.d.ts +3 -0
  56. package/dist/report-md.d.ts.map +1 -0
  57. package/dist/report-md.js +853 -0
  58. package/dist/report-md.js.map +1 -0
  59. package/dist/rlp.d.ts +37 -0
  60. package/dist/rlp.d.ts.map +1 -0
  61. package/dist/rlp.js +120 -0
  62. package/dist/rlp.js.map +1 -0
  63. package/dist/tar.d.ts +47 -0
  64. package/dist/tar.d.ts.map +1 -0
  65. package/dist/tar.js +483 -0
  66. package/dist/tar.js.map +1 -0
  67. package/dist/temporal.d.ts +55 -0
  68. package/dist/temporal.d.ts.map +1 -0
  69. package/dist/temporal.js +461 -0
  70. package/dist/temporal.js.map +1 -0
  71. package/dist/types.d.ts +1389 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +3 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/validity.d.ts +21 -0
  76. package/dist/validity.d.ts.map +1 -0
  77. package/dist/validity.js +86 -0
  78. package/dist/validity.js.map +1 -0
  79. package/dist/verify-tiers.d.ts +11 -0
  80. package/dist/verify-tiers.d.ts.map +1 -0
  81. package/dist/verify-tiers.js +120 -0
  82. package/dist/verify-tiers.js.map +1 -0
  83. package/dist/witness.d.ts +25 -0
  84. package/dist/witness.d.ts.map +1 -0
  85. package/dist/witness.js +308 -0
  86. package/dist/witness.js.map +1 -0
  87. package/package.json +42 -0
  88. package/src/anchors.ts +168 -0
  89. package/src/anomalies.ts +773 -0
  90. package/src/attestation.ts +878 -0
  91. package/src/audit.ts +136 -0
  92. package/src/authority.ts +165 -0
  93. package/src/aws-nitro-root-ca.ts +47 -0
  94. package/src/cli.ts +284 -0
  95. package/src/contents-hash.ts +95 -0
  96. package/src/export.ts +278 -0
  97. package/src/index.ts +122 -0
  98. package/src/ingest.ts +967 -0
  99. package/src/reconstruct.ts +491 -0
  100. package/src/report-json.ts +331 -0
  101. package/src/report-md.ts +1067 -0
  102. package/src/rlp.ts +142 -0
  103. package/src/tar.ts +587 -0
  104. package/src/temporal.ts +629 -0
  105. package/src/types.ts +1528 -0
  106. package/src/validity.ts +90 -0
  107. package/src/verify-tiers.ts +137 -0
  108. package/src/witness.ts +407 -0
package/dist/ingest.js ADDED
@@ -0,0 +1,828 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+ /**
3
+ * bitgraph-audit ingest
4
+ *
5
+ * Streams bundle entries per docs/BUNDLE-FORMAT.md from a directory, a
6
+ * .tar, or a .tar.gz/.tgz container, classifies every entry, and builds
7
+ * the observed-proof, artifact, witness, and manifest indexes that the
8
+ * verification and reconstruction stages consume.
9
+ *
10
+ * Discovery is by schema shape, never by filename (the single exception is
11
+ * the reserved root manifest.json). The version policy is enforced first:
12
+ * a proof-shaped file whose version is not exactly "bitgraph/1" is rejected
13
+ * with the stable code "unsupported-version", counted, listed, and excluded
14
+ * from verification, chain reconstruction, and anomaly analysis.
15
+ *
16
+ * Memory: archives are never unpacked to disk and never loaded whole.
17
+ * Every entry is hashed incrementally as it streams. Only small JSON
18
+ * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing;
19
+ * artifact bytes are hashed and dropped. What ingest retains scales with
20
+ * the number of entries and the total size of the proof JSONs, not with
21
+ * artifact payload sizes.
22
+ *
23
+ * Untrusted .tar / .tar.gz containers are additionally bounded by
24
+ * configurable caps (IngestLimits, defaults DEFAULT_INGEST_LIMITS): a total
25
+ * decompressed-byte ceiling, a maximum entry count, and a maximum size for
26
+ * metadata entries buffered whole (PAX headers, GNU long names). A crafted
27
+ * archive that would otherwise exhaust memory (a decompression bomb, an
28
+ * entry flood, or a header declaring an absurd size) aborts ingest with a
29
+ * clear error rather than allocating without bound. The defaults sit well
30
+ * above any legitimate bundle; directory bundles are already on disk and
31
+ * are not capped here.
32
+ */
33
+ import { createReadStream } from "node:fs";
34
+ import { open, readdir, readFile, stat } from "node:fs/promises";
35
+ import { join } from "node:path";
36
+ import { createGunzip } from "node:zlib";
37
+ import { sha256 } from "@noble/hashes/sha256";
38
+ import { computeProofHash } from "@mikeargento/bitgraph-verify";
39
+ import { readTarEntries } from "./tar.js";
40
+ import { combineEntryDigests } from "./contents-hash.js";
41
+ /**
42
+ * Largest file that is buffered for JSON candidacy. Larger entries are
43
+ * treated as artifact candidates only (they are hashed, never parsed).
44
+ * Real proofs, manifests, and witnesses are a few kilobytes; the cap only
45
+ * bounds transient memory for pathological inputs.
46
+ */
47
+ const MAX_CANDIDATE_JSON_BYTES = 8 * 1024 * 1024;
48
+ /**
49
+ * Default resource caps for untrusted .tar / .tar.gz ingest. Chosen well
50
+ * above any legitimate bundle (the 50k-proof benchmark bundle decompresses
51
+ * to a few hundred MiB across ~50k entries, with kilobyte-scale metadata),
52
+ * so real inputs never approach them while a crafted archive aborts.
53
+ */
54
+ export const DEFAULT_INGEST_LIMITS = {
55
+ maxTotalBytes: 2 * 1024 * 1024 * 1024, // 2 GiB decompressed
56
+ maxEntryCount: 1_000_000,
57
+ maxMetadataEntryBytes: 8 * 1024 * 1024, // 8 MiB per PAX/GNU metadata entry
58
+ };
59
+ const NUL = new Uint8Array([0]);
60
+ const MANIFEST_PATH = "manifest.json";
61
+ const BUNDLE_VERSION = "bitgraph-bundle/1";
62
+ const WITNESS_VERSION = "bitgraph-anchor-witness/1";
63
+ // ---------------------------------------------------------------------------
64
+ // Public API
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Ingest a bundle from a directory, .tar, or .tar.gz/.tgz path.
68
+ *
69
+ * Performs no verification; run verifyObservedProofs() on the result.
70
+ * Performs no network access of any kind.
71
+ *
72
+ * @param limits Resource caps for untrusted tar/tar.gz containers; defaults
73
+ * to DEFAULT_INGEST_LIMITS. Ignored for directory bundles.
74
+ * @throws {TypeError} when the path is not one of the accepted container
75
+ * forms. Corrupt archives throw Error from the tar or
76
+ * gzip layer, and an archive that exceeds a cap throws
77
+ * Error rather than exhausting memory.
78
+ */
79
+ export async function ingestBundle(bundlePath, limits = DEFAULT_INGEST_LIMITS) {
80
+ const container = await detectContainer(bundlePath);
81
+ const findings = [];
82
+ let skippedUnsafePaths = 0;
83
+ let entriesScanned = 0;
84
+ // ---------------------------------------------------------------------
85
+ // Scan: stream every entry once, hashing incrementally.
86
+ // ---------------------------------------------------------------------
87
+ const scanned = [];
88
+ const rootTracker = new CommonRootTracker();
89
+ if (container === "directory") {
90
+ for await (const file of walkDirectory(bundlePath)) {
91
+ entriesScanned++;
92
+ const stream = createReadStream(file.absPath);
93
+ const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream);
94
+ scanned.push(makeScannedEntry(file.relPath, hashed));
95
+ }
96
+ }
97
+ else {
98
+ const { stream, close } = openContainerByteStream(bundlePath, container);
99
+ try {
100
+ for await (const entry of readTarEntries(stream, limits)) {
101
+ if (entry.kind !== "file")
102
+ continue;
103
+ entriesScanned++;
104
+ const normalized = normalizeEntryPath(entry.path);
105
+ // Every file entry, safe or not, weighs on the bundle-root
106
+ // decision: a stripped root exists only when ALL entries in the
107
+ // archive sit under one common top-level directory.
108
+ rootTracker.observe(normalized.path);
109
+ if (normalized.unsafe) {
110
+ skippedUnsafePaths++;
111
+ findings.push({
112
+ code: "unsafe-path",
113
+ path: entry.path,
114
+ message: `entry skipped: ${normalized.reason}`,
115
+ });
116
+ continue;
117
+ }
118
+ const slash = normalized.path.indexOf("/");
119
+ const strippedVariant = slash === -1 ? undefined : normalized.path.slice(slash + 1);
120
+ const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body);
121
+ scanned.push(makeScannedEntry(normalized.path, hashed));
122
+ }
123
+ }
124
+ finally {
125
+ close();
126
+ }
127
+ }
128
+ // ---------------------------------------------------------------------
129
+ // Finalize paths: tar bundle-root normalization + last-wins duplicates.
130
+ // ---------------------------------------------------------------------
131
+ let strippedRootPrefix;
132
+ if (container !== "directory" && scanned.length > 0) {
133
+ strippedRootPrefix = rootTracker.commonRoot();
134
+ }
135
+ const finalByPath = new Map();
136
+ for (const entry of scanned) {
137
+ let finalPath = entry.rawPath;
138
+ let entryDigest = entry.entryDigestFull;
139
+ if (strippedRootPrefix !== undefined) {
140
+ finalPath = entry.rawPath.slice(strippedRootPrefix.length + 1);
141
+ entryDigest = entry.entryDigestStripped;
142
+ }
143
+ if (finalByPath.has(finalPath)) {
144
+ findings.push({
145
+ code: "duplicate-path",
146
+ path: finalPath,
147
+ message: "multiple archive entries normalized to the same path; the last entry wins",
148
+ });
149
+ }
150
+ finalByPath.set(finalPath, {
151
+ path: finalPath,
152
+ sha256Hex: entry.sha256Hex,
153
+ byteLength: entry.byteLength,
154
+ entryDigest,
155
+ ...(entry.json !== undefined ? { json: entry.json } : {}),
156
+ });
157
+ }
158
+ const finalEntries = Array.from(finalByPath.values());
159
+ // ---------------------------------------------------------------------
160
+ // Deterministic contents hash over everything except the root manifest.
161
+ // ---------------------------------------------------------------------
162
+ const computedContentsHashB64 = combineEntryDigests(finalEntries
163
+ .filter((entry) => entry.path !== MANIFEST_PATH)
164
+ .map((entry) => ({ path: entry.path, entryDigest: entry.entryDigest })));
165
+ // ---------------------------------------------------------------------
166
+ // Classify.
167
+ // ---------------------------------------------------------------------
168
+ const proofs = [];
169
+ const proofsByHash = new Map();
170
+ const unsupportedVersions = [];
171
+ const witnesses = [];
172
+ const artifactsByHex = new Map();
173
+ let manifest;
174
+ let proofFiles = 0;
175
+ let exactDuplicates = 0;
176
+ let semanticDuplicates = 0;
177
+ for (const entry of finalEntries) {
178
+ if (entry.path === MANIFEST_PATH) {
179
+ manifest = classifyManifest(entry, computedContentsHashB64, findings);
180
+ continue;
181
+ }
182
+ const parsed = entry.json;
183
+ if (parsed !== undefined && isProofShaped(parsed)) {
184
+ const version = parsed["version"];
185
+ if (version === "bitgraph/1") {
186
+ proofFiles++;
187
+ const outcome = recordMemberProof(entry, parsed, proofsByHash, proofs, findings);
188
+ if (outcome === "exact-duplicate")
189
+ exactDuplicates++;
190
+ if (outcome === "semantic-duplicate")
191
+ semanticDuplicates++;
192
+ continue;
193
+ }
194
+ // Version policy: rejected, counted, listed, excluded. The file
195
+ // remains a candidate artifact per spec section 6.3 (it is not a
196
+ // member proof).
197
+ unsupportedVersions.push({
198
+ code: "unsupported-version",
199
+ path: entry.path,
200
+ version,
201
+ fileSha256Hex: entry.sha256Hex,
202
+ });
203
+ findings.push({
204
+ code: "unsupported-version",
205
+ path: entry.path,
206
+ message: `proof-shaped file rejected: version "${version}" is not "bitgraph/1"`,
207
+ details: { version },
208
+ });
209
+ indexArtifact(entry, artifactsByHex);
210
+ continue;
211
+ }
212
+ if (parsed !== undefined &&
213
+ parsed["version"] === WITNESS_VERSION) {
214
+ witnesses.push({
215
+ path: entry.path,
216
+ fileSha256Hex: entry.sha256Hex,
217
+ witness: parsed,
218
+ });
219
+ continue;
220
+ }
221
+ indexArtifact(entry, artifactsByHex);
222
+ }
223
+ // ---------------------------------------------------------------------
224
+ // Content-addressed artifact matching.
225
+ // ---------------------------------------------------------------------
226
+ for (const proof of proofs) {
227
+ const digestHex = strictDigestToHex(proof.proof["artifact"]);
228
+ if (digestHex === null)
229
+ continue;
230
+ const artifact = artifactsByHex.get(digestHex);
231
+ if (artifact !== undefined) {
232
+ artifact.matchedProofHashes.push(proof.proofHash);
233
+ }
234
+ }
235
+ const artifacts = Array.from(artifactsByHex.values());
236
+ const counts = {
237
+ observed: proofs.length,
238
+ proofFiles,
239
+ exactDuplicates,
240
+ semanticDuplicates,
241
+ unsupportedVersion: unsupportedVersions.length,
242
+ artifacts: artifacts.length,
243
+ witnesses: witnesses.length,
244
+ skippedUnsafePaths,
245
+ };
246
+ return {
247
+ bundlePath,
248
+ container,
249
+ ...(strippedRootPrefix !== undefined ? { strippedRootPrefix } : {}),
250
+ entriesScanned,
251
+ proofs,
252
+ unsupportedVersions,
253
+ artifacts,
254
+ witnesses,
255
+ ...(manifest !== undefined ? { manifest } : {}),
256
+ computedContentsHashB64,
257
+ findings,
258
+ counts,
259
+ };
260
+ }
261
+ /**
262
+ * Stream the bytes of every artifact that content-matched at least one
263
+ * proof, one artifact at a time, in deterministic order (first-observation
264
+ * order for directories, archive order for tars). Peak memory is bounded
265
+ * by the largest matched artifact, because the canonical verify() API
266
+ * takes whole byte arrays.
267
+ *
268
+ * Bytes are re-read from the container and re-hashed before being yielded;
269
+ * an entry whose bytes no longer hash to the recorded digest (for example
270
+ * a file modified on disk between passes) is skipped, never yielded under
271
+ * the stale digest.
272
+ */
273
+ export async function* streamMatchedArtifacts(ingest) {
274
+ const needed = ingest.artifacts.filter((a) => a.matchedProofHashes.length > 0);
275
+ if (needed.length === 0)
276
+ return;
277
+ if (ingest.container === "directory") {
278
+ for (const artifact of needed) {
279
+ for (const relPath of artifact.paths) {
280
+ let bytes;
281
+ try {
282
+ bytes = new Uint8Array(await readFile(join(ingest.bundlePath, ...relPath.split("/"))));
283
+ }
284
+ catch {
285
+ continue;
286
+ }
287
+ if (toHex(sha256(bytes)) !== artifact.sha256Hex)
288
+ continue;
289
+ yield { sha256Hex: artifact.sha256Hex, path: relPath, bytes };
290
+ break;
291
+ }
292
+ }
293
+ return;
294
+ }
295
+ const neededByPath = new Map();
296
+ for (const artifact of needed) {
297
+ for (const path of artifact.paths)
298
+ neededByPath.set(path, artifact);
299
+ }
300
+ const satisfied = new Set();
301
+ const { stream, close } = openContainerByteStream(ingest.bundlePath, ingest.container);
302
+ try {
303
+ for await (const entry of readTarEntries(stream, DEFAULT_INGEST_LIMITS)) {
304
+ if (satisfied.size === needed.length)
305
+ break;
306
+ if (entry.kind !== "file")
307
+ continue;
308
+ const normalized = normalizeEntryPath(entry.path);
309
+ if (normalized.unsafe)
310
+ continue;
311
+ let finalPath = normalized.path;
312
+ if (ingest.strippedRootPrefix !== undefined) {
313
+ if (!finalPath.startsWith(`${ingest.strippedRootPrefix}/`))
314
+ continue;
315
+ finalPath = finalPath.slice(ingest.strippedRootPrefix.length + 1);
316
+ }
317
+ const artifact = neededByPath.get(finalPath);
318
+ if (artifact === undefined || satisfied.has(artifact.sha256Hex))
319
+ continue;
320
+ const chunks = [];
321
+ let total = 0;
322
+ for await (const chunk of entry.body) {
323
+ chunks.push(chunk.slice());
324
+ total += chunk.length;
325
+ }
326
+ const bytes = concatBytes(chunks, total);
327
+ if (toHex(sha256(bytes)) !== artifact.sha256Hex)
328
+ continue;
329
+ satisfied.add(artifact.sha256Hex);
330
+ yield { sha256Hex: artifact.sha256Hex, path: finalPath, bytes };
331
+ }
332
+ }
333
+ finally {
334
+ close();
335
+ }
336
+ }
337
+ // ---------------------------------------------------------------------------
338
+ // Container detection and opening
339
+ // ---------------------------------------------------------------------------
340
+ async function detectContainer(bundlePath) {
341
+ const info = await stat(bundlePath);
342
+ if (info.isDirectory())
343
+ return "directory";
344
+ const lower = bundlePath.toLowerCase();
345
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz"))
346
+ return "tar-gz";
347
+ if (lower.endsWith(".tar"))
348
+ return "tar";
349
+ // Content sniffing: gzip magic bytes, then tar ustar magic at offset 257.
350
+ const handle = await open(bundlePath, "r");
351
+ try {
352
+ const head = Buffer.alloc(262);
353
+ const { bytesRead } = await handle.read(head, 0, 262, 0);
354
+ if (bytesRead >= 2 && head[0] === 0x1f && head[1] === 0x8b)
355
+ return "tar-gz";
356
+ if (bytesRead >= 262 && head.subarray(257, 262).toString("latin1") === "ustar")
357
+ return "tar";
358
+ }
359
+ finally {
360
+ await handle.close();
361
+ }
362
+ throw new TypeError(`bitgraph-audit: "${bundlePath}" is not an accepted bundle container ` +
363
+ `(expected a directory, .tar, .tar.gz, or .tgz)`);
364
+ }
365
+ function openContainerByteStream(bundlePath, container) {
366
+ const file = createReadStream(bundlePath);
367
+ if (container === "tar-gz") {
368
+ const gunzip = createGunzip();
369
+ file.pipe(gunzip);
370
+ // Surface file-read errors through the gunzip stream so async
371
+ // iteration rejects instead of hanging.
372
+ file.on("error", (err) => gunzip.destroy(err));
373
+ return {
374
+ stream: gunzip,
375
+ close: () => {
376
+ gunzip.destroy();
377
+ file.destroy();
378
+ },
379
+ };
380
+ }
381
+ return {
382
+ stream: file,
383
+ close: () => file.destroy(),
384
+ };
385
+ }
386
+ // ---------------------------------------------------------------------------
387
+ // Directory walking
388
+ // ---------------------------------------------------------------------------
389
+ async function* walkDirectory(root) {
390
+ yield* walkDir(root, "");
391
+ }
392
+ async function* walkDir(root, relDir) {
393
+ const absDir = relDir === "" ? root : join(root, ...relDir.split("/"));
394
+ const entries = await readdir(absDir, { withFileTypes: true });
395
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
396
+ for (const entry of entries) {
397
+ const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
398
+ if (entry.isDirectory()) {
399
+ yield* walkDir(root, rel);
400
+ }
401
+ else if (entry.isFile()) {
402
+ // Symlinks, sockets, and other non-regular files are not bundle
403
+ // entries and are never followed.
404
+ yield { relPath: rel, absPath: join(root, ...rel.split("/")) };
405
+ }
406
+ }
407
+ }
408
+ /**
409
+ * Stream an entry's content once, computing in parallel:
410
+ * - the plain content SHA-256 (file identity, artifact addressing),
411
+ * - the contents-hash entry digest SHA-256(path || 0x00 || content),
412
+ * - for tar entries below a potentially stripped root, the same entry
413
+ * digest under the stripped path variant,
414
+ * while buffering the content only if it stays within the JSON candidacy
415
+ * cap.
416
+ */
417
+ async function hashEntryStream(path, strippedPathVariant, declaredSize, chunks) {
418
+ const encoder = new TextEncoder();
419
+ const contentHasher = sha256.create();
420
+ const fullHasher = sha256.create();
421
+ fullHasher.update(encoder.encode(path));
422
+ fullHasher.update(NUL);
423
+ const strippedHasher = strippedPathVariant === undefined ? undefined : sha256.create();
424
+ if (strippedHasher !== undefined) {
425
+ strippedHasher.update(encoder.encode(strippedPathVariant));
426
+ strippedHasher.update(NUL);
427
+ }
428
+ let buffering = declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES;
429
+ const buffered = [];
430
+ let total = 0;
431
+ for await (const chunk of chunks) {
432
+ contentHasher.update(chunk);
433
+ fullHasher.update(chunk);
434
+ strippedHasher?.update(chunk);
435
+ total += chunk.length;
436
+ if (buffering) {
437
+ if (total > MAX_CANDIDATE_JSON_BYTES) {
438
+ buffered.length = 0;
439
+ buffering = false;
440
+ }
441
+ else {
442
+ // Copy: tar body chunks are views into the container's stream
443
+ // buffers and are not stable after iteration continues.
444
+ buffered.push(chunk.slice());
445
+ }
446
+ }
447
+ }
448
+ return {
449
+ sha256Hex: toHex(contentHasher.digest()),
450
+ byteLength: total,
451
+ entryDigestFull: fullHasher.digest(),
452
+ ...(strippedHasher !== undefined ? { entryDigestStripped: strippedHasher.digest() } : {}),
453
+ ...(buffering ? { buffered: concatBytes(buffered, total) } : {}),
454
+ };
455
+ }
456
+ function makeScannedEntry(rawPath, hashed) {
457
+ const json = hashed.buffered === undefined ? undefined : tryParseJsonObject(hashed.buffered);
458
+ return {
459
+ rawPath,
460
+ sha256Hex: hashed.sha256Hex,
461
+ byteLength: hashed.byteLength,
462
+ entryDigestFull: hashed.entryDigestFull,
463
+ ...(hashed.entryDigestStripped !== undefined
464
+ ? { entryDigestStripped: hashed.entryDigestStripped }
465
+ : {}),
466
+ ...(json !== undefined ? { json } : {}),
467
+ };
468
+ }
469
+ /**
470
+ * Parse an entry as UTF-8 JSON with a single top-level object. Returns
471
+ * undefined for anything else (invalid UTF-8, non-JSON, arrays,
472
+ * primitives). A single leading UTF-8 BOM is tolerated for parsing; hashes
473
+ * always cover the raw bytes including any BOM.
474
+ */
475
+ function tryParseJsonObject(bytes) {
476
+ let body = bytes;
477
+ if (body.length >= 3 && body[0] === 0xef && body[1] === 0xbb && body[2] === 0xbf) {
478
+ body = body.subarray(3);
479
+ }
480
+ let text;
481
+ try {
482
+ text = new TextDecoder("utf-8", { fatal: true }).decode(body);
483
+ }
484
+ catch {
485
+ return undefined;
486
+ }
487
+ let parsed;
488
+ try {
489
+ parsed = JSON.parse(text);
490
+ }
491
+ catch {
492
+ return undefined;
493
+ }
494
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
495
+ return undefined;
496
+ }
497
+ return parsed;
498
+ }
499
+ // ---------------------------------------------------------------------------
500
+ // Path handling
501
+ // ---------------------------------------------------------------------------
502
+ function normalizeEntryPath(raw) {
503
+ if (raw.includes("\0")) {
504
+ return { path: raw, unsafe: true, reason: "path contains a NUL byte" };
505
+ }
506
+ const absolute = raw.startsWith("/");
507
+ const components = raw.split("/").filter((c) => c !== "" && c !== ".");
508
+ const normalized = components.join("/");
509
+ if (absolute) {
510
+ return { path: normalized, unsafe: true, reason: "absolute path" };
511
+ }
512
+ if (components.some((c) => c === "..")) {
513
+ return { path: normalized, unsafe: true, reason: "path escapes the bundle root" };
514
+ }
515
+ if (normalized === "") {
516
+ return { path: normalized, unsafe: true, reason: "empty path" };
517
+ }
518
+ return { path: normalized, unsafe: false };
519
+ }
520
+ /**
521
+ * Tar bundle-root normalization (spec section 4.1): if every entry sits
522
+ * under one common top-level directory, that directory is the bundle root.
523
+ * Applies if and only if all entries share the same first path component
524
+ * and every entry has at least two components. Every file entry counts,
525
+ * including skipped unsafe ones; an entry outside the candidate root
526
+ * rules stripping out.
527
+ */
528
+ class CommonRootTracker {
529
+ candidate;
530
+ ruledOut = false;
531
+ observe(normalizedPath) {
532
+ if (this.ruledOut)
533
+ return;
534
+ const slash = normalizedPath.indexOf("/");
535
+ if (slash === -1) {
536
+ this.ruledOut = true;
537
+ return;
538
+ }
539
+ const first = normalizedPath.slice(0, slash);
540
+ if (first === "..") {
541
+ this.ruledOut = true;
542
+ return;
543
+ }
544
+ if (this.candidate === undefined) {
545
+ this.candidate = first;
546
+ }
547
+ else if (this.candidate !== first) {
548
+ this.ruledOut = true;
549
+ }
550
+ }
551
+ commonRoot() {
552
+ return this.ruledOut ? undefined : this.candidate;
553
+ }
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Proof classification
557
+ // ---------------------------------------------------------------------------
558
+ function isPlainObject(value) {
559
+ return value !== null && typeof value === "object" && !Array.isArray(value);
560
+ }
561
+ /**
562
+ * Proof-shaped per spec section 6.1: a string version plus object-valued
563
+ * artifact, commit, and signer fields.
564
+ */
565
+ function isProofShaped(value) {
566
+ if (!isPlainObject(value))
567
+ return false;
568
+ return (typeof value["version"] === "string" &&
569
+ isPlainObject(value["artifact"]) &&
570
+ isPlainObject(value["commit"]) &&
571
+ isPlainObject(value["signer"]));
572
+ }
573
+ function recordMemberProof(entry, parsed, proofsByHash, proofs, findings) {
574
+ const proofHash = computeProofHash(parsed);
575
+ const source = {
576
+ path: entry.path,
577
+ fileSha256Hex: entry.sha256Hex,
578
+ byteLength: entry.byteLength,
579
+ };
580
+ const embeddedStatus = embeddedProofHashStatus(parsed, proofHash);
581
+ if (embeddedStatus === "mismatch") {
582
+ findings.push({
583
+ code: "proofhash-mismatch",
584
+ path: entry.path,
585
+ message: "embedded proofHash does not match the computed canonical proof hash",
586
+ details: {
587
+ embedded: parsed["proofHash"],
588
+ computed: proofHash,
589
+ },
590
+ });
591
+ }
592
+ const existing = proofsByHash.get(proofHash);
593
+ if (existing !== undefined) {
594
+ const exact = existing.sources.some((s) => s.fileSha256Hex === source.fileSha256Hex);
595
+ existing.sources.push(source);
596
+ if (embeddedStatus === "mismatch") {
597
+ existing.embeddedProofHash = "mismatch";
598
+ }
599
+ findings.push({
600
+ code: exact ? "exact-duplicate" : "semantic-duplicate",
601
+ path: entry.path,
602
+ message: exact
603
+ ? "byte-identical copy of an already observed proof"
604
+ : "different byte encoding of an already observed proof (same canonical identity)",
605
+ details: { proofHash, firstObservedAt: existing.sources[0].path },
606
+ });
607
+ return exact ? "exact-duplicate" : "semantic-duplicate";
608
+ }
609
+ const commit = isPlainObject(parsed["commit"]) ? parsed["commit"] : {};
610
+ const signer = isPlainObject(parsed["signer"]) ? parsed["signer"] : {};
611
+ const environment = isPlainObject(parsed["environment"]) ? parsed["environment"] : {};
612
+ const counter = asString(commit["counter"]);
613
+ const epochId = asString(commit["epochId"]);
614
+ const chainIdRaw = asString(commit["chainId"]);
615
+ const observed = {
616
+ proofHash,
617
+ proof: parsed,
618
+ sources: [source],
619
+ version: "bitgraph/1",
620
+ ...(counter !== undefined ? { counter } : {}),
621
+ ...(asString(commit["slotCounter"]) !== undefined
622
+ ? { slotCounter: asString(commit["slotCounter"]) }
623
+ : {}),
624
+ ...(asString(commit["prevB64"]) !== undefined
625
+ ? { prevB64: asString(commit["prevB64"]) }
626
+ : {}),
627
+ ...(epochId !== undefined ? { epochId } : {}),
628
+ chainId: chainIdRaw !== undefined && chainIdRaw.length > 0 ? chainIdRaw : "global",
629
+ ...(asString(signer["publicKeyB64"]) !== undefined
630
+ ? { publicKeyB64: asString(signer["publicKeyB64"]) }
631
+ : {}),
632
+ ...(asString(environment["measurement"]) !== undefined
633
+ ? { measurement: asString(environment["measurement"]) }
634
+ : {}),
635
+ ...(asString(environment["enforcement"]) !== undefined
636
+ ? { enforcement: asString(environment["enforcement"]) }
637
+ : {}),
638
+ hasSlotAllocation: isPlainObject(parsed["slotAllocation"]),
639
+ hasAttestation: isPlainObject(environment["attestation"]),
640
+ hasAgency: isPlainObject(parsed["agency"]),
641
+ hasEpochLink: isPlainObject(commit["epochLink"]),
642
+ embeddedProofHash: embeddedStatus,
643
+ chainless: counter === undefined && epochId === undefined,
644
+ };
645
+ proofsByHash.set(proofHash, observed);
646
+ proofs.push(observed);
647
+ return "new";
648
+ }
649
+ function embeddedProofHashStatus(parsed, computed) {
650
+ const embedded = parsed["proofHash"];
651
+ if (embedded === undefined)
652
+ return "absent";
653
+ return typeof embedded === "string" && embedded === computed ? "match" : "mismatch";
654
+ }
655
+ function asString(value) {
656
+ return typeof value === "string" ? value : undefined;
657
+ }
658
+ // ---------------------------------------------------------------------------
659
+ // Artifact indexing and matching
660
+ // ---------------------------------------------------------------------------
661
+ function indexArtifact(entry, artifactsByHex) {
662
+ const existing = artifactsByHex.get(entry.sha256Hex);
663
+ if (existing !== undefined) {
664
+ existing.paths.push(entry.path);
665
+ return;
666
+ }
667
+ artifactsByHex.set(entry.sha256Hex, {
668
+ sha256Hex: entry.sha256Hex,
669
+ sha256B64: Buffer.from(entry.sha256Hex, "hex").toString("base64"),
670
+ byteLength: entry.byteLength,
671
+ paths: [entry.path],
672
+ matchedProofHashes: [],
673
+ });
674
+ }
675
+ /**
676
+ * Strict base64 digest decoding per spec section 6.3: RFC 4648 section 4
677
+ * alphabet with the round-trip property, decoding to exactly 32 bytes.
678
+ * Returns the lowercase hex form, or null when the digest is unusable for
679
+ * matching (the proof's own verification records the precise failure).
680
+ */
681
+ function strictDigestToHex(artifact) {
682
+ if (!isPlainObject(artifact))
683
+ return null;
684
+ const digestB64 = artifact["digestB64"];
685
+ if (typeof digestB64 !== "string" || digestB64.length === 0)
686
+ return null;
687
+ const decoded = Buffer.from(digestB64, "base64");
688
+ if (decoded.toString("base64") !== digestB64)
689
+ return null;
690
+ if (decoded.length !== 32)
691
+ return null;
692
+ return decoded.toString("hex");
693
+ }
694
+ // ---------------------------------------------------------------------------
695
+ // Manifest classification
696
+ // ---------------------------------------------------------------------------
697
+ function classifyManifest(entry, computedContentsHashB64, findings) {
698
+ if (entry.json === undefined) {
699
+ findings.push({
700
+ code: "manifest-unparseable",
701
+ path: entry.path,
702
+ message: "root manifest.json did not parse as a single JSON object; proceeding as if no manifest were present",
703
+ });
704
+ return { path: entry.path, parsed: false, recognized: false, problems: [] };
705
+ }
706
+ const raw = entry.json;
707
+ const version = raw["version"];
708
+ if (version !== BUNDLE_VERSION) {
709
+ findings.push({
710
+ code: "manifest-unrecognized-version",
711
+ path: entry.path,
712
+ message: `manifest version ${JSON.stringify(version)} is not "${BUNDLE_VERSION}"; the manifest was not interpreted`,
713
+ ...(typeof version === "string" ? { details: { version } } : {}),
714
+ });
715
+ return {
716
+ path: entry.path,
717
+ parsed: true,
718
+ ...(typeof version === "string" ? { version } : {}),
719
+ recognized: false,
720
+ problems: [],
721
+ };
722
+ }
723
+ const problems = validateManifestFields(raw);
724
+ for (const problem of problems) {
725
+ findings.push({
726
+ code: "manifest-field-invalid",
727
+ path: entry.path,
728
+ message: problem,
729
+ });
730
+ }
731
+ const report = {
732
+ path: entry.path,
733
+ parsed: true,
734
+ version: BUNDLE_VERSION,
735
+ recognized: true,
736
+ manifest: raw,
737
+ problems,
738
+ };
739
+ const declared = raw["contentsHashB64"];
740
+ if (typeof declared === "string" && declared.length > 0) {
741
+ const match = declared === computedContentsHashB64;
742
+ report.contentsHash = {
743
+ declaredB64: declared,
744
+ computedB64: computedContentsHashB64,
745
+ match,
746
+ };
747
+ if (!match) {
748
+ findings.push({
749
+ code: "manifest-contents-hash-mismatch",
750
+ path: entry.path,
751
+ message: "manifest contentsHashB64 does not match the computed contents hash; " +
752
+ "the bundle held is not byte-for-byte the bundle the manifest describes. " +
753
+ "Advisory: this does not fail any proof.",
754
+ details: { declared, computed: computedContentsHashB64 },
755
+ });
756
+ }
757
+ }
758
+ return report;
759
+ }
760
+ /** Type checks per spec section 7.1. Unknown fields are tolerated. */
761
+ function validateManifestFields(m) {
762
+ const problems = [];
763
+ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
764
+ if (m["epochIds"] !== undefined && !isStringArray(m["epochIds"])) {
765
+ problems.push("manifest.epochIds must be an array of strings when present");
766
+ }
767
+ if (m["chainIds"] !== undefined && !isStringArray(m["chainIds"])) {
768
+ problems.push("manifest.chainIds must be an array of strings when present");
769
+ }
770
+ if (m["proofCount"] !== undefined &&
771
+ (typeof m["proofCount"] !== "number" || !Number.isInteger(m["proofCount"]) || m["proofCount"] < 0)) {
772
+ problems.push("manifest.proofCount must be a non-negative integer when present");
773
+ }
774
+ if (m["counterRanges"] !== undefined) {
775
+ const ok = Array.isArray(m["counterRanges"]) &&
776
+ m["counterRanges"].every((r) => isPlainObject(r) &&
777
+ typeof r["epochId"] === "string" &&
778
+ typeof r["chainId"] === "string" &&
779
+ typeof r["min"] === "string" &&
780
+ typeof r["max"] === "string");
781
+ if (!ok) {
782
+ problems.push("manifest.counterRanges must be an array of { epochId, chainId, min, max } string fields when present");
783
+ }
784
+ }
785
+ if (m["generatedAt"] !== undefined && typeof m["generatedAt"] !== "string") {
786
+ problems.push("manifest.generatedAt must be a string when present");
787
+ }
788
+ if (m["contentsHashB64"] !== undefined) {
789
+ const v = m["contentsHashB64"];
790
+ let ok = typeof v === "string" && v.length > 0;
791
+ if (ok) {
792
+ const decoded = Buffer.from(v, "base64");
793
+ ok = decoded.toString("base64") === v && decoded.length === 32;
794
+ }
795
+ if (!ok) {
796
+ problems.push("manifest.contentsHashB64 must be standard base64 of 32 bytes when present");
797
+ }
798
+ }
799
+ if (m["artifactsIncluded"] !== undefined && typeof m["artifactsIncluded"] !== "boolean") {
800
+ problems.push("manifest.artifactsIncluded must be a boolean when present");
801
+ }
802
+ if (m["openEpochs"] !== undefined) {
803
+ const ok = Array.isArray(m["openEpochs"]) &&
804
+ m["openEpochs"].every((e) => isPlainObject(e) &&
805
+ typeof e["epochId"] === "string" &&
806
+ typeof e["counterAtSnapshot"] === "string");
807
+ if (!ok) {
808
+ problems.push("manifest.openEpochs must be an array of { epochId, counterAtSnapshot } string fields when present");
809
+ }
810
+ }
811
+ return problems;
812
+ }
813
+ // ---------------------------------------------------------------------------
814
+ // Byte helpers
815
+ // ---------------------------------------------------------------------------
816
+ function toHex(bytes) {
817
+ return Buffer.from(bytes).toString("hex");
818
+ }
819
+ function concatBytes(chunks, total) {
820
+ const out = new Uint8Array(total);
821
+ let offset = 0;
822
+ for (const chunk of chunks) {
823
+ out.set(chunk, offset);
824
+ offset += chunk.length;
825
+ }
826
+ return out;
827
+ }
828
+ //# sourceMappingURL=ingest.js.map