From 76500f12b54e07b1ea700a403b2593890d63b42a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:58:32 +0800 Subject: [PATCH] fix(native): preserve selected paths and binding identities Reuse filesystem-aware path selection for resource-only plist sessions so directory symlinks and trailing separators retain their meaning. Parse dyld bind names once as lossless text or exact bytes, preserving partial binding facts and independent valid metadata without invented class identities. Keep coverage issues separate from known opcode applicability and exercise both producer and generated Mach-O consumers. --- docs/native-investigation.md | 16 ++ src/native/AppleDispatchMetadata.test.ts | 50 ++++- src/native/AppleMachoFixups.test.ts | 165 ++++++++++++++++ src/native/AppleMachoFixups.ts | 176 +++++++++++++----- src/native/NativePlistResourceRoot.test.ts | 38 +++- src/native/NativePlistSource.ts | 4 +- .../resourceOnlyPlistSelection.test.ts | 75 ++++++++ 7 files changed, 469 insertions(+), 55 deletions(-) create mode 100644 tests/acceptance/applications/resourceOnlyPlistSelection.test.ts 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)); + }, +);