fix(windows): resolve default tools before process launch (#7725)

Resolve Windows ADB, Apktool and update commands against the selected
PATH/PATHEXT before launch. Keep explicit paths and relative PATH choices,
report lookup failures, and reuse absolute batch invocation and ownership.

Add native launch/argv regressions, real npm metadata probes and focused
CI ownership so these boundaries retain Windows verification on the fast tier.

Fixes #7724

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-28 07:27:48 +00:00
1 parent 0e4b806db1
commit 2b8fb387b6
15 files changed
+634 -121

No files matched your search

+1
View File
@@ -901,6 +901,7 @@ jobs:
tests/boundary/process/packageNpmInvocation.test.ts
tests/boundary/process/packedBridgeVerification.test.ts
tests/boundary/process/windowsOutputCapture.test.ts
tests/boundary/process/windowsExecutableSelection.test.ts
tests/acceptance/webModuleCli.test.ts
tests/acceptance/webSourceLocationCli.test.ts
tests/boundary/cli/referenceSourceImportStatus.test.ts
+10 -1
View File
@@ -1,7 +1,12 @@
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { matchesGlob } from "node:path";
import { CI_FLAGS as flags, MANUAL_VERIFIERS, SCOPES } from "./scopes.mjs";
import {
CI_FLAGS as flags,
MANUAL_VERIFIERS,
SCOPES,
WINDOWS_EXECUTABLE_SELECTION_INPUTS,
} from "./scopes.mjs";
import { planTestSelection } from "./test-selection.mjs";
// Test files select the host/runtime lanes that execute their focused suites.
@@ -206,6 +211,10 @@ if (tier === "reused") {
.split("\0")
.filter(Boolean);
for (const file of files) {
selected.package ||= matchesAny(
file,
WINDOWS_EXECUTABLE_SELECTION_INPUTS,
);
if (matchesAny(file, SCOPES.reverse_tools)) selected.reverse_tools = true;
if (
/^(README(?:_[^/]+)?\.md|AGENTS\.md|CONTRIBUTING\.md|CHANGELOG\.md|SECURITY\.md|native\/windows\/README\.md|\.github\/pull_request_template\.md)$/.test(
+11
View File
@@ -1,3 +1,13 @@
// These boundaries require native Windows launch verification even where
// broad process ownership would otherwise suppress host fanout on the fast tier.
export const WINDOWS_EXECUTABLE_SELECTION_INPUTS = [
"src/process/WindowsExecutable*.ts",
"src/adb/AdbConfiguration.ts",
"src/apktool/ApktoolConfiguration.ts",
"src/application/UpdateCommandRunner.ts",
"tests/boundary/process/windowsExecutableSelection.test.ts",
];
// Ownership of the native-host, real-provider and website verification lanes.
// Shared build inputs are classified once in plan.mjs rather than repeated in
// every workflow.
@@ -101,6 +111,7 @@ export const SCOPES = {
],
// Four-host installed-package matrix and the Windows curated lane.
package: [
...WINDOWS_EXECUTABLE_SELECTION_INPUTS,
// Both host compilers must be exercised when their installation, options,
// or entrypoints change; Linux's accelerated checker cannot stand in for Go.
"scripts/run-typescript.*",
+28 -17
View File
@@ -7,31 +7,42 @@ import {
} from "./AdbConfiguration.js";
describe("resolveAdbBinary", () => {
it("prefers an absolute REA_ADB_PATH", () => {
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" })).toEqual({
it("prefers an absolute REA_ADB_PATH", async () => {
expect(
await resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" }, "linux"),
).toEqual({
binary: "/opt/tools/adb",
pathSource: "environment",
environment: { [ADB_BINARY_ENV]: "/opt/tools/adb" },
});
});
it("falls back to PATH resolution without the environment key", () => {
expect(resolveAdbBinary({})).toEqual({
binary: "adb",
pathSource: "path",
environment: {},
});
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({
binary: "adb",
pathSource: "path",
environment: { [ADB_BINARY_ENV]: " " },
});
it.skipIf(process.platform === "win32")(
"falls back to POSIX PATH resolution without the environment key",
async () => {
expect(await resolveAdbBinary({})).toEqual({
binary: "adb",
pathSource: "path",
environment: {},
});
expect(await resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({
binary: "adb",
pathSource: "path",
environment: { [ADB_BINARY_ENV]: " " },
});
},
);
it("does not defer a missing Windows PATH selection to process creation", async () => {
await expect(
resolveAdbBinary({ Path: "", PathExt: ".EXE" }, "win32"),
).rejects.toMatchObject({ code: "binary_missing" });
});
it("rejects a relative environment path explicitly", () => {
expect(() => resolveAdbBinary({ [ADB_BINARY_ENV]: "tools/adb" })).toThrow(
/must be an absolute path/u,
);
it("rejects a relative environment path explicitly", async () => {
await expect(
resolveAdbBinary({ [ADB_BINARY_ENV]: "tools/adb" }),
).rejects.toThrow(/must be an absolute path/u);
});
});
+69 -9
View File
@@ -3,7 +3,9 @@ import {
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import { isAbsolute, win32 } from "node:path";
import { findWindowsExecutable } from "../process/WindowsExecutable.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
@@ -32,21 +34,30 @@ export interface AdbBinarySelection {
/**
* Resolve the caller-supplied adb binary. `REA_ADB_PATH` must be absolute so
* the selection stays explicit; without it, adb is resolved from `PATH` by
* the platform exec at run time, like any bring-your-own tool.
* the selection stays explicit. Windows resolves the default from the selected
* PATH before launch; POSIX retains execFile PATH lookup.
*/
export const resolveAdbBinary = (
export const resolveAdbBinary = async (
environment: Readonly<Record<string, string | undefined>>,
): AdbBinarySelection => {
const selectedEnvironment = snapshotEnvironment(environment);
platform: NodeJS.Platform = process.platform,
signal?: AbortSignal,
): Promise<AdbBinarySelection> => {
const selectedEnvironment = snapshotEnvironment(environment, platform);
const configured = selectedEnvironment[ADB_BINARY_ENV];
if (configured === undefined || configured.trim() === "")
return {
binary: "adb",
binary:
platform === "win32"
? await resolveWindowsAdb(selectedEnvironment, signal)
: "adb",
pathSource: "path",
environment: selectedEnvironment,
};
if (!isAbsolute(configured))
if (
!(platform === "win32"
? win32.isAbsolute(configured)
: isAbsolute(configured))
)
throw new AdbConfigurationFailure(
"not_absolute",
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
@@ -59,9 +70,56 @@ export const resolveAdbBinary = (
};
};
const resolveWindowsAdb = async (
environment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<string> => {
const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal);
try {
const found = await findWindowsExecutable("adb", environment, {
signal: deadline.signal,
});
if (found.ok) return found.value;
const failure = found.error;
if (failure.kind === "interrupted") {
if (deadline.interruption === "cancelled")
throw new AnalysisCancelledError("inspect_adb_client", {
cause: signal?.reason,
});
throw new AnalysisTimeoutError("inspect_adb_client", VERSION_TIMEOUT_MS, {
diagnostics: { phase: "windows_path_lookup" },
});
}
throw new AdbConfigurationFailure(
failure.kind === "missing" ? "binary_missing" : "binary_unresolved",
"The adb binary could not be selected from the Windows PATH/PATHEXT",
{
selected_binary: "adb",
path_source: "path",
remediation: `Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT`,
...(failure.kind === "invalid_extension"
? { invalid_extension: failure.extension }
: {}),
...(failure.kind === "inspection"
? {
candidate_path: failure.path,
...(failure.code === null
? {}
: { filesystem_error: failure.code }),
}
: {}),
},
failure.kind === "inspection" ? { cause: failure.cause } : undefined,
);
} finally {
deadline.dispose();
}
};
export type AdbConfigurationFailureCode =
| "not_absolute"
| "binary_missing"
| "binary_unresolved"
| "binary_not_executable"
| "version_unresolved";
@@ -221,7 +279,9 @@ const rejectionCodeOf = (
): ProviderRejectionCode =>
code === "binary_missing"
? "executable_missing"
: code === "not_absolute" || code === "binary_not_executable"
: code === "not_absolute" ||
code === "binary_not_executable" ||
code === "binary_unresolved"
? "not_configured"
: "version_unresolved";
+4 -4
View File
@@ -890,8 +890,8 @@ export class AdbProvider {
((rootOptions) => PrivateRuntimeRoot.create(rootOptions));
}
private selection(): AdbBinarySelection {
return resolveAdbBinary(this.environment);
private selection(signal?: AbortSignal): Promise<AdbBinarySelection> {
return resolveAdbBinary(this.environment, process.platform, signal);
}
/** Probe the selected adb binary; never contacts devices or the server. */
@@ -899,7 +899,7 @@ export class AdbProvider {
signal?: AbortSignal,
): Promise<ProviderAvailability> {
try {
const selection = this.selection();
const selection = await this.selection(signal);
await inspectAdbClient(selection, signal);
return {
status: "available",
@@ -1332,7 +1332,7 @@ export class AdbProvider {
let selection: AdbBinarySelection;
let identity: AdbClientIdentity;
try {
selection = this.selection();
selection = await this.selection(signal);
identity = await inspectAdbClient(selection, signal);
} catch (cause) {
if (cause instanceof AnalysisError)
+13
View File
@@ -531,3 +531,16 @@ afterEach(async () => {
await commands.close();
commands = new ApktoolCommandCleanup();
});
it("does not implicitly search CWD when no opt-out flag is present", async () => {
await expect(
resolveApktoolCommand(
{ Path: "C:\\Selected Tools;D:\\Other Tools", PathExt: ".EXE" },
"win32",
),
).rejects.toMatchObject({ code: "command_missing" });
expect(lookup.stat.mock.calls.map(([path]) => path)).toEqual([
"C:\\Selected Tools\\apktool.EXE",
"D:\\Other Tools\\apktool.EXE",
]);
});
+30 -59
View File
@@ -12,9 +12,8 @@ import {
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 { findWindowsExecutable } from "../process/WindowsExecutable.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
import { randomUUID } from "node:crypto";
@@ -84,25 +83,6 @@ export const resolveApktoolCommand = async (
}
if (platform !== "win32")
return { command: "apktool", commandSource: "path" };
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 = () =>
@@ -115,44 +95,35 @@ export const resolveApktoolCommand = async (
...apktoolVersionProbeTimeoutGuidance(),
});
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 },
);
}
}
}
const found = await findWindowsExecutable("apktool", selectedEnvironment, {
signal: deadline.signal,
allowBatch: true,
});
if (found.ok) return { command: found.value, commandSource: "path" };
const failure = found.error;
if (failure.kind === "interrupted") throw lookupInterruption();
if (failure.kind === "invalid_extension")
throw new ApktoolConfigurationFailure(
"command_unresolved",
"Windows PATHEXT must contain filename extensions, not paths",
{
selected_command: "apktool",
command_source: "path",
invalid_extension: failure.extension,
},
);
if (failure.kind === "inspection")
throw new ApktoolConfigurationFailure(
"command_unresolved",
`The PATH-selected apktool launcher could not be inspected: ${failure.path}`,
{
selected_command: "apktool",
command_source: "path",
candidate_path: failure.path,
...(failure.code === null ? {} : { filesystem_error: failure.code }),
},
{ cause: failure.cause },
);
} finally {
deadline.dispose();
}
+71 -7
View File
@@ -1,4 +1,12 @@
import spawn from "cross-spawn";
import { spawn as spawnWindows } from "node:child_process";
import { win32 } from "node:path";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { findWindowsExecutable } from "../process/WindowsExecutable.js";
import {
isWindowsBatchFile,
windowsBatchCommand,
} from "../process/WindowsBatchCommand.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
ProviderProcessSupervisor,
@@ -40,6 +48,7 @@ export class UpdateCommandRunner {
workingDirectory,
"complete",
this.#resources,
deadline.signal,
deadline,
),
);
@@ -67,6 +76,7 @@ export class UpdateCommandRunner {
workingDirectory,
"diagnostics",
this.#resources,
this.#controller.signal,
),
)
.catch((cause: unknown) =>
@@ -87,19 +97,73 @@ const runUpdateProcess = async (
workingDirectory: string | undefined,
retention: "complete" | "diagnostics",
resources: ArtifactResourceScope,
lookupSignal: AbortSignal,
probe?: ProviderStartupDeadline,
): Promise<Result<string, string>> => {
if (probe?.signal.aborted)
return err("Update metadata probe cancelled before launch.");
const [executable, ...args] = command;
if (executable === undefined) return err("No executable supplied.");
const environment = withoutPackageRunnerIdentity(selectedEnvironment);
const child = spawn(executable, args, {
stdio: ["ignore", "pipe", "pipe"],
env: environment,
windowsHide: true,
...(workingDirectory === undefined ? {} : { cwd: workingDirectory }),
});
const environment = snapshotEnvironment(
withoutPackageRunnerIdentity(selectedEnvironment),
);
let selectedExecutable = executable;
let selectedArguments = args;
let windowsVerbatimArguments = false;
if (process.platform === "win32") {
if (
!win32.isAbsolute(executable) &&
!/[\\/]/u.test(executable) &&
win32.parse(executable).root === ""
) {
const found = await findWindowsExecutable(executable, environment, {
signal: lookupSignal,
allowBatch: true,
...(workingDirectory === undefined ? {} : { cwd: workingDirectory }),
});
if (!found.ok) {
const failure = found.error;
return err(
failure.kind === "interrupted"
? "Update command PATH lookup interrupted before launch."
: failure.kind === "inspection"
? `Update command PATH candidate could not be inspected: ${failure.path} (${failure.code ?? "unknown"}).`
: failure.kind === "invalid_extension"
? `Invalid Windows PATHEXT extension: ${failure.extension}`
: `Update executable was not found on the selected Windows PATH/PATHEXT: ${executable}`,
);
}
selectedExecutable = found.value;
} else {
selectedExecutable = win32.resolve(
workingDirectory ?? process.cwd(),
executable,
);
}
if (isWindowsBatchFile(selectedExecutable)) {
const invocation = windowsBatchCommand({
environment,
batchPath: selectedExecutable,
arguments: args,
});
selectedExecutable = invocation.command;
selectedArguments = [...invocation.arguments];
windowsVerbatimArguments = true;
}
}
if (lookupSignal.aborted)
return err("Update command cancelled before launch.");
const child = (process.platform === "win32" ? spawnWindows : spawn)(
selectedExecutable,
selectedArguments,
{
stdio: ["ignore", "pipe", "pipe"],
env: environment,
windowsHide: true,
windowsVerbatimArguments,
...(workingDirectory === undefined ? {} : { cwd: workingDirectory }),
},
);
let processFailure: string | undefined;
const supervisor = new ProviderProcessSupervisor(
{ process: child, ownsProcessLifetime: true },
@@ -1,5 +1,6 @@
import { existsSync, statSync } from "node:fs";
import { win32 } from "node:path";
import { windowsPathEntries } from "../process/WindowsExecutable.js";
export interface WindowsExecutableLookup {
/** Windows-formatted working directory; defaults to the process CWD. */
@@ -8,25 +9,6 @@ export interface WindowsExecutableLookup {
readonly isFile?: (path: string) => boolean;
}
const splitWindowsPath = (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.startsWith('"') && entry.endsWith('"') && entry.length >= 2)
return [entry.slice(1, -1)];
return entry.includes('"') ? [] : [entry];
});
};
const isRegularFile = (path: string): boolean => {
try {
return existsSync(path) && statSync(path).isFile();
@@ -53,7 +35,7 @@ export const resolveWindowsProviderExecutable = (
const pathValue = environment.PATH ?? environment.Path ?? environment.path;
if (pathValue === undefined) return executable;
const pathEntries = splitWindowsPath(pathValue);
const pathEntries = windowsPathEntries(pathValue);
const explicitExtension = win32.extname(executable).toLowerCase();
if (explicitExtension !== "" && ![".com", ".exe"].includes(explicitExtension))
return executable;
+116
View File
@@ -0,0 +1,116 @@
import { beforeEach, expect, it, vi } from "vitest";
import { findWindowsExecutable } from "./WindowsExecutable.js";
const lookup = vi.hoisted(() => ({ stat: vi.fn() }));
vi.mock("node:fs/promises", () => ({ stat: lookup.stat }));
beforeEach(() => {
lookup.stat.mockReset();
lookup.stat.mockRejectedValue(
Object.assign(new Error("missing"), { code: "ENOENT" }),
);
});
const options = () => ({
signal: new AbortController().signal,
cwd: "C:\\project",
});
it("looks up an explicitly named extension without appending PATHEXT again", async () => {
lookup.stat.mockResolvedValue({ isFile: () => true });
expect(
await findWindowsExecutable(
"npm.cmd",
{ PATH: "C:\\tools", PATHEXT: ".EXE;.CMD" },
{ ...options(), allowBatch: true },
),
).toEqual({ ok: true, value: "C:\\tools\\npm.cmd" });
expect(lookup.stat).toHaveBeenCalledExactlyOnceWith("C:\\tools\\npm.cmd");
});
it("uses native PATH extensions for ADB, preserving directory and extension precedence", async () => {
lookup.stat.mockImplementation(async (path: string) => ({
isFile: () => ["C:\\tools\\adb.EXE", "D:\\tools\\adb.COM"].includes(path),
}));
expect(
await findWindowsExecutable(
"adb",
{ Path: "C:\\tools;D:\\tools", PathExt: ".CMD;.COM;.EXE" },
options(),
),
).toEqual({ ok: true, value: "C:\\tools\\adb.EXE" });
expect(lookup.stat.mock.calls.map(([path]) => path)).toEqual([
"C:\\tools\\adb.COM",
"C:\\tools\\adb.EXE",
]);
});
it("preserves explicitly selected relative directories and quoted semicolons", async () => {
lookup.stat.mockImplementation(async (path: string) => ({
isFile: () => path === "C:\\project\\tools;selected\\npm.CMD",
}));
expect(
await findWindowsExecutable(
"npm",
{ PATH: ';"";".\\tools;selected";.', PATHEXT: ".CMD" },
{ ...options(), allowBatch: true },
),
).toEqual({ ok: true, value: "C:\\project\\tools;selected\\npm.CMD" });
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
it.each([
{},
{ PATH: "" },
{ PATH: ';"";;' },
{ PATH: "C:\\tools", PATHEXT: "" },
])(
"returns a missing result without probing implicit CWD for %j",
async (environment) => {
expect(await findWindowsExecutable("adb", environment, options())).toEqual({
ok: false,
error: { kind: "missing" },
});
expect(lookup.stat).not.toHaveBeenCalled();
},
);
it("retains the failed candidate and cause rather than selecting a later executable", async () => {
const cause = Object.assign(new Error("denied"), { code: "EACCES" });
lookup.stat.mockRejectedValue(cause);
expect(
await findWindowsExecutable(
"adb",
{ PATH: "C:\\tools;D:\\tools", PATHEXT: ".EXE" },
options(),
),
).toEqual({
ok: false,
error: {
kind: "inspection",
path: "C:\\tools\\adb.EXE",
code: "EACCES",
cause,
},
});
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
it("stops a pending filesystem lookup when its owner aborts", async () => {
const controller = new AbortController();
let complete!: (value: { isFile: () => boolean }) => void;
lookup.stat.mockImplementation(
() =>
new Promise((resolve) => {
complete = resolve;
}),
);
const pending = findWindowsExecutable(
"adb",
{ PATH: "C:\\tools", PATHEXT: ".EXE" },
{ signal: controller.signal },
);
controller.abort();
expect(await pending).toEqual({ ok: false, error: { kind: "interrupted" } });
complete({ isFile: () => true });
expect(lookup.stat).toHaveBeenCalledTimes(1);
});
+108
View File
@@ -0,0 +1,108 @@
import { stat } from "node:fs/promises";
import { win32 } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
import { ABORTED, 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];
});
};
export type WindowsExecutableFailure =
| { readonly kind: "missing" }
| { readonly kind: "interrupted" }
| { readonly kind: "invalid_extension"; readonly extension: string }
| {
readonly kind: "inspection";
readonly path: string;
readonly code: string | null;
readonly cause: unknown;
};
/**
* Resolve a default bare command against PATH/PATHEXT only. 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.
*/
export const findWindowsExecutable = async (
executable: string,
environment: Readonly<NodeJS.ProcessEnv>,
options: {
readonly signal: AbortSignal;
readonly cwd?: string;
readonly allowBatch?: boolean;
},
): 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) {
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 === ABORTED || 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,
});
}
}
}
return err({ kind: "missing" });
};
@@ -750,6 +750,27 @@ it.each([
"scripts/verify/apple/example.mjs",
{ code: true, apple: true, package: false, runtime: false },
],
[
"src/process/WindowsExecutable.ts",
{ code: true, runtime: true, package: true },
],
[
"src/process/WindowsExecutable.test.ts",
{ code: true, runtime: true, package: true },
],
["src/adb/AdbConfiguration.ts", { code: true, runtime: true, package: true }],
[
"src/apktool/ApktoolConfiguration.ts",
{ code: true, runtime: true, package: true },
],
[
"src/application/UpdateCommandRunner.ts",
{ code: true, runtime: true, package: true },
],
[
"tests/boundary/process/windowsExecutableSelection.test.ts",
{ code: true, runtime: true, package: true },
],
[
"src/windows/Example.ts",
{ code: true, runtime: true, package: true, apple: false, evm: false },
@@ -0,0 +1,146 @@
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { processTest } from "../../support/process/processFixture.js";
processTest.skipIf(process.platform !== "win32")(
"pins default ADB, Apktool and update launches to the selected Windows PATH",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-windows-tool-selection-");
const working = join(root, "project");
const trusted = join(root, "selected tools");
const marker = join(root, "selected-update.txt");
const recorder = join(root, "record-update.cjs");
await writeFile(
recorder,
`
const [selection, marker, ...arguments_] = process.argv.slice(2);
require('node:fs').appendFileSync(marker, JSON.stringify([selection, ...arguments_]) + '\\n');
process.stdout.write(selection);
`,
);
await Promise.all([mkdir(working), mkdir(trusted)]);
for (const directory of [working, trusted]) {
await copyFile(process.execPath, join(directory, "adb.exe"));
await writeFile(join(directory, "apktool.cmd"), "@echo 2.12.1\r\n");
await writeFile(
join(directory, "npm.cmd"),
`@"${process.execPath}" "${recorder}" "${directory === trusted ? "selected" : "cwd"}" "${marker}" %*\r\n`,
);
}
const moduleUrl = (name: string) =>
JSON.stringify(new URL(`../../../dist/${name}.js`, import.meta.url).href);
const program = `
import assert from 'node:assert/strict';
import { resolveAdbBinary } from ${moduleUrl("adb/AdbConfiguration")};
import { resolveApktoolCommand, inspectApktoolClient } from ${moduleUrl("apktool/ApktoolConfiguration")};
import { ApktoolCommandCleanup } from ${moduleUrl("apktool/ApktoolCommandCleanup")};
import { execFileOutput } from ${moduleUrl("process/ExecFileOutput")};
import { UpdateCommandRunner } from ${moduleUrl("application/UpdateCommandRunner")};
const environment = { ...process.env };
for (const key of Object.keys(environment))
if (['path', 'pathext', 'nodefaultcurrentdirectoryinexepath', 'rea_adb_path', 'rea_apktool_command'].includes(key.toLowerCase())) delete environment[key];
environment.Path = ${JSON.stringify(trusted)};
environment.PathExt = '.EXE;.CMD';
const adb = await resolveAdbBinary(environment);
assert.equal(adb.binary.toLowerCase(), ${JSON.stringify(join(trusted, "adb.exe").toLowerCase())});
const native = await execFileOutput(adb.binary, ['-e', 'process.stdout.write(process.execPath)'], { env: adb.environment, timeout: 5000 });
assert.equal(native.stdout.toLowerCase(), adb.binary.toLowerCase());
const apktool = await resolveApktoolCommand(environment);
assert.equal(apktool.command.toLowerCase(), ${JSON.stringify(join(trusted, "apktool.cmd").toLowerCase())});
const apktoolCommands = new ApktoolCommandCleanup();
try { assert.equal((await inspectApktoolClient(apktool, apktoolCommands, { environment })).version, '2.12.1'); }
finally { await apktoolCommands.close(); }
const runner = new UpdateCommandRunner();
try {
assert.deepEqual(await runner.runProbe(['npm', 'prefix', '--global'], environment), { ok: true, value: 'selected' });
assert.deepEqual(await runner.runInstall(['npm', 'install', '--global', '[email protected]'], environment, 'structured'), { ok: true, value: '' });
const missing = { ...environment, Path: '' };
await assert.rejects(resolveAdbBinary(missing), { code: 'binary_missing' });
await assert.rejects(resolveApktoolCommand(missing), { code: 'command_missing' });
assert.equal((await runner.runProbe(['npm', 'prefix'], missing)).ok, false);
// A caller who explicitly includes '.' still selects that directory.
const explicit = { ...environment, Path: '.' };
assert.equal((await resolveAdbBinary(explicit)).binary.toLowerCase(), ${JSON.stringify(join(working, "adb.exe").toLowerCase())});
assert.equal((await resolveApktoolCommand(explicit)).command.toLowerCase(), ${JSON.stringify(join(working, "apktool.cmd").toLowerCase())});
assert.deepEqual(await runner.runProbe(['npm', 'prefix'], explicit), { ok: true, value: 'cwd' });
} finally { await runner.close(); }
process.stdout.write('verified');
`;
const result = await processes.run(
process.execPath,
["--input-type=module", "--eval", program],
{
cwd: working,
timeoutMs: 30_000,
windowsHide: true,
},
);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toBe("verified");
expect(
(await readFile(marker, "utf8"))
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line)),
).toEqual([
["selected", "prefix", "--global"],
["selected", "install", "--global", "[email protected]"],
["cwd", "prefix"],
]);
},
);
processTest.skipIf(process.platform !== "win32")(
"runs real npm update metadata probes from a project containing an npm launcher",
async ({ processes }) => {
const root = await createTestTempDirectory("rea-windows-real-npm-");
const marker = join(root, "project-launcher.txt");
await writeFile(
join(root, "npm.cmd"),
`@echo cwd>"${marker}"\r\n@echo cwd\r\n`,
);
const runnerUrl = JSON.stringify(
new URL(
"../../../dist/application/UpdateCommandRunner.js",
import.meta.url,
).href,
);
const program = `
import assert from 'node:assert/strict';
import { isAbsolute } from 'node:path';
import { UpdateCommandRunner } from ${runnerUrl};
const environment = { ...process.env };
for (const key of Object.keys(environment))
if (key.toLowerCase() === 'nodefaultcurrentdirectoryinexepath') delete environment[key];
const runner = new UpdateCommandRunner();
try {
const version = await runner.runProbe(['npm', '--version'], environment);
assert.equal(version.ok, true, JSON.stringify(version));
assert.match(version.value, /^\\d+\\.\\d+\\.\\d+$/);
for (const query of ['root', 'prefix']) {
const result = await runner.runProbe(['npm', query, '--global'], environment);
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(isAbsolute(result.value), true, JSON.stringify(result));
}
} finally { await runner.close(); }
process.stdout.write('verified-real-npm');
`;
const result = await processes.run(
process.execPath,
["--input-type=module", "--eval", program],
{
cwd: root,
timeoutMs: 40_000,
windowsHide: true,
},
);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toBe("verified-real-npm");
await expect(readFile(marker, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
@@ -60,7 +60,7 @@ beforeEach(() => {
hooks.failure = undefined;
});
const capture = (
const capture = async (
script: string,
signal?: AbortSignal,
limits = { timeoutMs: 1000, maxOutputBytes: 1024 },
@@ -68,7 +68,7 @@ const capture = (
runAdbCapture(
"inspect_adb_device",
{ device: "device" },
resolveAdbBinary({ ...process.env, REA_ADB_PATH: process.execPath }),
await resolveAdbBinary({ ...process.env, REA_ADB_PATH: process.execPath }),
["-e", script],
limits,
signal,
@@ -165,7 +165,7 @@ test.each([null, "caller stopped", new Error("caller stopped")] as const)(
const controller = new AbortController();
controller.abort(reason);
const error: unknown = await inspectAdbClient(
resolveAdbBinary({ REA_ADB_PATH: "/must-not-start" }),
await resolveAdbBinary({ REA_ADB_PATH: process.execPath }),
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
@@ -210,7 +210,7 @@ nativeClientTest(
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(null);
const error: unknown = await inspectAdbClient(
resolveAdbBinary({ REA_ADB_PATH: join(root, "missing") }),
await resolveAdbBinary({ REA_ADB_PATH: join(root, "missing") }),
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({