mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
fix(providers): preserve bounded artifact reads and cleanup owners
Refs #4373, #4455, #4457, #4458. Use canonical artifact ownership in Go, Rust, SQLite and PE readers and their failure consumers. Admit PE read budgets before allocation and ignore zero-extent sections in interval ownership. Close Rust resources through CLI and MCP lifecycles.
This commit is contained in:
+8
-18
@@ -584,22 +584,6 @@ const hardLinksUnsupported = (cause: unknown): boolean =>
|
||||
const describeAdbFailure = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const cleanupLeasesForRetryFailure = (
|
||||
cause: unknown,
|
||||
): readonly AnalysisCleanupLease[] | undefined => {
|
||||
if (!(cause instanceof ArtifactResourceRetryFailure)) return undefined;
|
||||
const leases = new Set<AnalysisCleanupLease>();
|
||||
for (const owner of cause.failedOwners) {
|
||||
if (
|
||||
owner.kind !== "lease" ||
|
||||
!(owner.lease instanceof AnalysisCleanupLease)
|
||||
)
|
||||
return undefined;
|
||||
leases.add(owner.lease);
|
||||
}
|
||||
return leases.size > 0 ? [...leases] : undefined;
|
||||
};
|
||||
|
||||
interface ScreenPublicationCloseFailure {
|
||||
readonly cause: unknown;
|
||||
readonly resource: string;
|
||||
@@ -1046,7 +1030,10 @@ export class AdbProvider {
|
||||
const leases =
|
||||
(cause instanceof AnalysisError
|
||||
? this.#outputDirectoryRetryLeases.get(cause)
|
||||
: undefined) ?? cleanupLeasesForRetryFailure(cause);
|
||||
: undefined) ??
|
||||
(cause instanceof ArtifactResourceRetryFailure
|
||||
? cause.cleanupLeases
|
||||
: undefined);
|
||||
if (leases === undefined) allFailuresHaveExactLeaseOwners = false;
|
||||
for (const lease of leases ?? []) failedLeases.add(lease);
|
||||
}
|
||||
@@ -1390,7 +1377,10 @@ export class AdbProvider {
|
||||
[cause],
|
||||
this.#cleanupResources(cause, "ADB provider-owned resource"),
|
||||
);
|
||||
const leases = cleanupLeasesForRetryFailure(cause);
|
||||
const leases =
|
||||
cause instanceof ArtifactResourceRetryFailure
|
||||
? cause.cleanupLeases
|
||||
: undefined;
|
||||
if (leases !== undefined) new AnalysisCleanupLeaseGroup(error, leases);
|
||||
return err(error);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Result } from "../../domain/result.js";
|
||||
/** Replaceable offline dependency reader with no target-execution authority. */
|
||||
export interface RustBinaryPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectRustBinaryInput,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -36,6 +36,10 @@ const architectureForSubject = (architecture: string) => {
|
||||
export class RustBinaryService {
|
||||
constructor(readonly provider: RustBinaryPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Inspect one explicitly selected file without acquiring a binary session. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
|
||||
@@ -68,7 +68,9 @@ export const webArtifactReadError = (
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
||||
const primary =
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError(context.operation, { cause })
|
||||
? new AnalysisCancelledError(context.operation, {
|
||||
cause: signal.reason,
|
||||
})
|
||||
: new ArtifactOperationError(
|
||||
context.operation,
|
||||
cause.reason,
|
||||
@@ -88,7 +90,9 @@ export const webArtifactReadError = (
|
||||
);
|
||||
}
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(context.operation, { cause });
|
||||
return new AnalysisCancelledError(context.operation, {
|
||||
cause: signal.reason,
|
||||
});
|
||||
const selection = context.callerSelected
|
||||
? selectedFileError(cause, context)
|
||||
: undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { artifactFileCancellationCause } from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
@@ -90,7 +91,10 @@ export const historicalCaptureFailure = (
|
||||
cause,
|
||||
);
|
||||
}
|
||||
if (options?.signal?.aborted) return new AnalysisCancelledError(OPERATION);
|
||||
if (options?.signal?.aborted)
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
});
|
||||
if (
|
||||
phase === "capture-read" &&
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
@@ -107,7 +111,9 @@ export const historicalCaptureFailure = (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.reason === "cancelled"
|
||||
)
|
||||
return new AnalysisCancelledError(OPERATION);
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (
|
||||
phase === "capture-read" &&
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
|
||||
+18
-11
@@ -2,7 +2,7 @@ import { z } from "incur";
|
||||
import { createRustBinaryService } from "../composition/rust.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
@@ -13,7 +13,6 @@ export const registerRustCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
const service = createRustBinaryService();
|
||||
cli.command(CLI_COMMANDS.inspectRustBinary, {
|
||||
description:
|
||||
"Inspect an embedded cargo-auditable Rust dependency list without target execution",
|
||||
@@ -21,14 +20,22 @@ export const registerRustCommands = (
|
||||
path: z.string().describe("Local ELF, PE or thin Mach-O image"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
|
||||
const result = await service.inspect(
|
||||
{ path: absoluteCallerPath(args.path) },
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
withCommandCancellation((signal) => {
|
||||
const service = createRustBinaryService();
|
||||
return logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, () =>
|
||||
finishAnalysisCommand(
|
||||
() =>
|
||||
service.inspect(
|
||||
{ path: absoluteCallerPath(args.path) },
|
||||
{ signal },
|
||||
),
|
||||
service,
|
||||
"rust-auditable",
|
||||
"inspect_rust_binary",
|
||||
undefined,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -341,7 +341,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
if (options?.signal?.aborted)
|
||||
throw new AnalysisCancelledError(OPERATION, {
|
||||
cause,
|
||||
cause: options.signal.reason,
|
||||
capturedOutput,
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
});
|
||||
|
||||
+83
-56
@@ -1,4 +1,10 @@
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
assertArtifactReadActive,
|
||||
bindArtifactFileCleanup,
|
||||
artifactFileCancellationCause,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
@@ -40,19 +46,16 @@ const OPERATION = "inspect_go_binary";
|
||||
const INPUT_BYTES = 256 * 1024 * 1024;
|
||||
const OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const failureForPrimary = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION, { cause });
|
||||
if (signal?.aborted) return new AnalysisCancelledError(OPERATION, { cause });
|
||||
return new AnalysisCancelledError(OPERATION, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (cause instanceof GoBinaryResourceFailure)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
@@ -119,7 +122,13 @@ const failureFor = (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
const primary = failureForPrimary(cause, path, signal);
|
||||
const primary =
|
||||
cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "failed"
|
||||
? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path)
|
||||
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
|
||||
? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
|
||||
: failureForPrimary(cause, path);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||
return primary;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
@@ -128,7 +137,9 @@ const failureFor = (
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ operation: OPERATION, cause },
|
||||
);
|
||||
return analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
|
||||
const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
const outputLimit = (path: string) =>
|
||||
@@ -174,8 +185,24 @@ export class GoBinaryProvider implements GoBinaryPort {
|
||||
readonly identity = GO_BINARY_PROVIDER_IDENTITY;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#resources.close();
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#resources.close();
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
cause.cleanup === undefined
|
||||
)
|
||||
throw cause;
|
||||
const error = new ProviderCleanupError(
|
||||
GO_BINARY_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read complete compiler/module metadata while preserving original byte identity and source ranges. */
|
||||
@@ -184,57 +211,57 @@ export class GoBinaryProvider implements GoBinaryPort {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<GoBinary, AnalysisError>> {
|
||||
try {
|
||||
const snapshot = await this.#resources.run(() =>
|
||||
readStableArtifact(
|
||||
return await this.#resources.run(async () => {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
INPUT_BYTES,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
),
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
const image = readGoBinaryImage(snapshot.bytes);
|
||||
const info = image.build_info;
|
||||
if (
|
||||
info !== null &&
|
||||
((info.module_text !== null &&
|
||||
Buffer.byteLength(info.module_text) > OUTPUT_BYTES) ||
|
||||
Buffer.byteLength(info.module_bytes_base64) > OUTPUT_BYTES)
|
||||
)
|
||||
throw outputLimit(input.path);
|
||||
const module =
|
||||
info === null
|
||||
? null
|
||||
: info.module_text === null
|
||||
? parseGoModuleBytes(
|
||||
Buffer.from(info.module_bytes_base64, "base64").subarray(
|
||||
16,
|
||||
-16,
|
||||
),
|
||||
)
|
||||
: parseGoModuleText(info.module_text);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
build_info: info === null ? null : { ...info, module },
|
||||
limitations: limitationsFor(info, module),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = goBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Go reader produced invalid source ranges or report values.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(parsed.data);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const image = readGoBinaryImage(snapshot.bytes);
|
||||
const info = image.build_info;
|
||||
if (
|
||||
info !== null &&
|
||||
((info.module_text !== null &&
|
||||
Buffer.byteLength(info.module_text) > OUTPUT_BYTES) ||
|
||||
Buffer.byteLength(info.module_bytes_base64) > OUTPUT_BYTES)
|
||||
)
|
||||
throw outputLimit(input.path);
|
||||
const module =
|
||||
info === null
|
||||
? null
|
||||
: info.module_text === null
|
||||
? parseGoModuleBytes(
|
||||
Buffer.from(info.module_bytes_base64, "base64").subarray(
|
||||
16,
|
||||
-16,
|
||||
),
|
||||
)
|
||||
: parseGoModuleText(info.module_text);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
build_info: info === null ? null : { ...info, module },
|
||||
limitations: limitationsFor(info, module),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = goBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Go reader produced invalid source ranges or report values.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
return ok(parsed.data);
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(failureFor(cause, input.path, options?.signal));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactReaderBoundary } from "../../artifacts/ArtifactReader.js";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
@@ -41,7 +42,7 @@ export const parsePeResources = async (
|
||||
input: InspectPeResourcesInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PeResources> => {
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
const layout = readPeResourceLayout(bytes);
|
||||
const directories: PeResources["directories"] = [];
|
||||
const resources: PeResources["resources"] = [];
|
||||
@@ -145,7 +146,7 @@ export const parsePeResources = async (
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (index % 128 === 0) {
|
||||
await setImmediate();
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
}
|
||||
const entry = header + 16 + index * 8;
|
||||
const encoded = bytes.readUInt32LE(entry);
|
||||
@@ -212,7 +213,7 @@ export const parsePeResources = async (
|
||||
const hash = createHash("sha256");
|
||||
for (let offset = 0; offset < range.bytes; offset += 1048576) {
|
||||
await setImmediate();
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
hash.update(
|
||||
bytes.subarray(
|
||||
range.offset + offset,
|
||||
@@ -252,7 +253,7 @@ export const parsePeResources = async (
|
||||
"Exact shared byte ranges are retained; non-identical overlapping resource structures or payloads are rejected.",
|
||||
],
|
||||
};
|
||||
signal?.throwIfAborted();
|
||||
assertArtifactReadActive(signal);
|
||||
if (jsonBytes(report, METADATA_BYTES) > METADATA_BYTES)
|
||||
limit("PE resource result exceeds the 8 MiB allocation budget.", "memory");
|
||||
return report;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
bindArtifactFileCleanup,
|
||||
artifactFileCancellationCause,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import type { PeResourcesPort } from "../../application/binaryDiagnostics/PeResourcesPort.js";
|
||||
@@ -27,33 +32,49 @@ export class PeResourcesProvider implements PeResourcesPort {
|
||||
readonly identity = PE_RESOURCES_PROVIDER;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.#resources.close();
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#resources.close();
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
cause.cleanup === undefined
|
||||
)
|
||||
throw cause;
|
||||
const error = new ProviderCleanupError(
|
||||
PE_RESOURCES_PROVIDER.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: "inspect_pe_resources" },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(input: InspectPeResourcesInput, options?: ExecutionOptions) {
|
||||
try {
|
||||
const snapshot = await this.#resources.run(() =>
|
||||
readStableArtifact(
|
||||
return await this.#resources.run(async () => {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
input.max_file_bytes,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
await parsePeResources(
|
||||
snapshot.bytes,
|
||||
{
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
input,
|
||||
options?.signal,
|
||||
),
|
||||
);
|
||||
);
|
||||
return ok(
|
||||
await parsePeResources(
|
||||
snapshot.bytes,
|
||||
{
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
input,
|
||||
options?.signal,
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (cause) {
|
||||
return err(peResourcesFailure(cause, input, options?.signal));
|
||||
}
|
||||
@@ -80,7 +101,6 @@ export const inputBudgetRemediation = (
|
||||
const peResourcesFailurePrimary = (
|
||||
cause: unknown,
|
||||
input: InspectPeResourcesInput,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const operation = "inspect_pe_resources";
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
@@ -91,8 +111,9 @@ const peResourcesFailurePrimary = (
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(operation, { cause });
|
||||
if (signal?.aborted) return new AnalysisCancelledError(operation, { cause });
|
||||
return new AnalysisCancelledError(operation, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
// An input byte budget is a file-size bound, not a memory constraint, and
|
||||
@@ -160,7 +181,15 @@ const peResourcesFailure = (
|
||||
input: InspectPeResourcesInput,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const primary = peResourcesFailurePrimary(cause, input, signal);
|
||||
const primary =
|
||||
cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "failed"
|
||||
? peResourcesFailurePrimary(cause.fileCleanupFailure.outcome.cause, input)
|
||||
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
|
||||
? new AnalysisCancelledError("inspect_pe_resources", {
|
||||
cause: signal.reason,
|
||||
})
|
||||
: peResourcesFailurePrimary(cause, input);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||
return primary;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
@@ -169,9 +198,11 @@ const peResourcesFailure = (
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ operation: "inspect_pe_resources", cause },
|
||||
);
|
||||
return analysisErrorWithCleanupFailure(
|
||||
const error = analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
cleanup,
|
||||
"inspect_pe_resources",
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
bindArtifactFileCleanup,
|
||||
ArtifactFileCleanupFailure,
|
||||
artifactFileCancellationCause,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { access, mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -17,6 +22,7 @@ import {
|
||||
type RecordedCrash,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
@@ -186,19 +192,40 @@ export const inspectPwndbgCore = async ({
|
||||
);
|
||||
reply = replySchema.parse(JSON.parse(raw.bytes.toString("utf8")));
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
throw new AnalysisCancelledError(OPERATION, {
|
||||
cause,
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
(cause.reason === "cancelled" ||
|
||||
(cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "completed" &&
|
||||
options?.signal?.aborted))
|
||||
) {
|
||||
const error = new AnalysisCancelledError(OPERATION, {
|
||||
cause:
|
||||
cause.reason === "cancelled"
|
||||
? artifactFileCancellationCause(cause)
|
||||
: options?.signal?.reason,
|
||||
capturedOutput: recordedCrashStageOutput(diagnostics, output),
|
||||
...(cause instanceof ArtifactReaderFailure &&
|
||||
cause.cleanup !== undefined
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
});
|
||||
throw new AnalysisOutputError(
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
const error = new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`Core-only debugger reply failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{
|
||||
cause,
|
||||
...(cause instanceof ArtifactReaderFailure
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
capturedOutput: recordedCrashStageOutput(diagnostics, output),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
if (!reply.ok) {
|
||||
if (reply.reason === "unavailable" || reply.reason === "unsupported")
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
bindArtifactFileCleanup,
|
||||
ArtifactFileCleanupFailure,
|
||||
artifactFileCancellationCause,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import {
|
||||
pwntoolsDecoderFailure,
|
||||
pwntoolsUnavailable,
|
||||
@@ -322,16 +327,40 @@ export class PwntoolsDecoder<
|
||||
);
|
||||
reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
throw new AnalysisCancelledError(OPERATION, {
|
||||
cause,
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
(cause.reason === "cancelled" ||
|
||||
(cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "completed" &&
|
||||
options?.signal?.aborted))
|
||||
) {
|
||||
const error = new AnalysisCancelledError(OPERATION, {
|
||||
cause:
|
||||
cause.reason === "cancelled"
|
||||
? artifactFileCancellationCause(cause)
|
||||
: options?.signal?.reason,
|
||||
capturedOutput,
|
||||
...(cause instanceof ArtifactReaderFailure &&
|
||||
cause.cleanup !== undefined
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
});
|
||||
throw new AnalysisOutputError(
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
const error = new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`Owned pwntools decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause, capturedOutput },
|
||||
{
|
||||
cause,
|
||||
capturedOutput,
|
||||
...(cause instanceof ArtifactReaderFailure
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
if (!reply.ok) {
|
||||
if (reply.reason === "unsupported-target")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
assertArtifactReadActive,
|
||||
bindArtifactFileCleanup,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js";
|
||||
@@ -39,6 +47,27 @@ const OPERATION = "inspect_rust_binary";
|
||||
/** Stable in-process dependency inspection without subprocesses, temporary files, or a Rust toolchain. */
|
||||
export class RustBinaryProvider implements RustBinaryPort {
|
||||
readonly identity = RUST_BINARY_PROVIDER_IDENTITY;
|
||||
readonly #resources = new ArtifactResourceScope();
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#resources.close();
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
cause.cleanup === undefined
|
||||
)
|
||||
throw cause;
|
||||
const error = new ProviderCleanupError(
|
||||
RUST_BINARY_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one dependency section and any inferred rustc commit from the selected file. */
|
||||
async inspect(
|
||||
@@ -46,33 +75,39 @@ export class RustBinaryProvider implements RustBinaryPort {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<RustBinary, AnalysisError>> {
|
||||
try {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
RUST_BINARY_INPUT_BYTES,
|
||||
options?.signal,
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
const image = readRustBinaryImage(snapshot.bytes);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
limitations: limitationsFor(image),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = rustBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Rust reader produced an invalid dependency report.",
|
||||
{ cause: parsed.error },
|
||||
return await this.#resources.run(async () => {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
RUST_BINARY_INPUT_BYTES,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(parsed.data);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const image = readRustBinaryImage(snapshot.bytes);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
limitations: limitationsFor(image),
|
||||
};
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES
|
||||
)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = rustBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Rust reader produced an invalid dependency report.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
assertArtifactReadActive(options?.signal);
|
||||
return ok(parsed.data);
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(failureFor(cause, input.path, options?.signal));
|
||||
}
|
||||
@@ -143,13 +178,8 @@ const rustcLimitation = (
|
||||
];
|
||||
};
|
||||
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted) return new AnalysisCancelledError(OPERATION);
|
||||
if (cause instanceof RustAuditableResourceFailure)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
@@ -173,6 +203,31 @@ const failureFor = (
|
||||
return artifactFailure(cause, path);
|
||||
};
|
||||
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
const primary =
|
||||
cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "failed"
|
||||
? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path)
|
||||
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
|
||||
? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
|
||||
: failureForPrimary(cause, path);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||
return primary;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
RUST_BINARY_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{ reason: cause.cleanup.reason },
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
};
|
||||
|
||||
const artifactFailure = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return artifactReaderFailure(cause, path);
|
||||
@@ -212,7 +267,7 @@ const artifactReaderFailure = (
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION);
|
||||
return new AnalysisCancelledError(OPERATION, { cause: cause.cause });
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
|
||||
@@ -310,6 +310,7 @@ export const createServer = (
|
||||
const webModuleTrace =
|
||||
options.webModuleTrace ?? createWebModuleTraceService(environment);
|
||||
const goBinary = options.goBinary ?? createGoBinaryService();
|
||||
const rustBinary = options.rustBinary ?? createRustBinaryService();
|
||||
const peResources = createPeResourcesService();
|
||||
const webNetworkCapture =
|
||||
options.webNetworkCapture ?? createWebNetworkCaptureService(environment);
|
||||
@@ -387,6 +388,7 @@ export const createServer = (
|
||||
webSourceLocation.close(),
|
||||
webModuleTrace.close(),
|
||||
goBinary.close(),
|
||||
rustBinary.close(),
|
||||
peResources.close(),
|
||||
webNetworkCapture.close(),
|
||||
javascriptRecovery.close?.() ?? Promise.resolve(),
|
||||
@@ -444,6 +446,7 @@ export const createServer = (
|
||||
withAdmittedAnalysis: analysisAdmission,
|
||||
artifactResources,
|
||||
goBinary,
|
||||
rustBinary,
|
||||
peResources,
|
||||
webNetworkCapture,
|
||||
};
|
||||
@@ -642,6 +645,7 @@ const registerConfiguredAnalysisTools = (
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
goBinary,
|
||||
rustBinary,
|
||||
peResources,
|
||||
webNetworkCapture,
|
||||
}: ServerToolContext,
|
||||
@@ -698,7 +702,7 @@ const registerConfiguredAnalysisTools = (
|
||||
);
|
||||
registerRustTools(
|
||||
server,
|
||||
options.rustBinary ?? createRustBinaryService(),
|
||||
rustBinary,
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
@@ -809,6 +813,7 @@ const createSessionRecorders = (
|
||||
|
||||
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
|
||||
readonly goBinary: GoBinaryService;
|
||||
readonly rustBinary: RustBinaryService;
|
||||
readonly peResources: PeResourcesService;
|
||||
readonly webNetworkCapture: WebNetworkCaptureService;
|
||||
readonly artifactResources: ArtifactResourceScope;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
bindArtifactFileCleanup,
|
||||
artifactFileCancellationCause,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
@@ -49,25 +54,32 @@ export const sqliteDatabaseFailure = (
|
||||
const operation = "inspect_sqlite_database";
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.cleanup !== undefined)
|
||||
return new ProviderCleanupError(
|
||||
if (cause.cleanup !== undefined) {
|
||||
const physicalRead =
|
||||
cause instanceof ArtifactFileCleanupFailure
|
||||
? cause.fileCleanupFailure.outcome
|
||||
: undefined;
|
||||
const previous =
|
||||
physicalRead?.kind === "failed"
|
||||
? sqliteDatabaseFailure(physicalRead.cause, path, phase)
|
||||
: signal?.aborted
|
||||
? new AnalysisCancelledError(operation, { cause: signal.reason })
|
||||
: physicalRead?.kind === "completed" || cause.cause === undefined
|
||||
? undefined
|
||||
: sqliteDatabaseFailure(cause.cause, path, phase);
|
||||
const error = new ProviderCleanupError(
|
||||
SQLITE_PROVIDER_IDENTITY.id,
|
||||
cause.cleanup.resources,
|
||||
{
|
||||
reason: cause.cleanup.reason,
|
||||
previous_error:
|
||||
cause.cause === undefined
|
||||
? null
|
||||
: projectAnalysisError(
|
||||
signal?.aborted
|
||||
? new AnalysisCancelledError(operation, {
|
||||
cause: cause.cause,
|
||||
})
|
||||
: sqliteDatabaseFailure(cause.cause, path, phase),
|
||||
),
|
||||
previous === undefined ? null : projectAnalysisError(previous),
|
||||
},
|
||||
{ operation, cause },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
}
|
||||
if (cause.reason === "io" && cause.cause !== undefined)
|
||||
return sqliteDatabaseFailure(cause.cause, path, phase, signal);
|
||||
if (cause.reason === "integrity")
|
||||
@@ -75,7 +87,9 @@ export const sqliteDatabaseFailure = (
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(operation, { cause });
|
||||
return new AnalysisCancelledError(operation, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/sqliteDatabase.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
withArtifactFileHandle,
|
||||
assertArtifactReadActive,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import {
|
||||
readStableArtifact,
|
||||
type StableArtifactFileSystem,
|
||||
@@ -180,61 +183,51 @@ const verifyInactiveJournal = async (
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
),
|
||||
);
|
||||
const owner = new OwnedFileHandle(file);
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (!sameState(before, await file.stat({ bigint: true })))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`SQLite journal changed before open: ${path}`,
|
||||
);
|
||||
// TRUNCATE commits leave an empty file; PERSIST commits zero the complete
|
||||
// 28-byte fixed header. A new active journal may zero only its magic bytes.
|
||||
const header = Buffer.alloc(28);
|
||||
let bytesRead = 0;
|
||||
if (before.size > 0n) {
|
||||
while (bytesRead < header.length) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await file.read(
|
||||
header,
|
||||
bytesRead,
|
||||
header.length - bytesRead,
|
||||
bytesRead,
|
||||
await withArtifactFileHandle(
|
||||
path,
|
||||
file,
|
||||
resourceScope,
|
||||
signal,
|
||||
async (file) => {
|
||||
if (!sameState(before, await file.stat({ bigint: true })))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`SQLite journal changed before open: ${path}`,
|
||||
);
|
||||
if (read.bytesRead === 0) break;
|
||||
bytesRead += read.bytesRead;
|
||||
// TRUNCATE commits leave an empty file; PERSIST commits zero the complete
|
||||
// 28-byte fixed header. A new active journal may zero only its magic bytes.
|
||||
const header = Buffer.alloc(28);
|
||||
let bytesRead = 0;
|
||||
if (before.size > 0n) {
|
||||
while (bytesRead < header.length) {
|
||||
assertArtifactReadActive(signal);
|
||||
const read = await file.read(
|
||||
header,
|
||||
bytesRead,
|
||||
header.length - bytesRead,
|
||||
bytesRead,
|
||||
);
|
||||
if (read.bytesRead === 0) break;
|
||||
bytesRead += read.bytesRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sameState(before, await file.stat({ bigint: true })))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`SQLite journal changed during inspection: ${path}`,
|
||||
);
|
||||
if (
|
||||
before.size !== 0n &&
|
||||
(bytesRead !== header.length || header.some((byte) => byte !== 0))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
phase === "initial" ? "format" : "integrity",
|
||||
phase === "initial"
|
||||
? `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.`
|
||||
: `Rollback journal became active or malformed during database acquisition: ${path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
failure = cause;
|
||||
throw cause;
|
||||
} finally {
|
||||
const cleanup = await resourceScope.release({
|
||||
kind: "file-handle",
|
||||
resource: path,
|
||||
handle: owner,
|
||||
});
|
||||
if (cleanup.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
failure ?? cleanup.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(cleanup.cause, path),
|
||||
);
|
||||
}
|
||||
if (!sameState(before, await file.stat({ bigint: true })))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`SQLite journal changed during inspection: ${path}`,
|
||||
);
|
||||
if (
|
||||
before.size !== 0n &&
|
||||
(bytesRead !== header.length || header.some((byte) => byte !== 0))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
phase === "initial" ? "format" : "integrity",
|
||||
phase === "initial"
|
||||
? `Rollback journal may require recovery: ${path}. Supply a clean offline snapshot; only empty journals or a fully zeroed header are supported.`
|
||||
: `Rollback journal became active or malformed during database acquisition: ${path}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Absence is a caller value to correct, not malformed path or content.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
bindArtifactFileCleanup,
|
||||
ArtifactFileCleanupFailure,
|
||||
artifactFileCancellationCause,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -176,13 +181,30 @@ export const executeSqliteDatabaseWorker = async (request: {
|
||||
? { cleanup: cause.cleanup }
|
||||
: {}),
|
||||
};
|
||||
if (request.signal?.aborted)
|
||||
throw new AnalysisCancelledError(operation, options);
|
||||
throw new AnalysisOutputError(
|
||||
if (
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
(cause.reason === "cancelled" ||
|
||||
(cause instanceof ArtifactFileCleanupFailure &&
|
||||
cause.fileCleanupFailure.outcome.kind === "completed" &&
|
||||
request.signal?.aborted))
|
||||
) {
|
||||
const error = new AnalysisCancelledError(operation, {
|
||||
...options,
|
||||
cause:
|
||||
cause.reason === "cancelled"
|
||||
? artifactFileCancellationCause(cause)
|
||||
: request.signal?.reason,
|
||||
});
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
const error = new AnalysisOutputError(
|
||||
operation,
|
||||
`Owned SQLite worker reply is missing or malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
options,
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
throw error;
|
||||
}
|
||||
if (!reply.ok) throw rejectedReply(reply, capturedOutput);
|
||||
return reply.inspection;
|
||||
|
||||
@@ -164,7 +164,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
if (options?.signal?.aborted)
|
||||
throw new AnalysisCancelledError(OPERATION, {
|
||||
cause,
|
||||
cause: options.signal.reason,
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
});
|
||||
throw new AnalysisArtifactChangedError(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -48,7 +49,10 @@ export const wabtFailure = (
|
||||
(cause instanceof Error && cause.name === "AbortError")
|
||||
)
|
||||
return new AnalysisCancelledError("inspect_wasm_artifact", {
|
||||
cause,
|
||||
cause:
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? artifactFileCancellationCause(cause)
|
||||
: cause,
|
||||
...cleanupOptions,
|
||||
});
|
||||
if (cause instanceof OwnedCommandFailure) {
|
||||
|
||||
@@ -92,6 +92,103 @@ const expectPeInputFailuresAcrossAdapters = async (input: {
|
||||
|
||||
const contract = toolContract("inspect_pe_resources");
|
||||
|
||||
const expectPeMappingOwnershipAcrossAdapters = async (
|
||||
cli: TestCli,
|
||||
client: Client,
|
||||
root: string,
|
||||
): Promise<void> => {
|
||||
for (const { name, payloadRva, payloadBytes, section, expectedOffset } of [
|
||||
{
|
||||
name: "empty-header",
|
||||
payloadRva: 0x300,
|
||||
payloadBytes: 0,
|
||||
section: null,
|
||||
expectedOffset: 0x300,
|
||||
},
|
||||
{
|
||||
name: "header-section-collision",
|
||||
payloadRva: 0x300,
|
||||
payloadBytes: 0,
|
||||
section: { rva: 0x300, bytes: 4 },
|
||||
expectedOffset: null,
|
||||
},
|
||||
{
|
||||
name: "empty-section-in-header",
|
||||
payloadRva: 0x300,
|
||||
payloadBytes: 4,
|
||||
section: { rva: 0x301, bytes: 0 },
|
||||
expectedOffset: 0x300,
|
||||
},
|
||||
{
|
||||
name: "empty-section-in-resources",
|
||||
payloadRva: null,
|
||||
payloadBytes: 4,
|
||||
section: { rva: 0x2001, bytes: 0 },
|
||||
expectedOffset: "original",
|
||||
},
|
||||
]) {
|
||||
const fixture = peResourceFixture(false, [
|
||||
{ type: 10, name: 1, language: 1033, payload: Buffer.from("DATA") },
|
||||
]);
|
||||
const data = fixture.dataOffsets[0] ?? 0;
|
||||
let bytes = fixture.bytes;
|
||||
const originalPayloadOffset = bytes.readUInt32LE(data) - 0x1c00;
|
||||
if (payloadRva !== null) bytes.writeUInt32LE(payloadRva, data);
|
||||
bytes.writeUInt32LE(payloadBytes, data + 4);
|
||||
if (section !== null) {
|
||||
bytes.writeUInt16LE(2, 0x86);
|
||||
const at = fixture.sectionAt + 40;
|
||||
bytes.writeUInt32LE(section.bytes, at + 8);
|
||||
bytes.writeUInt32LE(section.rva, at + 12);
|
||||
bytes.writeUInt32LE(section.bytes, at + 16);
|
||||
bytes.writeUInt32LE(bytes.length, at + 20);
|
||||
bytes = Buffer.concat([bytes, Buffer.alloc(section.bytes)]);
|
||||
}
|
||||
const path = join(root, `${name}.exe`);
|
||||
await writeFile(path, bytes);
|
||||
if (expectedOffset === null) {
|
||||
await expectPeInputFailureAcrossAdapters({
|
||||
cli,
|
||||
client,
|
||||
toolName: contract.name,
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-pe-resources", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, name).toBe(0);
|
||||
const evidence = contract.outputSchema.parse(result.json);
|
||||
const response = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError, name).not.toBe(true);
|
||||
const mcp = contract.outputSchema.parse(response.structuredContent);
|
||||
expect(mcp.evidence_id).toBe(evidence.evidence_id);
|
||||
expect(mcp.normalized_result).toEqual(evidence.normalized_result);
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
coverage: { status: "complete", resources: 1 },
|
||||
resources: [
|
||||
{
|
||||
payload: {
|
||||
location: {
|
||||
offset:
|
||||
expectedOffset === "original"
|
||||
? originalPayloadOffset
|
||||
: expectedOffset,
|
||||
bytes: payloadBytes,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
}
|
||||
};
|
||||
|
||||
const expectPeInventoriesAcrossAdapters = async (
|
||||
cli: TestCli,
|
||||
client: Client,
|
||||
@@ -201,6 +298,7 @@ cliTest(
|
||||
"inspect_pe_resources",
|
||||
);
|
||||
await expectPeInventoriesAcrossAdapters(cli, client, root);
|
||||
await expectPeMappingOwnershipAcrossAdapters(cli, client, root);
|
||||
await expectPePaddingFailureAcrossAdapters({
|
||||
cli,
|
||||
client,
|
||||
|
||||
@@ -308,6 +308,10 @@ it("retains reply-read cancellation and retries the failed reply descriptor clos
|
||||
descriptorCleanup.denied = false;
|
||||
await selected.provider.close();
|
||||
expect(handle.fd).toBe(-1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
for (const root of selected.roots)
|
||||
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const parsing = vi.hoisted(() => {
|
||||
const gate = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((complete) => {
|
||||
resolve = complete;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
return { gate, entered: gate(), release: gate() };
|
||||
});
|
||||
|
||||
vi.mock("../../src/native/pe/PeResourceParser.js", async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import("../../src/native/pe/PeResourceParser.js")
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
parsePeResources: async (
|
||||
...arguments_: Parameters<typeof original.parsePeResources>
|
||||
) => {
|
||||
parsing.entered.resolve();
|
||||
await parsing.release.promise;
|
||||
return original.parsePeResources(...arguments_);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { inspectPeResourcesInputSchema } from "../../src/domain/native/peResources.js";
|
||||
import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js";
|
||||
import { peResourceFixture } from "../../src/native/pe/PeResources.fixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
|
||||
beforeEach(() => {
|
||||
parsing.entered = parsing.gate();
|
||||
parsing.release = parsing.gate();
|
||||
});
|
||||
|
||||
it.each(["completion", "cancellation"])(
|
||||
"waits for admitted PE parsing through %s before closing",
|
||||
async (outcome) => {
|
||||
const root = await createTestTempDirectory("rea-pe-parsing-lifetime-");
|
||||
const path = join(root, "image.exe");
|
||||
await writeFile(path, peResourceFixture().bytes);
|
||||
const provider = new PeResourcesProvider();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancelled during admitted parsing");
|
||||
const inspection = provider.inspect(
|
||||
inspectPeResourcesInputSchema.parse({ path }),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await parsing.entered.promise;
|
||||
let closed = false;
|
||||
const closing = provider.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
try {
|
||||
if (outcome === "cancellation") controller.abort(reason);
|
||||
await setImmediate();
|
||||
expect(
|
||||
closed,
|
||||
"close reported release while parsing was still admitted",
|
||||
).toBe(false);
|
||||
parsing.release.resolve();
|
||||
const result = await inspection;
|
||||
if (outcome === "cancellation") {
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(result.ok ? undefined : result.error.cause).toBe(reason);
|
||||
} else
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { coverage: { status: "complete" } },
|
||||
});
|
||||
await closing;
|
||||
expect(closed).toBe(true);
|
||||
expect(
|
||||
(await provider.inspect(inspectPeResourcesInputSchema.parse({ path })))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
} finally {
|
||||
parsing.release.resolve();
|
||||
await inspection;
|
||||
await closing;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,421 @@
|
||||
import { writeFile, type FileHandle } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const fault = vi.hoisted(() => ({
|
||||
path: "",
|
||||
blockNew: true,
|
||||
blocked: new Set<FileHandle>(),
|
||||
handles: [] as FileHandle[],
|
||||
onClose: undefined as (() => void) | undefined,
|
||||
readFailure: undefined as { cause: unknown } | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...original,
|
||||
open: async (...arguments_: Parameters<typeof original.open>) => {
|
||||
const handle = await original.open(...arguments_);
|
||||
if (String(arguments_[0]) !== fault.path) return handle;
|
||||
const close = handle.close.bind(handle);
|
||||
fault.handles.push(handle);
|
||||
if (fault.blockNew) fault.blocked.add(handle);
|
||||
if (fault.readFailure !== undefined) {
|
||||
const failure = fault.readFailure;
|
||||
handle.stat = async () => {
|
||||
throw failure.cause;
|
||||
};
|
||||
}
|
||||
handle.close = async () => {
|
||||
fault.onClose?.();
|
||||
if (fault.blocked.has(handle))
|
||||
throw new Error("controlled Rust descriptor close failure");
|
||||
await close();
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { RustBinaryProvider } from "../../src/rust/RustBinaryProvider.js";
|
||||
import { RustBinaryService } from "../../src/application/rust/RustBinaryService.js";
|
||||
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
ArtifactFileCleanupFailure,
|
||||
withArtifactRegularFile,
|
||||
} from "../../src/artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
||||
import { GoBinaryProvider } from "../../src/go/GoBinaryProvider.js";
|
||||
import { PeResourcesProvider } from "../../src/native/pe/PeResourcesProvider.js";
|
||||
import { inspectPeResourcesInputSchema } from "../../src/domain/native/peResources.js";
|
||||
import { peResourceFixture } from "../../src/native/pe/PeResources.fixture.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
||||
import { createRustImage } from "../fixtures/rust/image.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { mcpTest } from "../support/mcp/mcpFixture.js";
|
||||
|
||||
beforeEach(() => {
|
||||
fault.path = "";
|
||||
fault.blockNew = true;
|
||||
fault.blocked.clear();
|
||||
fault.handles = [];
|
||||
fault.onClose = undefined;
|
||||
fault.readFailure = undefined;
|
||||
});
|
||||
|
||||
const staticProviders = [
|
||||
["Rust", () => new RustBinaryProvider()],
|
||||
["Go", () => new GoBinaryProvider()],
|
||||
["PE", () => new PeResourcesProvider()],
|
||||
] as const;
|
||||
|
||||
it.each(staticProviders)(
|
||||
"preserves the exact late-close cancellation cause through %s inspection",
|
||||
async (_name, makeProvider) => {
|
||||
const path = await selectedImage();
|
||||
const provider = makeProvider();
|
||||
if (provider instanceof PeResourcesProvider)
|
||||
await writeFile(path, peResourceFixture().bytes);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel during confirmed descriptor close");
|
||||
fault.blockNew = false;
|
||||
fault.onClose = () => controller.abort(reason);
|
||||
try {
|
||||
const result =
|
||||
provider instanceof PeResourcesProvider
|
||||
? await provider.inspect(
|
||||
inspectPeResourcesInputSchema.parse({ path }),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: await provider.inspect({ path }, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("Expected late cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
staticProviders.flatMap(([name, makeProvider]) => [
|
||||
[
|
||||
name,
|
||||
makeProvider,
|
||||
new ArtifactReaderFailure("integrity", "source replacement"),
|
||||
AnalysisArtifactChangedError,
|
||||
] as const,
|
||||
[
|
||||
name,
|
||||
makeProvider,
|
||||
Object.assign(new Error("read denied"), { code: "EACCES" }),
|
||||
AnalysisAccessDeniedError,
|
||||
] as const,
|
||||
[
|
||||
name,
|
||||
makeProvider,
|
||||
new ArtifactReaderFailure("limit", "read limit"),
|
||||
AnalysisResourceConstraintError,
|
||||
] as const,
|
||||
]),
|
||||
)(
|
||||
"preserves %s read failure after successful close observes cancellation",
|
||||
async (_name, makeProvider, primary, expected) => {
|
||||
const path = await selectedImage();
|
||||
const provider = makeProvider();
|
||||
fault.blockNew = false;
|
||||
fault.readFailure = { cause: primary };
|
||||
const controller = new AbortController();
|
||||
fault.onClose = () => controller.abort(new Error("later cancellation"));
|
||||
try {
|
||||
const result =
|
||||
provider instanceof PeResourcesProvider
|
||||
? await provider.inspect(
|
||||
inspectPeResourcesInputSchema.parse({ path }),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: await provider.inspect({ path }, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("Expected primary read failure");
|
||||
expect(result.error).toBeInstanceOf(expected);
|
||||
expect(result.error.cause).toBe(primary);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(staticProviders)(
|
||||
"keeps the primary %s read failure when cancellation arrives during failed close",
|
||||
async (_name, makeProvider) => {
|
||||
const path = await selectedImage();
|
||||
const provider = makeProvider();
|
||||
const primary = new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"controlled source replacement",
|
||||
);
|
||||
fault.readFailure = { cause: primary };
|
||||
const controller = new AbortController();
|
||||
fault.onClose = () => controller.abort(new Error("later cancellation"));
|
||||
try {
|
||||
const result =
|
||||
provider instanceof PeResourcesProvider
|
||||
? await provider.inspect(
|
||||
inspectPeResourcesInputSchema.parse({ path }),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: await provider.inspect({ path }, { signal: controller.signal });
|
||||
if (result.ok)
|
||||
throw new Error("Expected primary failure and retained descriptor");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
const aggregate = result.error.cause;
|
||||
if (!(aggregate instanceof AggregateError))
|
||||
throw new Error("Missing primary/cleanup aggregate");
|
||||
const [original] = aggregate.errors;
|
||||
expect(original).toBeInstanceOf(AnalysisArtifactChangedError);
|
||||
expect((original as AnalysisArtifactChangedError).cause).toBe(primary);
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
afterEach(async () => {
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
fault.onClose = undefined;
|
||||
for (const handle of fault.handles) if (handle.fd >= 0) await handle.close();
|
||||
});
|
||||
|
||||
const selectedImage = async () => {
|
||||
const root = await createTestTempDirectory("rea-rust-artifact-owner-");
|
||||
const path = join(root, "image.elf");
|
||||
await writeFile(path, createRustImage({ format: "elf" }));
|
||||
fault.path = path;
|
||||
return path;
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"retains Rust descriptor cleanup and recovers in the same MCP connection",
|
||||
async ({ mcp }) => {
|
||||
const path = await selectedImage();
|
||||
const service = new RustBinaryService(new RustBinaryProvider());
|
||||
const failures: AnalysisError[] = [];
|
||||
const inspect = service.inspect.bind(service);
|
||||
vi.spyOn(service, "inspect").mockImplementation(async (...arguments_) => {
|
||||
const result = await inspect(...arguments_);
|
||||
if (!result.ok) failures.push(result.error);
|
||||
return result;
|
||||
});
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Unexpected native provider");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ rustBinary: service },
|
||||
);
|
||||
const client = await mcp.connect(server);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: { resources: [path] },
|
||||
});
|
||||
}
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(failures.map((error) => error.cleanupIncomplete)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
const recovered = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(recovered.isError).not.toBe(true);
|
||||
expect(failures.map((error) => error.cleanupIncomplete)).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
||||
} finally {
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps mixed descriptor cleanup and its aggregate bound to the exact unresolved owners", async () => {
|
||||
const path = await selectedImage();
|
||||
const service = new RustBinaryService(new RustBinaryProvider());
|
||||
try {
|
||||
const inspections = await Promise.all([
|
||||
service.inspect({ path }),
|
||||
service.inspect({ path }),
|
||||
]);
|
||||
expect(fault.handles).toHaveLength(2);
|
||||
const errors = inspections.map((result) => {
|
||||
if (result.ok) throw new Error("Expected retained cleanup");
|
||||
return result.error;
|
||||
});
|
||||
const firstClose = await service.close().catch((cause: unknown) => cause);
|
||||
if (!(firstClose instanceof AnalysisError))
|
||||
throw new Error("Missing typed shutdown failure");
|
||||
expect(firstClose.cleanupIncomplete).toBe(true);
|
||||
const handlesByError = errors.map((error) => {
|
||||
const aggregate = error.cause;
|
||||
if (!(aggregate instanceof AggregateError))
|
||||
throw new Error("Missing cleanup aggregate");
|
||||
const cleanup = aggregate.errors[1];
|
||||
if (
|
||||
!(cleanup instanceof AnalysisError) ||
|
||||
!(cleanup.cause instanceof ArtifactFileCleanupFailure)
|
||||
)
|
||||
throw new Error("Missing exact descriptor owner");
|
||||
return cleanup.cause.fileCleanupFailure.owner.handle;
|
||||
});
|
||||
expect(new Set(handlesByError)).toEqual(new Set(fault.handles));
|
||||
const [first, second] = handlesByError;
|
||||
if (first === undefined || second === undefined)
|
||||
throw new Error("Missing distinct descriptors");
|
||||
fault.blocked.delete(first);
|
||||
const mixedClose = await service.close().catch((cause: unknown) => cause);
|
||||
if (!(mixedClose instanceof AnalysisError))
|
||||
throw new Error("Missing mixed shutdown failure");
|
||||
expect(first.fd).toBe(-1);
|
||||
expect(second.fd).toBeGreaterThanOrEqual(0);
|
||||
expect(errors.map((error) => error.cleanupIncomplete)).toEqual([
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect(firstClose.cleanupIncomplete).toBe(true);
|
||||
expect(mixedClose.cleanupIncomplete).toBe(true);
|
||||
fault.blocked.clear();
|
||||
await service.close();
|
||||
expect(errors.map((error) => error.cleanupIncomplete)).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(firstClose.cleanupIncomplete).toBe(false);
|
||||
expect(mixedClose.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
|
||||
mcpTest(
|
||||
"server shutdown releases Rust descriptors retained by a failed tool call",
|
||||
async ({ mcp }) => {
|
||||
const path = await selectedImage();
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Unexpected native provider");
|
||||
});
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = await mcp.connect(server);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(parseMcpToolError(response).error.code).toBe("cleanup_incomplete");
|
||||
expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0);
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await server.close();
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
} finally {
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the caller's cancellation cause when descriptor close also fails", async () => {
|
||||
const path = await selectedImage();
|
||||
const provider = new RustBinaryProvider();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancel during Rust descriptor close");
|
||||
fault.onClose = () => controller.abort(reason);
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("Expected cancellation with retained cleanup");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
const aggregate = result.error.cause;
|
||||
if (!(aggregate instanceof AggregateError))
|
||||
throw new Error("Missing primary/cleanup aggregate");
|
||||
const [primary] = aggregate.errors;
|
||||
expect(primary).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect((primary as AnalysisCancelledError).cause).toBe(reason);
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
fault.blockNew = false;
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
fault.onClose = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves upstream resource-bound facts through an unconfirmed descriptor close", async () => {
|
||||
const path = await selectedImage();
|
||||
const scope = new ArtifactResourceScope();
|
||||
const primary = new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"Controlled input budget refusal",
|
||||
{ boundary: "file-size" },
|
||||
);
|
||||
try {
|
||||
const failure = await scope
|
||||
.run(() =>
|
||||
withArtifactRegularFile(path, scope, undefined, async () => {
|
||||
throw primary;
|
||||
}),
|
||||
)
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure);
|
||||
expect(failure).toMatchObject({ reason: "limit", boundary: "file-size" });
|
||||
if (!(failure instanceof ArtifactFileCleanupFailure))
|
||||
throw new Error("Expected exact descriptor cleanup failure");
|
||||
expect(failure.fileCleanupFailure.outcome).toEqual({
|
||||
kind: "failed",
|
||||
cause: primary,
|
||||
});
|
||||
} finally {
|
||||
fault.blocked.clear();
|
||||
await scope.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user