@mikeargento/bitgraph-audit 0.1.2 → 0.2.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/dist/anomalies.d.ts.map +1 -1
- package/dist/anomalies.js +44 -6
- package/dist/anomalies.js.map +1 -1
- package/dist/audit.d.ts +29 -2
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +30 -15
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest.d.ts +25 -0
- package/dist/ingest.d.ts.map +1 -1
- package/dist/ingest.js +120 -0
- package/dist/ingest.js.map +1 -1
- package/dist/report-json.d.ts.map +1 -1
- package/dist/report-json.js +2 -0
- package/dist/report-json.js.map +1 -1
- package/dist/report-md.js +14 -0
- package/dist/report-md.js.map +1 -1
- package/dist/types.d.ts +49 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/anomalies.ts +46 -6
- package/src/audit.ts +45 -16
- package/src/index.ts +5 -2
- package/src/ingest.ts +159 -0
- package/src/report-json.ts +2 -0
- package/src/report-md.ts +17 -0
- package/src/types.ts +50 -1
package/src/anomalies.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import type {
|
|
36
36
|
AnomalyReport,
|
|
37
|
+
BoundaryEntryPoint,
|
|
37
38
|
ChainAnomaly,
|
|
38
39
|
ChainPartition,
|
|
39
40
|
DivergenceParty,
|
|
@@ -72,6 +73,7 @@ export async function classifyAnomalies(
|
|
|
72
73
|
): Promise<AnomalyReport> {
|
|
73
74
|
const anomalies: ChainAnomaly[] = [];
|
|
74
75
|
const divergences: DivergenceRecord[] = [];
|
|
76
|
+
const boundaryEntryPoints: BoundaryEntryPoint[] = [];
|
|
75
77
|
|
|
76
78
|
const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
|
|
77
79
|
// Predecessor links (prevB64, epochLink.prevProofHashB64) reference the CHAIN
|
|
@@ -89,7 +91,7 @@ export async function classifyAnomalies(
|
|
|
89
91
|
await analyzeCollisions(partition, members, "slot", anomalies, divergences);
|
|
90
92
|
await analyzeCrossKindPositionReuse(partition, members, anomalies, divergences);
|
|
91
93
|
await analyzePredecessorReuse(partition, members, byChainHash, anomalies, divergences);
|
|
92
|
-
analyzeChainBreaks(partition, members, byChainHash, partitionOf, anomalies);
|
|
94
|
+
analyzeChainBreaks(partition, members, byChainHash, partitionOf, anomalies, boundaryEntryPoints);
|
|
93
95
|
await analyzeMultipleGenesis(partition, members, anomalies, divergences);
|
|
94
96
|
analyzeSlotOrder(partition, members, anomalies);
|
|
95
97
|
}
|
|
@@ -99,7 +101,7 @@ export async function classifyAnomalies(
|
|
|
99
101
|
// happened upstream in reconstruct.ts against chain hashes.
|
|
100
102
|
await analyzeEpochLinks(reconstruction.epochRelationships.edges, byHash, anomalies, divergences);
|
|
101
103
|
|
|
102
|
-
return { anomalies, divergences };
|
|
104
|
+
return { anomalies, divergences, boundaryEntryPoints };
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
// ---------------------------------------------------------------------------
|
|
@@ -385,10 +387,20 @@ function analyzeChainBreaks(
|
|
|
385
387
|
members: ObservedProof[],
|
|
386
388
|
byChainHash: Map<string, ObservedProof>,
|
|
387
389
|
partitionOf: Map<string, PartitionKey>,
|
|
388
|
-
anomalies: ChainAnomaly[]
|
|
390
|
+
anomalies: ChainAnomaly[],
|
|
391
|
+
boundaryEntryPoints: BoundaryEntryPoint[]
|
|
389
392
|
): void {
|
|
390
393
|
// prevB64 resolves in-partition when it equals a member's CHAIN hash.
|
|
391
394
|
const memberChainHashes = new Set(members.map((m) => m.chainHash));
|
|
395
|
+
// Lowest observed commit counter: a dangling link AT the lowest position is
|
|
396
|
+
// the excerpt's frontier (an expected boundary); a dangling link ABOVE it is
|
|
397
|
+
// an interior hole (a real chain break, and the missing proof's positions
|
|
398
|
+
// also surface as unexplained-counter-positions).
|
|
399
|
+
let minCounter: bigint | undefined;
|
|
400
|
+
for (const m of members) {
|
|
401
|
+
const c = parseCounter(m.counter);
|
|
402
|
+
if (c !== undefined && (minCounter === undefined || c < minCounter)) minCounter = c;
|
|
403
|
+
}
|
|
392
404
|
for (const m of [...members].sort(byCounterThenHash)) {
|
|
393
405
|
if (m.prevB64 === undefined) continue;
|
|
394
406
|
if (memberChainHashes.has(m.prevB64)) continue; // resolved in-partition
|
|
@@ -433,14 +445,42 @@ function analyzeChainBreaks(
|
|
|
433
445
|
continue;
|
|
434
446
|
}
|
|
435
447
|
|
|
448
|
+
// A well-formed, in-partition prevB64 whose predecessor is absent splits
|
|
449
|
+
// into two cases by position:
|
|
450
|
+
//
|
|
451
|
+
// - FRONTIER (m is at the lowest observed counter): the excerpt simply
|
|
452
|
+
// starts here; its predecessor precedes the exported window. This is the
|
|
453
|
+
// EXPECTED boundary of any bounded bundle, not a defect. A validly
|
|
454
|
+
// signed, attested proof only exists by extending the chain (fail-closed
|
|
455
|
+
// construction), so its predecessor did exist; it is just not included.
|
|
456
|
+
// Recorded as an informational boundary entry point — never sets the
|
|
457
|
+
// exit code, never marks the partition non-intact. A full-epoch export
|
|
458
|
+
// has none of these (its earliest proof is the genesis, no prevB64).
|
|
459
|
+
//
|
|
460
|
+
// - INTERIOR (a lower-counter member is present): a proof sits below m yet
|
|
461
|
+
// the link into m is broken — a genuine hole. Stays a chain-break-missing
|
|
462
|
+
// anomaly. The missing proof's own positions also surface as
|
|
463
|
+
// unexplained-counter-positions, so an interior hole always fails.
|
|
464
|
+
const c = parseCounter(m.counter);
|
|
465
|
+
const isFrontier = c === undefined || minCounter === undefined || c <= minCounter;
|
|
466
|
+
if (isFrontier) {
|
|
467
|
+
boundaryEntryPoints.push({
|
|
468
|
+
partition: partition.key,
|
|
469
|
+
proofHash: m.proofHash,
|
|
470
|
+
prevB64: m.prevB64,
|
|
471
|
+
});
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
436
475
|
anomalies.push({
|
|
437
476
|
code: "chain-break-missing",
|
|
438
477
|
partition: partition.key,
|
|
439
478
|
proofHashes: [m.proofHash],
|
|
440
479
|
message:
|
|
441
|
-
"commit.prevB64 references a predecessor proof that is absent from the supplied bundle
|
|
442
|
-
"
|
|
443
|
-
"
|
|
480
|
+
"commit.prevB64 references a predecessor proof that is absent from the supplied bundle, and a " +
|
|
481
|
+
"lower-positioned proof IS present, so this is an interior break (a hole), not the excerpt's " +
|
|
482
|
+
"starting boundary. The chain cannot be reconstructed across this link from the supplied " +
|
|
483
|
+
"evidence; this does not, by itself, establish that the predecessor never existed.",
|
|
444
484
|
details: { prevB64: m.prevB64 },
|
|
445
485
|
});
|
|
446
486
|
}
|
package/src/audit.ts
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* Zero network access, as everywhere in this package.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
21
20
|
import { ingestBundle } from "./ingest.js";
|
|
22
21
|
import { verifyObservedProofs } from "./verify-tiers.js";
|
|
23
22
|
import { reconstructChains } from "./reconstruct.js";
|
|
@@ -27,28 +26,49 @@ import { identifyAnchors } from "./anchors.js";
|
|
|
27
26
|
import { verifyAnchorWitnesses } from "./witness.js";
|
|
28
27
|
import { deriveTemporalBounds } from "./temporal.js";
|
|
29
28
|
import { validateAttestations } from "./attestation.js";
|
|
30
|
-
import type { AuditOptions, AuditResult, ExitFlags } from "./types.js";
|
|
29
|
+
import type { AuditOptions, AuditResult, ExitFlags, IngestResult } from "./types.js";
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The audit package's own version, as a source constant rather than a
|
|
33
|
+
* runtime package.json read. The read had two failure modes in bundled
|
|
34
|
+
* embedders (esbuild/webpack output, browsers): a foreign package.json one
|
|
35
|
+
* level up supplies the WRONG version into every report, or there is no
|
|
36
|
+
* package.json at all and the read throws mid-audit. A unit test asserts
|
|
37
|
+
* this equals package.json's version, so the constant cannot drift silently
|
|
38
|
+
* across releases.
|
|
39
|
+
*/
|
|
40
|
+
export const AUDIT_VERSION = "0.2.0";
|
|
33
41
|
|
|
34
|
-
/** The audit package's own version
|
|
42
|
+
/** The audit package's own version. */
|
|
35
43
|
export function auditToolVersion(): string {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
return AUDIT_VERSION;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Options for auditIngest(): the audit options plus the run stamp. */
|
|
48
|
+
export interface AuditIngestOptions extends AuditOptions {
|
|
49
|
+
/**
|
|
50
|
+
* The runMetadata.startedAt value. runAudit() stamps the wall clock here;
|
|
51
|
+
* an embedder that needs a fully deterministic result supplies its own
|
|
52
|
+
* (any string, for example ""). Defaults to the wall clock when omitted.
|
|
53
|
+
*/
|
|
54
|
+
startedAt?: string;
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
58
|
+
* The pure tail of the pipeline over an already-ingested bundle: every
|
|
59
|
+
* stage after ingest, in canonical order, with no filesystem access. This
|
|
60
|
+
* is how a browser or an embedder that used ingestEntries() gets the same
|
|
61
|
+
* AuditResult the CLI gets from a path. runAudit() is exactly
|
|
62
|
+
* ingestBundle() followed by this.
|
|
46
63
|
*/
|
|
47
|
-
export async function
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
export async function auditIngest(
|
|
65
|
+
ingest: IngestResult,
|
|
66
|
+
options?: AuditIngestOptions
|
|
67
|
+
): Promise<AuditResult> {
|
|
68
|
+
// The ONLY wall-clock read in the pipeline, and only when the caller did
|
|
69
|
+
// not supply the stamp. See AuditRunMetadata.
|
|
70
|
+
const startedAt = options?.startedAt ?? new Date().toISOString();
|
|
50
71
|
|
|
51
|
-
const ingest = await ingestBundle(bundlePath);
|
|
52
72
|
const verification = await verifyObservedProofs(
|
|
53
73
|
ingest,
|
|
54
74
|
options?.trustAnchors !== undefined ? { trustAnchors: options.trustAnchors } : undefined
|
|
@@ -72,7 +92,7 @@ export async function runAudit(bundlePath: string, options?: AuditOptions): Prom
|
|
|
72
92
|
runMetadata: {
|
|
73
93
|
toolVersion: auditToolVersion(),
|
|
74
94
|
startedAt,
|
|
75
|
-
bundlePath,
|
|
95
|
+
bundlePath: ingest.bundlePath,
|
|
76
96
|
container: ingest.container,
|
|
77
97
|
},
|
|
78
98
|
ingest,
|
|
@@ -87,6 +107,15 @@ export async function runAudit(bundlePath: string, options?: AuditOptions): Prom
|
|
|
87
107
|
};
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Run the complete audit pipeline over a bundle (directory, .tar, .tar.gz,
|
|
112
|
+
* or .tgz) and return everything every stage produced.
|
|
113
|
+
*/
|
|
114
|
+
export async function runAudit(bundlePath: string, options?: AuditOptions): Promise<AuditResult> {
|
|
115
|
+
const ingest = await ingestBundle(bundlePath);
|
|
116
|
+
return auditIngest(ingest, options);
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* Anchor witness verification-failure codes: every finding the witness
|
|
92
121
|
* stage emits is a failure (verifyAnchorWitnesses records findings only for
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export type {
|
|
|
45
45
|
ReconstructionResult,
|
|
46
46
|
ChainAnomaly,
|
|
47
47
|
UnexplainedPositionsDetail,
|
|
48
|
+
BoundaryEntryPoint,
|
|
48
49
|
DivergenceKind,
|
|
49
50
|
DivergenceParty,
|
|
50
51
|
DivergenceRecord,
|
|
@@ -84,7 +85,8 @@ export type {
|
|
|
84
85
|
AuditJsonReport,
|
|
85
86
|
} from "./types.js";
|
|
86
87
|
|
|
87
|
-
export { ingestBundle, streamMatchedArtifacts, DEFAULT_INGEST_LIMITS } from "./ingest.js";
|
|
88
|
+
export { ingestBundle, ingestEntries, streamMatchedArtifacts, DEFAULT_INGEST_LIMITS } from "./ingest.js";
|
|
89
|
+
export type { BundleEntrySource } from "./ingest.js";
|
|
88
90
|
|
|
89
91
|
export { verifyObservedProofs } from "./verify-tiers.js";
|
|
90
92
|
|
|
@@ -102,7 +104,8 @@ export { deriveTemporalBounds } from "./temporal.js";
|
|
|
102
104
|
|
|
103
105
|
export { validateAttestations, validateNitroAttestationDocument } from "./attestation.js";
|
|
104
106
|
|
|
105
|
-
export { runAudit, computeExitFlags, auditToolVersion } from "./audit.js";
|
|
107
|
+
export { runAudit, auditIngest, computeExitFlags, auditToolVersion, AUDIT_VERSION } from "./audit.js";
|
|
108
|
+
export type { AuditIngestOptions } from "./audit.js";
|
|
106
109
|
|
|
107
110
|
export { buildJsonReport } from "./report-json.js";
|
|
108
111
|
|
package/src/ingest.ts
CHANGED
|
@@ -160,6 +160,141 @@ export async function ingestBundle(
|
|
|
160
160
|
strippedRootPrefix = rootTracker.commonRoot();
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
return finalizeIngest({
|
|
164
|
+
bundlePath,
|
|
165
|
+
container,
|
|
166
|
+
scanned,
|
|
167
|
+
strippedRootPrefix,
|
|
168
|
+
findings,
|
|
169
|
+
entriesScanned,
|
|
170
|
+
skippedUnsafePaths,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One in-memory bundle entry for ingestEntries(): a bundle-root-relative
|
|
176
|
+
* path (forward slashes) and a way to open its bytes. `open` may be called
|
|
177
|
+
* more than once (once to scan and hash, again to re-read matched artifact
|
|
178
|
+
* bytes for full-tier verification), and it may return the bytes whole,
|
|
179
|
+
* a promise of them, or an async chunk stream, so a browser can hand over
|
|
180
|
+
* File objects without buffering every artifact up front.
|
|
181
|
+
*/
|
|
182
|
+
export interface BundleEntrySource {
|
|
183
|
+
path: string;
|
|
184
|
+
open: () => Uint8Array | Promise<Uint8Array> | AsyncIterable<Uint8Array>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Per-result registry of entry sources for in-memory ("memory" container)
|
|
189
|
+
* ingests, so streamMatchedArtifacts can re-read artifact bytes without the
|
|
190
|
+
* IngestResult carrying functions. Keyed by identity: a structurally cloned
|
|
191
|
+
* IngestResult loses its sources and yields no artifact bytes, which
|
|
192
|
+
* downgrades every proof to the integrity tier rather than crashing.
|
|
193
|
+
*/
|
|
194
|
+
const memorySources = new WeakMap<IngestResult, Map<string, BundleEntrySource>>();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Ingest a bundle from in-memory entries: the filesystem-free counterpart
|
|
198
|
+
* of ingestBundle(), for browsers and embedders that already hold the
|
|
199
|
+
* bytes. Same discovery, classification, hashing, and matching, so the
|
|
200
|
+
* result feeds the same verification and reconstruction stages. Entries
|
|
201
|
+
* are ordered by path before scanning so the result is deterministic
|
|
202
|
+
* regardless of the order the caller supplied them; paths are normalized
|
|
203
|
+
* exactly as tar entries are (unsafe paths are skipped and reported).
|
|
204
|
+
* Performs no verification and no network access.
|
|
205
|
+
*/
|
|
206
|
+
export async function ingestEntries(
|
|
207
|
+
entries: Iterable<BundleEntrySource>,
|
|
208
|
+
options?: { label?: string }
|
|
209
|
+
): Promise<IngestResult> {
|
|
210
|
+
const findings: AuditFinding[] = [];
|
|
211
|
+
let skippedUnsafePaths = 0;
|
|
212
|
+
let entriesScanned = 0;
|
|
213
|
+
const scanned: ScannedEntry[] = [];
|
|
214
|
+
const sources = new Map<string, BundleEntrySource>();
|
|
215
|
+
|
|
216
|
+
const ordered = Array.from(entries).sort((a, b) =>
|
|
217
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0
|
|
218
|
+
);
|
|
219
|
+
for (const entry of ordered) {
|
|
220
|
+
entriesScanned++;
|
|
221
|
+
const normalized = normalizeEntryPath(entry.path);
|
|
222
|
+
if (normalized.unsafe) {
|
|
223
|
+
skippedUnsafePaths++;
|
|
224
|
+
findings.push({
|
|
225
|
+
code: "unsafe-path",
|
|
226
|
+
path: entry.path,
|
|
227
|
+
message: `entry skipped: ${normalized.reason}`,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const hashed = await hashEntryStream(
|
|
232
|
+
normalized.path,
|
|
233
|
+
undefined,
|
|
234
|
+
undefined,
|
|
235
|
+
openAsChunks(entry.open())
|
|
236
|
+
);
|
|
237
|
+
scanned.push(makeScannedEntry(normalized.path, hashed));
|
|
238
|
+
sources.set(normalized.path, entry);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result = finalizeIngest({
|
|
242
|
+
bundlePath: options?.label ?? "",
|
|
243
|
+
container: "memory",
|
|
244
|
+
scanned,
|
|
245
|
+
strippedRootPrefix: undefined,
|
|
246
|
+
findings,
|
|
247
|
+
entriesScanned,
|
|
248
|
+
skippedUnsafePaths,
|
|
249
|
+
});
|
|
250
|
+
memorySources.set(result, sources);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Adapt every accepted `open()` return shape to a chunk stream. */
|
|
255
|
+
async function* openAsChunks(
|
|
256
|
+
opened: Uint8Array | Promise<Uint8Array> | AsyncIterable<Uint8Array>
|
|
257
|
+
): AsyncGenerator<Uint8Array, void, void> {
|
|
258
|
+
const value = await Promise.resolve(opened as Uint8Array | Promise<Uint8Array>);
|
|
259
|
+
if (value instanceof Uint8Array) {
|
|
260
|
+
yield value;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Not a Uint8Array and not a promise of one: an async iterable.
|
|
264
|
+
for await (const chunk of opened as AsyncIterable<Uint8Array>) yield chunk;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Read an in-memory entry whole for artifact re-reads. */
|
|
268
|
+
async function readSourceWhole(source: BundleEntrySource): Promise<Uint8Array> {
|
|
269
|
+
const chunks: Uint8Array[] = [];
|
|
270
|
+
let total = 0;
|
|
271
|
+
for await (const chunk of openAsChunks(source.open())) {
|
|
272
|
+
chunks.push(chunk);
|
|
273
|
+
total += chunk.length;
|
|
274
|
+
}
|
|
275
|
+
return concatBytes(chunks, total);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface FinalizeParams {
|
|
279
|
+
bundlePath: string;
|
|
280
|
+
container: ContainerKind;
|
|
281
|
+
scanned: ScannedEntry[];
|
|
282
|
+
strippedRootPrefix: string | undefined;
|
|
283
|
+
findings: AuditFinding[];
|
|
284
|
+
entriesScanned: number;
|
|
285
|
+
skippedUnsafePaths: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The container-independent tail of ingest: path finalization, the
|
|
290
|
+
* contents hash, classification by schema shape, and content-addressed
|
|
291
|
+
* artifact matching. Both ingestBundle() and ingestEntries() end here, so
|
|
292
|
+
* a directory, an archive, and an in-memory entry set that hold the same
|
|
293
|
+
* bytes at the same paths classify identically.
|
|
294
|
+
*/
|
|
295
|
+
function finalizeIngest(params: FinalizeParams): IngestResult {
|
|
296
|
+
const { bundlePath, container, scanned, strippedRootPrefix, findings, entriesScanned, skippedUnsafePaths } = params;
|
|
297
|
+
|
|
163
298
|
const finalByPath = new Map<string, FinalEntry>();
|
|
164
299
|
for (const entry of scanned) {
|
|
165
300
|
let finalPath = entry.rawPath;
|
|
@@ -318,6 +453,30 @@ export async function* streamMatchedArtifacts(
|
|
|
318
453
|
const needed = ingest.artifacts.filter((a) => a.matchedProofHashes.length > 0);
|
|
319
454
|
if (needed.length === 0) return;
|
|
320
455
|
|
|
456
|
+
if (ingest.container === "memory") {
|
|
457
|
+
// In-memory ingest: re-open through the registered sources. A result
|
|
458
|
+
// whose sources are unknown (cloned, or not from ingestEntries) yields
|
|
459
|
+
// nothing, so verification falls back to the integrity tier.
|
|
460
|
+
const sources = memorySources.get(ingest);
|
|
461
|
+
if (sources === undefined) return;
|
|
462
|
+
for (const artifact of needed) {
|
|
463
|
+
for (const relPath of artifact.paths) {
|
|
464
|
+
const source = sources.get(relPath);
|
|
465
|
+
if (source === undefined) continue;
|
|
466
|
+
let bytes: Uint8Array;
|
|
467
|
+
try {
|
|
468
|
+
bytes = await readSourceWhole(source);
|
|
469
|
+
} catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (toHex(sha256(bytes)) !== artifact.sha256Hex) continue;
|
|
473
|
+
yield { sha256Hex: artifact.sha256Hex, path: relPath, bytes };
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
321
480
|
if (ingest.container === "directory") {
|
|
322
481
|
for (const artifact of needed) {
|
|
323
482
|
for (const relPath of artifact.paths) {
|
package/src/report-json.ts
CHANGED
|
@@ -104,6 +104,7 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
|
|
|
104
104
|
},
|
|
105
105
|
anomalies,
|
|
106
106
|
divergences: result.anomalies.divergences,
|
|
107
|
+
boundaryEntryPoints: result.anomalies.boundaryEntryPoints,
|
|
107
108
|
authorities: {
|
|
108
109
|
groups: result.authorities.groups,
|
|
109
110
|
sharedSignersAcrossEpochs: result.authorities.sharedSignersAcrossEpochs,
|
|
@@ -307,6 +308,7 @@ function buildSummary(
|
|
|
307
308
|
epochsObserved: result.reconstruction.epochRelationships.epochs.length,
|
|
308
309
|
anomalyCountsByCode,
|
|
309
310
|
divergenceCount: result.anomalies.divergences.length,
|
|
311
|
+
boundaryEntryPoints: result.anomalies.boundaryEntryPoints.length,
|
|
310
312
|
authorityGroupCount: result.authorities.groups.length,
|
|
311
313
|
distinctSignerCount: signers.size,
|
|
312
314
|
distinctDeclaredMeasurementCount: measurements.size,
|
package/src/report-md.ts
CHANGED
|
@@ -252,6 +252,23 @@ function executiveSummary(
|
|
|
252
252
|
}
|
|
253
253
|
lines.push("");
|
|
254
254
|
|
|
255
|
+
// Boundary entry points (expected excerpt frontiers, not anomalies).
|
|
256
|
+
const boundaries = report.boundaryEntryPoints.length;
|
|
257
|
+
if (boundaries > 0) {
|
|
258
|
+
lines.push("### Bundle boundaries");
|
|
259
|
+
lines.push("");
|
|
260
|
+
lines.push(
|
|
261
|
+
`${withCommas(boundaries)} ${plural(boundaries, "proof is", "proofs are")} the earliest of a ` +
|
|
262
|
+
"reconstructed chain and reference a predecessor that precedes the exported window. This is the " +
|
|
263
|
+
"expected boundary of a bounded excerpt (a single-file proof bundle or a small batch), not a " +
|
|
264
|
+
"chain-integrity defect: a validly signed, attested proof only comes into existence by extending " +
|
|
265
|
+
"the chain, so its predecessor did exist and is simply not included here. Boundaries never set the " +
|
|
266
|
+
"exit code. A full-epoch export has none (its earliest proof is the epoch genesis, which has no " +
|
|
267
|
+
"predecessor link)."
|
|
268
|
+
);
|
|
269
|
+
lines.push("");
|
|
270
|
+
}
|
|
271
|
+
|
|
255
272
|
// Divergences.
|
|
256
273
|
lines.push("### Divergences");
|
|
257
274
|
lines.push("");
|
package/src/types.ts
CHANGED
|
@@ -385,7 +385,11 @@ export interface ManifestReport {
|
|
|
385
385
|
// ---------------------------------------------------------------------------
|
|
386
386
|
|
|
387
387
|
/** Accepted container forms per the bundle spec section 4. */
|
|
388
|
-
|
|
388
|
+
/**
|
|
389
|
+
* "memory" is an ingestEntries() result: entries supplied by the caller
|
|
390
|
+
* with no container on disk. bundlePath is then the caller's label (or "").
|
|
391
|
+
*/
|
|
392
|
+
export type ContainerKind = "directory" | "tar" | "tar-gz" | "memory";
|
|
389
393
|
|
|
390
394
|
export interface IngestCounts {
|
|
391
395
|
/** Unique observed proofs by canonical identity. */
|
|
@@ -758,6 +762,36 @@ export interface ChainAnomaly {
|
|
|
758
762
|
details?: Record<string, unknown>;
|
|
759
763
|
}
|
|
760
764
|
|
|
765
|
+
/**
|
|
766
|
+
* A chain link whose predecessor is absent from the supplied bundle: the
|
|
767
|
+
* earliest proof of a reconstructed run points to history that precedes the
|
|
768
|
+
* exported window.
|
|
769
|
+
*
|
|
770
|
+
* This is the EXPECTED boundary of any bounded excerpt (a single-file proof
|
|
771
|
+
* bundle, a small batch), NOT a chain-integrity defect: a validly signed,
|
|
772
|
+
* attested proof only comes into existence by extending the chain (the
|
|
773
|
+
* fail-closed construction property), so its own existence is evidence it
|
|
774
|
+
* occupied a real position; the predecessor is simply not included in this
|
|
775
|
+
* export. A full-epoch export has NO boundary entry points, its earliest
|
|
776
|
+
* proof is the epoch genesis, which carries no prevB64 at all. An INTERIOR
|
|
777
|
+
* hole never masquerades as a boundary: a missing mid-chain proof both
|
|
778
|
+
* fragments the component (so the successor becomes its own component's
|
|
779
|
+
* boundary) AND surfaces as an `unexplained-counter-positions` anomaly within
|
|
780
|
+
* the observed range, which still trips the exit code.
|
|
781
|
+
*
|
|
782
|
+
* Boundary entry points are informational: they never set the exit code and
|
|
783
|
+
* never mark a partition non-intact. The count is surfaced in the summary so a
|
|
784
|
+
* full-epoch audit (which expects zero) makes a stray boundary visible.
|
|
785
|
+
*/
|
|
786
|
+
export interface BoundaryEntryPoint {
|
|
787
|
+
/** The partition this boundary is scoped to. */
|
|
788
|
+
partition: PartitionKey;
|
|
789
|
+
/** The proof whose commit.prevB64 references a predecessor absent from the bundle. */
|
|
790
|
+
proofHash: string;
|
|
791
|
+
/** The unresolved predecessor chain hash (standard base64). */
|
|
792
|
+
prevB64: string;
|
|
793
|
+
}
|
|
794
|
+
|
|
761
795
|
/**
|
|
762
796
|
* Detail payload of an "unexplained-counter-positions" anomaly (G2).
|
|
763
797
|
* A position is explained when it is some observed proof's commit counter
|
|
@@ -833,6 +867,12 @@ export interface AnomalyReport {
|
|
|
833
867
|
anomalies: ChainAnomaly[];
|
|
834
868
|
/** Divergence records for every conflict between valid proofs. */
|
|
835
869
|
divergences: DivergenceRecord[];
|
|
870
|
+
/**
|
|
871
|
+
* Expected excerpt boundaries: a bundle's earliest proofs whose predecessor
|
|
872
|
+
* precedes the exported window. Informational only, never affects the exit
|
|
873
|
+
* code or partition intactness. Zero for a full-epoch export.
|
|
874
|
+
*/
|
|
875
|
+
boundaryEntryPoints: BoundaryEntryPoint[];
|
|
836
876
|
}
|
|
837
877
|
|
|
838
878
|
// ---------------------------------------------------------------------------
|
|
@@ -1448,6 +1488,13 @@ export interface ReportSummary {
|
|
|
1448
1488
|
/** Anomaly counts keyed by stable code, keys sorted. */
|
|
1449
1489
|
anomalyCountsByCode: Record<string, number>;
|
|
1450
1490
|
divergenceCount: number;
|
|
1491
|
+
/**
|
|
1492
|
+
* Count of expected excerpt boundaries (earliest proofs whose predecessor
|
|
1493
|
+
* precedes the exported window). Informational, never a failure. A
|
|
1494
|
+
* full-epoch export has zero; a bounded proof bundle has one per included
|
|
1495
|
+
* chain segment.
|
|
1496
|
+
*/
|
|
1497
|
+
boundaryEntryPoints: number;
|
|
1451
1498
|
authorityGroupCount: number;
|
|
1452
1499
|
distinctSignerCount: number;
|
|
1453
1500
|
distinctDeclaredMeasurementCount: number;
|
|
@@ -1506,6 +1553,8 @@ export interface AuditJsonReport {
|
|
|
1506
1553
|
/** Unified anomaly list: stage order (ingest, chain, authority, anchor, witness, attestation), stage-internal detection order. */
|
|
1507
1554
|
anomalies: ReportAnomaly[];
|
|
1508
1555
|
divergences: DivergenceRecord[];
|
|
1556
|
+
/** Expected excerpt boundaries (informational; never a failure). Empty for a full-epoch export. */
|
|
1557
|
+
boundaryEntryPoints: BoundaryEntryPoint[];
|
|
1509
1558
|
authorities: {
|
|
1510
1559
|
groups: AuthorityGroup[];
|
|
1511
1560
|
sharedSignersAcrossEpochs: SignerEpochSpan[];
|