fix(hopper): recognize stripped Objective-C class labels
Classify legacy class-object names at the Hopper boundary and carry the typed fact into class discovery. Preserve exact labels, literal filtering, unrelated row metadata, and raw replies; derive classification from source text rather than incoming tags. Closes #5617
This commit is contained in:
1 parent
dd0015aaf1
commit
22c84ce64a
9 files changed
+323
-15
No files matched your search
@@ -37,6 +37,7 @@ import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection
|
||||
import { traceNativeValues } from "./native/NativeValueTrace.js";
|
||||
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
import {
|
||||
@@ -676,11 +677,15 @@ export class EnhancedTools {
|
||||
) {
|
||||
const result = await this.#call(tool, {}, signal);
|
||||
if (!result.ok) return result;
|
||||
const parsed = z
|
||||
.array(z.object({ address: z.string(), value: z.string() }))
|
||||
.safeParse(result.value);
|
||||
const parsed = z.array(addressedValueSchema).safeParse(result.value);
|
||||
return parsed.success
|
||||
? ok(parsed.data.map(({ address, value: name }) => ({ address, name })))
|
||||
? ok(
|
||||
parsed.data.map(({ address, value: name, objc_class_label }) => ({
|
||||
address,
|
||||
name,
|
||||
...(objc_class_label === undefined ? {} : { objc_class_label }),
|
||||
})),
|
||||
)
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
tool,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const addressedValueSchema = z.object({
|
||||
source: z.enum(["default", "analysis", "ai", "imported", "user_defined"]),
|
||||
})
|
||||
.exactOptional(),
|
||||
objc_class_label: z.literal("hopper_legacy_class_object").exactOptional(),
|
||||
procedure: z
|
||||
.object({
|
||||
external: z.boolean(),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { discoverObjcClasses } from "./symbolAnalysis.js";
|
||||
|
||||
describe("Objective-C class label discovery", () => {
|
||||
it("does not reinterpret a provider-specific label without an owner tag", () => {
|
||||
expect(
|
||||
discoverObjcClasses(
|
||||
[{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
|
||||
"Hunt",
|
||||
),
|
||||
).toEqual({ count: 0, classes: [] });
|
||||
});
|
||||
|
||||
it("uses provider-tagged stripped class labels and filters their exact text", () => {
|
||||
const labels = [
|
||||
"__objc_class_HuntRoot_class",
|
||||
"__objc_class_HuntSub_class",
|
||||
"__objc_class_HuntWorker_class",
|
||||
"__objc_class_HuntModel_class",
|
||||
"__objc_class_HuntLegacy_class",
|
||||
"__objc_class_Hunt_class_class",
|
||||
"prefix__objc_class_HuntForeign_class",
|
||||
"__objc_class__class",
|
||||
"__objc_class_HuntMissingSuffix",
|
||||
"__objc_class_HuntExtra_class_suffix",
|
||||
];
|
||||
const names = labels.map((name, index) => ({
|
||||
address: `0x${(index + 1).toString(16)}`,
|
||||
name,
|
||||
...(index < 6
|
||||
? { objc_class_label: "hopper_legacy_class_object" as const }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
expect(discoverObjcClasses(names, "Hunt")).toEqual({
|
||||
count: 6,
|
||||
classes: names.slice(0, 6).map(({ address, name }) => ({
|
||||
address,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
expect(discoverObjcClasses(names, "__objc_class_HuntRoot_class")).toEqual({
|
||||
count: 1,
|
||||
classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ export const discoverObjcClasses = (
|
||||
const classes = uniqueByName(
|
||||
names
|
||||
.filter(
|
||||
({ name }) =>
|
||||
({ name, objc_class_label }) =>
|
||||
name.includes(OBJC_CLASS_MARKER) ||
|
||||
objc_class_label === "hopper_legacy_class_object" ||
|
||||
(name.startsWith(LEGACY_OBJC_CLASS_PREFIX) &&
|
||||
name.length > LEGACY_OBJC_CLASS_PREFIX.length &&
|
||||
!/^l_OBJC_CLASS_NAME_(?:\.\d+)?$/u.test(name)),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseConfig } from "../config/parseConfig.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { silentLogger } from "../logger.js";
|
||||
import { HopperClient } from "./HopperClient.js";
|
||||
import { HopperProvider } from "./HopperProvider.js";
|
||||
|
||||
import { projectHopperNameInventory } from "./HopperNameInventory.js";
|
||||
|
||||
describe("Hopper name inventory labels", () => {
|
||||
it("tags stripped class-object labels without rewriting source values", () => {
|
||||
const value = [
|
||||
{
|
||||
address: "0x1",
|
||||
value: "__objc_class_HuntRoot_class",
|
||||
provider_metadata: { section: "__DATA,__objc_classrefs" },
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "__objc_class_Hunt_class_class",
|
||||
provider_metadata: { section: "__DATA,__objc_classrefs" },
|
||||
},
|
||||
{ address: "0x3", value: "_OBJC_CLASS_$_Unchanged" },
|
||||
{ address: "0x4", value: "prefix__objc_class_Foreign_class" },
|
||||
{ address: "0x5", value: "__objc_class__class" },
|
||||
{ address: "0x6", value: "__objc_class_MissingSuffix" },
|
||||
{ address: "0x7", value: "__objc_class_Extra_class_suffix" },
|
||||
];
|
||||
|
||||
expect(projectHopperNameInventory(value)).toEqual({
|
||||
changed: true,
|
||||
result: [
|
||||
{
|
||||
address: "0x1",
|
||||
value: "__objc_class_HuntRoot_class",
|
||||
provider_metadata: { section: "__DATA,__objc_classrefs" },
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "__objc_class_Hunt_class_class",
|
||||
provider_metadata: { section: "__DATA,__objc_classrefs" },
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
},
|
||||
value[2],
|
||||
value[3],
|
||||
value[4],
|
||||
value[5],
|
||||
value[6],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces provider-supplied label tags with the Hopper-owned classification", () => {
|
||||
const value = [
|
||||
{
|
||||
address: "0x1",
|
||||
value: "__objc_class_HuntRoot_class",
|
||||
objc_class_label: "untrusted_other_label",
|
||||
provider_metadata: { ordinal: 3 },
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "ordinary_name",
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
provider_metadata: { ordinal: 4 },
|
||||
},
|
||||
];
|
||||
|
||||
expect(projectHopperNameInventory(value)).toEqual({
|
||||
changed: true,
|
||||
result: [
|
||||
{
|
||||
address: "0x1",
|
||||
value: "__objc_class_HuntRoot_class",
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
provider_metadata: { ordinal: 3 },
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "ordinary_name",
|
||||
provider_metadata: { ordinal: 4 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves malformed provider inventories untouched for the consumer to reject", () => {
|
||||
const value = [{ address: 1, value: "__objc_class_HuntRoot_class" }];
|
||||
expect(projectHopperNameInventory(value)).toEqual({
|
||||
result: value,
|
||||
changed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves source rows and the exact raw reply through the provider boundary", async () => {
|
||||
const config = parseConfig({});
|
||||
if (!config.ok) throw config.error;
|
||||
const source = [
|
||||
{
|
||||
address: "0x1",
|
||||
value: "__objc_class_HuntRoot_class",
|
||||
symbol: { primary: false, source: "imported" },
|
||||
provider_metadata: { section: "__DATA,__objc_classrefs" },
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "ordinary_name",
|
||||
provider_metadata: { ordinal: 2 },
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
},
|
||||
];
|
||||
const callTool = vi
|
||||
.spyOn(HopperClient.prototype, "callTool")
|
||||
.mockResolvedValue(ok(source));
|
||||
const provider = new HopperProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
{},
|
||||
"linux",
|
||||
);
|
||||
const client = provider.createClient({
|
||||
path: "/tmp/fixture.hop",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
});
|
||||
|
||||
const result = await client.execute("list_names", {});
|
||||
|
||||
expect(callTool).toHaveBeenCalledOnce();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.result).toEqual([
|
||||
{
|
||||
...source[0],
|
||||
objc_class_label: "hopper_legacy_class_object",
|
||||
},
|
||||
{
|
||||
address: "0x2",
|
||||
value: "ordinary_name",
|
||||
provider_metadata: { ordinal: 2 },
|
||||
},
|
||||
]);
|
||||
expect(result.value.rawResult).toEqual(source);
|
||||
}
|
||||
await client.close();
|
||||
callTool.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const LEGACY_OBJC_CLASS_LABEL_PREFIX = "__objc_class_";
|
||||
const LEGACY_OBJC_CLASS_LABEL_SUFFIX = "_class";
|
||||
const addressedNameRowsSchema = z.array(
|
||||
z.object({ address: z.string(), value: z.string() }),
|
||||
);
|
||||
|
||||
const isLegacyObjcClassObjectLabel = (value: string): boolean =>
|
||||
value.startsWith(LEGACY_OBJC_CLASS_LABEL_PREFIX) &&
|
||||
value.endsWith(LEGACY_OBJC_CLASS_LABEL_SUFFIX) &&
|
||||
value.length >
|
||||
LEGACY_OBJC_CLASS_LABEL_PREFIX.length +
|
||||
LEGACY_OBJC_CLASS_LABEL_SUFFIX.length;
|
||||
|
||||
/** Tag Hopper's stripped class-object labels while retaining their exact text. */
|
||||
export const projectHopperNameInventory = (
|
||||
value: JsonValue,
|
||||
): { readonly result: JsonValue; readonly changed: boolean } => {
|
||||
const parsed = addressedNameRowsSchema.safeParse(value);
|
||||
if (!parsed.success) return { result: value, changed: false };
|
||||
|
||||
let changed = false;
|
||||
if (!Array.isArray(value)) return { result: value, changed: false };
|
||||
const result = value.map((entry, index) => {
|
||||
if (entry === null || Array.isArray(entry) || typeof entry !== "object")
|
||||
return entry;
|
||||
const parsedEntry = parsed.data[index];
|
||||
if (parsedEntry === undefined) return entry;
|
||||
const { objc_class_label: _untrustedLabel, ...sourceFields } = entry;
|
||||
const ownsLegacyLabel = isLegacyObjcClassObjectLabel(parsedEntry.value);
|
||||
if (ownsLegacyLabel) {
|
||||
changed = true;
|
||||
return {
|
||||
...sourceFields,
|
||||
objc_class_label: "hopper_legacy_class_object" as const,
|
||||
};
|
||||
}
|
||||
if (_untrustedLabel !== undefined) {
|
||||
changed = true;
|
||||
return sourceFields;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return changed ? { result, changed } : { result: value, changed: false };
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { mapHopperFileOffset } from "./HopperFileOffset.js";
|
||||
import { HopperRegexSearch } from "./HopperRegexSearch.js";
|
||||
import { HopperClient } from "./HopperClient.js";
|
||||
import { projectHopperNameInventory } from "./HopperNameInventory.js";
|
||||
import { hopperMachOImageSchema } from "./HopperMachOImage.js";
|
||||
|
||||
import {
|
||||
@@ -274,7 +275,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
|
||||
rawResult: JsonValue | null = null,
|
||||
) =>
|
||||
createAnalysisExecution(normalized, executionProvider, {
|
||||
// Only the file-offset mapping differs from Hopper's reply.
|
||||
// Retain the source reply when an adapter-owned projection changes it.
|
||||
rawResult,
|
||||
...(profile === undefined ? {} : { analysisProfile: profile }),
|
||||
...(operation === "procedure_callers" ||
|
||||
@@ -316,15 +317,18 @@ export class HopperProvider implements AnalysisProviderCandidate {
|
||||
operation === "address_to_file_offset"
|
||||
? await mapHopperFileOffset(target, result.value, options?.signal)
|
||||
: result;
|
||||
return mapped.ok
|
||||
? ok(
|
||||
projectExecution(
|
||||
operation,
|
||||
mapped.value,
|
||||
mapped === result ? null : result.value,
|
||||
),
|
||||
)
|
||||
: mapped;
|
||||
if (!mapped.ok) return mapped;
|
||||
const inventory =
|
||||
operation === "list_names"
|
||||
? projectHopperNameInventory(mapped.value)
|
||||
: { result: mapped.value, changed: false };
|
||||
return ok(
|
||||
projectExecution(
|
||||
operation,
|
||||
inventory.result,
|
||||
inventory.changed || mapped !== result ? result.value : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
executeIfReady: async (operation, parameters, options) => {
|
||||
if (operation !== "list_documents" && operation !== "list_procedures")
|
||||
|
||||
@@ -99,6 +99,57 @@ it("discovers declarations without counting duplicates or compiler bookkeeping",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers stripped Hopper class labels through MCP with literal filtering", async () => {
|
||||
const names = [
|
||||
"__objc_class_HuntRoot_class",
|
||||
"__objc_class_HuntSub_class",
|
||||
"__objc_class_HuntWorker_class",
|
||||
"__objc_class_HuntModel_class",
|
||||
"__objc_class_HuntLegacy_class",
|
||||
"prefix__objc_class_HuntForeign_class",
|
||||
"__objc_class__class",
|
||||
"__objc_class_HuntMissingSuffix",
|
||||
];
|
||||
const client = await connect({
|
||||
execute: (name) => {
|
||||
expect(name).toBe("list_names");
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
names.map((value, index) => ({
|
||||
address: `0x${index + 1}`,
|
||||
value,
|
||||
...(index < 5
|
||||
? { objc_class_label: "hopper_legacy_class_object" }
|
||||
: {}),
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const all = jsonResult(
|
||||
await client.callTool({ name: "get_objc_classes", arguments: {} }),
|
||||
);
|
||||
expect(all).toMatchObject({
|
||||
count: 5,
|
||||
classes: names.slice(0, 5).map((name, index) => ({
|
||||
address: `0x${index + 1}`,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
const filtered = jsonResult(
|
||||
await client.callTool({
|
||||
name: "get_objc_classes",
|
||||
arguments: { pattern: "__objc_class_HuntRoot_class" },
|
||||
}),
|
||||
);
|
||||
expect(filtered).toMatchObject({
|
||||
count: 1,
|
||||
classes: [{ address: "0x1", name: "__objc_class_HuntRoot_class" }],
|
||||
});
|
||||
});
|
||||
|
||||
const completeOverviewPort: AnalysisOperationPort = {
|
||||
execute: (name) => {
|
||||
switch (name) {
|
||||
|
||||
Reference in new issue
Block a user