mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
refactor(process): give POSIX PATH search order one owner (#10891)
* refactor(process): give POSIX PATH search order one owner Program lookup, the packet-tool locator and Jadx's Java lookup each split PATH and resolved empty and relative entries themselves. They now take the candidates from posixPathCandidates in src/process/executableSearch and keep only their own probe and failure reporting. A counted architecture guard keeps new PATH splits from returning; the remaining Windows and Ghidra searches are baselined. * refactor(process): move the colon-split PATH searches onto the search owner REA's launcher-alias lookup and Ghidra's macOS Java lookup split PATH on ":" and skipped relative entries themselves; they now use posixPathCandidates with relativeEntries: "skip", and the guard counts colon splits of PATH as well. * fix(process): produce PATH candidates lazily so an unused entry cannot fail an earlier match * refactor(process): give Windows PATH/PATHEXT search order the executable search owner (#10916) * refactor(process): give Windows PATH/PATHEXT search order the executable search owner * fix(process): keep later PATH entries searchable after a stray quote or a removed working directory * fix(reverse): refuse a Windows objdump or Rizin command that PATH does not find (#10953) * fix(reverse): refuse an objdump or Rizin command Windows PATH does not find instead of launching the bare name * fix(reverse): report a Windows lookup refusal after cancellation and to capability discovery * fix(desktop): search a Windows host's PATH as Windows does when doctor looks up a program programOnPath sent every host through the POSIX PATH search, so on Windows a relative PATH entry reached absoluteCallerPath with platform linux and the Windows working directory, which resolved against itself until the stack overflowed. programOnPath now searches PATH and PATHEXT through windowsPathCandidates on a Windows host, and absoluteCallerPath refuses a non-POSIX working directory with a TypeError instead of recursing.
This commit is contained in:
1 parent
659772952d
commit
422cff00ab
23 files changed
+912
-330
No files matched your search
@@ -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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PathSearchCandidate> =
|
||||
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,
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
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);
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<NodeJS.ProcessEnv>;
|
||||
@@ -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<Record<string, JsonValue>>;
|
||||
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<Result<Evidence, AnalysisError>> {
|
||||
#observe({
|
||||
selection,
|
||||
...request
|
||||
}: Omit<ReverseCommandObservation, "profile"> & {
|
||||
readonly selection: ReverseEngineeringCommandSelection;
|
||||
}): Promise<Result<Evidence, AnalysisError>> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
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" });
|
||||
};
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
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<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): string =>
|
||||
platform === "win32"
|
||||
? resolveWindowsProviderExecutable(executable, environment)
|
||||
: executable;
|
||||
profile: Omit<ReverseEngineeringProviderCommand, "executable">,
|
||||
): 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 = (
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
): Promise<string | undefined> => {
|
||||
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
|
||||
|
||||
@@ -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<boolean> => {
|
||||
try {
|
||||
@@ -11,10 +13,6 @@ const isExecutable = async (path: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 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<Record<string, string | undefined>>,
|
||||
startedIn: string,
|
||||
): Promise<FoundProgram | undefined> => {
|
||||
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;
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<PathSearchCandidate> =
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Result<string, WindowsExecutableFailure>> => {
|
||||
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" });
|
||||
|
||||
@@ -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<typeof windowsPathCandidates>
|
||||
): 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<PathSearchCandidate, void, undefined> {
|
||||
// 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<string> = 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<NodeJS.ProcessEnv>,
|
||||
search: { readonly cwd?: string; readonly allowBatch?: boolean } = {},
|
||||
): Result<Iterable<PathSearchCandidate>, 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),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Parameters<typeof execFileOutput>[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();
|
||||
},
|
||||
);
|
||||
@@ -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 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user