Move shared function, dossier, inventory, reference and output semantics from the legacy Hopper module to native domain owners. Migrate provider, adapter, test and verifier consumers directly.
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { analysisViewInputError } from "./analysisViewErrors.js";
|
|
import { analysisTextPage } from "./analysisViewText.js";
|
|
import type { AnalysisError } from "../analysisErrorBase.js";
|
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
|
import type { FunctionDossier } from "../native/functionDossier.js";
|
|
import { jsonValueSchema } from "../jsonValue.js";
|
|
import { err, ok, type Result } from "../result.js";
|
|
import type {
|
|
AnalysisViewParent,
|
|
AnalysisViewRequest,
|
|
UnsignedAnalysisView,
|
|
} from "./analysisView.js";
|
|
import {
|
|
completeWithinViewCoverage,
|
|
pageViewCoverage,
|
|
} from "./analysisViewCoverage.js";
|
|
|
|
type Artifact = { readonly path: string; readonly sha256: string };
|
|
|
|
const viewError = (message: string): AnalysisError =>
|
|
analysisViewInputError([
|
|
{ path: ["view"], reason: "invalid_value", message },
|
|
]);
|
|
|
|
const parentFields = (
|
|
parent: AnalysisViewParent,
|
|
dossier: FunctionDossier,
|
|
artifact: Artifact,
|
|
) => {
|
|
const flow = dossier.native_value_flow;
|
|
const api = dossier.native_api;
|
|
const unknowns: string[] = [];
|
|
if (flow === null) unknowns.push("native_value_flow was not recorded");
|
|
else if (!flow.available)
|
|
unknowns.push("native_value_flow unavailable: " + flow.reason);
|
|
else if (flow.truncated)
|
|
unknowns.push(
|
|
"native_value_flow is truncated; omitted observations remain unknown",
|
|
);
|
|
if (api === null) unknowns.push("native_api was not recorded");
|
|
else if (!api.available)
|
|
unknowns.push(
|
|
"native_api unavailable: " + api.reason,
|
|
...api.residual_unknowns,
|
|
);
|
|
return {
|
|
parent_evidence_id: parent.evidenceId,
|
|
parent_operation: "analyze_function" as const,
|
|
parent_digest: parent.evidenceId.slice(3),
|
|
procedure_address: dossier.procedure.address,
|
|
artifact,
|
|
limitations: uniqueSorted([
|
|
...parent.limitations,
|
|
...dossier.limitations,
|
|
...(flow?.limitations ?? []),
|
|
...(api?.available ? api.limitations : []),
|
|
]),
|
|
unknowns: uniqueSorted(unknowns),
|
|
};
|
|
};
|
|
|
|
const nativeRows = (
|
|
dossier: FunctionDossier,
|
|
facet: Exclude<
|
|
Extract<AnalysisViewRequest, { readonly kind: "native" }>["facet"],
|
|
"procedure" | "pseudocode" | "native_api" | "value_flow_summary"
|
|
>,
|
|
): readonly unknown[] | undefined => {
|
|
const sourceFlow = dossier.native_value_flow;
|
|
const flow = sourceFlow?.available ? sourceFlow : undefined;
|
|
return {
|
|
assembly: dossier.assembly,
|
|
basic_blocks: dossier.basic_blocks,
|
|
comments: dossier.comments,
|
|
callers: dossier.callers,
|
|
callees: dossier.callees,
|
|
incoming_references: dossier.incoming_references,
|
|
outgoing_references: dossier.outgoing_references,
|
|
unresolved_calls: dossier.unresolved_calls,
|
|
referenced_strings: dossier.referenced_strings,
|
|
referenced_names: dossier.referenced_names,
|
|
swift_string_literals: dossier.swift_string_literals,
|
|
swift_string_literal_omissions: dossier.swift_string_literal_omissions,
|
|
value_flow_operations: flow?.operations,
|
|
value_flow_def_use: flow?.def_use,
|
|
value_flow_effects: flow?.effects,
|
|
value_flow_parameters: flow?.parameters,
|
|
value_flow_parameter_uses: flow?.parameter_uses,
|
|
}[facet];
|
|
};
|
|
|
|
type FacetAvailability = NonNullable<FunctionDossier["facet_availability"]>;
|
|
type FacetAvailabilityKey = keyof FacetAvailability;
|
|
|
|
/** Row facets whose provider availability is declared under another key. */
|
|
const facetAvailabilityKeys: Readonly<
|
|
Partial<Record<string, FacetAvailabilityKey>>
|
|
> = {
|
|
incoming_references: "references",
|
|
outgoing_references: "references",
|
|
referenced_strings: "strings_names",
|
|
referenced_names: "strings_names",
|
|
basic_blocks: "cfg",
|
|
};
|
|
|
|
const facetAvailability = (
|
|
dossier: FunctionDossier,
|
|
facet: string,
|
|
): FacetAvailability[FacetAvailabilityKey] | undefined =>
|
|
dossier.facet_availability?.[
|
|
facetAvailabilityKeys[facet] ?? (facet as FacetAvailabilityKey)
|
|
];
|
|
|
|
/** Reasons the provider declared row facets unavailable; empty rows there are not observations. */
|
|
const unavailableFacetUnknowns = (dossier: FunctionDossier): string[] => {
|
|
const availability = dossier.facet_availability;
|
|
if (availability === undefined) return [];
|
|
return Object.entries(availability).flatMap(([facet, status]) =>
|
|
status.available ? [] : [`${facet} unavailable: ${status.reason}`],
|
|
);
|
|
};
|
|
|
|
/** Optional dossier records; the native facet path reports them "not recorded" when omitted. */
|
|
const optionalFacets = [
|
|
"unresolved_calls",
|
|
"swift_string_literals",
|
|
"swift_string_literal_omissions",
|
|
] as const;
|
|
|
|
/** Omitted optional records are unknown, not empty, so the summary names them. */
|
|
const unrecordedFacetUnknowns = (dossier: FunctionDossier): string[] =>
|
|
optionalFacets.flatMap((facet) =>
|
|
dossier[facet] === undefined ? [`${facet} not recorded`] : [],
|
|
);
|
|
|
|
/** Compact dossier summary: identity, pseudocode size, facet counts and value-flow availability. */
|
|
const summarizeDossier = (dossier: FunctionDossier) => {
|
|
const sourceFlow = dossier.native_value_flow;
|
|
const flow = sourceFlow?.available ? sourceFlow : undefined;
|
|
// A count is unknown, not zero, when the provider declared its facet unavailable.
|
|
const count = (facet: string, rows: readonly unknown[]): number | null =>
|
|
facetAvailability(dossier, facet)?.available === false ? null : rows.length;
|
|
// An omitted optional record is unknown, not zero.
|
|
const recorded = (rows: readonly unknown[] | undefined): number | null =>
|
|
rows === undefined ? null : rows.length;
|
|
return {
|
|
procedure: {
|
|
name: dossier.procedure.name,
|
|
address: dossier.procedure.address,
|
|
},
|
|
pseudocode: {
|
|
available:
|
|
dossier.facet_availability?.pseudocode?.available ??
|
|
dossier.pseudocode.length > 0,
|
|
code_units: dossier.pseudocode.length,
|
|
},
|
|
value_flow: {
|
|
available: flow !== undefined,
|
|
truncated: flow?.truncated ?? false,
|
|
},
|
|
facet_availability: dossier.facet_availability ?? null,
|
|
counts: {
|
|
assembly: count("assembly", dossier.assembly),
|
|
basic_blocks: count("basic_blocks", dossier.basic_blocks),
|
|
comments: count("comments", dossier.comments),
|
|
callers: count("callers", dossier.callers),
|
|
callees: count("callees", dossier.callees),
|
|
incoming_references: count(
|
|
"incoming_references",
|
|
dossier.incoming_references,
|
|
),
|
|
outgoing_references: count(
|
|
"outgoing_references",
|
|
dossier.outgoing_references,
|
|
),
|
|
unresolved_calls: recorded(dossier.unresolved_calls),
|
|
referenced_strings: count(
|
|
"referenced_strings",
|
|
dossier.referenced_strings,
|
|
),
|
|
referenced_names: count("referenced_names", dossier.referenced_names),
|
|
swift_string_literals: recorded(dossier.swift_string_literals),
|
|
swift_string_literal_omissions: recorded(
|
|
dossier.swift_string_literal_omissions,
|
|
),
|
|
// Value-flow rows are unknown, not zero, when value flow was not recorded
|
|
// or the provider declared it unavailable; parentFields names which.
|
|
value_flow_operations: flow?.operations.length ?? null,
|
|
value_flow_def_use: flow?.def_use.length ?? null,
|
|
value_flow_effects: flow?.effects.length ?? null,
|
|
value_flow_parameters: flow?.parameters.length ?? null,
|
|
value_flow_parameter_uses: flow?.parameter_uses.length ?? null,
|
|
},
|
|
limitation_count: dossier.limitations.length,
|
|
};
|
|
};
|
|
|
|
const summaryView = (
|
|
view: Extract<AnalysisViewRequest, { readonly kind: "summary" }>,
|
|
dossier: FunctionDossier,
|
|
common: ReturnType<typeof parentFields>,
|
|
): UnsignedAnalysisView => ({
|
|
kind: "summary",
|
|
view,
|
|
summary: summarizeDossier(dossier),
|
|
coverage: completeWithinViewCoverage(1, 1),
|
|
...common,
|
|
unknowns: uniqueSorted([
|
|
...common.unknowns,
|
|
...unavailableFacetUnknowns(dossier),
|
|
...unrecordedFacetUnknowns(dossier),
|
|
]),
|
|
});
|
|
|
|
/** Project bounded native function facets from complete retained Evidence without reanalysis. */
|
|
export const projectNativeFunctionView = (
|
|
parent: AnalysisViewParent,
|
|
dossier: FunctionDossier,
|
|
view: AnalysisViewRequest,
|
|
): Result<UnsignedAnalysisView, AnalysisError> => {
|
|
if (view.kind !== "native" && view.kind !== "summary")
|
|
return err(
|
|
viewError("analyze_function Evidence requires a native or summary view."),
|
|
);
|
|
const artifact = parent.artifact;
|
|
if (!artifact)
|
|
return err(
|
|
analysisViewInputError([
|
|
{
|
|
path: ["source", "subject"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"Native function Evidence requires an artifact-bound subject.",
|
|
},
|
|
]),
|
|
);
|
|
const common = parentFields(parent, dossier, artifact);
|
|
if (view.kind === "summary") return ok(summaryView(view, dossier, common));
|
|
const availability = facetAvailability(dossier, view.facet);
|
|
if (availability?.available === false)
|
|
common.unknowns = uniqueSorted([...common.unknowns, availability.reason]);
|
|
const singleton = (item: unknown): UnsignedAnalysisView => ({
|
|
kind: "native",
|
|
view,
|
|
item: jsonValueSchema.parse(item),
|
|
coverage: completeWithinViewCoverage(1, 1),
|
|
...common,
|
|
});
|
|
if (availability?.available === false) return ok(singleton(availability));
|
|
const flow = dossier.native_value_flow;
|
|
if (
|
|
view.facet === "procedure" ||
|
|
view.facet === "native_api" ||
|
|
view.facet === "value_flow_summary"
|
|
) {
|
|
if (view.offset !== 0)
|
|
return err(viewError("Singleton native facets require offset 0."));
|
|
if (view.facet === "procedure") return ok(singleton(dossier.procedure));
|
|
if (view.facet === "native_api")
|
|
return ok(
|
|
singleton(
|
|
dossier.native_api ?? { available: false, reason: "not recorded" },
|
|
),
|
|
);
|
|
const status =
|
|
flow === null
|
|
? { available: false, reason: "not recorded" }
|
|
: !flow.available
|
|
? { available: false, reason: flow.reason }
|
|
: {
|
|
available: true,
|
|
provenance: flow.provenance,
|
|
truncated: flow.truncated,
|
|
omitted_operations_lower_bound:
|
|
flow.omitted_operations_lower_bound,
|
|
known_omitted_inputs: flow.known_omitted_inputs,
|
|
known_omitted_edges: flow.known_omitted_edges,
|
|
operations: flow.operations.length,
|
|
def_use: flow.def_use.length,
|
|
effects: flow.effects.length,
|
|
parameters: flow.parameters.length,
|
|
parameter_uses: flow.parameter_uses.length,
|
|
};
|
|
return ok(singleton(status));
|
|
}
|
|
if (view.facet === "pseudocode") {
|
|
const page = analysisTextPage(dossier.pseudocode, view.offset, view.limit);
|
|
return page.ok
|
|
? ok({ kind: "native", view, ...page.value, ...common })
|
|
: page;
|
|
}
|
|
const rows = nativeRows(dossier, view.facet);
|
|
if (rows === undefined)
|
|
return ok(
|
|
singleton({
|
|
available: false,
|
|
// Only value-flow facets inherit the value-flow failure reason; an
|
|
// older dossier simply has no unresolved_calls record, and a target
|
|
// whose provider does not decode Swift literals has no such record.
|
|
reason:
|
|
view.facet.startsWith("value_flow_") && flow?.available === false
|
|
? flow.reason
|
|
: "not recorded",
|
|
}),
|
|
);
|
|
const items = rows.slice(view.offset, view.offset + view.limit);
|
|
return ok({
|
|
kind: "native",
|
|
view,
|
|
item: jsonValueSchema.parse(items),
|
|
coverage: pageViewCoverage(view.offset, items.length, rows.length),
|
|
...common,
|
|
});
|
|
};
|