fix(ghidra): classify modern Swift manglings in swift type analysis (#1208)
* fix(ghidra): classify modern Swift manglings in swift type analysis Categorize modern Swift 4.2+ manglings ($s, _$s) instead of dropping them in analyze_swift_types. The classifier only accepted the legacy _Tt marker, so procedure names reported by current providers produced an empty inventory. Legacy _Tt families and behavior are unchanged. Fixes #1187. * fix(swift): retain provider aliases and category unknowns --------- Co-authored-by: Saim Shafique <sx4imm@gmail.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
04a516b1e3
commit
6935dc7fe1
|
|
@ -322,11 +322,18 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
|||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
||||
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
|
||||
|
||||
The optional Swift lane compiles a source-owned fixture and checks modern and
|
||||
provider-demangled procedure names through real stdio MCP, with and without the
|
||||
dSYM companion. It checks unfiltered, structs, and literal-name filters,
|
||||
same-address alias evidence, deduplication, and explicit unresolved categories.
|
||||
It does not add a Swift prerequisite to the host C fixture lane.
|
||||
|
||||
The host lane also verifies namespaced C++ ABI symbols and annotation identity.
|
||||
That facet requires Ghidra's matching `demangler_gnu_v2_41` native component in
|
||||
`GPL/DemanglerGnu/os/<platform>/` or `build/os/<platform>/`, in addition to the
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@
|
|||
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { toolContract } from "../dist/contracts/toolContracts.js";
|
||||
import { requireMcpResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const installation = inspectGhidraInstallation({
|
||||
installDir: process.env.GHIDRA_INSTALL_DIR,
|
||||
javaHome: process.env.JAVA_HOME,
|
||||
});
|
||||
if (process.platform !== "darwin" || installation.status !== "available")
|
||||
throw new Error(
|
||||
"verify:ghidra:swift requires macOS, Ghidra 12.1.4 with its native decompiler, and JDK 21; set GHIDRA_INSTALL_DIR and JAVA_HOME.",
|
||||
);
|
||||
try {
|
||||
await execute("swiftc", ["--version"], { timeout: 10_000 });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:ghidra:swift requires the host Swift compiler (swiftc).",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-swift-"));
|
||||
const client = new Client({ name: "ghidra-swift-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [fileURLToPath(new URL("./rea.mjs", import.meta.url)), "mcp"],
|
||||
env: Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => value !== undefined),
|
||||
),
|
||||
stderr: "pipe",
|
||||
});
|
||||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/swift/type-inventory.swift", import.meta.url),
|
||||
);
|
||||
const binary = join(root, "swift-inventory");
|
||||
const reports = [];
|
||||
try {
|
||||
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await mkdir(join(root, "copied"));
|
||||
const copied = join(root, "copied", "swift-inventory");
|
||||
await copyFile(binary, copied);
|
||||
await client.connect(transport);
|
||||
for (const [variant, path] of [
|
||||
["debug", binary],
|
||||
["without-dsym", copied],
|
||||
]) {
|
||||
await call("open_binary", { path, provider_id: "ghidra" });
|
||||
try {
|
||||
const procedures = await call("list_procedures", {});
|
||||
const inventory = await call("analyze_swift_types", {});
|
||||
assert(
|
||||
inventory.total > 0,
|
||||
"Swift procedures disappeared from the inventory",
|
||||
);
|
||||
assert(
|
||||
inventory.categories.other.count > 0,
|
||||
"Top-level Swift symbols were lost",
|
||||
);
|
||||
const structs = await call("analyze_swift_types", {
|
||||
category: "structs",
|
||||
});
|
||||
assert(
|
||||
structs.categories.structs.count > 0,
|
||||
"Modern struct symbols were lost",
|
||||
);
|
||||
const metadata = await call("analyze_swift_types", {
|
||||
category: "structs",
|
||||
pattern: "VMa",
|
||||
});
|
||||
assert.equal(
|
||||
metadata.total,
|
||||
2,
|
||||
"Pair and Scorer metadata accessors must both be classified",
|
||||
);
|
||||
const pair = await call("analyze_swift_types", {
|
||||
category: "structs",
|
||||
pattern: "Pair",
|
||||
});
|
||||
assert(pair.total > 0, "Literal name filtering lost Pair symbols");
|
||||
const unresolved = await call("analyze_swift_types", {
|
||||
category: "structs",
|
||||
pattern: "swiftIndirect",
|
||||
});
|
||||
assert(
|
||||
unresolved.unclassified.length > 0,
|
||||
"Unsupported categories must remain explicit",
|
||||
);
|
||||
const entries = Object.values(inventory.categories).flatMap(
|
||||
(category) => category.items,
|
||||
);
|
||||
assert.equal(
|
||||
new Set(entries.map((entry) => entry.name)).size,
|
||||
inventory.total,
|
||||
);
|
||||
assert(
|
||||
!entries.some(
|
||||
(entry) =>
|
||||
entry.name === "entry" || entry.name === "_swift_allocObject",
|
||||
),
|
||||
);
|
||||
for (const entry of entries)
|
||||
assert(
|
||||
procedures.some(
|
||||
(procedure) =>
|
||||
procedure.address === entry.address &&
|
||||
procedure.value === entry.name,
|
||||
),
|
||||
);
|
||||
reports.push({
|
||||
variant,
|
||||
total: inventory.total,
|
||||
structs: structs.total,
|
||||
metadata_accessors: metadata.total,
|
||||
unclassified: inventory.unclassified.length,
|
||||
});
|
||||
} finally {
|
||||
await call("close_binary", {});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
provider: "ghidra",
|
||||
fixtures: reports,
|
||||
verifier_run: await completeVerifierRun(run),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
async function call(name, arguments_) {
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
if (name === "analyze_swift_types")
|
||||
toolContract(name).outputSchema.parse(response.structuredContent);
|
||||
return requireMcpResult(response, name);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
|||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
|
|
@ -358,9 +359,30 @@ export class EnhancedTools {
|
|||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const procedures = await this.#allAddressed("list_procedures", signal);
|
||||
return procedures.ok
|
||||
? ok(categorizeSwiftTypes(procedures.value, input))
|
||||
: procedures;
|
||||
if (!procedures.ok) return procedures;
|
||||
const symbols = await this.#allAddressed("list_names", signal);
|
||||
if (
|
||||
!symbols.ok &&
|
||||
!(symbols.error instanceof AnalysisCapabilityUnavailableError)
|
||||
)
|
||||
return symbols;
|
||||
const result = categorizeSwiftTypes(
|
||||
procedures.value,
|
||||
input,
|
||||
symbols.ok ? symbols.value : [],
|
||||
);
|
||||
return ok(
|
||||
symbols.ok
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
symbol_inventory_error: projectAnalysisError(symbols.error),
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
"The provider's symbol inventory is unavailable; demangled procedures could not be joined to their Swift aliases.",
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async #findXrefs(name: string, signal?: AbortSignal): EnhancedResult {
|
||||
|
|
|
|||
|
|
@ -30,12 +30,14 @@ export const enhancedInputSchemas = {
|
|||
category: z
|
||||
.enum(["classes", "structs", "enums", "protocols", "extensions", "other"])
|
||||
.optional()
|
||||
.describe("Limit results to one Swift symbol category."),
|
||||
.describe(
|
||||
"Limit categorized results to one Swift category; unresolved category observations remain in unclassified.",
|
||||
),
|
||||
pattern: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Case-sensitive literal filter applied to mangled symbol names.",
|
||||
"Case-sensitive literal filter applied to provider procedure names and their supporting Swift manglings.",
|
||||
),
|
||||
}),
|
||||
find_xrefs_to_name: z.strictObject({ name: z.string() }),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|||
),
|
||||
enhanced(
|
||||
"analyze_swift_types",
|
||||
'Categorize analyzed procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Returns deduplicated names grouped with counts. Optionally select one category and/or apply a case-sensitive literal name filter; for example, use {category: "classes", pattern: "Account"} to find matching class symbols.',
|
||||
'Categorize analyzed Swift procedure names using their manglings or same-address Swift symbol aliases. Decodes legacy and literal modern nominal contexts into classes, structs, enums, protocols, and extensions. Preserves provider names and supporting mangled_names; unresolved categories appear in other and in unclassified, which remains inline even under category filters. Returns deduplicated procedure-name counts, not unique source types, plus identification limitations. Optionally select one category and/or apply a case-sensitive literal filter to provider names or supporting manglings; for example, use {category: "structs", pattern: "Pair"}.',
|
||||
enhancedInputSchemas.analyze_swift_types,
|
||||
),
|
||||
enhanced(
|
||||
|
|
|
|||
|
|
@ -283,9 +283,21 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
z.string(),
|
||||
z.object({
|
||||
count: z.number().int().min(0),
|
||||
items: z.array(addressedEntry),
|
||||
items: z.array(
|
||||
addressedEntry.extend({
|
||||
mangled_names: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
unclassified: z.array(
|
||||
addressedEntry.extend({
|
||||
mangled_names: z.array(z.string()),
|
||||
reason: z.enum(["category_not_decoded", "conflicting_categories"]),
|
||||
}),
|
||||
),
|
||||
limitations: z.array(z.string()),
|
||||
symbol_inventory_error: analysisErrorProjectionSchema.exactOptional(),
|
||||
}),
|
||||
),
|
||||
find_xrefs_to_name: resultOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
const LEGACY_CATEGORIES = [
|
||||
["_TtC", "classes"],
|
||||
["_TtV", "structs"],
|
||||
["_TtO", "enums"],
|
||||
["_TtP", "protocols"],
|
||||
["_TtE", "extensions"],
|
||||
] as const;
|
||||
|
||||
/** Supported procedure categories derived from Swift symbol encodings. */
|
||||
export type SwiftTypeCategory = (typeof LEGACY_CATEGORIES)[number][1] | "other";
|
||||
|
||||
const NOMINAL_CATEGORIES: ReadonlyMap<string, SwiftTypeCategory> = new Map([
|
||||
["C", "classes"],
|
||||
["V", "structs"],
|
||||
["O", "enums"],
|
||||
["P", "protocols"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Recognize Swift manglings and decode literal module/nominal contexts.
|
||||
* Substitutions, generic contexts, and other encodings remain unknown.
|
||||
*/
|
||||
export const classifySwiftSymbol = (
|
||||
name: string,
|
||||
): { readonly category: SwiftTypeCategory | null } | undefined => {
|
||||
const prefix = /^_?(?:\$[sS]|_T0)/u.exec(name);
|
||||
if (prefix === null) {
|
||||
if (!name.includes("_Tt")) return undefined;
|
||||
return {
|
||||
category:
|
||||
LEGACY_CATEGORIES.find(([prefix]) => name.startsWith(prefix))?.[1] ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
const mangling = name.slice(prefix[0].length);
|
||||
let offset = identifierEnd(mangling, 0);
|
||||
if (offset === undefined) return { category: null };
|
||||
let category: SwiftTypeCategory | null = null;
|
||||
for (;;) {
|
||||
const end = identifierEnd(mangling, offset);
|
||||
if (end === undefined) break;
|
||||
const kind = mangling.charAt(end);
|
||||
const nominal = NOMINAL_CATEGORIES.get(kind);
|
||||
if (nominal === undefined) {
|
||||
if (kind === "E" && category !== null) category = "extensions";
|
||||
break;
|
||||
}
|
||||
category = nominal;
|
||||
offset = end + 1;
|
||||
}
|
||||
return { category };
|
||||
};
|
||||
|
||||
const identifierEnd = (
|
||||
mangling: string,
|
||||
offset: number,
|
||||
): number | undefined => {
|
||||
const lengthToken = /^[1-9][0-9]*/u.exec(mangling.slice(offset))?.[0];
|
||||
if (lengthToken === undefined) return undefined;
|
||||
const start = offset + lengthToken.length;
|
||||
const end = start + Number(lengthToken);
|
||||
// Swift identifiers with substitutions/punycode need a fuller decoder.
|
||||
if (
|
||||
!Number.isSafeInteger(end) ||
|
||||
end > mangling.length ||
|
||||
!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(mangling.slice(start, end))
|
||||
)
|
||||
return undefined;
|
||||
return end;
|
||||
};
|
||||
|
|
@ -80,4 +80,165 @@ describe("symbol analysis", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies modern Swift manglings reported by current providers", () => {
|
||||
const result = categorizeSwiftTypes([
|
||||
{ address: "0x1000009f8", name: "$s4main4PairVMa" },
|
||||
{ address: "0x100000ac4", name: "$s4main6ScorerVMa" },
|
||||
{ address: "0x100000ab4", name: "_$s4main4PairVMa" },
|
||||
{ address: "0x1", name: "_TtCClass" },
|
||||
{ address: "0x1000009f8", name: "$s4main4PairVMa" },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
total: 4,
|
||||
categories: {
|
||||
classes: { count: 1 },
|
||||
structs: {
|
||||
count: 3,
|
||||
items: [
|
||||
{ address: "0x1000009f8", name: "$s4main4PairVMa" },
|
||||
{ address: "0x100000ac4", name: "$s4main6ScorerVMa" },
|
||||
{ address: "0x100000ab4", name: "_$s4main4PairVMa" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-Swift procedure names out of the Swift inventory", () => {
|
||||
const result = categorizeSwiftTypes([
|
||||
{ address: "0x1", name: "printf" },
|
||||
{ address: "0x2", name: "_OBJC_CLASS_$_App" },
|
||||
{ address: "0x3", name: "std::vector<int>::push_back" },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({ total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("modern Swift category evidence", () => {
|
||||
it.each([
|
||||
["$s4main4PairVMa", "structs"],
|
||||
["_$s4main6ScorerVMa", "structs"],
|
||||
["$s4main7ServiceCMa", "classes"],
|
||||
["$s4main5StateOMa", "enums"],
|
||||
["$s4main7ScoringPMp", "protocols"],
|
||||
["$s4main5OuterV5InnerCMa", "classes"],
|
||||
["$s4main4PairV5otherE7doubledSiyF", "extensions"],
|
||||
["$S4main4PairVMa", "structs"],
|
||||
["_T04main4PairVMa", "structs"],
|
||||
["$s4_TtX4PairVMa", "structs"],
|
||||
] as const)("decodes nominal contexts for %s", (name, category) => {
|
||||
expect(
|
||||
categorizeSwiftTypes([{ address: "0x1", name }], { category }),
|
||||
).toMatchObject({
|
||||
total: 1,
|
||||
categories: { [category]: { count: 1 } },
|
||||
unclassified: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains unresolved Swift observations under category filters", () => {
|
||||
const names = [
|
||||
"$s4main13swiftIndirectySiAA7Scoring_p_SitF",
|
||||
"$sSi4mainE7doubledSiyF",
|
||||
"$s9999999999999999999999BadVMa",
|
||||
"$s4main04PairVMa",
|
||||
];
|
||||
const result = categorizeSwiftTypes(
|
||||
names.map((name, index) => ({ address: String(index), name })),
|
||||
{ category: "structs" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
total: 0,
|
||||
unclassified: names.map((name) => ({
|
||||
name,
|
||||
mangled_names: [name],
|
||||
reason: "category_not_decoded",
|
||||
})),
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("could not be established"),
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
categorizeSwiftTypes([{ address: "0x1", name: names[0] ?? "" }], {
|
||||
category: "structs",
|
||||
pattern: "missing",
|
||||
}),
|
||||
).toMatchObject({ total: 0, unclassified: [] });
|
||||
});
|
||||
|
||||
it("joins Swift aliases only at the exact procedure address", () => {
|
||||
const result = categorizeSwiftTypes(
|
||||
[
|
||||
{ address: "0x1", name: "main::Pair::typeMetadataAccessor" },
|
||||
{ address: "0x2", name: "std::vector<int>::push_back" },
|
||||
{ address: "0x3", name: "main::Pair::other" },
|
||||
],
|
||||
{ category: "structs", pattern: "Pair" },
|
||||
[
|
||||
{ address: "0x1", name: "_$s4main4PairVMa" },
|
||||
{ address: "0x4", name: "$s4main4PairVMa" },
|
||||
],
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
total: 1,
|
||||
categories: {
|
||||
structs: {
|
||||
items: [
|
||||
{
|
||||
address: "0x1",
|
||||
name: "main::Pair::typeMetadataAccessor",
|
||||
mangled_names: ["_$s4main4PairVMa"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unclassified: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates display names after selecting a category", () => {
|
||||
expect(
|
||||
categorizeSwiftTypes(
|
||||
[
|
||||
{ address: "0x1", name: "shared" },
|
||||
{ address: "0x2", name: "shared" },
|
||||
],
|
||||
{ category: "structs" },
|
||||
[
|
||||
{ address: "0x1", name: "$s4main7ServiceCMa" },
|
||||
{ address: "0x2", name: "$s4main4PairVMa" },
|
||||
],
|
||||
),
|
||||
).toMatchObject({
|
||||
total: 1,
|
||||
categories: { structs: { items: [{ address: "0x2", name: "shared" }] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Swift alias ambiguity", () => {
|
||||
it("reports conflicting aliases rather than choosing a category", () => {
|
||||
expect(
|
||||
categorizeSwiftTypes(
|
||||
[{ address: "0x1", name: "shared" }],
|
||||
{ category: "structs" },
|
||||
[
|
||||
{ address: "0x1", name: "$s4main4PairVMa" },
|
||||
{ address: "0x1", name: "$s4main7ServiceCMa" },
|
||||
],
|
||||
),
|
||||
).toMatchObject({
|
||||
total: 0,
|
||||
unclassified: [
|
||||
{
|
||||
name: "shared",
|
||||
reason: "conflicting_categories",
|
||||
mangled_names: ["$s4main4PairVMa", "$s4main7ServiceCMa"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { AddressedName } from "./hopperValues.js";
|
||||
import {
|
||||
classifySwiftSymbol,
|
||||
type SwiftTypeCategory,
|
||||
} from "./swiftSymbolClassification.js";
|
||||
|
||||
/** Select and deduplicate Objective-C class labels. */
|
||||
export const discoverObjcClasses = (
|
||||
|
|
@ -34,50 +38,108 @@ export const discoverObjcProtocols = (
|
|||
};
|
||||
};
|
||||
|
||||
const SWIFT_CATEGORIES = [
|
||||
["classes", "_TtC"],
|
||||
["structs", "_TtV"],
|
||||
["enums", "_TtO"],
|
||||
["protocols", "_TtP"],
|
||||
["extensions", "_TtE"],
|
||||
] as const;
|
||||
type SwiftTypeCategory = (typeof SWIFT_CATEGORIES)[number][0] | "other";
|
||||
|
||||
/** Categorize deduplicated Swift mangled symbols. */
|
||||
/** Categorize procedure names using Swift manglings at their exact addresses. */
|
||||
export const categorizeSwiftTypes = (
|
||||
procedures: readonly AddressedName[],
|
||||
filter: {
|
||||
readonly category?: SwiftTypeCategory | undefined;
|
||||
readonly pattern?: string | undefined;
|
||||
} = {},
|
||||
): JsonValue => {
|
||||
const groups: Record<string, AddressedName[]> = Object.fromEntries(
|
||||
[...SWIFT_CATEGORIES.map(([category]) => category), "other"].map(
|
||||
symbols: readonly AddressedName[] = [],
|
||||
) => {
|
||||
const groups: Record<
|
||||
string,
|
||||
Array<{
|
||||
address: string;
|
||||
name: string;
|
||||
mangled_names: string[];
|
||||
}>
|
||||
> = Object.fromEntries(
|
||||
["classes", "structs", "enums", "protocols", "extensions", "other"].map(
|
||||
(category) => [category, []],
|
||||
),
|
||||
);
|
||||
const aliases = swiftAliasesByAddress(symbols);
|
||||
const pattern = filter.pattern;
|
||||
const seen = new Set<string>();
|
||||
const unclassifiedNames = new Set<string>();
|
||||
const unclassified: Array<{
|
||||
address: string;
|
||||
name: string;
|
||||
mangled_names: string[];
|
||||
reason: "category_not_decoded" | "conflicting_categories";
|
||||
}> = [];
|
||||
|
||||
for (const entry of procedures) {
|
||||
if (!entry.name.includes("_Tt") || seen.has(entry.name)) continue;
|
||||
const category =
|
||||
SWIFT_CATEGORIES.find(([, prefix]) =>
|
||||
entry.name.startsWith(prefix),
|
||||
)?.[0] ?? "other";
|
||||
if (filter.category !== undefined && filter.category !== category) continue;
|
||||
if (filter.pattern !== undefined && !entry.name.includes(filter.pattern))
|
||||
if (seen.has(entry.name)) continue;
|
||||
const mangledNames = [
|
||||
...new Set([
|
||||
...(classifySwiftSymbol(entry.name) === undefined ? [] : [entry.name]),
|
||||
...(aliases.get(entry.address) ?? []),
|
||||
]),
|
||||
];
|
||||
if (mangledNames.length === 0) continue;
|
||||
if (
|
||||
pattern !== undefined &&
|
||||
![entry.name, ...mangledNames].some((name) => name.includes(pattern))
|
||||
)
|
||||
continue;
|
||||
seen.add(entry.name);
|
||||
groups[category]?.push(entry);
|
||||
const kinds = new Set(
|
||||
mangledNames.map((name) => classifySwiftSymbol(name)?.category ?? null),
|
||||
);
|
||||
const category = kinds.size === 1 ? ([...kinds][0] ?? null) : null;
|
||||
const item = {
|
||||
address: entry.address,
|
||||
name: entry.name,
|
||||
mangled_names: mangledNames,
|
||||
};
|
||||
if (category === null && !unclassifiedNames.has(entry.name)) {
|
||||
unclassifiedNames.add(entry.name);
|
||||
unclassified.push({
|
||||
...item,
|
||||
reason:
|
||||
kinds.size > 1 && !kinds.has(null)
|
||||
? "conflicting_categories"
|
||||
: "category_not_decoded",
|
||||
});
|
||||
}
|
||||
const bucket = category ?? "other";
|
||||
if (filter.category === undefined || filter.category === bucket) {
|
||||
seen.add(entry.name);
|
||||
groups[bucket]?.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const categories = Object.fromEntries(
|
||||
Object.entries(groups).map(([category, items]) => [
|
||||
category,
|
||||
{ count: items.length, items: items.map(toJsonEntry) },
|
||||
{ count: items.length, items },
|
||||
]),
|
||||
);
|
||||
return { total: seen.size, categories };
|
||||
return {
|
||||
total: Object.values(groups).reduce((sum, items) => sum + items.length, 0),
|
||||
categories,
|
||||
unclassified,
|
||||
limitations: [
|
||||
"Identification uses Swift mangled procedure names or same-address symbol aliases. Demangled names without retained Swift manglings cannot be identified. Counts deduplicate procedure names, not source declarations.",
|
||||
...(unclassified.length === 0
|
||||
? []
|
||||
: [
|
||||
"Some Swift categories could not be established from the retained manglings. Unclassified observations remain inline regardless of the category filter.",
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const swiftAliasesByAddress = (symbols: readonly AddressedName[]) => {
|
||||
const aliases = new Map<string, string[]>();
|
||||
for (const { address, name } of symbols) {
|
||||
if (classifySwiftSymbol(name) === undefined) continue;
|
||||
const existing = aliases.get(address);
|
||||
if (existing === undefined) aliases.set(address, [name]);
|
||||
else existing.push(name);
|
||||
}
|
||||
return aliases;
|
||||
};
|
||||
|
||||
const uniqueByName = (entries: readonly AddressedName[]): AddressedName[] => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { err } from "../../../src/domain/result.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import { GHIDRA_SWIFT_INVENTORY } from "../../fixtures/swift/ghidraInventory.js";
|
||||
import {
|
||||
closeEnhancedToolResources,
|
||||
connect,
|
||||
jsonResult,
|
||||
} from "./enhancedToolsHarness.js";
|
||||
|
||||
afterEach(closeEnhancedToolResources);
|
||||
|
||||
describe("Swift classification through MCP", () => {
|
||||
it.each([
|
||||
{ arguments: {}, total: 6 },
|
||||
{ arguments: { category: "structs" }, total: 5 },
|
||||
{ arguments: { category: "structs", pattern: "Pair" }, total: 3 },
|
||||
{ arguments: { pattern: "_$s4main4PairVMa" }, total: 1 },
|
||||
])("classifies golden Ghidra observations with $arguments", async (test) => {
|
||||
const client = await connect({
|
||||
execute: async (operation) =>
|
||||
ok(
|
||||
operation === "list_procedures"
|
||||
? GHIDRA_SWIFT_INVENTORY.procedures
|
||||
: GHIDRA_SWIFT_INVENTORY.symbols,
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_swift_types",
|
||||
arguments: test.arguments,
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(jsonResult(result)).toMatchObject({ total: test.total });
|
||||
if (test.arguments.pattern === undefined)
|
||||
expect(jsonResult(result)).toMatchObject({
|
||||
unclassified: [
|
||||
{
|
||||
name: "main::$swiftIndirect",
|
||||
reason: "category_not_decoded",
|
||||
mangled_names: ["_$s4main13swiftIndirectySiAA7Scoring_p_SitF"],
|
||||
},
|
||||
],
|
||||
});
|
||||
else expect(jsonResult(result)).toMatchObject({ unclassified: [] });
|
||||
});
|
||||
|
||||
it("keeps provider names and supporting aliases inline", async () => {
|
||||
const client = await connect({
|
||||
execute: async (operation) =>
|
||||
ok(
|
||||
operation === "list_procedures"
|
||||
? GHIDRA_SWIFT_INVENTORY.procedures
|
||||
: GHIDRA_SWIFT_INVENTORY.symbols,
|
||||
),
|
||||
});
|
||||
expect(
|
||||
jsonResult(
|
||||
await client.callTool({
|
||||
name: "analyze_swift_types",
|
||||
arguments: { category: "structs", pattern: "typeMetadataAccessor" },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
total: 2,
|
||||
categories: {
|
||||
structs: {
|
||||
items: [
|
||||
{
|
||||
address: "0x100000ab4",
|
||||
name: "main::Pair::typeMetadataAccessor",
|
||||
mangled_names: ["_$s4main4PairVMa"],
|
||||
},
|
||||
{
|
||||
address: "0x100000ac4",
|
||||
name: "main::Scorer::typeMetadataAccessor",
|
||||
mangled_names: ["_$s4main6ScorerVMa"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
unclassified: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains known facts when a provider has no symbol inventory", async () => {
|
||||
const client = await connect({
|
||||
execute: async (operation) =>
|
||||
operation === "list_procedures"
|
||||
? ok([{ address: "0x1", value: "$s4main4PairVMa" }])
|
||||
: err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"fixture",
|
||||
operation,
|
||||
"no symbols",
|
||||
),
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_swift_types",
|
||||
arguments: { category: "structs" },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(jsonResult(result)).toMatchObject({
|
||||
total: 1,
|
||||
symbol_inventory_error: { code: "capability_unavailable" },
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("symbol inventory is unavailable"),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
new AnalysisOutputError("list_names", "malformed symbols"),
|
||||
new AnalysisArtifactChangedError(
|
||||
"list_names",
|
||||
"/fixture",
|
||||
"fixture changed",
|
||||
),
|
||||
])("preserves symbol-inventory failures: $_tag", async (failure) => {
|
||||
const tools = new EnhancedTools({
|
||||
execute: async (operation) =>
|
||||
operation === "list_procedures"
|
||||
? ok([{ address: "0x1", value: "$s4main4PairVMa" }])
|
||||
: err(failure),
|
||||
});
|
||||
expect(await tools.execute("analyze_swift_types", {})).toEqual(
|
||||
err(failure),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns cancellation during the symbol join", async () => {
|
||||
const controller = new AbortController();
|
||||
const tools = new EnhancedTools({
|
||||
execute: async (operation) => {
|
||||
if (operation === "list_names") controller.abort();
|
||||
return ok([{ address: "0x1", value: "$s4main4PairVMa" }]);
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await tools.execute("analyze_swift_types", {}, controller.signal),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
protocol Scoring { func score(_ x: Int) -> Int }
|
||||
struct Pair { var a: Int; var b: Int }
|
||||
struct Scorer: Scoring {
|
||||
@inline(never) func score(_ x: Int) -> Int { x + 29 }
|
||||
}
|
||||
@inline(never)
|
||||
func swiftIndirect(_ s: any Scoring, _ x: Int) -> Int { s.score(x) }
|
||||
@inline(never)
|
||||
func swiftPair(_ p: Pair) -> Int { p.a + 3 * p.b }
|
||||
print(swiftIndirect(Scorer(), CommandLine.arguments.count) + swiftPair(Pair(a: 1, b: 2)))
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
// Golden projection of Ghidra 12.1.4 list_procedures/list_names on macOS arm64.
|
||||
// Source: tests/conformance/swift/type-inventory.swift, compiled with swiftc -O -g.
|
||||
// Includes selected procedure entries, all their symbols, and an off-entry metadata symbol.
|
||||
export const GHIDRA_SWIFT_INVENTORY = {
|
||||
procedures: [
|
||||
{
|
||||
address: "0x100000858",
|
||||
value: "entry",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "main::Pair::init",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000958",
|
||||
value: "main::$swiftIndirect",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x1000009bc",
|
||||
value: "main::Pair::set_a",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000a28",
|
||||
value: "main::Scorer::$score",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ab4",
|
||||
value: "main::Pair::typeMetadataAccessor",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ac4",
|
||||
value: "main::Scorer::typeMetadataAccessor",
|
||||
procedure: {
|
||||
external: false,
|
||||
thunk: false,
|
||||
thunk_target: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
symbols: [
|
||||
{
|
||||
address: "0x100000858",
|
||||
value: "_main",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000858",
|
||||
value: "entry",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "_$s4main4PairV1a1bACSi_SitcfC",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "_$s4main4PairV1aSivM.resume.0",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "_$s4main4PairV1aSivg",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "_$s4main4PairV1bSivM.resume.0",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "_$s4main6ScorerVACycfC",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000954",
|
||||
value: "main::Pair::init",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000958",
|
||||
value: "_$s4main13swiftIndirectySiAA7Scoring_p_SitF",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000958",
|
||||
value: "main::$swiftIndirect",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x1000009bc",
|
||||
value: "_$s4main4PairV1aSivs",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x1000009bc",
|
||||
value: "main::Pair::set_a",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000a28",
|
||||
value: "_$s4main6ScorerV5scoreyS2iFTf4nd_n",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000a28",
|
||||
value: "main::Scorer::$score",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ab4",
|
||||
value: "_$s4main4PairVMa",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ab4",
|
||||
value: "main::Pair::typeMetadataAccessor",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ac4",
|
||||
value: "_$s4main6ScorerVMa",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000ac4",
|
||||
value: "main::Scorer::typeMetadataAccessor",
|
||||
symbol: {
|
||||
primary: true,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "function",
|
||||
source: "analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
address: "0x100000c0c",
|
||||
value: "_$s4main4PairVMn",
|
||||
symbol: {
|
||||
primary: false,
|
||||
dynamic: false,
|
||||
external: false,
|
||||
type: "label",
|
||||
source: "imported",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Loading…
Reference in New Issue