From 1eca60051506cce310116854fdb59ef9cd195a7e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:00:47 +0800 Subject: [PATCH] fix(apktool): resolve Windows PATH launchers before invocation (#2802) * fix(apktool): resolve Windows PATH launchers before invocation Resolve the default launcher through the selected PATH/PATHEXT before choosing native process creation or the existing conservative cmd.exe wrapper. Reuse the resulting selection and one canonical environment for the version probe and decode. Follow-up to the release #1693 review of #1777. Source inspected and formatted; tests, builds, check reruns and real Windows Apktool execution were skipped at maintainer request. * fix(apktool): bound lookup and retain only selected environment Source inspection and targeted formatting only. Tests, builds, lint, typechecks and provider workloads were not run at the user's explicit request. * fix(apktool): use supported timeout diagnostics Retain lookup phase through AnalysisErrorOptions diagnostics while preserving the operation and timeout identity. Source inspection and targeted formatting only; tests, checks, builds and provider workloads were skipped at the user request. --------- Co-authored-by: morluto --- src/apktool/ApktoolConfiguration.test.ts | 281 +++++++++++++++++- src/apktool/ApktoolConfiguration.ts | 138 +++++++-- src/apktool/ApktoolProvider.ts | 55 +++- .../binary/AnalysisProviderEvaluation.ts | 2 +- .../binary/LazyAnalysisProvider.ts | 2 +- src/ghidra/GhidraProviderClient.ts | 5 +- src/ida/IdaSessionClient.ts | 5 +- .../binary => process}/AbortablePromise.ts | 0 .../process-global/apktoolEnvironment.test.ts | 53 ++++ 9 files changed, 486 insertions(+), 55 deletions(-) rename src/{application/binary => process}/AbortablePromise.ts (100%) create mode 100644 tests/process-global/apktoolEnvironment.test.ts diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index d2d84874c..39cf1c652 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { APKTOOL_COMMAND_ENV, @@ -10,29 +10,43 @@ import { resolveApktoolCommand, } from "./ApktoolConfiguration.js"; +const lookup = vi.hoisted(() => ({ stat: vi.fn() })); +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + stat: lookup.stat, +})); +beforeEach(() => { + lookup.stat + .mockReset() + .mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); +}); + describe("resolveApktoolCommand", () => { - it("prefers an absolute REA_APKTOOL_COMMAND", () => { + it("prefers an absolute REA_APKTOOL_COMMAND", async () => { expect( - resolveApktoolCommand({ - [APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool", - }), + await resolveApktoolCommand( + { + [APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool", + }, + "linux", + ), ).toEqual({ command: "/usr/local/bin/apktool", commandSource: "environment", }); }); - it("falls back to PATH resolution without the environment key", () => { - expect(resolveApktoolCommand({})).toEqual({ + it("leaves POSIX PATH lookup to process creation", async () => { + expect(await resolveApktoolCommand({}, "linux")).toEqual({ command: "apktool", commandSource: "path", }); }); - it("rejects a relative environment path explicitly", () => { - expect(() => + it("rejects a relative environment path explicitly", async () => { + await expect( resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }), - ).toThrow(/must be an absolute path/u); + ).rejects.toThrow(/must be an absolute path/u); }); }); @@ -119,3 +133,250 @@ describe("apktoolInvocation", () => { }); }); }); + +describe("Windows PATH launcher selection", () => { + const environment = { + Path: '"C:\\Selected Tools";C:\\Other Tools', + PathExt: ".EXE;.CMD;.BAT", + ComSpec: "C:\\Windows\\System32\\cmd.exe", + NoDefaultCurrentDirectoryInExePath: "", + }; + const files = (...paths: string[]) => { + const selected = new Set(paths.map((path) => path.toLowerCase())); + lookup.stat.mockImplementation(async (path: string) => { + if (selected.has(path.toLowerCase())) return { isFile: () => true }; + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }); + }; + + it.each([".BAT", ".CMD"])( + "resolves a default %s launcher before choosing cmd.exe for version and decode", + async (extension) => { + const command = `C:\\Selected Tools\\apktool${extension}`; + files(command); + const selection = await resolveApktoolCommand(environment, "win32"); + expect(selection).toEqual({ command, commandSource: "path" }); + for (const arguments_ of [ + ["--version"], + ["d", "-o", "C:\\output folder", "C:\\target.apk"], + ]) { + const invocation = apktoolInvocation( + selection, + arguments_, + environment, + "win32", + ); + expect(invocation).toEqual({ + command: environment.ComSpec, + arguments: [ + "/d", + "/e:on", + "/v:off", + "/s", + "/c", + `"${[command, ...arguments_].map((token) => `"${token}"`).join(" ")}"`, + ], + windowsVerbatimArguments: true, + }); + } + }, + ); + + it("honors PATH directory order before extension order", async () => { + files("C:\\Selected Tools\\apktool.CMD", "C:\\Other Tools\\apktool.EXE"); + expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({ + command: "C:\\Selected Tools\\apktool.CMD", + }); + }); + + it("honors the selected PATHEXT ordering", async () => { + files("C:\\Selected Tools\\apktool.CMD", "C:\\Selected Tools\\apktool.EXE"); + expect( + await resolveApktoolCommand( + { ...environment, PathExt: ".CMD;.EXE" }, + "win32", + ), + ).toMatchObject({ command: "C:\\Selected Tools\\apktool.CMD" }); + }); + + it("keeps native executable arguments outside cmd.exe interpretation", async () => { + const command = "C:\\Selected Tools\\apktool.EXE"; + files(command); + const selection = await resolveApktoolCommand(environment, "win32"); + const arguments_ = ["d", "C:\\input & literal.apk"]; + expect( + apktoolInvocation(selection, arguments_, environment, "win32"), + ).toEqual({ + command, + arguments: arguments_, + windowsVerbatimArguments: false, + }); + }); + + it("preserves explicit launcher spelling without searching PATH", async () => { + const command = "C:\\selected alias\\APKTOOL.cmd"; + expect( + await resolveApktoolCommand( + { ...environment, [APKTOOL_COMMAND_ENV]: command }, + "win32", + ), + ).toEqual({ command, commandSource: "environment" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); + + it("does not inspect the current directory when the caller disables it", async () => { + await expect( + resolveApktoolCommand(environment, "win32"), + ).rejects.toMatchObject({ code: "command_missing" }); + expect( + lookup.stat.mock.calls.every( + ([path]) => + String(path).startsWith("C:\\Selected Tools\\") || + String(path).startsWith("C:\\Other Tools\\"), + ), + ).toBe(true); + }); + + it("does not turn an explicitly empty PATHEXT into default extensions", async () => { + files("C:\\Selected Tools\\apktool.EXE"); + await expect( + resolveApktoolCommand({ ...environment, PathExt: "" }, "win32"), + ).rejects.toMatchObject({ code: "command_missing" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); + + it("skips directories without treating them as executable launchers", async () => { + files("C:\\Other Tools\\apktool.EXE"); + const stat = lookup.stat.getMockImplementation()!; + lookup.stat.mockImplementation(async (path: string) => + path === "C:\\Selected Tools\\apktool.EXE" + ? { isFile: () => false } + : stat(path), + ); + expect(await resolveApktoolCommand(environment, "win32")).toMatchObject({ + command: "C:\\Other Tools\\apktool.EXE", + }); + }); + + it("preserves inspection failures instead of reporting a missing command or selecting a later file", async () => { + lookup.stat.mockRejectedValue( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + const failure = await resolveApktoolCommand(environment, "win32").catch( + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(ApktoolConfigurationFailure); + if (!(failure instanceof ApktoolConfigurationFailure)) throw failure; + expect(failure).toMatchObject({ + code: "command_unresolved", + diagnostics: { + candidate_path: "C:\\Selected Tools\\apktool.EXE", + filesystem_error: "EACCES", + }, + }); + expect(apktoolConfigurationAvailability(failure)).toMatchObject({ + code: "not_configured", + }); + expect(lookup.stat).toHaveBeenCalledTimes(1); + }); + + it("honors cancellation before inspecting launchers", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + resolveApktoolCommand(environment, "win32", controller.signal), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(lookup.stat).not.toHaveBeenCalled(); + }); +}); + +it("does not select a launcher when cancellation arrives during lookup", async () => { + const controller = new AbortController(); + lookup.stat.mockImplementation(async () => { + controller.abort(); + return { isFile: () => true }; + }); + await expect( + resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NoDefaultCurrentDirectoryInExePath: "1", + }, + "win32", + controller.signal, + ), + ).rejects.toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(lookup.stat).toHaveBeenCalledTimes(1); +}); + +it.each(["EXE", ".CMD\\..\\outside.exe", ".CMD\0"])( + "rejects malformed PATHEXT %j before path lookup", + async (PATHEXT) => { + await expect( + resolveApktoolCommand({ PATH: "C:\\Tools", PATHEXT }, "win32"), + ).rejects.toMatchObject({ + code: "command_unresolved", + diagnostics: { invalid_extension: PATHEXT }, + }); + expect(lookup.stat).not.toHaveBeenCalled(); + }, +); + +it("cancels a pending filesystem lookup and owns its eventual rejection", async () => { + const controller = new AbortController(); + let started!: () => void; + let rejectLookup!: (cause: Error) => void; + const pendingStat = new Promise((_resolve, reject) => { + rejectLookup = reject; + }); + const observed = new Promise((resolve) => { + started = resolve; + }); + lookup.stat.mockImplementation(() => { + started(); + return pendingStat; + }); + const pending = resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NODEFAULTCURRENTDIRECTORYINEXEPATH: "1", + }, + "win32", + controller.signal, + ); + await observed; + controller.abort(); + await expect(pending).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + rejectLookup(new Error("filesystem completed after cancellation")); + await Promise.resolve(); + expect(lookup.stat).toHaveBeenCalledTimes(1); +}); + +it("bounds the entire pending PATH lookup by the selection deadline", async () => { + vi.useFakeTimers(); + try { + lookup.stat.mockReturnValue(new Promise(() => {})); + const failure = expect( + resolveApktoolCommand( + { + PATH: "C:\\Tools", + PATHEXT: ".CMD", + NODEFAULTCURRENTDIRECTORYINEXEPATH: "1", + }, + "win32", + ), + ).rejects.toMatchObject({ + _tag: "AnalysisTimeoutError", + timeoutMs: 20_000, + }); + await vi.advanceTimersByTimeAsync(20_000); + await failure; + expect(lookup.stat).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 12532b104..b1861f2ac 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -1,5 +1,12 @@ -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; -import { isAbsolute } from "node:path"; +import { + AnalysisCancelledError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { isAbsolute, win32 } from "node:path"; +import { stat } from "node:fs/promises"; +import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; +import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import { execFileOutput, @@ -27,30 +34,119 @@ export interface ApktoolCommandSelection { } /** - * Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must - * be absolute so the selection stays explicit; without it, apktool is - * resolved from `PATH` at run time. Both the upstream wrapper script and - * `java -jar` launcher scripts work: REA only runs ` --version` - * and ` d ...`. + * Preserve an explicit launcher, or resolve the default Windows command before + * choosing native versus batch process creation. POSIX keeps execFile's PATH + * lookup. The resolved selection is reused for the version and decode runs. */ -export const resolveApktoolCommand = ( +export const resolveApktoolCommand = async ( environment: Readonly>, -): ApktoolCommandSelection => { - const configured = environment[APKTOOL_COMMAND_ENV]; - if (configured === undefined || configured.trim() === "") + platform: NodeJS.Platform = process.platform, + signal?: AbortSignal, +): Promise => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("inspect_apktool_client"); + const selectedEnvironment = snapshotEnvironment(environment, platform); + const configured = selectedEnvironment[APKTOOL_COMMAND_ENV]; + if (configured !== undefined && configured.trim() !== "") { + if ( + !(platform === "win32" + ? win32.isAbsolute(configured) + : isAbsolute(configured)) + ) + throw new ApktoolConfigurationFailure( + "not_absolute", + `${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`, + { configured_command: configured }, + ); + return { command: configured, commandSource: "environment" }; + } + if (platform !== "win32") return { command: "apktool", commandSource: "path" }; - if (!isAbsolute(configured)) - throw new ApktoolConfigurationFailure( - "not_absolute", - `${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`, - { configured_command: configured }, - ); - return { command: configured, commandSource: "environment" }; + const extensions = (selectedEnvironment.PATHEXT ?? ".EXE;.COM;.BAT;.CMD") + .split(";") + .filter((extension) => extension !== ""); + for (const extension of extensions) + if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension)) + throw new ApktoolConfigurationFailure( + "command_unresolved", + "Windows PATHEXT must contain filename extensions, not paths", + { + selected_command: "apktool", + command_source: "path", + invalid_extension: extension, + }, + ); + const directories = (selectedEnvironment.PATH ?? "") + .split(";") + .filter((directory) => directory !== ""); + if (selectedEnvironment.NODEFAULTCURRENTDIRECTORYINEXEPATH === undefined) + directories.unshift(process.cwd()); + // A single deadline owns the whole PATH search, including filesystem waits. + const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal); + const lookupInterruption = () => + deadline.interruption === "cancelled" + ? new AnalysisCancelledError("inspect_apktool_client") + : new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, { + diagnostics: { phase: "windows_path_lookup" }, + }); + try { + for (const entry of directories) { + const directory = + entry.startsWith('"') && entry.endsWith('"') + ? entry.slice(1, -1) + : entry; + for (const extension of extensions) { + if (deadline.interruption !== undefined) throw lookupInterruption(); + const candidate = win32.resolve(directory, `apktool${extension}`); + try { + const metadata = await waitForAbortable( + stat(candidate), + deadline.signal, + ); + if (metadata === ABORTED || deadline.interruption !== undefined) + throw lookupInterruption(); + if (metadata.isFile()) + return { command: candidate, commandSource: "path" }; + } catch (cause: unknown) { + if (deadline.interruption !== undefined) throw lookupInterruption(); + const code = + typeof cause === "object" && cause !== null + ? Reflect.get(cause, "code") + : undefined; + if (code === "ENOENT" || code === "ENOTDIR") continue; + throw new ApktoolConfigurationFailure( + "command_unresolved", + `The PATH-selected apktool launcher could not be inspected: ${candidate}`, + { + selected_command: "apktool", + command_source: "path", + candidate_path: candidate, + ...(typeof code === "string" ? { filesystem_error: code } : {}), + }, + { cause }, + ); + } + } + } + } finally { + deadline.dispose(); + } + + throw new ApktoolConfigurationFailure( + "command_missing", + "The apktool launcher was not found on the selected Windows PATH/PATHEXT", + { + selected_command: "apktool", + command_source: "path", + remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`, + }, + ); }; export type ApktoolConfigurationFailureCode = | "not_absolute" | "command_missing" + | "command_unresolved" | "launcher_unsupported" | "version_unresolved"; @@ -121,7 +217,7 @@ export const inspectApktoolClient = async ( environment: Readonly> = process.env, platform: NodeJS.Platform = process.platform, ): Promise => { - const hostEnvironment = { ...process.env, ...environment }; + const hostEnvironment = snapshotEnvironment(environment, platform); let invocation: ApktoolInvocation; try { invocation = apktoolInvocation( @@ -195,7 +291,9 @@ const rejectionCodeOf = ( ): ProviderRejectionCode => code === "command_missing" ? "executable_missing" - : code === "not_absolute" || code === "launcher_unsupported" + : code === "not_absolute" || + code === "launcher_unsupported" || + code === "command_unresolved" ? "not_configured" : "version_unresolved"; diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 1e1f187c0..c58ea1f49 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -51,6 +51,7 @@ import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; import { + APKTOOL_COMMAND_ENV, ApktoolConfigurationFailure, apktoolConfigurationAvailability, apktoolInvocation, @@ -155,10 +156,9 @@ const runApktool = async ( ): Promise> => { if (signal?.aborted === true) return err(new AnalysisCancelledError(operation)); - const hostEnvironment = { ...process.env, ...environment }; let invocation: ReturnType; try { - invocation = apktoolInvocation(selection, arguments_, hostEnvironment); + invocation = apktoolInvocation(selection, arguments_, environment); } catch (cause) { if (!(cause instanceof WindowsBatchCommandError)) throw cause; const reason = `The apktool batch launcher cannot receive REA's decode paths through cmd.exe: ${cause.message}`; @@ -178,7 +178,7 @@ const runApktool = async ( arguments: invocation.arguments, windowsVerbatimArguments: invocation.windowsVerbatimArguments, runId: randomUUID(), - hostEnvironment, + hostEnvironment: environment, }, { timeoutMs: limits.timeoutMs, @@ -317,7 +317,9 @@ export interface ApktoolProviderOptions { * (`--no-src`) because Java-source recovery belongs to the JADX family. */ export class ApktoolProvider { - private readonly environment: Readonly>; + private readonly selectedEnvironment: + | Readonly> + | undefined; private readonly workspaces = new Set(); private readonly removeWorkspace: (path: string) => Promise; @@ -328,14 +330,25 @@ export class ApktoolProvider { #closePromise: Promise | undefined; constructor(options: ApktoolProviderOptions = {}) { - this.environment = snapshotEnvironment(options.environment ?? process.env); + this.selectedEnvironment = + options.environment === undefined + ? undefined + : snapshotEnvironment(options.environment); this.removeWorkspace = options.removeWorkspace ?? ((path) => rm(path, { recursive: true, force: true })); } - private selection(): ApktoolCommandSelection { - return resolveApktoolCommand(this.environment); + private operationEnvironment(): Readonly { + const ambient = snapshotEnvironment(process.env); + if (this.selectedEnvironment === undefined) return ambient; + // Child inheritance is sampled per operation. An explicit map keeps its + // launcher choice authoritative, including an omitted command selecting PATH. + return snapshotEnvironment({ + ...ambient, + ...this.selectedEnvironment, + [APKTOOL_COMMAND_ENV]: this.selectedEnvironment[APKTOOL_COMMAND_ENV], + }); } /** Probe the selected apktool launcher; decodes nothing. */ @@ -354,9 +367,14 @@ export class ApktoolProvider { async #inspectAvailability( signal: AbortSignal, ): Promise { + const environment = this.operationEnvironment(); try { - const selection = this.selection(); - await inspectApktoolClient(selection, signal, this.environment); + const selection = await resolveApktoolCommand( + environment, + process.platform, + signal, + ); + await inspectApktoolClient(selection, signal, environment); throwIfCancelled("inspect_apktool_client", signal); return { status: "available", @@ -406,17 +424,22 @@ export class ApktoolProvider { ): Promise> { if (signal?.aborted === true) return err(new AnalysisCancelledError(request.operation)); + const environment = this.operationEnvironment(); let selection: ApktoolCommandSelection; let identity: ApktoolClientIdentity; try { - selection = this.selection(); - identity = await inspectApktoolClient( - selection, + selection = await resolveApktoolCommand( + environment, + process.platform, signal, - this.environment, ); + identity = await inspectApktoolClient(selection, signal, environment); } catch (cause) { - if (cause instanceof AnalysisCancelledError) return err(cause); + if ( + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisTimeoutError + ) + return err(cause); if (cause instanceof ApktoolConfigurationFailure) { return err( providerConfigurationRefusal( @@ -449,6 +472,7 @@ export class ApktoolProvider { return this.decodeResources( request.operation, selection, + environment, client, provider, request.input, @@ -500,6 +524,7 @@ export class ApktoolProvider { private async decodeResources( operation: "decode_android_resources", selection: ApktoolCommandSelection, + environment: Readonly, client: { command: string; command_source: string; @@ -628,7 +653,7 @@ export class ApktoolProvider { maxOutputBytes: DECODE_MAX_OUTPUT_BYTES, }, signal, - this.environment, + environment, (owner) => this.#pendingCommandOwners.set(owner, root), ); if (!decoded.ok) { diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index 1654ab783..27292ed31 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -12,7 +12,7 @@ import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import type { AnalysisProviderCandidate } from "../AnalysisProvider.js"; import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js"; -import { ABORTED, waitForAbortable } from "./AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js"; /** Profile-resolution truth for one discovered provider candidate. */ export interface AnalysisProviderCandidateEvaluation { diff --git a/src/application/binary/LazyAnalysisProvider.ts b/src/application/binary/LazyAnalysisProvider.ts index a87e77445..095acf5ed 100644 --- a/src/application/binary/LazyAnalysisProvider.ts +++ b/src/application/binary/LazyAnalysisProvider.ts @@ -5,7 +5,7 @@ import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js"; import { err, ok } from "../../domain/result.js"; -import { ABORTED, waitForAbortable } from "./AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../../process/AbortablePromise.js"; import type { AnalysisClient, AnalysisClientContext, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 45a20ba6f..cff45f78b 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -78,10 +78,7 @@ import { parseNativeAotPe, type NativeAotPeResult, } from "../domain/native/nativeAotPe.js"; -import { - ABORTED, - waitForAbortable, -} from "../application/binary/AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import { GHIDRA_PROVIDER_IDENTITY, diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 70d4fc365..dea9e1d86 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -import { - ABORTED, - waitForAbortable, -} from "../application/binary/AbortablePromise.js"; +import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { createAnalysisExecution, type AnalysisClient, diff --git a/src/application/binary/AbortablePromise.ts b/src/process/AbortablePromise.ts similarity index 100% rename from src/application/binary/AbortablePromise.ts rename to src/process/AbortablePromise.ts diff --git a/tests/process-global/apktoolEnvironment.test.ts b/tests/process-global/apktoolEnvironment.test.ts new file mode 100644 index 000000000..90f4d483c --- /dev/null +++ b/tests/process-global/apktoolEnvironment.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, it, onTestFinished, vi } from "vitest"; + +import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js"; +import { execFileOutput } from "../../src/process/ExecFileOutput.js"; + +vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/process/ExecFileOutput.js") + >()), + execFileOutput: vi.fn(async () => ({ stdout: "2.7.0-stub\n", stderr: "" })), +})); +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(execFileOutput).mockClear(); +}); + +it.skipIf(process.platform === "win32")( + "keeps an explicit environment's PATH choice and samples ambient inheritance per operation", + async () => { + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "initial"); + const provider = new ApktoolProvider({ environment: {} }); + onTestFinished(() => provider.close()); + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/changed-apktool"); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "changed"); + await provider.inspectAvailability(); + expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe("apktool"); + expect(vi.mocked(execFileOutput).mock.lastCall?.[2]?.env).toMatchObject({ + REA_APKTOOL_COMMAND: undefined, + REA_APKTOOL_TEST_INHERITED_VALUE: "changed", + }); + vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", undefined); + await provider.inspectAvailability(); + expect( + vi.mocked(execFileOutput).mock.lastCall?.[2]?.env + ?.REA_APKTOOL_TEST_INHERITED_VALUE, + ).toBeUndefined(); + }, +); + +it.skipIf(process.platform === "win32")( + "inherits the current default launcher when no environment was selected", + async () => { + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/initial-apktool"); + const provider = new ApktoolProvider(); + onTestFinished(() => provider.close()); + vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/later-apktool"); + await provider.inspectAvailability(); + expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe( + "/ambient/later-apktool", + ); + }, +);