diff --git a/docs/testing.md b/docs/testing.md index 1422874cb..b4ed4fc65 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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//` or `build/os//`, in addition to the diff --git a/package.json b/package.json index 537a0396a..ef3ac0893 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-real-ghidra-swift.mjs b/scripts/verify-real-ghidra-swift.mjs new file mode 100644 index 000000000..589155a24 --- /dev/null +++ b/scripts/verify-real-ghidra-swift.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); +} diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index d05a1cde5..a27d0e566 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -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 { diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index fdebbc28b..5d81cc986 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -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() }), diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 1b407eda3..18b8d3af0 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -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( diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 0da2b520c..a928101ec 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -283,9 +283,21 @@ export const enhancedOutputSchemas: Readonly> = { 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( diff --git a/src/domain/swiftSymbolClassification.ts b/src/domain/swiftSymbolClassification.ts new file mode 100644 index 000000000..4903a97bd --- /dev/null +++ b/src/domain/swiftSymbolClassification.ts @@ -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 = 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; +}; diff --git a/src/domain/symbolAnalysis.test.ts b/src/domain/symbolAnalysis.test.ts index a545dbc97..f663d4ec2 100644 --- a/src/domain/symbolAnalysis.test.ts +++ b/src/domain/symbolAnalysis.test.ts @@ -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::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::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"], + }, + ], + }); + }); }); diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index a733ecc77..9f2e89915 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -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 = 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(); + const unclassifiedNames = new Set(); + 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(); + 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[] => { diff --git a/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts new file mode 100644 index 000000000..49d7268a5 --- /dev/null +++ b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts @@ -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" } }); + }); +}); diff --git a/tests/conformance/swift/type-inventory.swift b/tests/conformance/swift/type-inventory.swift new file mode 100644 index 000000000..90560a0d5 --- /dev/null +++ b/tests/conformance/swift/type-inventory.swift @@ -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))) diff --git a/tests/fixtures/swift/ghidraInventory.ts b/tests/fixtures/swift/ghidraInventory.ts new file mode 100644 index 000000000..2f23b32ba --- /dev/null +++ b/tests/fixtures/swift/ghidraInventory.ts @@ -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", + }, + }, + ], +};