mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(paths): preserve native selections across launch and publication
This commit is contained in:
1 parent
da526fc014
commit
f2b4468183
15 files changed
+536
-95
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok as resultOk, err } from "../../../src/domain/result.js";
|
||||
import { rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
@@ -659,9 +659,9 @@ const evidenceFor = (value: unknown) => {
|
||||
return parseEvidence(value);
|
||||
};
|
||||
|
||||
it("reports every unresolved Electron path without starting a provider", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-invalid-paths-");
|
||||
const capture = vi.fn();
|
||||
const activePathAdmissionClient = async (
|
||||
capture: ElectronActiveObservationPort["capture"],
|
||||
): Promise<Client> => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("must not start");
|
||||
});
|
||||
@@ -680,6 +680,13 @@ it("reports every unresolved Electron path without starting a provider", async (
|
||||
resources.push(client, server, session);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
it("reports every unresolved Electron path without starting a provider", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-invalid-paths-");
|
||||
const capture = vi.fn();
|
||||
const client = await activePathAdmissionClient(capture);
|
||||
const fields = ["executable_path", "application_path", "application_root"];
|
||||
const result = await client.callTool({
|
||||
name: "capture_electron_scenario",
|
||||
@@ -705,6 +712,71 @@ it("reports every unresolved Electron path without starting a provider", async (
|
||||
await client.ping();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses obstructed Electron selections and admits native symlink traversal through MCP",
|
||||
async () => {
|
||||
const root = await realpath(
|
||||
await createTestTempDirectory("rea-electron-obstructed-paths-"),
|
||||
);
|
||||
await mkdir(join(root, "application/child"), { recursive: true });
|
||||
await writeFile(join(root, "file"), "not a directory");
|
||||
await writeFile(join(root, "electron"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
await writeFile(join(root, "main.js"), "module.exports = {};\n");
|
||||
await writeFile(
|
||||
join(root, "application/main.js"),
|
||||
"module.exports = {};\n",
|
||||
);
|
||||
await symlink("application/child", join(root, "link"));
|
||||
const capture = vi.fn<ElectronActiveObservationPort["capture"]>(
|
||||
async (input) =>
|
||||
resultOk(
|
||||
createElectronActiveObservationFixtureResult(input.application_path),
|
||||
),
|
||||
);
|
||||
const client = await activePathAdmissionClient(capture);
|
||||
const selected = {
|
||||
executable_path: join(root, "electron"),
|
||||
application_path: join(root, "main.js"),
|
||||
application_root: join(root, "application"),
|
||||
};
|
||||
for (const field of [
|
||||
"executable_path",
|
||||
"application_path",
|
||||
"application_root",
|
||||
] as const) {
|
||||
const refused = await client.callTool({
|
||||
name: "capture_electron_scenario",
|
||||
arguments: {
|
||||
...selected,
|
||||
[field]: `${root}/file/../${basename(selected[field])}`,
|
||||
},
|
||||
});
|
||||
expect(refused.isError).toBe(true);
|
||||
expect(parseMcpToolError(refused)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ path: [field] }] },
|
||||
},
|
||||
});
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
}
|
||||
const admitted = await client.callTool({
|
||||
name: "capture_electron_scenario",
|
||||
arguments: {
|
||||
...selected,
|
||||
application_path: `${root}/link/../main.js`,
|
||||
application_root: `${root}/link/..`,
|
||||
},
|
||||
});
|
||||
expect(admitted.isError, JSON.stringify(admitted)).not.toBe(true);
|
||||
expect(capture).toHaveBeenCalledOnce();
|
||||
expect(capture.mock.calls[0]?.[0]).toMatchObject({
|
||||
application_path: join(root, "application/main.js"),
|
||||
application_root: join(root, "application"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const expectPaddedElectronTargetRejected = async (
|
||||
client: Client,
|
||||
browser: FakeCdpBrowser,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
|
||||
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
|
||||
import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js";
|
||||
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
const sourceBytes = "selected native bytes";
|
||||
const pulledBytes = "pulled fixture";
|
||||
const bugreportBytes = "bugreport fixture";
|
||||
const png = Buffer.from(
|
||||
"89504e470d0a1a0a0000000d494844520000000400000003",
|
||||
"hex",
|
||||
);
|
||||
const digest = (bytes: string | Buffer) =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
// All ADB commands run a selected local script. No device, server or real adb
|
||||
// executable participates; file snapshots, publication and Evidence are real.
|
||||
const fixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-adb-selected-path-");
|
||||
const physical = join(root, "physical");
|
||||
await mkdir(join(physical, "nested"), { recursive: true });
|
||||
await symlink(join(physical, "nested"), join(root, "alias"));
|
||||
const binary = join(root, "adb");
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
await writeFile(
|
||||
binary,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = version ]; then printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"; exit 0; fi
|
||||
if [ "$3" = shell ] && [ "$4" = pm ] && [ "$5" = path ]; then printf 'package:/data/app/fixture/base.apk\\n'; exit 0; fi
|
||||
if [ "$3" = shell ]; then exit 1; fi
|
||||
if [ "$3" = push ] || [ "$3" = install ]; then
|
||||
printf '%s' "$4" > ${quote(join(root, "snapshot-path"))}
|
||||
/bin/cat "$4" > ${quote(join(root, "consumed"))}
|
||||
printf 'Success\\n'; exit 0
|
||||
fi
|
||||
if [ "$3" = pull ]; then printf '${pulledBytes}' > "$5"; exit 0; fi
|
||||
if [ "$3" = exec-out ]; then printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'; exit 0; fi
|
||||
if [ "$3" = bugreport ]; then
|
||||
printf '${bugreportBytes}' > "$4/bugreport.zip"
|
||||
printf 'Bug report copied to %s/bugreport.zip\\n' "$4"; exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const runtimePaths: string[] = [];
|
||||
const provider = new AdbProvider({
|
||||
environment: { REA_ADB_PATH: binary },
|
||||
createRuntimeRoot: async (options) => {
|
||||
const runtime = await PrivateRuntimeRoot.create(options);
|
||||
runtimePaths.push(runtime.path);
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => provider.close());
|
||||
return {
|
||||
root,
|
||||
physical,
|
||||
runtimePaths,
|
||||
service: new AdbDeviceAnalysisService(provider),
|
||||
};
|
||||
};
|
||||
|
||||
it.each(["push_adb_file", "install_adb_package"] as const)(
|
||||
"preserves native symlink traversal in %s snapshots and Evidence",
|
||||
async (operation) => {
|
||||
const f = await fixture();
|
||||
await writeFile(join(f.physical, "payload.apk"), sourceBytes);
|
||||
await writeFile(join(f.root, "payload.apk"), "wrong lexical bytes");
|
||||
const selected = `${f.root}/alias/../payload.apk`;
|
||||
const result = await f.service.execute(
|
||||
operation,
|
||||
operation === "push_adb_file"
|
||||
? {
|
||||
serial: "fixture",
|
||||
local_path: selected,
|
||||
device_path: "/data/local/tmp/payload.apk",
|
||||
overwrite: false,
|
||||
}
|
||||
: { serial: "fixture", apk_path: selected, replace: false },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(await readFile(join(f.root, "consumed"), "utf8")).toBe(sourceBytes);
|
||||
const field = operation === "push_adb_file" ? "local_path" : "apk_path";
|
||||
expect(result.value.parameters).toMatchObject({ [field]: selected });
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
[field]: selected,
|
||||
bytes: Buffer.byteLength(sourceBytes),
|
||||
sha256: digest(sourceBytes),
|
||||
});
|
||||
const snapshot = await readFile(join(f.root, "snapshot-path"), "utf8");
|
||||
expect(snapshot).not.toBe(selected);
|
||||
await expect(access(dirname(snapshot))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"pull_adb_file",
|
||||
"pull_adb_package",
|
||||
"capture_adb_screen",
|
||||
"collect_adb_bugreport",
|
||||
] as const)(
|
||||
"publishes %s through the selected output traversal and cleans owned staging",
|
||||
async (operation) => {
|
||||
const f = await fixture();
|
||||
const selected = `${f.root}/alias/../out`;
|
||||
const input = {
|
||||
serial: "fixture",
|
||||
output_directory: selected,
|
||||
...(operation === "pull_adb_file"
|
||||
? { device_path: "/data/local/tmp/pulled.bin" }
|
||||
: {}),
|
||||
...(operation === "pull_adb_package"
|
||||
? { package: "com.example.fixture" }
|
||||
: {}),
|
||||
};
|
||||
const result = await f.service.execute(operation, input);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.parameters).toMatchObject({
|
||||
output_directory: selected,
|
||||
});
|
||||
const relative =
|
||||
operation === "pull_adb_package"
|
||||
? "com.example.fixture/base.apk"
|
||||
: operation === "pull_adb_file"
|
||||
? "pulled.bin"
|
||||
: operation === "capture_adb_screen"
|
||||
? "screen.png"
|
||||
: "bugreport.zip";
|
||||
const expectedBytes =
|
||||
operation === "capture_adb_screen"
|
||||
? png
|
||||
: Buffer.from(
|
||||
operation === "collect_adb_bugreport"
|
||||
? bugreportBytes
|
||||
: pulledBytes,
|
||||
);
|
||||
expect(
|
||||
(await readFile(join(f.physical, "out", relative))).equals(expectedBytes),
|
||||
).toBe(true);
|
||||
const file = {
|
||||
local_path: `${selected}/${relative}`,
|
||||
bytes: expectedBytes.byteLength,
|
||||
sha256: digest(expectedBytes),
|
||||
};
|
||||
expect(result.value.normalized_result).toMatchObject(
|
||||
operation === "pull_adb_package"
|
||||
? {
|
||||
output_directory: selected,
|
||||
artifacts: [file],
|
||||
coverage: "complete",
|
||||
}
|
||||
: file,
|
||||
);
|
||||
await expect(access(join(f.root, "out"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
expect(
|
||||
(await readdir(join(f.physical, "out"))).filter((name) =>
|
||||
name.startsWith(".rea-"),
|
||||
),
|
||||
).toEqual([]);
|
||||
for (const path of f.runtimePaths) {
|
||||
if (operation === "capture_adb_screen")
|
||||
expect(dirname(path)).toBe(join(f.physical, "out"));
|
||||
await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -37,7 +37,11 @@ const request: Parameters<NativeCallTracer["trace"]>[0] = {
|
||||
backtrace_frames: 0,
|
||||
},
|
||||
};
|
||||
const tool = { path: process.execPath, sha256: "0".repeat(64) };
|
||||
const tool = {
|
||||
invocationPath: process.execPath,
|
||||
resolvedPath: process.execPath,
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
|
||||
const createTracer = (
|
||||
launch?: ConstructorParameters<typeof LldbCallTracer>[1],
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ProviderStartupDeadline } from "../../../../src/process/ProviderDeadlin
|
||||
import {
|
||||
NativeFixtureRunner,
|
||||
nativeMachoTarget,
|
||||
nativeResolvedToolFixture,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const EVENT = {
|
||||
@@ -72,7 +73,7 @@ const request: Parameters<NativeCallTracer["trace"]>[0] = {
|
||||
}),
|
||||
};
|
||||
|
||||
const fakeTool = { path: "/usr/bin/lldb", sha256: "b".repeat(64) };
|
||||
const fakeTool = nativeResolvedToolFixture("/usr/bin/lldb", "b".repeat(64));
|
||||
const successfulTracerOutput = {
|
||||
status: "traced",
|
||||
version: "lldb-fixture",
|
||||
@@ -251,7 +252,11 @@ describe("LLDB completed trace retention through finalization", () => {
|
||||
phase === "runtime cleanup failure" ? "cleanup-failure" : "cancelled",
|
||||
},
|
||||
target: { launch_identity: successfulTracerOutput.target_identity },
|
||||
debugger: { ...fakeTool, version: successfulTracerOutput.version },
|
||||
debugger: {
|
||||
path: fakeTool.invocationPath,
|
||||
sha256: fakeTool.sha256,
|
||||
version: successfulTracerOutput.version,
|
||||
},
|
||||
process: {
|
||||
outcome: "exited",
|
||||
exit_status: 0,
|
||||
@@ -284,7 +289,8 @@ const expectCompletedCleanupFacts = (
|
||||
completed: typeof successfulTracerOutput,
|
||||
) => {
|
||||
expect(partial.debugger).toEqual({
|
||||
...fakeTool,
|
||||
path: fakeTool.invocationPath,
|
||||
sha256: fakeTool.sha256,
|
||||
version: completed.version,
|
||||
});
|
||||
expect(partial.target.launch_identity).toEqual(completed.target_identity);
|
||||
@@ -728,7 +734,7 @@ describe("LLDB primary outcome during owned process cleanup", () => {
|
||||
return observedStop;
|
||||
});
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fakeLldb, sha256: "c".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fakeLldb, "c".repeat(64))),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -821,7 +827,7 @@ describe("LLDB process exit while output is still open", () => {
|
||||
return stopReceipt;
|
||||
});
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fakeLldb, sha256: "d".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fakeLldb, "d".repeat(64))),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -950,7 +956,7 @@ describe("LLDB target proof published before interruption", () => {
|
||||
)
|
||||
: undefined;
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fakeLldb, sha256: "c".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fakeLldb, "c".repeat(64))),
|
||||
);
|
||||
let retainedRoot: string | undefined;
|
||||
try {
|
||||
@@ -1032,7 +1038,7 @@ describe("LLDB target proof read by cleanup retry", () => {
|
||||
: originalStop.call(this, options);
|
||||
});
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fakeLldb, sha256: "c".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fakeLldb, "c".repeat(64))),
|
||||
);
|
||||
let retainedRoot: string | undefined;
|
||||
try {
|
||||
@@ -1160,7 +1166,7 @@ describe("LLDB process exit at output-close loop completion", () => {
|
||||
return closed;
|
||||
});
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fixture.fakeLldb, sha256: "e".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fixture.fakeLldb, "e".repeat(64))),
|
||||
);
|
||||
try {
|
||||
const result = await tracer.trace(request, controller.signal);
|
||||
@@ -1254,7 +1260,7 @@ describe("LLDB process exit at output-close loop completion", () => {
|
||||
return stopReceipt;
|
||||
});
|
||||
const tracer = new LldbCallTracer({}, undefined, async () =>
|
||||
ok({ path: fakeLldb, sha256: "f".repeat(64) }),
|
||||
ok(nativeResolvedToolFixture(fakeLldb, "f".repeat(64))),
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js";
|
||||
import { resolveXcrunTool } from "../../../../src/native/CommandRunner.js";
|
||||
import {
|
||||
resolveXcrunTool,
|
||||
XcrunCommandRunner,
|
||||
} from "../../../../src/native/CommandRunner.js";
|
||||
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
|
||||
import { observeProcessStartIdentity } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
@@ -22,27 +28,88 @@ const readySchema = z.object({
|
||||
config_path: z.string(),
|
||||
});
|
||||
const shellArgument = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const xcrunSelection = async (
|
||||
tool: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Promise<string> => {
|
||||
const { stdout } = await exec("/usr/bin/xcrun", ["--find", tool], {
|
||||
env: environment,
|
||||
});
|
||||
expect(stdout.endsWith("\n")).toBe(true);
|
||||
return stdout.slice(0, -1);
|
||||
};
|
||||
|
||||
const externalToolchain = async (root: string, tool: string) => {
|
||||
const toolchains = join(root, "Toolchains");
|
||||
const toolchain = join(toolchains, "Audit.xctoolchain");
|
||||
const invocationPath = join(toolchain, "usr/bin", tool);
|
||||
const resolvedPath = join(root, "tool-implementation");
|
||||
await mkdir(dirname(invocationPath), { recursive: true });
|
||||
// LLVM documents this external toolchain layout and selection mechanism:
|
||||
// llvm/tools/xcode-toolchain/CMakeLists.txt. No installed toolchain is changed.
|
||||
await writeFile(
|
||||
join(toolchain, "Info.plist"),
|
||||
`<?xml version="1.0"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleIdentifier</key><string>org.rea.audit.selected</string>
|
||||
<key>CompatibilityVersion</key><integer>2</integer>
|
||||
</dict></plist>`,
|
||||
);
|
||||
await symlink(resolvedPath, invocationPath);
|
||||
return {
|
||||
invocationPath,
|
||||
resolvedPath,
|
||||
environment: {
|
||||
...process.env,
|
||||
DEVELOPER_DIR: undefined,
|
||||
SDKROOT: undefined,
|
||||
EXTERNAL_TOOLCHAINS_DIR: toolchains,
|
||||
TOOLCHAINS: "org.rea.audit.selected",
|
||||
xcrun_nocache: "1",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"invokes the xcrun-selected native tool spelling and its sibling resources",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-native-selection-");
|
||||
const tool = await externalToolchain(root, "nm");
|
||||
const source = `#!/bin/sh\nprintf '%s\\n' "$0"\ncat "$(dirname "$0")/selected-resource"\n`;
|
||||
await writeFile(tool.resolvedPath, source, { mode: 0o755 });
|
||||
await writeFile(
|
||||
join(dirname(tool.invocationPath), "selected-resource"),
|
||||
"selected sibling resource\n",
|
||||
);
|
||||
const selectedPath = await xcrunSelection("nm", tool.environment);
|
||||
|
||||
const runner = new XcrunCommandRunner(tool.environment);
|
||||
try {
|
||||
const result = await runner.run("nm", [], {});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.stdout).toBe(
|
||||
`${selectedPath}\nselected sibling resource\n`,
|
||||
);
|
||||
expect(result.value.executable).toBe(selectedPath);
|
||||
expect(result.value.executable).not.toBe(tool.resolvedPath);
|
||||
expect(result.value.executableSha256).toBe(
|
||||
createHash("sha256").update(source).digest("hex"),
|
||||
);
|
||||
} finally {
|
||||
expect(await runner.retryCleanup()).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"cleans an xcrun-selected LLDB wrapper after cancellation without requiring its pre-exec argv",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-lldb-wrapper-");
|
||||
const child = join(root, "safe-child.mjs");
|
||||
const toolchains = join(root, "Toolchains");
|
||||
const toolchain = join(toolchains, "Audit.xctoolchain");
|
||||
const launcher = join(toolchain, "usr/bin/lldb");
|
||||
const tool = await externalToolchain(root, "lldb");
|
||||
const child = join(dirname(tool.invocationPath), "safe-child.mjs");
|
||||
const readyPath = join(root, "ready.json");
|
||||
await mkdir(dirname(launcher), { recursive: true });
|
||||
// LLVM documents this external toolchain layout and selection mechanism:
|
||||
// llvm/tools/xcode-toolchain/CMakeLists.txt. No installed toolchain is changed.
|
||||
await writeFile(
|
||||
join(toolchain, "Info.plist"),
|
||||
`<?xml version="1.0"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleIdentifier</key><string>org.rea.audit.lldb</string>
|
||||
<key>CompatibilityVersion</key><integer>2</integer>
|
||||
</dict></plist>`,
|
||||
);
|
||||
// This producer launches no debugger or target. Its delayed self-exit also
|
||||
// bounds the baseline regression's deliberately refused cleanup.
|
||||
await writeFile(
|
||||
@@ -63,21 +130,15 @@ writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({
|
||||
setTimeout(() => process.exit(0), 10000);
|
||||
`,
|
||||
);
|
||||
const launcherText = `#!/bin/sh\nexec ${shellArgument(process.execPath)} ${shellArgument(child)} "$@"\n`;
|
||||
await writeFile(launcher, launcherText);
|
||||
await chmod(launcher, 0o755);
|
||||
const environment = {
|
||||
...process.env,
|
||||
DEVELOPER_DIR: undefined,
|
||||
SDKROOT: undefined,
|
||||
EXTERNAL_TOOLCHAINS_DIR: toolchains,
|
||||
TOOLCHAINS: "org.rea.audit.lldb",
|
||||
xcrun_nocache: "1",
|
||||
};
|
||||
const launcherText = `#!/bin/sh\nexec ${shellArgument(process.execPath)} "$(dirname "$0")/safe-child.mjs" "$@"\n`;
|
||||
await writeFile(tool.resolvedPath, launcherText);
|
||||
await chmod(tool.resolvedPath, 0o755);
|
||||
const { environment } = tool;
|
||||
const selected = await resolveXcrunTool("lldb", environment);
|
||||
if (!selected.ok) throw selected.error;
|
||||
expect(selected.value).toEqual({
|
||||
path: await realpath(launcher),
|
||||
invocationPath: await xcrunSelection("lldb", environment),
|
||||
resolvedPath: await realpath(tool.invocationPath),
|
||||
sha256: createHash("sha256").update(launcherText).digest("hex"),
|
||||
});
|
||||
// Production discovery, acquisition and native process ownership terminate
|
||||
|
||||
@@ -12,7 +12,11 @@ import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../../../../src/process/DarwinProcessRunTokenReader.js";
|
||||
import * as processBoundary from "../../../../src/process/ProviderProcess.js";
|
||||
|
||||
const tool = { path: "/usr/bin/true", sha256: "b".repeat(64) };
|
||||
const tool = {
|
||||
invocationPath: "/usr/bin/true",
|
||||
resolvedPath: "/usr/bin/true",
|
||||
sha256: "b".repeat(64),
|
||||
};
|
||||
const request = {
|
||||
executable: "/fixture",
|
||||
architecture: "arm64" as const,
|
||||
|
||||
@@ -85,6 +85,37 @@ it("keeps a realpath I/O failure primary when cancellation is observed before re
|
||||
expect(result.error.cause).toBe(ioFailure);
|
||||
});
|
||||
|
||||
it("retains exact cancellation observed after successful tool path resolution", async () => {
|
||||
let finishRealpath!: () => void;
|
||||
let realpathStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
realpathStarted = resolve;
|
||||
});
|
||||
realpathState.resolve = () =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishRealpath = () => resolve("/controlled/physical-tool");
|
||||
realpathStarted();
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const cancellationReason = { source: "caller", phase: "tool selection" };
|
||||
const pending = resolveXcrunTool("file", {}, controller.signal, launcher);
|
||||
await started;
|
||||
controller.abort(cancellationReason);
|
||||
finishRealpath();
|
||||
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("Expected selection cancellation");
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "cancelled",
|
||||
cancellation: { reason: cancellationReason },
|
||||
});
|
||||
expect(result.error.cancellation?.reason).toBe(cancellationReason);
|
||||
expect(result.error.cause).toMatchObject({
|
||||
reason: "cancelled",
|
||||
cause: cancellationReason,
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"uses the AbortError cause as the exact tool-hash cancellation fact",
|
||||
async () => {
|
||||
|
||||
@@ -76,7 +76,11 @@ it.skipIf(process.platform === "win32")(
|
||||
PATH: "/selected-path",
|
||||
};
|
||||
const runner = new XcrunCommandRunner(environment, async () =>
|
||||
ok({ path: process.execPath, sha256: "a".repeat(64) }),
|
||||
ok({
|
||||
invocationPath: process.execPath,
|
||||
resolvedPath: process.execPath,
|
||||
sha256: "a".repeat(64),
|
||||
}),
|
||||
);
|
||||
environment.REA_NATIVE_SELECTED = "mutated-after-construction";
|
||||
const captured = await runner.run(
|
||||
@@ -141,7 +145,12 @@ it.skipIf(process.platform === "win32")(
|
||||
};
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
async () => ok({ path: process.execPath, sha256: "a".repeat(64) }),
|
||||
async () =>
|
||||
ok({
|
||||
invocationPath: process.execPath,
|
||||
resolvedPath: process.execPath,
|
||||
sha256: "a".repeat(64),
|
||||
}),
|
||||
launcher,
|
||||
);
|
||||
|
||||
@@ -195,7 +204,11 @@ it.skipIf(process.platform === "win32")(
|
||||
.update(await readFile(process.execPath))
|
||||
.digest("hex");
|
||||
const runner = new XcrunCommandRunner({}, async () =>
|
||||
ok({ path: process.execPath, sha256 }),
|
||||
ok({
|
||||
invocationPath: process.execPath,
|
||||
resolvedPath: process.execPath,
|
||||
sha256,
|
||||
}),
|
||||
);
|
||||
const exited = await runner.run("file", ["-e", "process.exit(1)"], {
|
||||
acceptNonZero: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
nativeFixture as fixture,
|
||||
nativeMachoTarget as machoTarget,
|
||||
nativeMachoTargetForFile,
|
||||
nativeResolvedToolFixture,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
/** The file runner identifies selected .plist inputs as XML. */
|
||||
@@ -222,7 +223,7 @@ describe("native command output collection", () => {
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(executable))
|
||||
.digest("hex");
|
||||
return ok({ path: executable, sha256: digest });
|
||||
return ok(nativeResolvedToolFixture(executable, digest));
|
||||
});
|
||||
|
||||
const originalDigest = await install("original-tool-output");
|
||||
@@ -248,7 +249,9 @@ describe("native command output collection", () => {
|
||||
|
||||
it("retains the complete native command output", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const output = "x".repeat(4096);
|
||||
const captured = await runner.run(
|
||||
@@ -262,7 +265,9 @@ describe("native command output collection", () => {
|
||||
|
||||
it("retains both output streams and exit status when a command fails", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
@@ -292,7 +297,9 @@ describe("native command output collection", () => {
|
||||
|
||||
it("retains accepted nonzero output as a complete command capture", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const result = await runner.run(
|
||||
"file",
|
||||
@@ -318,7 +325,9 @@ describe("native command output collection", () => {
|
||||
describe("native command failure capture and limits", () => {
|
||||
it("projects captured diagnostics from a failed native provider command", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const result = await new NativeMacOSProvider({}, runner, "darwin")
|
||||
.createClient(machoTarget("/private/fixture"))
|
||||
@@ -353,7 +362,9 @@ describe("native command failure capture and limits", () => {
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
() =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
() => Promise.reject(inspection),
|
||||
);
|
||||
const result = await new NativeMacOSProvider({}, runner, "darwin")
|
||||
@@ -374,7 +385,9 @@ describe("native command failure capture and limits", () => {
|
||||
|
||||
it("retains output written before cancellation and reaps the stalled command", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = runner.run(
|
||||
@@ -429,7 +442,7 @@ describe("native owned cancellation boundary", () => {
|
||||
spawnOwnedProviderProcess({ ...options, expectedCommand: null });
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
async () => ok({ path: executable, sha256: executableSha256 }),
|
||||
async () => ok(nativeResolvedToolFixture(executable, executableSha256)),
|
||||
launcher,
|
||||
);
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
@@ -544,7 +557,7 @@ describe("native cancellation cleanup provenance", () => {
|
||||
};
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
async () => ok({ path: executable, sha256: executableSha256 }),
|
||||
async () => ok(nativeResolvedToolFixture(executable, executableSha256)),
|
||||
launcher,
|
||||
);
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
@@ -681,7 +694,7 @@ describe("native returned cleanup ownership", () => {
|
||||
};
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
async () => ok({ path: executable, sha256: executableSha256 }),
|
||||
async () => ok(nativeResolvedToolFixture(executable, executableSha256)),
|
||||
launcher,
|
||||
);
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
@@ -824,7 +837,9 @@ describe("native late cancellation with cleanup", () => {
|
||||
const runner = new XcrunCommandRunner(
|
||||
{},
|
||||
async () =>
|
||||
ok({ path: "/controlled/native-tool", sha256: "a".repeat(64) }),
|
||||
ok(
|
||||
nativeResolvedToolFixture("/controlled/native-tool", "a".repeat(64)),
|
||||
),
|
||||
launcher,
|
||||
);
|
||||
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
|
||||
@@ -877,7 +892,9 @@ describe("native late cancellation with cleanup", () => {
|
||||
describe("native command failure capture and limits", () => {
|
||||
it("bounds both failure streams at a UTF-8 boundary while keeping their totals", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
// "é" is two bytes and straddles the excerpt boundary, so it is dropped whole.
|
||||
const stdout = `${"x".repeat(NATIVE_FAILURE_DIAGNOSTIC_BYTES - 1)}é${"y".repeat(1024)}`;
|
||||
@@ -907,7 +924,9 @@ describe("native command failure capture and limits", () => {
|
||||
|
||||
it("rejects output beyond the complete native-command budget", async () => {
|
||||
const runner = new XcrunCommandRunner({}, () =>
|
||||
Promise.resolve(ok({ path: process.execPath, sha256: "a".repeat(64) })),
|
||||
Promise.resolve(
|
||||
ok(nativeResolvedToolFixture(process.execPath, "a".repeat(64))),
|
||||
),
|
||||
);
|
||||
const megabytesOverBudget =
|
||||
Math.ceil(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES / (1024 * 1024)) + 1;
|
||||
|
||||
Vendored
+7
@@ -10,8 +10,15 @@ import type {
|
||||
NativeCommandCapture,
|
||||
NativeCommandOptions,
|
||||
NativeCommandRunner,
|
||||
ResolvedTool,
|
||||
} from "../../src/native/CommandRunner.js";
|
||||
|
||||
/** Resolver seam for fixtures with identical invocation and physical paths. */
|
||||
export const nativeResolvedToolFixture = (
|
||||
path: string,
|
||||
sha256: string,
|
||||
): ResolvedTool => ({ invocationPath: path, resolvedPath: path, sha256 });
|
||||
|
||||
/** Replay captured native command outputs through the production runner seam. */
|
||||
export class NativeFixtureRunner implements NativeCommandRunner {
|
||||
constructor(
|
||||
|
||||
Reference in new issue
Block a user