Merge pull request #910 from morluto/feat/ios-app-bundles

feat(native): open iOS-style and iOS-on-Mac wrapper app bundles
This commit is contained in:
Kaoru0822 2026-10-07 03:59:41 -07:00 committed by GitHub
commit 2ed89e6529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 345 additions and 88 deletions

View File

@ -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<Result<ResolvedAppBundle, BinaryTargetError>> => {
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<BundleLayout | undefined> => {
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<boolean> => {
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<string | undefined> => {
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<Result<ResolvedAppBundle, BinaryTargetError>> => {
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<string> =>
// `-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);

View File

@ -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<string> => {
const resolveAppBundle = async (
path: string,
): Promise<Result<string, BinaryTargetError>> => {
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
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<string> =>
// `-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,

View File

@ -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;
}

View File

@ -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 }),

View File

@ -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) =>
`<plist><dict><key>CFBundleExecutable</key><string>${name}</string></dict></plist>`;
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-");

View File

@ -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[]) {