mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(io): consolidate managed snapshots and retain cleanup ownership
Reuse managed snapshot admission and hashing in sequential path comparisons, retaining caller paths and completed inspection Evidence on later failure. Preserve exact failed-close owners alongside typed read and cancellation failures, and retry them through caller-owned resource scopes. Require canonical Hopper lease identities, remove signed offset coercion, and repair stale setup cleanup expectations that failed CI.
This commit is contained in:
@@ -53,6 +53,12 @@ CIL decoding, and projected inspection records have a separate conservative
|
||||
output occurrences still consume that budget. These are inspection resource
|
||||
policies, not PE format limits or a hard bound on total V8 heap use.
|
||||
|
||||
Path-based member comparison uses the same per-input snapshot policy and
|
||||
inspects its two selected paths sequentially, retaining each inspection rather
|
||||
than both full byte snapshots. The limit therefore remains a per-file resource
|
||||
admission rule, not an aggregate comparison limit. Comparison Evidence keeps
|
||||
the caller-selected paths as its parameters and artifact locations.
|
||||
|
||||
Input overflow returns a resource constraint. Decode or projection overflow
|
||||
reports `resource-limit` with unavailable or partial coverage; omitted facts do
|
||||
not become empty observations. Ambiguous PE section mappings are rejected
|
||||
|
||||
@@ -207,18 +207,26 @@ export const parseBinaryTarget = async (
|
||||
if (cause instanceof AnalysisCancelledError) return err(cause);
|
||||
if (cause instanceof BinaryTargetError) return err(cause);
|
||||
if (!(cause instanceof Error)) throw cause;
|
||||
const code = filesystemErrorCode(cause);
|
||||
if (code === undefined) throw cause;
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
candidate,
|
||||
targetFailureReason(code, cause.message),
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
const failure = classifyBinaryTargetFilesystemFailure(candidate, cause);
|
||||
if (failure === undefined) throw cause;
|
||||
return err(failure);
|
||||
}
|
||||
};
|
||||
|
||||
/** Classify a filesystem failure while reading a previously selected target. */
|
||||
export const classifyBinaryTargetFilesystemFailure = (
|
||||
path: string,
|
||||
cause: unknown,
|
||||
): BinaryTargetError | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const code = filesystemErrorCode(cause);
|
||||
return code === undefined
|
||||
? undefined
|
||||
: new BinaryTargetError(path, targetFailureReason(code, cause.message), {
|
||||
cause,
|
||||
});
|
||||
};
|
||||
|
||||
const filesystemErrorCode = (cause: Error): string | undefined => {
|
||||
const code: unknown = Reflect.get(cause, "code");
|
||||
return typeof code === "string" ? code : undefined;
|
||||
|
||||
@@ -71,5 +71,9 @@ it("preserves the original explanation and collected output alongside cleanup un
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(combined.cause).toBe(primary);
|
||||
expect(combined.cause).toBeInstanceOf(AggregateError);
|
||||
expect(combined.cause).toMatchObject({
|
||||
cause: primary,
|
||||
errors: [primary, cleanup],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -9,14 +6,20 @@ import {
|
||||
type CompareManagedMembersInput,
|
||||
} from "../../domain/managed/managedMemberComparison.js";
|
||||
import { parseManagedMemberEvidence } from "../../domain/managed/managedMemberComparisonMatch.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
|
||||
import {
|
||||
classifyBinaryTargetFilesystemFailure,
|
||||
parseBinaryTarget,
|
||||
} from "../BinaryTargetResolver.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { inspectManagedMembersBytes } from "../../dotnet/ManagedMemberInspector.js";
|
||||
import { readManagedSnapshot } from "../../dotnet/ManagedSnapshot.js";
|
||||
import {
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
@@ -43,109 +46,95 @@ export const compareManagedMembersEvidenceValidated = (
|
||||
|
||||
interface ManagedMemberComparisonPathDependencies {
|
||||
readonly resolveTarget: typeof parseBinaryTarget;
|
||||
readonly readBytes: (path: string) => Promise<Buffer>;
|
||||
readonly readSnapshot: typeof readManagedSnapshot;
|
||||
}
|
||||
|
||||
const DEFAULT_PATH_DEPENDENCIES: ManagedMemberComparisonPathDependencies = {
|
||||
resolveTarget: parseBinaryTarget,
|
||||
readBytes: readFile,
|
||||
};
|
||||
|
||||
/** Inspect two local PE artifacts and return one derived comparison Evidence. */
|
||||
export const compareManagedMemberPaths = async (
|
||||
input: {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
},
|
||||
dependencies: ManagedMemberComparisonPathDependencies = DEFAULT_PATH_DEPENDENCIES,
|
||||
dependencies: Partial<ManagedMemberComparisonPathDependencies> = {},
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const operation = "compare_managed_members";
|
||||
const resolveTarget = dependencies.resolveTarget ?? parseBinaryTarget;
|
||||
const readSnapshot = dependencies.readSnapshot ?? readManagedSnapshot;
|
||||
let partialEvidence: Evidence | undefined;
|
||||
try {
|
||||
const [leftTarget, rightTarget] = await Promise.all([
|
||||
dependencies.resolveTarget(input.leftPath),
|
||||
dependencies.resolveTarget(input.rightPath),
|
||||
resolveTarget(input.leftPath),
|
||||
resolveTarget(input.rightPath),
|
||||
]);
|
||||
// Report an unopenable path like every other path-based command does,
|
||||
// with the failed path and constraint, rather than a bare input error.
|
||||
if (!leftTarget.ok) return err(leftTarget.error);
|
||||
if (!rightTarget.ok) return err(rightTarget.error);
|
||||
const [leftBytes, rightBytes] = await Promise.all([
|
||||
dependencies.readBytes(leftTarget.value.path),
|
||||
dependencies.readBytes(rightTarget.value.path),
|
||||
]);
|
||||
for (const [target, bytes] of [
|
||||
[leftTarget.value, leftBytes],
|
||||
[rightTarget.value, rightBytes],
|
||||
] as const) {
|
||||
const observedSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (observedSha256 !== target.sha256)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${observedSha256} at ${target.path}`,
|
||||
),
|
||||
const inspectTarget = async (
|
||||
target: BinaryTarget,
|
||||
selectedPath: string,
|
||||
) => {
|
||||
const { path } = target;
|
||||
let snapshot: Awaited<ReturnType<typeof readManagedSnapshot>>;
|
||||
try {
|
||||
snapshot = await readSnapshot(
|
||||
path,
|
||||
operation,
|
||||
resources === undefined ? {} : { resources },
|
||||
);
|
||||
}
|
||||
const leftInspection = inspectManagedMembersBytes(
|
||||
leftBytes,
|
||||
leftTarget.value,
|
||||
);
|
||||
const rightInspection = inspectManagedMembersBytes(
|
||||
rightBytes,
|
||||
rightTarget.value,
|
||||
);
|
||||
const leftEvidence = createEvidence(
|
||||
leftTarget.value,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
{
|
||||
} catch (cause: unknown) {
|
||||
const failure = classifyBinaryTargetFilesystemFailure(path, cause);
|
||||
if (failure !== undefined) throw failure;
|
||||
throw cause;
|
||||
}
|
||||
if (snapshot.sha256 !== target.sha256)
|
||||
throw new EvidenceIntegrityError(
|
||||
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${snapshot.sha256} at ${target.path}`,
|
||||
);
|
||||
const inspection = inspectManagedMembersBytes(snapshot.bytes, target);
|
||||
const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, {
|
||||
operation: "inspect_managed_members",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(leftInspection),
|
||||
result: jsonValueSchema.parse(inspection),
|
||||
rawResult: null,
|
||||
limitations: leftInspection.limitations,
|
||||
locations: [{ kind: "artifact-path", path: leftTarget.value.path }],
|
||||
},
|
||||
);
|
||||
const rightEvidence = createEvidence(
|
||||
rightTarget.value,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
{
|
||||
operation: "inspect_managed_members",
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(rightInspection),
|
||||
rawResult: null,
|
||||
limitations: rightInspection.limitations,
|
||||
locations: [{ kind: "artifact-path", path: rightTarget.value.path }],
|
||||
},
|
||||
);
|
||||
limitations: inspection.limitations,
|
||||
locations: [{ kind: "artifact-path", path: selectedPath }],
|
||||
});
|
||||
return { inspection, evidence };
|
||||
};
|
||||
const left = await inspectTarget(leftTarget.value, input.leftPath);
|
||||
partialEvidence = left.evidence;
|
||||
const right = await inspectTarget(rightTarget.value, input.rightPath);
|
||||
const result = compareManagedMembers(
|
||||
{
|
||||
evidenceId: leftEvidence.evidence_id,
|
||||
result: leftInspection,
|
||||
evidenceId: left.evidence.evidence_id,
|
||||
result: left.inspection,
|
||||
},
|
||||
{
|
||||
evidenceId: rightEvidence.evidence_id,
|
||||
result: rightInspection,
|
||||
evidenceId: right.evidence.evidence_id,
|
||||
result: right.inspection,
|
||||
},
|
||||
);
|
||||
return ok(
|
||||
createManagedMemberComparisonEvidence(
|
||||
{
|
||||
left: leftEvidence,
|
||||
right: rightEvidence,
|
||||
left: left.evidence,
|
||||
right: right.evidence,
|
||||
},
|
||||
result,
|
||||
{ leftPath: input.leftPath, rightPath: input.rightPath },
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
return workflowFailure(operation, cause, partialEvidence);
|
||||
}
|
||||
};
|
||||
|
||||
/** ECMA-335 representable limits for path-based member comparison. */
|
||||
|
||||
const createManagedMemberComparisonEvidence = (
|
||||
parameters: Pick<CompareManagedMembersInput, "left" | "right">,
|
||||
result: z.infer<typeof managedMemberComparisonResultSchema>,
|
||||
sourcePaths?: { readonly leftPath: string; readonly rightPath: string },
|
||||
): Evidence =>
|
||||
createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, {
|
||||
predicateType: "rea.managed-member-comparison",
|
||||
@@ -153,6 +142,12 @@ const createManagedMemberComparisonEvidence = (
|
||||
parameters: {
|
||||
left_evidence_id: parameters.left.evidence_id,
|
||||
right_evidence_id: parameters.right.evidence_id,
|
||||
...(sourcePaths === undefined
|
||||
? {}
|
||||
: {
|
||||
left_path: sourcePaths.leftPath,
|
||||
right_path: sourcePaths.rightPath,
|
||||
}),
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
@@ -160,18 +155,33 @@ const createManagedMemberComparisonEvidence = (
|
||||
authority: "analyst-inference",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
locations:
|
||||
sourcePaths === undefined
|
||||
? []
|
||||
: [
|
||||
{ kind: "artifact-path", path: sourcePaths.leftPath },
|
||||
{ kind: "artifact-path", path: sourcePaths.rightPath },
|
||||
],
|
||||
evidenceLinks: result.evidence_links,
|
||||
});
|
||||
|
||||
const workflowFailure = (
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
): Result<never, AnalysisError> =>
|
||||
err(
|
||||
cause instanceof z.ZodError || cause instanceof TypeError
|
||||
? workflowInputError(operation, cause)
|
||||
: new AnalysisProtocolError(
|
||||
"Managed member comparison produced an invalid result",
|
||||
{ cause },
|
||||
),
|
||||
partialEvidence?: Evidence,
|
||||
): Result<never, AnalysisError> => {
|
||||
const failure =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: cause instanceof z.ZodError || cause instanceof TypeError
|
||||
? workflowInputError(operation, cause)
|
||||
: new AnalysisProtocolError(
|
||||
"Managed member comparison produced an invalid result",
|
||||
{ cause },
|
||||
);
|
||||
return err(
|
||||
partialEvidence === undefined
|
||||
? failure
|
||||
: failure.retainPartialObservation(partialEvidence),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,13 @@ import { compareManagedMemberPaths } from "../application/managed/ManagedMemberC
|
||||
import { verifyManagedNativeBoundariesEvidence } from "../application/managed/ManagedNativeVerificationService.js";
|
||||
import { importManagedReconstructionEvidence } from "../application/managed/ManagedReconstructionService.js";
|
||||
import type { DirectAnalysis } from "../composition/directAnalysis.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -112,10 +117,44 @@ const registerCompareManagedMembers = (
|
||||
args: compareManagedMemberArgs,
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare-managed-members", async () => {
|
||||
const result = await compareManagedMemberPaths({
|
||||
leftPath: args.leftPath,
|
||||
rightPath: args.rightPath,
|
||||
});
|
||||
const resources = new ArtifactResourceScope();
|
||||
const result = await compareManagedMemberPaths(
|
||||
{
|
||||
leftPath: args.leftPath,
|
||||
rightPath: args.rightPath,
|
||||
},
|
||||
{},
|
||||
resources,
|
||||
);
|
||||
try {
|
||||
await resources.close();
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
"Managed member comparison resources",
|
||||
);
|
||||
const failure = new ProviderCleanupError(
|
||||
MANAGED_STATIC_PROVIDER.id,
|
||||
cleanup.resources,
|
||||
{ reason: cleanup.reason },
|
||||
{
|
||||
cause,
|
||||
operation: "compare_managed_members",
|
||||
...(result.ok ? { partialObservation: result.value } : {}),
|
||||
},
|
||||
);
|
||||
const error = result.ok
|
||||
? failure
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
failure,
|
||||
"compare_managed_members",
|
||||
);
|
||||
return {
|
||||
error: "Managed member comparison failed",
|
||||
...projectAnalysisError(error),
|
||||
};
|
||||
}
|
||||
return result.ok
|
||||
? result.value
|
||||
: {
|
||||
|
||||
@@ -13,7 +13,11 @@ export const analysisErrorWithCleanupFailure = (
|
||||
cleanup instanceof ProviderAdapterError ? cleanup.providerId : "rea",
|
||||
operation,
|
||||
{
|
||||
cause: primary,
|
||||
cause: new AggregateError(
|
||||
[primary, cleanup],
|
||||
"Analysis failed and cleanup remains incomplete.",
|
||||
{ cause: primary },
|
||||
),
|
||||
userMessage: projectAnalysisError(primary).message,
|
||||
...(primary.capturedOutput === undefined
|
||||
? {}
|
||||
|
||||
@@ -135,7 +135,7 @@ const requireRange = (
|
||||
);
|
||||
};
|
||||
|
||||
const align4 = (value: number): number => (value + 3) & ~3;
|
||||
const align4 = (value: number): number => Math.ceil(value / 4) * 4;
|
||||
|
||||
const readStreamName = (
|
||||
bytes: Buffer,
|
||||
|
||||
@@ -132,7 +132,7 @@ export const methodBody = (
|
||||
"utf8",
|
||||
);
|
||||
const methodEnd = ilOffset + header.ilSize;
|
||||
const sectionOffset = (methodEnd + 3) & ~3;
|
||||
const sectionOffset = Math.ceil(methodEnd / 4) * 4;
|
||||
const exceptionRegions =
|
||||
header.format === "fat" && (header.flags & 8) !== 0
|
||||
? validateExceptionRegionRanges(
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { classifyBinaryTargetFilesystemFailure } from "../application/BinaryTargetResolver.js";
|
||||
import { withRegularFile } from "../application/RegularFileRead.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
type RegularFileReadOptions,
|
||||
} from "../application/RegularFileRead.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
// Managed static inspection retains the complete PE snapshot in memory.
|
||||
export const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
interface ManagedSourceObservation {
|
||||
readonly byteLength: number;
|
||||
readonly sha256: string;
|
||||
readonly bytes?: Buffer;
|
||||
}
|
||||
|
||||
interface ManagedSnapshot extends ManagedSourceObservation {
|
||||
readonly bytes: Buffer;
|
||||
}
|
||||
|
||||
const managedSnapshotCapacityError = (
|
||||
operation: string,
|
||||
): AnalysisResourceConstraintError =>
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
"Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.",
|
||||
{ max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES },
|
||||
);
|
||||
|
||||
export const readManagedSnapshot = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<ManagedSnapshot> => {
|
||||
return readManagedFile(
|
||||
path,
|
||||
operation,
|
||||
async (handle, metadata) => {
|
||||
const signal = options.signal;
|
||||
if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||
throw managedSnapshotCapacityError(operation);
|
||||
const bytes = await readBoundedFileBytes(
|
||||
handle,
|
||||
MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
signal,
|
||||
);
|
||||
if (bytes === undefined) throw managedSnapshotCapacityError(operation);
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
byteLength: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes,
|
||||
};
|
||||
},
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
export const hashManagedSource = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<ManagedSourceObservation> =>
|
||||
readManagedFile(
|
||||
path,
|
||||
operation,
|
||||
async (handle, metadata) => {
|
||||
const signal = options.signal;
|
||||
if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||
throw managedSnapshotCapacityError(operation);
|
||||
const digest = createHash("sha256");
|
||||
let byteLength = 0;
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
if (!Buffer.isBuffer(chunk))
|
||||
throw new TypeError("Managed source stream returned non-buffer data");
|
||||
if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength)
|
||||
throw managedSnapshotCapacityError(operation);
|
||||
byteLength += chunk.length;
|
||||
digest.update(chunk);
|
||||
}
|
||||
return { byteLength, sha256: digest.digest("hex") };
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const readManagedFile = <Value>(
|
||||
path: string,
|
||||
operation: string,
|
||||
read: (handle: FileHandle, metadata: Stats) => Promise<Value>,
|
||||
options: RegularFileReadOptions,
|
||||
): Promise<Value> =>
|
||||
withRegularFile(path, read, options).catch((cause: unknown) => {
|
||||
throw normalizeManagedReadFailure(cause, path, operation, options.signal);
|
||||
});
|
||||
|
||||
/** Keep cleanup ownership and the original typed read failure at this boundary. */
|
||||
const normalizeManagedReadFailure = (
|
||||
cause: unknown,
|
||||
fallbackPath: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): unknown => {
|
||||
const cleanupError = managedCleanupError(
|
||||
cause,
|
||||
fallbackPath,
|
||||
operation,
|
||||
signal,
|
||||
);
|
||||
if (cleanupError !== undefined) return cleanupError;
|
||||
return normalizeReadFailure(cause, fallbackPath, operation, signal);
|
||||
};
|
||||
|
||||
/** Project cleanup failures owned by managed file reads or their shared scope. */
|
||||
export const managedCleanupError = (
|
||||
cause: unknown,
|
||||
fallbackPath: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError | undefined => {
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
MANAGED_STATIC_PROVIDER.id,
|
||||
[cause.path],
|
||||
{ reason: "A managed input file handle could not be closed" },
|
||||
{ cause, operation },
|
||||
);
|
||||
const failure =
|
||||
cause.outcome.kind === "failed"
|
||||
? normalizeReadFailure(
|
||||
cause.outcome.cause,
|
||||
cause.path,
|
||||
operation,
|
||||
signal,
|
||||
)
|
||||
: undefined;
|
||||
return failure instanceof AnalysisError
|
||||
? analysisErrorWithCleanupFailure(failure, cleanup, operation)
|
||||
: cleanup;
|
||||
}
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||
return new ProviderCleanupError(
|
||||
MANAGED_STATIC_PROVIDER.id,
|
||||
cause.cleanup.resources.length > 0
|
||||
? cause.cleanup.resources
|
||||
: [fallbackPath],
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation },
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeReadFailure = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): unknown =>
|
||||
cause instanceof RegularFileChangedError
|
||||
? new EvidenceIntegrityError(
|
||||
`Managed source changed while ${operation} was reading ${path}.`,
|
||||
{ cause },
|
||||
)
|
||||
: cause instanceof AnalysisError
|
||||
? cause
|
||||
: (classifyBinaryTargetFilesystemFailure(path, cause) ??
|
||||
(signal?.aborted === true
|
||||
? new AnalysisCancelledError(operation, { cause })
|
||||
: cause));
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
@@ -9,6 +7,8 @@ import {
|
||||
type ExecutionOptions,
|
||||
type ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { managedStaticCapabilities } from "./ManagedStaticProviderMetadata.js";
|
||||
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import {
|
||||
@@ -22,10 +22,10 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
@@ -37,23 +37,12 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js";
|
||||
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
|
||||
import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js";
|
||||
|
||||
import { openRegularFile } from "../filesystem/RegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
// This provider eagerly retains the full PE snapshot. Keep its input resource
|
||||
// policy separate from decoded metadata/output capacity; it is not a PE format limit.
|
||||
const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
const snapshotCapacityError = (
|
||||
operation: string,
|
||||
): AnalysisResourceConstraintError =>
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
"Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.",
|
||||
{ max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES },
|
||||
);
|
||||
import { RegularFileCleanupFailure } from "../application/RegularFileRead.js";
|
||||
import {
|
||||
hashManagedSource,
|
||||
managedCleanupError,
|
||||
readManagedSnapshot,
|
||||
} from "./ManagedSnapshot.js";
|
||||
|
||||
/** Execution-free managed PE/CLI auxiliary provider. */
|
||||
export class ManagedStaticProvider implements AnalysisProvider {
|
||||
@@ -75,6 +64,7 @@ export class ManagedStaticProvider implements AnalysisProvider {
|
||||
|
||||
class ManagedStaticClient implements AnalysisClient {
|
||||
#snapshotBytes: Buffer | undefined;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
|
||||
constructor(private readonly target: BinaryTarget) {}
|
||||
|
||||
@@ -107,16 +97,14 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
const snapshot = this.#snapshotBytes;
|
||||
const observed =
|
||||
snapshot === undefined
|
||||
? await readManagedSnapshot(
|
||||
this.target.path,
|
||||
operation,
|
||||
options?.signal,
|
||||
)
|
||||
: await hashManagedSource(
|
||||
this.target.path,
|
||||
operation,
|
||||
options?.signal,
|
||||
);
|
||||
? await readManagedSnapshot(this.target.path, operation, {
|
||||
signal: options?.signal,
|
||||
resources: this.#resources,
|
||||
})
|
||||
: await hashManagedSource(this.target.path, operation, {
|
||||
signal: options?.signal,
|
||||
resources: this.#resources,
|
||||
});
|
||||
if (observed.sha256 !== this.target.sha256)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
@@ -142,9 +130,11 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (isCleanupFailure(cause))
|
||||
return err(this.#cleanupError(operation, cause));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, {
|
||||
cause,
|
||||
@@ -153,74 +143,37 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
async close(): Promise<Result<null, AnalysisError>> {
|
||||
this.#snapshotBytes = undefined;
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagedSourceObservation {
|
||||
readonly byteLength: number;
|
||||
readonly sha256: string;
|
||||
readonly bytes?: Buffer;
|
||||
}
|
||||
|
||||
const readManagedSnapshot = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedSourceObservation> => {
|
||||
const handle = await openRegularFile(path, { symlinks: "follow", signal });
|
||||
try {
|
||||
const metadata = await handle.stat();
|
||||
if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||
throw snapshotCapacityError(operation);
|
||||
const bytes = await readBoundedFileBytes(
|
||||
handle,
|
||||
MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
signal,
|
||||
);
|
||||
if (bytes === undefined) throw snapshotCapacityError(operation);
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
byteLength: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const hashManagedSource = async (
|
||||
path: string,
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedSourceObservation> => {
|
||||
const handle = await openRegularFile(path, { symlinks: "follow", signal });
|
||||
try {
|
||||
if ((await handle.stat()).size > MAX_MANAGED_SNAPSHOT_BYTES)
|
||||
throw snapshotCapacityError(operation);
|
||||
const digest = createHash("sha256");
|
||||
let byteLength = 0;
|
||||
const stream = handle.createReadStream({
|
||||
autoClose: false,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
for await (const chunk of stream) {
|
||||
signal?.throwIfAborted();
|
||||
if (!Buffer.isBuffer(chunk))
|
||||
throw new TypeError("Managed source stream returned non-buffer data");
|
||||
if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength)
|
||||
throw snapshotCapacityError(operation);
|
||||
byteLength += chunk.length;
|
||||
digest.update(chunk);
|
||||
try {
|
||||
await this.#resources.close();
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
return err(this.#cleanupError("close", cause));
|
||||
}
|
||||
return { byteLength, sha256: digest.digest("hex") };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
#cleanupError(operation: string, cause: unknown): ProviderCleanupError {
|
||||
const failure = managedCleanupError(cause, this.target.path, operation);
|
||||
if (failure instanceof ProviderCleanupError) return failure;
|
||||
const cleanup =
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
return new ProviderCleanupError(
|
||||
MANAGED_STATIC_PROVIDER.id,
|
||||
cleanup?.resources ?? [this.target.path],
|
||||
{
|
||||
reason:
|
||||
cleanup?.reason ??
|
||||
(cause instanceof Error ? cause.message : String(cause)),
|
||||
},
|
||||
{ cause, operation },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isCleanupFailure = (cause: unknown): boolean =>
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined);
|
||||
|
||||
const isManagedOperation = (
|
||||
operation: AnalysisOperation,
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { lstat, mkdir, mkdtemp, readdir, rm, rmdir } from "node:fs/promises";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
rm,
|
||||
rmdir,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -25,8 +34,9 @@ afterEach(async () => {
|
||||
describe("Hopper target leases", () => {
|
||||
it("reports the owning REA session for a duplicate target and profile", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const targetPath = await temporaryTarget();
|
||||
const first = await acquireHopperTargetLease({
|
||||
...leaseInput,
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "session-first",
|
||||
});
|
||||
@@ -34,7 +44,7 @@ describe("Hopper target leases", () => {
|
||||
if (!first.acquired) return;
|
||||
|
||||
const duplicate = await acquireHopperTargetLease({
|
||||
...leaseInput,
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "session-second",
|
||||
});
|
||||
@@ -45,7 +55,7 @@ describe("Hopper target leases", () => {
|
||||
|
||||
await first.lease.release();
|
||||
const reopened = await acquireHopperTargetLease({
|
||||
...leaseInput,
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "session-third",
|
||||
});
|
||||
@@ -55,13 +65,14 @@ describe("Hopper target leases", () => {
|
||||
|
||||
it("keeps different Hopper profiles independent", async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const targetPath = await temporaryTarget();
|
||||
const first = await acquireHopperTargetLease({
|
||||
...leaseInput,
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "session-one",
|
||||
});
|
||||
const second = await acquireHopperTargetLease({
|
||||
...leaseInput,
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
loaderArgs: ["--aarch64"],
|
||||
runId: "session-two",
|
||||
@@ -71,6 +82,63 @@ describe("Hopper target leases", () => {
|
||||
if (first.acquired) await first.lease.release();
|
||||
if (second.acquired) await second.lease.release();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"shares one lease for symlink aliases of the same target",
|
||||
async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const targetPath = await temporaryTarget();
|
||||
const alias = join(dirname(targetPath), "sample-alias");
|
||||
await symlink(targetPath, alias);
|
||||
const first = await acquireHopperTargetLease({
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "canonical-owner",
|
||||
});
|
||||
expect(first.acquired).toBe(true);
|
||||
if (!first.acquired) return;
|
||||
try {
|
||||
expect(
|
||||
await acquireHopperTargetLease({
|
||||
...leaseInput(alias),
|
||||
directory,
|
||||
runId: "alias-request",
|
||||
}),
|
||||
).toMatchObject({
|
||||
acquired: false,
|
||||
owner: { runId: "canonical-owner" },
|
||||
});
|
||||
} finally {
|
||||
await first.lease.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["missing", "not-directory"] as const)(
|
||||
"does not create a lease endpoint when target canonicalization fails for %s",
|
||||
async (failure) => {
|
||||
const directory = await temporaryDirectory();
|
||||
const parent = await temporaryDirectory();
|
||||
const targetPath =
|
||||
failure === "missing"
|
||||
? join(parent, "missing-target")
|
||||
: join(await temporaryTarget(parent), "child");
|
||||
await expect(
|
||||
acquireHopperTargetLease({
|
||||
...leaseInput(targetPath),
|
||||
directory,
|
||||
runId: "unresolvable-target",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "HopperStartError",
|
||||
userMessage: expect.stringContaining("canonical path"),
|
||||
cause: expect.objectContaining({
|
||||
code: failure === "missing" ? "ENOENT" : "ENOTDIR",
|
||||
}),
|
||||
});
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Linux Hopper application leases", () => {
|
||||
@@ -212,10 +280,17 @@ const releasedApplicationSocket = async (
|
||||
return join(directory, name);
|
||||
};
|
||||
|
||||
const leaseInput = {
|
||||
targetPath: "/tmp/sample-binary",
|
||||
const leaseInput = (targetPath: string) => ({
|
||||
targetPath,
|
||||
targetKind: "executable" as const,
|
||||
loaderArgs: [],
|
||||
});
|
||||
|
||||
const temporaryTarget = async (parent?: string): Promise<string> => {
|
||||
const directory = parent ?? (await temporaryDirectory());
|
||||
const targetPath = join(directory, "sample-binary");
|
||||
await writeFile(targetPath, "admitted target bytes");
|
||||
return targetPath;
|
||||
};
|
||||
|
||||
const temporaryDirectory = async (): Promise<string> => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type Server,
|
||||
type Socket,
|
||||
} from "node:net";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { HopperStartError } from "../domain/hopperErrors.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
|
||||
@@ -37,16 +37,17 @@ export const acquireHopperTargetLease = async (input: {
|
||||
readonly runId: string;
|
||||
readonly directory?: string;
|
||||
}): Promise<HopperTargetLeaseAcquisition> => {
|
||||
// The canonical path only derives the lease key; it never authorizes access.
|
||||
// Common expected `realpath` failures include a missing target or path
|
||||
// component (ENOENT, ENOTDIR), an unreadable component (EACCES), or a
|
||||
// symlink loop (ELOOP). This catch also falls back for every other error,
|
||||
// which may hide a real filesystem problem. The lexical `resolve` fallback
|
||||
// still yields a deterministic key, but aliases may take separate leases
|
||||
// whenever `realpath` cannot resolve them.
|
||||
const targetPath = await realpath(input.targetPath).catch(() =>
|
||||
resolve(input.targetPath),
|
||||
);
|
||||
// Canonicalization is required for lease identity. A lexical fallback could
|
||||
// let two aliases reserve independent sockets for the same Hopper document.
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = await realpath(input.targetPath);
|
||||
} catch (cause: unknown) {
|
||||
throw new HopperStartError({
|
||||
cause,
|
||||
userMessage: `REA could not resolve Hopper target ${JSON.stringify(input.targetPath)} to a canonical path before reserving the document. Confirm that the target exists and its path is accessible.`,
|
||||
});
|
||||
}
|
||||
return acquireLease({
|
||||
...input,
|
||||
identity: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readSkillFile,
|
||||
skillDestinations,
|
||||
} from "../../../src/application/SetupSkill.js";
|
||||
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
|
||||
import { systemSetupHost } from "../../../src/application/SetupHost.js";
|
||||
import {
|
||||
runUninstall,
|
||||
@@ -67,7 +68,7 @@ const failFirstCloses = (path: string) => {
|
||||
};
|
||||
|
||||
describe("skill regular-file cleanup ownership", () => {
|
||||
it("keeps setup skill reads owned until setup host disposal", async () => {
|
||||
it("retains a failed setup skill read until setup host disposal", async () => {
|
||||
const home = await createTestTempDirectory("rea-setup-skill-close-");
|
||||
const [destination] = skillDestinations(home, []);
|
||||
if (destination === undefined) throw new Error("Shared skill root missing");
|
||||
@@ -81,7 +82,18 @@ describe("skill regular-file cleanup ownership", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
expect(await host.skillNeedsInstall([])).toBe(true);
|
||||
const failure = await host.skillNeedsInstall([]).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
||||
expect(failure).toMatchObject({
|
||||
path: manifest,
|
||||
outcome: { kind: "completed" },
|
||||
cleanupCause: expect.objectContaining({
|
||||
message: "test holds the skill handle open",
|
||||
}),
|
||||
});
|
||||
expect(close.attempts.length).toBeGreaterThan(0);
|
||||
expect(new Set(close.attempts).size).toBe(1);
|
||||
const attemptsBeforeClose = close.attempts.length;
|
||||
@@ -147,7 +159,7 @@ describe("skill regular-file cleanup ownership", () => {
|
||||
});
|
||||
|
||||
describe("skill cleanup diagnostics", () => {
|
||||
it("preserves a doctor report and cleanup diagnostic when host close still fails", async () => {
|
||||
it("preserves read and host cleanup failures when doctor identity cannot complete", async () => {
|
||||
const home = await createTestTempDirectory("rea-doctor-skill-close-");
|
||||
const [destination] = skillDestinations(home, []);
|
||||
if (destination === undefined) throw new Error("Shared skill root missing");
|
||||
@@ -172,15 +184,23 @@ describe("skill cleanup diagnostics", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const report = await runDoctorOwned(undefined, host, { skill: true });
|
||||
|
||||
expect(report.healthy).toBe(false);
|
||||
expect(report.identity?.skill.installed_version).toBeNull();
|
||||
expect(report.checks).toContainEqual(
|
||||
const failure = await runDoctorOwned(undefined, host, {
|
||||
skill: true,
|
||||
}).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toBeInstanceOf(AggregateError);
|
||||
if (!(failure instanceof AggregateError))
|
||||
throw new Error("Doctor cleanup should preserve both failures");
|
||||
expect(failure.errors[0]).toBeInstanceOf(RegularFileCleanupFailure);
|
||||
expect(failure.errors[0]).toMatchObject({
|
||||
path: manifest,
|
||||
outcome: { kind: "completed" },
|
||||
});
|
||||
expect(failure.errors[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "doctor-resource-cleanup",
|
||||
ok: false,
|
||||
detail: expect.stringContaining(manifest),
|
||||
message: expect.stringContaining(manifest),
|
||||
}),
|
||||
);
|
||||
expect(close.attempts.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
|
||||
|
||||
import {
|
||||
compareManagedMembersEvidenceValidated,
|
||||
@@ -19,6 +21,11 @@ import { compareManagedMembersInputSchema } from "../../../../src/domain/managed
|
||||
import { inspectManagedMembersBytes } from "../../../../src/dotnet/ManagedMemberInspector.js";
|
||||
import { managedPeFixtureTarget } from "../../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import {
|
||||
MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
readManagedSnapshot,
|
||||
} from "../../../../src/dotnet/ManagedSnapshot.js";
|
||||
import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js";
|
||||
|
||||
it.each([
|
||||
["accessibility", 6, 0x0011],
|
||||
@@ -184,6 +191,14 @@ describe("managed member comparison path workflow", () => {
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
});
|
||||
expect(result.value.parameters).toMatchObject({
|
||||
left_path: leftPath,
|
||||
right_path: rightPath,
|
||||
});
|
||||
expect(result.value.locations).toEqual([
|
||||
{ kind: "artifact-path", path: leftPath },
|
||||
{ kind: "artifact-path", path: rightPath },
|
||||
]);
|
||||
expect(
|
||||
managedMemberComparisonResultSchema.parse(result.value.normalized_result)
|
||||
.matching.exact_il_signature,
|
||||
@@ -205,7 +220,179 @@ describe("managed member comparison path workflow", () => {
|
||||
details: { path: expect.stringContaining("missing.dll") },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed comparison post-admission read failures", () => {
|
||||
it.each([
|
||||
["left", "disappearance", "ENOENT"],
|
||||
["left", "permission denial", "EACCES"],
|
||||
["right", "disappearance", "ENOENT"],
|
||||
["right", "permission denial", "EACCES"],
|
||||
])(
|
||||
"classifies a %s-side %s during the post-resolution read",
|
||||
async (side, _name, code) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-read-failure-",
|
||||
);
|
||||
const leftPath = join(directory, "left.dll");
|
||||
const rightPath = join(directory, "right.dll");
|
||||
await writeFile(leftPath, buildManagedPeFixture());
|
||||
await writeFile(rightPath, buildManagedPeFixture());
|
||||
|
||||
const result = await compareManagedMemberPaths(
|
||||
{ leftPath, rightPath },
|
||||
{
|
||||
resolveTarget: parseBinaryTarget,
|
||||
readSnapshot: async (path, operation, options) => {
|
||||
if (path === (side === "left" ? leftPath : rightPath)) {
|
||||
const failure = Object.assign(new Error(`read failed: ${code}`), {
|
||||
code,
|
||||
});
|
||||
throw failure;
|
||||
}
|
||||
return readManagedSnapshot(path, operation, options);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("BinaryTargetError");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { path: side === "left" ? leftPath : rightPath },
|
||||
});
|
||||
if (side === "right")
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "inspect_managed_members",
|
||||
subject: {
|
||||
local_path: leftPath,
|
||||
digest: { sha256: expect.stringMatching(/^[0-9a-f]{64}$/u) },
|
||||
},
|
||||
});
|
||||
else expect(result.error.partialObservation).toBeUndefined();
|
||||
if (code === "EACCES")
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("managed comparison snapshot admission and cleanup", () => {
|
||||
it("rejects a sparse oversized snapshot through the default reader before reading it", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-oversized-snapshot-",
|
||||
);
|
||||
const leftPath = join(directory, "oversized.dll");
|
||||
const rightPath = join(directory, "right.dll");
|
||||
const fixture = buildManagedPeFixture();
|
||||
await writeFile(leftPath, Buffer.alloc(0));
|
||||
await truncate(leftPath, MAX_MANAGED_SNAPSHOT_BYTES + 1);
|
||||
await writeFile(rightPath, fixture);
|
||||
const template = managedPeFixtureTarget(fixture);
|
||||
const read = vi.spyOn(boundedFileBytes, "readBoundedFileBytes");
|
||||
try {
|
||||
const result = await compareManagedMemberPaths(
|
||||
{ leftPath, rightPath },
|
||||
{
|
||||
resolveTarget: async (path) => ({
|
||||
ok: true,
|
||||
value: { ...template, path },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
operation: "compare_managed_members",
|
||||
reportedLimits: {
|
||||
max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
},
|
||||
});
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a transient snapshot close failure through the same resource owner", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-compare-close-",
|
||||
);
|
||||
const leftPath = join(directory, "left.dll");
|
||||
const rightPath = join(directory, "right.dll");
|
||||
await writeFile(leftPath, buildManagedPeFixture());
|
||||
await writeFile(rightPath, buildManagedPeFixture());
|
||||
const resources = new ArtifactResourceScope();
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
let attempts = 0;
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("injected transient close failure");
|
||||
await originalClose.call(this);
|
||||
});
|
||||
try {
|
||||
const result = await compareManagedMemberPaths(
|
||||
{ leftPath, rightPath },
|
||||
{},
|
||||
resources,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(owners[0]).toBe(owners[1]);
|
||||
expect(attempts).toBe(3);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("projects persistent snapshot cleanup failure and retains its exact owner", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-compare-cleanup-",
|
||||
);
|
||||
const leftPath = join(directory, "left.dll");
|
||||
const rightPath = join(directory, "right.dll");
|
||||
await writeFile(leftPath, buildManagedPeFixture());
|
||||
await writeFile(rightPath, buildManagedPeFixture());
|
||||
const resources = new ArtifactResourceScope();
|
||||
const cleanupCause = new Error("injected persistent close failure");
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
throw cleanupCause;
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof compareManagedMemberPaths>>;
|
||||
try {
|
||||
result = await compareManagedMemberPaths(
|
||||
{ leftPath, rightPath },
|
||||
{},
|
||||
resources,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { cleanup: "incomplete", resources: [leftPath] },
|
||||
});
|
||||
expect(owners[0]).toBe(owners[1]);
|
||||
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed comparison target identity", () => {
|
||||
it("rejects a file that changes after target identity is resolved", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-compare-race-",
|
||||
@@ -227,7 +414,6 @@ describe("managed member comparison path workflow", () => {
|
||||
await writeFile(path, replacement);
|
||||
return target;
|
||||
},
|
||||
readBytes: (path) => readFile(path),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { truncate, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -15,6 +15,180 @@ import {
|
||||
} from "../../../../src/domain/managed/managedArtifact.js";
|
||||
import { ManagedStaticProvider } from "../../../../src/dotnet/ManagedStaticProvider.js";
|
||||
import { buildManagedPeFixture } from "../../../../src/dotnet/ManagedPe.fixture.js";
|
||||
import {
|
||||
MAX_MANAGED_SNAPSHOT_BYTES,
|
||||
readManagedSnapshot,
|
||||
} from "../../../../src/dotnet/ManagedSnapshot.js";
|
||||
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
|
||||
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { RegularFileCleanupFailure } from "../../../../src/application/RegularFileRead.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js";
|
||||
|
||||
describe("managed static snapshot capacity failure", () => {
|
||||
it("preserves a typed capacity failure when closing the admitted file also fails", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-capacity-cleanup-",
|
||||
);
|
||||
const path = join(directory, "sparse.exe");
|
||||
await writeFile(path, Buffer.alloc(0));
|
||||
await truncate(path, MAX_MANAGED_SNAPSHOT_BYTES + 1);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
let permitClose = false;
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
if (!permitClose) throw new Error("fixture handle close failure");
|
||||
await originalClose.call(this);
|
||||
});
|
||||
try {
|
||||
const failure = await readManagedSnapshot(
|
||||
path,
|
||||
"inspect_managed_artifact",
|
||||
{
|
||||
resources,
|
||||
},
|
||||
).catch((cause: unknown) => cause);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
cleanupResources: [path],
|
||||
diagnostics: { primary_error: { code: "resource_constraint" } },
|
||||
});
|
||||
expect(owners.length).toBeGreaterThan(0);
|
||||
expect(owners.every((owner) => owner === owners[0])).toBe(true);
|
||||
|
||||
permitClose = true;
|
||||
await resources.close();
|
||||
} finally {
|
||||
permitClose = true;
|
||||
await resources.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed static snapshot cancellation", () => {
|
||||
it("preserves cancellation when snapshot cleanup also fails", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-managed-cancel-cleanup-",
|
||||
);
|
||||
const path = join(directory, "fixture.exe");
|
||||
await writeFile(path, buildManagedPeFixture());
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
const client = new ManagedStaticProvider().createClient(parsed.value);
|
||||
const controller = new AbortController();
|
||||
const originalRead = boundedFileBytes.readBoundedFileBytes;
|
||||
const read = vi
|
||||
.spyOn(boundedFileBytes, "readBoundedFileBytes")
|
||||
.mockImplementation(async (...arguments_) => {
|
||||
controller.abort(new Error("fixture cancellation"));
|
||||
return originalRead(...arguments_);
|
||||
});
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
let permitClose = false;
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
if (!permitClose) throw new Error("fixture handle close failure");
|
||||
await originalClose.call(this);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"inspect_managed_artifact",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
diagnostics: {
|
||||
primary_error: { code: "cancelled" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
||||
if (!(result.error.cause instanceof AggregateError)) return;
|
||||
const cleanupError = result.error.cause.errors[1];
|
||||
expect(cleanupError).toBeInstanceOf(Error);
|
||||
if (!(cleanupError instanceof Error)) return;
|
||||
expect(cleanupError.cause).toBeInstanceOf(RegularFileCleanupFailure);
|
||||
if (!(cleanupError.cause instanceof RegularFileCleanupFailure)) return;
|
||||
expect(cleanupError.cause.owner).toBe(owners[0]);
|
||||
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
|
||||
|
||||
permitClose = true;
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(owners.every((owner) => owner === owners[0])).toBe(true);
|
||||
} finally {
|
||||
permitClose = true;
|
||||
await client.close();
|
||||
close.mockRestore();
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed static file ownership", () => {
|
||||
it("retains the same failed file owner, blocks reads, and retries it on client close", async () => {
|
||||
const directory = await createTestTempDirectory("rea-managed-close-");
|
||||
const bytes = buildManagedPeFixture();
|
||||
const path = join(directory, "fixture.exe");
|
||||
await writeFile(path, bytes);
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
const client = new ManagedStaticProvider().createClient(parsed.value);
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const owners: OwnedFileHandle[] = [];
|
||||
let permitClose = false;
|
||||
const close = vi
|
||||
.spyOn(OwnedFileHandle.prototype, "close")
|
||||
.mockImplementation(async function (this: OwnedFileHandle) {
|
||||
owners.push(this);
|
||||
if (!permitClose) throw new Error("fixture handle close failure");
|
||||
await originalClose.call(this);
|
||||
});
|
||||
try {
|
||||
const failed = await client.execute("inspect_managed_artifact", {});
|
||||
expect(failed).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
cleanupResources: [path],
|
||||
},
|
||||
});
|
||||
|
||||
const blocked = await client.execute("inspect_managed_artifact", {});
|
||||
expect(blocked).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
cleanupResources: [path],
|
||||
},
|
||||
});
|
||||
expect(owners).toHaveLength(3);
|
||||
expect(owners.every((owner) => owner === owners[0])).toBe(true);
|
||||
|
||||
permitClose = true;
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(owners).toHaveLength(4);
|
||||
expect(owners.every((owner) => owner === owners[0])).toBe(true);
|
||||
} finally {
|
||||
permitClose = true;
|
||||
await client.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed static provider path boundary", () => {
|
||||
it("executes as a read-only auxiliary provider with digest, cancellation, and format boundaries", async () => {
|
||||
|
||||
@@ -219,7 +219,11 @@ describe("managed one-shot cleanup outcomes", () => {
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.cause).toBe(primaryError);
|
||||
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
||||
expect(result.error.cause).toMatchObject({
|
||||
cause: primaryError,
|
||||
errors: [primaryError, cleanupError],
|
||||
});
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
|
||||
Reference in New Issue
Block a user