Give atomic Evidence and snapshot writes an explicit resource owner, preserve primary failures and completed publication receipts, and retry retained staging cleanup at the owning lifecycle. Verify inode identity before publishing or deleting; unknown path disappearance remains a cleanup failure. Keep snapshot admission reusable when a session closes and reopens. Consolidate JavaScript source-scope classification so cancellation cannot fabricate failures for unselected files. Classify configuration failures as invalid input. Remove stale assertions and reuse shared test cleanup ownership. Validation: 266 focused tests before final ownership tightening, 108 affected tests afterward, static and documentation checks, actual invalid-configuration CLI behavior, and isolated test temporary-directory hygiene. Refs #1968
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
|
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
|
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
|
import type { Evidence } from "../../domain/evidence.js";
|
|
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
|
import type { JsonValue } from "../../domain/jsonValue.js";
|
|
import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js";
|
|
import {
|
|
EvidenceFileError,
|
|
EvidenceIntegrityError,
|
|
AnalysisSnapshotMismatchError,
|
|
} from "../../domain/evidenceErrors.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
|
import type {
|
|
RecordUnknownInput,
|
|
ResidualUnknown,
|
|
UnknownStatus,
|
|
UpdateUnknownInput,
|
|
} from "../../domain/residualUnknown.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import type {
|
|
AnalysisExecution,
|
|
AnalysisOperation,
|
|
} from "../AnalysisProvider.js";
|
|
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
|
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { writeAnalysisSnapshot as writeSnapshotFile } from "./AnalysisSnapshotFiles.js";
|
|
|
|
const SNAPSHOT_MUTATION_RECOVERY =
|
|
"Export session observations through export_evidence_bundle if needed. Close the active target without saving a snapshot, then reopen it before importing or saving a snapshot.";
|
|
|
|
export interface ActiveAnalysisBinding {
|
|
readonly target: BinaryTarget;
|
|
readonly profile: AnalysisProfileCommitment | null;
|
|
}
|
|
|
|
/** Validated composed-workflow payload bound to the active target and profile. */
|
|
export interface WorkflowSnapshotRecordInput {
|
|
readonly operation: AnalysisOperation;
|
|
readonly parameters: Readonly<Record<string, JsonValue>>;
|
|
readonly execution: Parameters<
|
|
AnalysisSnapshotCache["recordWorkflow"]
|
|
>[0]["execution"];
|
|
}
|
|
|
|
/** Own binary snapshots and coordinate the composed investigation records. */
|
|
export abstract class BinarySessionRecords {
|
|
readonly #records: InvestigationRecords;
|
|
readonly #writeResources = new ArtifactResourceScope();
|
|
readonly #snapshot = new AnalysisSnapshotCache();
|
|
#snapshotInvalidated = false;
|
|
readonly #snapshotListeners = new Set<() => void | Promise<void>>();
|
|
|
|
constructor(records: InvestigationRecords = new InvestigationRecords()) {
|
|
this.#records = records;
|
|
}
|
|
|
|
/** Observe changes to the mutable current analysis snapshot resource. */
|
|
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
|
|
this.#snapshotListeners.add(listener);
|
|
return () => this.#snapshotListeners.delete(listener);
|
|
}
|
|
|
|
recordEvidence(
|
|
evidence: Evidence,
|
|
): Result<"added" | "duplicate", EvidenceIntegrityError> {
|
|
const recorded = this.#records.recordEvidence(evidence);
|
|
if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
hasEvidence(evidenceId: string): boolean {
|
|
return this.#records.hasEvidence(evidenceId);
|
|
}
|
|
|
|
evidenceById(evidenceId: string): Evidence | undefined {
|
|
return this.#records.evidenceById(evidenceId);
|
|
}
|
|
|
|
/** Borrow immutable investigation Evidence without copying a complete graph. */
|
|
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
|
return this.#records.evidenceForAnalysis(evidenceId);
|
|
}
|
|
|
|
exportEvidenceBundle(): EvidenceBundle {
|
|
return this.#records.exportEvidenceBundle();
|
|
}
|
|
|
|
/** Borrow a sealed bundle for complete serialization without cloning retained graphs. */
|
|
evidenceBundleForSerialization(): EvidenceBundle {
|
|
return this.#records.evidenceBundleForSerialization();
|
|
}
|
|
|
|
/** Persist one provider-neutral snapshot under this session's resource owner. */
|
|
writeAnalysisSnapshot(
|
|
snapshot: AnalysisSnapshot,
|
|
path: string,
|
|
overwrite: boolean,
|
|
): Promise<
|
|
Result<{ readonly path: string; readonly bytes: number }, AnalysisError>
|
|
> {
|
|
return writeSnapshotFile(snapshot, path, overwrite, this.#writeResources);
|
|
}
|
|
|
|
/** Retry and release staged snapshot files at the owning session shutdown. */
|
|
async closeWriteResources(): Promise<Result<null, AnalysisError>> {
|
|
try {
|
|
await this.#writeResources.retryCleanup();
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
const observation = ArtifactReaderFailure.cleanupObservation(
|
|
cause,
|
|
"analysis snapshot staging",
|
|
);
|
|
return err(
|
|
new EvidenceFileError("write", "io", {
|
|
cause,
|
|
cleanup: {
|
|
reason: observation.reason,
|
|
resources: observation.resources,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
importEvidenceBundle(
|
|
bundle: unknown,
|
|
): Result<number, EvidenceIntegrityError> {
|
|
const imported = this.#records.mergeEvidenceBundle(bundle);
|
|
if (!imported.ok) return imported;
|
|
if (imported.value.metadataChanged) this.invalidateSnapshot();
|
|
else if (imported.value.changed) this.#emitSnapshotChanged();
|
|
return ok(imported.value.recordsAdded);
|
|
}
|
|
|
|
protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
|
|
|
|
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (this.#snapshotInvalidated)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots are unavailable after analysis metadata mutations",
|
|
{
|
|
userMessage: `Analysis snapshots are unavailable after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
|
},
|
|
),
|
|
);
|
|
const target = active?.target;
|
|
const profile = active?.profile ?? undefined;
|
|
if (target !== undefined && profile === undefined)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots require a concrete provider analysis profile",
|
|
),
|
|
);
|
|
return this.#snapshot.export(
|
|
target,
|
|
profile,
|
|
target === undefined
|
|
? this.#records.exportEvidenceBundle()
|
|
: evidenceBundleForTarget(
|
|
this.#records.exportEvidenceBundle(),
|
|
target.sha256,
|
|
),
|
|
);
|
|
}
|
|
|
|
importAnalysisSnapshot(
|
|
snapshot: AnalysisSnapshot,
|
|
): Result<number, AnalysisError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (active !== undefined && this.#snapshotInvalidated)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Analysis snapshots cannot be imported after analysis metadata mutations",
|
|
{
|
|
userMessage: `Analysis snapshots cannot be imported after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
|
},
|
|
),
|
|
);
|
|
if (active?.profile === null)
|
|
return err(
|
|
new AnalysisSnapshotMismatchError(
|
|
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
|
|
),
|
|
);
|
|
const imported = this.#snapshot.import(
|
|
snapshot,
|
|
active === undefined
|
|
? undefined
|
|
: { target: active.target, profile: active.profile },
|
|
(bundle) => {
|
|
const imported = this.#records.mergeEvidenceBundle(bundle);
|
|
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
|
},
|
|
);
|
|
if (imported.ok) this.#emitSnapshotChanged();
|
|
return imported;
|
|
}
|
|
|
|
protected matchesSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment | null,
|
|
): boolean {
|
|
return this.#snapshot.matches(target, profile ?? undefined);
|
|
}
|
|
|
|
protected selectSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment,
|
|
): void {
|
|
this.#snapshot.select(target, profile);
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected lookupSnapshot(
|
|
target: BinaryTarget,
|
|
profile: AnalysisProfileCommitment,
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<
|
|
Record<string, import("../../domain/jsonValue.js").JsonValue>
|
|
>,
|
|
): AnalysisExecution | undefined {
|
|
if (this.#snapshotInvalidated) return undefined;
|
|
return this.#snapshot.lookup(target, profile, operation, parameters);
|
|
}
|
|
|
|
protected recordSnapshot(
|
|
input: Parameters<AnalysisSnapshotCache["record"]>[0],
|
|
): void {
|
|
if (this.#snapshotInvalidated) return;
|
|
this.#snapshot.record(input);
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
/** Retain one derived workflow result alongside its provider cache entries. */
|
|
recordWorkflowSnapshot(
|
|
input: WorkflowSnapshotRecordInput,
|
|
): Result<null, EvidenceIntegrityError> {
|
|
const active = this.activeAnalysisBinding();
|
|
if (active === undefined || active.profile === null)
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
"Workflow snapshot entries require an active concrete provider profile",
|
|
),
|
|
);
|
|
if (this.#snapshotInvalidated) return ok(null);
|
|
try {
|
|
this.#snapshot.recordWorkflow({
|
|
target: active.target,
|
|
profile: active.profile,
|
|
...input,
|
|
});
|
|
this.#emitSnapshotChanged();
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
new EvidenceIntegrityError(
|
|
cause instanceof Error
|
|
? cause.message
|
|
: "Workflow snapshot entry validation failed",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
protected invalidateSnapshot(): void {
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = true;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected resetSnapshotInvalidation(): void {
|
|
if (!this.#snapshotInvalidated) return;
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = false;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected clearSnapshot(): void {
|
|
this.#snapshot.clear();
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
protected clearSessionRecords(): void {
|
|
this.#records.clear();
|
|
this.#snapshot.clear();
|
|
this.#snapshotInvalidated = false;
|
|
this.#emitSnapshotChanged();
|
|
}
|
|
|
|
#emitSnapshotChanged(): void {
|
|
for (const listener of this.#snapshotListeners) {
|
|
try {
|
|
const notification = listener();
|
|
if (notification !== undefined)
|
|
void notification.catch((cause: unknown) => {
|
|
// best-effort cleanup: async observer notifications must not reject unhandled.
|
|
void cause;
|
|
});
|
|
} catch (cause: unknown) {
|
|
// External resource observers are best-effort; one callback must not
|
|
// make a committed evidence mutation appear to fail.
|
|
void cause;
|
|
}
|
|
}
|
|
}
|
|
|
|
recordUnknown(
|
|
input: RecordUnknownInput,
|
|
): Result<ResidualUnknown, AnalysisError> {
|
|
const target = this.activeAnalysisBinding()?.target;
|
|
const recorded = this.#records.recordUnknown(input, target);
|
|
if (recorded.ok) this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
recordEvidenceWithUnknown(
|
|
evidence: Evidence,
|
|
input: RecordUnknownInput,
|
|
): Result<ResidualUnknown | null, AnalysisError> {
|
|
const recorded = this.#records.recordEvidenceWithUnknown(evidence, input);
|
|
if (recorded.ok) this.#emitSnapshotChanged();
|
|
return recorded;
|
|
}
|
|
|
|
updateUnknown(
|
|
input: UpdateUnknownInput,
|
|
): Result<ResidualUnknown, AnalysisError> {
|
|
const target = this.activeAnalysisBinding()?.target;
|
|
const updated = this.#records.updateUnknown(input, target);
|
|
if (updated.ok) this.#emitSnapshotChanged();
|
|
return updated;
|
|
}
|
|
|
|
listUnknowns(
|
|
filters: {
|
|
readonly status?: UnknownStatus;
|
|
readonly severity?: ResidualUnknown["severity"];
|
|
readonly domain?: string;
|
|
} = {},
|
|
): ResidualUnknown[] {
|
|
return this.#records.listUnknowns(filters);
|
|
}
|
|
|
|
verifyUnknownResolution(unknownId: string): Result<
|
|
{
|
|
readonly valid: boolean;
|
|
readonly truthVerified: boolean;
|
|
readonly unknown: ResidualUnknown;
|
|
},
|
|
UnknownRegistryError
|
|
> {
|
|
return this.#records.verifyUnknownResolution(unknownId);
|
|
}
|
|
}
|