diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts new file mode 100644 index 000000000..f3e8b30c3 --- /dev/null +++ b/src/application/AppBundleExecutable.ts @@ -0,0 +1,186 @@ +import { execFile } from "node:child_process"; +import { lstat, readdir, readFile, realpath, stat } 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" }, +]; + +/** 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 + * 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 layoutPlistPresent(join(bundle, ...layout.plist))) return layout; + return undefined; +}; + +/** + * 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 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; + } 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> => { + 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({ executable: canonicalExecutable, infoPlist: plistPath }); + } 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..938633e9b 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -3,22 +3,19 @@ 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, + type ResolvedAppBundle, +} from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -35,8 +32,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 @@ -55,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()) @@ -107,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, @@ -216,89 +214,22 @@ 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( 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/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 7d365151d..dd7c54802 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -736,11 +736,10 @@ const resolvePlistPath = async ( return ok(resolve(requested)); const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); - return ok( - requested === undefined - ? resolve(root, "Contents/Info.plist") - : resolve(root, requested), - ); + if (requested !== undefined) return ok(resolve(root, requested)); + // 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 81f9126df..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"; @@ -293,6 +293,122 @@ 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")), + 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 () => { + 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 fd265d048..68eeee7b3 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -278,6 +278,29 @@ describe("native macOS provider inspection", () => { }); }); +describe("native plist defaults for iOS-style bundles", () => { + 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"); + 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), + bundleInfoPlist: join(app, "Info.plist"), + }); + + const plist = await client.execute("inspect_plist", {}); + expect(plist.ok && plist.value.result).toMatchObject({ + source_path: join(app, "Info.plist"), + }); + }); +}); + /** Emit entitlements whose dictionary also holds a legal `__proto__` key. */ class PrototypeEntitlementsRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) {