Files
rea/src/domain/functionComparisonDimensions.ts
T
Omisakin Joshua facd58973a fix(ghidra): budget Swift literal decoding and tighten use sites
Large literals now draw on one heap budget per dossier, measured once
before decoding, instead of a separate check per literal. A literal that
does not fit is listed in a new swift_string_literal_omissions field
with its address, byte length and reason, so consumers read omissions
from structured data rather than from limitation text.

strings_names comparison checks referenced strings and names first and
reports a change there even when Swift literal coverage differs; it
returns unknown only when those facts match and literal coverage is
absent on one side or partial.

A computed jump counts as a tail call only when it has no resolved
target inside the function, so jump tables no longer qualify, and a
return requires a RETURN p-code operation, so traps no longer qualify.

Decoded literals keep control characters, which Swift strings may
contain and JSON escapes. The Swift verifier builds the literal fixture
for arm64 on any Mac host and now also covers the empty string and a
literal containing a tab.
2026-10-12 10:42:03 +01:00

335 lines
9.3 KiB
TypeScript

import { canonicalJson } from "./comparisonSemantics.js";
import { diffLines } from "diff";
import type { FunctionSnapshot } from "./functionDossierEvidence.js";
import {
commentProjection,
identityProjection,
isAutoName,
normalizeCfg,
referenceKindProjection,
referenceProjection,
sorted,
stringAndNameProjection,
} from "./functionComparisonNormalization.js";
import type {
DimensionName,
FunctionDimension,
} from "./functionComparisonSchemas.js";
import {
dimensionResult,
unresolvedDimension,
type DimensionResultInput,
} from "./functionComparisonResults.js";
type ComparisonContext = Pick<
DimensionResultInput,
"links" | "providersDiffer"
>;
export const compareDimensions = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension[] => {
return [
compareIdentity(left, right, context),
compareText(
"pseudocode",
left.dossier.pseudocode,
right.dossier.pseudocode,
context,
),
compareText(
"assembly",
left.dossier.assembly.join("\n"),
right.dossier.assembly.join("\n"),
context,
),
compareComments(left, right, context),
compareCalls(left, right, context),
compareReferences(left, right, context),
compareStringsAndNames(left, right, context),
compareCfg(left, right, context),
];
};
const compareText = (
dimension: "pseudocode" | "assembly",
left: string,
right: string,
context: ComparisonContext,
): FunctionDimension => {
const limitations =
dimension === "assembly"
? [
"Assembly is opaque provider text; relocation normalization is unavailable.",
]
: [];
if (context.providersDiffer)
return unresolvedDimension({
dimension,
links: context.links,
leftCount: [...left].length,
rightCount: [...right].length,
limitations: [
...limitations,
"Exact text comparison requires one provider identity.",
],
});
const changes = diffLines(left, right, {
timeout: 100,
maxEditLength: 10_000,
});
if (changes === undefined)
return unresolvedDimension({
dimension,
links: context.links,
leftCount: [...left].length,
rightCount: [...right].length,
limitations: [
"Bounded line diff exceeded its time or edit-distance limit.",
],
});
const delta = changes.reduce(
(summary, change) => ({
added_lines:
summary.added_lines + (change.added ? (change.count ?? 0) : 0),
removed_lines:
summary.removed_lines + (change.removed ? (change.count ?? 0) : 0),
hunks: summary.hunks + (change.added || change.removed ? 1 : 0),
}),
{ added_lines: 0, removed_lines: 0, hunks: 0 },
);
return dimensionResult({
dimension,
status: delta.hunks === 0 ? "unchanged" : "changed",
left,
right,
...context,
leftCount: [...left].length,
rightCount: [...right].length,
textDelta: delta,
limitations,
});
};
const compareComments = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
const leftValues = commentProjection(left);
const rightValues = commentProjection(right);
if (leftValues === null || rightValues === null)
return unresolvedDimension({
dimension: "comments",
links: context.links,
leftCount: left.dossier.comments.length,
rightCount: right.dossier.comments.length,
limitations: [
"Comment locations could not be normalized relative to the function.",
],
});
return compareValues("comments", leftValues, rightValues, context);
};
const compareStringsAndNames = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
const complete =
swiftLiteralCoverage(left) === swiftLiteralCoverage(right) &&
swiftLiteralCoverage(left) !== "partial";
// Facts both dossiers record decide a change before Swift literal coverage can.
const leftValues = stringAndNameProjection(left, complete);
const rightValues = stringAndNameProjection(right, complete);
if (leftValues === null || rightValues === null)
return unresolvedDimension({
dimension: "strings_names",
links: context.links,
leftCount: null,
rightCount: null,
limitations: [
"Reference source locations could not be normalized to function offsets.",
],
});
const compared = compareValues(
"strings_names",
leftValues,
rightValues,
context,
);
if (complete || compared.status === "changed") return compared;
return unresolvedDimension({
dimension: "strings_names",
links: context.links,
leftCount: null,
rightCount: null,
limitations: [
"Swift String literal coverage differs or is partial; referenced strings and names match, but decoded literals were not compared.",
],
});
};
const swiftLiteralCoverage = (
snapshot: FunctionSnapshot,
): "absent" | "complete" | "partial" =>
snapshot.dossier.swift_string_literals === undefined
? "absent"
: (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0
? "partial"
: "complete";
const compareReferences = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
const leftProjection = referenceProjection(left);
const rightProjection = referenceProjection(right);
if (leftProjection.length === 0 && rightProjection.length === 0)
return compareValues(
"references",
leftProjection,
rightProjection,
context,
);
if (
canonicalJson(leftProjection, "Function comparison") !==
canonicalJson(rightProjection, "Function comparison")
)
return dimensionResult({
dimension: "references",
status: "changed",
left: leftProjection,
right: rightProjection,
...context,
leftCount: leftProjection.length,
rightCount: rightProjection.length,
textDelta: null,
limitations: [
"Reference endpoints differ independently of provider reference-kind metadata.",
],
});
const leftKinds = referenceKindProjection(left);
const rightKinds = referenceKindProjection(right);
if (leftKinds !== null && rightKinds !== null)
return compareValues("references", leftKinds, rightKinds, context);
return unresolvedDimension({
dimension: "references",
links: context.links,
leftCount: leftProjection.length,
rightCount: rightProjection.length,
limitations: [
"At least one provider did not expose reference kinds, so equal endpoints do not prove equal edge semantics.",
],
});
};
const compareIdentity = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
if (
isAutoName(left.dossier.procedure.name) ||
isAutoName(right.dossier.procedure.name)
)
return unresolvedDimension({
dimension: "identity",
links: context.links,
leftCount: null,
rightCount: null,
limitations: [
"Address-derived function names are not stable cross-version identity.",
],
});
return compareValues(
"identity",
identityProjection(left),
identityProjection(right),
context,
);
};
const compareCalls = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
const leftValues = callProjection(left);
const rightValues = callProjection(right);
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
return unresolvedDimension({
dimension: "calls",
links: context.links,
leftCount: leftValues.length,
rightCount: rightValues.length,
limitations: [
"Address-derived callee or caller names cannot be matched safely.",
],
});
return compareValues(
"calls",
sorted(leftValues),
sorted(rightValues),
context,
);
};
const callProjection = (snapshot: FunctionSnapshot) => [
...snapshot.dossier.callers.map(({ name }) => ({
direction: "in" as const,
name,
})),
...snapshot.dossier.callees.map(({ name }) => ({
direction: "out" as const,
name,
})),
];
const compareCfg = (
left: FunctionSnapshot,
right: FunctionSnapshot,
context: ComparisonContext,
): FunctionDimension => {
const leftBlocks = left.dossier.basic_blocks;
const rightBlocks = right.dossier.basic_blocks;
const leftGraph = normalizeCfg(leftBlocks);
const rightGraph = normalizeCfg(rightBlocks);
if (leftGraph === null || rightGraph === null)
return unresolvedDimension({
dimension: "cfg",
links: context.links,
leftCount: leftBlocks.length,
rightCount: rightBlocks.length,
limitations: [
"CFG addresses could not be normalized to local block indices.",
],
});
return compareValues("cfg", leftGraph, rightGraph, context);
};
const compareValues = (
dimension: DimensionName,
left: unknown,
right: unknown,
context: ComparisonContext,
): FunctionDimension => {
const leftJson = canonicalJson(left, "Function comparison");
const rightJson = canonicalJson(right, "Function comparison");
return dimensionResult({
dimension,
status: leftJson === rightJson ? "unchanged" : "changed",
left,
right,
...context,
leftCount: Array.isArray(left) ? left.length : null,
rightCount: Array.isArray(right) ? right.length : null,
textDelta: null,
limitations: [],
});
};