blob: 63012d01953f4011558f8d2b8348b81d93e5525d [file]
import { execFile } from 'node:child_process';
import { stat, realpath } from 'node:fs/promises';
import { dirname, isAbsolute, normalize, resolve } from 'node:path';
const OTOOL_EXECUTABLE = '/usr/bin/otool';
const DEFAULT_TIMEOUT_MS = 5_000;
const DEFAULT_MAX_IMAGES = 64;
const DEFAULT_MAX_DEPTH = 8;
const MAX_OTOOL_OUTPUT_BYTES = 1024 * 1024;
export type MacosExecutableDependencyFailureReason =
| 'inspection_failed'
| 'dependency_unresolved'
| 'dependency_limit_exceeded';
export type MacosExecutableDependencyResolution =
| {
ok: true;
runtimeReadableRoots: readonly string[];
executableRoots: readonly string[];
dependencyCount: number;
}
| {
ok: false;
reason: MacosExecutableDependencyFailureReason;
message: string;
};
export interface MacosOtoolRequest {
readonly imagePath: string;
readonly timeoutMs: number;
}
export type MacosOtoolRunner = (request: MacosOtoolRequest) => Promise<string>;
export interface ResolveMacosExecutableDependenciesOptions {
runOtool?: MacosOtoolRunner;
resolveFile?: (path: string) => Promise<string | undefined>;
now?: () => number;
timeoutMs?: number;
maxImages?: number;
maxDepth?: number;
}
interface PendingImage {
readonly path: string;
readonly depth: number;
readonly inheritedRunpaths: readonly string[];
}
export async function resolveMacosExecutableDependencies(
executable: string,
options: ResolveMacosExecutableDependenciesOptions = {},
): Promise<MacosExecutableDependencyResolution> {
const resolveFile = options.resolveFile ?? resolveExistingFile;
const rootExecutable = await resolveFile(executable);
if (!rootExecutable) {
return failure('inspection_failed', 'The executable is unavailable for dependency inspection.');
}
const now = options.now ?? Date.now;
const deadline = now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const maxImages = options.maxImages ?? DEFAULT_MAX_IMAGES;
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const runOtool = options.runOtool ?? runSystemOtool;
const executableDirectory = dirname(rootExecutable);
const runtimeReadableRoots = new Set<string>();
const executableRoots = new Set<string>([dirname(executable), executableDirectory]);
const visited = new Set<string>();
const queue: PendingImage[] = [
{
path: rootExecutable,
depth: 0,
inheritedRunpaths: [],
},
];
let dependencyCount = 0;
try {
while (queue.length > 0) {
const image = queue.shift();
if (!image || visited.has(image.path)) continue;
if (visited.size >= maxImages || image.depth > maxDepth) {
throw new DependencyResolutionError(
'dependency_limit_exceeded',
'Mach-O dependency inspection exceeded its bounded graph limits.',
);
}
visited.add(image.path);
const timeoutMs = remainingTimeout(deadline, now);
const loadCommandOutput = await runOtool({ imagePath: image.path, timeoutMs });
remainingTimeout(deadline, now);
const loadCommands = parseMachOLoadCommands(loadCommandOutput);
const ownRunpaths = resolveRunpaths(
loadCommands.runpaths,
dirname(image.path),
executableDirectory,
image.inheritedRunpaths,
);
const runpaths = unique([...ownRunpaths, ...image.inheritedRunpaths]);
for (const installName of loadCommands.dependencies) {
if (isSystemRuntimePath(installName)) continue;
const candidates = expandInstallName(
installName,
dirname(image.path),
executableDirectory,
runpaths,
);
const dependency = await resolveFirstDependency(candidates, resolveFile);
if (!dependency) {
throw new DependencyResolutionError(
'dependency_unresolved',
'A Mach-O dependency could not be resolved to an existing file.',
);
}
if (dependency.system || dependency.realPath === image.path) continue;
dependencyCount += 1;
addRuntimeDirectory(runtimeReadableRoots, executableRoots, dirname(dependency.lexicalPath));
addRuntimeDirectory(runtimeReadableRoots, executableRoots, dirname(dependency.realPath));
queue.push({
path: dependency.realPath,
depth: image.depth + 1,
inheritedRunpaths: runpaths,
});
}
}
} catch (error) {
if (error instanceof DependencyResolutionError) {
return failure(error.reason, error.message);
}
return failure('inspection_failed', 'Mach-O dependency inspection failed.');
}
return {
ok: true,
runtimeReadableRoots: [...runtimeReadableRoots],
executableRoots: [...executableRoots],
dependencyCount,
};
}
// `otool -L` mixes LC_ID_DYLIB into dependency output; only load commands belong here.
const MACHO_DYLIB_LOAD_COMMANDS = new Set([
'LC_LOAD_DYLIB',
'LC_LOAD_WEAK_DYLIB',
'LC_REEXPORT_DYLIB',
'LC_LAZY_LOAD_DYLIB',
'LC_LOAD_UPWARD_DYLIB',
]);
interface ParsedMachOLoadCommands {
readonly dependencies: readonly string[];
readonly runpaths: readonly string[];
}
function parseMachOLoadCommands(output: string): ParsedMachOLoadCommands {
const dependencies: string[] = [];
const runpaths: string[] = [];
let command: string | undefined;
for (const line of output.split(/\r?\n/)) {
const value = line.trim();
if (value.startsWith('cmd ')) {
command = value.slice('cmd '.length);
continue;
}
if (command === 'LC_RPATH') {
const match = /^path\s+(.+?)\s+\(offset\s+\d+\)$/.exec(value);
if (match?.[1]) {
runpaths.push(match[1]);
command = undefined;
}
continue;
}
if (command && MACHO_DYLIB_LOAD_COMMANDS.has(command)) {
const match = /^name\s+(.+?)\s+\(offset\s+\d+\)$/.exec(value);
if (match?.[1]) {
dependencies.push(match[1]);
command = undefined;
}
}
}
return {
dependencies: unique(dependencies),
runpaths: unique(runpaths),
};
}
function resolveRunpaths(
runpaths: readonly string[],
loaderDirectory: string,
executableDirectory: string,
inheritedRunpaths: readonly string[],
): readonly string[] {
return unique(
runpaths.flatMap((runpath) =>
expandPathExpression(runpath, loaderDirectory, executableDirectory, inheritedRunpaths),
),
);
}
function expandInstallName(
installName: string,
loaderDirectory: string,
executableDirectory: string,
runpaths: readonly string[],
): readonly string[] {
return expandPathExpression(installName, loaderDirectory, executableDirectory, runpaths);
}
function expandPathExpression(
value: string,
loaderDirectory: string,
executableDirectory: string,
runpaths: readonly string[],
): readonly string[] {
if (isAbsolute(value)) return [normalize(value)];
const loaderSuffix = tokenSuffix(value, '@loader_path');
if (loaderSuffix !== undefined) return [resolve(loaderDirectory, loaderSuffix)];
const executableSuffix = tokenSuffix(value, '@executable_path');
if (executableSuffix !== undefined) return [resolve(executableDirectory, executableSuffix)];
const rpathSuffix = tokenSuffix(value, '@rpath');
if (rpathSuffix !== undefined) {
return runpaths.map((runpath) => resolve(runpath, rpathSuffix));
}
return [];
}
function tokenSuffix(value: string, token: string): string | undefined {
if (value === token) return '';
return value.startsWith(`${token}/`) ? value.slice(token.length + 1) : undefined;
}
async function resolveFirstDependency(
candidates: readonly string[],
resolveFile: (path: string) => Promise<string | undefined>,
): Promise<
| {
lexicalPath: string;
realPath: string;
system: boolean;
}
| undefined
> {
for (const candidate of candidates) {
if (isSystemRuntimePath(candidate)) {
return { lexicalPath: candidate, realPath: candidate, system: true };
}
const realPath = await resolveFile(candidate);
if (realPath) return { lexicalPath: candidate, realPath, system: false };
}
return undefined;
}
function isSystemRuntimePath(path: string): boolean {
return (
path === '/System' ||
path.startsWith('/System/') ||
path === '/usr/lib' ||
path.startsWith('/usr/lib/') ||
path === '/Library/Apple' ||
path.startsWith('/Library/Apple/')
);
}
function addRuntimeDirectory(
runtimeReadableRoots: Set<string>,
executableRoots: Set<string>,
path: string,
): void {
if (!isAbsolute(path) || isSystemRuntimePath(path)) return;
runtimeReadableRoots.add(normalize(path));
executableRoots.add(normalize(path));
}
function remainingTimeout(deadline: number, now: () => number): number {
const remaining = deadline - now();
if (remaining <= 0) {
throw new DependencyResolutionError(
'dependency_limit_exceeded',
'Mach-O dependency inspection exceeded its time limit.',
);
}
return remaining;
}
async function resolveExistingFile(path: string): Promise<string | undefined> {
try {
const metadata = await stat(path);
if (!metadata.isFile()) return undefined;
return await realpath(path);
} catch {
return undefined;
}
}
async function runSystemOtool(request: MacosOtoolRequest): Promise<string> {
return await new Promise<string>((resolvePromise, reject) => {
execFile(
OTOOL_EXECUTABLE,
['-l', request.imagePath],
{
encoding: 'utf8',
timeout: request.timeoutMs,
killSignal: 'SIGKILL',
maxBuffer: MAX_OTOOL_OUTPUT_BYTES,
},
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolvePromise(stdout);
},
);
});
}
class DependencyResolutionError extends Error {
constructor(
readonly reason: MacosExecutableDependencyFailureReason,
message: string,
) {
super(message);
this.name = 'DependencyResolutionError';
}
}
function failure(
reason: MacosExecutableDependencyFailureReason,
message: string,
): MacosExecutableDependencyResolution {
return { ok: false, reason, message };
}
function unique(values: readonly string[]): readonly string[] {
return [...new Set(values)];
}