From 86ff3237e1d87715fccbfbfcde75402517376a99 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:10:06 +0800 Subject: [PATCH] fix(paths): preserve selected command, output, and source identities --- src/application/JsonFiles.ts | 10 +- src/artifacts/SafeOutputTree.ts | 40 ++- src/browser/WebSourceMapFetcher.ts | 11 +- src/domain/godotPckFormat.ts | 3 - src/firmware/FirmwareCommand.ts | 39 ++- src/firmware/FirmwareProvider.ts | 31 +- src/javascript/recovery/WakaruCommand.ts | 14 +- src/javascript/recovery/WakaruExecution.ts | 34 ++- src/javascript/recovery/WakaruPublication.ts | 2 +- src/pcap/PcapCommand.ts | 55 +++- src/pcap/PcapNativeBound.ts | 9 +- src/process/ArtifactCommandPath.ts | 39 ++- .../webSourceMapSourceLanguage.test.ts | 75 +++++ .../filesystem/evidenceBundleFiles.test.ts | 25 ++ .../filesystem/symlinkParentOwners.test.ts | 24 +- .../wakaruInputAllowance.test.ts | 10 +- .../wakaruPublicationCancellation.test.ts | 10 +- .../process/artifactSelectedCommand.test.ts | 286 +++++++++++++++++- .../artifactProvider.extraction.test.ts | 36 +++ .../firmwareFileLifetime.test.ts | 16 +- 20 files changed, 685 insertions(+), 84 deletions(-) create mode 100644 tests/boundary/browser/webSourceMapSourceLanguage.test.ts diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index f0d711e954..6a29d92da3 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -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; diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 6a6a8b1908..19f152e956 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -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 }; } diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 1c0f4b21b1..ebd70a4b29 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -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; } }; diff --git a/src/domain/godotPckFormat.ts b/src/domain/godotPckFormat.ts index 09dccebff0..70283a8555 100644 --- a/src/domain/godotPckFormat.ts +++ b/src/domain/godotPckFormat.ts @@ -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 && diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index c53fe6583e..e60729b8d5 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -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; -/** 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), }; diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index fd814a3864..39b2b1782d 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -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, diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 2d84fdb87e..71116c84bb 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -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 || diff --git a/src/javascript/recovery/WakaruExecution.ts b/src/javascript/recovery/WakaruExecution.ts index 8c0abfb2c9..bf09845cd3 100644 --- a/src/javascript/recovery/WakaruExecution.ts +++ b/src/javascript/recovery/WakaruExecution.ts @@ -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[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: { diff --git a/src/javascript/recovery/WakaruPublication.ts b/src/javascript/recovery/WakaruPublication.ts index a54a0be3dc..f381118c86 100644 --- a/src/javascript/recovery/WakaruPublication.ts +++ b/src/javascript/recovery/WakaruPublication.ts @@ -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, }, diff --git a/src/pcap/PcapCommand.ts b/src/pcap/PcapCommand.ts index 166043a16b..b7ac8d50a8 100644 --- a/src/pcap/PcapCommand.ts +++ b/src/pcap/PcapCommand.ts @@ -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 diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index 24a8bf4a08..d44a015605 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -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), }, }; diff --git a/src/process/ArtifactCommandPath.ts b/src/process/ArtifactCommandPath.ts index cdeabe1f0c..51b92922b7 100644 --- a/src/process/ArtifactCommandPath.ts +++ b/src/process/ArtifactCommandPath.ts @@ -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); diff --git a/tests/boundary/browser/webSourceMapSourceLanguage.test.ts b/tests/boundary/browser/webSourceMapSourceLanguage.test.ts new file mode 100644 index 0000000000..266d8e850e --- /dev/null +++ b/tests/boundary/browser/webSourceMapSourceLanguage.test.ts @@ -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 = 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((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + } +}); diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index c788921dae..9ee0b57fca 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -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", diff --git a/tests/boundary/filesystem/symlinkParentOwners.test.ts b/tests/boundary/filesystem/symlinkParentOwners.test.ts index 35ae0a71c0..9da82189a8 100644 --- a/tests/boundary/filesystem/symlinkParentOwners.test.ts +++ b/tests/boundary/filesystem/symlinkParentOwners.test.ts @@ -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", + }); + } + }, + ); }, ); diff --git a/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts b/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts index e0cd844c70..fb3dfe0a88 100644 --- a/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts +++ b/tests/boundary/javascript-recovery/wakaruInputAllowance.test.ts @@ -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, }), diff --git a/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts b/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts index 250e756239..7dcf5eff92 100644 --- a/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts +++ b/tests/boundary/javascript-recovery/wakaruPublicationCancellation.test.ts @@ -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, }), diff --git a/tests/boundary/process/artifactSelectedCommand.test.ts b/tests/boundary/process/artifactSelectedCommand.test.ts index f3a8675338..c2571a00c0 100644 --- a/tests/boundary/process/artifactSelectedCommand.test.ts +++ b/tests/boundary/process/artifactSelectedCommand.test.ts @@ -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>; + 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); + }, +); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 6488af65fc..52471b3318 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -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"); diff --git a/tests/process-global/firmwareFileLifetime.test.ts b/tests/process-global/firmwareFileLifetime.test.ts index ae94efbfed..75bf3db075 100644 --- a/tests/process-global/firmwareFileLifetime.test.ts +++ b/tests/process-global/firmwareFileLifetime.test.ts @@ -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[0], ) => {