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-");