From aeebba391ee5c288b75cd828e7d18d32b40aa698 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:59:28 +0000 Subject: [PATCH 1/2] feat(native): open iOS-style and iOS-on-Mac wrapper app bundles Every native command rejected an iOS app installed on an Apple silicon Mac, such as /Applications/Stash.app, with "app has no readable CFBundleExecutable": the resolver understood only the macOS layout, Contents/Info.plist with Contents/MacOS/. iOS-style bundles, including those extracted from an IPA, keep Info.plist and the program file at the bundle root, and an iOS-on-Mac wrapper holds that bundle in Wrapper/.app. Bundle resolution moves to AppBundleExecutable and accepts the macOS layout, then the flat layout, then a wrapper's single real Wrapper/*.app directory. The WrappedBundle symlink is never followed, and the program file must stay inside its bundle. inspect_plist defaults to the Info.plist beside the program file when the bundle has no Contents/Info.plist. Co-Authored-By: Claude Opus 5.5 --- src/application/AppBundleExecutable.ts | 168 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 83 +-------- src/native/NativeMacOSProvider.ts | 21 ++- .../boundary/filesystem/binaryTarget.test.ts | 56 ++++++ .../providers/native/nativeProvider.test.ts | 20 +++ 5 files changed, 266 insertions(+), 82 deletions(-) create mode 100644 src/application/AppBundleExecutable.ts diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts new file mode 100644 index 000000000..36e4452f6 --- /dev/null +++ b/src/application/AppBundleExecutable.ts @@ -0,0 +1,168 @@ +import { execFile } from "node:child_process"; +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { z } from "zod"; + +import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { isPathWithinRoot } from "../domain/localPath.js"; +import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const execFileAsync = promisify(execFile); + +/** Where one app bundle layout keeps its Info.plist and program file. */ +interface BundleLayout { + readonly plist: readonly string[]; + readonly programs: readonly string[]; + readonly programsLabel: string; +} + +const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ + // macOS bundles keep both under Contents. + { + plist: ["Contents", "Info.plist"], + programs: ["Contents", "MacOS"], + programsLabel: "Contents/MacOS", + }, + // iOS-style bundles, as extracted from an IPA, keep both at the root. + { plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" }, +]; + +/** + * Resolve an app bundle directory to its declared program file. macOS and + * flat iOS-style layouts are read directly. An iOS app installed on a Mac is + * a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is + * found by listing `Wrapper`, never by following the `WrappedBundle` link. + */ +export const resolveAppBundleExecutable = async ( + path: string, +): Promise> => { + const layout = await presentLayout(path); + if (layout !== undefined) return resolveLayoutExecutable(path, layout); + const wrapped = await wrappedBundle(path); + if (wrapped !== undefined) { + const wrappedLayout = await presentLayout(wrapped); + if (wrappedLayout !== undefined) + return resolveLayoutExecutable(wrapped, wrappedLayout); + } + return err( + new BinaryTargetError(path, "app has no readable CFBundleExecutable", { + cause: new Error( + "No Contents/Info.plist, root Info.plist, or single Wrapper/*.app bundle was found", + ), + }), + ); +}; + +const presentLayout = async ( + bundle: string, +): Promise => { + for (const layout of BUNDLE_LAYOUTS) + if (await isRegularFile(join(bundle, ...layout.plist))) return layout; + return undefined; +}; + +const isRegularFile = async (path: string): Promise => { + try { + return (await lstat(path)).isFile(); + } catch { + return false; + } +}; + +/** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */ +const wrappedBundle = async (bundle: string): Promise => { + const wrapper = join(bundle, "Wrapper"); + try { + if (!(await lstat(wrapper)).isDirectory()) return undefined; + const apps = (await readdir(wrapper, { withFileTypes: true })).filter( + (entry) => + entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), + ); + const [app] = apps; + return apps.length === 1 && app !== undefined + ? join(wrapper, app.name) + : undefined; + } catch { + return undefined; + } +}; + +const resolveLayoutExecutable = async ( + bundle: string, + layout: BundleLayout, +): Promise> => { + const plistPath = join(bundle, ...layout.plist); + let name: string; + try { + const plist = await readFile(plistPath); + name = + plist.subarray(0, 6).toString("ascii") === "bplist" + ? await readBinaryPlistExecutable(plistPath) + : parseXmlPlistExecutable(plist.toString("utf8")); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app has no readable CFBundleExecutable", { + cause, + }), + ); + } + if (!isSafeExecutableName(name)) + return err( + new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"), + ); + const programs = join(bundle, ...layout.programs); + const executable = join(programs, name); + try { + const [canonicalPrograms, canonicalExecutable] = await Promise.all([ + realpath(programs), + realpath(executable), + ]); + if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) + return err( + new BinaryTargetError( + bundle, + `app program file leaves ${layout.programsLabel}`, + ), + ); + return ok(canonicalExecutable); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app program file is missing", { cause }), + ); + } +}; + +/** Decode the top-level executable name with an XML parser, not a pattern. */ +const parseXmlPlistExecutable = (plist: string): string => { + // An unrelated `__proto__` entry must not make the bundle unreadable. + const { value } = parseXmlPropertyList(plist); + const executable = executableEntrySchema.safeParse(value); + if (!executable.success) throw new Error("CFBundleExecutable is missing"); + return executable.data.CFBundleExecutable; +}; + +const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); + +const readBinaryPlistExecutable = async (plistPath: string): Promise => + // `-n` strips only the newline plutil appends; it requires macOS 12+. + ( + await execFileAsync("/usr/bin/plutil", [ + "-extract", + "CFBundleExecutable", + "raw", + "-n", + "-o", + "-", + plistPath, + ]) + ).stdout; + +const isSafeExecutableName = (name: string): boolean => + name.length > 0 && + name !== "." && + name !== ".." && + !name.includes("\0") && + !/[/\\]/u.test(name); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 1b2fb8fdc..564a867af 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -3,22 +3,16 @@ import { createHash } from "node:crypto"; import { access, open, - readFile, realpath, stat, type FileHandle, } from "node:fs/promises"; -import { execFile } from "node:child_process"; -import { extname, isAbsolute, join, resolve } from "node:path"; -import { promisify } from "node:util"; +import { extname, isAbsolute, resolve } from "node:path"; -import { z } from "zod"; - -import { isPathWithinRoot } from "../domain/localPath.js"; -import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -35,8 +29,6 @@ import { type ExecutableMetadata, } from "../domain/binaryTarget.js"; -const execFileAsync = promisify(execFile); - /** * Resolve and classify a readable local target before a provider is selected. * FAT Mach-O inputs select only a host-compatible architecture so setup remains @@ -223,82 +215,15 @@ const resolveAppBundle = async ( return err( new BinaryTargetError( path, - "this opening route requires a file or macOS app bundle", + "this opening route requires a file or app bundle", { constraint: "directory_requires_file", }, ), ); - const plistPath = join(path, "Contents", "Info.plist"); - let name: string; - try { - const plist = await readFile(plistPath); - name = - plist.subarray(0, 6).toString("ascii") === "bplist" - ? await readBinaryPlistExecutable(plistPath) - : parseXmlPlistExecutable(plist.toString("utf8")); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app has no readable CFBundleExecutable", { - cause, - }), - ); - } - if (!isSafeExecutableName(name)) - return err( - new BinaryTargetError(path, "app has an unsafe CFBundleExecutable"), - ); - const programs = join(path, "Contents", "MacOS"); - const executable = join(programs, name); - try { - const [canonicalPrograms, canonicalExecutable] = await Promise.all([ - realpath(programs), - realpath(executable), - ]); - if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) - return err( - new BinaryTargetError(path, "app program file leaves Contents/MacOS"), - ); - return ok(canonicalExecutable); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app program file is missing", { cause }), - ); - } + return resolveAppBundleExecutable(path); }; -/** Decode the top-level executable name with an XML parser, not a pattern. */ -const parseXmlPlistExecutable = (plist: string): string => { - // An unrelated `__proto__` entry must not make the bundle unreadable. - const { value } = parseXmlPropertyList(plist); - const executable = executableEntrySchema.safeParse(value); - if (!executable.success) throw new Error("CFBundleExecutable is missing"); - return executable.data.CFBundleExecutable; -}; - -const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); - -const readBinaryPlistExecutable = async (plistPath: string): Promise => - // `-n` strips only the newline plutil appends; it requires macOS 12+. - ( - await execFileAsync("/usr/bin/plutil", [ - "-extract", - "CFBundleExecutable", - "raw", - "-n", - "-o", - "-", - plistPath, - ]) - ).stdout; - -const isSafeExecutableName = (name: string): boolean => - name.length > 0 && - name !== "." && - name !== ".." && - !name.includes("\0") && - !/[/\\]/u.test(name); - const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 78d0b2bb7..584b511f7 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -713,6 +713,14 @@ const requireRegularPlist = async ( } }; +const isFile = async (path: string): Promise => { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +}; + const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, @@ -723,10 +731,17 @@ const resolvePlistPath = async ( return ok(resolve(requested)); const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); + if (requested !== undefined) return ok(resolve(root, requested)); + const macOS = resolve(root, "Contents/Info.plist"); + // iOS-style bundles, including the one inside an iOS-on-Mac wrapper, keep + // Info.plist beside the program file the target resolved to. + const beside = resolve(dirname(target.path), "Info.plist"); return ok( - requested === undefined - ? resolve(root, "Contents/Info.plist") - : resolve(root, requested), + sourceMetadata.isDirectory() && + !(await isFile(macOS)) && + (await isFile(beside)) + ? beside + : macOS, ); } catch (cause: unknown) { return err( diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 81f9126df..7096fcdce 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -293,6 +293,62 @@ describe("app executable filename fidelity", () => { ); }); +describe("iOS-style app bundle targets", () => { + const plist = (name: string) => + `CFBundleExecutable${name}`; + + it("resolves a flat bundle's program file beside its Info.plist", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Flat.app"); + await mkdir(app); + await writeFile(join(app, "Info.plist"), plist("Flat")); + await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "Flat")), + format: "mach-o", + }); + }); + + it.skipIf(process.platform === "win32")( + "resolves an iOS-on-Mac wrapper through its single Wrapper bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Phone.app"); + const wrapped = join(app, "Wrapper", "Phone.app"); + await mkdir(wrapped, { recursive: true }); + await writeFile(join(wrapped, "Info.plist"), plist("Phone")); + await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c)); + await symlink("Wrapper/Phone.app", join(app, "WrappedBundle")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(wrapped, "Phone")), + format: "mach-o", + }); + + await mkdir(join(app, "Wrapper", "Second.app")); + expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a flat bundle program symlink that leaves the bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Escaping.app"); + const outside = join(directory, "outside"); + await mkdir(app); + await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile(join(app, "Info.plist"), plist("Escaping")); + await symlink(outside, join(app, "Escaping")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an escaping program file"); + expect(result.error.message).toContain("leaves the bundle root"); + }, + ); +}); + describe("DOS binary target I/O", () => { it("validates a complete DOS file beyond the initial metadata probe", async () => { const directory = await createTestTempDirectory("rea-dos-target-"); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 53027135a..ed86df0e0 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -278,6 +278,26 @@ describe("native macOS provider inspection", () => { }); }); +describe("native plist defaults for iOS-style bundles", () => { + it("defaults to the Info.plist beside a flat bundle's program file", async () => { + directory = await createTestTempDirectory("rea-native-flat-"); + const app = join(directory, "Flat.app"); + const executable = join(app, "Flat"); + await mkdir(app, { recursive: true }); + await writeFile(executable, "fixture"); + await writeFile(join(app, "Info.plist"), "fixture"); + const client = new NativeMacOSProvider( + new FixtureRunner(), + "darwin", + ).createClient(machoTarget(executable, app)); + + const plist = await client.execute("inspect_plist", {}); + expect(plist.ok && plist.value.result).toMatchObject({ + source_path: join(app, "Info.plist"), + }); + }); +}); + describe("native dispatch metadata error results", () => { it("preserves tagged cancellation and integrity failures", async () => { directory = await createTestTempDirectory("rea-dispatch-errors-"); From 418a5562f6f640503c751924a4787586c2c5c98a Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:10:29 +0000 Subject: [PATCH 2/2] fix(native): keep the resolved bundle plist and permission failures - Carry the Info.plist that declared the program file on the target as bundleInfoPlist, so inspect_plist defaults to it even when the program file is a symlink into another bundle directory. - Accept a readable Info.plist symlink, as reading the plist directly did. - Treat only a missing path as an absent layout or wrapper; a permission denial now reaches the resolver's permission-denied classification. Co-Authored-By: Claude Opus 5.5 --- src/application/AppBundleExecutable.ts | 56 +++++++++++------ src/application/BinaryTargetResolver.ts | 14 +++-- src/domain/binaryTargetTypes.ts | 2 + src/native/NativeMacOSProvider.ts | 22 +------ .../boundary/filesystem/binaryTarget.test.ts | 62 ++++++++++++++++++- .../providers/native/nativeProvider.test.ts | 7 ++- 6 files changed, 118 insertions(+), 45 deletions(-) diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 36e4452f6..f3e8b30c3 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -30,6 +30,12 @@ const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ { plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" }, ]; +/** A resolved bundle program file and the Info.plist that declared it. */ +export interface ResolvedAppBundle { + readonly executable: string; + readonly infoPlist?: string; +} + /** * Resolve an app bundle directory to its declared program file. macOS and * flat iOS-style layouts are read directly. An iOS app installed on a Mac is @@ -38,7 +44,7 @@ const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ */ export const resolveAppBundleExecutable = async ( path: string, -): Promise> => { +): Promise> => { const layout = await presentLayout(path); if (layout !== undefined) return resolveLayoutExecutable(path, layout); const wrapped = await wrappedBundle(path); @@ -60,40 +66,52 @@ const presentLayout = async ( bundle: string, ): Promise => { for (const layout of BUNDLE_LAYOUTS) - if (await isRegularFile(join(bundle, ...layout.plist))) return layout; + if (await layoutPlistPresent(join(bundle, ...layout.plist))) return layout; return undefined; }; -const isRegularFile = async (path: string): Promise => { +/** + * Whether a layout's Info.plist exists. Readable symlinks count, as they did + * when the plist was read directly; any failure other than absence, such as + * a permission denial, is left for the caller to report. + */ +const layoutPlistPresent = async (path: string): Promise => { try { - return (await lstat(path)).isFile(); - } catch { - return false; + return (await stat(path)).isFile(); + } catch (cause: unknown) { + if (isAbsence(cause)) return false; + throw cause; } }; +const isAbsence = (cause: unknown): boolean => { + const code: unknown = + cause instanceof Error ? Reflect.get(cause, "code") : undefined; + return code === "ENOENT" || code === "ENOTDIR"; +}; + /** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */ const wrappedBundle = async (bundle: string): Promise => { const wrapper = join(bundle, "Wrapper"); try { if (!(await lstat(wrapper)).isDirectory()) return undefined; - const apps = (await readdir(wrapper, { withFileTypes: true })).filter( - (entry) => - entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), - ); - const [app] = apps; - return apps.length === 1 && app !== undefined - ? join(wrapper, app.name) - : undefined; - } catch { - return undefined; + } catch (cause: unknown) { + if (isAbsence(cause)) return undefined; + throw cause; } + const apps = (await readdir(wrapper, { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), + ); + const [app] = apps; + return apps.length === 1 && app !== undefined + ? join(wrapper, app.name) + : undefined; }; const resolveLayoutExecutable = async ( bundle: string, layout: BundleLayout, -): Promise> => { +): Promise> => { const plistPath = join(bundle, ...layout.plist); let name: string; try { @@ -127,7 +145,7 @@ const resolveLayoutExecutable = async ( `app program file leaves ${layout.programsLabel}`, ), ); - return ok(canonicalExecutable); + return ok({ executable: canonicalExecutable, infoPlist: plistPath }); } catch (cause: unknown) { return err( new BinaryTargetError(bundle, "app program file is missing", { cause }), diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 564a867af..938633e9b 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -12,7 +12,10 @@ import { extname, isAbsolute, resolve } from "node:path"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; -import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +import { + resolveAppBundleExecutable, + type ResolvedAppBundle, +} from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -47,7 +50,9 @@ export const parseBinaryTarget = async ( const canonical = await realpath(candidate); const resolved = await resolveAppBundle(canonical); if (!resolved.ok) return err(resolved.error); - const path = resolved.value; + const { executable: path, infoPlist } = resolved.value; + const bundle = + infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; const handle = await open(path, "r"); try { if (!(await handle.stat()).isFile()) @@ -99,6 +104,7 @@ export const parseBinaryTarget = async ( return ok({ path, sourcePath: process.platform === "win32" ? candidate : canonical, + ...bundle, sha256: await sha256Handle(handle), kind: "executable", ...detected.value, @@ -208,9 +214,9 @@ const sha256Handle = async (handle: FileHandle): Promise => { const resolveAppBundle = async ( path: string, -): Promise> => { +): Promise> => { const metadata = await stat(path); - if (!metadata.isDirectory()) return ok(path); + if (!metadata.isDirectory()) return ok({ executable: path }); if (extname(path).toLowerCase() !== ".app") return err( new BinaryTargetError( diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 9083b13c1..edb914706 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -4,6 +4,8 @@ export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; + /** Info.plist of the app bundle the target was resolved from. */ + readonly bundleInfoPlist?: string; readonly sha256: string; } diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 584b511f7..0d832d5e7 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -713,14 +713,6 @@ const requireRegularPlist = async ( } }; -const isFile = async (path: string): Promise => { - try { - return (await stat(path)).isFile(); - } catch { - return false; - } -}; - const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, @@ -732,17 +724,9 @@ const resolvePlistPath = async ( const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); if (requested !== undefined) return ok(resolve(root, requested)); - const macOS = resolve(root, "Contents/Info.plist"); - // iOS-style bundles, including the one inside an iOS-on-Mac wrapper, keep - // Info.plist beside the program file the target resolved to. - const beside = resolve(dirname(target.path), "Info.plist"); - return ok( - sourceMetadata.isDirectory() && - !(await isFile(macOS)) && - (await isFile(beside)) - ? beside - : macOS, - ); + // A bundle target records the Info.plist its program file was declared + // in: Contents/Info.plist, or the root plist of an iOS-style bundle. + return ok(target.bundleInfoPlist ?? resolve(root, "Contents/Info.plist")); } catch (cause: unknown) { return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 7096fcdce..979904547 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -306,10 +306,70 @@ describe("iOS-style app bundle targets", () => { const result = await parseBinaryTarget(app, directory, "arm64"); expect(result.ok && result.value).toMatchObject({ path: await realpath(join(app, "Flat")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), format: "mach-o", }); }); + it.skipIf(process.platform === "win32")( + "keeps the declaring Info.plist for a symlinked program file", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + await mkdir(join(app, "bin"), { recursive: true }); + await writeFile(join(app, "Info.plist"), plist("Linked")); + await writeFile( + join(app, "bin", "real"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await symlink("bin/real", join(app, "Linked")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "bin", "real")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "follows a readable Contents/Info.plist symlink", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + const contents = join(app, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile(join(contents, "Real.plist"), plist("Linked")); + await symlink("Real.plist", join(contents, "Info.plist")); + await writeFile( + join(contents, "MacOS", "Linked"), + thinMach(0xfeedfacf, 0x0100000c), + ); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(contents, "MacOS", "Linked")), + }); + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports a wrapper it cannot read as a permission denial", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Locked.app"); + const wrapper = join(app, "Wrapper"); + await mkdir(join(wrapper, "Locked.app"), { recursive: true }); + await chmod(wrapper, 0o000); + try { + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a permission denial"); + expect(result.error.message).toContain("permission denied"); + } finally { + await chmod(wrapper, 0o755); + } + }, + ); + it.skipIf(process.platform === "win32")( "resolves an iOS-on-Mac wrapper through its single Wrapper bundle", async () => { diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index ed86df0e0..0fb8276cc 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -279,7 +279,7 @@ describe("native macOS provider inspection", () => { }); describe("native plist defaults for iOS-style bundles", () => { - it("defaults to the Info.plist beside a flat bundle's program file", async () => { + it("defaults to the Info.plist the bundle program was resolved from", async () => { directory = await createTestTempDirectory("rea-native-flat-"); const app = join(directory, "Flat.app"); const executable = join(app, "Flat"); @@ -289,7 +289,10 @@ describe("native plist defaults for iOS-style bundles", () => { const client = new NativeMacOSProvider( new FixtureRunner(), "darwin", - ).createClient(machoTarget(executable, app)); + ).createClient({ + ...machoTarget(executable, app), + bundleInfoPlist: join(app, "Info.plist"), + }); const plist = await client.execute("inspect_plist", {}); expect(plist.ok && plist.value.result).toMatchObject({