mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(process): preserve selected launch paths and completed command output
This commit is contained in:
1 parent
86e7cd9b48
commit
eb51eb6915
12 files changed
+882
-88
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+30
-14
@@ -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<PcapSnapshot>();
|
||||
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,
|
||||
|
||||
@@ -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<ArtifactCommandPath> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
+19
-11
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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"),
|
||||
`<?xml version="1.0"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleIdentifier</key><string>org.rea.audit.lldb</string>
|
||||
<key>CompatibilityVersion</key><integer>2</integer>
|
||||
</dict></plist>`,
|
||||
);
|
||||
// This producer launches no debugger or target. Its delayed self-exit also
|
||||
// bounds the baseline regression's deliberately refused cleanup.
|
||||
await writeFile(
|
||||
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<typeof readySchema> | 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,
|
||||
);
|
||||
Reference in new issue
Block a user