diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 6e71760097..fb70dc19d8 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -467,6 +467,12 @@ unreadable default plist of another target reports `capability_unavailable`. Permission failures remain `access_denied`; output and cleanup failures retain their own error categories. +Selected plist paths preserve filesystem resolution, including `..` after a +directory symlink and trailing directory separators. Resource-only bundle +sessions resolve relative selections from the bundle root with the same path +semantics as executable sessions; the reported source path retains those +selected segments. + A conversion refusal includes plutil's diagnostic from either output stream. `details.captured_output` retains bounded stdout/stderr excerpts, observed byte totals and exit status. `truncated: true` means those excerpts are incomplete. @@ -700,6 +706,16 @@ complete observation. arm64e formats; - legacy `LC_DYLD_INFO` rebase and bind opcodes. + [dyld symbol names are byte strings](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L203-L208), + compared with [`strcmp`](https://github.com/apple-oss-distributions/dyld/blob/dyld-1330/mach_o/BindOpcodes.cpp#L42-L56). + When a name has no lossless UTF-8 text + projection, the affected binding remains unresolved and pointer-fixup + coverage is partial; later readable bindings and other metadata survive. + The internal fixup reader retains the exact name bytes and parsed binding + fields, while public Objective-C and Swift results retain the unresolved + reason instead of publishing a guessed symbol or class name. A literal + U+FFFD in valid UTF-8 remains a known symbol character. + A Swift conformance whose type or protocol reference is an external bind keeps its name as `null` and carries the raw dyld identity in `type_binding` or `protocol_binding`; that record and its facet coverage are diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index e6a9b8ef57..aeed9ad65f 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -314,6 +314,54 @@ const swiftConformanceTypeReferenceImage = ( return bytes; }; +describe("Imported class symbol byte identities", () => { + it.each(["invalid bytes", "literal replacement character"] as const)( + "keeps imported class identity distinct for %s", + (nameKind) => { + const bytes = swiftExternalSwiftBindingImage("objc-class"); + const nameAt = 0xf02; + const literalName = "_OBJC_CLASS_$_�bject"; + if (nameKind === "invalid bytes") + bytes[nameAt + Buffer.byteLength("_OBJC_CLASS_$_")] = 0xff; + else Buffer.from(literalName).copy(bytes, nameAt); + const result = decodeAppleDispatchMetadata( + bytes, + 100, + provenance, + "arm64", + ["0x100000b00"], + ); + expect(result.swift_conformances).toMatchObject([ + { + type_name: null, + type_reference_kind: 3, + protocol_name: "Equatable", + type_binding: + nameKind === "invalid bytes" ? null : { symbol: literalName }, + decode: { status: "partial" }, + }, + ]); + expect(result.objc_class_import_bindings).toEqual( + nameKind === "invalid bytes" + ? [] + : [expect.objectContaining({ symbol: literalName })], + ); + expect(result.objc_class_import_binding_coverage).toMatchObject({ + status: nameKind === "invalid bytes" ? "partial" : "complete", + }); + expect( + result.coverage.find(({ facet }) => facet === "pointer_fixups"), + ).toMatchObject({ + status: nameKind === "invalid bytes" ? "partial" : "complete", + }); + if (nameKind === "invalid bytes") + expect(result.objc_class_import_binding_coverage?.reason).toContain( + "UTF-8", + ); + }, + ); +}); + describe("Swift imported protocol references", () => { it("preserves bind identity without reading VM address zero and marks coverage partial", () => { const result = decodeAppleDispatchMetadata( @@ -1638,7 +1686,7 @@ describe("malformed Apple dispatch metadata", () => { const bytes = fixture(); bytes.writeUInt32LE(7, 36); expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow( - "command size", + new RangeError("load command 0 at offset 0 has invalid cmdsize 7"), ); expect(() => decodeAppleDispatchMetadata(Buffer.alloc(10), 100, provenance), diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 43cda5621b..ce3effe14f 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -155,6 +155,171 @@ const SEGMENTS: Segment[] = [ { address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false }, ]; +it.each([1, 2, 3] as const)( + "preserves non-UTF-8 chained symbol bytes without inventing class identity for import format %i", + (format) => { + const data = chainedData(format); + const nameAt = data.readUInt32LE(12) + 1; + data[nameAt + Buffer.byteLength("_OBJC_CLASS_$_")] = 0xff; + data.writeBigUInt64LE( + field(1n, 63) | field(2n, 51) | field(3n, 24), + 0x4008, + ); + data.writeBigUInt64LE(1n | field(1n, 63), 0x4010); + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: 180 }, + binds: [], + rebases: [], + dylibs: ["/library-1", "/library-2"], + }, + SEGMENTS, + BASE, + ); + expect( + fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)), + ).toMatchObject({ + kind: "unsupported", + reason: expect.stringContaining("UTF-8"), + binding: { + symbol: null, + symbol_bytes_base64: data + .subarray(nameAt, data.indexOf(0, nameAt)) + .toString("base64"), + binding_source: "chained", + library_ordinal: 1, + library: "/library-1", + weak: false, + addend: 3n, + }, + }); + expect( + fixups.decode(BASE + 0x4010n, data.readBigUInt64LE(0x4010)), + ).toMatchObject({ + kind: "bind", + symbol: "_free", + library_ordinal: 2, + }); + expect(fixups.issues).toContainEqual( + expect.objectContaining({ + code: "chained_pointer_decode_failed", + location: `0x${(BASE + 0x4008n).toString(16)}`, + }), + ); + expect(fixups.decoded).toBe(0); + const literal = "_OBJC_CLASS_$_�bject"; + Buffer.from(literal).copy(data, nameAt); + const literalFixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: 180 }, + binds: [], + rebases: [], + dylibs: ["/library-1", "/library-2"], + }, + SEGMENTS, + BASE, + ); + expect( + literalFixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)), + ).toMatchObject({ + kind: "bind", + symbol: literal, + }); + }, +); + +it.each(["bind", "weak", "lazy"] as const)( + "retains partial non-UTF-8 %s bindings and recovers subsequent lossless names", + (stream) => { + const invalidName = Buffer.concat([ + Buffer.from("_OBJC_CLASS_$_"), + Buffer.from([0xff]), + ]); + const otherInvalidName = Buffer.concat([ + Buffer.from("_OBJC_CLASS_$_"), + Buffer.from([0xfe]), + ]); + const literalName = "_OBJC_CLASS_$_�"; + const opcodes = Buffer.from([ + 0x11, + 0x71, + 0x08, + 0x60, + 0x04, + 0x40, + ...invalidName, + 0, + 0x90, + 0x40, + ...otherInvalidName, + 0, + 0x90, + 0x40, + ...Buffer.from(literalName), + 0, + 0x90, + 0x40, + ...Buffer.from("_later"), + 0, + 0x90, + 0, + ]); + const data = Buffer.alloc(0x8000); + opcodes.copy(data, 0x7000); + const fixups = parsePointerFixups( + data, + { + chained: null, + binds: [{ offset: 0x7000, size: opcodes.length, stream }], + rebases: [], + dylibs: ["/library-1"], + }, + SEGMENTS, + BASE, + ); + expect(fixups.decode(BASE + 0x4008n, 0n)).toMatchObject({ + kind: "unsupported", + reason: expect.stringContaining("UTF-8"), + binding: { + symbol: null, + symbol_bytes_base64: invalidName.toString("base64"), + binding_source: stream, + library_ordinal: 1, + library: stream === "weak" ? null : "/library-1", + weak: false, + addend: 4n, + }, + }); + expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({ + kind: "unsupported", + binding: { + symbol: null, + symbol_bytes_base64: otherInvalidName.toString("base64"), + }, + }); + expect(fixups.decode(BASE + 0x4018n, 0n)).toMatchObject({ + kind: "bind", + symbol: literalName, + }); + expect(fixups.decode(BASE + 0x4020n, 0n)).toMatchObject({ + kind: "bind", + symbol: "_later", + }); + expect(fixups.issues).toContainEqual( + expect.objectContaining({ + code: "bind_symbol_text_unavailable", + location: `0x${(BASE + 0x4008n).toString(16)}`, + }), + ); + expect(fixups.decode(BASE + 0x4028n, 0n)).toEqual({ + kind: "raw", + target: 0n, + }); + }, +); + const legacyRebaseInput = (stream: Buffer) => { const data = Buffer.alloc(0x8000); stream.copy(data, 0x7000); diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index de2c6939cc..0e977418de 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -35,7 +35,12 @@ export type DecodedPointer = readonly weak: boolean; readonly addend: bigint; } - | { readonly kind: "unsupported"; readonly reason: string }; + | { + readonly kind: "unsupported"; + readonly reason: string; + /** Source bind facts survive when its byte name has no lossless text projection. */ + readonly binding?: Import & { readonly symbol: null }; + }; /** Pointer-slot outcome, including whether a rebase was actually applied. */ export type PointerResolution = @@ -58,7 +63,10 @@ export interface PointerFixups { readonly issues: readonly DecodeIssue[]; /** Chained segments, legacy rebase streams, or bind streams this reader walked. */ readonly examined: number; - /** Fully decoded fixup segments or bind streams, in the same units as `examined`. */ + /** Chained segments without observed decode failures, plus structurally parsed + * legacy opcode streams, in the same units as `examined`. Legacy location + * coverage survives unavailable symbol text, which is qualified by `issues`. + */ readonly decoded: number; /** Declared legacy opcode streams examined by this owner. */ readonly legacyRebaseStreams: number; @@ -87,22 +95,28 @@ const bits = (value: bigint, from: number, width: number): bigint => const signed = (value: bigint, width: number): bigint => value >= 1n << BigInt(width - 1) ? value - (1n << BigInt(width)) : value; -interface Import { - readonly symbol: string; +type SymbolName = + | { readonly symbol: string } + | { readonly symbol: null; readonly symbol_bytes_base64: string }; + +type Import = SymbolName & { readonly binding_source: "chained" | "bind" | "weak" | "lazy"; readonly library_ordinal: number; readonly library: string | null; readonly weak: boolean; readonly addend: bigint; -} +}; interface BindRun { readonly start: bigint; count: bigint; readonly stride: bigint; readonly binding: - | { readonly kind: "bind"; readonly value: Import } - | { readonly kind: "unsupported"; readonly reason: string }; + | { + readonly kind: "bind"; + readonly value: Import & { readonly symbol: string }; + } + | Extract; } interface BindFacts { @@ -111,6 +125,7 @@ interface BindFacts { readonly issues: readonly DecodeIssue[]; readonly examined: number; readonly decoded: number; + /** All opcode locations are known, independently of symbol text availability. */ readonly complete: boolean; } @@ -132,13 +147,50 @@ const libraryName = ( return null; }; -const cString = (bytes: Buffer, offset: number, end: number): string => { +/** dyld names are NUL-terminated bytes; text projection must never replace their identity. */ +const symbolName = (bytes: Buffer): SymbolName => { + try { + return { + symbol: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ), + }; + } catch { + return { symbol: null, symbol_bytes_base64: bytes.toString("base64") }; + } +}; + +const cString = (bytes: Buffer, offset: number, end: number): SymbolName => { const stop = bytes.indexOf(0, offset); if (offset < 0 || offset >= end || stop < 0 || stop >= end) throw new RangeError("Fixup symbol name is outside the symbol pool"); - return bytes.toString("utf8", offset, stop); + return symbolName(bytes.subarray(offset, stop)); }; +const resolvedBind = ( + imported: Import, + addend: bigint, +): Exclude => + imported.symbol === null + ? { + kind: "unsupported", + reason: + "Fixup symbol bytes cannot be represented losslessly as UTF-8 text", + binding: { ...imported, addend: imported.addend + addend }, + } + : { kind: "bind", ...imported, addend: imported.addend + addend }; + +const sameImport = (left: Import, right: Import): boolean => + left.symbol === right.symbol && + (left.symbol !== null || + right.symbol !== null || + left.symbol_bytes_base64 === right.symbol_bytes_base64) && + left.binding_source === right.binding_source && + left.library_ordinal === right.library_ordinal && + left.library === right.library && + left.weak === right.weak && + left.addend === right.addend; + const readImport = ( data: { readonly bytes: Buffer; @@ -172,7 +224,7 @@ const readImport = ( const ordinal = encodedOrdinal > 0xfff0 ? encodedOrdinal - 0x10000 : encodedOrdinal; return { - symbol: cString( + ...cString( bytes, base + header.symbols + Number(bits(word, 32, 32)), end, @@ -189,7 +241,7 @@ const readImport = ( const ordinal = encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal; return { - symbol: cString(bytes, base + header.symbols + (word >>> 9), end), + ...cString(bytes, base + header.symbols + (word >>> 9), end), binding_source: "chained", library_ordinal: ordinal, library: libraryName(ordinal, dylibs), @@ -212,7 +264,7 @@ export const decodeChainedPointer = ( : imports[Number(ordinal)]; if (target === undefined) throw new RangeError(`Chained bind ordinal ${ordinal} has no import`); - return { kind: "bind", ...target, addend: target.addend + addend }; + return resolvedBind(target, addend); }; if (format === 2 || format === 6) { if (bits(raw, 63, 1) === 1n) @@ -496,6 +548,31 @@ const chainedFixups = ( const failures = issues.map(({ message }) => message); const applicabilityFailures = [...rebases.failures, ...binds.failures]; const applicabilityComplete = rebases.complete && binds.complete; + const recordPointerFailure = ( + reason: string, + address: bigint, + chainIndex: number, + ): void => { + const location = `0x${address.toString(16)}`; + if ( + !issues.some( + (entry) => + entry.code === "chained_pointer_decode_failed" && + entry.location === location, + ) + ) { + issues.push( + issue( + "pointer_fixups", + "chained_pointer_decode_failed", + reason, + location, + ), + ); + failures.push(reason); + } + decodedRanges.delete(chainIndex); + }; return { kind: "chained", formats, @@ -595,6 +672,8 @@ const chainedFixups = ( baseAddress, imports, ); + if (decoded.kind === "unsupported") + recordPointerFailure(decoded.reason, address, chainIndex); return decoded; } if ( @@ -605,25 +684,7 @@ const chainedFixups = ( return legacyBind ?? legacyRebase ?? rawPointer(); } catch (cause: unknown) { const reason = cause instanceof Error ? cause.message : String(cause); - const location = `0x${address.toString(16)}`; - if ( - !issues.some( - (entry) => - entry.code === "chained_pointer_decode_failed" && - entry.location === location, - ) - ) { - issues.push( - issue( - "pointer_fixups", - "chained_pointer_decode_failed", - reason, - location, - ), - ); - failures.push(reason); - } - decodedRanges.delete(chainIndex); + recordPointerFailure(reason, address, chainIndex); return { kind: "unsupported", reason }; } }, @@ -926,7 +987,7 @@ interface BindState { ordinal: number; ordinalFailure: string | null; segmentIndex: number; - symbol: string; + symbol: SymbolName; weak: boolean; addend: bigint; address: bigint; @@ -967,7 +1028,7 @@ const setBindRegister = ( const stop = bytes.indexOf(0, cursor.at); if (stop < 0 || stop >= end) throw new RangeError("Unterminated bind symbol"); - state.symbol = bytes.toString("utf8", cursor.at, stop); + state.symbol = symbolName(bytes.subarray(cursor.at, stop)); state.weak = (immediate & 0x1) !== 0; cursor.at = stop + 1; return true; @@ -1010,7 +1071,7 @@ const readBindStream = ( ordinal: 0, ordinalFailure: null, segmentIndex: -1, - symbol: "", + symbol: { symbol: "" }, weak: false, addend: 0n, address: 0n, @@ -1034,17 +1095,19 @@ const readBindStream = ( if (state.ordinalFailure !== null) throw new RangeError(state.ordinalFailure); const resolvedLibrary = libraryName(state.ordinal, image.dylibs); - binding = { - kind: "bind", - value: { - symbol: state.symbol, + const decoded = resolvedBind( + { + ...state.symbol, binding_source: range.stream, library_ordinal: state.ordinal, library: range.stream === "weak" ? null : resolvedLibrary, weak: state.weak, addend: state.addend, }, - }; + 0n, + ); + binding = + decoded.kind === "bind" ? { kind: "bind", value: decoded } : decoded; } catch (cause: unknown) { binding = { kind: "unsupported", @@ -1057,14 +1120,12 @@ const readBindStream = ( right: BindRun["binding"], ) => left.kind === "unsupported" && right.kind === "unsupported" - ? left.reason === right.reason + ? left.reason === right.reason && + (left.binding === undefined || right.binding === undefined + ? left.binding === right.binding + : sameImport(left.binding, right.binding)) : left.kind === "bind" && right.kind === "bind" - ? left.value.symbol === right.value.symbol && - left.value.binding_source === right.value.binding_source && - left.value.library_ordinal === right.value.library_ordinal && - left.value.library === right.value.library && - left.value.weak === right.value.weak && - left.value.addend === right.value.addend + ? sameImport(left.value, right.value) : false; if ( previous !== undefined && @@ -1157,15 +1218,30 @@ const parseLegacyBinds = ( ); } } + const complete = failures.length === 0; + const issues = failures.map((message) => + issue("pointer_fixups", "bind_stream_decode_failed", message), + ); + for (const run of runs) { + if (run.binding.kind !== "unsupported" || run.binding.binding === undefined) + continue; + issues.push( + issue( + "pointer_fixups", + "bind_symbol_text_unavailable", + run.binding.reason, + `0x${run.start.toString(16)}`, + ), + ); + failures.push(run.binding.reason); + } return { runs, failures, - issues: failures.map((message) => - issue("pointer_fixups", "bind_stream_decode_failed", message), - ), + issues, examined: streams.length, decoded, - complete: failures.length === 0, + complete, }; }; diff --git a/src/native/NativePlistResourceRoot.test.ts b/src/native/NativePlistResourceRoot.test.ts index 81ef2beb38..f1b4ac8b09 100644 --- a/src/native/NativePlistResourceRoot.test.ts +++ b/src/native/NativePlistResourceRoot.test.ts @@ -4,7 +4,7 @@ import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js"; import { NativeFixtureRunner } from "../../tests/fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; -import { writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; it("inspects a resolved app plist without executable identity", async () => { const bundle = await createTestTempDirectory("rea-plist-resource-root-"); @@ -44,3 +44,39 @@ it("inspects a resolved app plist without executable identity", async () => { }, }); }); + +it.skipIf(process.platform === "win32")( + "preserves physical selected plist paths in resource-only roots", + async () => { + const bundle = await createTestTempDirectory("rea-plist-resource-path-"); + const physical = join(bundle, "physical"); + await mkdir(join(physical, "child"), { recursive: true }); + await writeFile(join(bundle, "Info.plist"), "lexical decoy"); + await writeFile(join(physical, "Info.plist"), "selected source"); + await symlink(join(physical, "child"), join(bundle, "alias")); + const root = { + callerPath: bundle, + canonicalPath: bundle, + infoPlistPath: join(bundle, "Info.plist"), + layout: "root" as const, + executableSha256: null, + }; + for (const requested of [ + "alias/../Info.plist", + `${bundle}/alias/../Info.plist`, + "physical/Info.plist", + ]) { + const selected = resolveBundleResourcePlistSelection(root, requested); + if (!selected.ok) throw selected.error; + expect(selected.value.origin).toBe("selected"); + expect(await readFile(selected.value.path, "utf8")).toBe( + "selected source", + ); + } + const directory = resolveBundleResourcePlistSelection(root, "Info.plist/"); + if (!directory.ok) throw directory.error; + await expect(readFile(directory.value.path)).rejects.toMatchObject({ + code: "ENOTDIR", + }); + }, +); diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index eb2086220c..164428fe03 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -91,9 +91,7 @@ export const resolveBundleResourcePlistSelection = ( if (requested === undefined) return ok({ path: root.infoPlistPath, origin: "default" }); return ok({ - path: isAbsolute(requested) - ? resolve(requested) - : resolve(root.canonicalPath, requested), + path: absoluteCallerPath(requested, { cwd: root.canonicalPath }), origin: "selected", }); }; diff --git a/tests/acceptance/applications/resourceOnlyPlistSelection.test.ts b/tests/acceptance/applications/resourceOnlyPlistSelection.test.ts new file mode 100644 index 0000000000..e5a22ed9be --- /dev/null +++ b/tests/acceptance/applications/resourceOnlyPlistSelection.test.ts @@ -0,0 +1,75 @@ +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { buildBinary } from "plist"; +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.skipIf(process.platform !== "darwin")( + "resource-only MCP plist selections retain symlink-parent and directory semantics", + async () => { + const root = await createTestTempDirectory("rea-resource-plist-selection-"); + const app = join(root, "Fixture.app"); + const contents = join(app, "Contents"); + const physical = join(contents, "physical"); + await mkdir(join(physical, "child"), { recursive: true }); + const defaultPath = join(contents, "Info.plist"); + const sourcePath = join(physical, "Selected.plist"); + const decoyPath = join(contents, "Selected.plist"); + const source = buildBinary({ Marker: "physical source" }); + const decoy = buildBinary({ Marker: "lexical decoy" }); + await writeFile( + defaultPath, + buildBinary({ CFBundleIdentifier: "fixture" }), + ); + await writeFile(sourcePath, source); + await writeFile(decoyPath, decoy); + await symlink("physical/child", join(contents, "alias")); + const { call } = await connectLocalToolsMcp({ + HOME: root, + USERPROFILE: root, + }); + expect( + (await call("open_bundle_resources", { path: app })).isError, + ).not.toBe(true); + const defaultResult = await call("inspect_plist", {}); + expect(defaultResult.isError).not.toBe(true); + expect( + parseEvidence(defaultResult.structuredContent).normalized_result, + ).toMatchObject({ + source_path: defaultPath, + value: { CFBundleIdentifier: "fixture" }, + }); + for (const requested of [ + "Contents/alias/../Selected.plist", + `${contents}/alias/../Selected.plist`, + sourcePath, + ]) { + const response = await call("inspect_plist", { path: requested }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + parseEvidence(response.structuredContent).normalized_result, + ).toMatchObject({ + source_path: requested.startsWith("/") + ? requested + : `${app}/${requested}`, + value: { Marker: "physical source" }, + resource_root: { canonical_path: app, executable_sha256: null }, + }); + } + expect( + parseMcpToolError( + await call("inspect_plist", { path: "Contents/Selected.plist/" }), + ).error, + ).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["path"] }] }, + }); + expect(await readFile(sourcePath)).toEqual(Buffer.from(source)); + expect(await readFile(decoyPath)).toEqual(Buffer.from(decoy)); + }, +);