fix(managed): share source identity and input failure ownership

This commit is contained in:
morluto committed 2026-10-24 02:35:22 +08:00
1 parent e76f308d26
commit 4345a45982
7 files changed
+160 -40

No files matched your search

+8
View File
@@ -323,6 +323,14 @@ when matching declared native calls to supplied native Evidence. Static inspecti
does not load or execute the assembly. Reconstructed structure and unresolved
native/runtime questions retain their own authority and coverage.
When the selected source changes during acquisition or no longer matches its
admitted digest, inspection and comparison report retryable `artifact_changed`;
any completed hash retains the observed digest and byte count. Retry against a
stable source. This differs from corrupted retained Evidence, which remains an
Evidence-integrity failure. Malformed selected PE input has the same input-format
classification in inspection and comparison, and a failed right-side comparison
retains the completed left observation.
## Member comparison
Call edges and field accesses retain `il_offset` as their instruction site within
@@ -11,7 +11,6 @@ import {
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
@@ -20,7 +19,11 @@ import {
} 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 {
readManagedSnapshot,
verifyManagedSourceIdentity,
} from "../../dotnet/ManagedSnapshot.js";
import { managedInspectionInputFailure } from "../../dotnet/ManagedReaderFailure.js";
import {
MANAGED_STATIC_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
@@ -130,15 +133,19 @@ export const compareManagedMemberPaths = async (
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 admitted = verifyManagedSourceIdentity(target, snapshot, operation);
if (!admitted.ok) throw admitted.error;
// Member comparison never reads literals or spends their decode budget.
let inspection: ReturnType<typeof inspectManagedMembersBytes>;
try {
inspection = inspectManagedMembersBytes(snapshot.bytes, target, {
userStrings: false,
});
} catch (cause: unknown) {
throw (
managedInspectionInputFailure(operation, selectedPath, cause) ?? cause
);
// Member comparison never reads literals, so it does not spend the
// decode budget on them.
const inspection = inspectManagedMembersBytes(snapshot.bytes, target, {
userStrings: false,
});
}
const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
+17
View File
@@ -1,3 +1,4 @@
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
/** Bounded format failure retained as caller-visible managed coverage. */
@@ -10,6 +11,22 @@ export class ManagedReaderFailure extends Error {
}
}
/** Malformed selected metadata is input failure for every inspection workflow. */
export const managedInspectionInputFailure = (
operation: string,
path: string,
cause: unknown,
): AnalysisInputError | undefined =>
cause instanceof ManagedReaderFailure && cause.issue.code !== "resource-limit"
? new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${path}: ${cause.issue.detail}`,
},
])
: undefined;
/** Construct a typed managed parse failure at an exact byte location. */
export const managedFailure = (
code: ManagedParseIssue["code"],
+28 -5
View File
@@ -17,10 +17,11 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
@@ -39,6 +40,29 @@ interface ManagedSnapshot extends ManagedSourceObservation {
readonly bytes: Buffer;
}
/** Admit one observed source digest before any parser or cache consumes its bytes. */
export const verifyManagedSourceIdentity = (
target: { readonly path: string; readonly sha256: string },
observed: Pick<ManagedSourceObservation, "sha256" | "byteLength">,
operation: string,
): Result<null, AnalysisArtifactChangedError> =>
observed.sha256 === target.sha256
? ok(null)
: err(
new AnalysisArtifactChangedError(
operation,
target.path,
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${observed.sha256} at ${target.path}`,
{
diagnostics: {
expected_sha256: target.sha256,
observed_sha256: observed.sha256,
observed_bytes: observed.byteLength,
},
},
),
);
const managedSnapshotCapacityError = (
operation: string,
): AnalysisResourceConstraintError =>
@@ -233,10 +257,9 @@ const normalizeReadFailure = (
if (signal?.aborted === true && cause === signal.reason)
return new AnalysisCancelledError(operation, { cause });
if (cause instanceof RegularFileChangedError)
return new EvidenceIntegrityError(
`Managed source changed while ${operation} was reading ${path}.`,
{ cause },
);
return new AnalysisArtifactChangedError(operation, path, cause.message, {
cause,
});
if (cause instanceof AnalysisError) return cause;
return (
classifyBinaryTargetFilesystemFailure(path, cause) ??
+14 -23
View File
@@ -22,10 +22,8 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
} 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";
@@ -40,11 +38,12 @@ import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js";
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import { managedInspectionInputFailure } from "./ManagedReaderFailure.js";
import {
hashManagedSource,
managedCleanupError,
readManagedSnapshot,
verifyManagedSourceIdentity,
} from "./ManagedSnapshot.js";
/** Execution-free managed PE/CLI auxiliary provider. */
@@ -114,12 +113,12 @@ class ManagedStaticClient implements AnalysisClient {
signal: options?.signal,
resources: this.#resources,
});
if (observed.sha256 !== this.target.sha256)
return err(
new EvidenceIntegrityError(
`Managed artifact digest changed after open: expected ${this.target.sha256}, observed ${observed.sha256} at ${this.target.path}`,
),
);
const admitted = verifyManagedSourceIdentity(
this.target,
observed,
operation,
);
if (!admitted.ok) return admitted;
const bytes = snapshot ?? observed.bytes;
if (bytes === undefined)
throw new TypeError("Managed snapshot bytes are unavailable");
@@ -148,20 +147,12 @@ class ManagedStaticClient implements AnalysisClient {
cause: options.signal.reason,
}),
);
// A PE header or section table the reader cannot map is malformed input.
if (
cause instanceof ManagedReaderFailure &&
cause.issue.code !== "resource-limit"
)
return err(
new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${this.target.path}: ${cause.issue.detail}`,
},
]),
);
const inputFailure = managedInspectionInputFailure(
operation,
this.target.path,
cause,
);
if (inputFailure !== undefined) return err(inputFailure);
return err(
new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, {
cause,
@@ -888,6 +888,41 @@ describe("managed comparison snapshot admission and cleanup", () => {
});
});
describe("managed comparison malformed artifact admission", () => {
it.each(["left", "right"] as const)(
"retains malformed %s source classification and completed peer Evidence",
async (side) => {
const directory = await createTestTempDirectory("rea-managed-malformed-");
const good = join(directory, "good.dll");
const bad = join(directory, "bad.dll");
const bytes = buildManagedPeFixture();
await writeFile(good, bytes);
bytes.writeUInt16LE(0, bytes.readUInt32LE(0x3c) + 6);
await writeFile(bad, bytes);
const result = await compareManagedMemberPaths({
leftPath: side === "left" ? bad : good,
rightPath: side === "right" ? bad : good,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error._tag).toBe("AnalysisInputError");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
issues: [
expect.objectContaining({
message: expect.stringContaining("PE section count"),
}),
],
},
});
expect(result.error.partialObservation !== undefined).toBe(
side === "right",
);
},
);
});
describe("managed comparison target identity", () => {
it("rejects a file that changes after target identity is resolved", async () => {
const directory = await createTestTempDirectory(
@@ -915,7 +950,7 @@ describe("managed comparison target identity", () => {
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error._tag).toBe("EvidenceIntegrityError");
expect(result.error._tag).toBe("AnalysisArtifactChangedError");
expect(result.error.message).toContain(leftPath);
});
});
@@ -25,6 +25,39 @@ import { RegularFileCleanupFailure } from "../../../../src/filesystem/OwnedRegul
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import * as boundedFileBytes from "../../../../src/process/BoundedFileBytes.js";
it("classifies a source changed during managed snapshot acquisition as artifact_changed", async () => {
const directory = await createTestTempDirectory("rea-managed-source-change-");
const path = join(directory, "fixture.dll");
const bytes = buildManagedPeFixture();
await writeFile(path, bytes);
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const client = new ManagedStaticProvider().createClient(target.value);
const original = boundedFileBytes.readBoundedFileBytes;
const read = vi
.spyOn(boundedFileBytes, "readBoundedFileBytes")
.mockImplementation(async (...args) => {
const observed = await original(...args);
await writeFile(
path,
buildManagedPeFixture({ methodName: "ChangedAfterRead" }),
);
return observed;
});
try {
const result = await client.execute("inspect_managed_members", {});
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_changed",
retryable: true,
});
} finally {
read.mockRestore();
await client.close();
}
});
describe("managed static snapshot capacity failure", () => {
it("preserves a typed capacity failure when closing the admitted file also fails", async () => {
const directory = await createTestTempDirectory(
@@ -330,9 +363,15 @@ describe("managed static provider path boundary", () => {
const staleDigest = await client.execute("inspect_managed_artifact", {});
expect(staleDigest).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
error: { _tag: "AnalysisArtifactChangedError" },
});
if (!staleDigest.ok)
expect(staleDigest.error.diagnostics).toMatchObject({
expected_sha256: parsed.value.sha256,
observed_sha256: createHash("sha256").update(changed).digest("hex"),
});
await client.close();
const nativeClient = new ManagedStaticProvider().createClient({
path: parsed.value.path,