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 { parsePacketOutput } from "./parsePacketOutput.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
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 { isAbsolute, join } from "node:path";
|
||||||
import {
|
import {
|
||||||
createAnalysisExecution,
|
createAnalysisExecution,
|
||||||
@@ -32,6 +32,10 @@ import {
|
|||||||
type ArtifactAnalysisRuntime,
|
type ArtifactAnalysisRuntime,
|
||||||
} from "../process/ArtifactAnalysisResources.js";
|
} from "../process/ArtifactAnalysisResources.js";
|
||||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||||
|
import {
|
||||||
|
assertArtifactCommandPath,
|
||||||
|
selectArtifactCommandPath,
|
||||||
|
} from "../process/ArtifactCommandPath.js";
|
||||||
import {
|
import {
|
||||||
AnalysisError,
|
AnalysisError,
|
||||||
type AnalysisCapturedOutput,
|
type AnalysisCapturedOutput,
|
||||||
@@ -155,7 +159,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`,
|
`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);
|
await access(command, constants.X_OK);
|
||||||
const toolSnapshot = await this.#resources.read(
|
const toolSnapshot = await this.#resources.read(
|
||||||
command,
|
command,
|
||||||
@@ -167,7 +175,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||||||
this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||||
if (!isAbsolute(configuredLimiter))
|
if (!isAbsolute(configuredLimiter))
|
||||||
throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute.");
|
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);
|
await access(limiter, constants.X_OK);
|
||||||
const limiterIdentity = await this.#resources.read(
|
const limiterIdentity = await this.#resources.read(
|
||||||
limiter,
|
limiter,
|
||||||
@@ -175,7 +187,10 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
effectiveLimits = await this.#resources.readLimits(limits);
|
effectiveLimits = await this.#resources.readLimits(limits);
|
||||||
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
|
const limitArguments = prlimitArgumentVector(
|
||||||
|
effectiveLimits,
|
||||||
|
selectedCommand.invocationPath,
|
||||||
|
);
|
||||||
phase = "workspace";
|
phase = "workspace";
|
||||||
root = await this.createRuntime();
|
root = await this.createRuntime();
|
||||||
this.#resources.retain(root, undefined);
|
this.#resources.retain(root, undefined);
|
||||||
@@ -184,9 +199,15 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||||||
await mkdir(configurationPath, { mode: 0o700 });
|
await mkdir(configurationPath, { mode: 0o700 });
|
||||||
const execute = async (args: readonly string[]) => {
|
const execute = async (args: readonly string[]) => {
|
||||||
capturedOutput = undefined;
|
capturedOutput = undefined;
|
||||||
|
await assertArtifactCommandPath(selectedCommand, operation, {
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
await assertArtifactCommandPath(selectedLimiter, operation, {
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
const output = await runOwnedCommand(
|
const output = await runOwnedCommand(
|
||||||
{
|
{
|
||||||
command: limiter,
|
command: selectedLimiter.invocationPath,
|
||||||
arguments: [...limitArguments, ...args],
|
arguments: [...limitArguments, ...args],
|
||||||
...(root === undefined ? {} : { cwd: root.path }),
|
...(root === undefined ? {} : { cwd: root.path }),
|
||||||
expectedCommand: null,
|
expectedCommand: null,
|
||||||
@@ -209,6 +230,12 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
capturedOutput = artifactCommandOutput(output);
|
capturedOutput = artifactCommandOutput(output);
|
||||||
|
await assertArtifactCommandPath(selectedCommand, operation, {
|
||||||
|
capturedOutput,
|
||||||
|
});
|
||||||
|
await assertArtifactCommandPath(selectedLimiter, operation, {
|
||||||
|
capturedOutput,
|
||||||
|
});
|
||||||
const current = await this.#resources.readCommandAfterRun(
|
const current = await this.#resources.readCommandAfterRun(
|
||||||
command,
|
command,
|
||||||
{ path: limiter, sha256: limiterIdentity.sha256 },
|
{ path: limiter, sha256: limiterIdentity.sha256 },
|
||||||
|
|||||||
@@ -1404,7 +1404,9 @@ const runLldb = async (
|
|||||||
command: executable,
|
command: executable,
|
||||||
arguments: arguments_,
|
arguments: arguments_,
|
||||||
runId: randomUUID(),
|
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,
|
signal: deadline.signal,
|
||||||
hostEnvironment: options.environment,
|
hostEnvironment: options.environment,
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-14
@@ -54,7 +54,11 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|||||||
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
|
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
|
||||||
import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
|
import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
|
||||||
import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.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 = {
|
export const PCAP_PROVIDER_IDENTITY = {
|
||||||
id: "pcap",
|
id: "pcap",
|
||||||
@@ -208,7 +212,7 @@ export class PcapProvider {
|
|||||||
private readonly launcher: PcapCommandLauncher | undefined;
|
private readonly launcher: PcapCommandLauncher | undefined;
|
||||||
private readonly wiresharkIdentities = new Map<
|
private readonly wiresharkIdentities = new Map<
|
||||||
string,
|
string,
|
||||||
{ readonly path: string; readonly identity: WiresharkExecutableIdentity }
|
ArtifactCommandPath & { readonly identity: WiresharkExecutableIdentity }
|
||||||
>();
|
>();
|
||||||
private readonly snapshots = new Set<PcapSnapshot>();
|
private readonly snapshots = new Set<PcapSnapshot>();
|
||||||
private readonly snapshotFailures = new Map<
|
private readonly snapshotFailures = new Map<
|
||||||
@@ -282,21 +286,22 @@ export class PcapProvider {
|
|||||||
const selected = this.wiresharkIdentities.get(command);
|
const selected = this.wiresharkIdentities.get(command);
|
||||||
try {
|
try {
|
||||||
if (selected !== undefined) {
|
if (selected !== undefined) {
|
||||||
|
await assertArtifactCommandPath(selected, operation, { signal });
|
||||||
const before = await this.readToolIdentity(
|
const before = await this.readToolIdentity(
|
||||||
operation,
|
operation,
|
||||||
selected.path,
|
selected.resolvedPath,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
assertWiresharkExecutableIdentity(
|
assertWiresharkExecutableIdentity(
|
||||||
operation,
|
operation,
|
||||||
selected.path,
|
selected.resolvedPath,
|
||||||
selected.identity,
|
selected.identity,
|
||||||
wiresharkExecutableIdentity(before),
|
wiresharkExecutableIdentity(before),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
output = await runPcapCommand({
|
output = await runPcapCommand({
|
||||||
operation,
|
operation,
|
||||||
command: selected?.path ?? command,
|
command: selected?.invocationPath ?? command,
|
||||||
arguments: args,
|
arguments: args,
|
||||||
limits,
|
limits,
|
||||||
signal,
|
signal,
|
||||||
@@ -327,10 +332,20 @@ export class PcapProvider {
|
|||||||
});
|
});
|
||||||
commandReturned = true;
|
commandReturned = true;
|
||||||
if (selected !== undefined) {
|
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(
|
assertWiresharkExecutableIdentity(
|
||||||
operation,
|
operation,
|
||||||
selected.path,
|
selected.resolvedPath,
|
||||||
selected.identity,
|
selected.identity,
|
||||||
wiresharkExecutableIdentity(after),
|
wiresharkExecutableIdentity(after),
|
||||||
{
|
{
|
||||||
@@ -403,9 +418,9 @@ export class PcapProvider {
|
|||||||
] as const) {
|
] as const) {
|
||||||
const path = locateWiresharkTool(selected, this.environment);
|
const path = locateWiresharkTool(selected, this.environment);
|
||||||
if (path === undefined) continue;
|
if (path === undefined) continue;
|
||||||
let resolvedPath: string;
|
let commandPath: ArtifactCommandPath;
|
||||||
try {
|
try {
|
||||||
resolvedPath = await realpath(path);
|
commandPath = await selectArtifactCommandPath(path, signal);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (
|
if (
|
||||||
cause instanceof Error &&
|
cause instanceof Error &&
|
||||||
@@ -422,11 +437,11 @@ export class PcapProvider {
|
|||||||
}
|
}
|
||||||
const snapshot = await this.readToolIdentity(
|
const snapshot = await this.readToolIdentity(
|
||||||
operation,
|
operation,
|
||||||
resolvedPath,
|
commandPath.resolvedPath,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
this.wiresharkIdentities.set(selected.command, {
|
this.wiresharkIdentities.set(selected.command, {
|
||||||
path: resolvedPath,
|
...commandPath,
|
||||||
identity: wiresharkExecutableIdentity(snapshot),
|
identity: wiresharkExecutableIdentity(snapshot),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -461,7 +476,8 @@ export class PcapProvider {
|
|||||||
tshark_source: selection.tshark.source,
|
tshark_source: selection.tshark.source,
|
||||||
tshark_version: identity.tsharkVersion,
|
tshark_version: identity.tsharkVersion,
|
||||||
tshark_resolved_path:
|
tshark_resolved_path:
|
||||||
this.wiresharkIdentities.get(selection.tshark.command)?.path ?? null,
|
this.wiresharkIdentities.get(selection.tshark.command)
|
||||||
|
?.resolvedPath ?? null,
|
||||||
tshark_sha256:
|
tshark_sha256:
|
||||||
this.wiresharkIdentities.get(selection.tshark.command)?.identity
|
this.wiresharkIdentities.get(selection.tshark.command)?.identity
|
||||||
.sha256 ?? null,
|
.sha256 ?? null,
|
||||||
@@ -474,8 +490,8 @@ export class PcapProvider {
|
|||||||
capinfos_source: selection.capinfos.source,
|
capinfos_source: selection.capinfos.source,
|
||||||
capinfos_version: identity.capinfosVersion,
|
capinfos_version: identity.capinfosVersion,
|
||||||
capinfos_resolved_path:
|
capinfos_resolved_path:
|
||||||
this.wiresharkIdentities.get(selection.capinfos.command)?.path ??
|
this.wiresharkIdentities.get(selection.capinfos.command)
|
||||||
null,
|
?.resolvedPath ?? null,
|
||||||
capinfos_sha256:
|
capinfos_sha256:
|
||||||
this.wiresharkIdentities.get(selection.capinfos.command)?.identity
|
this.wiresharkIdentities.get(selection.capinfos.command)?.identity
|
||||||
.sha256 ?? null,
|
.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";
|
} from "../process/LinuxResourceLimits.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
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 { basename, isAbsolute, join } from "node:path";
|
||||||
import {
|
import {
|
||||||
createAnalysisExecution,
|
createAnalysisExecution,
|
||||||
@@ -34,6 +34,10 @@ import {
|
|||||||
type ArtifactAnalysisRuntime,
|
type ArtifactAnalysisRuntime,
|
||||||
} from "../process/ArtifactAnalysisResources.js";
|
} from "../process/ArtifactAnalysisResources.js";
|
||||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||||
|
import {
|
||||||
|
assertArtifactCommandPath,
|
||||||
|
selectArtifactCommandPath,
|
||||||
|
} from "../process/ArtifactCommandPath.js";
|
||||||
import {
|
import {
|
||||||
AnalysisError,
|
AnalysisError,
|
||||||
type AnalysisCapturedOutput,
|
type AnalysisCapturedOutput,
|
||||||
@@ -147,7 +151,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Supply absolute REA_SIGROK_COMMAND for sigrok-cli 0.7.2 with libsigrokdecode.",
|
"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);
|
await access(command, constants.X_OK);
|
||||||
const tool = await this.#resources.read(
|
const tool = await this.#resources.read(
|
||||||
command,
|
command,
|
||||||
@@ -158,7 +166,11 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
|||||||
this.environment.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
this.environment.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||||
if (!isAbsolute(configuredLimiter))
|
if (!isAbsolute(configuredLimiter))
|
||||||
throw new Error("REA_SIGROK_PRLIMIT_COMMAND must be absolute.");
|
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);
|
await access(limiter, constants.X_OK);
|
||||||
const limiterIdentity = await this.#resources.read(
|
const limiterIdentity = await this.#resources.read(
|
||||||
limiter,
|
limiter,
|
||||||
@@ -166,7 +178,10 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
|||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
effectiveLimits = await this.#resources.readLimits(LIMITS);
|
effectiveLimits = await this.#resources.readLimits(LIMITS);
|
||||||
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
|
const limitArguments = prlimitArgumentVector(
|
||||||
|
effectiveLimits,
|
||||||
|
selectedCommand.invocationPath,
|
||||||
|
);
|
||||||
phase = "workspace";
|
phase = "workspace";
|
||||||
root = await this.createRuntime();
|
root = await this.createRuntime();
|
||||||
this.#resources.retain(root, undefined);
|
this.#resources.retain(root, undefined);
|
||||||
@@ -180,9 +195,15 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
|||||||
selection?: SigrokSelection,
|
selection?: SigrokSelection,
|
||||||
) => {
|
) => {
|
||||||
capturedOutput = undefined;
|
capturedOutput = undefined;
|
||||||
|
await assertArtifactCommandPath(selectedCommand, operation, {
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
await assertArtifactCommandPath(selectedLimiter, operation, {
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
const output = await runOwnedCommand(
|
const output = await runOwnedCommand(
|
||||||
{
|
{
|
||||||
command: limiter,
|
command: selectedLimiter.invocationPath,
|
||||||
arguments: [...limitArguments, ...args],
|
arguments: [...limitArguments, ...args],
|
||||||
...(root === undefined ? {} : { cwd: root.path }),
|
...(root === undefined ? {} : { cwd: root.path }),
|
||||||
expectedCommand: null,
|
expectedCommand: null,
|
||||||
@@ -202,6 +223,12 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
capturedOutput = artifactCommandOutput(output);
|
capturedOutput = artifactCommandOutput(output);
|
||||||
|
await assertArtifactCommandPath(selectedCommand, operation, {
|
||||||
|
capturedOutput,
|
||||||
|
});
|
||||||
|
await assertArtifactCommandPath(selectedLimiter, operation, {
|
||||||
|
capturedOutput,
|
||||||
|
});
|
||||||
const current = await this.#resources.readCommandAfterRun(
|
const current = await this.#resources.readCommandAfterRun(
|
||||||
command,
|
command,
|
||||||
{ path: limiter, sha256: limiterIdentity.sha256 },
|
{ path: limiter, sha256: limiterIdentity.sha256 },
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ it.each([
|
|||||||
"-e",
|
"-e",
|
||||||
`process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`,
|
`process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`,
|
||||||
],
|
],
|
||||||
expectedCommand: null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -497,7 +496,6 @@ it.each([
|
|||||||
"-e",
|
"-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)});`,
|
`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",
|
"-e",
|
||||||
`process.stdout.write(${JSON.stringify(stdout)}); process.exit(0);`,
|
`process.stdout.write(${JSON.stringify(stdout)}); process.exit(0);`,
|
||||||
],
|
],
|
||||||
expectedCommand: null,
|
|
||||||
});
|
});
|
||||||
return launched;
|
return launched;
|
||||||
},
|
},
|
||||||
@@ -768,7 +765,6 @@ const launchWabtOutcome = async (
|
|||||||
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
|
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
arguments: ["-e", script],
|
arguments: ["-e", script],
|
||||||
expectedCommand: null,
|
|
||||||
});
|
});
|
||||||
if (!version && mode === "cancelled") {
|
if (!version && mode === "cancelled") {
|
||||||
setImmediate(() => controller.abort());
|
setImmediate(() => controller.abort());
|
||||||
@@ -793,7 +789,6 @@ it("enforces a selected command deadline and completes a corrected retry after c
|
|||||||
"-e",
|
"-e",
|
||||||
'setTimeout(() => process.stdout.write("1.0.42\\n"), 250)',
|
'setTimeout(() => process.stdout.write("1.0.42\\n"), 250)',
|
||||||
],
|
],
|
||||||
expectedCommand: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return launchWabtOutcome(input, "success", {
|
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),
|
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
arguments: ["-e", script],
|
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),
|
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
arguments: ["-e", script],
|
arguments: ["-e", script],
|
||||||
expectedCommand: null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { randomUUID, createHash } from "node:crypto";
|
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 { constants } from "node:fs";
|
||||||
import { isAbsolute, join } from "node:path";
|
import { isAbsolute, join } from "node:path";
|
||||||
|
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||||
import type {
|
import type {
|
||||||
WasmArtifactExecutionOptions,
|
WasmArtifactExecutionOptions,
|
||||||
WasmArtifactExecution,
|
WasmArtifactExecution,
|
||||||
@@ -45,6 +46,12 @@ import {
|
|||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import { runOwnedCommand } from "../process/OwnedCommand.js";
|
import { runOwnedCommand } from "../process/OwnedCommand.js";
|
||||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.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 { WabtRuntime } from "./WabtRuntime.js";
|
||||||
import {
|
import {
|
||||||
WABT_PROVIDER_IDENTITY,
|
WABT_PROVIDER_IDENTITY,
|
||||||
@@ -212,18 +219,21 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
sha256: string;
|
sha256: string;
|
||||||
version_banner: string;
|
version_banner: string;
|
||||||
arguments: string[];
|
arguments: string[];
|
||||||
|
selection: ArtifactCommandPath;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
for (const tool of [
|
for (const tool of [
|
||||||
"wasm-validate",
|
"wasm-validate",
|
||||||
"wasm-objdump",
|
"wasm-objdump",
|
||||||
"wasm2wat",
|
"wasm2wat",
|
||||||
] as const) {
|
] as const) {
|
||||||
const path = await realpath(
|
const selection = await selectArtifactCommandPath(
|
||||||
join(
|
appendCallerPath(
|
||||||
directory,
|
directory,
|
||||||
`${tool}${process.platform === "win32" ? ".exe" : ""}`,
|
`${tool}${process.platform === "win32" ? ".exe" : ""}`,
|
||||||
),
|
),
|
||||||
|
options?.signal,
|
||||||
);
|
);
|
||||||
|
const path = selection.resolvedPath;
|
||||||
await access(path, constants.X_OK);
|
await access(path, constants.X_OK);
|
||||||
const binary = await readStableArtifact(
|
const binary = await readStableArtifact(
|
||||||
path,
|
path,
|
||||||
@@ -235,6 +245,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
commands.push({
|
commands.push({
|
||||||
tool,
|
tool,
|
||||||
path,
|
path,
|
||||||
|
selection,
|
||||||
sha256: binary.sha256,
|
sha256: binary.sha256,
|
||||||
version_banner: "",
|
version_banner: "",
|
||||||
arguments:
|
arguments:
|
||||||
@@ -265,6 +276,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
OPERATION,
|
OPERATION,
|
||||||
input.path,
|
input.path,
|
||||||
"Private WASM snapshot bytes changed.",
|
"Private WASM snapshot bytes changed.",
|
||||||
|
{ capturedOutput },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const execute = async (
|
const execute = async (
|
||||||
@@ -273,13 +285,16 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
acceptNonZeroExit = false,
|
acceptNonZeroExit = false,
|
||||||
) => {
|
) => {
|
||||||
capturedOutput = undefined;
|
capturedOutput = undefined;
|
||||||
|
await assertArtifactCommandPath(command.selection, OPERATION, options);
|
||||||
await verifySnapshot();
|
await verifySnapshot();
|
||||||
const snapshot = await runOwnedCommand(
|
const snapshot = await runOwnedCommand(
|
||||||
{
|
{
|
||||||
command: command.path,
|
command: command.selection.invocationPath,
|
||||||
arguments: args,
|
arguments: args,
|
||||||
cwd: root.path,
|
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()}`,
|
runId: `rea-wabt-${randomUUID()}`,
|
||||||
hostEnvironment: this.environment,
|
hostEnvironment: this.environment,
|
||||||
},
|
},
|
||||||
@@ -295,6 +310,11 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
|
...(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(
|
const current = await readStableArtifact(
|
||||||
command.path,
|
command.path,
|
||||||
WABT_LIMITS.toolBytes,
|
WABT_LIMITS.toolBytes,
|
||||||
@@ -306,6 +326,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
throw new AnalysisOutputError(
|
throw new AnalysisOutputError(
|
||||||
OPERATION,
|
OPERATION,
|
||||||
"Configured WABT executable changed during inspection.",
|
"Configured WABT executable changed during inspection.",
|
||||||
|
{ capturedOutput },
|
||||||
);
|
);
|
||||||
await verifySnapshot();
|
await verifySnapshot();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -347,10 +368,13 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
source: "https://github.com/WebAssembly/wabt",
|
source: "https://github.com/WebAssembly/wabt",
|
||||||
release: WABT_VERSION,
|
release: WABT_VERSION,
|
||||||
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
|
source_commit: "ff0ef7e0009402740c805a9744c09b05be063e48",
|
||||||
commands,
|
commands: commands.map(
|
||||||
|
({ selection: _selection, ...command }) => command,
|
||||||
|
),
|
||||||
timeout_ms: commandTimeoutMs,
|
timeout_ms: commandTimeoutMs,
|
||||||
output_budget_bytes: WABT_LIMITS.outputBytes,
|
output_budget_bytes: WABT_LIMITS.outputBytes,
|
||||||
};
|
};
|
||||||
|
capturedOutput = undefined;
|
||||||
phase = "artifact";
|
phase = "artifact";
|
||||||
const selected = await readSelectedInput(input.path, (snapshot) => {
|
const selected = await readSelectedInput(input.path, (snapshot) => {
|
||||||
const observed = {
|
const observed = {
|
||||||
@@ -457,11 +481,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
capturedOutput: {
|
capturedOutput: artifactCommandOutput(validation),
|
||||||
stdout: validation.stdout.text,
|
|
||||||
stderr: validation.stderr.text,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -488,11 +508,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
OPERATION,
|
OPERATION,
|
||||||
"Unexpected WABT diagnostic output.",
|
"Unexpected WABT diagnostic output.",
|
||||||
{
|
{
|
||||||
capturedOutput: {
|
capturedOutput: artifactCommandOutput(output),
|
||||||
stdout: output.stdout.text,
|
|
||||||
stderr: output.stderr.text,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (warnings.length > 0)
|
if (warnings.length > 0)
|
||||||
@@ -500,23 +516,14 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
};
|
};
|
||||||
retainWarnings("wasm-validate", validation);
|
retainWarnings("wasm-validate", validation);
|
||||||
const dump = await execute(objdump, objdump.arguments);
|
const dump = await execute(objdump, objdump.arguments);
|
||||||
capturedOutput = {
|
|
||||||
stdout: dump.stdout.text,
|
|
||||||
stderr: dump.stderr.text,
|
|
||||||
truncated: false,
|
|
||||||
};
|
|
||||||
retainWarnings("wasm-objdump", dump);
|
retainWarnings("wasm-objdump", dump);
|
||||||
const wat = await execute(decode, decode.arguments);
|
const wat = await execute(decode, decode.arguments);
|
||||||
capturedOutput = {
|
|
||||||
stdout: wat.stdout.text,
|
|
||||||
stderr: wat.stderr.text,
|
|
||||||
truncated: false,
|
|
||||||
};
|
|
||||||
retainWarnings("wasm2wat", wat);
|
retainWarnings("wasm2wat", wat);
|
||||||
if (!wat.stdout.text.startsWith("(module"))
|
if (!wat.stdout.text.startsWith("(module"))
|
||||||
throw new AnalysisOutputError(
|
throw new AnalysisOutputError(
|
||||||
OPERATION,
|
OPERATION,
|
||||||
"Unexpected WABT decoding output.",
|
"Unexpected WABT decoding output.",
|
||||||
|
{ capturedOutput },
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
imports,
|
imports,
|
||||||
@@ -545,11 +552,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
optionalLimitations.push(
|
optionalLimitations.push(
|
||||||
"WABT reported optional custom-section interpretation warnings; exact warning output is retained in custom_section_warnings.",
|
"WABT reported optional custom-section interpretation warnings; exact warning output is retained in custom_section_warnings.",
|
||||||
);
|
);
|
||||||
capturedOutput = {
|
capturedOutput = artifactCommandOutput(dump);
|
||||||
stdout: dump.stdout.text,
|
|
||||||
stderr: dump.stderr.text,
|
|
||||||
truncated: false,
|
|
||||||
};
|
|
||||||
const { sections, headers, details } = parseWabtOutput(
|
const { sections, headers, details } = parseWabtOutput(
|
||||||
dump.stdout.text,
|
dump.stdout.text,
|
||||||
selected.bytes.length,
|
selected.bytes.length,
|
||||||
@@ -606,6 +609,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
{
|
{
|
||||||
commandTimeoutMs,
|
commandTimeoutMs,
|
||||||
inputField: artifactInput.field,
|
inputField: artifactInput.field,
|
||||||
|
observedOutput: capturedOutput,
|
||||||
...(versionProbe === undefined ? {} : { versionProbe }),
|
...(versionProbe === undefined ? {} : { versionProbe }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -614,19 +618,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
|||||||
if (partial !== undefined) failure.retainPartialObservation(partial);
|
if (partial !== undefined) failure.retainPartialObservation(partial);
|
||||||
}
|
}
|
||||||
runtime?.retainCommandFailure(cause, failure, this.#resources);
|
runtime?.retainCommandFailure(cause, failure, this.#resources);
|
||||||
result = err(
|
result = err(failure);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (runtime !== undefined) {
|
if (runtime !== undefined) {
|
||||||
const cleanup = await this.#resources.release({
|
const cleanup = await this.#resources.release({
|
||||||
|
|||||||
+19
-11
@@ -6,7 +6,10 @@ import {
|
|||||||
} from "../artifacts/ArtifactFileOperation.js";
|
} from "../artifacts/ArtifactFileOperation.js";
|
||||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.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 { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
@@ -48,6 +51,7 @@ type WabtFailureContext = {
|
|||||||
readonly commandTimeoutMs: number;
|
readonly commandTimeoutMs: number;
|
||||||
readonly versionProbe?: WabtVersionProbe;
|
readonly versionProbe?: WabtVersionProbe;
|
||||||
readonly inputField?: readonly (string | number)[];
|
readonly inputField?: readonly (string | number)[];
|
||||||
|
readonly observedOutput?: AnalysisCapturedOutput | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Configuration is captured by the server; fixing files and changing its environment are distinct repairs. */
|
/** 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;
|
if (cause instanceof AnalysisError) return cause;
|
||||||
const artifactCleanup =
|
const artifactCleanup =
|
||||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||||
const cleanupOptions =
|
const artifactOptions = {
|
||||||
artifactCleanup === undefined ? {} : { cleanup: artifactCleanup };
|
...(artifactCleanup === undefined ? {} : { cleanup: artifactCleanup }),
|
||||||
|
...(execution?.observedOutput === undefined
|
||||||
|
? {}
|
||||||
|
: { capturedOutput: execution.observedOutput }),
|
||||||
|
};
|
||||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||||
if (
|
if (
|
||||||
(cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") ||
|
(cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") ||
|
||||||
@@ -111,7 +119,7 @@ const classifyWabtFailure = (
|
|||||||
cause instanceof ArtifactReaderFailure
|
cause instanceof ArtifactReaderFailure
|
||||||
? artifactFileCancellationCause(cause)
|
? artifactFileCancellationCause(cause)
|
||||||
: cause,
|
: cause,
|
||||||
...cleanupOptions,
|
...artifactOptions,
|
||||||
});
|
});
|
||||||
if (cause instanceof OwnedCommandFailure) {
|
if (cause instanceof OwnedCommandFailure) {
|
||||||
const capturedOutput =
|
const capturedOutput =
|
||||||
@@ -224,7 +232,7 @@ const classifyWabtFailure = (
|
|||||||
cause.message,
|
cause.message,
|
||||||
{
|
{
|
||||||
cause,
|
cause,
|
||||||
...cleanupOptions,
|
...artifactOptions,
|
||||||
userMessage: `Configured WABT tools exceed the ${String(WABT_LIMITS.toolBytes)}-byte tool allowance. This release has no runtime override.`,
|
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.`,
|
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,
|
reason,
|
||||||
{
|
{
|
||||||
cause,
|
cause,
|
||||||
...cleanupOptions,
|
...artifactOptions,
|
||||||
userMessage: reason,
|
userMessage: reason,
|
||||||
remediationAction: wabtConfigurationRecoveryAction(cause),
|
remediationAction: wabtConfigurationRecoveryAction(cause),
|
||||||
},
|
},
|
||||||
@@ -254,7 +262,7 @@ const classifyWabtFailure = (
|
|||||||
{ maximum_input_bytes: WABT_LIMITS.inputBytes },
|
{ maximum_input_bytes: WABT_LIMITS.inputBytes },
|
||||||
{
|
{
|
||||||
cause,
|
cause,
|
||||||
...cleanupOptions,
|
...artifactOptions,
|
||||||
retryable: false,
|
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.`,
|
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",
|
"inspect_wasm_artifact",
|
||||||
path,
|
path,
|
||||||
reason,
|
reason,
|
||||||
{ cause, ...cleanupOptions },
|
{ cause, ...artifactOptions },
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
phase === "artifact" &&
|
phase === "artifact" &&
|
||||||
@@ -280,12 +288,12 @@ const classifyWabtFailure = (
|
|||||||
"inspect_wasm_artifact",
|
"inspect_wasm_artifact",
|
||||||
path,
|
path,
|
||||||
cause.code,
|
cause.code,
|
||||||
{ cause, ...cleanupOptions },
|
{ cause, ...artifactOptions },
|
||||||
);
|
);
|
||||||
if (phase === "artifact")
|
if (phase === "artifact")
|
||||||
return new AnalysisInputError(
|
return new AnalysisInputError(
|
||||||
"inspect_wasm_artifact",
|
"inspect_wasm_artifact",
|
||||||
{ cause, ...cleanupOptions },
|
{ cause, ...artifactOptions },
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
path: [...(execution?.inputField ?? ["path"])],
|
path: [...(execution?.inputField ?? ["path"])],
|
||||||
@@ -296,6 +304,6 @@ const classifyWabtFailure = (
|
|||||||
);
|
);
|
||||||
return new AnalysisOutputError("inspect_wasm_artifact", reason, {
|
return new AnalysisOutputError("inspect_wasm_artifact", reason, {
|
||||||
cause,
|
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.
|
* refusal paths, and cancellation before spawn.
|
||||||
*/
|
*/
|
||||||
import { createHash } from "node:crypto";
|
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 { join } from "node:path";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { expect, it, onTestFinished, vi } from "vitest";
|
import { expect, it, onTestFinished, vi } from "vitest";
|
||||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
@@ -47,6 +58,72 @@ const providerOver = (): PcapProvider => {
|
|||||||
return provider;
|
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 (
|
const evidenceResult = async (
|
||||||
provider: PcapProvider,
|
provider: PcapProvider,
|
||||||
request: PcapRequest,
|
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