diff --git a/docs/guides/managed/managed-code-analysis.md b/docs/guides/managed/managed-code-analysis.md index 9df1841082..9b42ffdf21 100644 --- a/docs/guides/managed/managed-code-analysis.md +++ b/docs/guides/managed/managed-code-analysis.md @@ -388,7 +388,11 @@ name can select different nested owners. Such disjoint selected owners cannot establish a member pair or added/removed status even when both inventories are complete. Missing nominal identities cannot establish selected membership. Incomplete selected inventories or missing identities leave aggregate agreement -unknown and coverage partial; known observed member pairs remain available. +unknown, and the comparison's `coverage.status` is then `unknown`; known +observed member pairs remain available. A side whose member inspection is +`unavailable` also makes the comparison `unknown`, even beside a known gap on +the other side. `partial` is reserved for known gaps such as a different +selected scope or an unequal method-row page. Complete empty selected inventories agree even if their unmatched request names differ. Whole-assembly and selected inventories remain distinct scopes, and each source retains its exact request. @@ -686,7 +690,9 @@ Windows name decorations are static inferred candidates; exact spelling still suppresses decoration and no runtime binding is proven. The mapping follows [Microsoft's charset guidance](https://learn.microsoft.com/en-us/dotnet/standard/native-interop/charset). Partial input coverage records a residual question even when no unresolved -individual boundary was produced. Selected literal inventories likewise remain +individual boundary was produced. An unavailable boundary inspection read no +P/Invoke declarations, so the verification's `coverage.status` is `unknown`, +even beside non-exhaustive native exports. Selected literal inventories likewise remain partial when unknown MethodDef ownership can hide a selected body. Metadata tokens are not native addresses. Native body mapping for C++/CLI, diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index ef429a00fb..1a8c9911f0 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -214,7 +214,11 @@ export const managedMemberComparisonResultSchema = z.strictObject({ methods: z.array(methodComparisonItemSchema), fields: z.array(fieldComparisonItemSchema), coverage: z.strictObject({ - status: z.enum(["complete-within-inputs", "partial"]), + status: z + .enum(["complete-within-inputs", "partial", "unknown"]) + .describe( + "complete-within-inputs only when both observations are complete over the same selected scope and method rows; unknown when a side is unavailable or the selected scope agreement cannot be established.", + ), left_status: z.enum(["complete", "partial", "unavailable"]), right_status: z.enum(["complete", "partial", "unavailable"]), }), diff --git a/src/domain/managed/managedMemberComparisonCoverage.ts b/src/domain/managed/managedMemberComparisonCoverage.ts index d039e012ec..f5f961886a 100644 --- a/src/domain/managed/managedMemberComparisonCoverage.ts +++ b/src/domain/managed/managedMemberComparisonCoverage.ts @@ -1,3 +1,4 @@ +import { joinCoverageStatus, type CoverageStatus } from "../coverage.js"; import type { ManagedMemberComparisonResult, ManagedMemberComparisonSide, @@ -17,23 +18,34 @@ export const buildComparisonCoverage = ({ right, selectionAgreement, }: ComparisonCoverageInput): ManagedMemberComparisonResult["coverage"] => { - const leftStatus = sideCoverageStatus(left); - const rightStatus = sideCoverageStatus(right); - const unknownInput = - left.coverage.state !== "complete" || - right.coverage.state !== "complete" || - selectionAgreement !== "same" || - !sameMethodRows(left, right); + // An unavailable side examined nothing, and an unknown selection agreement + // cannot tell whether both sides cover the same types: either makes the + // comparison unknown, even beside a known gap. + const joined = joinCoverageStatus([ + sideCoverageKnowledge(left), + sideCoverageKnowledge(right), + selectionAgreement === "unknown" + ? "unknown" + : selectionAgreement === "different" || !sameMethodRows(left, right) + ? "partial" + : "complete", + ]); return { - status: - !unknownInput && leftStatus === "complete" && rightStatus === "complete" - ? "complete-within-inputs" - : "partial", - left_status: leftStatus, - right_status: rightStatus, + status: joined === "complete" ? "complete-within-inputs" : joined, + left_status: sideCoverageStatus(left), + right_status: sideCoverageStatus(right), }; }; +const sideCoverageKnowledge = ( + result: ManagedMemberInspection, +): CoverageStatus => + result.coverage.state === "unavailable" + ? "unknown" + : result.coverage.state === "complete" + ? "complete" + : "partial"; + /** Full MethodDef coverage is required before inferring cross-build absence. */ export const hasCompleteMethodRows = ( result: ManagedMemberInspection, diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 063c5a067e..150dd0433b 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -1,4 +1,5 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; +import { joinCoverageStatus } from "../coverage.js"; import type { Evidence } from "../evidence.js"; import { machoRuntimeSymbolName } from "../native/machoSymbolName.js"; import type { @@ -566,13 +567,19 @@ export const buildVerificationResult = ({ ({ boundary_kind: kind }) => kind !== "pinvoke" && kind !== "runtime-provided", ).length; - const coverage: ManagedNativeVerificationResult["coverage"] = { - status: - managed.coverage.state === "complete" && - native.unsupported === 0 && - !native.partialExports - ? "complete-within-inputs" + // An unavailable boundary inspection read no P/Invoke declarations, so the + // verification cannot tell what it would cover: unknown wins beside the + // known gaps of skipped native Evidence or non-exhaustive exports. + const status = joinCoverageStatus([ + managed.coverage.state === "unavailable" + ? "unknown" + : managed.coverage.state === "complete" + ? "complete" : "partial", + native.unsupported === 0 && !native.partialExports ? "complete" : "partial", + ]); + const coverage: ManagedNativeVerificationResult["coverage"] = { + status: status === "complete" ? "complete-within-inputs" : status, }; return { algorithm: { diff --git a/src/domain/managed/managedNativeVerificationSchemas.ts b/src/domain/managed/managedNativeVerificationSchemas.ts index ddbf4bad5d..05622092fb 100644 --- a/src/domain/managed/managedNativeVerificationSchemas.ts +++ b/src/domain/managed/managedNativeVerificationSchemas.ts @@ -143,7 +143,11 @@ export const managedNativeVerificationRequestSchema = z .superRefine(distinctNativeSources); const verificationCoverageSchema = z.strictObject({ - status: z.enum(["complete-within-inputs", "partial"]), + status: z + .enum(["complete-within-inputs", "partial", "unknown"]) + .describe( + "unknown when the managed boundary inspection is unavailable; partial for a partial inspection, unsupported native Evidence or non-exhaustive exports.", + ), }); /** Provider-neutral managed/native verification result. */ diff --git a/tests/acceptance/managedMetadataDelta.test.ts b/tests/acceptance/managedMetadataDelta.test.ts index 832834a6e1..a02245e147 100644 --- a/tests/acceptance/managedMetadataDelta.test.ts +++ b/tests/acceptance/managedMetadataDelta.test.ts @@ -138,7 +138,9 @@ cliTest( const report = managedMemberComparisonResultSchema.parse( parseEvidence(compared.structuredContent).normalized_result, ); - expect(report.coverage.status).toBe("partial"); + // The delta's baseline-dependent selection cannot establish its scope, so + // the comparison's coverage is unknown rather than a known gap. + expect(report.coverage.status).toBe("unknown"); expect(report.summary).toMatchObject({ added: 0, removed: 0, unknown: 2 }); expect(report.evidence_links).toEqual( sources.map(({ evidence_id }) => evidence_id), diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index 6f0a620485..a9e8292de2 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -93,7 +93,9 @@ describe("managed comparison selected nominal membership", () => { ...source, result: partial, }); - expect(comparison.coverage.status).toBe("partial"); + // The partial side cannot establish its selected scope, so scope + // agreement and the comparison's coverage are unknown. + expect(comparison.coverage.status).toBe("unknown"); expect(comparison.summary).toEqual({ added: 0, removed: 0, @@ -135,7 +137,7 @@ describe("managed comparison selected nominal membership", () => { unchanged: 0, }); expect(comparison.matching.unmatched).toBe(4); - expect(comparison.coverage.status).toBe("partial"); + expect(comparison.coverage.status).toBe("unknown"); }, ); @@ -255,7 +257,9 @@ describe("managed comparison selected scope agreement", () => { ...source, result: limited, }); - expect(comparison.coverage.status).toBe("partial"); + // Scope agreement that cannot be established makes the comparison + // unknown, not a known partial gap. + expect(comparison.coverage.status).toBe("unknown"); expect(comparison.limitations).toContainEqual( expect.stringContaining( "Selected declaring-type scope agreement is unknown", @@ -267,6 +271,27 @@ describe("managed comparison selected scope agreement", () => { }, ); + it("reports a whole-assembly comparison unknown when one side examined no metadata", () => { + const source = inspect(buildManagedPeFixture(), "/fixture/whole.dll"); + const unavailable = structuredClone(source.result); + unavailable.metadata.status = "malformed"; + unavailable.coverage.state = "unavailable"; + unavailable.types = []; + unavailable.methods = []; + unavailable.fields = []; + unavailable.method_rows = null; + const sides = [source, { ...source, result: unavailable }] as const; + // Both selections are whole-assembly, so only the unavailable side, not + // scope agreement, makes the result unknown; it wins beside the partial + // method-row inventory, and in either order. + for (const [left, right] of [sides, [sides[1], sides[0]]] as const) + expect(compareManagedMembers(left, right).coverage).toEqual({ + status: "unknown", + left_status: left === source ? "complete" : "unavailable", + right_status: right === source ? "complete" : "unavailable", + }); + }); + it("recognizes complete empty selected scopes despite different unmatched requests", () => { const bytes = buildManagedPeFixture(); const left = inspect(bytes, "/fixture/empty-left.dll", ["Absent.Left"]); diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index bd15f689bf..ee8e9b475e 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -912,6 +912,66 @@ it("retains every observed native candidate", () => { expect(result.pinvoke_imports[0]?.candidates).toHaveLength(2); }); +it("reports coverage unknown when the managed boundary inspection is unavailable", () => { + const withManagedCoverage = ( + state: "partial" | "unavailable", + nativeObservations: ReturnType[], + ) => { + const input = exampleInput(); + const managed = input.managed_boundaries; + const normalized = managedNativeBoundaryInspectionSchema.parse( + managed.normalized_result, + ); + return verifyManagedNativeVerificationSource( + readFixtureSource({ + ...input, + managed_boundaries: createEvidence(undefined, managed.provider, { + operation: managed.operation, + parameters: managed.parameters, + result: { + ...normalized, + ...(state === "unavailable" + ? { module_refs: [], pinvoke_imports: [] } + : {}), + coverage: { ...normalized.coverage, state }, + }, + rawResult: null, + }), + native_observations: nativeObservations, + }), + ); + }; + const exhaustive = nativeEvidenceWithExports(["open_native"]); + const macho = inspectMachoSchema.parse(exhaustive.normalized_result); + const partialExports = createEvidence( + exhaustive.subject === null + ? undefined + : { + path: exhaustive.subject.local_path, + sha256: exhaustive.subject.digest.sha256, + format: exhaustive.subject.format, + }, + exhaustive.provider, + { + operation: exhaustive.operation, + parameters: exhaustive.parameters, + result: { ...macho, exports: { ...macho.exports, exhaustive: false } }, + rawResult: null, + }, + ); + + // Nothing was read on the managed side, so the result is unknown, and + // unknown wins beside the known gap of non-exhaustive native exports. + for (const native of [exhaustive, partialExports]) + expect(withManagedCoverage("unavailable", [native]).coverage).toEqual({ + status: "unknown", + }); + // A partial inspection is a known gap. + expect(withManagedCoverage("partial", [exhaustive]).coverage).toEqual({ + status: "partial", + }); +}); + describe("managed/native verification result algebra", () => { it("rejects states without their required native match", () => { const result = verifyManagedNativeVerificationSource(