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/<executable>. 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/<Name>.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 <noreply@anthropic.com>
This commit is contained in:
parent
eab34a9d8c
commit
aeebba391e
|
|
@ -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<Result<string, 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 isRegularFile(join(bundle, ...layout.plist))) return layout;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isRegularFile = async (path: string): Promise<boolean> => {
|
||||
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<string | undefined> => {
|
||||
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<Result<string, 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(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<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);
|
||||
|
|
@ -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<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,
|
||||
|
|
|
|||
|
|
@ -713,6 +713,14 @@ const requireRegularPlist = async (
|
|||
}
|
||||
};
|
||||
|
||||
const isFile = async (path: string): Promise<boolean> => {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -293,6 +293,62 @@ 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")),
|
||||
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-");
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
Loading…
Reference in New Issue