refactor(ghidra): consolidate function and inventory admission

This commit is contained in:
morluto committed 2026-10-26 23:27:43 +08:00
1 parent b45a83368c
commit d9908fd7a4
10 files changed
+678 -366

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { address, document } from "./toolContractHelpers.js";
import {
MAX_BYTE_SEARCH_MATCHES,
bytePatternFields,
validateBytePattern,
} from "../domain/native/bytePattern.js";
@@ -17,7 +18,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
document,
start: address.optional(),
end: address.optional(),
limit: z.int().min(1).max(1000).default(100),
limit: z.int().min(1).max(MAX_BYTE_SEARCH_MATCHES).default(100),
cursor: z.string().min(1).optional(),
})
.superRefine(validateBytePattern),
+107 -102
View File
@@ -88,87 +88,88 @@ export const analysisBookmarkSchema = z.strictObject({
name: z.string().nullable(),
});
/** Complete observed function-body ranges; every range endpoint is inclusive. */
export const functionBodySchema = z.discriminatedUnion("available", [
unavailableAnalysisFactSchema,
z
.strictObject({
available: z.literal(true),
provenance: z.string().min(1),
ranges: z.array(
z.strictObject({
start: z.string(),
end: z
.string()
.describe("Inclusive last address of this observed body range."),
}),
),
total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
non_contiguous: z.boolean(),
contains_entry: z.boolean(),
})
.superRefine((body, context) => {
let total = 0n;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
let previousSpace: string | null | undefined;
for (const range of body.ranges) {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
if (
start === null ||
end === null ||
start.space !== end.space ||
end.offset < start.offset
) {
context.addIssue({
code: "custom",
message: "Function body range has invalid inclusive endpoints",
});
return;
}
if (previousSpace !== undefined && previousSpace !== start.space)
closedSpaces.add(previousSpace);
const previous = bounds.get(start.space);
if (
closedSpaces.has(start.space) ||
(previous !== undefined && start.offset <= previous.end + 1n)
) {
context.addIssue({
code: "custom",
message:
"Function body ranges must be ordered, disjoint and maximal within each address space",
});
return;
}
bounds.set(start.space, {
start: previous?.start ?? start.offset,
end: end.offset,
});
previousSpace = start.space;
total += end.offset - start.offset + 1n;
}
const onlyBounds =
bounds.size === 1 ? [...bounds.values()][0] : undefined;
const span =
bounds.size === 0
? 0n
: onlyBounds === undefined
? null
: onlyBounds.end - onlyBounds.start + 1n;
const reportedSpan =
body.span_bytes === null ? null : BigInt(body.span_bytes);
export const availableFunctionBodySchema = z
.strictObject({
available: z.literal(true),
provenance: z.string().min(1),
ranges: z.array(
z.strictObject({
start: z.string(),
end: z
.string()
.describe("Inclusive last address of this observed body range."),
}),
),
total_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
span_bytes: z.int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
non_contiguous: z.boolean(),
contains_entry: z.boolean(),
})
.superRefine((body, context) => {
let total = 0n;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
let previousSpace: string | null | undefined;
for (const range of body.ranges) {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
if (
total !== BigInt(body.total_bytes) ||
span !== reportedSpan ||
body.non_contiguous !== body.ranges.length > 1
)
start === null ||
end === null ||
start.space !== end.space ||
end.offset < start.offset
) {
context.addIssue({
code: "custom",
message: "Function body range has invalid inclusive endpoints",
});
return;
}
if (previousSpace !== undefined && previousSpace !== start.space)
closedSpaces.add(previousSpace);
const previous = bounds.get(start.space);
if (
closedSpaces.has(start.space) ||
(previous !== undefined && start.offset <= previous.end + 1n)
) {
context.addIssue({
code: "custom",
message:
"Function body counts, enclosing span or contiguity disagree with inclusive ranges",
"Function body ranges must be ordered, disjoint and maximal within each address space",
});
}),
return;
}
bounds.set(start.space, {
start: previous?.start ?? start.offset,
end: end.offset,
});
previousSpace = start.space;
total += end.offset - start.offset + 1n;
}
const onlyBounds = bounds.size === 1 ? [...bounds.values()][0] : undefined;
const span =
bounds.size === 0
? 0n
: onlyBounds === undefined
? null
: onlyBounds.end - onlyBounds.start + 1n;
const reportedSpan =
body.span_bytes === null ? null : BigInt(body.span_bytes);
if (
total !== BigInt(body.total_bytes) ||
span !== reportedSpan ||
body.non_contiguous !== body.ranges.length > 1
)
context.addIssue({
code: "custom",
message:
"Function body counts, enclosing span or contiguity disagree with inclusive ranges",
});
});
export const functionBodySchema = z.discriminatedUnion("available", [
unavailableAnalysisFactSchema,
availableFunctionBodySchema,
]);
/** Check the declared entry membership against complete, inclusive body ranges. */
@@ -325,23 +326,25 @@ const swiftStringLiteralFields = {
};
/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */
export const swiftSmallStringLiteralSchema = z.strictObject({
...swiftStringLiteralFields,
representation: z
.literal("small")
.describe("The UTF-8 bytes are inside the two words."),
address: z.null().describe("A small literal has no storage of its own."),
});
export const swiftLargeStringLiteralSchema = z.strictObject({
...swiftStringLiteralFields,
representation: z
.literal("large")
.describe(
"An immortal literal whose words hold its count and a pointer to its bytes.",
),
address: z.string().describe("Address of a large literal's bytes."),
});
export const swiftStringLiteralSchema = z.discriminatedUnion("representation", [
z.strictObject({
...swiftStringLiteralFields,
representation: z
.literal("small")
.describe("The UTF-8 bytes are inside the two words."),
address: z.null().describe("A small literal has no storage of its own."),
}),
z.strictObject({
...swiftStringLiteralFields,
representation: z
.literal("large")
.describe(
"An immortal literal whose words hold its count and a pointer to its bytes.",
),
address: z.string().describe("Address of a large literal's bytes."),
}),
swiftSmallStringLiteralSchema,
swiftLargeStringLiteralSchema,
]);
const swiftStringLiteralOmissionFields = {
@@ -357,19 +360,21 @@ const swiftStringLiteralOmissionFields = {
};
/** A Swift literal candidate omitted because decoding capacity or control flow left it unknown. */
export const swiftSmallStringLiteralOmissionSchema = z.strictObject({
...swiftStringLiteralOmissionFields,
representation: z.literal("small"),
address: z.null(),
});
export const swiftLargeStringLiteralOmissionSchema = z.strictObject({
...swiftStringLiteralOmissionFields,
representation: z.literal("large"),
address: z.string().describe("Address of the literal's bytes."),
});
export const swiftStringLiteralOmissionSchema = z.discriminatedUnion(
"representation",
[
z.strictObject({
...swiftStringLiteralOmissionFields,
representation: z.literal("small"),
address: z.null(),
}),
z.strictObject({
...swiftStringLiteralOmissionFields,
representation: z.literal("large"),
address: z.string().describe("Address of the literal's bytes."),
}),
swiftSmallStringLiteralOmissionSchema,
swiftLargeStringLiteralOmissionSchema,
],
);
+4 -1
View File
@@ -3,6 +3,9 @@ import { addressCoordinate } from "./addressCoordinate.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { err, ok, type Result } from "../result.js";
/** Maximum matches delivered by one byte-search page. */
export const MAX_BYTE_SEARCH_MATCHES = 1000;
/** Exact grammar belongs to the object refinement, not a repeating advertised regex. */
export const bytePatternFields = {
pattern: z
@@ -152,7 +155,7 @@ export const byteSearchResultSchema = z
.nullable(),
}),
)
.max(1000),
.max(MAX_BYTE_SEARCH_MATCHES),
searched: z.strictObject({
blocks: z.array(coverageBlock),
bytes: z.int().nonnegative().max(Number.MAX_SAFE_INTEGER),
+2 -2
View File
@@ -87,7 +87,7 @@ const inferredJumpTableSchema = z
})
.strict();
const availableNativeApiBoundarySchema = z
export const availableNativeApiBoundarySchema = z
.object({
available: z.literal(true),
provenance: z.string().min(1),
@@ -115,7 +115,7 @@ const availableNativeApiBoundarySchema = z
})
.strict();
const unavailableNativeApiBoundarySchema = z
export const unavailableNativeApiBoundarySchema = z
.object({
available: z.literal(false),
reason: z.string().min(1),
+33 -31
View File
@@ -46,36 +46,38 @@ export const nativePcodeEffectSchema = z.strictObject({
});
/** Bounded data-flow observations; STORE is not necessarily a persistent/global write. */
export const availableNativeValueFlowSchema = z.strictObject({
available: z.literal(true),
provenance: z.literal("ghidra-high-pcode"),
operations: z.array(nativePcodeOperationSchema),
def_use: z.array(nativePcodeDefUseSchema),
effects: z.array(nativePcodeEffectSchema),
parameters: z.array(
z.strictObject({
ordinal: z.int().nonnegative(),
name: z.string(),
data_type: z.string(),
}),
),
parameter_uses: z.array(
z.strictObject({
ordinal: z.int().nonnegative(),
use: z.string(),
input_index: z.int().nonnegative(),
}),
),
truncated: z.boolean(),
omitted_operations_lower_bound: z.int().nonnegative(),
known_omitted_inputs: z.int().nonnegative(),
known_omitted_edges: z.int().nonnegative(),
limitations: z.array(z.string().min(1)),
});
export const unavailableNativeValueFlowSchema = z.strictObject({
available: z.literal(false),
reason: z.string().min(1),
limitations: z.array(z.string().min(1)),
});
export const nativeValueFlowSchema = z.discriminatedUnion("available", [
z.strictObject({
available: z.literal(true),
provenance: z.literal("ghidra-high-pcode"),
operations: z.array(nativePcodeOperationSchema),
def_use: z.array(nativePcodeDefUseSchema),
effects: z.array(nativePcodeEffectSchema),
parameters: z.array(
z.strictObject({
ordinal: z.int().nonnegative(),
name: z.string(),
data_type: z.string(),
}),
),
parameter_uses: z.array(
z.strictObject({
ordinal: z.int().nonnegative(),
use: z.string(),
input_index: z.int().nonnegative(),
}),
),
truncated: z.boolean(),
omitted_operations_lower_bound: z.int().nonnegative(),
known_omitted_inputs: z.int().nonnegative(),
known_omitted_edges: z.int().nonnegative(),
limitations: z.array(z.string().min(1)),
}),
z.strictObject({
available: z.literal(false),
reason: z.string().min(1),
limitations: z.array(z.string().min(1)),
}),
availableNativeValueFlowSchema,
unavailableNativeValueFlowSchema,
]);
+205
View File
@@ -1121,3 +1121,208 @@ describe("Ghidra bridge script processor context contract", () => {
expect(content).toContain('"ghidra-program-context"');
});
});
describe("Ghidra dossier nested boundary composition", () => {
const identityLocations = [
"caller",
"callee",
"incoming source",
"incoming target",
"outgoing source",
"outgoing target",
] as const;
const withIdentity = (
location: (typeof identityLocations)[number],
identity: JsonValue,
): JsonValue => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
if (location === "caller") return { ...dossier, callers: [identity] };
if (location === "callee") return { ...dossier, callees: [identity] };
const collection = location.startsWith("incoming")
? "incoming_references"
: "outgoing_references";
const field = location.endsWith("source")
? "source_procedure"
: "target_procedure";
return {
...dossier,
[collection]: [{ ...ghidraReferenceEdge(), [field]: identity }],
};
};
it.each(identityLocations)(
"retains the dossier's optional symbol and empty name on a nested %s identity",
(location) => {
const { symbol: omitted, ...identity } = ghidraFunctionIdentity();
expect(omitted).toBeDefined();
const value = withIdentity(location, { ...identity, name: "" });
expect(
parseFunctionValueFixture("analyze_function", value),
).toMatchObject({
ok: true,
value,
});
},
);
it.each(identityLocations)(
"rejects noncanonical address and foreign body/classification provenance on a nested %s identity",
(location) => {
const identity = ghidraFunctionIdentity();
for (const change of [
{ address: "401000" },
{ body: { ...identity.body, provenance: "foreign-provider" } },
{ body: { ...identity.body, contains_entry: false } },
{
classification: {
...identity.classification,
provenance: "foreign-provider",
},
},
])
expect(
parseFunctionValueFixture(
"analyze_function",
withIdentity(location, { ...identity, ...change }),
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
},
);
it.each(["incoming_references", "outgoing_references"] as const)(
"requires Ghidra reference-manager provenance inside %s",
(collection) => {
const edge = ghidraReferenceEdge();
expect(
parseFunctionValueFixture("analyze_function", {
...functionDossierSchema.parse(ghidraFunctionDossier()),
[collection]: [
{
...edge,
kind: { ...edge.kind, provenance: "hopper-public-python-api" },
},
],
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
},
);
it.each([
{
name: "comment address",
fixture: (address: string) => ({
comments: [{ address, kind: "comment", text: "observed comment" }],
}),
},
{
name: "basic-block start",
fixture: (address: string) => ({
basic_blocks: [{ start: address, end: "0x401006", successors: [] }],
}),
},
{
name: "basic-block successor",
fixture: (address: string) => ({
basic_blocks: [
{ start: "0x401000", end: "0x401006", successors: [address] },
],
}),
},
{
name: "referenced name source",
fixture: (address: string) => ({
referenced_names: [
{ address: "0x402000", source_address: address, value: "observed" },
],
}),
},
{
name: "referenced string source",
fixture: (address: string) => ({
referenced_strings: [
{ address: "0x402000", source_address: address, value: "observed" },
],
}),
},
])("requires a canonical $name", ({ fixture }) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
expect(
parseFunctionValueFixture("analyze_function", {
...dossier,
...fixture("0x401000"),
}).ok,
).toBe(true);
expect(
parseFunctionValueFixture("analyze_function", {
...dossier,
...fixture("401000"),
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
});
describe("Ghidra native facet coordinate admission", () => {
it.each(["dispatch", "data source"] as const)(
"requires canonical jump-table %s coordinates",
(location) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const boundary = dossier.native_api;
if (boundary?.available !== true)
throw new Error("Missing native API fixture");
const table = boundary.jump_tables[0];
const source = table?.data_sources[0];
if (table === undefined || source === undefined)
throw new Error("Missing jump-table fixture");
expect(parseFunctionValueFixture("analyze_function", dossier).ok).toBe(
true,
);
expect(
parseFunctionValueFixture("analyze_function", {
...dossier,
native_api: {
...boundary,
jump_tables: [
{
...table,
...(location === "dispatch"
? { dispatch_address: "401010" }
: { data_sources: [{ ...source, address: "403000" }] }),
},
],
},
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
},
);
it.each(["operation", "input", "output"] as const)(
"requires canonical p-code %s coordinates without inventing null locations",
(location) => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const flow = dossier.native_value_flow;
if (flow?.available !== true) throw new Error("Missing p-code fixture");
const operation = flow.operations[0];
const input = operation?.inputs[0];
const output = operation?.output;
if (operation === undefined || input === undefined || output == null)
throw new Error("Missing p-code operation fixture");
for (const address of ["register:0x0", null, "register:0"] as const) {
if (location === "operation" && address === null) continue;
const changed = {
...operation,
...(location === "operation"
? { address }
: location === "input"
? { inputs: [{ ...input, location: address }] }
: { output: { ...output, location: address } }),
};
expect(
parseFunctionValueFixture("analyze_function", {
...dossier,
native_value_flow: { ...flow, operations: [changed] },
}).ok,
).toBe(address !== "register:0");
}
},
);
});
+82 -176
View File
@@ -1,6 +1,10 @@
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import { procedureInfoSchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
ghidraNativeApiBoundary,
ghidraNativeValueFlow,
} from "./GhidraNativeAnalysisValues.js";
import {
nativeFunctionAnnotationsInputSchema,
nativeFunctionAnnotationsSchema,
@@ -20,15 +24,17 @@ import {
functionInstructionWindowSchema,
functionDossierSchema,
availableReferenceKindSchema,
type FunctionDossier,
unresolvedCallSchema,
swiftSmallStringLiteralSchema,
swiftLargeStringLiteralSchema,
swiftSmallStringLiteralOmissionSchema,
swiftLargeStringLiteralOmissionSchema,
} from "../domain/hopperValues.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "../domain/jsonValue.js";
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "../domain/native/nativeValueFlow.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
relationshipCoverageSchema,
@@ -42,6 +48,7 @@ import {
ghidraFunctionClassificationSchema as classification,
ghidraFunctionBodySchema as ghidraFunctionBody,
ghidraProcedureIdentitySchema as procedureIdentity,
ghidraFunctionIdentitySchema as dossierProcedureIdentity,
} from "./GhidraInventoryValues.js";
/** Function analysis and annotation operations admitted by the Ghidra adapter. */
@@ -190,92 +197,82 @@ const procedureInfo = procedureInfoSchema
});
});
const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
(value, context) => {
if (!value.available) return;
if (value.provenance !== "ghidra-high-function")
context.addIssue({
code: "custom",
message:
"Ghidra native API observations require HighFunction provenance",
});
const addresses = value.jump_tables.flatMap((table) => [
table.dispatch_address,
...table.data_sources.map(({ address }) => address),
...table.mappings.map(({ target_address }) => target_address),
...table.default_targets.map(({ target_address }) => target_address),
]);
if (
addresses.some(
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
code: "custom",
message:
"Ghidra native API observation contains a non-canonical address",
});
},
);
const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine(
(value, context) => {
if (!value.available) return;
const operations = value.operations;
const addresses = operations.flatMap((operation) => [
operation.address,
...operation.inputs.flatMap(({ location }) =>
location === null ? [] : [location],
),
...(operation.output?.location == null
? []
: [operation.output.location]),
]);
if (
addresses.some(
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
code: "custom",
message: "Ghidra p-code flow contains a non-canonical address",
});
const operationIds = new Set(operations.map(({ id }) => id));
if (operationIds.size !== operations.length)
context.addIssue({
code: "custom",
message: "Ghidra p-code operation identifiers are not unique",
});
if (
value.def_use.some(
({ definition, use }) =>
!operationIds.has(definition) || !operationIds.has(use),
) ||
value.effects.some(({ operation }) => !operationIds.has(operation))
)
context.addIssue({
code: "custom",
message: "Ghidra p-code relationship references a missing operation",
});
if (
value.truncated !==
(value.omitted_operations_lower_bound > 0 ||
value.known_omitted_inputs > 0 ||
value.known_omitted_edges > 0)
)
context.addIssue({
code: "custom",
message: "Ghidra p-code truncation flag contradicts omitted counts",
});
},
);
const dossierReferenceEdge =
functionDossierSchema.shape.incoming_references.element.extend({
source_address: nativeAddressObservationSchema,
target_address: nativeAddressObservationSchema,
source_procedure: dossierProcedureIdentity.nullable(),
target_procedure: dossierProcedureIdentity.nullable(),
kind: referenceKind,
});
const ghidraFunctionDossier = functionDossierSchema
.extend({
procedure: functionDossierSchema.shape.procedure.safeExtend({
classification,
body: ghidraFunctionBody,
procedure: dossierProcedureIdentity.safeExtend({
signature: functionDossierSchema.shape.procedure.shape.signature,
locals: z.array(localVariable),
}),
comments: z.array(
functionDossierSchema.shape.comments.element.extend({
address: nativeAddressObservationSchema,
}),
),
callers: z.array(dossierProcedureIdentity),
callees: z.array(dossierProcedureIdentity),
incoming_references: z.array(dossierReferenceEdge),
outgoing_references: z.array(dossierReferenceEdge),
unresolved_calls: z
.array(
unresolvedCallSchema.extend({
address: nativeAddressObservationSchema,
}),
)
.exactOptional(),
referenced_strings: z.array(
functionDossierSchema.shape.referenced_strings.element.extend({
address: nativeAddressObservationSchema,
source_address: nativeAddressObservationSchema,
}),
),
referenced_names: z.array(
functionDossierSchema.shape.referenced_names.element.extend({
address: nativeAddressObservationSchema,
source_address: nativeAddressObservationSchema,
}),
),
swift_string_literals: z
.array(
z.discriminatedUnion("representation", [
swiftSmallStringLiteralSchema.extend({
source_address: nativeAddressObservationSchema,
}),
swiftLargeStringLiteralSchema.extend({
source_address: nativeAddressObservationSchema,
address: nativeAddressObservationSchema,
}),
]),
)
.exactOptional(),
swift_string_literal_omissions: z
.array(
z.discriminatedUnion("representation", [
swiftSmallStringLiteralOmissionSchema.extend({
source_address: nativeAddressObservationSchema,
}),
swiftLargeStringLiteralOmissionSchema.extend({
source_address: nativeAddressObservationSchema,
address: nativeAddressObservationSchema,
}),
]),
)
.exactOptional(),
basic_blocks: z.array(
functionDossierSchema.shape.basic_blocks.element.extend({
start: nativeAddressObservationSchema,
end: nativeAddressObservationSchema,
successors: z.array(nativeAddressObservationSchema),
}),
),
native_api: ghidraNativeApiBoundary,
native_value_flow: ghidraNativeValueFlow,
})
@@ -290,79 +287,6 @@ const ghidraFunctionDossier = functionDossierSchema
message:
"Ghidra dossier must record Swift literals and their omissions together",
});
const addresses = [
value.procedure.address,
...value.comments.map(({ address }) => address),
...value.callers.map(({ address }) => address),
...value.callees.map(({ address }) => address),
...(value.unresolved_calls ?? []).map(({ address }) => address),
...value.incoming_references.flatMap(referenceAddresses),
...value.outgoing_references.flatMap(referenceAddresses),
...value.referenced_strings.flatMap(({ address, source_address }) => [
address,
source_address,
]),
...value.referenced_names.flatMap(({ address, source_address }) => [
address,
source_address,
]),
...(value.swift_string_literals ?? []).flatMap(
({ address, source_address }) =>
address === null ? [source_address] : [address, source_address],
),
...(value.swift_string_literal_omissions ?? []).flatMap(
({ address, source_address }) =>
address === null ? [source_address] : [address, source_address],
),
...value.basic_blocks.flatMap(({ start, end, successors }) => [
start,
end,
...successors,
]),
];
if (
addresses.some(
(address) => !nativeAddressObservationSchema.safeParse(address).success,
)
)
context.addIssue({
code: "custom",
message: "Ghidra dossier contains a non-canonical address",
});
const identities = [
value.procedure,
...value.callers,
...value.callees,
...value.incoming_references.flatMap(referenceProcedures),
...value.outgoing_references.flatMap(referenceProcedures),
];
if (
identities.some(
(identity) =>
!classification.safeParse(identity.classification).success ||
!ghidraFunctionBody.safeParse(identity.body).success ||
!functionBodyEntryAgrees(identity.body, identity.address) ||
(identity.body.available &&
identity.classification?.external === false &&
!identity.body.contains_entry),
)
)
context.addIssue({
code: "custom",
message: "Ghidra dossier omitted function classification",
});
if (
value.procedure.locals.some(
(variable) => !localVariable.safeParse(variable).success,
) ||
[...value.incoming_references, ...value.outgoing_references].some(
({ kind }) => !referenceKind.safeParse(kind).success,
)
)
context.addIssue({
code: "custom",
message: "Ghidra dossier contains invalid provenance",
});
if (
!value.limitations.some((item) => /indirect|computed/u.test(item)) ||
!value.limitations.some((item) => /provider|Ghidra|Hopper/u.test(item))
@@ -507,21 +431,3 @@ export const parseGhidraFunctionResult = (
),
);
};
const referenceAddresses = (
edge: FunctionDossier["incoming_references"][number],
): readonly string[] => [
edge.source_address,
edge.target_address,
...(edge.source_procedure === null ? [] : [edge.source_procedure.address]),
...(edge.target_procedure === null ? [] : [edge.target_procedure.address]),
];
const referenceProcedures = (
edge: FunctionDossier["incoming_references"][number],
): readonly NonNullable<
FunctionDossier["incoming_references"][number]["source_procedure"]
>[] => [
...(edge.source_procedure === null ? [] : [edge.source_procedure]),
...(edge.target_procedure === null ? [] : [edge.target_procedure]),
];
+111
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
parseGhidraInventoryInput,
@@ -256,3 +257,113 @@ describe("containing-procedure complete body evidence", () => {
).toBe(false);
});
});
const byteSearch = (space = "") => ({
matches: [
{
address: `${space}0x000a`,
segment: "text",
file_offset: 10,
procedure: { address: `${space}0x0008`, name: "entry" },
},
],
searched: {
blocks: [
{
segment: "text",
start: `${space}0x0000`,
end: `${space}0x000f`,
bytes: 16,
},
],
bytes: 16,
skipped_uninitialized: [
{
segment: "bss",
start: `${space}0x0020`,
end: `${space}0x002f`,
bytes: 16,
},
],
skipped_not_loaded: [
{
segment: "unloaded",
start: `${space}0x0030`,
end: `${space}0x003f`,
bytes: 16,
},
],
},
complete: true,
next_cursor: null as string | null,
});
it.each(["", "ram%3Aoverlay:"])(
"preserves padded byte-search coordinates in space %j",
(space) => {
const value = byteSearch(space);
expect(parseGhidraInventoryResult("search_bytes", value)).toEqual({
ok: true,
value: { value },
});
},
);
it.each([
["match", ["matches", 0, "address"]],
["procedure", ["matches", 0, "procedure", "address"]],
["block start", ["searched", "blocks", 0, "start"]],
["block end", ["searched", "blocks", 0, "end"]],
["uninitialized start", ["searched", "skipped_uninitialized", 0, "start"]],
["uninitialized end", ["searched", "skipped_uninitialized", 0, "end"]],
["unloaded start", ["searched", "skipped_not_loaded", 0, "start"]],
["unloaded end", ["searched", "skipped_not_loaded", 0, "end"]],
] as const)(
"rejects noncanonical %s at its owning address field",
(_label, path) => {
const value = byteSearch("ram%3Aoverlay:");
let object: unknown = value;
for (const part of path.slice(0, -1))
object = Reflect.get(object as object, part);
const field = path.at(-1)!;
const address: unknown = Reflect.get(object as object, field);
if (typeof address !== "string")
throw new Error("Expected an address fixture");
Reflect.set(object as object, field, address.replace("%3A", "%3a"));
const result = parseGhidraInventoryResult("search_bytes", value);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected canonical-address refusal");
expect(result.error.cause).toBeInstanceOf(z.ZodError);
if (!(result.error.cause instanceof z.ZodError))
throw new Error("Expected field schema issues");
expect(result.error.cause.issues).toContainEqual(
expect.objectContaining({ path: [...path] }),
);
},
);
it("retains byte-search match capacity and coverage/continuation rules", () => {
const overCapacity = byteSearch();
overCapacity.matches = Array.from(
{ length: 1001 },
() => overCapacity.matches[0]!,
);
expect(parseGhidraInventoryResult("search_bytes", overCapacity).ok).toBe(
false,
);
overCapacity.matches.pop();
expect(parseGhidraInventoryResult("search_bytes", overCapacity).ok).toBe(
true,
);
const continuation = byteSearch();
continuation.next_cursor = "next";
expect(parseGhidraInventoryResult("search_bytes", continuation).ok).toBe(
false,
);
const outside = byteSearch();
outside.matches[0]!.address = "0x0010";
expect(parseGhidraInventoryResult("search_bytes", outside).ok).toBe(false);
const range = byteSearch();
range.searched.skipped_not_loaded[0]!.bytes = 15;
expect(parseGhidraInventoryResult("search_bytes", range).ok).toBe(false);
});
+45 -53
View File
@@ -4,6 +4,7 @@ import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js";
import { z } from "zod";
import { jsonRecordSchema } from "../domain/jsonRecord.js";
import {
MAX_BYTE_SEARCH_MATCHES,
bytePatternFields,
validateBytePattern,
byteSearchResultSchema,
@@ -18,7 +19,8 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import {
functionBodySchema,
availableFunctionBodySchema,
procedureIdentitySchema,
functionBodyEntryAgrees,
projectBinaryOverview,
} from "../domain/hopperValues.js";
@@ -94,7 +96,7 @@ const inputSchemas = {
document,
start: explicitAddress.optional(),
end: explicitAddress.optional(),
limit: z.int().min(1).max(1000).default(100),
limit: z.int().min(1).max(MAX_BYTE_SEARCH_MATCHES).default(100),
cursor: z.string().min(1).optional(),
})
.superRefine(validateBytePattern),
@@ -171,48 +173,38 @@ export const ghidraFunctionClassificationSchema = procedureFacts
.extend({ provenance: z.literal("ghidra-function-manager") })
.strict();
/** Require complete canonical AddressSet evidence on every Ghidra function identity. */
export const ghidraFunctionBodySchema = functionBodySchema.superRefine(
(body, context) => {
if (
!body.available ||
body.provenance !== "ghidra-function-body-address-set" ||
body.ranges.some(
({ start, end }) =>
!nativeAddressObservationSchema.safeParse(start).success ||
!nativeAddressObservationSchema.safeParse(end).success,
)
)
context.addIssue({
code: "custom",
message:
"Ghidra omitted complete canonical function-body AddressSet evidence",
});
},
);
export const ghidraFunctionBodySchema = availableFunctionBodySchema.safeExtend({
provenance: z.literal("ghidra-function-body-address-set"),
ranges: z.array(
availableFunctionBodySchema.shape.ranges.element.extend({
start: nativeAddressObservationSchema,
end: nativeAddressObservationSchema,
}),
),
});
/** Strict provider identity shared by inventory and function-analysis results. */
export const ghidraProcedureIdentitySchema = z
.object({
/** Canonical Ghidra identity, retaining the generic dossier's name and symbol admission. */
export const ghidraFunctionIdentitySchema = procedureIdentitySchema
.safeExtend({
address: nativeAddressObservationSchema,
name: z.string().min(1),
symbol: nativeSymbolMetadataSchema.nullable(),
classification: ghidraFunctionClassificationSchema,
body: ghidraFunctionBodySchema,
})
.strict()
.superRefine((value, context) => {
if (
!functionBodyEntryAgrees(value.body, value.address) ||
(value.body.available &&
!value.classification.external &&
!value.body.contains_entry)
)
if (!value.classification.external && !value.body.contains_entry)
context.addIssue({
code: "custom",
message: "Ghidra function body disagrees with its entry point",
});
});
/** Inventory identities require a nonempty name and an explicit symbol observation. */
export const ghidraProcedureIdentitySchema =
ghidraFunctionIdentitySchema.safeExtend({
name: z.string().min(1),
symbol: nativeSymbolMetadataSchema.nullable(),
});
const stringFacts = z
.object({
encoding: z.string().min(1),
@@ -293,6 +285,20 @@ const containingProcedure = z.discriminatedUnion("found", [
.strict(),
]);
const byteMatch = byteSearchResultSchema.shape.matches.element;
const canonicalByteMatch = byteMatch.safeExtend({
address: nativeAddressObservationSchema,
procedure: byteMatch.shape.procedure
.unwrap()
.safeExtend({ address: nativeAddressObservationSchema })
.nullable(),
});
const byteCoverage = byteSearchResultSchema.shape.searched.shape;
const canonicalByteCoverage = byteCoverage.blocks.element.safeExtend({
start: nativeAddressObservationSchema,
end: nativeAddressObservationSchema,
});
const resultSchemas = {
read_binary_inventory_summary: z
.strictObject({
@@ -302,27 +308,13 @@ const resultSchemas = {
string_count: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
})
.transform(projectBinaryOverview),
search_bytes: byteSearchResultSchema.superRefine((value, context) => {
const addresses = [
...value.matches.flatMap((match) => [
match.address,
...(match.procedure === null ? [] : [match.procedure.address]),
]),
...[
...value.searched.blocks,
...value.searched.skipped_uninitialized,
...value.searched.skipped_not_loaded,
].flatMap((block) => [block.start, block.end]),
];
if (
addresses.some(
(value) => !nativeAddressObservationSchema.safeParse(value).success,
)
)
context.addIssue({
code: "custom",
message: "Byte search addresses must be canonical",
});
search_bytes: byteSearchResultSchema.safeExtend({
matches: z.array(canonicalByteMatch).max(MAX_BYTE_SEARCH_MATCHES),
searched: byteSearchResultSchema.shape.searched.safeExtend({
blocks: z.array(canonicalByteCoverage),
skipped_uninitialized: z.array(canonicalByteCoverage),
skipped_not_loaded: z.array(canonicalByteCoverage),
}),
}),
inspect_native_load_image: nativeLoadImageObservationSchema,
read_bytes: nativeByteReadSchema
+87
View File
@@ -0,0 +1,87 @@
import { z } from "zod";
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import {
availableNativeApiBoundarySchema,
unavailableNativeApiBoundarySchema,
} from "../domain/native/nativeApiBoundary.js";
import {
availableNativeValueFlowSchema,
unavailableNativeValueFlowSchema,
nativePcodeOperationSchema,
nativeVarnodeSchema,
} from "../domain/native/nativeValueFlow.js";
const jumpTable = availableNativeApiBoundarySchema.shape.jump_tables.element;
const ghidraJumpTable = jumpTable.extend({
dispatch_address: nativeAddressObservationSchema,
data_sources: z.array(
jumpTable.shape.data_sources.element.extend({
address: nativeAddressObservationSchema,
}),
),
mappings: z.array(
jumpTable.shape.mappings.element.extend({
target_address: nativeAddressObservationSchema,
}),
),
default_targets: z.array(
jumpTable.shape.default_targets.element.extend({
target_address: nativeAddressObservationSchema,
}),
),
});
/** Ghidra API facts carry canonical coordinates and HighFunction provenance. */
export const ghidraNativeApiBoundary = z.discriminatedUnion("available", [
unavailableNativeApiBoundarySchema,
availableNativeApiBoundarySchema.extend({
provenance: z.literal("ghidra-high-function"),
jump_tables: z.array(ghidraJumpTable),
}),
]);
const ghidraVarnode = nativeVarnodeSchema.extend({
location: nativeAddressObservationSchema.nullable(),
});
const ghidraPcodeOperation = nativePcodeOperationSchema.extend({
address: nativeAddressObservationSchema,
inputs: z.array(ghidraVarnode),
output: ghidraVarnode.nullable(),
});
/** Validate relationships only after each p-code observation has been admitted. */
export const ghidraNativeValueFlow = z.discriminatedUnion("available", [
unavailableNativeValueFlowSchema,
availableNativeValueFlowSchema
.extend({ operations: z.array(ghidraPcodeOperation) })
.superRefine((value, context) => {
const { operations } = value;
const operationIds = new Set(operations.map(({ id }) => id));
if (operationIds.size !== operations.length)
context.addIssue({
code: "custom",
message: "Ghidra p-code operation identifiers are not unique",
});
if (
value.def_use.some(
({ definition, use }) =>
!operationIds.has(definition) || !operationIds.has(use),
) ||
value.effects.some(({ operation }) => !operationIds.has(operation))
)
context.addIssue({
code: "custom",
message: "Ghidra p-code relationship references a missing operation",
});
if (
value.truncated !==
(value.omitted_operations_lower_bound > 0 ||
value.known_omitted_inputs > 0 ||
value.known_omitted_edges > 0)
)
context.addIssue({
code: "custom",
message: "Ghidra p-code truncation flag contradicts omitted counts",
});
}),
]);