mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(python): preserve pyc file outcomes and cancelled Evidence
This commit is contained in:
@@ -2,6 +2,7 @@ import { expect, it } from "vitest";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { PythonPyc } from "../../domain/python/pythonPyc.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
@@ -105,12 +106,13 @@ it("records the selected artifact and the header plus marshal ranges", async ()
|
||||
]);
|
||||
});
|
||||
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
it("retains completed pyc Evidence and the opaque caller reason on late cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new AnalysisInputError("opaque caller metadata");
|
||||
const service = new PythonPycService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
controller.abort(reason);
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
@@ -119,5 +121,54 @@ it("discards an observation if the caller cancels while the provider completes",
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
operation: "inspect_python_pyc",
|
||||
normalized_result: report,
|
||||
subject: { local_path: report.artifact.path },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a pre-admission cancellation reason without calling the pyc provider", async () => {
|
||||
const reason = new Error("caller stopped before pyc admission");
|
||||
let calls = 0;
|
||||
const service = new PythonPycService({
|
||||
identity,
|
||||
inspect: async () => {
|
||||
calls++;
|
||||
return ok(report);
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: AbortSignal.abort(reason) },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves malformed provider output instead of relabeling it as a late abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const service = new PythonPycService({
|
||||
identity,
|
||||
inspect: async () => {
|
||||
controller.abort(new Error("later caller stop"));
|
||||
return ok({
|
||||
...report,
|
||||
artifact: { ...report.artifact, path: "/different.pyc" },
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected output failure");
|
||||
expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
pythonPycSchema,
|
||||
} from "../../domain/python/pythonPyc.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_python_pyc";
|
||||
|
||||
@@ -31,7 +32,9 @@ export class PythonPycService {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const input = inspectPythonPycInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
@@ -51,8 +54,6 @@ export class PythonPycService {
|
||||
);
|
||||
const result = await this.provider.inspect(input.data, options);
|
||||
if (!result.ok) return result;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = pythonPycSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
@@ -68,7 +69,7 @@ export class PythonPycService {
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
return ok(
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
@@ -97,6 +98,7 @@ export class PythonPycService {
|
||||
],
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFailure,
|
||||
bindArtifactFileCleanup,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
@@ -33,11 +37,7 @@ export const PYTHON_PYC_PROVIDER_IDENTITY = {
|
||||
|
||||
const OPERATION = "inspect_python_pyc";
|
||||
|
||||
const failureForPrimary = (
|
||||
cause: unknown,
|
||||
input: InspectPythonPycInput,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const failureForPrimary = (cause: unknown, input: InspectPythonPycInput) => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(
|
||||
@@ -47,8 +47,9 @@ const failureForPrimary = (
|
||||
{ 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 PythonPycResourceFailure)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
@@ -117,12 +118,8 @@ const failureForPrimary = (
|
||||
});
|
||||
};
|
||||
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
input: InspectPythonPycInput,
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const primary = failureForPrimary(cause, input, signal);
|
||||
const failureFor = (cause: unknown, input: InspectPythonPycInput) => {
|
||||
const primary = failureForPrimary(artifactFilePrimaryFailure(cause), input);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
|
||||
return primary;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
@@ -168,7 +165,7 @@ export class PythonPycProvider implements PythonPycPort {
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
return err(failureFor(cause, input, options?.signal));
|
||||
return err(failureFor(cause, input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +702,7 @@ const fileFailureProviders = [
|
||||
["PE certificates", () => new PeCertificatesProvider()],
|
||||
["native exports", () => new NativeExportsProvider()],
|
||||
["BEAM", () => new BeamProvider()],
|
||||
["Python pyc", () => new PythonPycProvider()],
|
||||
] as const;
|
||||
|
||||
const inspectFileFailure = (
|
||||
@@ -710,6 +711,11 @@ const inspectFileFailure = (
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const options = signal === undefined ? undefined : { signal };
|
||||
if (provider instanceof PythonPycProvider)
|
||||
return provider.inspect(
|
||||
inspectPythonPycInputSchema.parse({ path }),
|
||||
options,
|
||||
);
|
||||
if (provider instanceof ElfNotesProvider)
|
||||
return provider.inspect(
|
||||
inspectElfNotesInputSchema.parse({ path }),
|
||||
@@ -846,3 +852,39 @@ it.each(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves a pyc permission failure through late cancellation (failed close: %s)",
|
||||
async (failedClose) => {
|
||||
const path = await selectedImage();
|
||||
const provider = new PythonPycProvider();
|
||||
const controller = new AbortController();
|
||||
const physical = Object.assign(new Error("pyc descriptor stat denied"), {
|
||||
code: "EACCES",
|
||||
});
|
||||
fault.blockNew = failedClose;
|
||||
fault.readFailure = { cause: physical };
|
||||
fault.onClose = () => controller.abort(physical);
|
||||
try {
|
||||
const result = await provider.inspect(
|
||||
inspectPythonPycInputSchema.parse({ path }),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected physical read failure");
|
||||
const primary = result.error.cleanupPrimary ?? result.error;
|
||||
expect(primary).toBeInstanceOf(AnalysisAccessDeniedError);
|
||||
expect(primary.cause).toBe(physical);
|
||||
expect(result.error.cleanupIncomplete).toBe(failedClose);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]!.fd >= 0).toBe(failedClose);
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(fault.handles[0]!.fd).toBe(-1);
|
||||
} finally {
|
||||
fault.onClose = undefined;
|
||||
fault.blocked.clear();
|
||||
await provider.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user