diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 34fff1f30f..8a4306c17a 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -350,6 +350,6 @@ generation error. ## Configuration and coverage -The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. Values are passed directly to process spawning, not expanded as shell command lines. A host executable launch refusal preserves the selected command and host error code, with recovery through the corresponding command setting, PATH, or execute/search permissions. GDB does not retry that refusal with MI2; its MI2 fallback applies to protocol initialization failures. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane. +The optional `REA_OBJDUMP_COMMAND`, `REA_GDB_COMMAND`, and `REA_RIZIN_COMMAND` values select executable names or paths; otherwise REA searches `PATH`. On Windows, REA searches `PATH` and `PATHEXT` for a bare objdump or Rizin name itself and refuses with `capability_unavailable` (`boundary: "provider-lookup"`) when it is not found, rather than letting Windows also search the current directory. Values are passed directly to process spawning, not expanded as shell command lines. A host executable launch refusal preserves the selected command and host error code, with recovery through the corresponding command setting, PATH, or execute/search permissions. GDB does not retry that refusal with MI2; its MI2 fallback applies to protocol initialization failures. Provider versions and capabilities remain unknown until observed from the installed tool and verified in the corresponding real-provider lane. Provider capture limits are separate from the MCP response budget. A result that fits the provider limit may still exceed the default 10 MiB MCP response budget and return a transport `resource_constraint`. Configure `REA_MCP_MAX_RESPONSE_BYTES` to match a larger client receive buffer when that client supports one; otherwise use retained Evidence/export or complete CLI JSON output. See [MCP response limits](reference/mcp-contracts.md#tool-results). diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 9e8f6ecfe7..7a53f9710b 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -441,6 +441,9 @@ "src/sqlite/SqliteDatabaseWorkerExecution.ts": 2, "src/wasm/WabtArtifactProvider.ts": 1 }, + "path-search-split": { + "src/process/capture/ProcessScenarioRuntimeValidation.ts": 1 + }, "ambient-host-platform": { "src/adb/AdbConfiguration.ts": 1, "src/adb/AdbDeviceOutput.ts": 1, diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 07e1da8bf3..b093c674bc 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -480,6 +480,16 @@ checkCounted( /\.bindCleanupDependencies\(/gu, ["src/domain/analysisErrorBase.ts"], ); +// A launcher's PATH search order (POSIX empty and relative entries and `..`; +// Windows quoted entries and PATHEXT) belongs to src/process/executableSearch; +// callers choose only how they probe each candidate. +checkCounted( + "PATH or PATHEXT split outside the executable search owner (use posixPathCandidates or windowsPathCandidates from src/process/executableSearch)", + "path-search-split", + files, + /\.split\(\s*(?:posix\.|win32\.)?(?:delimiter|pathDelimiter)\s*\)|\bPATH\b[^\n]{0,12}\.split\(":"\)|\bPATHEXT\b[^\n]{0,60}\.split\(/gu, + ["src/process/executableSearch.ts"], +); // The running host is read once by src/platform/HostPlatform; composition // takes it from there and passes the platform or architecture on as a diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index 6340eda048..89817e8b0d 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -17,7 +17,12 @@ import { } from "./JadxInterruption.js"; import { constants } from "node:fs"; import { access, realpath, stat } from "node:fs/promises"; -import { delimiter, isAbsolute, resolve } from "node:path"; +import { isAbsolute } from "node:path"; +import { + posixPathCandidates, + windowsPathCandidates, + type PathSearchCandidate, +} from "../process/executableSearch.js"; import { inspectWindowsNativeReadiness } from "../process/WindowsAuthority.js"; import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; @@ -592,27 +597,23 @@ const resolveJava = async ( const executable = process.platform === "win32" ? "java.exe" : "java"; if (environment.JAVA_HOME === undefined) { const windows = process.platform === "win32"; - const path = windows - ? (Object.entries(environment).find( - ([key]) => key.toLowerCase() === "path", - )?.[1] ?? "") - : (environment.PATH ?? "/usr/bin:/bin"); // Resolve in the caller's directory once. The module probe and the engine // run in different directories, and the selected executable also owns the - // retained session's cache identity. Empty POSIX PATH components select cwd. - const directories = path.split(delimiter); - for (const directory of windows - ? directories.filter((entry) => entry !== "") - : directories) { + // retained session's cache identity. Empty POSIX PATH components select + // cwd. `java.exe` names its extension, so PATHEXT never fails the search. + const windowsSearch = windows + ? windowsPathCandidates(executable, environment) + : undefined; + const candidates: Iterable = + windowsSearch === undefined + ? posixPathCandidates(executable, environment.PATH ?? "/usr/bin:/bin", { + relativeEntries: "resolve", + }) + : windowsSearch.ok + ? windowsSearch.value + : []; + for (const { path: candidate } of candidates) { throwIfJadxInterrupted(signal); - // Windows PATH commonly quotes individual entries containing spaces. - const selected = - windows && directory.startsWith('"') && directory.endsWith('"') - ? directory.slice(1, -1) - : directory; - const candidate = windows - ? resolve(selected, executable) - : absoluteCallerPath(appendCallerPath(selected, executable)); try { await access( candidate, diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index e8f2463288..059ddd5d91 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -10,7 +10,7 @@ import { javaScriptRuntime, type JavaScriptRuntime, } from "../domain/runtimeVersion.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { posixPathCandidates } from "../process/executableSearch.js"; import { PRODUCT_IDENTITY } from "../identity.js"; /** Package runners recognized for invocation guidance and legacy registrations. */ @@ -166,9 +166,11 @@ const sameFileExecutableAlias = ( environment: Readonly, identify: typeof entrypointIdentity, ): string => { - for (const directory of (environment.PATH ?? "").split(":")) { - if (!isAbsolute(directory)) continue; - const candidate = absoluteCallerPath(`${directory}/${name}`); + for (const { path: candidate } of posixPathCandidates( + name, + environment.PATH ?? "", + { relativeEntries: "skip" }, + )) { if (identify(candidate, selected) !== "owned") continue; try { accessSync(candidate, constants.X_OK); diff --git a/src/application/reverse/ReverseEngineeringCommandPort.ts b/src/application/reverse/ReverseEngineeringCommandPort.ts index d5680bf58a..2d32b5000b 100644 --- a/src/application/reverse/ReverseEngineeringCommandPort.ts +++ b/src/application/reverse/ReverseEngineeringCommandPort.ts @@ -1,4 +1,5 @@ import type { EvidenceProvider } from "../../domain/evidence.js"; +import type { Result } from "../../domain/result.js"; import type { ObjdumpSelection, RizinInput, @@ -19,8 +20,27 @@ export interface ReverseEngineeringProviderCommand { readonly environmentOverrides?: Readonly; } +/** + * A configured bare command the host search did not find, so nothing is + * launched: handing Windows the bare name would also search the current + * directory. + */ +export interface ReverseEngineeringCommandUnavailable { + readonly identity: EvidenceProvider; + /** The command as configured. */ + readonly command: string; + readonly reason: + | { readonly kind: "not_found" } + | { readonly kind: "invalid_pathext"; readonly extension: string }; +} + +export type ReverseEngineeringCommandSelection = Result< + ReverseEngineeringProviderCommand, + ReverseEngineeringCommandUnavailable +>; + /** Provider-specific command construction stays outside the application layer. */ export interface ReverseEngineeringCommandPort { - objdump(input: ObjdumpSelection): ReverseEngineeringProviderCommand; - rizin(input: RizinInput): ReverseEngineeringProviderCommand; + objdump(input: ObjdumpSelection): ReverseEngineeringCommandSelection; + rizin(input: RizinInput): ReverseEngineeringCommandSelection; } diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index daa0ed2184..0c3c54afe7 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -49,6 +49,8 @@ import { } from "../../domain/reverseEngineering.js"; import type { ReverseEngineeringCommandPort, + ReverseEngineeringCommandSelection, + ReverseEngineeringCommandUnavailable, ReverseEngineeringProviderCommand, } from "./ReverseEngineeringCommandPort.js"; import { @@ -172,6 +174,51 @@ interface ReverseCommandObservation { readonly outputBudgetBytes: number; } +/** The provider a reverse command runs and how a caller repairs its command. */ +const reverseCommandRepair = ( + operation: ReverseEngineeringOperation, +): { readonly provider: string; readonly remediationAction: string } => { + const objdump = operation === "inspect_with_objdump"; + return { + provider: objdump ? "objdump" : "rizin", + remediationAction: `Set ${objdump ? "REA_OBJDUMP_COMMAND" : "REA_RIZIN_COMMAND"} to an installed executable ${objdump ? "GNU objdump" : "Rizin"} command, or make that command available on PATH. Check execute permission on the command and search permission on its parent directories before retrying.`, + }; +}; + +/** A configured command the host search did not find; nothing was launched. */ +const reverseCommandUnavailable = ( + operation: ReverseEngineeringOperation, + unavailable: ReverseEngineeringCommandUnavailable, +): AnalysisCapabilityUnavailableError => { + const { provider, remediationAction } = reverseCommandRepair(operation); + const { command, reason } = unavailable; + return new AnalysisCapabilityUnavailableError( + unavailable.identity.id, + operation, + reason.kind === "not_found" + ? "Configured command was not found on PATH" + : `Configured command could not be searched on PATH (invalid PATHEXT entry ${JSON.stringify(reason.extension)})`, + { + userMessage: + reason.kind === "not_found" + ? `Could not find the selected ${provider} command ${JSON.stringify(command)} on PATH; REA did not search the current directory.` + : `Could not search PATH for the selected ${provider} command ${JSON.stringify(command)}: PATHEXT entry ${JSON.stringify(reason.extension)} is not a filename extension.`, + remediationAction: + reason.kind === "not_found" + ? remediationAction + : `Remove ${JSON.stringify(reason.extension)} from PATHEXT, or ${remediationAction.charAt(0).toLowerCase()}${remediationAction.slice(1)}`, + diagnostics: { + boundary: "provider-lookup", + command, + lookup_failure: reason.kind, + ...(reason.kind === "invalid_pathext" + ? { pathext_entry: reason.extension } + : {}), + }, + }, + ); +}; + /** Shared application workflow for caller-selected local analysis commands. */ export class ReverseEngineeringService { readonly #environment: Readonly; @@ -286,6 +333,29 @@ export class ReverseEngineeringService { return created; } + /** + * Record that this dispatch could not run the configured command, unless a + * later dispatch has already succeeded or failed. + */ + #recordUnavailable( + operation: ReverseEngineeringOperation, + dispatch: number, + failure: AnalysisCapabilityUnavailableError, + ): void { + const outcome = this.#outcome(operation); + if ( + (outcome.failure === undefined || dispatch > outcome.failure.dispatch) && + dispatch > outcome.succeeded + ) + outcome.failure = { + dispatch, + reason: failure.userMessage ?? failure.message, + remediation: + failure.remediationAction ?? + reverseCommandRepair(operation).remediationAction, + }; + } + /** Current launch failures observed for caller-configured standalone tools. */ observedLaunchFailures(): CommandProviderLaunchFailures { const result: Partial< @@ -358,7 +428,7 @@ export class ReverseEngineeringService { ); if (!ready.ok) return ready; } - const command = this.#commands.objdump(input); + const selection = this.#commands.objdump(input); let parameters: Readonly>; if (input.operation === "disassemble") { const { symbol, ...base } = input; @@ -370,7 +440,7 @@ export class ReverseEngineeringService { parameters = { ...parameters, max_output_bytes: maxOutputBytes }; const observed = await this.#observe({ operation: "inspect_with_objdump", - profile: command, + selection, path: input.operation === "architectures" ? undefined : input.path, parameters, signal: options.signal, @@ -402,10 +472,9 @@ export class ReverseEngineeringService { max_output_bytes: maxOutputBytes, ...input } = request; - const command = this.#commands.rizin(input); return this.#observe({ operation: "execute_rizin_command", - profile: command, + selection: this.#commands.rizin(input), path: input.path, parameters: request, signal: options.signal, @@ -414,9 +483,12 @@ export class ReverseEngineeringService { }); } - #observe( - request: ReverseCommandObservation, - ): Promise> { + #observe({ + selection, + ...request + }: Omit & { + readonly selection: ReverseEngineeringCommandSelection; + }): Promise> { const { operation, signal, outputBudgetBytes } = request; if (signal?.aborted) return Promise.resolve( @@ -431,6 +503,13 @@ export class ReverseEngineeringService { ), ), ); + // A command the host search did not find is this dispatch's outcome: + // discovery reports it like a launch failure until a later success. + if (!selection.ok) { + const refusal = reverseCommandUnavailable(operation, selection.error); + this.#recordUnavailable(operation, ++this.#dispatched, refusal); + return Promise.resolve(err(refusal)); + } if ( this.#activeOperations.size >= MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS || @@ -470,6 +549,7 @@ export class ReverseEngineeringService { this.#reservedOutputBytes += outputBudgetBytes; const running = this.#observeProvider({ ...request, + profile: selection.value, signal: operationSignal, }); const tracked = running @@ -902,12 +982,8 @@ export class ReverseEngineeringService { ...reverseCommandTimeoutGuidance(timeoutMs), }); else { - const provider = - operation === "inspect_with_objdump" ? "objdump" : "rizin"; - const setting = - provider === "objdump" - ? "REA_OBJDUMP_COMMAND" - : "REA_RIZIN_COMMAND"; + const { provider, remediationAction } = + reverseCommandRepair(operation); earlyFailure = new AnalysisCapabilityUnavailableError( profile.identity.id, operation, @@ -915,7 +991,7 @@ export class ReverseEngineeringService { { cause, userMessage: `Could not launch the selected ${provider} executable ${JSON.stringify(command)} (${String(code ?? "unknown error")}).`, - remediationAction: `Set ${setting} to an installed executable ${provider === "objdump" ? "GNU objdump" : "Rizin"} command, or make that command available on PATH. Check execute permission on the command and search permission on its parent directories before retrying.`, + remediationAction, diagnostics: { boundary: "provider-launch", command, @@ -1018,20 +1094,7 @@ export class ReverseEngineeringService { earlyFailure.diagnostics?.boundary === "provider-launch" && earlyFailure.providerId === profile.identity.id ) { - const outcome = this.#outcome(operation); - if ( - (outcome.failure === undefined || - dispatch > outcome.failure.dispatch) && - dispatch > outcome.succeeded - ) { - outcome.failure = { - dispatch, - reason: earlyFailure.userMessage ?? earlyFailure.message, - remediation: - earlyFailure.remediationAction ?? - `Set ${operation === "inspect_with_objdump" ? "REA_OBJDUMP_COMMAND" : "REA_RIZIN_COMMAND"} to an installed executable command.`, - }; - } + this.#recordUnavailable(operation, dispatch, earlyFailure); } return err(this.#registerCleanupFailure(earlyFailure, cleanupOwner)); } diff --git a/src/composition/resolveWindowsProviderExecutable.ts b/src/composition/resolveWindowsProviderExecutable.ts index dc255939ae..896faf22f1 100644 --- a/src/composition/resolveWindowsProviderExecutable.ts +++ b/src/composition/resolveWindowsProviderExecutable.ts @@ -1,6 +1,10 @@ import { statSync } from "node:fs"; import { win32 } from "node:path"; -import { windowsPathEntries } from "../process/WindowsExecutable.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + windowsPathCandidates, + type WindowsPathSearchFailure, +} from "../process/executableSearch.js"; export interface WindowsExecutableLookup { /** Windows-formatted working directory; defaults to the process CWD. */ @@ -17,43 +21,32 @@ const isRegularFile = (path: string): boolean => { } }; -/** Resolve Windows bare command names against the captured child environment. */ +/** + * Resolve a configured command against the captured child environment. A + * path is resolved against `cwd`; a bare name must be found on PATH/PATHEXT, + * and is never returned unresolved, because Windows would then also search + * the current directory. + */ export const resolveWindowsProviderExecutable = ( executable: string, environment: Readonly, lookup: WindowsExecutableLookup = {}, -): string => { +): Result< + string, + { readonly kind: "not_found" } | WindowsPathSearchFailure +> => { const cwd = lookup.cwd ?? process.cwd(); - const absolute = (...paths: string[]): string => win32.resolve(cwd, ...paths); if ( win32.isAbsolute(executable) || executable.includes("/") || executable.includes("\\") || win32.parse(executable).root !== "" ) - return absolute(executable); + return ok(win32.resolve(cwd, executable)); - const pathValue = environment.PATH ?? environment.Path ?? environment.path; - if (pathValue === undefined) return executable; - const pathEntries = windowsPathEntries(pathValue); - const explicitExtension = win32.extname(executable).toLowerCase(); - if (explicitExtension !== "" && ![".com", ".exe"].includes(explicitExtension)) - return executable; - const extensions = - explicitExtension !== "" - ? [""] - : (environment.PATHEXT ?? environment.PathExt ?? ".COM;.EXE") - .split(";") - .filter((extension) => - [".com", ".exe"].includes(extension.toLowerCase()), - ); - - for (const entry of pathEntries) { - if (entry.length === 0) continue; - for (const extension of extensions) { - const candidate = absolute(entry, `${executable}${extension}`); - if ((lookup.isFile ?? isRegularFile)(candidate)) return candidate; - } - } - return executable; + const candidates = windowsPathCandidates(executable, environment, { cwd }); + if (!candidates.ok) return candidates; + for (const { path } of candidates.value) + if ((lookup.isFile ?? isRegularFile)(path)) return ok(path); + return err({ kind: "not_found" }); }; diff --git a/src/composition/reverseEngineering.ts b/src/composition/reverseEngineering.ts index 301c65f1aa..fafef9f338 100644 --- a/src/composition/reverseEngineering.ts +++ b/src/composition/reverseEngineering.ts @@ -1,5 +1,10 @@ import type { ReverseEngineeringServiceRuntimeOptions } from "../application/reverse/ReverseEngineeringService.js"; -import type { ReverseEngineeringCommandPort } from "../application/reverse/ReverseEngineeringCommandPort.js"; +import type { + ReverseEngineeringCommandPort, + ReverseEngineeringCommandSelection, + ReverseEngineeringProviderCommand, +} from "../application/reverse/ReverseEngineeringCommandPort.js"; +import { err, ok } from "../domain/result.js"; import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js"; import { objdumpCommand } from "../objdump/ObjdumpCommand.js"; import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; @@ -15,37 +20,50 @@ export const createReverseEngineeringCommandPort = ( environment: Readonly, platform: NodeJS.Platform = process.platform, ): ReverseEngineeringCommandPort => ({ - objdump: (input) => ({ - executable: providerExecutable( + objdump: (input) => + selectCommand( environment.REA_OBJDUMP_COMMAND ?? "objdump", environment, platform, + { + arguments: objdumpCommand(input), + identity: OBJDUMP_PROVIDER_IDENTITY, + environmentOverrides: { DEBUGINFOD_URLS: "" }, + }, ), - arguments: objdumpCommand(input), - identity: OBJDUMP_PROVIDER_IDENTITY, - environmentOverrides: { DEBUGINFOD_URLS: "" }, - }), - rizin: (input) => ({ - executable: providerExecutable( + rizin: (input) => + selectCommand( environment.REA_RIZIN_COMMAND ?? "rizin", environment, platform, + { + arguments: rizinCommand(input), + limitations: rizinCommandLimitations(input), + rejection: rizinCommandRejection, + identity: RIZIN_PROVIDER_IDENTITY, + }, ), - arguments: rizinCommand(input), - limitations: rizinCommandLimitations(input), - rejection: rizinCommandRejection, - identity: RIZIN_PROVIDER_IDENTITY, - }), }); -const providerExecutable = ( - executable: string, +/** Bind the configured executable, resolving Windows bare names on PATH. */ +const selectCommand = ( + command: string, environment: Readonly, platform: NodeJS.Platform, -): string => - platform === "win32" - ? resolveWindowsProviderExecutable(executable, environment) - : executable; + profile: Omit, +): ReverseEngineeringCommandSelection => { + if (platform !== "win32") return ok({ ...profile, executable: command }); + const resolved = resolveWindowsProviderExecutable(command, environment); + if (resolved.ok) return ok({ ...profile, executable: resolved.value }); + return err({ + identity: profile.identity, + command, + reason: + resolved.error.kind === "not_found" + ? { kind: "not_found" } + : { kind: "invalid_pathext", extension: resolved.error.extension }, + }); +}; /** Compose BYO reverse-engineering commands without acquiring tools at import time. */ export const createReverseEngineeringService = ( diff --git a/src/desktop/BuildComputerEnvironment.ts b/src/desktop/BuildComputerEnvironment.ts index 270671a436..376884e349 100644 --- a/src/desktop/BuildComputerEnvironment.ts +++ b/src/desktop/BuildComputerEnvironment.ts @@ -5,6 +5,8 @@ import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { reaCacheDirectory } from "../config/kunaCacheDirectory.js"; import { appendCallerPath } from "../filesystem/callerPath.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { HOST_PLATFORM } from "../platform/HostPlatform.js"; +import { windowsPathCandidates } from "../process/executableSearch.js"; import { BUILD_COMPUTER_SETTINGS } from "../domain/desktop/buildComputerSettings.js"; import { resolveProgram } from "./ProgramLookup.js"; @@ -39,11 +41,23 @@ export const defaultBuildComputerArchive = ( export const agentEnvReportedVersion = (stdout: string): string | undefined => /^agent-env (\S+)\s*$/u.exec(stdout.trim())?.[1]; -/** An absolute program on PATH, never one a relative PATH entry found. */ +/** + * An absolute program on this host's PATH, never one a relative PATH entry + * found. Windows searches PATH and PATHEXT as its launcher does; other hosts + * search POSIX PATH. + */ export const programOnPath = async ( program: string, environment: Readonly, ): Promise => { + if (HOST_PLATFORM.platform === "win32") { + const candidates = windowsPathCandidates(program, environment); + if (!candidates.ok) return undefined; + for (const candidate of candidates.value) + if (!candidate.relativeEntry && (await isFile(candidate.path))) + return candidate.path; + return undefined; + } const found = await resolveProgram(program, environment, process.cwd()); return found === undefined || found.fromRelativeEntry ? undefined diff --git a/src/desktop/ProgramLookup.ts b/src/desktop/ProgramLookup.ts index b8fbe7c09f..352f267327 100644 --- a/src/desktop/ProgramLookup.ts +++ b/src/desktop/ProgramLookup.ts @@ -1,6 +1,8 @@ import { constants } from "node:fs"; import { access, stat } from "node:fs/promises"; -import { delimiter, isAbsolute } from "node:path"; + +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { posixPathCandidates } from "../process/executableSearch.js"; const isExecutable = async (path: string): Promise => { try { @@ -11,10 +13,6 @@ const isExecutable = async (path: string): Promise => { } }; -/** A path without empty or `.` segments, which name nothing; `..` stays, since it climbs from a link's target. */ -const tidy = (path: string): string => - path.replace(/\/(?:\.?\/)+/gu, "/").replace(/(?<=.)\/\.?$/u, ""); - /** A program as found, and whether a relative or empty PATH entry found it. */ export interface FoundProgram { readonly path: string; @@ -32,18 +30,21 @@ export const resolveProgram = async ( environment: Readonly>, startedIn: string, ): Promise => { - const from = (path: string) => - tidy(isAbsolute(path) ? path : `${startedIn}/${path}`); if (program.includes("/")) { - const path = from(program); + const path = absoluteCallerPath(program, { cwd: startedIn }); return (await isExecutable(path)) ? { path, fromRelativeEntry: false } : undefined; } - for (const directory of environment.PATH?.split(delimiter) ?? []) { - const path = from(directory === "" ? program : `${directory}/${program}`); - if (await isExecutable(path)) - return { path, fromRelativeEntry: !isAbsolute(directory) }; - } + if (environment.PATH === undefined) return undefined; + for (const candidate of posixPathCandidates(program, environment.PATH, { + relativeEntries: "resolve", + workingDirectory: startedIn, + })) + if (await isExecutable(candidate.path)) + return { + path: candidate.path, + fromRelativeEntry: candidate.relativeEntry, + }; return undefined; }; diff --git a/src/filesystem/callerPath.ts b/src/filesystem/callerPath.ts index a2ff309288..1e17b24a90 100644 --- a/src/filesystem/callerPath.ts +++ b/src/filesystem/callerPath.ts @@ -35,6 +35,12 @@ export const absoluteCallerPath = ( if (platform === "win32") return win32.resolve(cwd ?? process.cwd(), path); if (!posix.isAbsolute(path)) { const selectedCwd = cwd ?? process.cwd(); + // This process's own directory must be POSIX-absolute to anchor POSIX + // semantics; resolving a Windows directory against itself never ends. + if (cwd === undefined && !posix.isAbsolute(selectedCwd)) + throw new TypeError( + `Cannot resolve ${path} with POSIX semantics from the non-POSIX working directory ${selectedCwd}`, + ); const base = posix.isAbsolute(selectedCwd) ? selectedCwd : absoluteCallerPath(selectedCwd, { platform }); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 386df34def..b69286319d 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -115,6 +115,21 @@ it.skipIf(process.platform === "win32")( process.platform, ), ).toBe(installation); + // A removed working directory holds nothing under `.` or an empty entry; + // a later absolute entry still finds the installation. + const cwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + try { + expect( + discoverGhidraInstallationOnPath( + { PATH: `.::${pathDirectory}` }, + process.platform, + ), + ).toBe(installation); + } finally { + cwd.mockRestore(); + } const incomplete = join(root, "incomplete"); const incompletePath = join(root, "incomplete-bin"); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 52b07f4f28..f43db07e5c 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -18,6 +18,11 @@ import { absoluteCallerPath, appendCallerPath, } from "../filesystem/callerPath.js"; +import { + posixPathCandidates, + windowsPathCandidates, + type PathSearchCandidate, +} from "../process/executableSearch.js"; import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, @@ -29,7 +34,7 @@ import { realpathSync, statSync, } from "node:fs"; -import { delimiter as pathDelimiter, dirname, posix, win32 } from "node:path"; +import { dirname, posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { @@ -569,12 +574,13 @@ const installationCoordinates = async ( command: platform === "win32" ? "java.exe" : "java", }; // Check the caller's PATH, never a file named java in the working directory. - const javaOnPath = options.environment.PATH?.split(":") - .filter((directory) => path.isAbsolute(directory)) - .map((directory) => absoluteCallerPath(`${directory}/java`, { platform })) - .find((candidate) => host.executable(candidate)); - if (javaOnPath !== undefined) - return { source: "path", command: javaOnPath }; + for (const { path: javaOnPath } of posixPathCandidates( + "java", + options.environment.PATH ?? "", + { relativeEntries: "skip" }, + )) + if (host.executable(javaOnPath)) + return { source: "path", command: javaOnPath }; const homebrew = homebrewJavaCandidates()[0]; return homebrew === undefined ? { source: "default", command: "/usr/bin/java" } @@ -1128,41 +1134,46 @@ export const discoverGhidraInstallationOnPath = ( ): string | undefined => { if (platform !== process.platform) return undefined; const path = platform === "win32" ? win32 : posix; - const pathValue = environment.PATH; - if (pathValue === undefined) return undefined; - const launchers = + if (platform !== "win32" && environment.PATH === undefined) return undefined; + // Each PATH entry is tried for every launcher name before the next entry. + // POSIX empty entries select cwd, including an entirely empty PATH. + const windowsSearch = platform === "win32" - ? ["ghidra.bat", "ghidraRun.bat", "ghidra.exe", "ghidraRun.exe"] - : ["ghidra", "ghidraRun"]; - for (const directory of pathValue.split(pathDelimiter)) { - // POSIX empty PATH entries select cwd, including an entirely empty PATH. - if (directory.length === 0 && platform === "win32") continue; - for (const launcher of launchers) { - let resolved: string; - try { - const candidate = absoluteCallerPath( - appendCallerPath(directory, launcher, platform), - { platform }, - ); - if (!executablePath(candidate)) continue; - resolved = realpathSync.native(candidate); - } catch (cause: unknown) { - void cause; - continue; - } - const root = dirname(resolved); - const properties = path.join(root, "Ghidra", "application.properties"); - const headless = path.join( - root, - "support", - platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless", - ); - try { - if (statSync(properties).isFile() && executablePath(headless)) - return stableGhidraInstallationRoot(root, platform); - } catch (cause: unknown) { - void cause; - } + ? windowsPathCandidates( + ["ghidra.bat", "ghidraRun.bat", "ghidra.exe", "ghidraRun.exe"], + environment, + { allowBatch: true }, + ) + : undefined; + const candidates: Iterable = + windowsSearch === undefined + ? posixPathCandidates(["ghidra", "ghidraRun"], environment.PATH ?? "", { + relativeEntries: "resolve", + }) + : windowsSearch.ok + ? windowsSearch.value + : []; + for (const { path: candidate } of candidates) { + let resolved: string; + try { + if (!executablePath(candidate)) continue; + resolved = realpathSync.native(candidate); + } catch (cause: unknown) { + void cause; + continue; + } + const root = dirname(resolved); + const properties = path.join(root, "Ghidra", "application.properties"); + const headless = path.join( + root, + "support", + platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless", + ); + try { + if (statSync(properties).isFile() && executablePath(headless)) + return stableGhidraInstallationRoot(root, platform); + } catch (cause: unknown) { + void cause; } } return undefined; diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index 8506aae1a7..b04d35bd24 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -1,7 +1,7 @@ import { accessSync, constants, statSync } from "node:fs"; -import { delimiter, isAbsolute } from "node:path"; +import { isAbsolute } from "node:path"; -import { appendCallerPath } from "../filesystem/callerPath.js"; +import { posixPathCandidates } from "../process/executableSearch.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; @@ -147,9 +147,11 @@ export const locateWiresharkTool = ( ): string | undefined => { if (selection.source === "environment" || isAbsolute(selection.command)) return selection.command; - for (const directory of (environment.PATH ?? "").split(delimiter)) { - if (!isAbsolute(directory)) continue; - const candidate = appendCallerPath(directory, selection.command); + for (const { path: candidate } of posixPathCandidates( + selection.command, + environment.PATH ?? "", + { relativeEntries: "skip" }, + )) { try { accessSync(candidate, constants.X_OK); if (statSync(candidate).isFile()) return candidate; diff --git a/src/process/WindowsExecutable.ts b/src/process/WindowsExecutable.ts index b74f6e6174..777f876b2b 100644 --- a/src/process/WindowsExecutable.ts +++ b/src/process/WindowsExecutable.ts @@ -1,30 +1,8 @@ import { stat } from "node:fs/promises"; -import { win32 } from "node:path"; import { err, ok, type Result } from "../domain/result.js"; import { AbortedWait, waitForAbortable } from "./AbortablePromise.js"; -import { snapshotEnvironment } from "./snapshotEnvironment.js"; - -/** Parse PATH once, preserving quoted semicolons and explicit relative entries. */ -export const windowsPathEntries = (value: string): readonly string[] => { - const entries: string[] = []; - let start = 0; - let quoted = false; - for (let index = 0; index < value.length; index += 1) { - if (value[index] === '"') quoted = !quoted; - else if (value[index] === ";" && !quoted) { - entries.push(value.slice(start, index)); - start = index + 1; - } - } - entries.push(value.slice(start)); - return entries.flatMap((entry) => { - if (entry === "") return []; - if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2) - return entry.length === 2 ? [] : [entry.slice(1, -1)]; - return entry.includes('"') ? [] : [entry]; - }); -}; +import { windowsPathCandidates } from "./executableSearch.js"; export type WindowsExecutableFailure = | { readonly kind: "missing" } @@ -38,7 +16,8 @@ export type WindowsExecutableFailure = }; /** - * Resolve a default bare command against PATH/PATHEXT only. Never return a bare + * Resolve a default bare command against PATH/PATHEXT only, probing the + * search owner's candidates (src/process/executableSearch) in order. Never return a bare * name on failure: handing it to Windows would restart implicit CWD lookup. * Callers own the lookup deadline and translation to their operation errors. */ @@ -52,56 +31,28 @@ export const findWindowsExecutable = async ( }, ): Promise> => { if (options.signal.aborted) return err({ kind: "interrupted" }); - const selected = snapshotEnvironment(environment, "win32"); - const extensions = (selected.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .filter((extension) => extension !== ""); - for (const extension of extensions) - if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension)) - return err({ kind: "invalid_extension", extension }); - const explicitExtension = win32.extname(executable); - const allowed = - explicitExtension !== "" - ? options.allowBatch === true || - [".exe", ".com"].includes(explicitExtension.toLowerCase()) - ? [""] - : [] - : options.allowBatch === true - ? extensions - : extensions.filter((extension) => - [".exe", ".com"].includes(extension.toLowerCase()), - ); - const cwd = options.cwd ?? process.cwd(); - for (const directory of windowsPathEntries(selected.PATH ?? "")) { - for (const extension of allowed) { + const candidates = windowsPathCandidates(executable, environment, options); + if (!candidates.ok) return candidates; + for (const { path: candidate } of candidates.value) { + if (options.signal.aborted) return err({ kind: "interrupted" }); + try { + const metadata = await waitForAbortable(stat(candidate), options.signal); + if (metadata instanceof AbortedWait || options.signal.aborted) + return err({ kind: "interrupted" }); + if (metadata.isFile()) return ok(candidate); + } catch (cause: unknown) { if (options.signal.aborted) return err({ kind: "interrupted" }); - const candidate = win32.resolve( - cwd, - directory, - `${executable}${extension}`, - ); - try { - const metadata = await waitForAbortable( - stat(candidate), - options.signal, - ); - if (metadata instanceof AbortedWait || options.signal.aborted) - return err({ kind: "interrupted" }); - if (metadata.isFile()) return ok(candidate); - } catch (cause: unknown) { - if (options.signal.aborted) return err({ kind: "interrupted" }); - const code = - typeof cause === "object" && cause !== null - ? Reflect.get(cause, "code") - : undefined; - if (code === "ENOENT" || code === "ENOTDIR") continue; - return err({ - kind: "inspection", - path: candidate, - code: typeof code === "string" ? code : null, - cause, - }); - } + const code = + typeof cause === "object" && cause !== null + ? Reflect.get(cause, "code") + : undefined; + if (code === "ENOENT" || code === "ENOTDIR") continue; + return err({ + kind: "inspection", + path: candidate, + code: typeof code === "string" ? code : null, + cause, + }); } } return err({ kind: "missing" }); diff --git a/src/process/executableSearch.test.ts b/src/process/executableSearch.test.ts new file mode 100644 index 0000000000..6702cb0c69 --- /dev/null +++ b/src/process/executableSearch.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + posixPathCandidates, + windowsPathCandidates, +} from "./executableSearch.js"; + +describe("posixPathCandidates", () => { + it("searches PATH in order, resolving empty and relative entries against the working directory", () => { + expect([ + ...posixPathCandidates("tool", "/usr/bin::bin:/opt/./x/../y/", { + relativeEntries: "resolve", + workingDirectory: "/work", + }), + ]).toEqual([ + { path: "/usr/bin/tool", relativeEntry: false }, + { path: "/work/tool", relativeEntry: true }, + { path: "/work/bin/tool", relativeEntry: true }, + // `..` stays for the filesystem to resolve after a symbolic link. + { path: "/opt/x/../y/tool", relativeEntry: false }, + ]); + }); + + it("leaves out entries that depend on the working directory when asked", () => { + expect([ + ...posixPathCandidates("tool", ":bin:/usr/bin", { + relativeEntries: "skip", + }), + ]).toEqual([{ path: "/usr/bin/tool", relativeEntry: false }]); + // An empty PATH is one empty entry: the working directory. + expect([ + ...posixPathCandidates("tool", "", { relativeEntries: "skip" }), + ]).toEqual([]); + expect([ + ...posixPathCandidates("tool", "", { + relativeEntries: "resolve", + workingDirectory: "/work", + }), + ]).toEqual([{ path: "/work/tool", relativeEntry: true }]); + }); + + it("resolves no entry after the one the caller stops at", () => { + // Resolving `.` without a working directory reads this process's + // directory, which may have been removed; an earlier match must not + // depend on it. + const cwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + try { + const [first] = posixPathCandidates("java", "/jdk/bin:.", { + relativeEntries: "resolve", + }); + expect(first).toEqual({ path: "/jdk/bin/java", relativeEntry: false }); + expect(cwd).not.toHaveBeenCalled(); + } finally { + cwd.mockRestore(); + } + }); + + it("skips relative entries once the working directory is removed and searches later ones", () => { + const cwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + try { + expect([ + ...posixPathCandidates("java", ".::/jdk/bin", { + relativeEntries: "resolve", + }), + ]).toEqual([{ path: "/jdk/bin/java", relativeEntry: false }]); + const windows = windowsPathCandidates("java", { + PATH: "bin;C:\\jdk\\bin", + PATHEXT: ".EXE", + }); + if (!windows.ok) throw new Error(windows.error.extension); + expect([...windows.value]).toEqual([ + { path: "C:\\jdk\\bin\\java.EXE", relativeEntry: false }, + ]); + } finally { + cwd.mockRestore(); + } + }); + + it("tries every name in an entry before the next entry", () => { + expect([ + ...posixPathCandidates(["ghidra", "ghidraRun"], "/a:/b", { + relativeEntries: "skip", + }), + ]).toEqual([ + { path: "/a/ghidra", relativeEntry: false }, + { path: "/a/ghidraRun", relativeEntry: false }, + { path: "/b/ghidra", relativeEntry: false }, + { path: "/b/ghidraRun", relativeEntry: false }, + ]); + }); +}); + +describe("windowsPathCandidates", () => { + const paths = ( + ...input: Parameters + ): readonly string[] => { + const search = windowsPathCandidates(...input); + if (!search.ok) throw new Error(search.error.extension); + return [...search.value].map(({ path }) => path); + }; + + it("searches quoted, relative and case-insensitive PATH entries with image PATHEXT extensions", () => { + expect( + paths( + "rizin", + { + Path: '"C:\\Program Files\\a;b";.\\bin;;"";bad"quote', + PathExt: ".CMD;.EXE;.COM", + }, + { cwd: "D:\\work" }, + ), + ).toEqual([ + "C:\\Program Files\\a;b\\rizin.EXE", + "C:\\Program Files\\a;b\\rizin.COM", + "D:\\work\\bin\\rizin.EXE", + "D:\\work\\bin\\rizin.COM", + ]); + }); + + it("opens a quote only at the start of an entry, so a stray quote cannot hide later entries", () => { + expect( + paths( + ["ghidra.bat"], + { PATH: 'bad"quote;C:\\ghidra;"C:\\a;b"' }, + { allowBatch: true, cwd: "C:\\" }, + ), + ).toEqual(["C:\\ghidra\\ghidra.bat", "C:\\a;b\\ghidra.bat"]); + }); + + it("includes batch launchers only when the caller can start them through cmd.exe", () => { + const environment = { PATH: "C:\\tools", PATHEXT: ".BAT;.EXE" }; + expect(paths("apktool", environment, { cwd: "C:\\" })).toEqual([ + "C:\\tools\\apktool.EXE", + ]); + expect( + paths("apktool", environment, { cwd: "C:\\", allowBatch: true }), + ).toEqual(["C:\\tools\\apktool.BAT", "C:\\tools\\apktool.EXE"]); + expect(paths("npm.cmd", environment, { cwd: "C:\\" })).toEqual([]); + }); + + it("tries every launcher name in an entry before the next entry", () => { + expect( + paths( + ["ghidra.bat", "ghidra.exe"], + { PATH: "C:\\a;C:\\b" }, + { allowBatch: true, cwd: "C:\\" }, + ), + ).toEqual([ + "C:\\a\\ghidra.bat", + "C:\\a\\ghidra.exe", + "C:\\b\\ghidra.bat", + "C:\\b\\ghidra.exe", + ]); + }); + + it("fails a search that would append an invalid PATHEXT entry, but not one with a named extension", () => { + const environment = { PATH: "C:\\jdk\\bin", PATHEXT: ".EXE;C:\\bad" }; + expect(windowsPathCandidates("java", environment)).toEqual({ + ok: false, + error: { kind: "invalid_extension", extension: "C:\\bad" }, + }); + expect(paths("java.exe", environment, { cwd: "C:\\" })).toEqual([ + "C:\\jdk\\bin\\java.exe", + ]); + }); +}); diff --git a/src/process/executableSearch.ts b/src/process/executableSearch.ts new file mode 100644 index 0000000000..3ea455a2d4 --- /dev/null +++ b/src/process/executableSearch.ts @@ -0,0 +1,175 @@ +import { posix, win32 } from "node:path"; + +import { + absoluteCallerPath, + appendCallerPath, +} from "../filesystem/callerPath.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { snapshotEnvironment } from "./snapshotEnvironment.js"; + +/** One file a launcher tries for a bare command, in search order. */ +export interface PathSearchCandidate { + /** The absolute file the launch would execute from this entry. */ + readonly path: string; + /** An empty or relative PATH entry, which depends on the working directory. */ + readonly relativeEntry: boolean; +} + +/** + * How entries that depend on the working directory are searched: `skip` leaves + * them out; otherwise they resolve against `workingDirectory`, or against this + * process's directory when omitted (read only when such an entry exists). + */ +export type RelativePathEntries = + | { readonly relativeEntries: "skip" } + | { readonly relativeEntries: "resolve"; readonly workingDirectory?: string }; + +/** + * The files `execvp` tries for a bare command (one without `/`), in PATH + * order: an empty entry names the working directory and a relative entry + * resolves against it. `..` is kept for the filesystem to resolve after any + * symbolic link. Each caller decides how a candidate is probed and how a + * failed probe is reported; this owner fixes only the search order. + * Candidates are produced on demand: stop iterating at the first match. + * Several names are tried in each entry before the next entry. + */ +export function* posixPathCandidates( + commands: string | readonly string[], + searchPath: string, + selection: RelativePathEntries, +): Generator { + // Lazy, so an entry after the first match is never resolved: resolving a + // relative entry reads the working directory, which may no longer exist. + for (const directory of searchPath.split(posix.delimiter)) { + const relativeEntry = !posix.isAbsolute(directory); + if (relativeEntry && selection.relativeEntries === "skip") continue; + const workingDirectory = + relativeEntry && selection.relativeEntries === "resolve" + ? (selection.workingDirectory ?? currentDirectory()) + : undefined; + // A removed working directory holds nothing; later entries still count. + if (relativeEntry && workingDirectory === undefined) continue; + for (const command of typeof commands === "string" ? [commands] : commands) + yield { + path: absoluteCallerPath( + appendCallerPath(directory, command, "linux"), + { + ...(workingDirectory === undefined + ? {} + : { cwd: workingDirectory }), + platform: "linux", + }, + ), + relativeEntry, + }; + } +} + +/** + * This process's directory, or `undefined` once it has been removed: a + * launcher then finds nothing under a relative entry and moves on. + */ +const currentDirectory = (): string | undefined => { + try { + return process.cwd(); + } catch (cause: unknown) { + // A removed directory has no path; the entry names nothing. + void cause; + return undefined; + } +}; + +/** + * Windows PATH entries in order. A quote opens only at the start of an entry, + * and a quoted entry may contain `;`; elsewhere a quote is an ordinary + * character, so it cannot join later entries. Empty entries, `""` and + * entries with a stray quote name nothing and are skipped. + */ +const windowsPathEntries = (value: string): readonly string[] => { + const entries: string[] = []; + let start = 0; + let quoted = false; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '"' && (quoted || index === start)) quoted = !quoted; + else if (value[index] === ";" && !quoted) { + entries.push(value.slice(start, index)); + start = index + 1; + } + } + entries.push(value.slice(start)); + return entries.flatMap((entry) => { + if (entry === "") return []; + if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2) + return entry.length === 2 ? [] : [entry.slice(1, -1)]; + return entry.includes('"') ? [] : [entry]; + }); +}; + +/** Image extensions Windows starts directly; anything else needs a host such as cmd.exe. */ +const WINDOWS_IMAGE_EXTENSIONS: ReadonlySet = new Set([".com", ".exe"]); + +/** A PATHEXT entry that is not a filename extension, so the search is unknown. */ +export interface WindowsPathSearchFailure { + readonly kind: "invalid_extension"; + readonly extension: string; +} + +/** + * The files Windows' PATH/PATHEXT search tries for bare commands, in order: + * for each PATH entry (relative ones resolve against `cwd`, or against this + * process's directory, read only when such an entry is reached and skipped + * once removed), each command with every PATHEXT extension, + * or as written when it already names an extension. Only `.com` and `.exe` + * images are searched unless `allowBatch`; a `.bat` or `.cmd` launcher must + * then be started through src/process/WindowsBatchCommand. An invalid + * PATHEXT entry fails a search that would append extensions; a command that + * names its extension never consults PATHEXT. Environment names match without + * case. + */ +export const windowsPathCandidates = ( + commands: string | readonly string[], + environment: Readonly, + search: { readonly cwd?: string; readonly allowBatch?: boolean } = {}, +): Result, WindowsPathSearchFailure> => { + const selected = snapshotEnvironment(environment, "win32"); + const listed = typeof commands === "string" ? [commands] : commands; + const extensions = (selected.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter((extension) => extension !== ""); + const invalid = listed.every((command) => win32.extname(command) !== "") + ? undefined + : extensions.find( + (extension) => + !extension.startsWith(".") || /[\\/:\0]/u.test(extension), + ); + if (invalid !== undefined) + return err({ kind: "invalid_extension", extension: invalid }); + const runnable = (extension: string) => + search.allowBatch === true || + WINDOWS_IMAGE_EXTENSIONS.has(extension.toLowerCase()); + const names = listed.flatMap((command) => { + const explicit = win32.extname(command); + if (explicit !== "") return runnable(explicit) ? [command] : []; + return extensions + .filter(runnable) + .map((extension) => `${command}${extension}`); + }); + const entries = windowsPathEntries(selected.PATH ?? ""); + return ok({ + *[Symbol.iterator]() { + for (const directory of entries) { + // Only an entry without a drive or UNC root reads the working + // directory; a removed one holds nothing, and later entries count. + const workingDirectory = /^(?:[A-Za-z]:[\\/]|[\\/]{2})/u.test(directory) + ? directory + : (search.cwd ?? currentDirectory()); + if (workingDirectory === undefined) continue; + for (const name of names) + yield { + path: win32.resolve(workingDirectory, directory, name), + relativeEntry: !win32.isAbsolute(directory), + }; + } + }, + }); +}; diff --git a/tests/boundary/filesystem/callerPath.test.ts b/tests/boundary/filesystem/callerPath.test.ts index aa7cbfd53d..f22395196c 100644 --- a/tests/boundary/filesystem/callerPath.test.ts +++ b/tests/boundary/filesystem/callerPath.test.ts @@ -84,3 +84,18 @@ it("keeps unselected Windows share paths lexical", () => { }), ).toBe("C:\\CaseSensitive\\Different.js"); }); + +it("refuses POSIX resolution from a Windows working directory instead of recursing", () => { + const cwd = vi.spyOn(process, "cwd").mockReturnValue("C:\\work"); + try { + // A POSIX PATH search on a Windows host reached this with a relative entry. + expect(() => absoluteCallerPath("bin/uv", { platform: "linux" })).toThrow( + TypeError, + ); + expect(() => + absoluteCallerPath("bin/uv", { cwd: "relative", platform: "linux" }), + ).toThrow(TypeError); + } finally { + cwd.mockRestore(); + } +}); diff --git a/tests/composition/resolveWindowsProviderExecutable.test.ts b/tests/composition/resolveWindowsProviderExecutable.test.ts index 95ea2b144e..fdb16cea26 100644 --- a/tests/composition/resolveWindowsProviderExecutable.test.ts +++ b/tests/composition/resolveWindowsProviderExecutable.test.ts @@ -2,6 +2,8 @@ import { win32 } from "node:path"; import { describe, expect, it } from "vitest"; +import { err, ok } from "../../src/domain/result.js"; +import { RIZIN_PROVIDER_IDENTITY } from "../../src/rizin/RizinProviderIdentity.js"; import { resolveWindowsProviderExecutable } from "../../src/composition/resolveWindowsProviderExecutable.js"; import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; @@ -29,7 +31,7 @@ describe("resolveWindowsProviderExecutable", () => { }, windowsLookup(cwd, [expected]), ), - ).toBe(expected); + ).toEqual(ok(expected)); }); it("resolves a relative configured path against the caller's Windows CWD", () => { @@ -39,7 +41,7 @@ describe("resolveWindowsProviderExecutable", () => { {}, windowsLookup("D:\\analysis", []), ), - ).toBe("D:\\analysis\\tools\\objdump.exe"); + ).toEqual(ok("D:\\analysis\\tools\\objdump.exe")); }); it("resolves relative PATH hits to absolute drive paths", () => { @@ -50,7 +52,7 @@ describe("resolveWindowsProviderExecutable", () => { { PATH: ".\\bin", PATHEXT: ".EXE" }, windowsLookup("D:\\analysis", [expected]), ), - ).toBe(expected); + ).toEqual(ok(expected)); }); it("skips script extensions and preserves a caller-selected absolute path", () => { @@ -62,24 +64,31 @@ describe("resolveWindowsProviderExecutable", () => { { PATH: "C:\\tools", PATHEXT: ".CMD;.BAT;.EXE;.COM" }, windowsLookup("C:\\work", ["C:\\tools\\rizin.CMD", expected]), ), - ).toBe(expected); + ).toEqual(ok(expected)); expect( resolveWindowsProviderExecutable( selected, {}, windowsLookup("D:\\work", []), ), - ).toBe(selected); + ).toEqual(ok(selected)); }); - it("keeps unresolved bare command names unchanged", () => { + it("refuses an unresolved bare name instead of letting Windows search the current directory", () => { expect( resolveWindowsProviderExecutable( "missing-tool", { PATH: ".\\tools", PATHEXT: ".EXE" }, windowsLookup("C:\\work", []), ), - ).toBe("missing-tool"); + ).toEqual(err({ kind: "not_found" })); + expect( + resolveWindowsProviderExecutable( + "missing-tool", + { PATH: "C:\\tools", PATHEXT: ".EXE;C:\\bad" }, + windowsLookup("C:\\work", []), + ), + ).toEqual(err({ kind: "invalid_extension", extension: "C:\\bad" })); }); it("passes an absolute selected command through provider composition", () => { @@ -88,8 +97,25 @@ describe("resolveWindowsProviderExecutable", () => { "win32", ); expect( - commands.objdump({ path: "artifact", operation: "file_headers" }) - .executable, - ).toBe("C:\\tools\\objdump.exe"); + commands.objdump({ path: "artifact", operation: "file_headers" }), + ).toMatchObject({ + ok: true, + value: { executable: "C:\\tools\\objdump.exe" }, + }); + }); + + it("reports a bare command missing from PATH as unavailable before launch", () => { + const commands = createReverseEngineeringCommandPort( + { PATH: "", REA_RIZIN_COMMAND: "rea-missing-rizin" }, + "win32", + ); + expect(commands.rizin({ path: "artifact", command: "i" })).toMatchObject({ + ok: false, + error: { + identity: { id: RIZIN_PROVIDER_IDENTITY.id }, + command: "rea-missing-rizin", + reason: { kind: "not_found" }, + }, + }); }); }); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index e5480ff776..a905a198a3 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -8,6 +8,7 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; import { digestFile } from "../../src/application/reverse/ReverseEngineeringService.js"; +import { ok } from "../../src/domain/result.js"; import { TestReverseEngineeringService as ReverseEngineeringService } from "../support/reverseEngineeringService.js"; import { providerSnapshot } from "../support/reverseEngineeringCompositionFixtures.js"; import { createReverseEngineeringService } from "../../src/composition/reverseEngineering.js"; @@ -33,7 +34,7 @@ it.each(["objdump", "rizin"] as const)( }, }; const service = new ReverseEngineeringService({ - commands: { objdump: () => profile, rizin: () => profile }, + commands: { objdump: () => ok(profile), rizin: () => ok(profile) }, }); const inspect = (max_output_bytes: number) => provider === "objdump" diff --git a/tests/composition/reverseEngineeringCommandPort.test.ts b/tests/composition/reverseEngineeringCommandPort.test.ts index f1b574a612..1b8f400885 100644 --- a/tests/composition/reverseEngineeringCommandPort.test.ts +++ b/tests/composition/reverseEngineeringCommandPort.test.ts @@ -7,6 +7,8 @@ import { expect, it, vi } from "vitest"; import { ReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { ok } from "../../src/domain/result.js"; +import { createReverseEngineeringCommandPort } from "../../src/composition/reverseEngineering.js"; import { execFileOutput } from "../../src/process/ExecFileOutput.js"; type ExecFileOutputOptions = NonNullable[2]>; @@ -25,21 +27,23 @@ it("uses provider profiles supplied through the application port", async () => { const service = new ReverseEngineeringService({ environment: { REA_OBJDUMP_COMMAND: "ambient-objdump" }, commands: { - objdump: () => ({ - executable: "injected-objdump", - arguments: ["--injected", path], - identity: { - id: "test.objdump", - name: "Injected objdump", - version: null, - }, - environmentOverrides: { INJECTED_PROFILE: "yes" }, - }), - rizin: () => ({ - executable: "injected-rizin", - arguments: ["--injected", path], - identity: { id: "test.rizin", name: "Injected Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "injected-objdump", + arguments: ["--injected", path], + identity: { + id: "test.objdump", + name: "Injected objdump", + version: null, + }, + environmentOverrides: { INJECTED_PROFILE: "yes" }, + }), + rizin: () => + ok({ + executable: "injected-rizin", + arguments: ["--injected", path], + identity: { id: "test.rizin", name: "Injected Rizin", version: null }, + }), }, run, }); @@ -77,21 +81,23 @@ it("does not reintroduce ambient variables absent from the captured provider env const service = new ReverseEngineeringService({ environment: { REA_OBJDUMP_COMMAND: "captured-objdump" }, commands: { - objdump: () => ({ - executable: "captured-objdump", - arguments: [path], - identity: { - id: "test.objdump", - name: "Captured objdump", - version: null, - }, - environmentOverrides: { PROFILE_VALUE: "explicit" }, - }), - rizin: () => ({ - executable: "captured-rizin", - arguments: [path], - identity: { id: "test.rizin", name: "Captured Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "captured-objdump", + arguments: [path], + identity: { + id: "test.objdump", + name: "Captured objdump", + version: null, + }, + environmentOverrides: { PROFILE_VALUE: "explicit" }, + }), + rizin: () => + ok({ + executable: "captured-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Captured Rizin", version: null }, + }), }, run, }); @@ -117,20 +123,22 @@ it("projects missing and non-file objdump targets with actionable path reasons", const run = vi.fn(async () => ({ stdout: "", stderr: "" })); const service = new ReverseEngineeringService({ commands: { - objdump: () => ({ - executable: "unused-objdump", - arguments: [missing], - identity: { - id: "test.objdump", - name: "Test objdump", - version: null, - }, - }), - rizin: ({ path }) => ({ - executable: "unused-rizin", - arguments: [path], - identity: { id: "test.rizin", name: "Test Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "unused-objdump", + arguments: [missing], + identity: { + id: "test.objdump", + name: "Test objdump", + version: null, + }, + }), + rizin: ({ path }) => + ok({ + executable: "unused-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Test Rizin", version: null }, + }), }, run, }); @@ -191,20 +199,22 @@ it("keeps target preflight I/O failures out of invalid-input classification", as const service = new ReverseEngineeringService({ stat, commands: { - objdump: () => ({ - executable: "unused-objdump", - arguments: [path], - identity: { - id: "test.objdump", - name: "Test objdump", - version: null, - }, - }), - rizin: () => ({ - executable: "unused-rizin", - arguments: [path], - identity: { id: "test.rizin", name: "Test Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "unused-objdump", + arguments: [path], + identity: { + id: "test.objdump", + name: "Test objdump", + version: null, + }, + }), + rizin: () => + ok({ + executable: "unused-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Test Rizin", version: null }, + }), }, run, }); @@ -257,20 +267,22 @@ it.each([ const service = new ReverseEngineeringService({ stat, commands: { - objdump: () => ({ - executable: "unused-objdump", - arguments: [path], - identity: { - id: "test.objdump", - name: "Test objdump", - version: null, - }, - }), - rizin: () => ({ - executable: "unused-rizin", - arguments: [path], - identity: { id: "test.rizin", name: "Test Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "unused-objdump", + arguments: [path], + identity: { + id: "test.objdump", + name: "Test objdump", + version: null, + }, + }), + rizin: () => + ok({ + executable: "unused-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Test Rizin", version: null }, + }), }, run, }); @@ -308,3 +320,69 @@ it.each([ } }, ); + +it.each([ + { + operation: "inspect_with_objdump", + environment: { PATH: "", REA_OBJDUMP_COMMAND: "rea-missing-objdump" }, + run: (service: ReverseEngineeringService, signal?: AbortSignal) => + service.inspectWithObjdump( + { path: "artifact", operation: "file_headers" }, + signal === undefined ? {} : { signal }, + ), + command: "rea-missing-objdump", + reason: { lookup_failure: "not_found" }, + }, + { + operation: "execute_rizin_command", + environment: { PATH: "C:\\tools", PATHEXT: ".EXE;C:\\bad" }, + run: (service: ReverseEngineeringService, signal?: AbortSignal) => + service.executeRizinCommand( + { path: "artifact", command: "i" }, + signal === undefined ? {} : { signal }, + ), + command: "rizin", + reason: { lookup_failure: "invalid_pathext", pathext_entry: "C:\\bad" }, + }, +])( + "refuses $operation on Windows when the bare command is not found on PATH, launching nothing", + async ({ operation, environment, run: execute, command, reason }) => { + const run = vi.fn(async () => ({ stdout: "", stderr: "" })); + const service = new ReverseEngineeringService({ + environment, + commands: createReverseEngineeringCommandPort(environment, "win32"), + run, + }); + const result = await execute(service); + if (result.ok) throw new Error("Expected a lookup refusal"); + const projection = projectAnalysisError(result.error); + expect(analysisErrorProjectionSchema.safeParse(projection).success).toBe( + true, + ); + expect(projection).toMatchObject({ + code: "capability_unavailable", + details: { + operation, + diagnostics: { boundary: "provider-lookup", command, ...reason }, + }, + }); + // Capability discovery reports the refusal and its repair, as it does + // for a launch failure. + expect(service.observedLaunchFailures()).toEqual({ + [operation]: { + reason: projection.message, + remediation: projection.remediation.action, + }, + }); + // Cancellation is reported before the lookup refusal. + const controller = new AbortController(); + const abortReason = new Error("caller stopped"); + controller.abort(abortReason); + const cancelled = await execute(service, controller.signal); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: abortReason }, + }); + expect(run).not.toHaveBeenCalled(); + }, +); diff --git a/tests/composition/reverseEngineeringUnknownFailures.test.ts b/tests/composition/reverseEngineeringUnknownFailures.test.ts index 592fb280e1..6ecac90f09 100644 --- a/tests/composition/reverseEngineeringUnknownFailures.test.ts +++ b/tests/composition/reverseEngineeringUnknownFailures.test.ts @@ -6,6 +6,7 @@ import { expect, it, vi } from "vitest"; import { ReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { ok } from "../../src/domain/result.js"; import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -35,7 +36,7 @@ it.each([false, true])( identity: { id: "fixture", name: "fixture", version: null }, }; const service = new ReverseEngineeringService({ - commands: { objdump: () => profile, rizin: () => profile }, + commands: { objdump: () => ok(profile), rizin: () => ok(profile) }, runOwned: async () => { if (cancel) controller.abort(abortReason); throw owned; @@ -70,7 +71,7 @@ it("keeps the owned producer cancellation reason authoritative", async () => { identity: { id: "fixture", name: "fixture", version: null }, }; const service = new ReverseEngineeringService({ - commands: { objdump: () => profile, rizin: () => profile }, + commands: { objdump: () => ok(profile), rizin: () => ok(profile) }, runOwned: async () => { controller.abort(abortReason); throw new OwnedCommandFailure("cancelled", "owned process cancelled"); @@ -110,16 +111,22 @@ it.each([ : stat, run, commands: { - objdump: () => ({ - executable: "unused-objdump", - arguments: [path], - identity: { id: "test.objdump", name: "Test objdump", version: null }, - }), - rizin: () => ({ - executable: "unused-rizin", - arguments: [path], - identity: { id: "test.rizin", name: "Test Rizin", version: null }, - }), + objdump: () => + ok({ + executable: "unused-objdump", + arguments: [path], + identity: { + id: "test.objdump", + name: "Test objdump", + version: null, + }, + }), + rizin: () => + ok({ + executable: "unused-rizin", + arguments: [path], + identity: { id: "test.rizin", name: "Test Rizin", version: null }, + }), }, });