mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(paths): preserve selected command, output, and source identities
This commit is contained in:
1 parent
18dc78fa43
commit
86ff3237e1
20 files changed
+685
-84
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
open,
|
||||
realpath,
|
||||
rename,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
@@ -353,7 +354,14 @@ const writeTextPartsOwned = async (
|
||||
return err(
|
||||
new EvidenceFileError("write", "not-file", { path: requestedPath }),
|
||||
);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const selectedParent = dirname(requestedPath);
|
||||
// Native lookup must accept the parent before realpath can collapse it.
|
||||
// On macOS realpath(file/..) succeeds even though that traversal is ENOTDIR.
|
||||
if (!(await stat(selectedParent)).isDirectory())
|
||||
return err(
|
||||
new EvidenceFileError("write", "missing", { path: requestedPath }),
|
||||
);
|
||||
const canonicalParent = await realpath(selectedParent);
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
if (fileErrorCode(cause) === "ENOENT") return undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
@@ -194,24 +195,33 @@ export class SafeOutputTree {
|
||||
throw new SafeOutputTreeDestinationRefusal(
|
||||
"Invalid extraction output root",
|
||||
);
|
||||
const parent = await realpath(dirname(requested)).catch(
|
||||
(cause: unknown) => {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(String(cause.code))
|
||||
)
|
||||
throw new SafeOutputTreeDestinationRefusal(
|
||||
"Extraction output parent is unavailable",
|
||||
{ cause },
|
||||
);
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
let parent: string;
|
||||
try {
|
||||
const selectedParent = dirname(requested);
|
||||
// Darwin realpath can collapse file/.. despite stat rejecting that
|
||||
// traversal. Admit the selected directory before canonicalizing it.
|
||||
if (!(await stat(selectedParent)).isDirectory())
|
||||
throw new SafeOutputTreeDestinationRefusal(
|
||||
"Extraction output parent must be a directory",
|
||||
);
|
||||
parent = await realpath(selectedParent);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof SafeOutputTreeDestinationRefusal) throw cause;
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(String(cause.code))
|
||||
)
|
||||
throw new SafeOutputTreeDestinationRefusal(
|
||||
"Extraction output parent is unavailable",
|
||||
{ cause },
|
||||
);
|
||||
},
|
||||
);
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Extraction output parent is unavailable",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const canonicalOutput = join(parent, name);
|
||||
return { requested, canonicalOutput };
|
||||
}
|
||||
|
||||
@@ -937,14 +937,15 @@ const originalModuleEdges = (
|
||||
return { edges, incomplete };
|
||||
};
|
||||
|
||||
/** Select source language from the URL path, excluding query/fragment. */
|
||||
/** Derive a language hint without changing the retained source URL identity. */
|
||||
const originalSourcePath = (source: string): string => {
|
||||
const path = URL.parse(source)?.pathname ?? source;
|
||||
try {
|
||||
return new URL(source).pathname;
|
||||
return decodeURIComponent(path);
|
||||
} catch (cause: unknown) {
|
||||
// Unresolved source identities can still carry a useful relative path.
|
||||
void cause;
|
||||
return source;
|
||||
if (!(cause instanceof URIError)) throw cause;
|
||||
// A literal or malformed escape must not discard an otherwise useful hint.
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/** "GDPC" read as a little-endian uint32: the Godot Engine pack magic. */
|
||||
export const GODOT_PCK_MAGIC = 0x43504447;
|
||||
|
||||
/** Whether bytes start with a standalone Godot PCK header. */
|
||||
export const hasGodotPckHeader = (bytes: Uint8Array): boolean =>
|
||||
bytes.length >= 4 &&
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
selectArtifactCommandPath,
|
||||
type ArtifactCommandPath,
|
||||
} from "../process/ArtifactCommandPath.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
FIRMWARE_TOOL_REQUIREMENTS,
|
||||
type FirmwareEngineName,
|
||||
} from "../contracts/firmware/firmwareToolRequirements.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
@@ -14,8 +18,8 @@ import {
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type {
|
||||
AnalysisCapturedOutput,
|
||||
AnalysisError,
|
||||
AnalysisCapturedOutput,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { projectAnalysisErrorWithoutCapturedOutput } from "../domain/analysisErrorProjection.js";
|
||||
import type { FirmwareDeadline } from "./FirmwareDeadline.js";
|
||||
@@ -47,7 +51,7 @@ export type FirmwareLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** Captured tool output for one finished firmware command. */
|
||||
/** Captured tool output for one finished firmware command, including byte coverage. */
|
||||
const capturedFirmwareOutput = (
|
||||
snapshot: ProviderProcessSnapshot,
|
||||
): AnalysisCapturedOutput => ({
|
||||
@@ -94,19 +98,23 @@ export const resolveFirmwareCommand = async (
|
||||
operation,
|
||||
`This integration requires Linux and an absolute ${variable} path to the caller-supplied tool`,
|
||||
);
|
||||
const command = await realpath(configured).catch((cause: unknown) => {
|
||||
throw firmwareToolUnavailable(
|
||||
engine,
|
||||
operation,
|
||||
`${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
});
|
||||
const selection = await selectArtifactCommandPath(configured, signal).catch(
|
||||
(cause: unknown) => {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw firmwareToolUnavailable(
|
||||
engine,
|
||||
operation,
|
||||
`${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
const command = selection.invocationPath;
|
||||
let sha256: string;
|
||||
try {
|
||||
await access(command, constants.R_OK | constants.X_OK);
|
||||
if (!(await stat(command)).isFile())
|
||||
throw new TypeError("Configured path is not a regular file");
|
||||
sha256 = await files.hash(command, signal);
|
||||
sha256 = await files.hash(selection.resolvedPath, signal);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw firmwareToolUnavailable(
|
||||
@@ -123,11 +131,14 @@ export const resolveFirmwareCommand = async (
|
||||
operation,
|
||||
"REA_FIRMWARE_PRLIMIT_COMMAND must be absolute",
|
||||
);
|
||||
let limiterSelection: ArtifactCommandPath;
|
||||
try {
|
||||
await access(limiter, constants.X_OK);
|
||||
limiterSelection = await selectArtifactCommandPath(limiter, signal);
|
||||
await access(limiterSelection.resolvedPath, constants.X_OK);
|
||||
if (!(await stat(limiter)).isFile())
|
||||
throw new TypeError("Configured path is not a regular file");
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw firmwareToolUnavailable(
|
||||
engine,
|
||||
operation,
|
||||
@@ -135,8 +146,8 @@ export const resolveFirmwareCommand = async (
|
||||
);
|
||||
}
|
||||
return {
|
||||
command,
|
||||
limiter,
|
||||
command: selection,
|
||||
limiter: limiterSelection,
|
||||
sha256,
|
||||
fileSizeCeiling: await files.fileSizeCeiling(signal),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||
import { join } from "node:path";
|
||||
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
|
||||
import {
|
||||
@@ -368,14 +369,20 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
const ownedRootLease = new AnalysisCleanupLease(() => ownedRoot.close());
|
||||
rootLease = ownedRootLease;
|
||||
const target = await this.#files.snapshot(request, root.path, signal);
|
||||
const run = (
|
||||
const run = async (
|
||||
args: readonly string[],
|
||||
outputBudget?: { root: string; bytes: number; entries: number },
|
||||
) =>
|
||||
runFirmwareCommand({
|
||||
) => {
|
||||
for (const selected of [command.command, command.limiter])
|
||||
await assertArtifactCommandPath(selected, request.operation, {
|
||||
signal,
|
||||
});
|
||||
const output = await runFirmwareCommand({
|
||||
engine: engineName,
|
||||
operation: request.operation,
|
||||
...command,
|
||||
fileSizeCeiling: command.fileSizeCeiling,
|
||||
command: command.command.invocationPath,
|
||||
limiter: command.limiter.invocationPath,
|
||||
args,
|
||||
cwd: root?.path ?? "",
|
||||
environment: this.environment,
|
||||
@@ -395,6 +402,20 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
? {}
|
||||
: { outputBudget, acceptReportedExtractionFailure: true }),
|
||||
});
|
||||
for (const selected of [command.command, command.limiter])
|
||||
await assertArtifactCommandPath(selected, request.operation, {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout.text,
|
||||
stderr: output.stderr.text,
|
||||
stdout_bytes: output.stdout.bytes,
|
||||
stderr_bytes: output.stderr.bytes,
|
||||
exit_code: output.exit_code ?? null,
|
||||
signal: output.signal,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
return output;
|
||||
};
|
||||
const versionRun = await run(["--version"]);
|
||||
const version = versionRun.stdout.text.trim();
|
||||
if (versionRun.stdout.bytes > 1024)
|
||||
@@ -413,7 +434,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||
const engine = {
|
||||
name: engineName,
|
||||
version: admitted.version,
|
||||
executable_path: command.command,
|
||||
executable_path: command.command.resolvedPath,
|
||||
executable_sha256: command.sha256,
|
||||
source_revision: null,
|
||||
worker_count: 1 as const,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { selectArtifactCommandPath } from "../../process/ArtifactCommandPath.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
@@ -87,10 +88,10 @@ export const resolveWakaruCommand = async (
|
||||
{ userMessage: WAKARU_REQUIREMENT },
|
||||
);
|
||||
try {
|
||||
const command = await realpath(configured);
|
||||
await access(command, constants.R_OK | constants.X_OK);
|
||||
const selection = await selectArtifactCommandPath(configured, signal);
|
||||
await access(selection.resolvedPath, constants.R_OK | constants.X_OK);
|
||||
const fingerprint = await fingerprintRecoveryFile(
|
||||
command,
|
||||
selection.resolvedPath,
|
||||
RECOVERY_LIMITS.outputBytes,
|
||||
signal,
|
||||
resources,
|
||||
@@ -99,10 +100,11 @@ export const resolveWakaruCommand = async (
|
||||
environment.REA_JAVASCRIPT_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||
if (!isAbsolute(limiter))
|
||||
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
|
||||
await access(limiter, constants.X_OK);
|
||||
const limiterSelection = await selectArtifactCommandPath(limiter, signal);
|
||||
await access(limiterSelection.resolvedPath, constants.X_OK);
|
||||
if (!(await stat(limiter)).isFile())
|
||||
throw new TypeError(`Resource limiter is not a regular file: ${limiter}`);
|
||||
return { command, limiter, ...fingerprint };
|
||||
return { command: selection, limiter: limiterSelection, ...fingerprint };
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertArtifactCommandPath } from "../../process/ArtifactCommandPath.js";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
@@ -61,7 +62,8 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
} = context;
|
||||
const staging = join(root, "modules");
|
||||
const commandContext = {
|
||||
...engine,
|
||||
command: engine.command.invocationPath,
|
||||
limiter: engine.limiter.invocationPath,
|
||||
cwd: root,
|
||||
deadline: context.deadline,
|
||||
environment: environment,
|
||||
@@ -69,7 +71,25 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
...(launcher === undefined ? {} : { launcher: launcher }),
|
||||
resources,
|
||||
};
|
||||
const version = await runWakaruCommand({
|
||||
const run = async (execution: Parameters<typeof runWakaruCommand>[0]) => {
|
||||
for (const selected of [engine.command, engine.limiter])
|
||||
await assertArtifactCommandPath(selected, OPERATION, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
const output = await runWakaruCommand(execution);
|
||||
for (const selected of [engine.command, engine.limiter])
|
||||
await assertArtifactCommandPath(selected, OPERATION, {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
exit_code: output.exit_code,
|
||||
signal: output.signal,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
return output;
|
||||
};
|
||||
const version = await run({
|
||||
...commandContext,
|
||||
arguments: ["--version"],
|
||||
});
|
||||
@@ -87,7 +107,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
{ userMessage: `${mismatch}.` },
|
||||
);
|
||||
}
|
||||
const run = await runWakaruCommand({
|
||||
const execution = await run({
|
||||
...commandContext,
|
||||
staging,
|
||||
arguments: [
|
||||
@@ -118,14 +138,14 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
),
|
||||
);
|
||||
const parsed = await parseWakaruReports({
|
||||
execution: run,
|
||||
execution,
|
||||
provenancePath: join(staging, "provenance.json"),
|
||||
snapshots,
|
||||
files,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
resources,
|
||||
});
|
||||
return { version, admitted, run, parsed };
|
||||
return { version, admitted, run: execution, parsed };
|
||||
};
|
||||
|
||||
/** Publish validated recovery and retain exact source and engine bindings. */
|
||||
@@ -170,7 +190,7 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
);
|
||||
}
|
||||
const currentEngine = await fingerprintRecoveryFile(
|
||||
engine.command,
|
||||
engine.command.resolvedPath,
|
||||
RECOVERY_LIMITS.outputBytes,
|
||||
options?.signal,
|
||||
resources,
|
||||
@@ -178,7 +198,7 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
if (currentEngine.sha256 !== engine.sha256)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
engine.command,
|
||||
engine.command.invocationPath,
|
||||
"Configured engine bytes changed during recovery",
|
||||
{
|
||||
diagnostics: {
|
||||
|
||||
@@ -109,7 +109,7 @@ export const publishWakaruArtifacts = async (
|
||||
id: "wakaru",
|
||||
version: context.admitted.version,
|
||||
executable: {
|
||||
path: context.engine.command,
|
||||
path: context.engine.command.resolvedPath,
|
||||
sha256: context.engine.sha256,
|
||||
bytes: context.engine.bytes,
|
||||
},
|
||||
|
||||
+52
-3
@@ -1,3 +1,11 @@
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
assertArtifactCommandPath,
|
||||
type ArtifactCommandPath,
|
||||
} from "../process/ArtifactCommandPath.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -58,7 +66,10 @@ export const runPcapCommand = async (options: {
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly inputPath?: string;
|
||||
readonly nativeBound?: {
|
||||
readonly limiter?: { command: string; arguments: readonly string[] };
|
||||
readonly limiter?: {
|
||||
command: ArtifactCommandPath;
|
||||
arguments: readonly string[];
|
||||
};
|
||||
readonly memoryLimitBytes?: number;
|
||||
};
|
||||
readonly launcher?: PcapCommandLauncher;
|
||||
@@ -92,7 +103,7 @@ export const runPcapCommand = async (options: {
|
||||
options.nativeBound?.limiter === undefined
|
||||
? invocation
|
||||
: {
|
||||
command: options.nativeBound.limiter.command,
|
||||
command: options.nativeBound.limiter.command.invocationPath,
|
||||
arguments: [
|
||||
...options.nativeBound.limiter.arguments,
|
||||
invocation.command,
|
||||
@@ -102,6 +113,9 @@ export const runPcapCommand = async (options: {
|
||||
// Native ownership preparation has its own bounded compiler lifecycle;
|
||||
// do not spend the tool's execution deadline rebuilding a cold SDK cache.
|
||||
await prepareOwnedCommand(signal);
|
||||
const selectedLimiter = options.nativeBound?.limiter?.command;
|
||||
if (selectedLimiter !== undefined)
|
||||
await assertArtifactCommandPath(selectedLimiter, operation, { signal });
|
||||
const output = await runOwnedCommand(
|
||||
{
|
||||
...boundedInvocation,
|
||||
@@ -137,6 +151,20 @@ export const runPcapCommand = async (options: {
|
||||
},
|
||||
},
|
||||
);
|
||||
if (selectedLimiter !== undefined)
|
||||
await assertArtifactCommandPath(selectedLimiter, operation, {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout.text,
|
||||
stderr: output.stderr.text,
|
||||
stdout_bytes: output.stdout.bytes,
|
||||
stderr_bytes: output.stderr.bytes,
|
||||
exit_code: output.exitCode ?? null,
|
||||
signal: output.signal ?? null,
|
||||
truncated:
|
||||
output.diagnosticTruncated === true ||
|
||||
output.outputIncomplete === true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
stdout: output.stdout.text,
|
||||
stderr: output.stderr.text,
|
||||
@@ -147,7 +175,28 @@ export const runPcapCommand = async (options: {
|
||||
...(cleanup === undefined ? {} : { cleanup }),
|
||||
};
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) throw cause;
|
||||
if (cause instanceof AnalysisError) {
|
||||
if (cleanup !== undefined) {
|
||||
const pending = new ProviderCleanupError(
|
||||
"pcap",
|
||||
cleanup.resources,
|
||||
{
|
||||
reason: cleanup.reason,
|
||||
},
|
||||
{
|
||||
operation,
|
||||
capturedOutput: cleanup.capturedOutput,
|
||||
cleanup: { reason: cleanup.reason, resources: cleanup.resources },
|
||||
},
|
||||
);
|
||||
throw analysisErrorWithCleanupFailure(cause, pending, operation);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
throw new AnalysisCancelledError(operation, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { selectArtifactCommandPath } from "../process/ArtifactCommandPath.js";
|
||||
import {
|
||||
pcapCaptureHostSupported,
|
||||
PCAP_CAPTURE_UNSUPPORTED_HOST_REASON,
|
||||
} from "../domain/pcap/pcapAnalysis.js";
|
||||
import { access, realpath } from "node:fs/promises";
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -49,8 +50,8 @@ export const preparePcapNativeBound = async (
|
||||
try {
|
||||
if (!isAbsolute(configured))
|
||||
throw new Error(`${PRLIMIT_COMMAND_ENV} must be absolute.`);
|
||||
const limiter = await realpath(configured);
|
||||
await access(limiter, constants.X_OK);
|
||||
const selection = await selectArtifactCommandPath(configured);
|
||||
await access(selection.resolvedPath, constants.X_OK);
|
||||
const limits = await resources.readLimits({
|
||||
address_space_bytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes,
|
||||
file_bytes: BOUNDED_PRODUCER_LIMITS.file_bytes,
|
||||
@@ -58,7 +59,7 @@ export const preparePcapNativeBound = async (
|
||||
});
|
||||
const nativeBound = {
|
||||
limiter: {
|
||||
command: limiter,
|
||||
command: selection,
|
||||
arguments: prlimitCeilingArguments(limits),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js";
|
||||
import {
|
||||
artifactSelectedPathErrno,
|
||||
assertArtifactReadActive,
|
||||
} from "../artifacts/ArtifactFileOperation.js";
|
||||
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** The selected launch spelling and the physical file whose bytes are fingerprinted. */
|
||||
export interface ArtifactCommandPath {
|
||||
@@ -47,13 +54,27 @@ export const assertArtifactCommandPath = async (
|
||||
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 (artifactSelectedPathErrno(cause) !== undefined) throw changed(cause);
|
||||
const systemCode =
|
||||
cause instanceof Error && "code" in cause
|
||||
? String(cause.code)
|
||||
: undefined;
|
||||
const failureOptions = { cause, capturedOutput: options.capturedOutput };
|
||||
if (systemCode === "EACCES" || systemCode === "EPERM")
|
||||
throw new AnalysisAccessDeniedError(
|
||||
operation,
|
||||
selected.invocationPath,
|
||||
systemCode,
|
||||
failureOptions,
|
||||
);
|
||||
throw new ProviderAdapterError("rea", operation, {
|
||||
...failureOptions,
|
||||
diagnostics: {
|
||||
path: selected.invocationPath,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
system_code: systemCode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (current !== selected.resolvedPath) throw changed();
|
||||
assertArtifactReadActive(options.signal);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
|
||||
import { fetchWebSourceMapsOk } from "../../fixtures/fetchWebSourceMapsOk.js";
|
||||
|
||||
it("selects original source language from decoded URL paths while retaining URL evidence", async () => {
|
||||
const declarations = [
|
||||
"plain.ts?revision=2#view.tsx",
|
||||
"escaped%2Ets?revision=2#view.tsx",
|
||||
"escaped.%74s?revision=2#view.tsx",
|
||||
"invalid%.ts?revision=2#view.tsx",
|
||||
];
|
||||
const content = 'import "./dep.ts"; const value = <number>1;';
|
||||
const text = JSON.stringify({
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: declarations,
|
||||
sourcesContent: declarations.map(() => content),
|
||||
mappings: "AAAA,ACAA,ACAA,ACAA",
|
||||
});
|
||||
const requests: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url ?? "");
|
||||
response.writeHead(200, { "content-type": "application/json" }).end(text);
|
||||
});
|
||||
try {
|
||||
const listening = once(server, "listening");
|
||||
server.listen(0, "127.0.0.1");
|
||||
await listening;
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new TypeError("Expected TCP listener");
|
||||
const origin = `http://127.0.0.1:${String(address.port)}`;
|
||||
const url = `${origin}/maps/app.js.map`;
|
||||
const result = await fetchWebSourceMapsOk(
|
||||
[{ scriptKey: "script", declaredUrl: url, fetchUrl: url }],
|
||||
analyzeWebBundleInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
target_id: "selected-page",
|
||||
allowed_origins: [origin],
|
||||
fetch_source_maps: true,
|
||||
}),
|
||||
);
|
||||
const sources = declarations.map((source) => `${origin}/maps/${source}`);
|
||||
expect(requests).toEqual(["/maps/app.js.map"]);
|
||||
expect(result.status).toBe("included");
|
||||
expect(result.items[0]).toMatchObject({
|
||||
status: "included",
|
||||
limitation: null,
|
||||
artifact: { text },
|
||||
original_sources: sources.map((source) => ({
|
||||
source,
|
||||
artifact: { media_type: "text/typescript", text: content },
|
||||
})),
|
||||
mappings: sources.map((source) => ({ source })),
|
||||
original_module_edges: sources.map((source) => ({
|
||||
from_source: source,
|
||||
kind: "static_import",
|
||||
specifier: "./dep.ts",
|
||||
resolved_source: `${origin}/maps/dep.ts`,
|
||||
})),
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
if (server.listening)
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -729,6 +729,31 @@ it("reuses authenticated file snapshots while isolating mutable public reads", a
|
||||
ledger.clear();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects a regular-file parent traversal without writing to its lexical sibling",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-evidence-parent-");
|
||||
const blocker = join(directory, "file");
|
||||
await writeFile(blocker, "unchanged");
|
||||
const selected = `${blocker}/../bundle.json`;
|
||||
expect(await writeBundle(bundle(), selected, false)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceFileError", path: selected },
|
||||
});
|
||||
expect(await readdir(directory)).toEqual(["file"]);
|
||||
expect(await readFile(blocker, "utf8")).toBe("unchanged");
|
||||
const corrected = join(directory, "bundle.json");
|
||||
expect(await writeBundle(bundle(), corrected, false)).toMatchObject({
|
||||
ok: true,
|
||||
value: { path: corrected },
|
||||
});
|
||||
expect(await readEvidenceBundle(corrected)).toEqual({
|
||||
ok: true,
|
||||
value: bundle(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("evidence bundle filesystem adapter", () => {
|
||||
it.skipIf(process.platform === "win32").each(["/", "/."])(
|
||||
"preserves the directory requirement of a missing output ending in %s",
|
||||
|
||||
@@ -27,7 +27,7 @@ const layout = async (prefix: string) => {
|
||||
};
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"file owners given a directory symlink followed by ..",
|
||||
"file owners preserve POSIX path traversal",
|
||||
() => {
|
||||
it("snapshots the firmware image the filesystem selects", async () => {
|
||||
const root = await layout("rea-symlink-parent-firmware-");
|
||||
@@ -88,5 +88,27 @@ describe.skipIf(process.platform === "win32")(
|
||||
await tree.rollback();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["file/out", "file/../out"])(
|
||||
"refuses an output parent traversing a regular file: %s",
|
||||
async (relative) => {
|
||||
const root = await layout("rea-nondirectory-parent-output-");
|
||||
await writeFile(join(root, "file"), "original");
|
||||
// Darwin realpath can collapse file/.. even though stat rejects it.
|
||||
const output = `${root}/${relative}`;
|
||||
for (const admit of [
|
||||
() => SafeOutputTree.checkDestination(output),
|
||||
() => SafeOutputTree.create(output),
|
||||
]) {
|
||||
await expect(admit()).rejects.toMatchObject({
|
||||
name: "SafeOutputTreeDestinationRefusal",
|
||||
reason: "path",
|
||||
});
|
||||
await expect(stat(join(root, "out"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -15,8 +15,14 @@ vi.mock(
|
||||
return {
|
||||
...actual,
|
||||
resolveWakaruCommand: async () => ({
|
||||
command: "/unused/wakaru",
|
||||
limiter: "/unused/prlimit",
|
||||
command: {
|
||||
invocationPath: "/unused/wakaru",
|
||||
resolvedPath: "/unused/wakaru",
|
||||
},
|
||||
limiter: {
|
||||
invocationPath: "/unused/prlimit",
|
||||
resolvedPath: "/unused/prlimit",
|
||||
},
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 1,
|
||||
}),
|
||||
|
||||
@@ -43,8 +43,14 @@ vi.mock(
|
||||
return {
|
||||
...actual,
|
||||
resolveWakaruCommand: async () => ({
|
||||
command: "/unused/wakaru",
|
||||
limiter: "/unused/prlimit",
|
||||
command: {
|
||||
invocationPath: "/unused/wakaru",
|
||||
resolvedPath: "/unused/wakaru",
|
||||
},
|
||||
limiter: {
|
||||
invocationPath: "/unused/prlimit",
|
||||
resolvedPath: "/unused/prlimit",
|
||||
},
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 1,
|
||||
}),
|
||||
|
||||
@@ -8,8 +8,24 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
import { FirmwareProvider } from "../../../src/firmware/FirmwareProvider.js";
|
||||
import { FirmwareAnalysisService } from "../../../src/application/firmware/FirmwareAnalysisService.js";
|
||||
import { FirmwareFileAccess } from "../../../src/firmware/FirmwareFiles.js";
|
||||
import { WakaruProvider } from "../../../src/javascript/recovery/WakaruProvider.js";
|
||||
import { JavaScriptRecoveryService } from "../../../src/application/javascript/JavaScriptRecoveryService.js";
|
||||
import {
|
||||
spawnOwnedProviderProcess,
|
||||
type ProviderProcessSupervisor,
|
||||
} from "../../../src/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { observeProcessStartIdentity } from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { preparePcapNativeBound } from "../../../src/pcap/PcapNativeBound.js";
|
||||
import { runPcapCommand } from "../../../src/pcap/PcapCommand.js";
|
||||
import { ArtifactAnalysisResources } from "../../../src/process/ArtifactAnalysisResources.js";
|
||||
import { PacketCaptureService } from "../../../src/application/captures/PacketCaptureService.js";
|
||||
import { SignalRecordingService } from "../../../src/application/signals/SignalRecordingService.js";
|
||||
import { TsharkCaptureProvider } from "../../../src/captures/TsharkCaptureProvider.js";
|
||||
@@ -117,3 +133,271 @@ it.skipIf(process.platform === "win32").each(["tshark", "sigrok"] as const)(
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
const selectedEngineLauncher = (kind: "firmware" | "wakaru"): string => {
|
||||
const producer = fileURLToPath(
|
||||
new URL(
|
||||
kind === "firmware"
|
||||
? "../../fixtures/firmware/producer.mjs"
|
||||
: "../../fixtures/javascript-recovery/producer.mjs",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const invocation = [
|
||||
process.execPath,
|
||||
producer,
|
||||
...(kind === "firmware" ? ["binwalk"] : []),
|
||||
]
|
||||
.map((argument) => `'${argument.replaceAll("'", "'\\''")}'`)
|
||||
.join(" ");
|
||||
return `#!/bin/sh\nIFS= read -r role < "$0.role" || exit 19\nif [ -f "$0.retarget" ]; then\n IFS= read -r target < "$0.retarget"\n /bin/ln -sf "$target" "$0"\nfi\nif [ "$role" = limiter ]; then\n while [ "$1" != -- ]; do shift; done\n shift\n exec "$@"\nfi\nexec ${invocation} "$@"\n`;
|
||||
};
|
||||
|
||||
// Exercise shared CLI/MCP workflows with real children and filesystem. Only
|
||||
// Linux/x64 admission and /proc limits are simulated; the launcher restores the
|
||||
// actual host before entering real process supervision.
|
||||
it.skipIf(process.platform === "win32").each(["firmware", "wakaru"] as const)(
|
||||
"executes selected %s wrappers and retains completed output when either alias changes",
|
||||
async (kind) => {
|
||||
const directory = await createTestTempDirectory("rea-selected-command-");
|
||||
const bin = join(directory, "bin"),
|
||||
libexec = join(directory, "libexec");
|
||||
await mkdir(bin);
|
||||
await mkdir(libexec);
|
||||
const command = join(bin, "engine"),
|
||||
limiter = join(bin, "prlimit");
|
||||
const commandSource = join(libexec, "engine"),
|
||||
limiterSource = join(libexec, "limiter");
|
||||
const replacement = join(libexec, "replacement");
|
||||
const script = selectedEngineLauncher(kind);
|
||||
for (const path of [commandSource, limiterSource, replacement])
|
||||
await writeFile(path, script, { mode: 0o755 });
|
||||
await symlink(commandSource, command);
|
||||
await symlink(limiterSource, limiter);
|
||||
await writeFile(`${command}.role`, `${kind}\n`);
|
||||
await writeFile(`${limiter}.role`, "limiter\n");
|
||||
const input = join(
|
||||
directory,
|
||||
kind === "firmware" ? "input.bin" : "input.js",
|
||||
);
|
||||
await writeFile(
|
||||
input,
|
||||
kind === "firmware" ? "fixture-input-data" : "module.exports = 42;",
|
||||
);
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const arch = Object.getOwnPropertyDescriptor(process, "arch");
|
||||
if (platform === undefined || arch === undefined)
|
||||
throw new Error("Missing host descriptors");
|
||||
const restoreHost = () => {
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
Object.defineProperty(process, "arch", arch);
|
||||
};
|
||||
const launches: { cwd?: string; pid?: number }[] = [];
|
||||
const launcher: typeof spawnOwnedProviderProcess = async (options) => {
|
||||
restoreHost();
|
||||
const child = await spawnOwnedProviderProcess(options);
|
||||
launches.push({
|
||||
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
||||
...(child.process.pid === undefined ? {} : { pid: child.process.pid }),
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const fileSizeCeiling = vi
|
||||
.spyOn(FirmwareFileAccess.prototype, "fileSizeCeiling")
|
||||
.mockResolvedValue(Number.MAX_SAFE_INTEGER);
|
||||
onTestFinished(() => fileSizeCeiling.mockRestore());
|
||||
const service =
|
||||
kind === "firmware"
|
||||
? new FirmwareAnalysisService(
|
||||
new FirmwareProvider(
|
||||
{
|
||||
...process.env,
|
||||
REA_BINWALK_COMMAND: command,
|
||||
REA_FIRMWARE_PRLIMIT_COMMAND: limiter,
|
||||
},
|
||||
launcher,
|
||||
),
|
||||
)
|
||||
: new JavaScriptRecoveryService(
|
||||
new WakaruProvider(
|
||||
{
|
||||
...process.env,
|
||||
REA_WAKARU_COMMAND: command,
|
||||
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
|
||||
},
|
||||
launcher,
|
||||
),
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
restoreHost();
|
||||
await service.close();
|
||||
});
|
||||
let sequence = 0;
|
||||
const execute = async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
...platform,
|
||||
value: "linux",
|
||||
});
|
||||
Object.defineProperty(process, "arch", { ...arch, value: "x64" });
|
||||
try {
|
||||
return service instanceof FirmwareAnalysisService
|
||||
? await service.execute("inspect_firmware_regions", { path: input })
|
||||
: await service.recover({
|
||||
path: input,
|
||||
output_directory: join(directory, `output-${sequence++}`),
|
||||
});
|
||||
} finally {
|
||||
restoreHost();
|
||||
}
|
||||
};
|
||||
const first = await execute();
|
||||
if (!first.ok) throw first.error;
|
||||
expect(first.value.normalized_result).toMatchObject({
|
||||
engine:
|
||||
kind === "firmware"
|
||||
? { executable_path: await realpath(commandSource) }
|
||||
: { executable: { path: await realpath(commandSource) } },
|
||||
});
|
||||
expect(first.value.raw_result).toMatchObject({
|
||||
[kind === "firmware" ? "version_execution" : "version"]: {
|
||||
command,
|
||||
resource_limiter: limiter,
|
||||
},
|
||||
});
|
||||
for (const [selectedPath, target] of [
|
||||
[command, replacement],
|
||||
[limiter, replacement],
|
||||
[command, command],
|
||||
[limiter, limiter],
|
||||
] as const) {
|
||||
await writeFile(`${selectedPath}.retarget`, `${target}\n`);
|
||||
const changed = await execute();
|
||||
if (changed.ok) throw new Error("Retargeted executable was accepted");
|
||||
expect(changed.error).toMatchObject({
|
||||
_tag: "AnalysisArtifactChangedError",
|
||||
path: selectedPath,
|
||||
capturedOutput: {
|
||||
stdout: kind === "firmware" ? "binwalk 3.1.0\n" : "wakaru 1.14.0\n",
|
||||
},
|
||||
});
|
||||
await unlink(`${selectedPath}.retarget`);
|
||||
await unlink(selectedPath);
|
||||
await symlink(
|
||||
selectedPath === command ? commandSource : limiterSource,
|
||||
selectedPath,
|
||||
);
|
||||
}
|
||||
for (const launch of launches) {
|
||||
if (launch.pid !== undefined)
|
||||
expect(await observeProcessStartIdentity(launch.pid)).toBeUndefined();
|
||||
if (launch.cwd !== undefined)
|
||||
await expect(readdir(launch.cwd)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"executes the selected PCAP limiter and retains completed output when its alias changes",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-selected-pcap-limiter-",
|
||||
);
|
||||
const selected = join(directory, "prlimit"),
|
||||
physical = join(directory, "driver"),
|
||||
replacement = join(directory, "replacement");
|
||||
const script =
|
||||
'#!/bin/sh\n[ -f "$0.role" ] || exit 19\nif [ -f "$0.retarget" ]; then\n IFS= read -r target < "$0.retarget"\n /bin/ln -sf "$target" "$0"\nfi\nwhile [ "$1" != -- ]; do shift; done\nshift\nexec "$@"\n';
|
||||
await writeFile(physical, script, { mode: 0o755 });
|
||||
await writeFile(replacement, script, { mode: 0o755 });
|
||||
await symlink(physical, selected);
|
||||
await writeFile(`${selected}.role`, "limiter");
|
||||
const resources = new ArtifactAnalysisResources(
|
||||
"pcap",
|
||||
"inspect_pcap_capture",
|
||||
readStableArtifact,
|
||||
async (configured) => configured,
|
||||
{ message: "busy", details: {} },
|
||||
);
|
||||
onTestFinished(() => resources.close());
|
||||
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (platform === undefined) throw new Error("Missing host descriptor");
|
||||
let prepared: Awaited<ReturnType<typeof preparePcapNativeBound>>;
|
||||
try {
|
||||
Object.defineProperty(process, "platform", {
|
||||
...platform,
|
||||
value: "linux",
|
||||
});
|
||||
prepared = await preparePcapNativeBound(
|
||||
{ REA_TSHARK_PRLIMIT_COMMAND: selected },
|
||||
resources,
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platform);
|
||||
}
|
||||
let refuseCleanup = false;
|
||||
let retained: ProviderProcessSupervisor | undefined;
|
||||
onTestFinished(async () => {
|
||||
refuseCleanup = false;
|
||||
await retained?.stop();
|
||||
});
|
||||
const execute = () =>
|
||||
runPcapCommand({
|
||||
operation: "inspect_pcap_capture",
|
||||
command: "/bin/echo",
|
||||
arguments: ["selected limiter"],
|
||||
limits: { timeoutMs: 10_000, maxOutputBytes: 1024 },
|
||||
environment: process.env,
|
||||
signal: undefined,
|
||||
nativeBound: prepared.nativeBound,
|
||||
launcher: async (options) => {
|
||||
const child = await spawnOwnedProviderProcess(options);
|
||||
return {
|
||||
...child,
|
||||
cleanup: () =>
|
||||
refuseCleanup
|
||||
? Promise.resolve({
|
||||
cleaned: false,
|
||||
reason: "controlled pending command cleanup",
|
||||
})
|
||||
: cleanupOwnedProcessGroup(child.ownership),
|
||||
};
|
||||
},
|
||||
retain: (owner) => {
|
||||
retained = owner;
|
||||
},
|
||||
});
|
||||
await expect(execute()).resolves.toMatchObject({
|
||||
stdout: "selected limiter\n",
|
||||
});
|
||||
await writeFile(`${selected}.retarget`, `${replacement}\n`);
|
||||
await expect(execute()).rejects.toMatchObject({
|
||||
_tag: "AnalysisArtifactChangedError",
|
||||
path: selected,
|
||||
capturedOutput: { stdout: "selected limiter\n" },
|
||||
});
|
||||
await unlink(selected);
|
||||
await symlink(physical, selected);
|
||||
await writeFile(`${selected}.retarget`, `${selected}\n`);
|
||||
refuseCleanup = true;
|
||||
const failed: unknown = await execute().catch((cause: unknown) => cause);
|
||||
expect(failed).toBeInstanceOf(AnalysisError);
|
||||
if (!(failed instanceof AnalysisError))
|
||||
throw new Error("Expected typed cleanup failure");
|
||||
expect(failed).toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
cleanup: { reason: "controlled pending command cleanup" },
|
||||
capturedOutput: { stdout: "selected limiter\n" },
|
||||
});
|
||||
expect(failed.cleanupPrimary).toMatchObject({
|
||||
_tag: "AnalysisArtifactChangedError",
|
||||
path: selected,
|
||||
});
|
||||
refuseCleanup = false;
|
||||
await expect(retained?.stop()).resolves.toMatchObject({
|
||||
status: "verified-cleanup",
|
||||
});
|
||||
expect(failed.resolveCleanup().cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
@@ -12,8 +12,44 @@ import {
|
||||
artifactInventoryResultSchema,
|
||||
} from "../../../../src/domain/artifactGraph.js";
|
||||
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
|
||||
import { withArtifactClient } from "../../../support/artifactClientScope.js";
|
||||
|
||||
describe("artifact extraction", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a regular-file parent before acquiring output and permits a corrected request",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-invalid-parent-");
|
||||
const source = join(root, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "selected payload\n");
|
||||
await writeFile(join(root, "file"), "parent is a file");
|
||||
const output = join(root, "out");
|
||||
|
||||
await withArtifactClient(target(source, "directory"), async (client) => {
|
||||
const refused = await client.execute("extract_artifact", {
|
||||
output_root: `${root}/file/../out`,
|
||||
});
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "path" },
|
||||
});
|
||||
if (!refused.ok) expect(refused.error.cleanupIncomplete).toBe(false);
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const corrected = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
if (!corrected.ok) throw corrected.error;
|
||||
expect(
|
||||
artifactExtractionResultSchema.parse(corrected.value.result),
|
||||
).toMatchObject({ output_root: output, containment_verified: true });
|
||||
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
|
||||
"selected payload\n",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps committed files after the owning client closes", async () => {
|
||||
const root = await createTestTempDirectory("rea-extract-client-close-");
|
||||
const source = join(root, "source");
|
||||
|
||||
@@ -69,11 +69,17 @@ vi.mock("../../src/firmware/FirmwareCommand.js", async (importOriginal) => {
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
resolveFirmwareCommand: async () => ({
|
||||
command: "/fixture/binwalk",
|
||||
limiter: "/fixture/prlimit",
|
||||
sha256: "0".repeat(64),
|
||||
}),
|
||||
resolveFirmwareCommand: async () => {
|
||||
const { selectArtifactCommandPath } =
|
||||
await import("../../src/process/ArtifactCommandPath.js");
|
||||
const command = await selectArtifactCommandPath(process.execPath);
|
||||
return {
|
||||
command,
|
||||
limiter: command,
|
||||
sha256: "0".repeat(64),
|
||||
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
},
|
||||
runFirmwareCommand: async (
|
||||
context: Parameters<typeof original.runFirmwareCommand>[0],
|
||||
) => {
|
||||
|
||||
Reference in new issue
Block a user