Files
rea/src/application/AppBundleExecutable.ts
T
morluto 6ba6aea5eb fix(parsers): respect container facts and real decoding boundaries
Use zip.js's parsed symlink fact for inventory and open refusal. Treat an
unmatched Godot trailer as an unconfirmed candidate while preserving failures
for confirmed malformed packs. Report entropy path-resolution failures at the
selected-input boundary.

Separate Go header discovery from inline string extents in ELF and Mach-O,
and apply the aggregate content allowance after validating real source ranges.
Normalize binary plist UID widths through the existing scalar-copy owner,
budget exact clone growth, preserve source identity, and refuse inexact UIDs.

Validation: 389 distinct tests passed, one Linux /proc test skipped on Darwin;
build, typecheck/lint, docs, single-thread formatting, and discovery (2142 files)
passed. CLI/MCP Go fixtures and public ZIP/Godot/keyed-archive workflows ran.
Real producer prevalence and external provider execution were not established.

Repeated ordinary keyed-archive CLI inventory facts and estimated reply tokens
were equal before/after. Warm median elapsed was 0.5166s / 0.5177s; no speed claim.

Fixes #6751
Fixes #6763
Fixes #6754
Fixes #6767
Fixes #6762
Fixes #6779
2026-10-23 23:02:07 +08:00

355 lines
12 KiB
TypeScript

import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
import { join } from "node:path";
import { parseBinary } from "plist";
import { z } from "zod";
import { preflightPropertyListDecode } from "../artifacts/apple/PropertyListDecodeBudget.js";
import { decodeBinaryPlistUids } from "../artifacts/apple/PlistScalars.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { isPathWithinRoot } from "../domain/localPath.js";
import {
omitPrototypeKeys,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js";
import { err, ok, type Result } from "../domain/result.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Decoded representation bound for one binary Info.plist. */
const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024;
/** Where one app bundle layout keeps its Info.plist and program file. */
interface BundleLayout {
readonly plist: readonly string[];
readonly programs: readonly string[];
readonly programsLabel: string;
}
const BUNDLE_LAYOUTS: readonly BundleLayout[] = [
// macOS bundles keep both under Contents.
{
plist: ["Contents", "Info.plist"],
programs: ["Contents", "MacOS"],
programsLabel: "Contents/MacOS",
},
// iOS-style bundles, as extracted from an IPA, keep both at the root.
{ plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" },
];
/** A resolved bundle program file and the Info.plist that declared it. */
export interface ResolvedAppBundle {
readonly executable: string;
readonly infoPlist?: string;
}
/** Resource-only identity for an app directory; it is not a BinaryTarget. */
export interface ResolvedAppBundleResourceRoot {
/** Path selected by the caller, retained even when it is a symlink alias. */
readonly callerPath: string;
/** Canonical directory whose resources are inspected. */
readonly canonicalPath: string;
readonly infoPlistPath: string;
readonly layout: "contents" | "root";
/** No executable was opened or hashed by resource-root discovery. */
readonly executableSha256: string | null;
}
/** Filesystem operations used by the resolver, injectable for boundary tests. */
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
readonly realpath: (path: string) => Promise<string>;
readonly decodeBinaryPlist?: (path: string) => Promise<string>;
}
const defaultFileSystem: AppBundleFileSystem = { readFile, realpath };
/**
* 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,
fileSystem: AppBundleFileSystem = defaultFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
try {
const layout = await presentLayout(path);
if (layout !== undefined)
return resolveLayoutExecutable(path, layout, fileSystem);
const wrapped = await wrappedBundle(path);
if (wrapped !== undefined) {
const wrappedLayout = await presentLayout(wrapped);
if (wrappedLayout !== undefined)
return resolveLayoutExecutable(wrapped, wrappedLayout, fileSystem);
}
} catch (cause: unknown) {
if (cause instanceof BinaryTargetError) return err(cause);
return err(
new BinaryTargetError(path, errorReason(cause, path), { cause }),
);
}
return err(
new BinaryTargetError(
path,
`app Info.plist is missing or no supported bundle layout was found: ${path}`,
{
cause: new Error(
"No Contents/Info.plist, root Info.plist, or single Wrapper/*.app bundle was found",
),
},
),
);
};
/**
* Resolve only the root and Info.plist layout needed for read-only resource
* inspection. This intentionally does not require or guess CFBundleExecutable.
*/
export const resolveAppBundleResourceRoot = async (
path: string,
options: { readonly cwd?: string } = {},
): Promise<Result<ResolvedAppBundleResourceRoot, BinaryTargetError>> => {
const callerPath = absoluteCallerPath(
path,
options.cwd === undefined ? {} : { cwd: options.cwd },
);
try {
const canonicalInput = await realpath(callerPath);
const directLayout = await presentLayout(canonicalInput);
const wrapped =
directLayout === undefined
? await wrappedBundle(canonicalInput)
: undefined;
const root = wrapped ?? canonicalInput;
const layout = directLayout ?? (await presentLayout(root));
if (layout === undefined)
return err(
new BinaryTargetError(
callerPath,
`app resource root has no supported Info.plist layout: ${callerPath}`,
),
);
const canonicalPath = await realpath(root);
return ok({
callerPath,
canonicalPath,
infoPlistPath: join(canonicalPath, ...layout.plist),
layout: layout.plist[0] === "Contents" ? "contents" : "root",
executableSha256: null,
});
} catch (cause: unknown) {
return err(
new BinaryTargetError(callerPath, errorReason(cause, callerPath), {
cause,
}),
);
}
};
const presentLayout = async (
bundle: string,
): Promise<BundleLayout | undefined> => {
for (const layout of BUNDLE_LAYOUTS)
if (await layoutPlistPresent(join(bundle, ...layout.plist))) return layout;
return undefined;
};
/**
* Whether a layout's Info.plist exists. Readable symlinks count, as they did
* when the plist was read directly; any failure other than absence, such as
* a permission denial, is left for the caller to report.
*/
const layoutPlistPresent = async (path: string): Promise<boolean> => {
try {
return (await stat(path)).isFile();
} catch (cause: unknown) {
if (isAbsence(cause)) return false;
if (isPermissionDenied(cause))
throw new BinaryTargetError(
path,
`permission denied inspecting app Info.plist ${path}`,
{ cause },
);
throw cause;
}
};
const isAbsence = (cause: unknown): boolean => {
const code = errorCode(cause);
return code === "ENOENT" || code === "ENOTDIR";
};
const errorCode = (cause: unknown): unknown =>
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
const codeDescription = (cause: unknown): string => {
const code = errorCode(cause);
return typeof code === "string" || typeof code === "number"
? ` (${String(code)})`
: "";
};
const isSystemErrorCode = (cause: unknown): boolean => {
const code = errorCode(cause);
return (
typeof code === "string" &&
/^[A-Z][A-Z0-9_]+$/u.test(code) &&
!code.startsWith("ERR_")
);
};
const isPermissionDenied = (cause: unknown): boolean =>
errorCode(cause) === "EACCES" || errorCode(cause) === "EPERM";
const errorReason = (cause: unknown, path: string): string =>
isPermissionDenied(cause)
? `permission denied accessing app bundle path ${path}`
: isAbsence(cause)
? `app bundle path is missing: ${path}`
: `could not inspect app bundle path ${path}`;
/** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */
const wrappedBundle = async (bundle: string): Promise<string | undefined> => {
const wrapper = join(bundle, "Wrapper");
try {
if (!(await lstat(wrapper)).isDirectory()) return undefined;
} catch (cause: unknown) {
if (isAbsence(cause)) return undefined;
throw cause;
}
let app: string | undefined;
for await (const entry of await opendir(wrapper)) {
if (!entry.isDirectory() || !entry.name.toLowerCase().endsWith(".app"))
continue;
if (app !== undefined) return undefined;
app = join(wrapper, entry.name);
}
return app;
};
const resolveLayoutExecutable = async (
bundle: string,
layout: BundleLayout,
fileSystem: AppBundleFileSystem,
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
const plistPath = join(bundle, ...layout.plist);
let plist: Buffer;
try {
plist = await fileSystem.readFile(plistPath);
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied reading app Info.plist ${plistPath}`
: isAbsence(cause)
? `app Info.plist is missing: ${plistPath}`
: `could not read app Info.plist ${plistPath}${codeDescription(cause)}`,
{ cause },
),
);
}
let name: string;
try {
name = hasBinaryPlistSignature(plist)
? await (fileSystem.decodeBinaryPlist?.(plistPath) ??
parseBinaryPlistExecutable(plist))
: parseXmlPlistExecutable(decodeXmlPlistText(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
plistPath,
isPermissionDenied(cause)
? `permission denied decoding app 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 },
),
);
}
if (!isSafeExecutableName(name))
return err(
new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"),
);
const programs = join(bundle, ...layout.programs);
const executable = join(programs, name);
let canonicalBundle: string;
try {
canonicalBundle = await fileSystem.realpath(bundle);
} catch (cause: unknown) {
return err(
new BinaryTargetError(bundle, errorReason(cause, bundle), { cause }),
);
}
try {
const canonicalPrograms = await fileSystem.realpath(programs);
if (!isPathWithinRoot(canonicalBundle, canonicalPrograms))
return err(
new BinaryTargetError(
bundle,
`app program directory leaves ${layout.programsLabel}`,
),
);
const canonicalExecutable = await fileSystem.realpath(executable);
if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable))
return err(
new BinaryTargetError(
bundle,
`app program file leaves ${layout.programsLabel}`,
),
);
return ok({ executable: canonicalExecutable, infoPlist: plistPath });
} catch (cause: unknown) {
return err(
new BinaryTargetError(
executable,
isPermissionDenied(cause)
? `permission denied resolving app program file ${executable}`
: isAbsence(cause)
? `app program file is missing: ${executable}`
: `could not resolve app program file ${executable}`,
{ 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);
return executableName(value);
};
const executableName = (value: unknown): string => {
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() });
/** Decode a binary plist in-process, so no host plist tool is required. */
const parseBinaryPlistExecutable = (plist: Buffer): string => {
const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
// `plist` assigns a `__proto__` key as the prototype; copying drops it, as
// the XML path does.
return executableName(
omitPrototypeKeys(
parseBinary(decodeBinaryPlistUids(plist, plan.uidOffsetsToNormalize)),
).value,
);
};
const isSafeExecutableName = (name: string): boolean =>
name.length > 0 &&
name !== "." &&
name !== ".." &&
!name.includes("\0") &&
!/[/\\]/u.test(name);