feat(native): open extension, framework, XPC and plug-in bundle directories (#8117)

* feat(native): open extension, framework, XPC and plug-in bundle directories

open_binary and the CLI inspection commands admitted only .app
directories and pointed every other Apple bundle at the JavaScript
analyzer. Resolve .appex, .framework, .xpc, .systemextension, .bundle
and .plugin directories through their Info.plist CFBundleExecutable, in
the Contents or flat root layout, plus the versioned framework layout
under Versions/Current. Exactly one layout may declare the bundle and
every resolved path must stay inside it.

Unresolvable bundles fail closed with typed constraints, probed paths
and the Info.plist that was read. Other Apple bundle types are refused as
unsupported instead of being offered the JavaScript route. Resource-root
discovery shares the same layout rule, so inspect_plist reads a
versioned framework's Info.plist. The recognized bundle vocabulary now
has one owner in the domain.

Refs #4310

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): refuse kernel extensions and state bundle error fields

Name the Apple bundle types binary routes recognize but do not open
(.dext, .kext, .qlgenerator, .mdimporter) explicitly, so a kernel
extension is refused as unsupported instead of receiving the JavaScript
route. Document which bundle refusals carry probed_paths and info_plist,
and make success tests throw the typed admission error before asserting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(native): open flattened frameworks through Resources/Info.plist

Electron and Gecko apps ship frameworks without Versions/: the program
file sits at the framework root beside Resources/Info.plist, the layout
#8101 taught bundle anatomy. Read that layout for a framework without
Versions/, and keep the versioned layout for one with it, so a versioned
framework's root Resources link is never read as a second declaration.
The resource root reports it as flattened-framework.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-30 02:27:52 -07:00
1 parent a47dcca010
commit 37342ecde0
12 files changed
+742 -42

No files matched your search

+31
View File
@@ -761,6 +761,37 @@ concrete version, selection source, and complete analysis profile through
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
`open_binary` resolves an Apple bundle directory (`.app`, `.appex`,
`.framework`, `.xpc`, `.systemextension`, `.bundle` or `.plugin`, compared
case-insensitively) to the program file its `Info.plist` names in
`CFBundleExecutable`; the Evidence subject is that program file. It reads the
macOS `Contents/` layout or the flat root layout. A framework with `Versions/`
may instead keep `Info.plist` in `Versions/Current/Resources/` with its program
file in `Versions/Current/`; a flattened framework without `Versions/`, as
Electron and Gecko apps ship it, keeps `Resources/Info.plist` beside its
program file. An app keeps its iOS-on-Mac `Wrapper` resolution. For the
other types, exactly one layout may declare the bundle, and its `Info.plist`,
program directory and program file must stay inside the bundle. Resolution
never guesses a program file. Each refusal reports one `details.constraint`:
- `bundle_info_plist_missing`, with `probed_paths` listing every layout's
`Info.plist`.
- `bundle_layout_ambiguous`, with `probed_paths` listing the `Info.plist`
files found.
- `bundle_current_version_missing`, with `probed_paths` naming
`Versions/Current`.
- `bundle_path_escapes`, with `probed_paths` naming the path that resolved
outside the bundle.
- `bundle_executable_undeclared`, with `info_plist`.
- `bundle_program_missing`, with `info_plist` and `probed_paths` naming the
declared program file.
An unreadable or malformed `Info.plist` keeps its reason without a bundle
constraint, and `details.path` names that file. A `.dext`, `.kext`,
`.qlgenerator` or `.mdimporter` directory reports `bundle_type_unsupported`. A
resource root reports its `layout` as `contents`, `root`,
`versioned-framework` or `flattened-framework`.
`open_bundle_resources` selects a separate read-only app resource session
without resolving `CFBundleExecutable`, including when it is absent or
unresolvable. `binary_session` reports `session_kind: "bundle_resources"` and the
+5 -3
View File
@@ -71,9 +71,11 @@ directly to `analyze_javascript_application` with `input_path`; it does not
require `open_binary`. When that Evidence, or `inspect_binary_layout` Evidence,
is already retained, it suggests `inspect_analysis_view` before tracing a
feature seed. The CLI equivalent is `rea analyze <directory>`.
`open_binary` and the doctor target check admit files and macOS app bundles.
A directory outside that opening route is reported with an available analysis
action; this does not establish support for every directory format.
`open_binary` and the doctor target check admit files and Apple bundle
directories (`.app`, `.appex`, `.framework`, `.xpc`, `.systemextension`,
`.bundle` and `.plugin`). Another Apple bundle type is refused as unsupported,
and another directory is reported with an available analysis action; this does
not establish support for every directory format.
For target paths in `compare_application_versions`, the prompt suggests
opening each target before calling `inspect_artifact`; inspection operates on
+233 -19
View File
@@ -1,5 +1,5 @@
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
import { join, sep } from "node:path";
import { extname, join, sep } from "node:path";
import { z } from "zod";
@@ -10,6 +10,8 @@ import { isPathWithinRoot } from "../domain/localPath.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js";
import type { BundleResourceRoot } from "../domain/apple/bundleResourceRoot.js";
import { openableAppleBundleExtension } from "../domain/apple/appleBundleTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -18,6 +20,7 @@ const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024;
/** Where one app bundle layout keeps its Info.plist and program file. */
interface BundleLayout {
readonly name: BundleResourceRoot["layout"];
readonly plist: readonly string[];
readonly programs: readonly string[];
readonly programsLabel: string;
@@ -26,12 +29,44 @@ interface BundleLayout {
const BUNDLE_LAYOUTS: readonly BundleLayout[] = [
// macOS bundles keep both under Contents.
{
name: "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" },
{
name: "root",
plist: ["Info.plist"],
programs: [],
programsLabel: "the bundle root",
},
];
// A macOS framework keeps the selected version's resources and program file
// under Versions/Current; its root holds only symbolic links into it.
const VERSIONED_FRAMEWORK_LAYOUTS: readonly BundleLayout[] = [
...BUNDLE_LAYOUTS,
{
name: "versioned-framework",
plist: ["Versions", "Current", "Resources", "Info.plist"],
programs: ["Versions", "Current"],
programsLabel: "Versions/Current",
},
];
// A flattened framework, as Electron and Gecko apps ship it, is one version
// directory without the Versions wrapper: Resources/Info.plist beside the
// program file. Its Resources is a real directory, not the versioned layout's
// root link into Versions/Current, so the two layouts are never both read.
const FLATTENED_FRAMEWORK_LAYOUTS: readonly BundleLayout[] = [
...BUNDLE_LAYOUTS,
{
name: "flattened-framework",
plist: ["Resources", "Info.plist"],
programs: [],
programsLabel: "the bundle root",
},
];
/** A resolved bundle program file and the Info.plist that declared it. */
@@ -47,7 +82,7 @@ export interface ResolvedAppBundleResourceRoot {
/** Canonical directory whose resources are inspected. */
readonly canonicalPath: string;
readonly infoPlistPath: string;
readonly layout: "contents" | "root";
readonly layout: BundleResourceRoot["layout"];
/** No executable was opened or hashed by resource-root discovery. */
readonly executableSha256: string | null;
}
@@ -104,6 +139,114 @@ export const resolveAppBundleExecutable = async (
);
};
/**
* Resolve an extension, framework, XPC service, system extension or loadable
* bundle directory to its declared program file. An app keeps its own
* resolution, including the iOS-on-Mac Wrapper. Other types read the macOS
* Contents layout or the flat root layout, and frameworks also read the
* versioned layout. Exactly one layout may declare the bundle, and its
* Info.plist must stay inside the bundle; the program file is never guessed.
*/
export const resolveAppleBundleExecutable = async (
path: string,
fileSystem: AppBundleFileSystem = defaultFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
if (extname(path).toLowerCase() === ".app")
return resolveAppBundleExecutable(path, fileSystem);
try {
const layout = await declaredBundleLayout(path, fileSystem);
if (!layout.ok) return layout;
if (layout.value === undefined)
return err(
new BinaryTargetError(
path,
`bundle Info.plist was not found in any supported layout: ${path}`,
{
constraint: "bundle_info_plist_missing",
probedPaths: (await bundleLayouts(path)).map((candidate) =>
join(path, ...candidate.plist),
),
},
),
);
return await resolveLayoutExecutable(path, layout.value, fileSystem);
} catch (cause: unknown) {
if (cause instanceof BinaryTargetError) return err(cause);
return err(
new BinaryTargetError(path, errorReason(cause, path), { cause }),
);
}
};
const bundleLayouts = async (
bundle: string,
): Promise<readonly BundleLayout[]> => {
if (extname(bundle).toLowerCase() !== ".framework") return BUNDLE_LAYOUTS;
return (await entryPresent(join(bundle, "Versions")))
? VERSIONED_FRAMEWORK_LAYOUTS
: FLATTENED_FRAMEWORK_LAYOUTS;
};
/**
* The one layout whose Info.plist declares a bundle other than an app, or
* undefined when none does. A framework with Versions but no Versions/Current,
* Info.plist files in more than one layout, or an Info.plist that leaves the
* bundle fail closed instead of selecting a layout.
*/
const declaredBundleLayout = async (
path: string,
fileSystem: AppBundleFileSystem,
): Promise<Result<BundleLayout | undefined, BinaryTargetError>> => {
const layouts = await bundleLayouts(path);
if (layouts === VERSIONED_FRAMEWORK_LAYOUTS) {
const current = join(path, "Versions", "Current");
if (!(await entryPresent(current)))
return err(
new BinaryTargetError(
path,
`framework has Versions but no Versions/Current: ${current}`,
{
constraint: "bundle_current_version_missing",
probedPaths: [current],
},
),
);
}
const present: BundleLayout[] = [];
for (const layout of layouts)
if (await layoutPlistPresent(join(path, ...layout.plist)))
present.push(layout);
const [layout, ...others] = present;
if (layout === undefined) return ok(undefined);
if (others.length > 0)
return err(
new BinaryTargetError(
path,
`bundle declares Info.plist in more than one layout: ${path}`,
{
constraint: "bundle_layout_ambiguous",
probedPaths: present.map((candidate) =>
join(path, ...candidate.plist),
),
},
),
);
const plistPath = join(path, ...layout.plist);
if (
!isPathWithinRoot(
await fileSystem.realpath(path),
await fileSystem.realpath(plistPath),
)
)
return err(
new BinaryTargetError(path, "bundle Info.plist leaves the bundle", {
constraint: "bundle_path_escapes",
probedPaths: [plistPath],
}),
);
return ok(layout);
};
/**
* Resolve only the root and Info.plist layout needed for read-only resource
* inspection. This intentionally does not require or guess CFBundleExecutable.
@@ -123,6 +266,39 @@ export const findAppBundleResourceRoot = async (
// into a different, otherwise valid bundle resource root.
await stat(callerPath);
const canonicalInput = await realpath(callerPath);
const extension = extname(canonicalInput).toLowerCase();
if (
extension !== ".app" &&
openableAppleBundleExtension(extension) !== undefined
) {
const declared = await declaredBundleLayout(
canonicalInput,
defaultFileSystem,
);
if (!declared.ok) return declared;
// An admitted bundle type without Info.plist is a native admission
// failure, not an application directory for other routes.
if (declared.value === undefined)
return err(
new BinaryTargetError(
callerPath,
`bundle Info.plist was not found in any supported layout: ${callerPath}`,
{
constraint: "bundle_info_plist_missing",
probedPaths: (await bundleLayouts(canonicalInput)).map((layout) =>
join(canonicalInput, ...layout.plist),
),
},
),
);
return ok({
callerPath,
canonicalPath: canonicalInput,
infoPlistPath: join(canonicalInput, ...declared.value.plist),
layout: declared.value.name,
executableSha256: null,
});
}
const directLayout = await presentLayout(canonicalInput);
const wrapped =
directLayout === undefined
@@ -136,7 +312,7 @@ export const findAppBundleResourceRoot = async (
callerPath,
canonicalPath,
infoPlistPath: join(canonicalPath, ...layout.plist),
layout: layout.plist[0] === "Contents" ? "contents" : "root",
layout: layout.name,
executableSha256: null,
});
} catch (cause: unknown) {
@@ -194,6 +370,17 @@ const layoutPlistPresent = async (path: string): Promise<boolean> => {
}
};
/** Whether a directory entry, including a dangling link, exists. */
const entryPresent = async (path: string): Promise<boolean> => {
try {
await lstat(path);
return true;
} catch (cause: unknown) {
if (isAbsence(cause)) return false;
throw cause;
}
};
const isAbsence = (cause: unknown): boolean => {
const code = errorCode(cause);
return code === "ENOENT" || code === "ENOTDIR";
@@ -253,6 +440,8 @@ const resolveLayoutExecutable = async (
fileSystem: AppBundleFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
const plistPath = join(bundle, ...layout.plist);
// Reasons for an app keep their established wording.
const noun = extname(bundle).toLowerCase() === ".app" ? "app" : "bundle";
let plist: Buffer;
try {
plist = await fileSystem.readFile(plistPath);
@@ -261,10 +450,10 @@ const resolveLayoutExecutable = async (
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied reading app Info.plist ${plistPath}`
? `permission denied reading ${noun} Info.plist ${plistPath}`
: isAbsence(cause)
? `app Info.plist is missing: ${plistPath}`
: `could not read app Info.plist ${plistPath}${codeDescription(cause)}`,
? `${noun} Info.plist is missing: ${plistPath}`
: `could not read ${noun} Info.plist ${plistPath}${codeDescription(cause)}`,
{ cause },
),
);
@@ -281,17 +470,27 @@ const resolveLayoutExecutable = async (
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied decoding app Info.plist ${plistPath}${codeDescription(cause)}`
? `permission denied decoding ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: isSystemErrorCode(cause)
? `could not decode app Info.plist ${plistPath}${codeDescription(cause)}`
: `app Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{ cause },
? `could not decode ${noun} Info.plist ${plistPath}${codeDescription(cause)}`
: `${noun} Info.plist is malformed, unsupported, or lacks CFBundleExecutable: ${plistPath}${cause instanceof Error ? ` (${cause.message})` : ""}`,
{
cause,
// A bundle without code, such as a resource bundle, declares no
// program file; the open does not guess one from its contents.
...(cause instanceof UndeclaredBundleExecutable
? {
constraint: "bundle_executable_undeclared" as const,
infoPlist: plistPath,
}
: {}),
},
),
);
}
if (!isSafeExecutableName(name))
return err(
new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"),
new BinaryTargetError(bundle, `${noun} has an unsafe CFBundleExecutable`),
);
const programs = join(bundle, ...layout.programs);
const executable = join(programs, name);
@@ -309,7 +508,8 @@ const resolveLayoutExecutable = async (
return err(
new BinaryTargetError(
bundle,
`app program directory leaves ${layout.programsLabel}`,
`${noun} program directory leaves ${layout.programsLabel}`,
{ constraint: "bundle_path_escapes", probedPaths: [programs] },
),
);
const canonicalExecutable = await fileSystem.realpath(executable);
@@ -317,7 +517,8 @@ const resolveLayoutExecutable = async (
return err(
new BinaryTargetError(
bundle,
`app program file leaves ${layout.programsLabel}`,
`${noun} program file leaves ${layout.programsLabel}`,
{ constraint: "bundle_path_escapes", probedPaths: [executable] },
),
);
return ok({ executable: canonicalExecutable, infoPlist: plistPath });
@@ -326,11 +527,20 @@ const resolveLayoutExecutable = async (
new BinaryTargetError(
executable,
isPermissionDenied(cause)
? `permission denied resolving app program file ${executable}`
? `permission denied resolving ${noun} program file ${executable}`
: isAbsence(cause)
? `app program file is missing: ${executable}`
: `could not resolve app program file ${executable}`,
{ cause },
? `${noun} program file is missing: ${executable}`
: `could not resolve ${noun} program file ${executable}`,
{
cause,
...(isAbsence(cause)
? {
constraint: "bundle_program_missing" as const,
infoPlist: plistPath,
probedPaths: [executable],
}
: {}),
},
),
);
}
@@ -343,9 +553,13 @@ const parseXmlPlistExecutable = (plist: string): string => {
return executableName(value);
};
/** The Info.plist decoded but declares no string CFBundleExecutable. */
class UndeclaredBundleExecutable extends Error {}
const executableName = (value: unknown): string => {
const executable = executableEntrySchema.safeParse(value);
if (!executable.success) throw new Error("CFBundleExecutable is missing");
if (!executable.success)
throw new UndeclaredBundleExecutable("CFBundleExecutable is missing");
return executable.data.CFBundleExecutable;
};
+22 -7
View File
@@ -47,9 +47,14 @@ import { err, ok, type Result } from "../domain/result.js";
import { readMipsElfMetadata } from "./MipsElfMetadata.js";
import { validateElfDeclaredRanges } from "../domain/elfDeclaredRanges.js";
import {
resolveAppBundleExecutable,
resolveAppleBundleExecutable,
type ResolvedAppBundle,
} from "./AppBundleExecutable.js";
import {
OPENABLE_APPLE_BUNDLE_TYPES_TEXT,
isUnopenedAppleBundleExtension,
openableAppleBundleExtension,
} from "../domain/apple/appleBundleTypes.js";
import {
validateDosComLength,
type ExecutableFormatHint,
@@ -853,17 +858,27 @@ const resolveAppBundle = async (
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
const metadata = await stat(path);
if (!metadata.isDirectory()) return ok({ executable: path });
if (extname(path).toLowerCase() !== ".app")
if (openableAppleBundleExtension(extname(path)) !== undefined)
return resolveAppleBundleExecutable(path);
// Another Apple bundle type, such as a driver or kernel extension, is not
// an application directory and has no JavaScript route.
if (isUnopenedAppleBundleExtension(extname(path)))
return err(
new BinaryTargetError(
path,
"this opening route requires a file or app bundle",
{
constraint: "directory_requires_file",
},
`${extname(path)} bundle directories are not opened by this route; it opens ${OPENABLE_APPLE_BUNDLE_TYPES_TEXT} bundle directories`,
{ constraint: "bundle_type_unsupported" },
),
);
return resolveAppBundleExecutable(path);
return err(
new BinaryTargetError(
path,
"this opening route requires a file or a supported Apple bundle directory",
{
constraint: "directory_requires_file",
},
),
);
};
const readExecutableMetadata = async (
+1 -1
View File
@@ -169,7 +169,7 @@ export const PROMPT_CONTRACTS = [
{
tools: ["open_binary", "analyze_javascript_application"],
instruction:
"For a JavaScript/Electron application directory, call analyze_javascript_application with input_path=target_path directly; no open_binary call is needed. For a file or macOS app bundle, open target_path only when the required target is not active.",
"For a JavaScript/Electron application directory, call analyze_javascript_application with input_path=target_path directly; no open_binary call is needed. For a file or Apple bundle directory, open target_path only when the required target is not active.",
},
{
tools: ["current_document"],
+28 -7
View File
@@ -10,6 +10,7 @@ import {
AnalysisTimeoutError,
} from "./analysisErrorCore.js";
import { ArtifactOperationError } from "./artifactOperationError.js";
import { OPENABLE_APPLE_BUNDLE_TYPES_TEXT } from "./apple/appleBundleTypes.js";
import {
BinaryTargetError,
ConfigurationError,
@@ -143,7 +144,27 @@ export const analysisErrorRemediationAction = (
error instanceof BinaryTargetError &&
error.constraint === "directory_requires_file"
)
return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze <directory>`. For binary analysis, select its executable file.";
return `For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run \`rea analyze <directory>\`. For binary analysis, select its executable file or an Apple bundle directory (${OPENABLE_APPLE_BUNDLE_TYPES_TEXT}).`;
if (
error instanceof BinaryTargetError &&
error.constraint === "bundle_type_unsupported"
)
return `Select the bundle's program file directly, or open a bundle directory of a supported type (${OPENABLE_APPLE_BUNDLE_TYPES_TEXT}).`;
if (
error instanceof BinaryTargetError &&
(error.constraint === "bundle_executable_undeclared" ||
error.constraint === "bundle_program_missing") &&
error.infoPlist !== undefined
)
return `Read ${error.infoPlist} with inspect_plist (\`rea inspect-plist <path>\`) to review its CFBundleExecutable, then select an existing program file directly.`;
if (
error instanceof BinaryTargetError &&
(error.constraint === "bundle_info_plist_missing" ||
error.constraint === "bundle_layout_ambiguous" ||
error.constraint === "bundle_current_version_missing" ||
error.constraint === "bundle_path_escapes")
)
return "Review the bundle paths listed in details, then select the bundle's program file directly or repair the bundle layout.";
if (
error instanceof BinaryTargetError &&
error.constraint === "load_selection_rejected"
@@ -239,7 +260,8 @@ export const analysisErrorCategory = (
if (
error instanceof BinaryTargetError &&
(error.constraint === "wasm_requires_static_inspection" ||
error.constraint === "file_format_rejected")
error.constraint === "file_format_rejected" ||
error.constraint === "bundle_type_unsupported")
)
return "unsupported_target";
if (
@@ -396,11 +418,10 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
return `${error.message}. Static WebAssembly inspection uses a separate optional WABT lane, not a native analysis engine.`;
if (error instanceof BinaryTargetError)
return error.constraint === "directory_requires_file"
? "open_binary accepts files and macOS app bundles. This target is a directory; JavaScript/Electron application directories can be analyzed directly with analyze_javascript_application or `rea analyze <directory>`."
: error.constraint === "file_format_rejected" ||
error.constraint === "load_selection_rejected"
? `${error.message}.`
: `${error.message}. Check that the path exists, is readable, and points to a supported file.`;
? `open_binary accepts files and Apple bundle directories (${OPENABLE_APPLE_BUNDLE_TYPES_TEXT}). This target is another directory; JavaScript/Electron application directories can be analyzed directly with analyze_javascript_application or \`rea analyze <directory>\`.`
: error.constraint === undefined
? `${error.message}. Check that the path exists, is readable, and points to a supported file.`
: `${error.message}.`;
if (error._tag === "ProcessCaptureError")
return (
error.userMessage ??
+4
View File
@@ -639,6 +639,10 @@ const lifecycleErrorDetails = (
...(error.constraint === undefined
? {}
: { constraint: error.constraint }),
...(error.infoPlist === undefined ? {} : { info_plist: error.infoPlist }),
...(error.probedPaths === undefined
? {}
: { probed_paths: [...error.probedPaths] }),
...(error.systemCode === undefined
? {}
: { system_code: error.systemCode, boundary: "filesystem-read" }),
+2 -3
View File
@@ -2,11 +2,10 @@ import { z } from "zod";
import type { ProjectedInventoryEntry } from "../mobileApplicationGraphProjection.js";
import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js";
import { APPLE_BUNDLE_EXTENSION } from "./appleBundleTypes.js";
const pathSchema = z.string().min(1);
const BUNDLE_EXTENSION =
/\.(?:app|appex|xpc|framework|systemextension|dext|bundle|plugin|qlgenerator|mdimporter)$/iu;
const MACH_O_FORMATS: readonly string[] = ["mach-o", "mach-o-universal"];
/** One application root or nested bundle classified by path convention. */
@@ -151,7 +150,7 @@ const detectBundles = (tree: Tree, roots: readonly string[]): Bundle[] => {
const paths = new Set<string>(roots);
for (const path of directoryPaths(tree)) {
if (
BUNDLE_EXTENSION.test(path.split("/").at(-1) ?? "") &&
APPLE_BUNDLE_EXTENSION.test(path.split("/").at(-1) ?? "") &&
roots.some((root) => isWithin(path, root))
)
paths.add(path);
+46
View File
@@ -0,0 +1,46 @@
/** Directory extensions the artifact inventory classifies as Apple bundles. */
export const APPLE_BUNDLE_EXTENSION =
/\.(?:app|appex|xpc|framework|systemextension|dext|bundle|plugin|qlgenerator|mdimporter)$/iu;
/** Bundle directories that binary routes open through their declared program file. */
export const OPENABLE_APPLE_BUNDLE_EXTENSIONS = [
".app",
".appex",
".framework",
".xpc",
".systemextension",
".bundle",
".plugin",
] as const;
export type OpenableAppleBundleExtension =
(typeof OPENABLE_APPLE_BUNDLE_EXTENSIONS)[number];
/** Select an openable bundle type from a directory extension, ignoring case. */
export const openableAppleBundleExtension = (
extension: string,
): OpenableAppleBundleExtension | undefined => {
const lower = extension.toLowerCase();
return OPENABLE_APPLE_BUNDLE_EXTENSIONS.find(
(candidate) => candidate === lower,
);
};
/**
* Apple bundle directories that binary routes recognize but do not open, so
* they are refused as unsupported rather than offered another directory route.
*/
const UNOPENED_APPLE_BUNDLE_EXTENSIONS: readonly string[] = [
".dext",
".kext",
".qlgenerator",
".mdimporter",
];
/** Whether a directory extension names an Apple bundle type that is not opened. */
export const isUnopenedAppleBundleExtension = (extension: string): boolean =>
UNOPENED_APPLE_BUNDLE_EXTENSIONS.includes(extension.toLowerCase());
/** Caller-facing list of the bundle directories binary routes open. */
export const OPENABLE_APPLE_BUNDLE_TYPES_TEXT =
OPENABLE_APPLE_BUNDLE_EXTENSIONS.join(", ");
+6 -1
View File
@@ -5,7 +5,12 @@ export const bundleResourceRootSchema = z.strictObject({
caller_path: z.string().min(1),
canonical_path: z.string().min(1),
info_plist_path: z.string().min(1),
layout: z.enum(["contents", "root"]),
layout: z.enum([
"contents",
"root",
"versioned-framework",
"flattened-framework",
]),
executable_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
+20 -1
View File
@@ -40,19 +40,36 @@ export class NoBinaryOpenError extends AnalysisError {
}
}
/** Apple bundle resolution outcomes that leave no program file to open. */
export type BundleTargetConstraint =
| "bundle_type_unsupported"
| "bundle_info_plist_missing"
| "bundle_layout_ambiguous"
| "bundle_executable_undeclared"
| "bundle_program_missing"
| "bundle_current_version_missing"
| "bundle_path_escapes";
/** Additional constraints identified while admitting a filesystem target. */
export interface BinaryTargetErrorOptions extends AnalysisErrorOptions {
readonly constraint?:
| "directory_requires_file"
| "wasm_requires_static_inspection"
| "file_format_rejected"
| "load_selection_rejected";
| "load_selection_rejected"
| BundleTargetConstraint;
/** Info.plist whose declaration selected, or failed to select, the program file. */
readonly infoPlist?: string;
/** Bundle paths examined before resolution failed. */
readonly probedPaths?: readonly string[];
}
/** A supplied target path could not be safely opened as a supported app or binary. */
export class BinaryTargetError extends AnalysisError {
readonly _tag = "BinaryTargetError";
readonly constraint: BinaryTargetErrorOptions["constraint"];
readonly infoPlist: string | undefined;
readonly probedPaths: readonly string[] | undefined;
/** The OS read-permission code of the cause, when the host denied access. */
readonly systemCode: "EACCES" | "EPERM" | undefined;
constructor(
@@ -62,6 +79,8 @@ export class BinaryTargetError extends AnalysisError {
) {
super(`Cannot open artifact: ${reason}`, options);
this.constraint = options?.constraint;
this.infoPlist = options?.infoPlist;
this.probedPaths = options?.probedPaths;
this.systemCode = readPermissionCode(options?.cause);
}
}
@@ -0,0 +1,344 @@
import { mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findAppBundleResourceRoot } from "../../../src/application/AppBundleExecutable.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const program = () => thinMach(0xfeedfacf, 0x0100000c);
const infoPlist = (executable?: string) =>
`<plist><dict><key>CFBundleIdentifier</key><string>com.example.fixture</string>${
executable === undefined
? ""
: `<key>CFBundleExecutable</key><string>${executable}</string>`
}</dict></plist>`;
/** A bundle with Contents/Info.plist and Contents/MacOS/<name>. */
const contentsBundle = async (
root: string,
name: string,
executable = "Fixture",
): Promise<{ bundle: string; program: string }> => {
const bundle = join(root, name);
const programs = join(bundle, "Contents", "MacOS");
await mkdir(programs, { recursive: true });
await writeFile(
join(bundle, "Contents", "Info.plist"),
infoPlist(executable),
);
await writeFile(join(programs, executable), program());
return { bundle, program: join(programs, executable) };
};
/** The documented macOS framework layout, with its root symbolic links. */
const versionedFramework = async (
root: string,
options: { readonly declared?: string; readonly file?: string } = {},
): Promise<string> => {
const framework = join(root, "Demo.framework");
const version = join(framework, "Versions", "A");
await mkdir(join(version, "Resources"), { recursive: true });
await writeFile(
join(version, "Resources", "Info.plist"),
infoPlist(options.declared ?? "Demo"),
);
await writeFile(join(version, options.file ?? "Demo"), program());
await symlink("A", join(framework, "Versions", "Current"));
await symlink("Versions/Current/Demo", join(framework, "Demo"));
await symlink("Versions/Current/Resources", join(framework, "Resources"));
return framework;
};
const open = (path: string, cwd: string) =>
parseBinaryTarget(path, { cwd, hostArchitecture: "arm64" });
// Throw the typed admission error so a failed open reports its cause.
const opened = async (path: string, cwd: string) => {
const result = await open(path, cwd);
if (!result.ok) throw result.error;
return result.value;
};
const failure = async (path: string, cwd: string) => {
const result = await open(path, cwd);
if (result.ok) throw new Error(`${path} was admitted`);
return projectAnalysisError(result.error);
};
describe("Apple bundle directory admission", () => {
it.each([
"Ext.appex",
"Svc.xpc",
"Plug.bundle",
"Plug.plugin",
"Ext.systemextension",
"Upper.FRAMEWORK",
])("opens %s through Contents/Info.plist", async (name) => {
const root = await createTestTempDirectory("rea-bundle-target-");
const { bundle, program: path } = await contentsBundle(root, name);
expect(await opened(bundle, root)).toMatchObject({
path: await realpath(path),
format: "mach-o",
bundleInfoPlist: join(bundle, "Contents", "Info.plist"),
});
});
it("opens a flat framework through its root Info.plist", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = join(root, "Flat.framework");
await mkdir(framework);
await writeFile(join(framework, "Info.plist"), infoPlist("Flat"));
await writeFile(join(framework, "Flat"), program());
expect(await opened(framework, root)).toMatchObject({
path: await realpath(join(framework, "Flat")),
bundleInfoPlist: join(framework, "Info.plist"),
});
});
it("opens a flattened framework through Resources/Info.plist", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = join(root, "Electron Framework.framework");
await mkdir(join(framework, "Resources"), { recursive: true });
await writeFile(
join(framework, "Resources", "Info.plist"),
infoPlist("Electron Framework"),
);
await writeFile(join(framework, "Electron Framework"), program());
expect(await opened(framework, root)).toMatchObject({
path: await realpath(join(framework, "Electron Framework")),
bundleInfoPlist: join(framework, "Resources", "Info.plist"),
});
expect(await findAppBundleResourceRoot(framework)).toEqual({
ok: true,
value: expect.objectContaining({
layout: "flattened-framework",
infoPlistPath: join(
await realpath(framework),
"Resources",
"Info.plist",
),
}),
});
// A root Info.plist as well leaves two declarations to choose between.
await writeFile(
join(framework, "Info.plist"),
infoPlist("Electron Framework"),
);
expect((await failure(framework, root)).details).toMatchObject({
constraint: "bundle_layout_ambiguous",
});
});
it.skipIf(process.platform === "win32")(
"opens a versioned framework through Versions/Current",
async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = await versionedFramework(root);
expect(await opened(framework, root)).toMatchObject({
path: await realpath(join(framework, "Versions", "A", "Demo")),
bundleInfoPlist: join(
framework,
"Versions",
"Current",
"Resources",
"Info.plist",
),
});
expect(await findAppBundleResourceRoot(framework)).toEqual({
ok: true,
value: expect.objectContaining({
layout: "versioned-framework",
infoPlistPath: join(
await realpath(framework),
"Versions",
"Current",
"Resources",
"Info.plist",
),
}),
});
},
);
it.skipIf(process.platform === "win32")(
"follows CFBundleExecutable when it differs from the framework name",
async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = await versionedFramework(root, {
declared: "Other",
file: "Other",
});
expect(await opened(framework, root)).toMatchObject({
path: await realpath(join(framework, "Versions", "A", "Other")),
});
},
);
});
describe("Apple bundle directories that fail closed", () => {
it("reports every probed layout when no Info.plist is present", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = join(root, "Empty.framework");
await mkdir(framework);
const projected = await failure(framework, root);
const canonical = await realpath(framework);
expect(projected).toMatchObject({
code: "target_unavailable",
details: {
constraint: "bundle_info_plist_missing",
probed_paths: [
join(canonical, "Contents", "Info.plist"),
join(canonical, "Info.plist"),
// Without Versions/, a framework is read as flattened.
join(canonical, "Resources", "Info.plist"),
],
},
});
// A bundle type without Info.plist must reach this typed native failure
// instead of a route for application directories.
expect(await findAppBundleResourceRoot(framework)).toMatchObject({
ok: false,
error: { constraint: "bundle_info_plist_missing" },
});
});
it("refuses a bundle whose Info.plist appears in two layouts", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const { bundle } = await contentsBundle(root, "Twice.appex");
await writeFile(join(bundle, "Info.plist"), infoPlist("Fixture"));
const projected = await failure(bundle, root);
const canonical = await realpath(bundle);
expect(projected.details).toMatchObject({
constraint: "bundle_layout_ambiguous",
probed_paths: [
join(canonical, "Contents", "Info.plist"),
join(canonical, "Info.plist"),
],
});
});
it("does not guess a program file when CFBundleExecutable is absent", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const { bundle } = await contentsBundle(root, "Codeless.bundle");
await writeFile(join(bundle, "Contents", "Info.plist"), infoPlist());
const projected = await failure(bundle, root);
const plist = join(await realpath(bundle), "Contents", "Info.plist");
expect(projected.details).toMatchObject({
constraint: "bundle_executable_undeclared",
info_plist: plist,
});
expect(projected.remediation.action).toContain(plist);
expect(projected.remediation.action).toContain("inspect_plist");
});
it("names the Info.plist when the declared program file is absent", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const bundle = join(root, "Missing.xpc");
await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true });
await writeFile(join(bundle, "Contents", "Info.plist"), infoPlist("Gone"));
const projected = await failure(bundle, root);
const canonical = await realpath(bundle);
expect(projected.details).toMatchObject({
constraint: "bundle_program_missing",
info_plist: join(canonical, "Contents", "Info.plist"),
probed_paths: [join(canonical, "Contents", "MacOS", "Gone")],
});
expect(projected.remediation.action).toContain(
join(canonical, "Contents", "Info.plist"),
);
});
it.skipIf(process.platform === "win32")(
"requires Versions/Current when a framework has Versions",
async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const framework = join(root, "NoCurrent.framework");
await mkdir(join(framework, "Versions", "A", "Resources"), {
recursive: true,
});
const projected = await failure(framework, root);
expect(projected.details).toMatchObject({
constraint: "bundle_current_version_missing",
probed_paths: [join(await realpath(framework), "Versions", "Current")],
});
},
);
it.skipIf(process.platform === "win32")(
"refuses a Versions/Current link that leaves the framework",
async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const outside = join(root, "outside");
await mkdir(join(outside, "Resources"), { recursive: true });
await writeFile(
join(outside, "Resources", "Info.plist"),
infoPlist("Demo"),
);
await writeFile(join(outside, "Demo"), program());
const framework = join(root, "Escaping.framework");
await mkdir(join(framework, "Versions"), { recursive: true });
await symlink(outside, join(framework, "Versions", "Current"));
const projected = await failure(framework, root);
expect(projected.details).toMatchObject({
constraint: "bundle_path_escapes",
});
},
);
it("keeps the file-format refusal for a script program file", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const bundle = join(root, "Script.bundle");
await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true });
await writeFile(join(bundle, "Contents", "Info.plist"), infoPlist("run"));
await writeFile(
join(bundle, "Contents", "MacOS", "run"),
"#!/bin/sh\necho inert\n",
);
expect((await failure(bundle, root)).details).toMatchObject({
constraint: "file_format_rejected",
});
});
});
describe("directories this route does not open", () => {
it.each([".dext", ".kext"])(
"names a %s directory as an unsupported Apple bundle without the JavaScript route",
async (extension) => {
const root = await createTestTempDirectory("rea-bundle-target-");
const driver = join(root, `com.example.Driver${extension}`);
await mkdir(join(driver, "Contents", "MacOS"), { recursive: true });
await writeFile(
join(driver, "Contents", "Info.plist"),
infoPlist("Driver"),
);
await writeFile(join(driver, "Contents", "MacOS", "Driver"), program());
const projected = await failure(driver, root);
expect(projected).toMatchObject({
category: "unsupported_target",
details: { constraint: "bundle_type_unsupported" },
});
expect(projected.message).toContain(extension);
expect(projected.message).toContain(".framework");
expect(JSON.stringify(projected)).not.toContain(
"analyze_javascript_application",
);
},
);
it("lists the accepted bundle types for any other directory", async () => {
const root = await createTestTempDirectory("rea-bundle-target-");
const directory = join(root, "web");
await mkdir(directory);
const projected = await failure(directory, root);
expect(projected.details).toMatchObject({
constraint: "directory_requires_file",
});
expect(projected.message).toContain("analyze_javascript_application");
expect(projected.message).toContain(".appex");
expect(projected.remediation.action).toContain(".framework");
});
});