fix(native): validate matching observation context at selection

This commit is contained in:
morluto committed 2026-10-24 02:21:58 +08:00
1 parent 5e8f8b3163
commit cebd46d15b
4 files changed
+84 -34

No files matched your search

+5
View File
@@ -25,6 +25,11 @@ existing automatic-name exclusion rule applies and each function retains that
limitation. Exact names establish name correspondence, not unchanged code or
original-source identity.
Authentic Evidence selected for the wrong side, role, target, provider, profile
or document returns an input error naming the selected field or observation
index. Re-select compatible observations; authentication or contradictory producer
facts retain their separate integrity failures.
For unmatched candidates, optionally supply `left_observations` and
`right_observations`. `procedure_info` or existing `analyze_function` Evidence
gives exact inclusive body ranges; `read_bytes` supplies memory bytes. Observations
@@ -669,7 +669,8 @@ it("assigns source authentication, role selection, context, and lookup failures
right: left,
left_observations: nativeMatchingDetails("2", "0x10", "c3"),
},
tag: "EvidenceIntegrityError",
tag: "AnalysisInputError",
path: ["left_observations", 0],
},
{
input: {
@@ -975,3 +976,54 @@ it("retains zero-byte reads with unavailable addresses when no request coordinat
emptyRead.evidence_id,
);
});
it.each(["left_observations", "right_observations"] as const)(
"reports authentic mismatched provider/profile/document selections at %s without reauthentication advice",
async (field) => {
const inventory = nativeMatchingInventory("1", [
{ address: "0x10", value: "main" },
]);
const detail = nativeMatchingDetails("1", "0x10", "c3")[0]!;
const variants = [
createEvidence(
nativeMatchingTarget("1"),
{ ...nativeMatchingProvider, version: "other" },
{
operation: detail.operation,
parameters: detail.parameters,
result: detail.normalized_result,
},
),
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
operation: detail.operation,
parameters: detail.parameters,
result: detail.normalized_result,
analysisProfile: createAnalysisProfile(nativeMatchingProvider, {
loader: "different",
}),
}),
createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, {
operation: detail.operation,
parameters: { ...detail.parameters, document: "another-document" },
result: detail.normalized_result,
}),
];
for (const observation of variants) {
const result = await matchNativeFunctions({
left: inventory,
right: inventory,
[field]: [detail, observation],
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [{ path: [field, 1] }] },
});
}
const restored = await compare({
left: inventory,
right: inventory,
[field]: [detail],
});
expect(restored.report.summary.matched).toBe(1);
},
);
@@ -1,4 +1,5 @@
import type { MatchNativeFunctionsInput } from "../../contracts/native/functionMatchingContracts.js";
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import type { Evidence } from "../../domain/evidence.js";
import { readEvidence } from "../../domain/evidenceValidation.js";
@@ -56,9 +57,10 @@ export const resolveNativeMatchingSources = async (
const evidence = await resolve(raw);
if (
evidence.predicate_type !== "rea.analysis" ||
!operations.includes(evidence.operation)
!operations.includes(evidence.operation) ||
evidence.subject === null
) {
const message = `Expected rea.analysis ${operations.join(" or ")} Evidence, received ${evidence.predicate_type} ${evidence.operation}`;
const message = `Expected artifact-bound rea.analysis ${operations.join(" or ")} Evidence, received ${evidence.predicate_type} ${evidence.operation}`;
throw new AnalysisInputError(
admission.operation,
{ userMessage: message },
@@ -72,6 +74,7 @@ export const resolveNativeMatchingSources = async (
const observations = async (
inputs: readonly unknown[],
field: "left_observations" | "right_observations",
inventory: Evidence,
): Promise<Evidence[]> => {
const result: Evidence[] = [];
const seen = new Set<string>();
@@ -81,6 +84,24 @@ export const resolveNativeMatchingSources = async (
[field, index],
OBSERVATION_OPERATIONS,
);
if (
digestCanonicalValue(record.subject) !==
digestCanonicalValue(inventory.subject) ||
digestCanonicalValue(record.provider) !==
digestCanonicalValue(inventory.provider) ||
digestCanonicalValue(record.analysis_profile) !==
digestCanonicalValue(inventory.analysis_profile) ||
(record.parameters.document ?? null) !==
(inventory.parameters.document ?? null)
) {
const message =
"Select an observation from the inventory's exact target, provider, analysis profile and document context.";
throw new AnalysisInputError(
admission.operation,
{ userMessage: message },
[{ path: [field, index], reason: "invalid_value", message }],
);
}
if (!seen.has(record.evidence_id)) {
seen.add(record.evidence_id);
result.push(record);
@@ -91,10 +112,12 @@ export const resolveNativeMatchingSources = async (
const leftObservations = await observations(
input.left_observations,
"left_observations",
left,
);
const rightObservations = await observations(
input.right_observations,
"right_observations",
right,
);
const directLinks = [...sources.keys()].sort();
for (const source of sources.values())
+1 -31
View File
@@ -84,23 +84,6 @@ interface Facts {
readonly bytes: FunctionByteCoverage;
}
/** Supplementary observations must belong to this exact provider/target/profile/document. */
const requireContext = (inventory: Evidence, observation: Evidence): void => {
if (
observation.predicate_type !== "rea.analysis" ||
digestCanonicalValue(observation.subject) !==
digestCanonicalValue(inventory.subject) ||
digestCanonicalValue(observation.provider) !==
digestCanonicalValue(inventory.provider) ||
digestCanonicalValue(observation.analysis_profile) !==
digestCanonicalValue(inventory.analysis_profile) ||
(observation.parameters.document ?? null) !==
(inventory.parameters.document ?? null)
)
throw new EvidenceIntegrityError(
"Function observation does not belong to its selected inventory context",
);
};
const addBody = (observation: Evidence, details: Facts["details"]): void => {
const dossier =
observation.operation === "analyze_function"
@@ -290,20 +273,12 @@ const functionFact = async (
};
};
/** Consume normalized producer facts once; never infer body extents from neighbor entries. */
/** Consume application-admitted normalized facts; never infer body extents from neighbor entries. */
export const readFunctionInventory = async (
inventory: Evidence,
observations: readonly Evidence[],
checkpoint: NativeMatchingCheckpoint,
): Promise<MatchingFunction[]> => {
if (
inventory.operation !== "list_procedures" ||
inventory.predicate_type !== "rea.analysis" ||
inventory.subject === null
)
throw new EvidenceIntegrityError(
"Expected artifact-bound list_procedures Evidence",
);
const rows = sourcePayload(matchingRowsSchema, inventory.normalized_result);
const facts: Facts = {
details: new Map(),
@@ -312,7 +287,6 @@ export const readFunctionInventory = async (
};
for (const observation of observations) {
await checkpoint();
requireContext(inventory, observation);
switch (observation.operation) {
case "list_names":
await addNames(observation, facts.symbols, checkpoint);
@@ -324,10 +298,6 @@ export const readFunctionInventory = async (
case "analyze_function":
addBody(observation, facts.details);
break;
default:
throw new EvidenceIntegrityError(
"Function matching observations must be list_names, procedure_info, read_bytes or analyze_function Evidence",
);
}
}
const functions: MatchingFunction[] = [];