mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
0e4b806db1
commit
2b8fb387b6
15 files changed
+634
-121
No files matched your search
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user