Files
reverse-engineering-anything/src/application/EvidenceBundleFiles.ts
T
morluto 30228c9a5f fix!: guide snapshot inputs to the restore workflow (#8867)
BREAKING CHANGE: Snapshot-shaped bundle import failures use invalid_request rather than evidence_integrity_mismatch.
2026-11-05 01:50:53 +08:00

272 lines
9.2 KiB
TypeScript

import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { EvidenceBundleExportReceipt } from "../domain/investigationLifecycle.js";
import { jsonValueValidationIssue } from "../domain/jsonValue.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { resemblesAnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import {
safeParseImmutableEvidenceBundle,
safeParseEvidenceBundle,
isImmutableEvidenceBundle,
type EvidenceBundle,
} from "../domain/evidenceBundle.js";
import {
EvidenceFileError,
EvidenceIntegrityError,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
AnalysisCancelledError,
type AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { safeParseProcessCapture } from "../domain/process/processCaptureParsing.js";
import {
bufferedJsonParts,
canonicalJsonParts,
} from "../domain/jsonSerialization.js";
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceReadFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisResourceConstraintError
| AnalysisCancelledError;
type EvidenceWriteFailure =
| EvidenceFileError
| EvidenceIntegrityError
| AnalysisCancelledError
| AnalysisResourceConstraintError;
/** Project a published bundle with record and residual-unknown revision counts. */
export const evidenceBundleExportReceipt = (
bundle: EvidenceBundle,
published: PublishedEvidenceFile,
): EvidenceBundleExportReceipt => ({
path: published.path,
bytes: published.bytes,
records: bundle.records.length,
unknowns: bundle.unknowns.length,
});
/** Read and validate an evidence bundle at the caller-supplied path. */
export const readEvidenceBundle = (
path: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, false, signal, resources, operation);
/** Read an authenticated immutable snapshot for a subsequent session import. */
export const readImmutableEvidenceBundle = (
path: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, true, signal, resources, operation);
/** Join both comparison reads and preserve cleanup owned by either failed input. */
export const readComparisonEvidenceBundles = async (
leftPath: string,
rightPath: string,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "compare_bundles",
): Promise<
Result<
{ readonly left: EvidenceBundle; readonly right: EvidenceBundle },
AnalysisError
>
> => {
const [left, right] = await Promise.all([
readImmutableEvidenceBundle(leftPath, signal, resources, operation),
readImmutableEvidenceBundle(rightPath, signal, resources, operation),
]);
if (!left.ok) {
if (!right.ok && right.error.cleanupIncomplete)
return err(
analysisErrorWithCleanupFailure(
left.error,
right.error,
operation,
).bindCleanupDependencies([left.error, right.error]),
);
return left;
}
if (!right.ok) return right;
return ok({ left: left.value, right: right.value });
};
const readBundle = async (
path: string,
immutable: boolean,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources, operation);
if (!loaded.ok) return loaded;
if (signal?.aborted)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
return authenticateEvidenceBundleValue(loaded.value, path, {
operation,
immutable,
});
};
/** Authenticate a loaded bundle once, preserving file-reader failure and process-capture semantics. */
export const authenticateEvidenceBundleValue = (
value: unknown,
path: string,
options: { readonly operation: string; readonly immutable?: boolean },
): Result<EvidenceBundle, EvidenceReadFailure> => {
const { operation } = options;
const headroom = requireAuthenticationHeadroom(value, {
operation,
path: absoluteCallerPath(path),
description:
"Insufficient heap headroom to authenticate the Evidence bundle",
});
if (!headroom.ok) return headroom;
const parsed =
options.immutable === true
? safeParseImmutableEvidenceBundle(value)
: safeParseEvidenceBundle(value);
if (!parsed.ok) {
const failure = parsed.error;
if (failure.kind === "single-record")
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause: failure.cause,
userMessage: failure.message,
}),
);
if (
operation === "import_evidence_bundle" &&
typeof value === "object" &&
value !== null &&
!Object.hasOwn(value, "records") &&
resemblesAnalysisSnapshot(value)
)
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause: failure.cause,
userMessage:
"This JSON appears to be an analysis snapshot rather than an Evidence bundle. Its snapshot content has not been validated. To restore it, use a matching target, provider and analysis profile: call open_binary with snapshot_path set to this file, or pass --snapshot to a compatible CLI analysis command (which loads and updates the snapshot). Alternatively, supply a bundle from export_evidence_bundle.",
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: failure.cause,
userMessage: failure.message,
}),
);
}
return validateBundlePayloads(parsed.value, path, operation);
};
/** Validate typed payloads without restricting historical workflow or provider identities. */
const validateBundlePayloads = (
bundle: EvidenceBundle,
path: string,
operation: string,
): Result<
EvidenceBundle,
EvidenceIntegrityError | AnalysisResourceConstraintError
> => {
for (const record of bundle.records) {
if (record.predicate_type !== "rea.process-capture") continue;
// A sealed bundle borrows records, but this typed parser still allocates a
// capture copy. Admit its actual working set against current headroom.
const headroom = requireAuthenticationHeadroom(record.normalized_result, {
operation,
path: absoluteCallerPath(path),
description: `Insufficient heap headroom to validate process capture ${record.evidence_id} in the Evidence bundle`,
});
if (!headroom.ok) return headroom;
const parsedCapture = safeParseProcessCapture(record.normalized_result);
if (!parsedCapture.success) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: parsedCapture.error,
userMessage: `Process capture record ${record.evidence_id} has an invalid normalized_result (${describeValidationFailure(parsedCapture.error)}). Recreate or re-export the bundle, then try again.`,
}),
);
}
}
return ok(bundle);
};
/** Atomically write deterministic evidence JSON at the caller-supplied path. */
export const writeEvidenceBundle = async (
bundle: EvidenceBundle,
path: string,
overwrite: boolean,
resources: ArtifactResourceScope,
signal?: AbortSignal,
operation = "export_evidence_bundle",
): Promise<
Result<
{ readonly path: string; readonly bytes: number },
EvidenceWriteFailure
>
> => {
if (signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
);
if (!isImmutableEvidenceBundle(bundle)) {
const issue = jsonValueValidationIssue(bundle);
if (issue !== undefined)
return err(
new EvidenceIntegrityError("Evidence checkpoint is not ordinary JSON", {
userMessage: issue,
}),
);
const headroom = requireAuthenticationHeadroom(bundle, {
operation,
path: absoluteCallerPath(path),
description:
"Insufficient heap headroom to authenticate the Evidence checkpoint",
});
if (!headroom.ok) return headroom;
}
const checked = safeParseEvidenceBundle(bundle);
if (!checked.ok) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause: checked.error.cause,
}),
);
}
const validated = validateBundlePayloads(checked.value, path, operation);
if (!validated.ok) return validated;
return writeTextParts(
bufferedJsonParts(canonicalJsonParts(validated.value)),
path,
overwrite,
resources,
signal === undefined ? undefined : { signal, operation },
);
};