diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index ab8546e19d..55dadaa8d4 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -17,7 +17,7 @@ import { import { parsePacketOutput } from "./parsePacketOutput.js"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { access, mkdir, realpath, writeFile } from "node:fs/promises"; +import { access, mkdir, writeFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { createAnalysisExecution, @@ -32,6 +32,10 @@ import { type ArtifactAnalysisRuntime, } from "../process/ArtifactAnalysisResources.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + assertArtifactCommandPath, + selectArtifactCommandPath, +} from "../process/ArtifactCommandPath.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -155,7 +159,11 @@ export class TsharkCaptureProvider implements PacketCapturePort { throw new Error( `TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`, ); - const command = await realpath(configured); + const selectedCommand = await selectArtifactCommandPath( + configured, + options?.signal, + ); + const command = selectedCommand.resolvedPath; await access(command, constants.X_OK); const toolSnapshot = await this.#resources.read( command, @@ -167,7 +175,11 @@ export class TsharkCaptureProvider implements PacketCapturePort { this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; if (!isAbsolute(configuredLimiter)) throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute."); - const limiter = await realpath(configuredLimiter); + const selectedLimiter = await selectArtifactCommandPath( + configuredLimiter, + options?.signal, + ); + const limiter = selectedLimiter.resolvedPath; await access(limiter, constants.X_OK); const limiterIdentity = await this.#resources.read( limiter, @@ -175,7 +187,10 @@ export class TsharkCaptureProvider implements PacketCapturePort { options?.signal, ); effectiveLimits = await this.#resources.readLimits(limits); - const limitArguments = prlimitArgumentVector(effectiveLimits, command); + const limitArguments = prlimitArgumentVector( + effectiveLimits, + selectedCommand.invocationPath, + ); phase = "workspace"; root = await this.createRuntime(); this.#resources.retain(root, undefined); @@ -184,9 +199,15 @@ export class TsharkCaptureProvider implements PacketCapturePort { await mkdir(configurationPath, { mode: 0o700 }); const execute = async (args: readonly string[]) => { capturedOutput = undefined; + await assertArtifactCommandPath(selectedCommand, operation, { + signal: options?.signal, + }); + await assertArtifactCommandPath(selectedLimiter, operation, { + signal: options?.signal, + }); const output = await runOwnedCommand( { - command: limiter, + command: selectedLimiter.invocationPath, arguments: [...limitArguments, ...args], ...(root === undefined ? {} : { cwd: root.path }), expectedCommand: null, @@ -209,6 +230,12 @@ export class TsharkCaptureProvider implements PacketCapturePort { }, ); capturedOutput = artifactCommandOutput(output); + await assertArtifactCommandPath(selectedCommand, operation, { + capturedOutput, + }); + await assertArtifactCommandPath(selectedLimiter, operation, { + capturedOutput, + }); const current = await this.#resources.readCommandAfterRun( command, { path: limiter, sha256: limiterIdentity.sha256 }, diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index c2c5d510ce..fbce86001d 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -1404,7 +1404,9 @@ const runLldb = async ( command: executable, arguments: arguments_, runId: randomUUID(), - expectedCommand: executable, + // xcrun-selected toolchains may supply interpreter or exec launchers. + // Cleanup still requires native generation and run-token ownership. + expectedCommand: null, signal: deadline.signal, hostEnvironment: options.environment, }); diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 586e860321..7f83311579 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -54,7 +54,11 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js"; -import { realpath } from "node:fs/promises"; +import { + assertArtifactCommandPath, + selectArtifactCommandPath, + type ArtifactCommandPath, +} from "../process/ArtifactCommandPath.js"; export const PCAP_PROVIDER_IDENTITY = { id: "pcap", @@ -208,7 +212,7 @@ export class PcapProvider { private readonly launcher: PcapCommandLauncher | undefined; private readonly wiresharkIdentities = new Map< string, - { readonly path: string; readonly identity: WiresharkExecutableIdentity } + ArtifactCommandPath & { readonly identity: WiresharkExecutableIdentity } >(); private readonly snapshots = new Set(); private readonly snapshotFailures = new Map< @@ -282,21 +286,22 @@ export class PcapProvider { const selected = this.wiresharkIdentities.get(command); try { if (selected !== undefined) { + await assertArtifactCommandPath(selected, operation, { signal }); const before = await this.readToolIdentity( operation, - selected.path, + selected.resolvedPath, signal, ); assertWiresharkExecutableIdentity( operation, - selected.path, + selected.resolvedPath, selected.identity, wiresharkExecutableIdentity(before), ); } output = await runPcapCommand({ operation, - command: selected?.path ?? command, + command: selected?.invocationPath ?? command, arguments: args, limits, signal, @@ -327,10 +332,20 @@ export class PcapProvider { }); commandReturned = true; if (selected !== undefined) { - const after = await this.readToolIdentity(operation, selected.path); + await assertArtifactCommandPath(selected, operation, { + capturedOutput: { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.cleanup?.capturedOutput.truncated ?? false, + }, + }); + const after = await this.readToolIdentity( + operation, + selected.resolvedPath, + ); assertWiresharkExecutableIdentity( operation, - selected.path, + selected.resolvedPath, selected.identity, wiresharkExecutableIdentity(after), { @@ -403,9 +418,9 @@ export class PcapProvider { ] as const) { const path = locateWiresharkTool(selected, this.environment); if (path === undefined) continue; - let resolvedPath: string; + let commandPath: ArtifactCommandPath; try { - resolvedPath = await realpath(path); + commandPath = await selectArtifactCommandPath(path, signal); } catch (cause) { if ( cause instanceof Error && @@ -422,11 +437,11 @@ export class PcapProvider { } const snapshot = await this.readToolIdentity( operation, - resolvedPath, + commandPath.resolvedPath, signal, ); this.wiresharkIdentities.set(selected.command, { - path: resolvedPath, + ...commandPath, identity: wiresharkExecutableIdentity(snapshot), }); } @@ -461,7 +476,8 @@ export class PcapProvider { tshark_source: selection.tshark.source, tshark_version: identity.tsharkVersion, tshark_resolved_path: - this.wiresharkIdentities.get(selection.tshark.command)?.path ?? null, + this.wiresharkIdentities.get(selection.tshark.command) + ?.resolvedPath ?? null, tshark_sha256: this.wiresharkIdentities.get(selection.tshark.command)?.identity .sha256 ?? null, @@ -474,8 +490,8 @@ export class PcapProvider { capinfos_source: selection.capinfos.source, capinfos_version: identity.capinfosVersion, capinfos_resolved_path: - this.wiresharkIdentities.get(selection.capinfos.command)?.path ?? - null, + this.wiresharkIdentities.get(selection.capinfos.command) + ?.resolvedPath ?? null, capinfos_sha256: this.wiresharkIdentities.get(selection.capinfos.command)?.identity .sha256 ?? null, diff --git a/src/process/ArtifactCommandPath.ts b/src/process/ArtifactCommandPath.ts new file mode 100644 index 0000000000..cdeabe1f0c --- /dev/null +++ b/src/process/ArtifactCommandPath.ts @@ -0,0 +1,60 @@ +import { realpath } from "node:fs/promises"; + +import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js"; +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; + +/** The selected launch spelling and the physical file whose bytes are fingerprinted. */ +export interface ArtifactCommandPath { + readonly invocationPath: string; + readonly resolvedPath: string; +} + +/** Resolve identity without changing the caller's absolute executable selection. */ +export const selectArtifactCommandPath = async ( + invocationPath: string, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + const resolvedPath = await realpath(invocationPath); + assertArtifactReadActive(signal); + return Object.freeze({ invocationPath, resolvedPath }); +}; + +/** An invocation must still select the admitted physical executable. */ +export const assertArtifactCommandPath = async ( + selected: ArtifactCommandPath, + operation: string, + options: { + readonly signal?: AbortSignal | undefined; + readonly capturedOutput?: AnalysisCapturedOutput; + } = {}, +): Promise => { + assertArtifactReadActive(options.signal); + const changed = (cause?: unknown) => + new AnalysisArtifactChangedError( + operation, + selected.invocationPath, + "Selected command no longer resolves to its admitted executable.", + { + cause, + ...(options.capturedOutput === undefined + ? {} + : { capturedOutput: options.capturedOutput }), + }, + ); + let current: string; + try { + current = await realpath(selected.invocationPath); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + throw changed(cause); + throw cause; + } + if (current !== selected.resolvedPath) throw changed(); + assertArtifactReadActive(options.signal); +}; diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 09e43454d2..5a05a11fd1 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -19,7 +19,7 @@ import { } from "../process/LinuxResourceLimits.js"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { access, realpath, writeFile } from "node:fs/promises"; +import { access, writeFile } from "node:fs/promises"; import { basename, isAbsolute, join } from "node:path"; import { createAnalysisExecution, @@ -34,6 +34,10 @@ import { type ArtifactAnalysisRuntime, } from "../process/ArtifactAnalysisResources.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + assertArtifactCommandPath, + selectArtifactCommandPath, +} from "../process/ArtifactCommandPath.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -147,7 +151,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort { throw new Error( "Supply absolute REA_SIGROK_COMMAND for sigrok-cli 0.7.2 with libsigrokdecode.", ); - const command = await realpath(configured); + const selectedCommand = await selectArtifactCommandPath( + configured, + options?.signal, + ); + const command = selectedCommand.resolvedPath; await access(command, constants.X_OK); const tool = await this.#resources.read( command, @@ -158,7 +166,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort { this.environment.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; if (!isAbsolute(configuredLimiter)) throw new Error("REA_SIGROK_PRLIMIT_COMMAND must be absolute."); - const limiter = await realpath(configuredLimiter); + const selectedLimiter = await selectArtifactCommandPath( + configuredLimiter, + options?.signal, + ); + const limiter = selectedLimiter.resolvedPath; await access(limiter, constants.X_OK); const limiterIdentity = await this.#resources.read( limiter, @@ -166,7 +178,10 @@ export class SigrokRecordingProvider implements SignalRecordingPort { options?.signal, ); effectiveLimits = await this.#resources.readLimits(LIMITS); - const limitArguments = prlimitArgumentVector(effectiveLimits, command); + const limitArguments = prlimitArgumentVector( + effectiveLimits, + selectedCommand.invocationPath, + ); phase = "workspace"; root = await this.createRuntime(); this.#resources.retain(root, undefined); @@ -180,9 +195,15 @@ export class SigrokRecordingProvider implements SignalRecordingPort { selection?: SigrokSelection, ) => { capturedOutput = undefined; + await assertArtifactCommandPath(selectedCommand, operation, { + signal: options?.signal, + }); + await assertArtifactCommandPath(selectedLimiter, operation, { + signal: options?.signal, + }); const output = await runOwnedCommand( { - command: limiter, + command: selectedLimiter.invocationPath, arguments: [...limitArguments, ...args], ...(root === undefined ? {} : { cwd: root.path }), expectedCommand: null, @@ -202,6 +223,12 @@ export class SigrokRecordingProvider implements SignalRecordingPort { }, ); capturedOutput = artifactCommandOutput(output); + await assertArtifactCommandPath(selectedCommand, operation, { + capturedOutput, + }); + await assertArtifactCommandPath(selectedLimiter, operation, { + capturedOutput, + }); const current = await this.#resources.readCommandAfterRun( command, { path: limiter, sha256: limiterIdentity.sha256 }, diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index f189844891..d794e31cbd 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -284,7 +284,6 @@ it.each([ "-e", `process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`, ], - expectedCommand: null, }); }, ); @@ -497,7 +496,6 @@ it.each([ "-e", `process.stdout.write(${JSON.stringify(selected ? stdout : "1.0.42\n")}); process.stderr.write(${JSON.stringify(selected ? stderr : "")}); process.exit(${String(selected ? exitCode : 0)});`, ], - expectedCommand: null, }); }, ); @@ -564,7 +562,6 @@ it("retries a retained input descriptor before admitting the next WABT operation "-e", `process.stdout.write(${JSON.stringify(stdout)}); process.exit(0);`, ], - expectedCommand: null, }); return launched; }, @@ -768,7 +765,6 @@ const launchWabtOutcome = async ( hostEnvironment: nodeHostEnvironment(input.hostEnvironment), command: process.execPath, arguments: ["-e", script], - expectedCommand: null, }); if (!version && mode === "cancelled") { setImmediate(() => controller.abort()); @@ -793,7 +789,6 @@ it("enforces a selected command deadline and completes a corrected retry after c "-e", 'setTimeout(() => process.stdout.write("1.0.42\\n"), 250)', ], - expectedCommand: null, }); } return launchWabtOutcome(input, "success", { @@ -1237,7 +1232,6 @@ it("reports WABT output beyond the budget as a resource constraint with a bounde hostEnvironment: nodeHostEnvironment(input.hostEnvironment), command: process.execPath, arguments: ["-e", script], - expectedCommand: null, }); }, ); @@ -1302,7 +1296,6 @@ it("does not attribute wasm-objdump output to a wasm2wat launch failure", async hostEnvironment: nodeHostEnvironment(input.hostEnvironment), command: process.execPath, arguments: ["-e", script], - expectedCommand: null, }); }, ); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index e555444816..9c6b61ff92 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -1,7 +1,8 @@ import { randomUUID, createHash } from "node:crypto"; -import { access, realpath, writeFile } from "node:fs/promises"; +import { access, writeFile } from "node:fs/promises"; import { constants } from "node:fs"; import { isAbsolute, join } from "node:path"; +import { appendCallerPath } from "../filesystem/callerPath.js"; import type { WasmArtifactExecutionOptions, WasmArtifactExecution, @@ -45,6 +46,12 @@ import { import { err, ok, type Result } from "../domain/result.js"; import { runOwnedCommand } from "../process/OwnedCommand.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { artifactCommandOutput } from "../process/ArtifactAnalysisResources.js"; +import { + assertArtifactCommandPath, + selectArtifactCommandPath, + type ArtifactCommandPath, +} from "../process/ArtifactCommandPath.js"; import { WabtRuntime } from "./WabtRuntime.js"; import { WABT_PROVIDER_IDENTITY, @@ -212,18 +219,21 @@ export class WabtArtifactProvider implements WasmArtifactPort { sha256: string; version_banner: string; arguments: string[]; + selection: ArtifactCommandPath; }[] = []; for (const tool of [ "wasm-validate", "wasm-objdump", "wasm2wat", ] as const) { - const path = await realpath( - join( + const selection = await selectArtifactCommandPath( + appendCallerPath( directory, `${tool}${process.platform === "win32" ? ".exe" : ""}`, ), + options?.signal, ); + const path = selection.resolvedPath; await access(path, constants.X_OK); const binary = await readStableArtifact( path, @@ -235,6 +245,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { commands.push({ tool, path, + selection, sha256: binary.sha256, version_banner: "", arguments: @@ -265,6 +276,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { OPERATION, input.path, "Private WASM snapshot bytes changed.", + { capturedOutput }, ); }; const execute = async ( @@ -273,13 +285,16 @@ export class WabtArtifactProvider implements WasmArtifactPort { acceptNonZeroExit = false, ) => { capturedOutput = undefined; + await assertArtifactCommandPath(command.selection, OPERATION, options); await verifySnapshot(); const snapshot = await runOwnedCommand( { - command: command.path, + command: command.selection.invocationPath, arguments: args, cwd: root.path, - expectedCommand: command.path, + // Trusted caller tools may be interpreter shims; run-token, parent + // and process-group identity own cleanup after their exec handoff. + expectedCommand: null, runId: `rea-wabt-${randomUUID()}`, hostEnvironment: this.environment, }, @@ -295,6 +310,11 @@ export class WabtArtifactProvider implements WasmArtifactPort { ...(this.launcher === undefined ? {} : { launcher: this.launcher }), }, ); + capturedOutput = artifactCommandOutput(snapshot); + await assertArtifactCommandPath(command.selection, OPERATION, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + capturedOutput, + }); const current = await readStableArtifact( command.path, WABT_LIMITS.toolBytes, @@ -306,6 +326,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { throw new AnalysisOutputError( OPERATION, "Configured WABT executable changed during inspection.", + { capturedOutput }, ); await verifySnapshot(); return snapshot; @@ -347,10 +368,13 @@ export class WabtArtifactProvider implements WasmArtifactPort { source: "https://github.com/WebAssembly/wabt", release: WABT_VERSION, source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48", - commands, + commands: commands.map( + ({ selection: _selection, ...command }) => command, + ), timeout_ms: commandTimeoutMs, output_budget_bytes: WABT_LIMITS.outputBytes, }; + capturedOutput = undefined; phase = "artifact"; const selected = await readSelectedInput(input.path, (snapshot) => { const observed = { @@ -457,11 +481,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { ], }, ), - capturedOutput: { - stdout: validation.stdout.text, - stderr: validation.stderr.text, - truncated: false, - }, + capturedOutput: artifactCommandOutput(validation), }, [ { @@ -488,11 +508,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { OPERATION, "Unexpected WABT diagnostic output.", { - capturedOutput: { - stdout: output.stdout.text, - stderr: output.stderr.text, - truncated: false, - }, + capturedOutput: artifactCommandOutput(output), }, ); if (warnings.length > 0) @@ -500,23 +516,14 @@ export class WabtArtifactProvider implements WasmArtifactPort { }; retainWarnings("wasm-validate", validation); const dump = await execute(objdump, objdump.arguments); - capturedOutput = { - stdout: dump.stdout.text, - stderr: dump.stderr.text, - truncated: false, - }; retainWarnings("wasm-objdump", dump); const wat = await execute(decode, decode.arguments); - capturedOutput = { - stdout: wat.stdout.text, - stderr: wat.stderr.text, - truncated: false, - }; retainWarnings("wasm2wat", wat); if (!wat.stdout.text.startsWith("(module")) throw new AnalysisOutputError( OPERATION, "Unexpected WABT decoding output.", + { capturedOutput }, ); const { imports, @@ -545,11 +552,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { optionalLimitations.push( "WABT reported optional custom-section interpretation warnings; exact warning output is retained in custom_section_warnings.", ); - capturedOutput = { - stdout: dump.stdout.text, - stderr: dump.stderr.text, - truncated: false, - }; + capturedOutput = artifactCommandOutput(dump); const { sections, headers, details } = parseWabtOutput( dump.stdout.text, selected.bytes.length, @@ -606,6 +609,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { { commandTimeoutMs, inputField: artifactInput.field, + observedOutput: capturedOutput, ...(versionProbe === undefined ? {} : { versionProbe }), }, ); @@ -614,19 +618,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { if (partial !== undefined) failure.retainPartialObservation(partial); } runtime?.retainCommandFailure(cause, failure, this.#resources); - result = err( - failure instanceof AnalysisOutputError && - failure.capturedOutput === undefined && - capturedOutput !== undefined - ? new AnalysisOutputError(OPERATION, failure.reason, { - cause: failure, - capturedOutput, - cleanup: failure.cleanup, - diagnostics: failure.diagnostics, - partialObservation: failure.partialObservation, - }).bindCleanupDependencies([failure]) - : failure, - ); + result = err(failure); } if (runtime !== undefined) { const cleanup = await this.#resources.release({ diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 1a810d54ee..7348621b18 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -6,7 +6,10 @@ import { } from "../artifacts/ArtifactFileOperation.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisCancelledError, @@ -48,6 +51,7 @@ type WabtFailureContext = { readonly commandTimeoutMs: number; readonly versionProbe?: WabtVersionProbe; readonly inputField?: readonly (string | number)[]; + readonly observedOutput?: AnalysisCapturedOutput | undefined; }; /** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */ @@ -99,8 +103,12 @@ const classifyWabtFailure = ( if (cause instanceof AnalysisError) return cause; const artifactCleanup = cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; - const cleanupOptions = - artifactCleanup === undefined ? {} : { cleanup: artifactCleanup }; + const artifactOptions = { + ...(artifactCleanup === undefined ? {} : { cleanup: artifactCleanup }), + ...(execution?.observedOutput === undefined + ? {} + : { capturedOutput: execution.observedOutput }), + }; const reason = cause instanceof Error ? cause.message : String(cause); if ( (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || @@ -111,7 +119,7 @@ const classifyWabtFailure = ( cause instanceof ArtifactReaderFailure ? artifactFileCancellationCause(cause) : cause, - ...cleanupOptions, + ...artifactOptions, }); if (cause instanceof OwnedCommandFailure) { const capturedOutput = @@ -224,7 +232,7 @@ const classifyWabtFailure = ( cause.message, { cause, - ...cleanupOptions, + ...artifactOptions, userMessage: `Configured WABT tools exceed the ${String(WABT_LIMITS.toolBytes)}-byte tool allowance. This release has no runtime override.`, remediationAction: `The WABT tool allowance is ${String(WABT_LIMITS.toolBytes)} bytes. This release has no runtime override. command_timeout_ms and REA_WABT_BIN_DIRECTORY do not raise it. Select tools within the allowance. Do not repeat the unchanged call.`, }, @@ -236,7 +244,7 @@ const classifyWabtFailure = ( reason, { cause, - ...cleanupOptions, + ...artifactOptions, userMessage: reason, remediationAction: wabtConfigurationRecoveryAction(cause), }, @@ -254,7 +262,7 @@ const classifyWabtFailure = ( { maximum_input_bytes: WABT_LIMITS.inputBytes }, { cause, - ...cleanupOptions, + ...artifactOptions, retryable: false, remediationAction: `Select a suitable stable artifact within the reported ${String(WABT_LIMITS.inputBytes)}-byte input byte allowance. This release has no runtime override. command_timeout_ms does not raise it. Do not truncate required bytes; retrying the unchanged oversized input will fail again.`, }, @@ -268,7 +276,7 @@ const classifyWabtFailure = ( "inspect_wasm_artifact", path, reason, - { cause, ...cleanupOptions }, + { cause, ...artifactOptions }, ); if ( phase === "artifact" && @@ -280,12 +288,12 @@ const classifyWabtFailure = ( "inspect_wasm_artifact", path, cause.code, - { cause, ...cleanupOptions }, + { cause, ...artifactOptions }, ); if (phase === "artifact") return new AnalysisInputError( "inspect_wasm_artifact", - { cause, ...cleanupOptions }, + { cause, ...artifactOptions }, [ { path: [...(execution?.inputField ?? ["path"])], @@ -296,6 +304,6 @@ const classifyWabtFailure = ( ); return new AnalysisOutputError("inspect_wasm_artifact", reason, { cause, - ...cleanupOptions, + ...artifactOptions, }); }; diff --git a/tests/acceptance/tools/wabtLauncherIdentity.test.ts b/tests/acceptance/tools/wabtLauncherIdentity.test.ts new file mode 100644 index 0000000000..57a26e24f0 --- /dev/null +++ b/tests/acceptance/tools/wabtLauncherIdentity.test.ts @@ -0,0 +1,323 @@ +import { createHash } from "node:crypto"; +import { + access, + chmod, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { wasmArtifactSchema } from "../../../src/domain/wasm/wasmArtifact.js"; +import { observeProcessStartIdentity } from "../../../src/process/ProcessOwnershipObservation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const tools = ["wasm-validate", "wasm-objdump", "wasm2wat"] as const; +const commandTraceSchema = z.object({ + tool: z.enum(tools), + pid: z.int().positive(), + cwd: z.string(), + run_id: z.string(), + version: z.boolean(), + snapshot_sha256: z.string().nullable(), +}); +const fixtureUrl = (name: string) => + fileURLToPath(new URL(`../../fixtures/wasm/${name}`, import.meta.url)); + +/** Real shell/Node launchers with authored WABT output, not installed WABT engines. */ +const createFixture = async () => { + const root = await createTestTempDirectory("rea-wabt-launcher-"); + const artifact = join(root, "selected module.wasm"); + const hold = join(root, "hold-decoder"); + const trace = join(root, "commands.jsonl"); + const scratch = join(root, "scratch"); + const runner = join(root, "wabt-runner.cjs"); + await mkdir(scratch); + const bytes = Buffer.from( + "0061736d01000000010401600000020b0103656e7603666f6f0000030201000503010001071002066d656d6f727902000372756e00010a0601040001010b000c04746573747061796c6f6164", + "hex", + ); + await writeFile(artifact, bytes); + await writeFile( + runner, + ` + const fs = require("node:fs"); + const tool = process.argv[2]; + const version = process.argv[3] === "--version"; + const snapshot_sha256 = version ? null : require("node:crypto").createHash("sha256").update(fs.readFileSync("module.wasm")).digest("hex"); + fs.appendFileSync(process.env.REA_WABT_TEST_TRACE, JSON.stringify({ tool, pid: process.pid, version, cwd: process.cwd(), run_id: process.env.REA_PROCESS_RUN_ID, snapshot_sha256 }) + "\\n"); + if (version) { + process.stdout.write("1.0.42\\n"); + if (tool === "wasm-validate" && process.env.REA_WABT_TEST_REVOKE_COMMAND) + fs.chmodSync(process.env.REA_WABT_TEST_REVOKE_COMMAND, 0); + } + else if (tool === "wasm-objdump") { process.stdout.write(fs.readFileSync(process.env.REA_WABT_TEST_DUMP)); } + else if (tool === "wasm2wat") { + const decode = () => process.stdout.write(fs.readFileSync(process.env.REA_WABT_TEST_WAT)); + if (fs.existsSync(process.env.REA_WABT_TEST_HOLD)) { + setTimeout(decode, 4000); + } else { decode(); } + } + `, + ); + const install = async (directory: string, decoy = false) => { + await mkdir(directory, { recursive: true }); + for (const tool of tools) + await writeFile( + join(directory, tool), + decoy + ? "#!/bin/sh\nprintf 'unselected-decoy\\n'\n" + : `#!/bin/sh\nexec "$REA_WABT_TEST_NODE" "$REA_WABT_TEST_RUNNER" "${tool}" "$@"\n`, + { mode: 0o700 }, + ); + }; + const environment = { + ...process.env, + REA_WABT_TEST_NODE: process.execPath, + REA_WABT_TEST_RUNNER: runner, + REA_WABT_TEST_DUMP: fixtureUrl("objdump.txt"), + REA_WABT_TEST_WAT: fixtureUrl("decoded.wat"), + REA_WABT_TEST_HOLD: hold, + REA_WABT_TEST_TRACE: trace, + TMPDIR: scratch, + TMP: scratch, + TEMP: scratch, + }; + return { + root, + artifact, + bytes, + hold, + trace, + scratch, + install, + environment, + }; +}; + +cliTest.skipIf(process.platform === "win32")( + "cleans up real selected WABT interpreter shims after a command deadline", + async ({ cli }) => { + const fixture = await createFixture(); + const selected = join(fixture.root, "selected tools"); + await fixture.install(selected); + const invoke = (timeout: number) => + cli.run({ + arguments: [ + "inspect-wasm-artifact", + fixture.artifact, + "--command-timeout-ms", + String(timeout), + "--json", + ], + environment: { + ...fixture.environment, + REA_WABT_BIN_DIRECTORY: selected, + }, + }); + const control = await invoke(10_000); + expect(control.exitCode, `${control.stderr}\n${control.stdout}`).toBe(0); + const evidence = parseEvidence(control.json); + const report = wasmArtifactSchema.parse(evidence.normalized_result); + expect(report.artifact.sha256).toBe( + createHash("sha256").update(fixture.bytes).digest("hex"), + ); + expect(report.validation).toBe("valid"); + const trace = (await readFile(fixture.trace, "utf8")) + .trim() + .split("\n") + .map((line) => commandTraceSchema.parse(JSON.parse(line))); + expect(trace.length).toBeGreaterThan(0); + expect(new Set(trace.map(({ cwd }) => cwd)).size).toBe(1); + for (const command of trace) { + expect(command.cwd.startsWith(join(fixture.scratch, "rea-wabt-"))).toBe( + true, + ); + expect(command.run_id).toMatch(/^rea-wabt-/u); + expect(command.snapshot_sha256).toBe( + command.version ? null : report.artifact.sha256, + ); + } + expect(await readdir(fixture.scratch)).toEqual([]); + + await writeFile(fixture.hold, "hold this invocation"); + try { + const invocation = await invoke(2_000); + expect(invocation.exitCode, invocation.stderr).toBe(1); + const failure = analysisErrorProjectionSchema.parse(invocation.json); + expect(failure, invocation.stdout).toMatchObject({ + code: "provider_timeout", + details: { + partial_observation: { + normalized_result: { + validation: "valid", + artifact: { sha256: report.artifact.sha256 }, + }, + }, + }, + }); + expect(await readdir(fixture.scratch)).toEqual([]); + expect((await readFile(fixture.artifact)).equals(fixture.bytes)).toBe( + true, + ); + } finally { + // The authored shim self-exits even on the unfixed cleanup-refusal path. + // Observe its actual process lifetime; token-owned cleanup can use SIGKILL. + const commands = (await readFile(fixture.trace, "utf8")) + .trim() + .split("\n") + .map((line) => commandTraceSchema.parse(JSON.parse(line))); + const decoder = commands.findLast( + (command) => command.tool === "wasm2wat" && !command.version, + ); + if (decoder === undefined) + throw new Error("No decoder process was observed"); + await expect + .poll( + () => + observeProcessStartIdentity(decoder.pid, undefined, decoder.run_id), + { timeout: 6000 }, + ) + .toBeUndefined(); + } + }, +); + +cliTest.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "retains completed WABT version output when its post-command fingerprint read is denied", + async ({ cli }) => { + const fixture = await createFixture(); + const selected = join(fixture.root, "tools"); + await fixture.install(selected); + const revoked = join(selected, "wasm-validate"); + try { + const invocation = await cli.run({ + arguments: ["inspect-wasm-artifact", fixture.artifact, "--json"], + environment: { + ...fixture.environment, + REA_WABT_BIN_DIRECTORY: selected, + REA_WABT_TEST_REVOKE_COMMAND: revoked, + }, + }); + expect(invocation.exitCode, invocation.stderr).toBe(1); + const failure = analysisErrorProjectionSchema.parse(invocation.json); + expect(failure, invocation.stdout).toMatchObject({ + code: "capability_unavailable", + details: { + captured_output: { + stdout: "1.0.42\n", + stderr: "", + truncated: false, + exit_code: 0, + signal: null, + }, + }, + }); + expect((await readFile(fixture.artifact)).equals(fixture.bytes)).toBe( + true, + ); + expect(await readdir(fixture.scratch)).toEqual([]); + } finally { + await chmod(revoked, 0o700); + } + }, +); + +cliTest.skipIf(process.platform === "win32")( + "launches selected WABT symlinks with their own sibling resources and fingerprints canonical tools", + async ({ cli }) => { + const fixture = await createFixture(); + const targets = join(fixture.root, "canonical tools"); + const selected = join(fixture.root, "selected links"); + await fixture.install(targets); + await mkdir(selected); + await writeFile( + join(selected, "wabt-environment"), + "# selected installation resource\n", + ); + for (const tool of tools) { + const target = join(targets, tool); + const script = await readFile(target, "utf8"); + await writeFile( + target, + script.replace( + "#!/bin/sh\n", + '#!/bin/sh\n. "${0%/*}/wabt-environment"\n', + ), + ); + await symlink(target, join(selected, tool)); + } + const invocation = await cli.run({ + arguments: ["inspect-wasm-artifact", fixture.artifact, "--json"], + environment: { ...fixture.environment, REA_WABT_BIN_DIRECTORY: selected }, + }); + expect( + invocation.exitCode, + `${invocation.stderr}\n${invocation.stdout}`, + ).toBe(0); + const report = wasmArtifactSchema.parse( + parseEvidence(invocation.json).normalized_result, + ); + expect(report.tool_profile.commands.map(({ path }) => path)).toEqual( + tools.map((tool) => join(targets, tool)), + ); + for (const command of report.tool_profile.commands) + expect(command.sha256).toBe( + createHash("sha256") + .update(await readFile(command.path)) + .digest("hex"), + ); + expect((await readFile(fixture.artifact)).equals(fixture.bytes)).toBe(true); + expect(await readdir(fixture.scratch)).toEqual([]); + }, +); + +cliTest.skipIf(process.platform === "win32")( + "selects a WABT directory through literal symlink and parent traversal", + async ({ cli }) => { + const fixture = await createFixture(); + const intended = join(fixture.root, "intended"); + await mkdir(join(intended, "nested"), { recursive: true }); + await symlink(join(intended, "nested"), join(fixture.root, "alias")); + const selected = join(intended, "tools"); + await fixture.install(selected); + await fixture.install(join(fixture.root, "tools"), true); + const invocation = await cli.run({ + arguments: ["inspect-wasm-artifact", fixture.artifact, "--json"], + environment: { + ...fixture.environment, + REA_WABT_BIN_DIRECTORY: `${fixture.root}/alias/../tools`, + }, + }); + expect( + invocation.exitCode, + `${invocation.stderr}\n${invocation.stdout}`, + ).toBe(0); + const report = wasmArtifactSchema.parse( + parseEvidence(invocation.json).normalized_result, + ); + expect(report.tool_profile.commands.map(({ path }) => path)).toEqual( + tools.map((tool) => join(selected, tool)), + ); + for (const command of report.tool_profile.commands) + expect(command.sha256).toBe( + createHash("sha256") + .update(await readFile(command.path)) + .digest("hex"), + ); + expect(report.artifact.path).toBe(fixture.artifact); + expect((await readFile(fixture.artifact)).equals(fixture.bytes)).toBe(true); + expect(await readdir(fixture.scratch)).toEqual([]); + await expect( + access(join(fixture.root, "tools", "wasm-validate")), + ).resolves.toBeUndefined(); + }, +); diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 697d2e0e5a..497e87148c 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -7,8 +7,19 @@ * refusal paths, and cancellation before spawn. */ import { createHash } from "node:crypto"; -import { open, readFile, realpath, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + open, + readFile, + realpath, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -47,6 +58,72 @@ const providerOver = (): PcapProvider => { return provider; }; +it.skipIf(isWindows)( + "preserves selected Wireshark wrappers and re-admits changed symlink targets after success", + async () => { + const directory = await createTestTempDirectory( + "rea-pcap-selected-wrapper-", + ); + const bin = join(directory, "bin"), + libexec = join(directory, "libexec"); + await mkdir(bin); + await mkdir(libexec); + const source = join(libexec, "wrapper"), + replacement = join(libexec, "replacement"); + const script = + '#!/bin/sh\nselected="${0%/*}/${0##*/}"\nIFS= read -r banner < "$selected.version" || exit 19\nprintf "%s\\n" "$banner"\nif [ -f "$selected.retarget" ]; then\n IFS= read -r target < "$selected.retarget"\n /bin/ln -sf "$target" "$0"\nfi\n'; + for (const path of [source, replacement]) { + await writeFile(path, script); + await chmod(path, 0o755); + } + const tshark = join(bin, "tshark"), + capinfos = join(bin, "capinfos"); + for (const [path, name] of [ + [tshark, "TShark"], + [capinfos, "Capinfos"], + ] as const) { + await symlink(source, path); + await writeFile(`${path}.version`, `${name} (Wireshark) 4.6.6-stub\n`); + } + expect( + (await promisify(execFile)(tshark, ["--version"])).stdout.trim(), + ).toBe("TShark (Wireshark) 4.6.6-stub"); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tshark, + REA_CAPINFOS_COMMAND: capinfos, + }, + }); + onTestFinished(() => provider.close()); + const request = { operation: "inspect_pcap_client", input: {} } as const; + const first = await evidenceResult(provider, request); + expect(first.client).toMatchObject({ + tshark_command: tshark, + tshark_resolved_path: await realpath(source), + capinfos_command: capinfos, + }); + await writeFile(`${tshark}.retarget`, `${replacement}\n`); + const changed = await provider.execute(request); + if (changed.ok) throw new Error("Changed selected wrapper was accepted"); + expect(projectAnalysisError(changed.error)).toMatchObject({ + code: "artifact_changed", + details: { + path: tshark, + captured_output: { stdout: "TShark (Wireshark) 4.6.6-stub\n" }, + }, + }); + await unlink(`${tshark}.retarget`); + const fresh = await evidenceResult(provider, request); + expect(fresh.client).toMatchObject({ + tshark_command: tshark, + tshark_resolved_path: await realpath(replacement), + }); + expect(first.client).toMatchObject({ + tshark_resolved_path: await realpath(source), + }); + }, +); + const evidenceResult = async ( provider: PcapProvider, request: PcapRequest, diff --git a/tests/boundary/process/artifactSelectedCommand.test.ts b/tests/boundary/process/artifactSelectedCommand.test.ts new file mode 100644 index 0000000000..f3a8675338 --- /dev/null +++ b/tests/boundary/process/artifactSelectedCommand.test.ts @@ -0,0 +1,119 @@ +import { + chmod, + mkdir, + readdir, + realpath, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; + +import { PacketCaptureService } from "../../../src/application/captures/PacketCaptureService.js"; +import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js"; +import { TsharkCaptureProvider } from "../../../src/captures/TsharkCaptureProvider.js"; +import { SigrokRecordingProvider } from "../../../src/signals/SigrokRecordingProvider.js"; +import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import type { LinuxResourceLimits } from "../../../src/process/LinuxResourceLimits.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Real children and filesystem, with simulated Linux admission/limits. These +// scripts do not establish native TShark, sigrok or prlimit behavior. +it.skipIf(process.platform === "win32").each(["tshark", "sigrok"] as const)( + "executes selected %s and limiter wrappers, retaining identity failure output after success", + async (kind) => { + const directory = await createTestTempDirectory("rea-selected-producer-"); + const bin = join(directory, "bin"), + libexec = join(directory, "libexec"); + await mkdir(bin); + await mkdir(libexec); + const command = join(bin, kind), + limiter = join(bin, "prlimit"); + const commandSource = join(libexec, "producer"), + limiterSource = join(libexec, "limiter"); + const replacement = join(libexec, "replacement"); + const script = + '#!/bin/sh\nselected="${0%/*}/${0##*/}"\nIFS= read -r role < "$selected.role" || exit 19\nif [ "$role" = limiter ]; then\n if [ -f "$selected.retarget" ]; then\n IFS= read -r target < "$selected.retarget"\n /bin/ln -sf "$target" "$0"\n fi\n while [ "$1" != -- ]; do shift; done\n shift\n exec "$@"\nfi\nif [ "$1" = --version ]; then\n if [ "$role" = tshark ]; then printf "TShark (Wireshark) 4.6.9\\n"; else printf "sigrok-cli 0.7.2\\nlibsigrokdecode 0.5.3\\n"; fi\nelse\n if [ "$role" = tshark ]; then printf "P\\tInternet Protocol\\tip\\n"; else printf "Supported protocol decoders:\\n uart UART\\n"; fi\nfi\n'; + for (const path of [commandSource, limiterSource, replacement]) { + await writeFile(path, script); + await chmod(path, 0o755); + } + await symlink(commandSource, command); + await symlink(limiterSource, limiter); + await writeFile(`${command}.role`, `${kind}\n`); + await writeFile(`${limiter}.role`, "limiter\n"); + const environment = { + ...process.env, + ...(kind === "tshark" + ? { REA_TSHARK_COMMAND: command, REA_TSHARK_PRLIMIT_COMMAND: limiter } + : { REA_SIGROK_COMMAND: command, REA_SIGROK_PRLIMIT_COMMAND: limiter }), + }; + const runtime = () => + PrivateRuntimeRoot.create({ parent: directory, prefix: "workspace-" }); + const limits = async (configured: LinuxResourceLimits) => configured; + const provider = + kind === "tshark" + ? new TsharkCaptureProvider( + environment, + undefined, + runtime, + "linux", + readStableArtifact, + limits, + ) + : new SigrokRecordingProvider( + environment, + undefined, + runtime, + "linux", + readStableArtifact, + limits, + ); + onTestFinished(() => provider.close()); + const inspect = + provider instanceof TsharkCaptureProvider + ? () => new PacketCaptureService(provider).inspectFields({}) + : () => new SignalRecordingService(provider).inspectDecoders({}); + const first = await inspect(); + if (!first.ok) throw first.error; + expect(first.value.normalized_result).toMatchObject({ + tool: { configured_path: command, path: await realpath(commandSource) }, + resource_limiter: { + configured_path: limiter, + path: await realpath(limiterSource), + arguments: expect.arrayContaining([command]), + }, + }); + // Identical bytes at another physical path must not pass the current + // invocation's identity verification, even though the producer succeeds. + await writeFile(`${limiter}.retarget`, `${replacement}\n`); + const changed = await inspect(); + if (changed.ok) throw new Error("Retargeted limiter was accepted"); + expect(changed.error).toMatchObject({ + _tag: "AnalysisArtifactChangedError", + path: limiter, + capturedOutput: { + stdout: + kind === "tshark" + ? "TShark (Wireshark) 4.6.9\n" + : "sigrok-cli 0.7.2\nlibsigrokdecode 0.5.3\n", + }, + }); + await unlink(`${limiter}.retarget`); + const fresh = await inspect(); + if (!fresh.ok) throw fresh.error; + expect(fresh.value.normalized_result).toMatchObject({ + resource_limiter: { path: await realpath(replacement) }, + }); + expect(first.value.normalized_result).toMatchObject({ + resource_limiter: { path: await realpath(limiterSource) }, + }); + expect( + (await readdir(directory)).filter((name) => + name.startsWith("workspace-"), + ), + ).toEqual([]); + }, +); diff --git a/tests/boundary/providers/native/lldbLauncherBoundary.test.ts b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts new file mode 100644 index 0000000000..b0b106f3fb --- /dev/null +++ b/tests/boundary/providers/native/lldbLauncherBoundary.test.ts @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; +import { + access, + chmod, + mkdir, + readFile, + realpath, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +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 { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js"; +import { observeProcessStartIdentity } from "../../../../src/process/ProcessOwnershipObservation.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const readySchema = z.object({ + pid: z.int().positive(), + run_id: z.string().min(1), + config_path: z.string(), +}); +const shellArgument = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +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 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( + child, + `import { readFileSync, writeFileSync } from 'node:fs'; +const command = process.argv.find(value => value.startsWith('rea_trace ')); +const configPath = command.slice('rea_trace '.length); +const config = JSON.parse(readFileSync(configPath, 'utf8')); +writeFileSync(config.result_path, JSON.stringify({ + status: 'launch-error', error: 'fixture deliberately launches no target', + target_process_created: false, target_process_exit_observed: true +})); +for (const path of [config.observation_path, config.stdout_capture_path, config.stderr_capture_path]) + writeFileSync(path, ''); +writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ + pid: process.pid, run_id: process.env.REA_PROCESS_RUN_ID, config_path: configPath +})); +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 selected = await resolveXcrunTool("lldb", environment); + if (!selected.ok) throw selected.error; + expect(selected.value).toEqual({ + path: await realpath(launcher), + sha256: createHash("sha256").update(launcherText).digest("hex"), + }); + // Production discovery, acquisition and native process ownership terminate + // the real child reached through this caller-selected external toolchain. + const tracer = new LldbCallTracer(environment); + const controller = new AbortController(); + const reason = new Error("caller cancels the acquired wrapper fixture"); + let started: z.infer | undefined; + try { + const pending = tracer.trace( + { + executable: "/fixture/target-is-never-launched", + architecture: "arm64", + expectedSha256: "b".repeat(64), + input: nativeCallObservationInputSchema.parse({ + breakpoints: [{ kind: "function", name: "main" }], + duration_ms: 100, + }), + }, + controller.signal, + ); + await expect + .poll( + async () => { + started = readySchema.parse( + JSON.parse(await readFile(readyPath, "utf8")), + ); + return started.run_id; + }, + { timeout: 5_000 }, + ) + .toEqual(expect.any(String)); + if (started === undefined) throw new Error("Wrapper did not start"); + const identity = await observeProcessStartIdentity( + started.pid, + undefined, + started.run_id, + ); + expect(identity?.state).toBe("readable"); + controller.abort(reason); + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected caller cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + }); + expect(await observeProcessStartIdentity(started.pid)).toBeUndefined(); + await expect(access(dirname(started.config_path))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + controller.abort(reason); + const closed = await tracer.close(); + if (!closed.ok && started !== undefined) { + // The unpatched implementation retains its exact owner until the safe + // fixture self-exits. Do not bypass ownership with an unverified PID kill. + const pid = started.pid; + await expect + .poll(async () => await observeProcessStartIdentity(pid), { + timeout: 12_000, + }) + .toBeUndefined(); + expect((await tracer.close()).ok).toBe(true); + } else expect(closed.ok).toBe(true); + } + }, + 15_000, +);