diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 81f3106648..046c46aaf0 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/docs/mcp-prompts.md b/docs/mcp-prompts.md index ef630404bf..aa2811d0cf 100644 --- a/docs/mcp-prompts.md +++ b/docs/mcp-prompts.md @@ -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 `. -`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 diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 42a18c594f..58761256a1 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -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> => { + 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 => { + 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> => { + 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 => { } }; +/** Whether a directory entry, including a dangling link, exists. */ +const entryPresent = async (path: string): Promise => { + 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> => { 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; }; diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 3a5d299613..2c78b22227 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -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> => { 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 ( diff --git a/src/contracts/promptContracts.ts b/src/contracts/promptContracts.ts index 0a5a851772..4251a49f0b 100644 --- a/src/contracts/promptContracts.ts +++ b/src/contracts/promptContracts.ts @@ -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"], diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 5015503487..9467cf3fa4 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -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 `. 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 \`. 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 \`) 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 `." - : 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 \`.` + : 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 ?? diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 576cd7de92..21f9c3256b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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" }), diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index e5e10f6332..f89126c80c 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -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(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); diff --git a/src/domain/apple/appleBundleTypes.ts b/src/domain/apple/appleBundleTypes.ts new file mode 100644 index 0000000000..3738579283 --- /dev/null +++ b/src/domain/apple/appleBundleTypes.ts @@ -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(", "); diff --git a/src/domain/apple/bundleResourceRoot.ts b/src/domain/apple/bundleResourceRoot.ts index 35cb5eeaee..8d085c6ef7 100644 --- a/src/domain/apple/bundleResourceRoot.ts +++ b/src/domain/apple/bundleResourceRoot.ts @@ -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) diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index a2bbc25153..b0b955a776 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -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); } } diff --git a/tests/boundary/filesystem/appleBundleTargets.test.ts b/tests/boundary/filesystem/appleBundleTargets.test.ts new file mode 100644 index 0000000000..0be42e15f5 --- /dev/null +++ b/tests/boundary/filesystem/appleBundleTargets.test.ts @@ -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) => + `CFBundleIdentifiercom.example.fixture${ + executable === undefined + ? "" + : `CFBundleExecutable${executable}` + }`; + +/** A bundle with Contents/Info.plist and Contents/MacOS/. */ +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 => { + 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"); + }); +});