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 <morluto@users.noreply.github.com>
This commit is contained in:
@@ -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<typeof import("node:fs/promises")>()),
|
||||
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<void>((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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 `<command> --version`
|
||||
* and `<command> 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<Record<string, string | undefined>>,
|
||||
): ApktoolCommandSelection => {
|
||||
const configured = environment[APKTOOL_COMMAND_ENV];
|
||||
if (configured === undefined || configured.trim() === "")
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApktoolCommandSelection> => {
|
||||
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<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<ApktoolClientIdentity> => {
|
||||
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";
|
||||
|
||||
|
||||
@@ -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<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const hostEnvironment = { ...process.env, ...environment };
|
||||
let invocation: ReturnType<typeof apktoolInvocation>;
|
||||
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<Record<string, string | undefined>>;
|
||||
private readonly selectedEnvironment:
|
||||
| Readonly<Record<string, string | undefined>>
|
||||
| undefined;
|
||||
|
||||
private readonly workspaces = new Set<string>();
|
||||
private readonly removeWorkspace: (path: string) => Promise<void>;
|
||||
@@ -328,14 +330,25 @@ export class ApktoolProvider {
|
||||
#closePromise: Promise<void> | 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<NodeJS.ProcessEnv> {
|
||||
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<ProviderAvailability> {
|
||||
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<Result<AnalysisExecution, AnalysisError>> {
|
||||
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<NodeJS.ProcessEnv>,
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user