Every native command rejected an iOS app installed on an Apple silicon Mac, such as /Applications/Stash.app, with "app has no readable CFBundleExecutable": the resolver understood only the macOS layout, Contents/Info.plist with Contents/MacOS/<executable>. iOS-style bundles, including those extracted from an IPA, keep Info.plist and the program file at the bundle root, and an iOS-on-Mac wrapper holds that bundle in Wrapper/<Name>.app. Bundle resolution moves to AppBundleExecutable and accepts the macOS layout, then the flat layout, then a wrapper's single real Wrapper/*.app directory. The WrappedBundle symlink is never followed, and the program file must stay inside its bundle. inspect_plist defaults to the Info.plist beside the program file when the bundle has no Contents/Info.plist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
open,
|
|
realpath,
|
|
stat,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
|
|
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { resolveAppBundleExecutable } from "./AppBundleExecutable.js";
|
|
import {
|
|
validateDosComLength,
|
|
type ExecutableFormatHint,
|
|
} from "../domain/dosCom.js";
|
|
import { mzWindowsHeaderOffset } from "../domain/dosMz.js";
|
|
import {
|
|
hasZipSignature,
|
|
zipPackageFormatForPath,
|
|
} from "../domain/zipPackageFormat.js";
|
|
|
|
import {
|
|
parseExecutableHeader,
|
|
parsePeRecord,
|
|
type ExecutableMetadata,
|
|
} from "../domain/binaryTarget.js";
|
|
|
|
/**
|
|
* Resolve and classify a readable local target before a provider is selected.
|
|
* FAT Mach-O inputs select only a host-compatible architecture so setup remains
|
|
* non-interactive; unsupported or ambiguous inputs are returned as typed errors.
|
|
*/
|
|
export const parseBinaryTarget = async (
|
|
input: string,
|
|
cwd = process.cwd(),
|
|
hostArchitecture: NodeJS.Architecture = process.arch,
|
|
targetKind?: BinaryTarget["kind"],
|
|
formatHint?: ExecutableFormatHint,
|
|
): Promise<Result<BinaryTarget, BinaryTargetError>> => {
|
|
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
|
|
try {
|
|
await access(candidate, constants.R_OK);
|
|
const canonical = await realpath(candidate);
|
|
const resolved = await resolveAppBundle(canonical);
|
|
if (!resolved.ok) return err(resolved.error);
|
|
const path = resolved.value;
|
|
const handle = await open(path, "r");
|
|
try {
|
|
if (!(await handle.stat()).isFile())
|
|
return err(new BinaryTargetError(path, "target is not a regular file"));
|
|
if (formatHint === "dos-com") {
|
|
if (targetKind !== undefined && targetKind !== "executable")
|
|
return err(
|
|
new BinaryTargetError(
|
|
path,
|
|
"DOS COM format requires an executable target kind",
|
|
),
|
|
);
|
|
const length = validateDosComLength((await handle.stat()).size);
|
|
if (!length.ok) return err(new BinaryTargetError(path, length.error));
|
|
return ok({
|
|
path,
|
|
sourcePath: canonical,
|
|
sha256: await sha256Handle(handle),
|
|
kind: "executable",
|
|
format: "dos-com",
|
|
architecture: "x86",
|
|
availableArchitectures: ["x86"],
|
|
});
|
|
}
|
|
if (
|
|
targetKind === "database" ||
|
|
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
|
)
|
|
return ok({
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
sha256: await sha256Handle(handle),
|
|
kind: "database",
|
|
format: "analysis-database",
|
|
});
|
|
const artifactFormat = await detectArtifactFormat(path, handle);
|
|
if (artifactFormat !== undefined) {
|
|
const identity = {
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
sha256: await sha256Handle(handle),
|
|
};
|
|
return isArchiveFormat(artifactFormat)
|
|
? ok({ ...identity, kind: "archive", format: artifactFormat })
|
|
: ok({ ...identity, kind: "artifact", format: artifactFormat });
|
|
}
|
|
const detected = await readExecutableMetadata(handle, hostArchitecture);
|
|
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
|
|
return ok({
|
|
path,
|
|
sourcePath: process.platform === "win32" ? candidate : canonical,
|
|
sha256: await sha256Handle(handle),
|
|
kind: "executable",
|
|
...detected.value,
|
|
});
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof Error)) throw cause;
|
|
const code = filesystemErrorCode(cause);
|
|
if (code === undefined) throw cause;
|
|
return err(
|
|
new BinaryTargetError(
|
|
candidate,
|
|
targetFailureReason(code, cause.message),
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const filesystemErrorCode = (cause: Error): string | undefined => {
|
|
const code: unknown = Reflect.get(cause, "code");
|
|
return typeof code === "string" ? code : undefined;
|
|
};
|
|
|
|
const targetFailureReason = (code: string, detail: string): string => {
|
|
switch (code) {
|
|
case "ENOENT":
|
|
case "ENOTDIR":
|
|
return `target does not exist or a path component is not a directory: ${detail}`;
|
|
case "EACCES":
|
|
case "EPERM":
|
|
return `permission denied while reading target: ${detail}`;
|
|
case "EISDIR":
|
|
return `target is a directory, not a readable file: ${detail}`;
|
|
default:
|
|
return `target could not be read (${code}): ${detail}`;
|
|
}
|
|
};
|
|
|
|
const detectArtifactFormat = async (
|
|
path: string,
|
|
handle: FileHandle,
|
|
): Promise<
|
|
| Exclude<
|
|
BinaryTarget["format"],
|
|
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
|
|
>
|
|
| undefined
|
|
> => {
|
|
const lower = path.toLowerCase();
|
|
const magic = Buffer.alloc(8);
|
|
const observed = await handle.read(magic, 0, magic.length, 0);
|
|
if (hasZipSignature(magic.subarray(0, observed.bytesRead))) {
|
|
return zipPackageFormatForPath(lower) ?? "zip";
|
|
}
|
|
const named = namedArtifactFormat(lower);
|
|
if (named !== undefined) return named;
|
|
if (
|
|
lower.endsWith(".pkg") &&
|
|
observed.bytesRead >= 4 &&
|
|
magic.subarray(0, 4).toString("ascii") === "xar!"
|
|
)
|
|
return "pkg";
|
|
if (lower.endsWith(".dmg")) {
|
|
const size = (await handle.stat()).size;
|
|
if (size >= 512) {
|
|
const trailer = Buffer.alloc(4);
|
|
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
|
if (read.bytesRead === 4 && trailer.toString("ascii") === "koly")
|
|
return "dmg";
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const namedArtifactFormat = (
|
|
lowerPath: string,
|
|
): "asar" | "plist" | "source-map" | "javascript" | undefined => {
|
|
if (lowerPath.endsWith(".asar")) return "asar";
|
|
if (lowerPath.endsWith(".plist")) return "plist";
|
|
if (lowerPath.endsWith(".map")) return "source-map";
|
|
return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined;
|
|
};
|
|
|
|
const isArchiveFormat = (
|
|
format: Exclude<
|
|
BinaryTarget["format"],
|
|
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
|
|
>,
|
|
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
|
|
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);
|
|
|
|
const sha256Handle = async (handle: FileHandle): Promise<string> => {
|
|
const hash = createHash("sha256");
|
|
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
let position = 0;
|
|
while (true) {
|
|
const observed = await handle.read(chunk, 0, chunk.length, position);
|
|
if (observed.bytesRead === 0) break;
|
|
hash.update(chunk.subarray(0, observed.bytesRead));
|
|
position += observed.bytesRead;
|
|
}
|
|
return hash.digest("hex");
|
|
};
|
|
|
|
const resolveAppBundle = async (
|
|
path: string,
|
|
): Promise<Result<string, BinaryTargetError>> => {
|
|
const metadata = await stat(path);
|
|
if (!metadata.isDirectory()) return ok(path);
|
|
if (extname(path).toLowerCase() !== ".app")
|
|
return err(
|
|
new BinaryTargetError(
|
|
path,
|
|
"this opening route requires a file or app bundle",
|
|
{
|
|
constraint: "directory_requires_file",
|
|
},
|
|
),
|
|
);
|
|
return resolveAppBundleExecutable(path);
|
|
};
|
|
|
|
const readExecutableMetadata = async (
|
|
handle: FileHandle,
|
|
hostArchitecture: NodeJS.Architecture,
|
|
): Promise<Result<ExecutableMetadata, string>> => {
|
|
const prefix = Buffer.alloc(4096);
|
|
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
|
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
|
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
|
|
const offset = mzWindowsHeaderOffset(bytes);
|
|
if (offset !== null) {
|
|
if (offset < 64)
|
|
return err("invalid Windows new-header offset in MZ image");
|
|
return readPeMetadata(handle, offset);
|
|
}
|
|
// MZ relocation records may extend past the initial probe; their format
|
|
// bounds are at most 65535 records inside a 65535-paragraph header.
|
|
const tableEnd =
|
|
bytes.length >= 28
|
|
? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4
|
|
: 0;
|
|
const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0;
|
|
const fileSize = (await handle.stat()).size;
|
|
if (
|
|
tableEnd > bytes.length &&
|
|
tableEnd <= headerBytes &&
|
|
tableEnd <= fileSize
|
|
) {
|
|
const table = Buffer.alloc(tableEnd);
|
|
const observed = await handle.read(table, 0, table.length, 0);
|
|
return parseExecutableHeader(
|
|
table.subarray(0, observed.bytesRead),
|
|
hostArchitecture,
|
|
fileSize,
|
|
);
|
|
}
|
|
return parseExecutableHeader(bytes, hostArchitecture, fileSize);
|
|
}
|
|
if (bytes.length >= 8) {
|
|
const magic = bytes.readUInt32BE(0);
|
|
if ([0xcafebabf, 0xbfbafeca].includes(magic)) {
|
|
const little = magic === 0xbfbafeca;
|
|
const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4);
|
|
const required = 8 + count * 32;
|
|
if (count <= 128 && required > bytes.length) {
|
|
const header = Buffer.alloc(required);
|
|
const headerRead = await handle.read(header, 0, header.length, 0);
|
|
return parseExecutableHeader(
|
|
header.subarray(0, headerRead.bytesRead),
|
|
hostArchitecture,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return parseExecutableHeader(bytes, hostArchitecture);
|
|
};
|
|
|
|
const readPeMetadata = async (
|
|
handle: FileHandle,
|
|
offset: number,
|
|
): Promise<Result<ExecutableMetadata, string>> => {
|
|
const fileHeader = Buffer.alloc(24);
|
|
const fileHeaderRead = await handle.read(
|
|
fileHeader,
|
|
0,
|
|
fileHeader.length,
|
|
offset,
|
|
);
|
|
if (fileHeaderRead.bytesRead !== fileHeader.length)
|
|
return err("invalid or truncated PE header");
|
|
const signature = fileHeader.toString("ascii", 0, 2);
|
|
if (["NE", "LE", "LX"].includes(signature))
|
|
return err(`unsupported ${signature} executable in MZ image`);
|
|
const optionalHeaderSize = fileHeader.readUInt16LE(20);
|
|
if (optionalHeaderSize > 4096) return err("invalid PE optional header size");
|
|
const record = Buffer.alloc(24 + optionalHeaderSize);
|
|
const recordRead = await handle.read(record, 0, record.length, offset);
|
|
return recordRead.bytesRead === record.length
|
|
? parsePeRecord(record)
|
|
: err("invalid or truncated PE header");
|
|
};
|