From 4345a45982da76c44acb22ff66ac40b53e49b5dc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:30:11 +0800 Subject: [PATCH] fix(managed): share source identity and input failure ownership --- docs/managed-code-analysis.md | 8 ++++ .../managed/ManagedMemberComparisonService.ts | 27 +++++++----- src/dotnet/ManagedReaderFailure.ts | 17 ++++++++ src/dotnet/ManagedSnapshot.ts | 33 ++++++++++++--- src/dotnet/ManagedStaticProvider.ts | 37 +++++++---------- .../managed/managedMemberComparison.test.ts | 37 ++++++++++++++++- .../managed/managedStaticProvider.test.ts | 41 ++++++++++++++++++- 7 files changed, 160 insertions(+), 40 deletions(-) diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index bc42bcb431..a518f97572 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -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 diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts index eae9229858..6bb2594909 100644 --- a/src/application/managed/ManagedMemberComparisonService.ts +++ b/src/application/managed/ManagedMemberComparisonService.ts @@ -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; + 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: {}, diff --git a/src/dotnet/ManagedReaderFailure.ts b/src/dotnet/ManagedReaderFailure.ts index e360571b4d..641d17da52 100644 --- a/src/dotnet/ManagedReaderFailure.ts +++ b/src/dotnet/ManagedReaderFailure.ts @@ -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"], diff --git a/src/dotnet/ManagedSnapshot.ts b/src/dotnet/ManagedSnapshot.ts index c111556ae1..90c134e766 100644 --- a/src/dotnet/ManagedSnapshot.ts +++ b/src/dotnet/ManagedSnapshot.ts @@ -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, + operation: string, +): Result => + 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) ?? diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index c6279ec78e..3ffa5d1e6e 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -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, diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index 36143b90bd..77f775b418 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -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); }); }); diff --git a/tests/boundary/providers/managed/managedStaticProvider.test.ts b/tests/boundary/providers/managed/managedStaticProvider.test.ts index bd93ef341e..d5fbc44bf7 100644 --- a/tests/boundary/providers/managed/managedStaticProvider.test.ts +++ b/tests/boundary/providers/managed/managedStaticProvider.test.ts @@ -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,