diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index e92cb0dbbc..7912e586ab 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1,3 +1,7 @@ +import { + absoluteCallerPath, + appendCallerPath, +} from "../filesystem/callerPath.js"; import { execFileFailureCapturedOutput } from "../process/ExecFileOutput.js"; import { collectNumericIssues } from "./AdbNumericIssues.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; @@ -16,7 +20,7 @@ import { stat, writeFile, } from "node:fs/promises"; -import { basename, dirname, extname, join, resolve } from "node:path"; +import { basename, dirname, extname, join } from "node:path"; import { type ExecutionOptions, @@ -442,7 +446,7 @@ const prepareOutputDirectory = async ( operation: string, directory: string, ): Promise> => { - const root = resolve(directory); + const root = absoluteCallerPath(directory); try { await mkdir(root, { recursive: true }); await access(root, fsConstants.W_OK); @@ -668,7 +672,7 @@ const publishBugreport = async ( const stem = extension === "" ? fileName : fileName.slice(0, -extension.length); for (let suffix = 0; ; suffix += 1) { - const destination = join( + const destination = appendCallerPath( directory, suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`, ); @@ -767,7 +771,7 @@ const reservePackageDestinations = async ( readonly identity: OutputDirectoryIdentity; }[] = []; for (const entry of entries) { - const path = join(packageDirectory, entry.file_name); + const path = appendCallerPath(packageDirectory, entry.file_name); try { await mkdir(path); const created = await lstat(path); @@ -2045,7 +2049,7 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const packageDirectory = join(directory.value, packageName); + const packageDirectory = appendCallerPath(directory.value, packageName); let packageDirectoryCreated = false; let packageDirectoryIdentity: OutputDirectoryIdentity | undefined; try { @@ -2185,7 +2189,7 @@ export class AdbProvider { return err(error); }; for (const entry of parsed.paths) { - const localPath = join(packageDirectory, entry.file_name); + const localPath = appendCallerPath(packageDirectory, entry.file_name); const pulled = await runAdb( operation, { @@ -2377,7 +2381,7 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const localPath = join(directory.value, derived.fileName); + const localPath = appendCallerPath(directory.value, derived.fileName); const existing = await refuseExistingTarget(operation, localPath); if (existing !== undefined) return err(existing); const pulled = await runAdb( @@ -2459,7 +2463,7 @@ export class AdbProvider { }, signal: AbortSignal | undefined, ): Promise> { - const localPath = resolve(input.local_path); + const localPath = absoluteCallerPath(input.local_path); const info = await stat(localPath).then( (value) => value, () => null, @@ -2665,7 +2669,7 @@ export class AdbProvider { }, signal: AbortSignal | undefined, ): Promise> { - const apkPath = resolve(input.apk_path); + const apkPath = absoluteCallerPath(input.apk_path); const info = await stat(apkPath).then( (value) => value, () => null, @@ -3079,7 +3083,7 @@ export class AdbProvider { input.output_directory, ); if (!directory.ok) return directory; - const localPath = join(directory.value, "screen.png"); + const localPath = appendCallerPath(directory.value, "screen.png"); const existing = await refuseExistingTarget(operation, localPath); if (existing !== undefined) return err(existing); const captured = await runAdbCapture( @@ -3210,7 +3214,7 @@ export class AdbProvider { let publishedPath: string | undefined; try { stagingDirectory = await mkdtemp( - join(directory.value, ".rea-bugreport-"), + appendCallerPath(directory.value, ".rea-bugreport-"), ); } catch (cause) { return err( @@ -3255,7 +3259,14 @@ export class AdbProvider { "adb bugreport did not report an archive path; the build may use an unsupported format", ), ); - if (dirname(resolve(reportedPath)) !== stagingDirectory) + const reportedParent = await stat( + dirname(absoluteCallerPath(reportedPath)), + ); + if ( + !reportedParent.isDirectory() || + reportedParent.dev !== stagingIdentity.dev || + reportedParent.ino !== stagingIdentity.ino + ) return err( new AnalysisProtocolError( `adb bugreport reported an archive outside its staging directory: ${reportedPath}`, diff --git a/src/application/javascript/ElectronActiveObservationService.ts b/src/application/javascript/ElectronActiveObservationService.ts index ec8a26c557..81ccd3c734 100644 --- a/src/application/javascript/ElectronActiveObservationService.ts +++ b/src/application/javascript/ElectronActiveObservationService.ts @@ -60,6 +60,9 @@ const resolveInputPath = async ( selected: string, ): Promise> => { try { + // Darwin realpath can collapse file/.. even though native lookup rejects + // that traversal. Admit the selected path before canonicalizing it. + await stat(selected); return ok(await realpath(selected)); } catch (cause: unknown) { return err( diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index 3c7590efe8..2f801baf0e 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -1,7 +1,6 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; -import { realpath } from "node:fs/promises"; import { err, ok, type Result } from "../domain/result.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -18,6 +17,11 @@ import type { } from "../process/ProviderProcess.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js"; +import { + selectArtifactCommandPath, + type ArtifactCommandPath, +} from "../process/ArtifactCommandPath.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; /** Retain complete command output up to a bounded aggregate size. */ export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024; @@ -145,7 +149,7 @@ export type NativeCommandLauncher = ( options: OwnedProviderProcessSpawnOptions, ) => Promise; -/** Resolve one allowlisted native tool to an immutable executable identity. */ +/** Resolve one native tool's selected invocation and prelaunch file identity. */ export type NativeToolResolver = ( tool: string, signal?: AbortSignal, @@ -215,7 +219,7 @@ export class XcrunCommandRunner implements NativeCommandRunner { return err(this.#retainCleanupOwner(resolved.error)); } const captured = await captureProcess( - resolved.value.path, + resolved.value.invocationPath, arguments_, tool, options, @@ -226,7 +230,7 @@ export class XcrunCommandRunner implements NativeCommandRunner { return ok({ ...captured.value, tool, - executable: resolved.value.path, + executable: resolved.value.invocationPath, executableSha256: resolved.value.sha256, toolVersion: null, versionReason: @@ -343,9 +347,8 @@ const commandCleanupFailure = ( [...new Set(failures.flatMap(({ resources }) => resources))], ); -/** Executable path and SHA-256 sampled during one tool resolution. */ -export interface ResolvedTool { - readonly path: string; +/** Selected invocation and physical executable identity sampled during resolution. */ +export interface ResolvedTool extends ArtifactCommandPath { readonly sha256: string; } @@ -437,22 +440,26 @@ export const resolveXcrunTool = async ( ), ); try { - const path = await realpath(candidate); - return ok({ path, sha256: await hashFile(path, signal) }); + const selected = await selectArtifactCommandPath(candidate, signal); + return ok({ + ...selected, + sha256: await hashFile(selected.resolvedPath, signal), + }); } catch (cause: unknown) { const capture = failureCapture(found.value, false); const cancellation = signal?.aborted === true ? { reason: signal.reason } : undefined; - const abortedHashRead = - cause instanceof Error && - "code" in cause && - cause.code === "ABORT_ERR" && + const observedCancellation = signal?.aborted === true && - cause.cause === signal.reason; + cause instanceof Error && + cause.cause === signal.reason && + ((cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled") || + ("code" in cause && cause.code === "ABORT_ERR")); return err( new NativeCommandFailure( tool, - abortedHashRead ? "cancelled" : "io", + observedCancellation ? "cancelled" : "io", capture.exitCode, { cause, diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index fbce86001d..6955a627c1 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -170,7 +170,11 @@ type CapturedOutputRead = /** One completed LLDB observation, before projection into the result contract. */ export interface NativeCallTrace { - readonly debugger: ResolvedTool & { readonly version: string | null }; + readonly debugger: { + readonly path: string; + readonly sha256: string; + readonly version: string | null; + }; readonly run: z.infer; readonly stdout: CapturedOutput; readonly stderr: CapturedOutput; @@ -178,6 +182,15 @@ export interface NativeCallTrace { readonly terminated: boolean; } +const debuggerIdentity = ( + tool: ResolvedTool, + version: string | null, +): NativeCallTrace["debugger"] => ({ + path: tool.invocationPath, + sha256: tool.sha256, + version, +}); + /** Launch one owned process under a debugger and record calls. */ export interface NativeCallTracer { trace( @@ -628,7 +641,7 @@ export class LldbCallTracer implements NativeCallTracer { retained.targetStopped = false; this.#pendingCleanup = retained; const exited = await this.launch( - lldb.path, + lldb.invocationPath, [ "--batch", "--no-lldbinit", @@ -737,7 +750,7 @@ export class LldbCallTracer implements NativeCallTracer { }, request.input, { - debugger: { ...lldb, version: output.version }, + debugger: debuggerIdentity(lldb, output.version), run: output, stdout: stdout.ok ? stdout.value : (stdout.partial ?? null), stderr: stderr.ok ? stderr.value : (stderr.partial ?? null), @@ -892,7 +905,7 @@ export class LldbCallTracer implements NativeCallTracer { }, request.input, { - debugger: { ...lldb, version: output.version }, + debugger: debuggerIdentity(lldb, output.version), run: output, stdout: stdout.ok ? stdout.value : (stdout.partial ?? null), stderr: stderr.ok ? stderr.value : (stderr.partial ?? null), @@ -919,7 +932,7 @@ export class LldbCallTracer implements NativeCallTracer { ); } const trace: NativeCallTrace = { - debugger: { ...lldb, version: output.version }, + debugger: debuggerIdentity(lldb, output.version), run: output, stdout: stdout.value, stderr: stderr.value, diff --git a/src/process/PrivateRuntimeRoot.ts b/src/process/PrivateRuntimeRoot.ts index c319df0de9..98d61faff0 100644 --- a/src/process/PrivateRuntimeRoot.ts +++ b/src/process/PrivateRuntimeRoot.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, realpath, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -67,9 +67,15 @@ export class PrivateRuntimeRoot { ); return new PrivateRuntimeRoot(runtime.observation.path, runtime); } - const path = await mkdtemp( - join(options.parent ?? tmpdir(), options.prefix ?? "rea-provider-"), - ); + const selectedParent = options.parent ?? tmpdir(); + // Validate native traversal before canonicalizing the existing parent. The + // allocated private path then has one physical spelling for every consumer. + if (!(await stat(selectedParent)).isDirectory()) + throw new Error( + `Private runtime parent is not a directory: ${selectedParent}`, + ); + const parent = await realpath(selectedParent); + const path = await mkdtemp(join(parent, options.prefix ?? "rea-provider-")); // POSIX mkdtemp establishes owner-only access atomically. Return its owner // without a fallible permission rewrite and rollback after allocation. return new PrivateRuntimeRoot(path); diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index 84717af1cc..aa503be737 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -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 => { 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( + 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, diff --git a/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts b/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts new file mode 100644 index 0000000000..138c5bfa79 --- /dev/null +++ b/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts @@ -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" }); + } + }, +); diff --git a/tests/boundary/providers/native/LldbCallTracer.cleanup.test.ts b/tests/boundary/providers/native/LldbCallTracer.cleanup.test.ts index b3e365d5ce..f9d0302acd 100644 --- a/tests/boundary/providers/native/LldbCallTracer.cleanup.test.ts +++ b/tests/boundary/providers/native/LldbCallTracer.cleanup.test.ts @@ -37,7 +37,11 @@ const request: Parameters[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[1], diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index 1ee4cdda35..53b74aa0bf 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -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[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 { diff --git a/tests/boundary/providers/native/lldbLauncherBoundary.test.ts b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts index b0b106f3fb..0ae261a5f3 100644 --- a/tests/boundary/providers/native/lldbLauncherBoundary.test.ts +++ b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts @@ -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 => { + 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"), + ` + +CFBundleIdentifierorg.rea.audit.selected +CompatibilityVersion2 +`, + ); + 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"), - ` - -CFBundleIdentifierorg.rea.audit.lldb -CompatibilityVersion2 -`, - ); // 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 diff --git a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts index 908a209556..201256e3fa 100644 --- a/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts +++ b/tests/boundary/providers/native/lldbPrelaunchCleanup.test.ts @@ -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, diff --git a/tests/boundary/providers/native/nativeCommandResolver.test.ts b/tests/boundary/providers/native/nativeCommandResolver.test.ts index 3f09ce7a1e..2fefb52c32 100644 --- a/tests/boundary/providers/native/nativeCommandResolver.test.ts +++ b/tests/boundary/providers/native/nativeCommandResolver.test.ts @@ -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((resolve) => { + realpathStarted = resolve; + }); + realpathState.resolve = () => + new Promise((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 () => { diff --git a/tests/boundary/providers/native/nativeCommandTermination.test.ts b/tests/boundary/providers/native/nativeCommandTermination.test.ts index 7bda530080..8fdbeba7e3 100644 --- a/tests/boundary/providers/native/nativeCommandTermination.test.ts +++ b/tests/boundary/providers/native/nativeCommandTermination.test.ts @@ -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, diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 77d9d81365..40d727bf7b 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -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; diff --git a/tests/fixtures/nativeCommands.ts b/tests/fixtures/nativeCommands.ts index 61098971bd..980dedd53e 100644 --- a/tests/fixtures/nativeCommands.ts +++ b/tests/fixtures/nativeCommands.ts @@ -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(