Files
reverse-engineering-anything/src/domain/hopperValues.ts
T
morluto b7ebccf8d3 fix(navigation): reject malformed containing-procedure results
Move the unchanged resolution contract into its domain owner and use it when
building navigation context. Preserve provider failures instead of guessing
absence from error text or malformed result shapes. Return null only for an
explicit valid negative resolution.

Remove the obsolete error-message matcher and partial name parser. Cover four
malformed result shapes, original error identity, valid positive/negative facts
and the typed MCP unreadable-output response. Original source failed six cases.

Validation: navigation source/MCP and advertised schema checks passed. Compact
and full generated catalogs are byte-identical to the parent. Static, docs,
discovery and duplication checks passed.
2026-10-24 04:18:07 +08:00

588 lines
19 KiB
TypeScript

import { z } from "zod";
import { fixedTupleSchema } from "./fixedTupleSchema.js";
import { addressCoordinate } from "./native/addressCoordinate.js";
import type { JsonValue } from "./jsonValue.js";
import { err, ok, type Result } from "./result.js";
import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js";
import { nativeValueFlowSchema } from "./native/nativeValueFlow.js";
import { nativeCallClassificationSchema } from "./native/nativeCallReference.js";
import { AnalysisOutputError } from "./analysisErrorCore.js";
export interface AddressedName {
readonly address: string;
readonly name: string;
readonly objc_class_label?: "hopper_legacy_class_object";
}
export interface SegmentSummary {
readonly name: string;
readonly start: string;
readonly end: string;
readonly readable: boolean | null;
readonly writable: boolean | null;
readonly executable: boolean | null;
readonly image_base?: string;
readonly address_space?: string;
}
/** Return a bounded byte distance between canonical addresses in one space. */
export const addressDistance = (start: string, end: string): number | null => {
const left = addressCoordinate(start);
const right = addressCoordinate(end);
if (left === null || right === null || left.space !== right.space)
return null;
const distance = right.offset - left.offset;
if (distance < 0n || distance > BigInt(Number.MAX_SAFE_INTEGER)) return null;
return Number(distance);
};
const segmentSchema = z.object({
name: z.string(),
start: z.string(),
end: z.string(),
readable: z.boolean().nullable(),
writable: z.boolean().nullable(),
executable: z.boolean().nullable(),
image_base: z.string().min(1).exactOptional(),
address_space: z.string().min(1).exactOptional(),
});
const unavailableAnalysisFactSchema = z
.object({ available: z.literal(false), reason: z.string() })
.strict();
/** Typed string evidence shared by inventories and referenced function literals. */
export const analysisStringSchema = z.object({
address: z.string(),
value: z.string(),
provider_value: z
.string()
.exactOptional()
.describe(
"Original provider display text, which may shorten the typed object.",
),
decoding: unavailableAnalysisFactSchema
.exactOptional()
.describe(
"When present, value retains native display text because typed bytes could not be decoded.",
),
string: z
.object({
encoding: z.string().min(1),
encoding_status: z
.enum(["observed", "inferred"])
.exactOptional()
.describe(
"Whether the provider declares the encoding or REA infers it from the typed bytes and native display. This does not establish the original source's intended encoding.",
),
termination: z.enum(["missing", "present_or_not_required"]),
byte_length: z.int().min(0),
})
.exactOptional(),
});
/** An analyzed bookmark retains a missing provider label as null. */
export const analysisBookmarkSchema = z.strictObject({
address: z.string(),
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);
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",
});
}),
]);
/** Check the declared entry membership against complete, inclusive body ranges. */
export const functionBodyEntryAgrees = (
body: z.infer<typeof functionBodySchema>,
address: string,
): boolean => {
if (!body.available) return true;
const entry = addressCoordinate(address);
if (entry === null) return false;
const contains = body.ranges.some((range) => {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
return (
start !== null &&
end !== null &&
start.space === entry.space &&
entry.offset >= start.offset &&
entry.offset <= end.offset
);
});
return contains === body.contains_entry;
};
export const procedureClassificationSchema = z
.object({
external: z.boolean(),
thunk: z.boolean(),
thunk_target: z.string().nullable(),
provenance: z.string().min(1),
})
.strict();
export const procedureIdentitySchema = z
.object({
address: z.string(),
name: z.string(),
classification: procedureClassificationSchema.nullable(),
body: functionBodySchema,
})
.strict()
.superRefine((identity, context) => {
if (!functionBodyEntryAgrees(identity.body, identity.address))
context.addIssue({
code: "custom",
message:
"Function body entry membership disagrees with procedure identity",
});
});
/** An explicit provider result distinguishes a containing function from known absence. */
export const containingProcedureResolutionSchema = z.discriminatedUnion(
"found",
[
z.object({
query_address: z.string(),
found: z.literal(true),
procedure: procedureIdentitySchema,
}),
z.object({
query_address: z.string(),
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
}),
],
);
export const localVariableSchema = z
.object({
description: z.string(),
provenance: z.string().min(1),
name: z.string().nullable().exactOptional(),
stack_displacement: z
.string()
.regex(/^-?\d+$/u)
.nullable()
.exactOptional()
.describe(
"Observed signed stack byte displacement, encoded as decimal to preserve precision.",
),
})
.strict();
/** Provider-neutral complete raw-instruction list for one analyzed function. */
export const functionInstructionWindowSchema = z
.object({
procedure: procedureIdentitySchema,
instructions: z.array(z.string()),
limitations: z.array(z.string()),
})
.strict();
export const availableReferenceKindSchema = z
.object({
available: z.literal(true),
provenance: z.string().min(1),
type: z.string(),
flow: z.boolean(),
call: z.boolean(),
jump: z.boolean(),
data: z.boolean(),
read: z.boolean(),
write: z.boolean(),
indirect: z.boolean(),
computed: z.boolean(),
conditional: z.boolean(),
terminal: z.boolean(),
primary: z.boolean(),
operand_index: z.int(),
external: z.boolean(),
})
.strict();
export const referenceKindSchema = z.discriminatedUnion("available", [
unavailableAnalysisFactSchema,
availableReferenceKindSchema,
]);
/** Reported call sites without resolved targets; unreported dispatch stays unknown. */
export const unresolvedCallSchema = z.strictObject({
address: z.string(),
reason: z.string(),
});
const swiftStringLiteralFields = {
source_address: z
.string()
.describe(
"Instruction that consumes both words: a call, tail call, return, or paired store.",
),
value: z
.string()
.describe("Decoded UTF-8 text, including the canonical empty string."),
registers: fixedTupleSchema([z.string(), z.string()]),
words: fixedTupleSchema([z.string(), z.string()]).describe(
"Observed constant register values, in register order.",
),
};
/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */
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."),
}),
]);
const swiftStringLiteralOmissionFields = {
source_address: swiftStringLiteralFields.source_address,
byte_length: z.int().min(0).max(Number.MAX_SAFE_INTEGER),
reason: z
.enum(["capacity", "unresolved_jump"])
.describe(
"Why this candidate is omitted: capacity stopped decoding, or an unresolved jump left its use unknown.",
),
registers: swiftStringLiteralFields.registers,
words: swiftStringLiteralFields.words,
};
/** A Swift literal candidate omitted because decoding capacity or control flow left it unknown. */
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."),
}),
],
);
/** Observed endpoints with complete or partial reference classification evidence. */
export const referenceEdgeSchema = z
.object({
source_address: z.string(),
target_address: z.string(),
source_procedure: procedureIdentitySchema.nullable(),
target_procedure: procedureIdentitySchema.nullable(),
kind: referenceKindSchema,
call: nativeCallClassificationSchema.exactOptional(),
})
.strict();
const facetAvailabilitySchema = z.discriminatedUnion("available", [
z.object({ available: z.literal(true) }).strict(),
z.object({ available: z.literal(false), reason: z.string().min(1) }).strict(),
]);
export const functionDossierSchema = z
.object({
procedure: procedureIdentitySchema.safeExtend({
signature: z.string().nullable(),
locals: z.array(localVariableSchema),
}),
pseudocode: z.string(),
assembly: z.array(z.string()),
comments: z.array(
z
.object({
address: z.string(),
kind: z.enum(["comment", "inline"]),
text: z.string(),
})
.strict(),
),
callers: z.array(procedureIdentitySchema),
callees: z.array(procedureIdentitySchema),
incoming_references: z.array(referenceEdgeSchema),
outgoing_references: z.array(referenceEdgeSchema),
unresolved_calls: z.array(unresolvedCallSchema).exactOptional(),
referenced_strings: z.array(
analysisStringSchema.extend({ source_address: z.string() }),
),
referenced_names: z.array(
z
.object({
address: z.string(),
value: z.string(),
source_address: z.string(),
})
.strict(),
),
swift_string_literals: z
.array(swiftStringLiteralSchema)
.exactOptional()
.describe(
"Swift String literals decoded from constant register values. Absent when the provider does not decode them for this target.",
),
swift_string_literal_omissions: z
.array(swiftStringLiteralOmissionSchema)
.exactOptional()
.describe(
"Literal candidates omitted for capacity or unresolved control flow. Present exactly when swift_string_literals is present; empty when all examined use sites were classified.",
),
basic_blocks: z.array(
z
.object({
start: z.string(),
end: z.string(),
successors: z.array(z.string()),
})
.strict(),
),
native_api: nativeApiBoundarySchema.nullable(),
native_value_flow: nativeValueFlowSchema.nullable(),
facet_availability: z
.object({
pseudocode: facetAvailabilitySchema.exactOptional(),
assembly: facetAvailabilitySchema.exactOptional(),
comments: facetAvailabilitySchema.exactOptional(),
callers: facetAvailabilitySchema.exactOptional(),
callees: facetAvailabilitySchema.exactOptional(),
references: facetAvailabilitySchema.exactOptional(),
strings_names: facetAvailabilitySchema.exactOptional(),
cfg: facetAvailabilitySchema.exactOptional(),
})
.strict()
.exactOptional(),
limitations: z.array(z.string()),
})
.strict();
/** Strict analyzed-function dossier shared by provider and comparison boundaries. */
export type FunctionDossier = z.infer<typeof functionDossierSchema>;
/** Strictly parse a complete provider-neutral function dossier. */
export const parseFunctionDossier = (
value: JsonValue,
): Result<FunctionDossier, AnalysisOutputError> => {
const parsed = functionDossierSchema.safeParse(value);
return parsed.success
? ok(parsed.data)
: err(
new AnalysisOutputError(
"analyze_function",
"provider output did not match the dossier contract",
{ cause: parsed.error },
),
);
};
/** Parse callee/caller strings from direct or wrapped provider results. */
export const parseRelatedAddresses = (
value: JsonValue,
relation: "callees" | "callers",
operation = relation === "callees"
? "procedure_callees"
: "procedure_callers",
): Result<readonly string[], AnalysisOutputError> => {
const parsed = z.array(z.string()).safeParse(unwrapProperty(value, relation));
return parsed.success
? ok(parsed.data)
: invalid(operation, `${relation} list`, parsed.error);
};
/** Preserve reported address context in direct or wrapped provider segments. */
export const parseSegments = (
value: JsonValue,
operation = "list_segments",
): Result<readonly SegmentSummary[], AnalysisOutputError> => {
const parsed = z
.array(segmentSchema)
.safeParse(unwrapProperty(value, "segments"));
return parsed.success
? ok(parsed.data)
: invalid(operation, "segment list", parsed.error);
};
/** Return a reported overview base only when every parsed segment agrees. */
export const parseSegmentImageBase = (
segments: readonly SegmentSummary[],
): string | undefined => {
const base = segments[0]?.image_base;
return base !== undefined &&
segments.every((segment) => segment.image_base === base)
? base
: undefined;
};
/** Project the same overview from complete lists or provider-owned aggregates. */
export const projectBinaryOverview = (input: {
readonly document: string;
readonly procedure_count: number;
readonly string_count: number;
readonly segments?: readonly SegmentSummary[];
}): JsonValue => {
const { document, procedure_count, string_count, segments } = input;
const imageBase =
segments === undefined ? undefined : parseSegmentImageBase(segments);
return {
document,
...(imageBase === undefined ? {} : { image_base: imageBase }),
...(segments === undefined
? {}
: {
segments: segments.map(
({ name, start, end, image_base, address_space }) => ({
name,
start,
end,
length: addressDistance(start, end),
...(image_base === undefined ? {} : { image_base }),
...(address_space === undefined ? {} : { address_space }),
}),
),
segment_count: segments.length,
}),
procedure_count,
string_count,
};
};
/** Parse direct or wrapped provider document names. */
export const parseDocuments = (
value: JsonValue,
operation = "list_documents",
): Result<readonly string[], AnalysisOutputError> => {
const parsed = z
.array(z.string())
.safeParse(unwrapProperty(value, "documents"));
return parsed.success
? ok(parsed.data)
: invalid(operation, "document list", parsed.error);
};
/** Parse a direct or wrapped list when only its cardinality is required. */
export const parseListCount = (
value: JsonValue,
property: string,
operation = property === "procedures" ? "list_procedures" : "list_strings",
): Result<number, AnalysisOutputError> => {
const unwrapped = unwrapProperty(value, property);
const list = z.array(z.unknown()).safeParse(unwrapped);
if (list.success) return ok(list.data.length);
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);
return map.success
? ok(Object.keys(map.data).length)
: invalid(operation, `${property} list`, map.error);
};
const unwrapProperty = (value: JsonValue, property: string): JsonValue => {
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
property in value
) {
return value[property] ?? null;
}
return value;
};
const invalid = <T>(
operation: string,
expected: string,
cause: z.ZodError,
): Result<T, AnalysisOutputError> =>
err(
new AnalysisOutputError(
operation,
`Provider returned an invalid ${expected}`,
{
cause,
},
),
);