346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { NativeMacOSProvider } from "../src/native/NativeMacOSProvider.js";
|
|
import {
|
|
NativeCommandFailure,
|
|
XcrunCommandRunner,
|
|
type NativeCommandCapture,
|
|
type NativeCommandRunner,
|
|
} from "../src/native/CommandRunner.js";
|
|
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
|
import { err, ok } from "../src/domain/result.js";
|
|
import { parseLipoArchitectures } from "../src/native/parsers/lipo.js";
|
|
|
|
let directory: string | undefined;
|
|
afterEach(async () => {
|
|
if (directory !== undefined)
|
|
await rm(directory, { recursive: true, force: true });
|
|
directory = undefined;
|
|
});
|
|
|
|
describe("native macOS provider", () => {
|
|
it("retries a failed native tool resolution and caches only success", async () => {
|
|
let resolutions = 0;
|
|
const runner = new XcrunCommandRunner((tool) => {
|
|
resolutions += 1;
|
|
return Promise.resolve(
|
|
resolutions === 1
|
|
? err(new NativeCommandFailure(tool, "unavailable"))
|
|
: ok({ path: "/usr/bin/true", sha256: "a".repeat(64) }),
|
|
);
|
|
});
|
|
const options = { timeoutMs: 1_000, maxOutputBytes: 1_024 };
|
|
|
|
expect((await runner.run("file", [], options)).ok).toBe(false);
|
|
expect((await runner.run("file", [], options)).ok).toBe(true);
|
|
expect((await runner.run("file", [], options)).ok).toBe(true);
|
|
expect(resolutions).toBe(2);
|
|
});
|
|
|
|
it("fails closed when a native command exceeds its output limit", async () => {
|
|
const runner = new XcrunCommandRunner(() =>
|
|
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
|
);
|
|
const captured = await runner.run(
|
|
"file",
|
|
["-e", "process.stdout.write('x'.repeat(4096))"],
|
|
{ timeoutMs: 1_000, maxOutputBytes: 64 },
|
|
);
|
|
|
|
expect(captured).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "output-limit" },
|
|
});
|
|
});
|
|
|
|
it("normalizes comprehensive Mach-O inspection with exact bounded provenance", async () => {
|
|
const runner = new FixtureRunner();
|
|
const client = new NativeMacOSProvider(runner, "darwin").createClient(
|
|
machoTarget("/private/fixture"),
|
|
);
|
|
const execution = await client.execute("inspect_macho", {});
|
|
expect(execution.ok).toBe(true);
|
|
if (!execution.ok) return;
|
|
expect(execution.value.provider.id).toBe("native-macos");
|
|
expect(execution.value.result).toMatchObject({
|
|
format: "mach-o",
|
|
word_size: 64,
|
|
uuid: "01234567-89AB-CDEF-0123-456789ABCDEF",
|
|
architectures: { total: 2, exhaustive: true },
|
|
dependencies: {
|
|
items: [{ path: "/usr/lib/libSystem.B.dylib", kind: "LC_LOAD_DYLIB" }],
|
|
},
|
|
segments: {
|
|
items: [
|
|
{
|
|
name: "__TEXT",
|
|
initial_permissions: { read: true, write: false, execute: true },
|
|
sections: { items: [{ segment: "__TEXT", name: "__text" }] },
|
|
},
|
|
],
|
|
},
|
|
imports: { exhaustive: false },
|
|
exports: { exhaustive: false },
|
|
});
|
|
expect(JSON.stringify(execution.value.result)).not.toContain(
|
|
"/private/fixture",
|
|
);
|
|
expect(execution.value.locations).toContainEqual({
|
|
kind: "file-offset-range",
|
|
start: 16384,
|
|
end: 20480,
|
|
});
|
|
});
|
|
|
|
it("keeps Mach-O inspection available when optional vtool is unavailable", async () => {
|
|
const client = new NativeMacOSProvider(
|
|
new VtoolFailingRunner("unavailable"),
|
|
"darwin",
|
|
).createClient(machoTarget("/private/fixture"));
|
|
|
|
const execution = await client.execute("inspect_macho", {});
|
|
|
|
expect(execution.ok).toBe(true);
|
|
if (!execution.ok) return;
|
|
expect(execution.value.limitations).toEqual(
|
|
expect.arrayContaining([expect.stringMatching(/vtool.*unavailable/iu)]),
|
|
);
|
|
expect(execution.value.result).toMatchObject({
|
|
architectures: { total: 2 },
|
|
provenance: expect.not.arrayContaining([
|
|
expect.objectContaining({ tool: "vtool" }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("propagates optional vtool failures other than unavailability", async () => {
|
|
const client = new NativeMacOSProvider(
|
|
new VtoolFailingRunner("timeout"),
|
|
"darwin",
|
|
).createClient(machoTarget("/private/fixture"));
|
|
|
|
const execution = await client.execute("inspect_macho", {});
|
|
|
|
expect(execution).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisTimeoutError" },
|
|
});
|
|
});
|
|
|
|
it("supports architectures, signatures, plists, and ordered demangling", async () => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-native-"));
|
|
const app = join(directory, "Fixture.app");
|
|
const executable = join(app, "Contents/MacOS/Fixture");
|
|
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
|
|
await writeFile(executable, "fixture");
|
|
await writeFile(join(app, "Contents/Info.plist"), "fixture");
|
|
const client = new NativeMacOSProvider(
|
|
new FixtureRunner(),
|
|
"darwin",
|
|
).createClient(machoTarget(executable, app));
|
|
|
|
const architectures = await client.execute("list_architectures", {});
|
|
expect(architectures.ok && architectures.value.result).toMatchObject({
|
|
architectures: { total: 2 },
|
|
});
|
|
const signature = await client.execute("inspect_signature", {});
|
|
expect(signature.ok && signature.value.result).toMatchObject({
|
|
signed: true,
|
|
identifier: "com.example.fixture",
|
|
hardened_runtime: true,
|
|
entitlements: { "com.apple.security.app-sandbox": true },
|
|
});
|
|
const plist = await client.execute("inspect_plist", {
|
|
relative_path: "Contents/Info.plist",
|
|
});
|
|
expect(plist.ok && plist.value.result).toMatchObject({
|
|
bundle: { identifier: "com.example.fixture", version: "42" },
|
|
source_path: "Contents/Info.plist",
|
|
});
|
|
const demangled = await client.execute("demangle_swift", {
|
|
symbols: ["$s4Test3fooyyF", "plain_symbol"],
|
|
});
|
|
expect(demangled.ok && demangled.value.result).toMatchObject({
|
|
symbols: [
|
|
{ input: "$s4Test3fooyyF", status: "demangled" },
|
|
{ input: "plain_symbol", status: "unchanged" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("classifies unavailable, malformed, timeout, and cancellation", async () => {
|
|
const unavailable = new NativeMacOSProvider(
|
|
new FailingRunner("unavailable"),
|
|
"darwin",
|
|
).createClient(machoTarget("/fixture"));
|
|
const unavailableResult = await unavailable.execute(
|
|
"list_architectures",
|
|
{},
|
|
);
|
|
expect(!unavailableResult.ok && unavailableResult.error._tag).toBe(
|
|
"AnalysisCapabilityUnavailableError",
|
|
);
|
|
|
|
const malformed = new NativeMacOSProvider(
|
|
new FixtureRunner({ lipo: "malformed" }),
|
|
"darwin",
|
|
).createClient(machoTarget("/fixture"));
|
|
const malformedResult = await malformed.execute("list_architectures", {});
|
|
expect(!malformedResult.ok && malformedResult.error._tag).toBe(
|
|
"AnalysisOutputError",
|
|
);
|
|
|
|
for (const [reason, tag] of [
|
|
["timeout", "AnalysisTimeoutError"],
|
|
["cancelled", "AnalysisCancelledError"],
|
|
] as const) {
|
|
const client = new NativeMacOSProvider(
|
|
new FailingRunner(reason),
|
|
"darwin",
|
|
).createClient(machoTarget("/fixture"));
|
|
const result = await client.execute("list_architectures", {});
|
|
expect(!result.ok && result.error._tag).toBe(tag);
|
|
}
|
|
});
|
|
|
|
it("classifies pre-aborted requests before operation or runner discovery", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const runner = new CountingRunner();
|
|
const client = new NativeMacOSProvider(runner, "linux").createClient(
|
|
machoTarget("/fixture"),
|
|
);
|
|
|
|
const result = await client.execute(
|
|
"list_architectures",
|
|
{},
|
|
{
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
|
|
expect(!result.ok && result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(runner.calls).toBe(0);
|
|
});
|
|
|
|
it("parses thin and universal lipo fixtures", async () => {
|
|
expect(
|
|
parseLipoArchitectures(await fixture("lipo-thin.txt")),
|
|
).toMatchObject([{ name: "arm64" }]);
|
|
expect(parseLipoArchitectures(await fixture("lipo-fat.txt"))).toMatchObject(
|
|
[
|
|
{ name: "x86_64", file_offset: 16384, size: 4096 },
|
|
{ name: "arm64", file_offset: 32768, size: 8192 },
|
|
],
|
|
);
|
|
});
|
|
});
|
|
|
|
class FixtureRunner implements NativeCommandRunner {
|
|
constructor(
|
|
private readonly overrides: Readonly<Record<string, string>> = {},
|
|
) {}
|
|
|
|
async run(tool: string, arguments_: readonly string[]) {
|
|
const output = this.overrides[tool] ?? (await outputFor(tool, arguments_));
|
|
return ok(capture(tool, arguments_, output));
|
|
}
|
|
}
|
|
|
|
class FailingRunner implements NativeCommandRunner {
|
|
constructor(private readonly reason: NativeCommandFailure["reason"]) {}
|
|
|
|
run(tool: string) {
|
|
return Promise.resolve(err(new NativeCommandFailure(tool, this.reason)));
|
|
}
|
|
}
|
|
|
|
class VtoolFailingRunner implements NativeCommandRunner {
|
|
readonly #fixture = new FixtureRunner();
|
|
|
|
constructor(private readonly reason: NativeCommandFailure["reason"]) {}
|
|
|
|
run(tool: string, arguments_: readonly string[]) {
|
|
return tool === "vtool"
|
|
? Promise.resolve(err(new NativeCommandFailure(tool, this.reason)))
|
|
: this.#fixture.run(tool, arguments_);
|
|
}
|
|
}
|
|
|
|
class CountingRunner implements NativeCommandRunner {
|
|
calls = 0;
|
|
|
|
run(tool: string) {
|
|
this.calls += 1;
|
|
return Promise.resolve(err(new NativeCommandFailure(tool, "unavailable")));
|
|
}
|
|
}
|
|
|
|
const outputFor = async (
|
|
tool: string,
|
|
arguments_: readonly string[],
|
|
): Promise<string> => {
|
|
if (tool === "lipo") return fixture("lipo-fat.txt");
|
|
if (tool === "otool") return fixture("otool-load.txt");
|
|
if (tool === "nm") return "_main\n_$s4Test3fooyyF\n";
|
|
if (tool === "dyld_info")
|
|
return fixture(
|
|
arguments_[0] === "-imports" ? "dyld-imports.txt" : "dyld-exports.txt",
|
|
);
|
|
if (tool === "dwarfdump")
|
|
return "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n";
|
|
if (tool === "file")
|
|
return arguments_.at(-1)?.endsWith(".plist") === true
|
|
? "XML 1.0 document text\n"
|
|
: "Mach-O 64-bit executable arm64, little-endian\n";
|
|
if (tool === "vtool") return "Load command 3 LC_BUILD_VERSION\n";
|
|
if (tool === "swift-demangle") return fixture("demangle.txt");
|
|
if (tool === "plutil") return fixture("plist.json");
|
|
if (tool === "codesign") {
|
|
if (arguments_.includes("--entitlements"))
|
|
return fixture("entitlements.xml");
|
|
if (arguments_.includes("-r-"))
|
|
return "designated => identifier com.example.fixture\n";
|
|
return fixture("codesign.txt");
|
|
}
|
|
throw new Error(`Unexpected fixture tool ${tool}`);
|
|
};
|
|
|
|
const capture = (
|
|
tool: string,
|
|
arguments_: readonly string[],
|
|
output: string,
|
|
): NativeCommandCapture => ({
|
|
tool,
|
|
executable: `/usr/bin/${tool}`,
|
|
executableSha256: "a".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: [...arguments_],
|
|
stdout: tool === "codesign" ? "" : output,
|
|
stderr: tool === "codesign" ? output : "",
|
|
stdoutBytes: Buffer.byteLength(tool === "codesign" ? "" : output),
|
|
stderrBytes: Buffer.byteLength(tool === "codesign" ? output : ""),
|
|
stdoutTruncated: false,
|
|
stderrTruncated: false,
|
|
exitCode: 0,
|
|
signal: null,
|
|
});
|
|
|
|
const machoTarget = (path: string, sourcePath?: string): BinaryTarget => ({
|
|
path,
|
|
...(sourcePath === undefined ? {} : { sourcePath }),
|
|
sha256: "0".repeat(64),
|
|
kind: "executable",
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
availableArchitectures: ["x86_64", "arm64"],
|
|
});
|
|
|
|
const fixture = (name: string): Promise<string> =>
|
|
readFile(new URL(`./fixtures/native-macos/${name}`, import.meta.url), "utf8");
|