diff --git a/src/cli/commands/build/build.spec.ts b/src/cli/commands/build/build.spec.ts index 0905b1030..9ec606e71 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -1,24 +1,37 @@ import "../../../../tests/_mocks/fs.js"; -import { vol } from "memfs"; -import { build } from "./build.js"; +import "../../../../tests/_mocks/posix.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../../../tests/_mocks/internal-process.js"; +import { vol, fs } from "memfs"; +import path from "node:path"; +import process from "node:process"; import { DEFAULT_CONFIG } from "../../../config.js"; import { convertToNativePaths } from "../../../test.helpers.js"; -vi.mock("../../../core/utils/logger", () => { - return { - logger: { - error: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - silly: vi.fn(), - }, - }; -}); -vi.mock("../../../core/utils/command.js"); - describe("swa build", () => { - beforeEach(() => { + let build: typeof import("./build.js").build; + + beforeEach(async () => { + vi.resetModules(); + for (const module of [ + "../../../core/utils/workflow-config.js", + "../../../core/utils/options.js", + "../../../core/utils/file.js", + "../../../core/frameworks/detect.js", + ]) + vi.doUnmock(module); + vi.doMock("../../../core/utils/logger.js", () => ({ + logger: { error: vi.fn(), log: vi.fn(), warn: vi.fn(), silly: vi.fn() }, + })); + vi.doMock("../../../core/utils/command.js"); + ({ build } = await import("./build.js")); vol.reset(); + vol.fromJSON({ "/trusted/bin/npm": "inert native fixture; never executed" }); + fs.chmodSync("/trusted/bin/npm", 0o755); + vi.stubEnv("PATH", "/trusted/bin"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it("should run app build command", async () => { @@ -36,8 +49,16 @@ describe("swa build", () => { await build({ ...DEFAULT_CONFIG, appBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm install", "."); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: process.cwd(), + }); + expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); + expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(command.runCommand).mock.invocationCallOrder[0], + ); }); it("should run command in package.json path", async () => { @@ -51,8 +72,16 @@ describe("swa build", () => { appBuildCommand: "npm run something", }); - expect(command.runCommand).toHaveBeenCalledWith("npm install", "app"); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("app"), + }); + expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); + expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(command.runCommand).mock.invocationCallOrder[0], + ); }); it("should run api build command", async () => { @@ -70,9 +99,16 @@ describe("swa build", () => { await build({ ...DEFAULT_CONFIG, apiLocation: "api", apiBuildCommand: "npm run something" }); - expect(command.runCommand).toBeCalledTimes(2); - expect(command.runCommand).toHaveBeenCalledWith("npm install", "api"); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("api"), + }); + expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); + expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(command.runCommand).mock.invocationCallOrder[0], + ); }); it("should run command in package.json path", async () => { @@ -86,6 +122,12 @@ describe("swa build", () => { apiBuildCommand: "npm run something", }); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("api"), + }); + expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); }); @@ -109,3 +151,71 @@ describe("swa build", () => { // expect(execSyncMock).toHaveBeenCalledWith("npm run build --if-present", { cwd: convertToNativePaths("src/node-ts") }); }); }); + +describe("automatic dependency installs are internal, user build strings are deliberate", () => { + let build: typeof import("./build.js").build; + + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../../../core/utils/command.js"); + vi.doMock("../../../core/utils/workflow-config.js", () => ({ readWorkflowFile: () => undefined })); + vi.doMock("../../../core/utils/options.js", () => ({ isUserOrConfigOption: () => false })); + vi.doMock("../../../core/utils/file.js", async (importOriginal) => ({ + ...(await importOriginal()), + // Preserve the original finder isolation, independently of command execution. + findUpPackageJsonDir: async () => "/workspace/app", + })); + vi.doMock("../../../core/utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() }, + })); + vi.doMock("../../../core/frameworks/detect.js", () => ({ detectProjectFolders: vi.fn(), generateConfiguration: vi.fn() })); + ({ build } = await import("./build.js")); + vol.reset(); + mockInternalProcesses(); + vi.spyOn(process, "cwd").mockReturnValue("/workspace"); + vi.stubEnv("PATH", "/trusted/bin"); + vol.fromJSON({ + "/bin/sh": "inert system-shell fixture; never executed", + "/workspace/app/package.json": "{}", + "/trusted/bin/npm": "inert fixture", + "/trusted/bin/yarn": "inert fixture", + "/trusted/bin/pnpm": "inert fixture", + "/workspace/npm": "inert fixture", + "/workspace/yarn": "inert fixture", + "/workspace/pnpm": "inert fixture", + "/workspace/app/npm": "inert off-PATH fixture", + "/workspace/app/yarn": "inert off-PATH fixture", + "/workspace/app/pnpm": "inert off-PATH fixture", + "/workspace/app/.tools/npm": "inert configured fixture", + "/workspace/app/.tools/yarn": "inert configured fixture", + "/workspace/app/.tools/pnpm": "inert configured fixture", + }); + for (const file of Object.keys(vol.toJSON())) fs.chmodSync(file, 0o755); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ["npm", "package-lock.json"], + ["yarn", "yarn.lock"], + ["pnpm", "pnpm-lock.yaml"], + ])("runs automatic %s install through the caller-selected identity without off-PATH CWD preemption", async (manager, lock) => { + vol.fromJSON({ [`/workspace/app/${lock}`]: "" }); + for (const directory of ["/trusted/bin", "/workspace/app/.tools"]) { + vi.stubEnv("PATH", directory === "/trusted/bin" ? directory : `${directory}:/trusted/bin`); + await build({ ...DEFAULT_CONFIG, appLocation: "/workspace/app", outputLocation: ".", appBuildCommand: "npm run build && echo done" }); + expect(childProcess.execSync.mock.calls.map(([command]) => command)).not.toContain(`${manager} install`); + const launches = directLaunches().map(({ file, args, options }) => ({ file, args, cwd: options?.cwd })); + expect(launches).toContainEqual({ file: `${directory}/${manager}`, args: ["install"], cwd: "/workspace/app" }); + expect(launches.some(({ file }) => file === `/workspace/app/${manager}` || file === `/workspace/${manager}`)).toBe(false); + } + }); + + it("preserves an explicit user build command as a shell string", async () => { + const command = "npm run build && echo done"; + await build({ ...DEFAULT_CONFIG, appLocation: "/workspace/app", outputLocation: ".", appBuildCommand: command }); + expect(childProcess.execSync).toHaveBeenCalledWith(command, expect.objectContaining({ cwd: "/workspace/app", shell: "/bin/sh" })); + }); +}); diff --git a/src/cli/commands/build/build.ts b/src/cli/commands/build/build.ts index 1a927ccad..67a2ec689 100644 --- a/src/cli/commands/build/build.ts +++ b/src/cli/commands/build/build.ts @@ -5,8 +5,9 @@ import { findUpPackageJsonDir, pathExists } from "../../../core/utils/file.js"; import { isUserOrConfigOption } from "../../../core/utils/options.js"; import { logger } from "../../../core/utils/logger.js"; import { readWorkflowFile } from "../../../core/utils/workflow-config.js"; -import { runCommand } from "../../../core/utils/command.js"; +import { execInternalCommand, runCommand } from "../../../core/utils/command.js"; import { swaCliConfigFilename } from "../../../core/utils/cli-config.js"; +import { resolveTrustedPackageManager } from "../../../core/utils/executable.js"; export async function build(options: SWACLIConfig) { const workflowConfig = readWorkflowFile(); @@ -135,7 +136,8 @@ async function installNpmDependencies(packageJsonPath: string): Promise { try { const packageManager = await detectPackageManager(packageJsonPath); logger.log(`Installing dependencies with "${packageManager} install"...`); - runCommand(`${packageManager} install`, packageJsonPath); + const spec = resolveTrustedPackageManager(packageManager, { cwd: path.resolve(packageJsonPath) }); + execInternalCommand({ ...spec, args: [...spec.args, "install"], cwd: path.resolve(packageJsonPath) }); } catch (error) { logger.error(`Cannot install dependencies:`); logger.error(error as Error, true); diff --git a/src/cli/commands/db/init/init.ts b/src/cli/commands/db/init/init.ts index e5db9ef00..7e046ff53 100644 --- a/src/cli/commands/db/init/init.ts +++ b/src/cli/commands/db/init/init.ts @@ -19,7 +19,7 @@ export async function init(options: SWACLIConfig) { if (databaseType === undefined || !isValidDatabaseType(databaseType)) { logger.error( `--database-type is a required field. Please provide the type of the database you want to connect (mssql, postgresql, cosmosdb_nosql, mysql, cosmosdb_postgresql).`, - true + true, ); return; } @@ -44,13 +44,14 @@ export async function init(options: SWACLIConfig) { logger.log(`Creating ${DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME} configuration file`, "swa"); - const dataApiBinary = await getDataApiBuilderBinaryPath(); + const dataApiBinary = await getDataApiBuilderBinaryPath({ cwd: path.resolve(directory) }); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. If you already have data-api-builder installed, try running "dab init" directly to generate the config file. Exiting!!`, - true + true, ); + return; } let args: string[] = [ @@ -86,7 +87,7 @@ export async function init(options: SWACLIConfig) { if (!cosmosdb_nosqlDatabase) { logger.error( `--cosmosdb_nosql-database is required when database-type is cosmosdb_nosql, ${DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME} will not be created`, - true + true, ); } // create file staticwebapp.database.schema.json directly if database type cosmosdb_nosql since needed argument diff --git a/src/cli/commands/start/start.spec.ts b/src/cli/commands/start/start.spec.ts index 1080cea46..b6b2b9bc9 100644 --- a/src/cli/commands/start/start.spec.ts +++ b/src/cli/commands/start/start.spec.ts @@ -1,135 +1,301 @@ -import fs from "node:fs"; -import os from "node:os"; +import "../../../../tests/_mocks/fs.js"; +import "../../../../tests/_mocks/posix.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../../../tests/_mocks/internal-process.js"; +import { vol, fs } from "memfs"; +import process from "node:process"; import path from "node:path"; -import type { ConcurrentlyCommandInput, ConcurrentlyOptions, ConcurrentlyResult } from "concurrently"; -import { EventEmitter } from "node:events"; +import { DEFAULT_CONFIG } from "../../../config.js"; +import { getCertificate } from "../../../core/ssl.js"; +import { logger } from "../../../core/utils/logger.js"; +import type { InternalExecutionSpec } from "../../../core/utils/executable.js"; +import * as fixtureFs from "node:fs"; +import * as fixtureOs from "node:os"; +import * as fixturePath from "node:path"; +import * as fixtureProcess from "node:process"; +import * as fixtureUrl from "node:url"; +import * as fixtureChildProcess from "node:child_process"; -const concurrentlyMock = vi.fn(); -const spawnMock = vi.fn(); +const nativeModules = { + "node:fs": await vi.importActual("node:fs"), + "node:os": await vi.importActual("node:os"), + "node:path": await vi.importActual("node:path"), + "node:process": await vi.importActual("node:process"), + "node:url": await vi.importActual("node:url"), + "node:child_process": await vi.importActual("node:child_process"), +}; +const fixtureModules = { + "node:fs": fixtureFs, + "node:os": fixtureOs, + "node:path": fixturePath, + "node:process": fixtureProcess, + "node:url": fixtureUrl, + "node:child_process": fixtureChildProcess, +}; +const simulatedProcess = process; -vi.mock("concurrently", () => ({ - concurrently: concurrentlyMock, +vi.mock("../../../core/utils/net.js", () => ({ isAcceptingTcpConnections: async () => 4280, askNewPort: vi.fn(), parseUrl: vi.fn() })); +vi.mock("../../../core/utils/workflow-config.js", () => ({ + readWorkflowFile: ({ userWorkflowConfig }: { userWorkflowConfig: object }) => userWorkflowConfig, })); - -vi.mock("node:child_process", () => ({ - spawn: spawnMock, -})); - +vi.mock("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); vi.mock("../../../core/utils/logger.js", () => ({ - logger: { - silly: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - log: vi.fn(), - error: vi.fn(), - }, + logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn() }, })); -vi.mock("../../../core/utils/net.js", () => ({ - isAcceptingTcpConnections: vi.fn().mockResolvedValue(4280), - askNewPort: vi.fn(), - parseUrl: vi.fn(), -})); +describe("start internal launches versus intentional customer commands", () => { + let start: typeof import("./start.js").start; + let originalEnv: NodeJS.ProcessEnv; + const options = { + ...DEFAULT_CONFIG, + appLocation: "/workspace/app with spaces", + outputLocation: ".", + apiLocation: "/workspace/api with spaces", + dataApiLocation: "/workspace/data with spaces", + apiPort: 7071, + ssl: false, + }; -vi.mock("../../../core/func-core-tools.js", () => ({ - getCoreToolsBinary: vi.fn().mockResolvedValue("func"), - getNodeMajorVersion: vi.fn().mockReturnValue(20), - detectTargetCoreToolsVersion: vi.fn().mockReturnValue(4), - isCoreToolsVersionCompatible: vi.fn().mockReturnValue(true), -})); + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("../../../core/utils/process.js"); + vi.doMock("../../../core/func-core-tools.js", () => ({ + getCoreToolsBinary: async () => "/trusted tools/func", + getNodeMajorVersion: () => 20, + detectTargetCoreToolsVersion: () => 4, + isCoreToolsVersionCompatible: () => true, + })); + vi.doMock("../../../core/dataApiBuilder/index.js", () => ({ getDataApiBuilderBinaryPath: async () => "/trusted tools/dab" })); + ({ start } = await import("./start.js")); + originalEnv = { ...process.env }; + vol.reset(); + vol.fromJSON({ "/bin/sh": "inert system-shell fixture; never executed" }); + fs.chmodSync("/bin/sh", 0o755); + vol.mkdirSync(options.appLocation, { recursive: true }); + vol.mkdirSync(options.apiLocation, { recursive: true }); + vol.mkdirSync(options.dataApiLocation, { recursive: true }); + mockInternalProcesses(); + vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + }); -vi.mock("../../../core/dataApiBuilder/index.js", () => ({ - getDataApiBuilderBinaryPath: vi.fn(), -})); + afterEach(() => { + process.env = originalEnv; + }); -vi.mock("../../../core/utils/json.js", () => ({ - loadPackageJson: vi.fn().mockReturnValue({ version: "test" }), -})); + function launches() { + return directLaunches().map(({ file, args, options }) => ({ file, args, cwd: options?.cwd, shell: options?.shell })); + } + + function functionsLaunch() { + const launch = launches().find(({ cwd }) => cwd === options.apiLocation); + expect(launch?.file).toBe("/trusted tools/func"); + expect(launch?.shell).toBe(false); + expect(launch?.args.slice(0, 5)).toEqual(["start", "--cors", "*", "--port", "7071"]); + return launch!; + } -function createFakeChildProcess() { - const child = new EventEmitter() as any; - child.pid = 12345; - child.stdin = null; - child.stdout = null; - child.stderr = null; - return child; -} - -describe("swa start command execution safety", () => { - let tempRoot: string; - let originalExit: typeof process.exit; - - beforeEach(() => { - concurrentlyMock.mockReset(); - spawnMock.mockReset(); - spawnMock.mockReturnValue(createFakeChildProcess()); - - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "swa-start-spec-")); - fs.mkdirSync(path.join(tempRoot, "api"), { recursive: true }); - fs.mkdirSync(path.join(tempRoot, "app"), { recursive: true }); - - originalExit = process.exit; - process.exit = vi.fn() as any; + it("launches the emulator using current Node and literal server argv without the default shell adapter", async () => { + await start(options); + const emulator = launches().find((launch) => launch.file === process.execPath); + expect(emulator).toBeDefined(); + expect(emulator!.args).toHaveLength(1); + expect(path.isAbsolute(emulator!.args[0])).toBe(true); + expect(emulator!.args[0]).toMatch(/[/\\]msha[/\\]server\.js$/); + expect(emulator!.shell).toBe(false); + expect(launches().some(({ args }) => args.some((arg: string) => arg.startsWith("swa-internal-")))).toBe(false); }); - afterEach(() => { - process.exit = originalExit; - fs.rmSync(tempRoot, { recursive: true, force: true }); + it("launches Functions with a bound absolute prefix and explicit API cwd", async () => { + process.env.PATH = `${options.apiLocation}:/bin`; + await start(options); + expect(functionsLaunch().args).toEqual(["start", "--cors", "*", "--port", "7071"]); + const api = directLaunches().find(({ options: launchOptions }) => launchOptions?.cwd === options.apiLocation)!; + expect(api.options?.env?.PATH).toBe(`${options.apiLocation}:/bin`); }); - it("should spawn Functions with argv and shell disabled for funcArgs", async () => { - let capturedCommands: ConcurrentlyCommandInput[] = []; - let capturedOptions: Partial = {}; - - concurrentlyMock.mockImplementation((commands: ConcurrentlyCommandInput[], options: Partial) => { - capturedCommands = commands; - capturedOptions = options; - const result: ConcurrentlyResult = { - commands: [] as any, - result: Promise.resolve([ - { - command: { name: "swa", command: "node server.js" }, - killed: true, - index: 0, - exitCode: 0, - timings: { startDate: new Date(), endDate: new Date(), durationSeconds: 0 }, - }, - ] as any), - }; - return result; + it("parses funcArgs into literal argv without changing the selected Functions identity", async () => { + // Arrange + const funcArgs = '--name "two words" "" "$VALUE" && echo done'; + // Act + await start({ ...options, funcArgs }); + // Assert + expect(functionsLaunch().args).toEqual(["start", "--cors", "*", "--port", "7071", "--name", "two words", "", "$VALUE", "&&", "echo", "done"]); + }); + + it("rejects malformed funcArgs quotes before launching automatic workers", async () => { + // Real fatal logger.error exits; its test double must also stop execution. + vi.mocked(logger.error).mockImplementationOnce((message) => { + throw new Error(String(message)); }); - const { start } = await import("./start.js"); + await expect(start({ ...options, funcArgs: '"unclosed' })).rejects.toThrow("Unbalanced quotes in --func-args."); + expect(launches()).toEqual([]); + }); + + it("should spawn Functions with argv and shell disabled for funcArgs", async () => { + // Preserve upstream MSRC144619's payload/control through the actual existing + // thin adapter and named core, not its encoded command-prefix implementation. await start({ - appLocation: path.join(tempRoot, "app"), - outputLocation: ".", - apiLocation: path.join(tempRoot, "api"), - apiPort: 7071, - host: "localhost", - port: 4280, - devserverTimeout: 60, - ssl: false, - open: false, - swaConfigLocation: path.join(tempRoot, "app"), + ...options, + dataApiLocation: undefined, funcArgs: "| (echo vulnerable>swa-msrc-144619-windows.marker)", - } as SWACLIConfig); - - const apiCommand = (capturedCommands as any[]).find((command) => command.name === "api")?.command as string; - expect(apiCommand).toContain("__SWA_INTERNAL_FUNC_START__:"); - expect(apiCommand).not.toContain("vulnerable"); - expect(capturedOptions.spawn).toBeTypeOf("function"); + }); - (capturedOptions.spawn as any)(apiCommand, { env: {} }); + expect(functionsLaunch()).toEqual({ + file: "/trusted tools/func", + args: ["start", "--cors", "*", "--port", "7071", "|", "(echo", "vulnerable>swa-msrc-144619-windows.marker)"], + cwd: options.apiLocation, + shell: false, + }); + expect(launches().map(({ file }) => file)).toEqual([process.execPath, "/trusted tools/func"]); + expect(childProcess.exec).not.toHaveBeenCalled(); + expect(childProcess.execSync).not.toHaveBeenCalled(); + }); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(spawnMock).toHaveBeenCalledWith( - "func", - ["start", "--cors", "*", "--port", "7071", "|", "(echo", "vulnerable>swa-msrc-144619-windows.marker)"], + it("launches DAB with the selected absolute identity, literal args and explicit data cwd", async () => { + await start(options); + expect(launches()).toContainEqual( expect.objectContaining({ + file: "/trusted tools/dab", + args: ["start", "-c", "staticwebapp.database.config.json", "--no-https-redirect"], + cwd: options.dataApiLocation, shell: false, - windowsVerbatimArguments: false, - cwd: path.join(tempRoot, "api"), }), ); }); + + it("preserves the deliberate --run shell command instead of classifying it as an internal tool", async () => { + const run = "npm run preview && echo done"; + await start({ ...options, run }); + expect(process.env.SWA_CLI_STARTUP_COMMAND).toBe(run); + const shells = [...childProcess.exec.mock.calls.map(([command]) => command), ...launches().flatMap((launch) => launch.args)]; + expect(shells.some((command) => command.includes(run))).toBe(true); + expect(launches()).toContainEqual(expect.objectContaining({ file: "/bin/sh", args: ["-c", run], shell: false })); + }); + + it("preserves user-supplied TLS key and certificate without generating replacements", async () => { + await start({ ...options, ssl: true, sslCert: "/user certs/cert.pem", sslKey: "/user certs/key.pem" }); + expect(getCertificate).not.toHaveBeenCalled(); + expect(process.env.SWA_CLI_APP_SSL_CERT).toBe("/user certs/cert.pem"); + expect(process.env.SWA_CLI_APP_SSL_KEY).toBe("/user certs/key.pem"); + }); +}); + +describe("Functions literal argv security compatibility (benign native argv recorder)", () => { + const fs = nativeModules["node:fs"].default; + const os = nativeModules["node:os"].default; + const path = nativeModules["node:path"].default; + const process = nativeModules["node:process"].default; + const { spawnSync } = nativeModules["node:child_process"]; + const { fileURLToPath } = nativeModules["node:url"]; + let start: typeof import("./start.js").start; + let prepareExecution: typeof import("../../../core/utils/executable.js").prepareExecution; + let superviseProcesses: typeof import("../../../core/utils/process.js").superviseProcesses; + let sandbox: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + // Fresh production imports use real native modules, not the unit namespace. + vi.resetModules(); + for (const [name, module] of Object.entries(nativeModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => nativeModules["node:fs"]); + vi.doMock("child_process", () => nativeModules["node:child_process"]); + vi.stubGlobal("process", process); + vi.doMock("../../../core/utils/process.js", () => ({ superviseProcesses: vi.fn() })); + vi.doMock("../../../core/func-core-tools.js", () => ({ + getCoreToolsBinary: async () => process.execPath, + getNodeMajorVersion: () => 20, + detectTargetCoreToolsVersion: () => 4, + isCoreToolsVersionCompatible: () => true, + })); + vi.doUnmock("../../../core/dataApiBuilder/index.js"); + ({ start } = await import("./start.js")); + ({ prepareExecution } = await import("../../../core/utils/executable.js")); + ({ superviseProcesses } = await import("../../../core/utils/process.js")); + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "swa-suffix-compat-")); + originalEnv = { ...process.env }; + vi.mocked(superviseProcesses).mockResolvedValue([]); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(sandbox, { recursive: true }); + for (const [name, module] of Object.entries(fixtureModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => fixtureFs); + vi.doMock("child_process", () => fixtureChildProcess); + vi.stubGlobal("process", simulatedProcess); + }); + + it("keeps funcArgs variables, operators and redirection literal while retaining quoted space and UNC argument values", async () => { + const api = path.join(sandbox, "api with spaces"); + fs.mkdirSync(api); + fs.copyFileSync(fileURLToPath(new URL("../../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)), path.join(api, "start")); + const report = path.join(sandbox, "launch.jsonl"); + const marker = path.join(sandbox, "suffix marker.txt"); + process.env.SWA_COMPAT_REPORT = report; + process.env.SWA_COMPAT_MARKER = marker; + process.env.SWA_COMPAT_VALUE = "expanded two words"; + const cert = String.raw`\\fileserver\dev certs\local.pfx`; + process.env.SWA_COMPAT_CERT = cert; + const variable = (name: string) => (process.platform === "win32" ? `%${name}%` : `$${name}`); + const file = path.join(api, "certificate with spaces.pfx"); + const suffix = `--file "${file}" --cert "${cert}" --name "${variable("SWA_COMPAT_VALUE")}" && echo suffix-complete > "${variable("SWA_COMPAT_MARKER")}"`; + + function execute(execution: InternalExecutionSpec) { + const child = spawnSync(execution.executable, execution.args, { + cwd: execution.cwd, + env: { ...process.env, ...execution.env }, + shell: false, + windowsVerbatimArguments: execution.windowsVerbatimArguments, + encoding: "utf8", + timeout: 5000, + }); + expect(child.error).toBeUndefined(); + expect(child.status, child.stderr).toBe(0); + const record = JSON.parse(fs.readFileSync(report, "utf8").trim()); + return { execPath: record.execPath, args: record.args, cwd: record.cwd, suffixCompleted: fs.existsSync(marker) }; + } + + const expected = { + execPath: process.execPath, + args: [ + "--cors", + "*", + "--port", + "7071", + "--file", + file, + "--cert", + cert, + "--name", + variable("SWA_COMPAT_VALUE"), + "&&", + "echo", + "suffix-complete", + ">", + variable("SWA_COMPAT_MARKER"), + ], + cwd: fs.realpathSync(api), + suffixCompleted: false, + }; + // Positive control: actual native execution with independently specified argv. + // Negative control: no expansion, command operator or redirection may run. + expect(execute(prepareExecution({ executable: process.execPath, args: ["start", ...expected.args], cwd: api }))).toEqual(expected); + fs.writeFileSync(report, ""); + + await start({ + ...DEFAULT_CONFIG, + appLocation: api, + apiLocation: api, + dataApiLocation: undefined, + outputLocation: ".", + apiPort: 7071, + ssl: false, + funcArgs: suffix, + }); + const execution = vi.mocked(superviseProcesses).mock.calls[0]?.[0].find((command) => command.name === "api")?.execution; + expect(execution).toBeDefined(); + if (!execution) throw new Error("start did not schedule the Functions fixture."); + expect(execute(execution)).toEqual(expected); + }); }); diff --git a/src/cli/commands/start/start.ts b/src/cli/commands/start/start.ts index ccee3a83c..c525f5b02 100644 --- a/src/cli/commands/start/start.ts +++ b/src/cli/commands/start/start.ts @@ -1,5 +1,4 @@ -import { concurrently, CloseEvent, ConcurrentlyOptions } from "concurrently"; -import { spawn, SpawnOptions } from "node:child_process"; +import { CloseEvent } from "concurrently"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -19,6 +18,8 @@ import { getDataApiBuilderBinaryPath } from "../../../core/dataApiBuilder/index. import { swaCLIEnv } from "../../../core/env.js"; import { getCertificate } from "../../../core/ssl.js"; import { loadPackageJson } from "../../../core/utils/json.js"; +import { createShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; +import { NamedExecutionSpec, superviseProcesses } from "../../../core/utils/process.js"; const packageInfo = loadPackageJson(); @@ -27,13 +28,6 @@ const __dirname = path.dirname(__filename); // This is the path to ./dist/msha/server.js const mshaPath = path.resolve(__dirname, "../../../../dist/msha/server.js"); -const INTERNAL_FUNC_START_PREFIX = "__SWA_INTERNAL_FUNC_START__:"; - -type InternalFuncStartPayload = { - binary: string; - args: string[]; - cwd: string; -}; // import { createRequire } from "node:module"; // const require = createRequire(import.meta.url); @@ -71,6 +65,7 @@ export async function start(options: SWACLIConfig) { let useApiDevServer: string | undefined | null = undefined; let useDataApiDevServer: string | undefined | null = undefined; let startupCommand: string | undefined | null = undefined; + const launchCwd = process.cwd(); let resolvedPortNumber = await isAcceptingTcpConnections({ host, port }); if (resolvedPortNumber === 0) { @@ -175,20 +170,19 @@ export async function start(options: SWACLIConfig) { } const isApiLocationExistsOnDisk = fs.existsSync(userWorkflowConfig?.apiLocation!); - let apiFuncPayload: InternalFuncStartPayload | undefined = undefined; // handle the API location config - let serveApiCommand = "echo 'No API found. Skipping'"; + let serveApi: InternalExecutionSpec | undefined; if (useApiDevServer) { - serveApiCommand = `echo 'using API dev server at ${useApiDevServer}'`; + logger.info(`Using API dev server at ${useApiDevServer}`, "api"); // get the API port from the dev server apiPort = parseUrl(useApiDevServer)?.port; } else { if (apiLocation && userWorkflowConfig?.apiLocation) { - // check if the func binary is globally available and if not, download it - const funcBinary = await getCoreToolsBinary(); + // Check the caller's PATH before using the managed download. + const funcBinary = await getCoreToolsBinary({ cwd: path.resolve(userWorkflowConfig.apiLocation) }); const nodeMajorVersion = getNodeMajorVersion(); const targetVersion = detectTargetCoreToolsVersion(nodeMajorVersion); @@ -201,6 +195,7 @@ export async function start(options: SWACLIConfig) { `See https://aka.ms/functions-core-tools for more information.`, true ); + return; } else { if (isCoreToolsVersionCompatible(targetVersion, nodeMajorVersion) === false) { logger.error( @@ -208,38 +203,37 @@ export async function start(options: SWACLIConfig) { ); logger.error("See https://aka.ms/functions-node-versions for more information."); process.exit(1); + return; } // serve the api if and only if the user provides a folder via the --api-location flag if (isApiLocationExistsOnDisk) { - let parsedFuncArgs: string[] = []; + let parsedFuncArgs: string[]; try { parsedFuncArgs = parseCommandLineArgs(funcArgs); } catch (error: unknown) { logger.error((error as Error).message, true); + return; } - apiFuncPayload = { - binary: funcBinary, - cwd: userWorkflowConfig.apiLocation, - args: ["start", "--cors", "*", "--port", `${apiPort}`, ...parsedFuncArgs], + serveApi = { + executable: funcBinary, + args: ["start", "--cors", "*", "--port", String(apiPort), ...parsedFuncArgs], + cwd: path.resolve(userWorkflowConfig.apiLocation), }; - - serveApiCommand = `${INTERNAL_FUNC_START_PREFIX}${Buffer.from(JSON.stringify(apiFuncPayload)).toString("base64url")}`; } } } } - let serveDataApiCommand = "echo 'No Data API found'. Skipping"; - let startDataApiBuilderNeeded = false; + let serveDataApi: InternalExecutionSpec | undefined; if (useDataApiDevServer) { - serveDataApiCommand = `echo using Data API server at ${useDataApiDevServer}`; + logger.info(`Using Data API server at ${useDataApiDevServer}`, "dataApi"); dataApiPort = parseUrl(useDataApiDevServer)?.port; } else { if (dataApiLocation) { - const dataApiBinary = await getDataApiBuilderBinaryPath(); + const dataApiBinary = await getDataApiBuilderBinaryPath({ cwd: path.resolve(dataApiLocation) }); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -247,14 +241,18 @@ export async function start(options: SWACLIConfig) { starting data-api-builder engine separately. Exiting!!`, true, ); + return; } else { - serveDataApiCommand = `cd "${dataApiLocation}" && "${dataApiBinary}" start -c ${DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME} --no-https-redirect`; + serveDataApi = { + executable: dataApiBinary, + args: ["start", "-c", DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME, "--no-https-redirect"], + cwd: path.resolve(dataApiLocation), + }; dataApiPort = DEFAULT_CONFIG.dataApiPort; - startDataApiBuilderNeeded = true; } } - logger.silly(`Running ${serveDataApiCommand}`); + if (serveDataApi) logger.silly(`Using Data API Builder at ${serveDataApi.executable}`); } if (ssl) { @@ -280,6 +278,7 @@ export async function start(options: SWACLIConfig) { if (run) { startupCommand = createStartupScriptCommand(run, options); } + const startup = startupCommand ? createShellExecutionSpec(startupCommand, path.resolve(userWorkflowConfig?.appLocation ?? appLocation)) : undefined; // resolve the following config to their absolute paths // note: the server will perform a search starting from this path @@ -318,85 +317,26 @@ export async function start(options: SWACLIConfig) { // INFO: from here, code may access SWA CLI env vars. - const swa_cli_env = swaCLIEnv(); - // Convert the swa_cli_env to a Record object so that type checking - // works in the ConcurrentlyCommandInput[] array - let env: Record = {}; - for (const [k, v] of Object.entries(swa_cli_env)) { - env[k] = v; - } - - // Copy of the CommandInfo interface from concurrently.d.ts - // See https://github.com/open-cli-tools/concurrently/blob/main/src/command.ts#L10 - interface CommandInfo { - name: string; - command: string; - env?: Record; - cwd?: string; - prefixColor?: string; - raw?: boolean; - } - - const concurrentlyCommands: CommandInfo[] = [ - // start the reverse proxy - { command: `node "${mshaPath}"`, name: "swa", env, prefixColor: "gray.dim" }, - ]; + const env: NodeJS.ProcessEnv = { ...process.env, ...swaCLIEnv() }; + const commands: NamedExecutionSpec[] = [{ name: "swa", execution: { executable: process.execPath, args: [mshaPath], cwd: launchCwd, env } }]; - if (isApiLocationExistsOnDisk) { - concurrentlyCommands.push( - // serve the api, if it's available - { command: serveApiCommand, name: "api", env, prefixColor: "gray.dim" }, - ); - } - - if (startDataApiBuilderNeeded) { - concurrentlyCommands.push({ command: serveDataApiCommand, name: "dataApi", env }); - } - - // run an external script, if it's available - if (startupCommand) { - let startupPath = userWorkflowConfig?.appLocation; - - concurrentlyCommands.push({ command: `cd "${startupPath}" && ${startupCommand}`, name: "run", env, prefixColor: "gray.dim" }); - } + if (serveApi) commands.push({ name: "api", execution: { ...serveApi, env: { ...env, ...serveApi.env } } }); + if (serveDataApi) commands.push({ name: "dataApi", execution: { ...serveDataApi, env } }); + if (startup) commands.push({ name: "run", execution: { ...startup, env: { ...env, ...startup.env } } }); logger.silly(`Starting the SWA emulator with the following configuration:`); logger.silly({ ssl: [ssl, sslCert, sslKey], env: envVarsObj, commands: { - swa: concurrentlyCommands.find((c) => c.name === "swa")?.command, - api: apiFuncPayload - ? `"${apiFuncPayload.binary}" ${apiFuncPayload.args.join(" ")} (cwd: ${apiFuncPayload.cwd})` - : concurrentlyCommands.find((c) => c.name === "api")?.command, - dataApi: concurrentlyCommands.find((c) => c.name == "dataApi")?.command, - run: concurrentlyCommands.find((c) => c.name === "run")?.command, + swa: process.execPath, + api: serveApi?.executable, + dataApi: serveDataApi?.executable, + run: startup?.executable, }, }); - const concurrentlyOptions: Partial = { - restartTries: 0, - killOthers: ["failure", "success"], - raw: true, - spawn: (command: string, options: SpawnOptions) => { - if (command.startsWith(INTERNAL_FUNC_START_PREFIX)) { - const encodedPayload = command.slice(INTERNAL_FUNC_START_PREFIX.length); - const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as InternalFuncStartPayload; - const env = Object.fromEntries(Object.entries(options.env || {}).map(([key, value]) => [key, value == null ? undefined : String(value)])); - - return spawn(payload.binary, payload.args, { - ...options, - cwd: payload.cwd, - env, - shell: false, - windowsVerbatimArguments: false, - }); - } - - return spawnWithShell(command, options); - }, - }; - const { result } = concurrently(concurrentlyCommands, concurrentlyOptions); + const result = superviseProcesses(commands); await result .then( @@ -432,17 +372,3 @@ export async function start(options: SWACLIConfig) { logger.error(err.message, true); }); } - -function spawnWithShell(command: string, options: SpawnOptions) { - const shellOptions: SpawnOptions = { ...options }; - - if (process.platform === "win32") { - const shell = process.env.comspec || "cmd.exe"; - return spawn(shell, ["/d", "/s", "/c", `"${command}"`], { - ...shellOptions, - windowsVerbatimArguments: true, - }); - } - - return spawn("/bin/sh", ["-c", command], shellOptions); -} diff --git a/src/cli/index.spec.ts b/src/cli/index.spec.ts index 56c2ac5ee..55da747dd 100644 --- a/src/cli/index.spec.ts +++ b/src/cli/index.spec.ts @@ -1,16 +1,29 @@ +import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; import { fs, vol } from "memfs"; import { program } from "commander"; -import { run } from "./index.js"; +import fsNative from "node:fs"; +import os from "node:os"; +import process from "node:process"; +import path from "node:path"; import { loadPackageJson } from "../core/utils/json.js"; -import * as builder from "./commands/build/build.js"; const pkg = loadPackageJson(); - -vi.mock("node:fs"); -vi.mock("node:fs/promises", async () => { - const memfs: { fs: typeof fs } = await vi.importActual("memfs"); - return memfs.fs.promises; -}); +const magicDependencies = [ + "dotenv", + "./commands/deploy/register.js", + "./commands/init/register.js", + "./commands/login/register.js", + "./commands/start/register.js", + "./commands/build/register.js", + "./commands/db/init/register.js", + "./commands/docs.js", + "../core/utils/update-notifier.js", + "../core/func-core-tools.js", + "../core/utils/options.js", + "../core/utils/cli-config.js", + "../core/prompts.js", + "../core/utils/logger.js", +]; const originalConsoleError = console.error; @@ -25,6 +38,22 @@ const expectedConfig = { }; describe("cli", () => { + let run: typeof import("./index.js").run; + let builder: typeof import("./commands/build/build.js"); + + beforeAll(async () => { + // Load the real command registration once; magic-flow stubs must not replace it. + vi.resetModules(); + for (const module of magicDependencies) vi.doUnmock(module); + vi.doMock("node:fs"); + vi.doMock("node:fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs.promises; + }); + ({ run } = await import("./index.js")); + builder = await import("./commands/build/build.js"); + }); + beforeEach(() => { vol.reset(); program.exitOverride(); @@ -50,3 +79,122 @@ describe("cli", () => { expect(builder.build).toHaveBeenCalledWith({ ...expectedConfig, appLocation: "." }); }); }); + +describe("magic-flow same-installation reentry", () => { + const fs = fsNative; + let dotenv: typeof import("dotenv"); + let swaMagic: typeof import("./index.js").swaMagic; + let getCurrentSwaCliConfigFromFile: typeof import("../core/utils/cli-config.js").getCurrentSwaCliConfigFromFile; + let promptOrUseDefault: typeof import("../core/prompts.js").promptOrUseDefault; + + beforeEach(async () => { + vi.resetModules(); + vi.doUnmock("node:fs"); + vi.doUnmock("node:fs/promises"); + // Keep reentry tests isolated from login, credential stores, servers and SSL. + vi.doMock("dotenv", () => ({ default: { config: vi.fn() } })); + for (const module of magicDependencies.slice(1, 7)) vi.doMock(module, () => ({ default: vi.fn() })); + vi.doMock("./commands/docs.js", () => ({ registerDocs: vi.fn() })); + vi.doMock("../core/utils/update-notifier.js", () => ({ notifyOnUpdate: vi.fn() })); + vi.doMock("../core/func-core-tools.js", () => ({ getNodeMajorVersion: () => 20 })); + vi.doMock("../core/utils/options.js", () => ({ configureOptions: vi.fn() })); + vi.doMock("../core/utils/cli-config.js", () => ({ getCurrentSwaCliConfigFromFile: vi.fn(), swaCliConfigFilename: "swa-cli.config.json" })); + vi.doMock("../core/prompts.js", () => ({ promptOrUseDefault: vi.fn() })); + vi.doMock("../core/utils/logger.js", () => ({ logger: { log: vi.fn(), error: vi.fn() } })); + ({ swaMagic } = await import("./index.js")); + ({ default: dotenv } = await import("dotenv")); + ({ getCurrentSwaCliConfigFromFile } = await import("../core/utils/cli-config.js")); + ({ promptOrUseDefault } = await import("../core/prompts.js")); + mockInternalProcesses(); + vi.spyOn(process, "cwd").mockReturnValue("/project with spaces"); + vi.mocked(getCurrentSwaCliConfigFromFile).mockReturnValue(undefined); + vi.mocked(promptOrUseDefault).mockResolvedValue({ deploy: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reenters init, build and deploy with current Node and an absolute current-installation entrypoint", async () => { + await swaMagic({} as SWACLIConfig); + expect(childProcess.execSync.mock.calls.map(([command]) => command)).toEqual([]); + const launches = directLaunches(); + expect(launches).toHaveLength(3); + expect(launches.map((launch) => launch.args.slice(1))).toEqual([["init"], ["build"], ["deploy"]]); + for (const launch of launches) { + expect(launch.file).toBe(process.execPath); + expect(path.isAbsolute(launch.args[0])).toBe(true); + expect(launch.args[0]).toMatch(/[/\\]cli[/\\]bin\.js$/); + expect(launch.options).toEqual( + expect.objectContaining({ + cwd: "/project with spaces", + env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }), + }), + ); + } + expect(new Set(launches.map((launch) => launch.args[0])).size).toBe(1); + }); + + it("preserves the configured-project and declined-deploy branches", async () => { + vi.mocked(getCurrentSwaCliConfigFromFile).mockReturnValue({ appLocation: "." } as any); + vi.mocked(promptOrUseDefault).mockResolvedValue({ deploy: false }); + await swaMagic({} as SWACLIConfig); + // Both baseline shell calls and future direct calls are intercepted. + const commands = [ + ...childProcess.execSync.mock.calls.map(([command]) => command), + ...directLaunches().map((launch) => launch.args.slice(1).join(" ")), + ]; + expect(commands).toHaveLength(1); + expect(commands[0]).toMatch(/(?:^| )build$/); + }); + + it.each([false, true])("honors operation-effective configured PATH after dotenv without adding off-PATH CWD (present=%s)", async (present) => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "swa-entry-provenance-")); + try { + const project = path.join(sandbox, "project"); + const injected = path.join(project, "tools"); + const inherited = path.join(sandbox, "caller tools"); + const tool = "swa-path-fixture"; + const filename = process.platform === "win32" ? `${tool}.exe` : tool; + fs.mkdirSync(injected, { recursive: true }); + fs.mkdirSync(inherited); + fs.writeFileSync(path.join(injected, filename), "inert project fixture", { mode: 0o755 }); + fs.writeFileSync(path.join(inherited, filename), "inert caller fixture", { mode: 0o755 }); + fs.writeFileSync(path.join(project, filename), "inert off-PATH CWD canary", { mode: 0o755 }); + vi.spyOn(process, "cwd").mockReturnValue(project); + vi.stubEnv("PATH", present ? inherited : ""); + if (!present) delete process.env.PATH; + childProcess.spawnSync.mockReturnValue({ status: 0, stdout: "/usr/bin:/bin", stderr: "" }); + vi.mocked(dotenv.config).mockImplementation(() => { + process.env.PATH = injected; + return { parsed: { PATH: injected } }; + }); + vi.resetModules(); + const cli = await import("./index.js"); + const resolver = await import("../core/utils/executable.js"); + expect(process.env.PATH).toBe(injected); + const selected = resolver.resolveTrustedExecutable(tool, { cwd: project }); + process.env.PATH = inherited; + const external = resolver.resolveTrustedExecutable(tool, { cwd: project }); + process.env.PATH = injected; + await cli.swaMagic({} as SWACLIConfig); + const child = directLaunches().find(({ file, args }) => file === process.execPath && args?.includes("init")); + const env = child?.options?.env; + if (!env) throw new Error("Magic reentry did not provide its prepared environment."); + // One configured-lookup contract, paired with the identical off-PATH canary. + expect({ + selected, + external, + reentryPath: process.platform === "win32" ? env.Path ?? env.PATH : env.PATH, + absenceMarker: env.SWA_CLI_INTERNAL_PATH_ABSENT, + }).toEqual({ + selected: fs.realpathSync(path.join(injected, filename)), + external: fs.realpathSync(path.join(inherited, filename)), + reentryPath: injected, + absenceMarker: undefined, + }); + } finally { + fs.rmSync(sandbox, { recursive: true }); + } + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index bf855a931..f74a8f470 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,13 +5,14 @@ import process from "node:process"; import chalk from "chalk"; import { Command, Option, program } from "commander"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { notifyOnUpdate } from "../core/utils/update-notifier.js"; import { DEFAULT_CONFIG } from "../config.js"; import { configureOptions } from "../core/utils/options.js"; import { getCurrentSwaCliConfigFromFile, swaCliConfigFilename } from "../core/utils/cli-config.js"; import { getNodeMajorVersion } from "../core/func-core-tools.js"; import { logger } from "../core/utils/logger.js"; -import { runCommand } from "../core/utils/command.js"; +import { execInternalCommand } from "../core/utils/command.js"; import { default as registerDeploy } from "./commands/deploy/register.js"; import { default as registerInit } from "./commands/init/register.js"; import { default as registerLogin } from "./commands/login/register.js"; @@ -99,15 +100,19 @@ export async function run(argv?: string[]) { } export async function swaMagic(_options: SWACLIConfig) { + const executable = process.execPath; + const entrypoint = fileURLToPath(new URL("./bin.js", import.meta.url)); + const cwd = process.cwd(); + const reenter = (command: string) => execInternalCommand({ executable, args: [entrypoint, command], cwd }); try { const hasLoadedConfig = getCurrentSwaCliConfigFromFile(); if (!hasLoadedConfig) { logger.log(`${chalk.cyan("→")} No configuration found, running ${chalk.cyan("swa init")}...\n`); - runCommand("swa init"); + reenter("init"); } logger.log(`${chalk.cyan("→")} Running ${chalk.cyan("swa build")}...\n`); - runCommand("swa build"); + reenter("build"); logger.log(""); const response = await promptOrUseDefault(false, { @@ -122,7 +127,7 @@ export async function swaMagic(_options: SWACLIConfig) { } logger.log(`\n${chalk.cyan("→")} Running ${chalk.cyan("swa deploy")}...\n`); - runCommand("swa deploy"); + reenter("deploy"); } catch (_) { // Pokemon, go catch'em all! // (Errors are already caught an displayed in individual commands) diff --git a/src/core/dataApiBuilder/dab.spec.ts b/src/core/dataApiBuilder/dab.spec.ts index 6873d91bf..a44cd53ca 100644 --- a/src/core/dataApiBuilder/dab.spec.ts +++ b/src/core/dataApiBuilder/dab.spec.ts @@ -1,4 +1,11 @@ -import { DEFAULT_DATA_API_BUILDER_BINARY } from "../constants.js"; +import "../../../tests/_mocks/fs.js"; +import "../../../tests/_mocks/posix.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../../tests/_mocks/internal-process.js"; +import { vol, fs } from "memfs"; +import os from "node:os"; +import process from "node:process"; +import path from "node:path"; +import { DATA_API_BUILDER_FOLDER, DEFAULT_DATA_API_BUILDER_BINARY } from "../constants.js"; import { getDefaultDataApiBuilderBinaryForOS } from "./dab.js"; describe("getDefaultDataApiBuilderBinaryForOS", () => { @@ -30,3 +37,70 @@ describe("getDefaultDataApiBuilderBinaryForOS", () => { expect(getDefaultDataApiBuilderBinaryForOS(platform)).toEqual(expectedBinary); }); }); + +describe("internal executable identity (no fixture execution)", () => { + let installAndGetDataApiBuilder: typeof import("./dab.js").installAndGetDataApiBuilder; + let downloadAndValidateBinary: typeof import("../download-binary-helper.js").downloadAndValidateBinary; + + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../utils/fetch-proxy.js", () => ({ fetchWithProxy: vi.fn() })); + vi.doMock("../download-binary-helper.js", () => ({ downloadAndValidateBinary: vi.fn() })); + vi.doMock("../utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn(), info: vi.fn() }, + })); + ({ installAndGetDataApiBuilder } = await import("./dab.js")); + ({ downloadAndValidateBinary } = await import("../download-binary-helper.js")); + vol.reset(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + vi.spyOn(process, "cwd").mockReturnValue("/workspace"); + vi.stubEnv("PATH", "/trusted/bin"); + vol.fromJSON({ + "/workspace/func": "inert fixture", + "/workspace/dab": "inert fixture", + "/workspace/relative-tools/func": "inert fixture", + "/workspace/relative-tools/dab": "inert fixture", + "/trusted/bin/func": "inert fixture", + "/trusted/bin/dab": "inert fixture", + }); + for (const file of Object.keys(vol.toJSON())) fs.chmodSync(file, 0o755); + const { fetchWithProxy } = await import("../utils/fetch-proxy.js"); + vi.mocked(fetchWithProxy).mockResolvedValue({ + json: async () => [{ version: "latest", versionId: "1.2.3" }], + } as any); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("probes installed DAB with an absolute executable and argv, not a shell", async () => { + mockInternalProcesses("Microsoft.DataApiBuilder 1.2.3\r\n"); + await installAndGetDataApiBuilder(); + expect(childProcess.exec.mock.calls).toEqual([]); + expect(directLaunches()).toContainEqual(expect.objectContaining({ file: "/trusted/bin/dab", args: ["--version"] })); + }); + + it("returns the same trusted DAB identity selected for probing", async () => { + vi.stubEnv("PATH", "/workspace/relative-tools:/trusted/bin"); + mockInternalProcesses("Microsoft.DataApiBuilder 1.2.3\r\n"); + const result = await installAndGetDataApiBuilder(); + expect(result.binaryPath).toBe("/workspace/relative-tools/dab"); + expect(directLaunches()).toContainEqual(expect.objectContaining({ file: result.binaryPath, args: ["--version"] })); + }); + + it("fails closed when DAB probing and download both fail instead of returning a bare name", async () => { + mockInternalProcesses("", new Error("not installed")); + vi.mocked(downloadAndValidateBinary).mockRejectedValue(new Error("offline")); + await expect(installAndGetDataApiBuilder()).rejects.toThrow(); + }); + + it("preserves the absolute cached DAB download when the installed version is outdated", async () => { + mockInternalProcesses("Microsoft.DataApiBuilder 0.1.0\r\n"); + const binaryPath = path.join(DATA_API_BUILDER_FOLDER, "1.2.3", DEFAULT_DATA_API_BUILDER_BINARY.Linux); + vol.fromJSON({ [binaryPath]: "inert fixture" }); + const result = await installAndGetDataApiBuilder(); + expect(result.binaryPath).toBe(binaryPath); + }); +}); diff --git a/src/core/dataApiBuilder/dab.ts b/src/core/dataApiBuilder/dab.ts index 750bba6ca..481dd3118 100644 --- a/src/core/dataApiBuilder/dab.ts +++ b/src/core/dataApiBuilder/dab.ts @@ -7,8 +7,6 @@ import { DEFAULT_DATA_API_BUILDER_BINARY, } from "../constants.js"; import { fetchWithProxy as fetch } from "../../core/utils/fetch-proxy.js"; -import { promisify } from "node:util"; -import { exec } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import AdmZip from "adm-zip"; @@ -16,6 +14,7 @@ import path from "node:path"; import { logger } from "../utils/logger.js"; import { getPlatform } from "../utils/platform.js"; import { downloadAndValidateBinary } from "../download-binary-helper.js"; +import { ExecutableResolutionOptions, probeExecutable, resolveTrustedExecutable, resolveTrustedExecutablePath } from "../utils/executable.js"; /** * Gets the filepath where the Microsoft.DataApiBuilder.exe is located @@ -27,7 +26,7 @@ import { downloadAndValidateBinary } from "../download-binary-helper.js"; * * @returns binaryPath */ -export async function installAndGetDataApiBuilder(): Promise<{ binaryPath: string }> { +export async function installAndGetDataApiBuilder(options: ExecutableResolutionOptions = {}): Promise<{ binaryPath: string }> { const platform = getPlatform(); if (!platform) { throw new Error(`Unsupported platform: ${os.platform()}`); @@ -37,21 +36,12 @@ export async function installAndGetDataApiBuilder(): Promise<{ binaryPath: strin if (releaseMetadata === undefined) { throw new Error(`Could not load ${DATA_API_BUILDER_BINARY_NAME} metadata from remote. Please check your internet connection.`); // should we throw error and stop or can we allow users to use local version } - const isLatestVersionInstalled = await isLocalVersionInstalledAndLatest(releaseMetadata.versionId); - - if (!isLatestVersionInstalled) { - const binaryPath = await downloadAndUnzipBinary(releaseMetadata, platform); - - if (binaryPath != undefined) { - return { - binaryPath: binaryPath, - }; - } + const installed = await getInstalledDataApiBuilder(options); + if (installed?.version === releaseMetadata.versionId) { + return { binaryPath: installed.binaryPath }; } - return { - binaryPath: DATA_API_BUILDER_COMMAND, - }; + return { binaryPath: await downloadAndUnzipBinary(releaseMetadata, platform) }; } /** @@ -62,7 +52,7 @@ export async function installAndGetDataApiBuilder(): Promise<{ binaryPath: strin */ async function downloadAndUnzipBinary(releaseMetadata: DataApiBuilderReleaseMetadata, platform: "win-x64" | "osx-x64" | "linux-x64") { try { - const destDirectory = path.join(DATA_API_BUILDER_FOLDER, releaseMetadata.versionId); + const destDirectory = path.resolve(DATA_API_BUILDER_FOLDER, releaseMetadata.versionId); const binaryPath = path.join(destDirectory, getDefaultDataApiBuilderBinaryForOS(platform)); if (!fs.existsSync(binaryPath)) { @@ -83,10 +73,12 @@ async function downloadAndUnzipBinary(releaseMetadata: DataApiBuilderReleaseMeta fs.chmodSync(binaryPath, 0o755); } - return binaryPath; + const executable = resolveTrustedExecutablePath(binaryPath); + if (!executable) throw new Error(`Cannot find the downloaded ${DATA_API_BUILDER_BINARY_NAME} executable.`); + return executable; } catch (ex) { logger.error(`Unable to download/extract ${DATA_API_BUILDER_BINARY_NAME} binary. Exception ${ex}`); - return undefined; + throw ex; } } @@ -112,24 +104,6 @@ async function getReleaseDataApiBuilderMetadata(): Promise<{ releaseMetadata: Da } } -/** - * Returns if the version installed locally is latest or not - * @param releaseVersion current released Version of the Microsoft.DataApiBuilder.exe - * @param platform current OS - * @returns true if latest Version of data-api-builder is installed else false - */ -async function isLocalVersionInstalledAndLatest(releaseVersion: string): Promise { - const versionInstalled = await getInstalledVersion(DATA_API_BUILDER_COMMAND); - - if (versionInstalled) { - logger.silly(`Installed version: ${versionInstalled}`); - return versionInstalled == releaseVersion; - } - - logger.silly(`${DATA_API_BUILDER_COMMAND} is not installed.`); - return undefined; -} - /** * Unzips the given file to destDirectory * @param zipFilePath file to unzip @@ -163,17 +137,18 @@ export function getDefaultDataApiBuilderBinaryForOS(platform: string): string { /** * Returns installed version if installed else undefined - * @param command package to know the version + * @param options Working directory for configured PATH lookup and probing * @returns installed version */ -async function getInstalledVersion(command: "dab"): Promise { +async function getInstalledDataApiBuilder(options: ExecutableResolutionOptions): Promise<{ binaryPath: string; version: string } | undefined> { logger.silly(`Running ${DATA_API_BUILDER_COMMAND} --version`); + const binaryPath = resolveTrustedExecutable(DATA_API_BUILDER_COMMAND, options); + if (!binaryPath) return undefined; try { - const { stdout } = await promisify(exec)(`${command} --version`); + const { stdout } = await probeExecutable(binaryPath, ["--version"], options); const version = stdout.split(" ")[1].split("\r")[0]; // parsing output which looks like this "Microsoft.DataApiBuilder 0.5.0" (specific to dab) - - return version; + return { binaryPath, version }; } catch { return undefined; } diff --git a/src/core/dataApiBuilder/index.ts b/src/core/dataApiBuilder/index.ts index 1cfd910e6..048dc8844 100644 --- a/src/core/dataApiBuilder/index.ts +++ b/src/core/dataApiBuilder/index.ts @@ -1,13 +1,14 @@ import { DATA_API_BUILDER_BINARY_NAME } from "../constants.js"; import { logger } from "../utils/logger.js"; import { installAndGetDataApiBuilder } from "./dab.js"; +import { ExecutableResolutionOptions } from "../utils/executable.js"; /** * This function gets the Data-Api Builder binary path and returns it * @returns DataApiBuilderBinary path */ -export async function getDataApiBuilderBinaryPath(): Promise { - const binary = await installAndGetDataApiBuilder(); +export async function getDataApiBuilderBinaryPath(options: ExecutableResolutionOptions = {}): Promise { + const binary = await installAndGetDataApiBuilder(options); logger.silly(`${DATA_API_BUILDER_BINARY_NAME} found: ${binary.binaryPath}. Using this to start data-api server`); return binary.binaryPath; diff --git a/src/core/func-core-tools.spec.ts b/src/core/func-core-tools.spec.ts index 49c70a68c..9c330dfb1 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -1,10 +1,12 @@ import "../../tests/_mocks/fetch.js"; import "../../tests/_mocks/fs.js"; +import "../../tests/_mocks/posix.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; -import { vol } from "memfs"; -import { sep } from "node:path"; +import { vol, fs } from "memfs"; import { PassThrough } from "node:stream"; import os from "node:os"; +import process from "node:process"; import * as fct from "./func-core-tools.js"; import * as nodeFetch from "node-fetch"; import { Response } from "node-fetch"; @@ -46,6 +48,71 @@ function mockBinaryResponse(response: string, status = 200) { fetch.mockResolvedValueOnce(packageResponse); } +describe("internal executable identity (no fixture execution)", () => { + let getCoreToolsBinary: typeof fct.getCoreToolsBinary; + let resetCachedInstalledSystemCoreToolsVersion: typeof fct.resetCachedInstalledSystemCoreToolsVersion; + + beforeEach(async () => { + // Keep mocked proxy/download dependencies separate from the original fetch tests. + vi.resetModules(); + vi.doMock("./utils/fetch-proxy.js", () => ({ fetchWithProxy: vi.fn() })); + vi.doMock("./download-binary-helper.js", () => ({ downloadAndValidateBinary: vi.fn() })); + vi.doMock("./utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn(), info: vi.fn() }, + })); + ({ getCoreToolsBinary, resetCachedInstalledSystemCoreToolsVersion } = await import("./func-core-tools.js")); + vol.reset(); + resetCachedInstalledSystemCoreToolsVersion(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + vi.spyOn(process, "cwd").mockReturnValue("/workspace"); + vi.stubEnv("PATH", "/trusted/bin"); + vol.fromJSON({ + "/workspace/func": "inert fixture", + "/workspace/dab": "inert fixture", + "/workspace/relative-tools/func": "inert fixture", + "/workspace/relative-tools/dab": "inert fixture", + "/trusted/bin/func": "inert fixture", + "/trusted/bin/dab": "inert fixture", + }); + for (const file of Object.keys(vol.toJSON())) fs.chmodSync(file, 0o755); + const { fetchWithProxy } = await import("./utils/fetch-proxy.js"); + vi.mocked(fetchWithProxy).mockResolvedValue({ + json: async () => [{ version: "latest", versionId: "1.2.3" }], + } as any); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + resetCachedInstalledSystemCoreToolsVersion(); + }); + + it("probes installed Functions with an absolute executable and argv, not a shell", async () => { + mockInternalProcesses("4.0.0"); + await getCoreToolsBinary(); + expect(childProcess.exec.mock.calls).toEqual([]); + expect(directLaunches()).toContainEqual(expect.objectContaining({ file: "/trusted/bin/func", args: ["--version"] })); + }); + + it("returns the same trusted Functions identity selected for probing", async () => { + vi.stubEnv("PATH", "/workspace/relative-tools:/trusted/bin"); + mockInternalProcesses("4.0.0"); + const binary = await getCoreToolsBinary(); + expect(binary).toBe("/workspace/relative-tools/func"); + expect(directLaunches()).toContainEqual(expect.objectContaining({ file: binary, args: ["--version"] })); + }); + + it("preserves absolute cached Functions downloads when no compatible installed version exists", async () => { + mockInternalProcesses("2.0.0"); + vol.fromJSON({ + "/home/tester/.swa/core-tools/v4/.release-version": "4.3.2", + "/home/tester/.swa/core-tools/v4/func": "inert fixture", + }); + const binary = await getCoreToolsBinary(); + expect(binary).toBe("/home/tester/.swa/core-tools/v4/func"); + }); +}); + describe("funcCoreTools", () => { beforeEach(() => { vol.reset(); @@ -198,45 +265,48 @@ describe("funcCoreTools", () => { afterEach(() => { fct.resetCachedInstalledSystemCoreToolsVersion(); + vi.unstubAllEnvs(); }); it("should return the system binary if it's compatible", async () => { - fct.setCachedInstalledSystemCoreToolsVersion(4); + vol.fromJSON({ "/trusted/bin/func": "inert native fixture; never executed" }); + fs.chmodSync("/trusted/bin/func", 0o755); + vi.stubEnv("PATH", "/trusted/bin"); + mockInternalProcesses("4.0.0"); const binary = await fct.getCoreToolsBinary(); - expect(binary).toBe("func"); + expect(binary).toBe("/trusted/bin/func"); + expect(childProcess.execFile).toHaveBeenCalledWith( + "/trusted/bin/func", + ["--version"], + { encoding: "utf8", shell: false, env: { ...process.env } }, + expect.any(Function), + ); + expect(childProcess.exec).not.toHaveBeenCalled(); }); it("should return the downloaded binary if there's no system binary", async () => { fct.setCachedInstalledSystemCoreToolsVersion(undefined); vol.fromNestedJSON({ - ["/home/user/.swa/core-tools/v4"]: { ".release-version": "4.3.2" }, + ["/home/user/.swa/core-tools/v4"]: { ".release-version": "4.3.2", func: "inert cached executable" }, }); + fs.chmodSync("/home/user/.swa/core-tools/v4/func", 0o755); const binary = await fct.getCoreToolsBinary(); - // note: we have mocked os.platform(), so we can't check for os name! - if (sep === "/") { - expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); - } else { - expect(binary).toContain(":\\home\\user\\.swa\\core-tools\\v4\\func"); - } + expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); }); it("should return the downloaded binary if the system binary is incompatible", async () => { fct.setCachedInstalledSystemCoreToolsVersion(2); vol.fromNestedJSON({ - ["/home/user/.swa/core-tools/v4"]: { ".release-version": "4.3.2" }, + ["/home/user/.swa/core-tools/v4"]: { ".release-version": "4.3.2", func: "inert cached executable" }, }); + fs.chmodSync("/home/user/.swa/core-tools/v4/func", 0o755); const binary = await fct.getCoreToolsBinary(); - // note: we have mocked os.platform(), so we can't check for os name! - if (sep === "/") { - expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); - } else { - expect(binary).toContain(":\\home\\user\\.swa\\core-tools\\v4\\func"); - } + expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); }); // SKIPPED: Does not work because we cannot mock adm-zip right now @@ -271,12 +341,7 @@ describe("funcCoreTools", () => { ); const binary = await fct.getCoreToolsBinary(); - // note: we have mocked os.platform(), so we can't check for os name! - if (sep === "/") { - expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); - } else { - expect(binary).toContain(":\\home\\user\\.swa\\core-tools\\v4\\func"); - } + expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); }); it("should return undefined if an error occured", async () => { diff --git a/src/core/func-core-tools.ts b/src/core/func-core-tools.ts index a94553c69..be784f4e6 100644 --- a/src/core/func-core-tools.ts +++ b/src/core/func-core-tools.ts @@ -2,14 +2,13 @@ import os from "node:os"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -import { exec } from "node:child_process"; -import { promisify } from "node:util"; import { PassThrough } from "node:stream"; import crypto from "node:crypto"; import { fetchWithProxy as fetch } from "./utils/fetch-proxy.js"; import AdmZip from "adm-zip"; import cliProgress from "cli-progress"; import { logger } from "./utils/logger.js"; +import { ExecutableResolutionOptions, probeExecutable, resolveTrustedExecutable, resolveTrustedExecutablePath } from "./utils/executable.js"; const RELEASES_FEED_URL = "https://functionscdn.azureedge.net/public/cli-feed-v4.json"; const DEFAULT_FUNC_BINARY = "func"; @@ -35,7 +34,7 @@ function getCoreToolsFolder(version: number): string { function getCoreToolBinaryPath(version: number): string { const folder = getCoreToolsFolder(version); - return path.resolve(path.join(folder, "func")); + return path.resolve(path.join(folder, os.platform() === "win32" ? "func.exe" : "func")); } export function isCoreToolsVersionCompatible(coreToolsVersion: number, nodeVersion: number): boolean { @@ -77,14 +76,20 @@ export function resetCachedInstalledSystemCoreToolsVersion(): void { installedSystemCoreToolsVersionIsCached = false; } -async function getInstalledSystemCoreToolsVersion(): Promise { - if (installedSystemCoreToolsVersionIsCached) { - return cachedSystemCoreToolsVersion; +async function getInstalledSystemCoreTools(options: ExecutableResolutionOptions): Promise<{ binaryPath: string; version: number } | undefined> { + if (installedSystemCoreToolsVersionIsCached && cachedSystemCoreToolsVersion === undefined) { + return undefined; } + const binaryPath = resolveTrustedExecutable(DEFAULT_FUNC_BINARY, options); + if (!binaryPath) return undefined; + + if (installedSystemCoreToolsVersionIsCached && cachedSystemCoreToolsVersion !== undefined) { + return { binaryPath, version: cachedSystemCoreToolsVersion }; + } try { - const { stdout: version } = await promisify(exec)(`${DEFAULT_FUNC_BINARY} --version`); - return getMajorVersion(version); + const { stdout: version } = await probeExecutable(binaryPath, ["--version"], options); + return { binaryPath, version: getMajorVersion(version) }; } catch { return undefined; } @@ -262,19 +267,21 @@ export async function downloadCoreTools(version: number): Promise { return release.version; } -export async function getCoreToolsBinary(): Promise { +export async function getCoreToolsBinary(options: ExecutableResolutionOptions = {}): Promise { const nodeVersion = getNodeMajorVersion(); - const systemVersion = await getInstalledSystemCoreToolsVersion(); + const installed = await getInstalledSystemCoreTools(options); + const systemVersion = installed?.version; - if (systemVersion && isCoreToolsVersionCompatible(systemVersion, nodeVersion)) { - return DEFAULT_FUNC_BINARY; + if (installed && isCoreToolsVersionCompatible(installed.version, nodeVersion)) { + return installed.binaryPath; } const targetVersion = detectTargetCoreToolsVersion(nodeVersion); + const managedBinary = getCoreToolBinaryPath(targetVersion); const downloadedVersion = getDownloadedCoreToolsVersion(targetVersion); if (downloadedVersion) { // Should we check for newer versions here? - return getCoreToolBinaryPath(targetVersion); + return resolveTrustedExecutablePath(managedBinary); } if (systemVersion && systemVersion !== targetVersion) { @@ -289,7 +296,7 @@ export async function getCoreToolsBinary(): Promise { try { await downloadCoreTools(targetVersion); logger.log(`\nDownloaded Function Core Tools successfully`); - return getCoreToolBinaryPath(targetVersion); + return resolveTrustedExecutablePath(managedBinary); } catch (error: unknown) { logger.error(`Failed to download Functions Core Tools v${targetVersion}.`); logger.error(error as Error); diff --git a/src/core/ssl.spec.ts b/src/core/ssl.spec.ts new file mode 100644 index 000000000..1f6dff55f --- /dev/null +++ b/src/core/ssl.spec.ts @@ -0,0 +1,290 @@ +import childProcess, { ChildProcess } from "node:child_process"; +import { X509Certificate, createPrivateKey, generateKeyPairSync } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { PassThrough } from "node:stream"; +import type { PEMOptions } from "./ssl.js"; + +const moduleLoader = vi.hoisted(() => ({ load: vi.fn() })); +vi.mock("node:module", () => ({ createRequire: () => moduleLoader.load })); +vi.mock("./utils/logger.js", () => ({ logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() } })); + +interface Certificate { + csr: string; + serviceKey: string; + certificate: string; +} +type Callback = (error: Error | null, certificate: Certificate) => void; + +describe("SSL trusted bootstrap and baseline PEM delegation", () => { + let require: NodeRequire; + let material: Certificate; + let sandbox: string; + let project: string; + let cache: string; + let executable: string; + let generator: { config: ReturnType; createCertificate: ReturnType }; + const envKeys = ["OPENSSL_BIN", "LIBRARY", "VERSION", "CI"] as const; + const snapshot = () => envKeys.map((key) => [key, process.env[key] ?? null]); + + beforeAll(async () => { + require = (await vi.importActual("node:module")).createRequire(import.meta.url); + const forge = require("node-forge"); + const keys = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + const certificate = forge.pki.createCertificate(); + certificate.publicKey = forge.pki.publicKeyFromPem(keys.publicKey); + certificate.serialNumber = "01"; + certificate.validity.notBefore = new Date(); + certificate.validity.notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + certificate.setSubject([ + { name: "commonName", value: "localhost" }, + { name: "organizationName", value: "SWA regression" }, + { name: "organizationalUnitName", value: "Engineering" }, + ]); + certificate.setIssuer(certificate.subject.attributes); + certificate.setExtensions([ + { + name: "subjectAltName", + altNames: [ + { type: 2, value: "localhost" }, + { type: 7, ip: "127.0.0.1" }, + ], + }, + ]); + certificate.sign(forge.pki.privateKeyFromPem(keys.privateKey), forge.md.sha256.create()); + material = { csr: "inert CSR fixture", serviceKey: keys.privateKey, certificate: forge.pki.certificateToPem(certificate) }; + }); + + beforeEach(() => { + vi.resetModules(); + delete require.cache[require.resolve("pem")]; + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "swa-tls-contract-")); + project = path.join(sandbox, "project"); + const tools = path.join(sandbox, "external tools"); + fs.mkdirSync(project); + fs.mkdirSync(tools); + const projectTools = path.join(project, "bin"); + fs.mkdirSync(projectTools); + const filename = process.platform === "win32" ? "openssl.exe" : "openssl"; + executable = path.join(tools, filename); + fs.writeFileSync(executable, "inert native identity fixture", { mode: 0o755 }); + fs.writeFileSync(path.join(projectTools, filename), "inert project canary", { mode: 0o755 }); + executable = fs.realpathSync(executable); + cache = path.join(sandbox, "home", ".swa", "certificates", "ssl", "swa-cli-UNSIGNED.pem"); + vi.spyOn(os, "homedir").mockReturnValue(path.join(sandbox, "home")); + vi.spyOn(process, "cwd").mockReturnValue(project); + vi.stubEnv("PATH", tools); + vi.stubEnv("PEMJS_TMPDIR", sandbox); + for (const key of envKeys) { + vi.stubEnv(key, ""); + delete process.env[key]; + } + generator = { config: vi.fn(), createCertificate: vi.fn((_options: PEMOptions, callback: Callback) => callback(null, material)) }; + moduleLoader.load.mockReturnValue(generator); + for (const api of ["spawn", "spawnSync", "exec", "execFile", "execSync", "execFileSync", "fork"] as const) { + vi.spyOn(childProcess, api).mockImplementation(() => { + throw new Error(`Unexpected unbound ${api} in TLS test`); + }); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + delete require.cache[require.resolve("pem")]; + fs.rmSync(sandbox, { recursive: true }); + }); + + it("does not load pem or launch a subprocess on SSL import", async () => { + await import("./ssl.js"); + expect(moduleLoader.load).not.toHaveBeenCalled(); + }); + + it("returns original fresh-cache contents without requiring OpenSSL or loading pem", async () => { + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.writeFileSync(cache, "cached certificate fixture"); + vi.stubEnv("PATH", project); + const ssl = await import("./ssl.js"); + expect(await ssl.getCertificate({ selfSigned: true })).toBe("cached certificate fixture"); + expect(moduleLoader.load).not.toHaveBeenCalled(); + expect(fs.readFileSync(cache, "utf8")).toBe("cached certificate fixture"); + }); + + it("delegates TLS metadata and caches the generator's matching SHA256/2048-bit key and certificate unchanged", async () => { + const options = { + selfSigned: true, + days: 365, + commonName: "localhost", + organization: "SWA regression", + organizationUnit: "Engineering", + altNames: ["localhost", "127.0.0.1"], + }; + const ssl = await import("./ssl.js"); + expect(await ssl.getCertificate(options)).toBe(cache); + expect(generator.createCertificate).toHaveBeenCalledWith(options, expect.any(Function)); + const content = fs.readFileSync(cache, "utf8"); + const certificate = new X509Certificate(material.certificate); + expect(content === [material.csr, material.serviceKey, material.certificate].join("\n")).toBe(true); + expect(createPrivateKey(content).asymmetricKeyDetails?.modulusLength).toBeGreaterThanOrEqual(2048); + expect(require("node-forge").pki.certificateFromPem(material.certificate).signatureOid).toBe( + require("node-forge").pki.oids.sha256WithRSAEncryption, + ); + expect(certificate.subject).toContain("CN=localhost"); + expect(certificate.subject).toContain("O=SWA regression"); + expect(certificate.subject).toContain("OU=Engineering"); + expect(certificate.subjectAltName).toContain("DNS:localhost"); + expect(certificate.subjectAltName).toContain("IP Address:127.0.0.1"); + expect(certificate.checkPrivateKey(createPrivateKey(content))).toBe(true); + expect(Date.parse(certificate.validTo) - Date.parse(certificate.validFrom)).toBeGreaterThanOrEqual(364 * 24 * 60 * 60 * 1000); + }); + + it("forwards supplied key/CSR, hash, config, IPv6 SAN and requested size/validity rather than imposing new restrictions", async () => { + const options = { + serviceKey: material.serviceKey, + csr: "caller CSR", + hash: "sha512", + config: "caller extension config", + keyBitsize: 1024, + days: 2, + altNames: ["::1", "127.0.0.1"], + }; + const ssl = await import("./ssl.js"); + await ssl.getCertificate(options); + expect(generator.createCertificate.mock.calls[0]?.[0] === options).toBe(true); + }); + + it("regenerates an age-expired cache through the bound generator", async () => { + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.writeFileSync(cache, "old inert cache"); + const stat = fs.promises.stat.bind(fs.promises); + vi.spyOn(fs.promises, "stat").mockImplementation(async (...args) => { + const result = await stat(...args); + result.ctime = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + return result; + }); + const ssl = await import("./ssl.js"); + expect(await ssl.getCertificate({ days: 1 })).toBe(cache); + expect(generator.createCertificate).toHaveBeenCalledOnce(); + expect(fs.readFileSync(cache, "utf8") === [material.csr, material.serviceKey, material.certificate].join("\n")).toBe(true); + }); + + it.each([false, true])("restores present/absent bootstrap env after successful load (present=%s)", async (present) => { + if (present) { + process.env.OPENSSL_BIN = "openssl"; + process.env.LIBRARY = "fixture"; + } + const before = snapshot(); + moduleLoader.load.mockImplementation(() => { + expect(process.env.OPENSSL_BIN).toBe(executable); + expect(process.env.LIBRARY).toBeUndefined(); + return generator; + }); + const ssl = await import("./ssl.js"); + await ssl.getCertificate({ selfSigned: true }); + expect(snapshot()).toEqual(before); + expect(generator.config).toHaveBeenCalledWith({ pathOpenSSL: executable }); + }); + + it.each([false, true])("restores present/absent bootstrap env after a synchronous load failure (present=%s)", async (present) => { + if (present) { + process.env.OPENSSL_BIN = "openssl"; + process.env.LIBRARY = "fixture"; + } + const before = snapshot(); + const failure = new Error("inert pem load failure"); + moduleLoader.load.mockImplementation(() => { + expect(process.env.OPENSSL_BIN).toBe(executable); + expect(process.env.LIBRARY).toBeUndefined(); + throw failure; + }); + const ssl = await import("./ssl.js"); + await expect(ssl.getCertificate({ selfSigned: true })).rejects.toBe(failure); + expect(snapshot()).toEqual(before); + expect(fs.existsSync(cache)).toBe(false); + }); + + it("binds the actual pem eager probe and later dependency dispatch despite CI override and restored/mutated env", async () => { + process.env.OPENSSL_BIN = "openssl"; + process.env.CI = "true"; + process.env.LIBRARY = "fixture"; + process.env.VERSION = "1"; + const relative = path.join(project, "openssl", "fixture_v1", "bin", "openssl"); + fs.mkdirSync(path.dirname(relative), { recursive: true }); + fs.writeFileSync(relative, "inert CI canary"); + const exists = fs.existsSync; + vi.spyOn(fs, "existsSync").mockImplementation((file) => + String(file).replace(/\\/g, "/") === "./openssl/fixture_v1/bin/openssl" ? exists(relative) : exists(file), + ); + const before = snapshot(); + const launches: { api: string; executable: string }[] = []; + let guarded = false; + vi.spyOn(childProcess, "spawnSync").mockImplementation((file) => { + launches.push({ api: "eager", executable: file }); + if (guarded) expect(process.env.LIBRARY).toBeUndefined(); + return { pid: 0, status: 0, signal: null, output: [], stdout: Buffer.from("OpenSSL 3.0.0"), stderr: Buffer.alloc(0) }; + }); + vi.spyOn(childProcess, "spawn").mockImplementation((file) => { + launches.push({ api: "later", executable: file }); + const child = new ChildProcess(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + queueMicrotask(() => { + child.stdout!.emit("data", Buffer.from("Modulus=CAFEBABE\n")); + child.emit("exit", 0); + child.emit("close", 0); + }); + return child; + }); + require("pem"); + expect(launches).toEqual([{ api: "eager", executable: "./openssl/fixture_v1/bin/openssl" }]); + delete require.cache[require.resolve("pem")]; + launches.length = 0; + process.env.OPENSSL_BIN = "openssl"; + guarded = true; + moduleLoader.load.mockImplementation((name: string) => { + const actualPem: { + createCertificate(options: PEMOptions, callback: Callback): void; + getModulus(key: string, callback: (error: Error | null, result: { modulus: string }) => void): void; + } = require(name); + vi.spyOn(actualPem, "createCertificate").mockImplementation((_options, callback) => { + expect(snapshot()).toEqual(before); + process.env.OPENSSL_BIN = relative; + actualPem.getModulus("inert test key", (error, result) => { + if (!error) expect(result.modulus).toBe("CAFEBABE"); + callback(error, material); + }); + }); + return actualPem; + }); + const ssl = await import("./ssl.js"); + await ssl.getCertificate({ selfSigned: true }); + expect(launches).toEqual([ + { api: "eager", executable }, + { api: "later", executable }, + ]); + }); + + it("surfaces the generator callback error without caching success-shaped material", async () => { + const error = new Error("inert OpenSSL generation failure"); + generator.createCertificate.mockImplementation((_options: PEMOptions, callback: Callback) => callback(error, material)); + const ssl = await import("./ssl.js"); + await expect(ssl.getCertificate({ selfSigned: true })).rejects.toBe(error); + expect(fs.existsSync(cache)).toBe(false); + }); + + it("honors an explicit project OpenSSL override and pins that configured identity", async () => { + // Explicit configuration is distinct from an extra implicit CWD search. + const configured = fs.realpathSync(path.join(project, "bin", process.platform === "win32" ? "openssl.exe" : "openssl")); + process.env.OPENSSL_BIN = configured; + const before = snapshot(); + const ssl = await import("./ssl.js"); + await ssl.getCertificate({ selfSigned: true }); + expect(generator.config).toHaveBeenCalledWith({ pathOpenSSL: configured }); + expect(snapshot()).toEqual(before); + }); +}); diff --git a/src/core/ssl.ts b/src/core/ssl.ts index 4290f4480..e2c234511 100644 --- a/src/core/ssl.ts +++ b/src/core/ssl.ts @@ -1,7 +1,9 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import pem from "pem"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { resolveTrustedExecutable, resolveTrustedExecutablePath } from "./utils/executable.js"; import { logger } from "./utils/logger.js"; const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; @@ -83,6 +85,40 @@ interface SSLCertificate extends Pick void): void; +} + +let pem: Pem | undefined; + +function certificateGenerator(): Pem { + if (pem) return pem; + const configured = process.env.OPENSSL_BIN; + const executable = + configured && /[\\/]/.test(configured) + ? resolveTrustedExecutablePath(path.resolve(configured)) + : resolveTrustedExecutable(configured || "openssl"); + if (!executable) { + throw new Error("Cannot find an OpenSSL executable. Check your OpenSSL installation."); + } + const previous = { OPENSSL_BIN: process.env.OPENSSL_BIN, LIBRARY: process.env.LIBRARY }; + try { + process.env.OPENSSL_BIN = executable; + // pem's CI fixture hook otherwise overrides OPENSSL_BIN with a project-relative path before probing. + delete process.env.LIBRARY; + const loaded: Pem = createRequire(import.meta.url)("pem"); + loaded.config({ pathOpenSSL: executable }); + pem = loaded; + return loaded; + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + /** * Generate a new SSL certificate. This will create a new key and certificate and cache them in the * `~/.swa/certificates/ssl` directory, for one month. @@ -123,8 +159,9 @@ export async function getCertificate(options: PEMOptions): Promise { } async function createCertificate(options: PEMOptions): Promise { + const generator = certificateGenerator(); return new Promise((resolve, reject) => { - pem.createCertificate(options, (err: Error, keys: SSLCertificate) => { + generator.createCertificate(options, (err, keys) => { if (err) { reject(err); } else { diff --git a/src/core/utils/cli.spec.ts b/src/core/utils/cli.spec.ts index 9cd65e421..ca3e6ef52 100644 --- a/src/core/utils/cli.spec.ts +++ b/src/core/utils/cli.spec.ts @@ -110,6 +110,13 @@ describe("createStartupScriptCommand()", () => { ]); }); + it.each([String.raw`\\fileserver\devcerts\local.pfx`, String.raw`\\fileserver\dev certs\local.pfx`])( + "should preserve both leading backslashes in quoted UNC path %s", + (file) => { + expect(parseCommandLineArgs(`--cert "${file}"`)).toStrictEqual(["--cert", file]); + }, + ); + it("should keep option terminator as a literal token", () => { expect(parseCommandLineArgs(`-- --literal --not-option`)).toStrictEqual(["--", "--literal", "--not-option"]); }); diff --git a/src/core/utils/cli.ts b/src/core/utils/cli.ts index 5ec0230b3..37acb26e5 100644 --- a/src/core/utils/cli.ts +++ b/src/core/utils/cli.ts @@ -160,7 +160,7 @@ export function parseCommandLineArgs(input?: string): string[] { tokenStarted = true; } else if (quote === '"') { const nextChar = input[index + 1]; - if (nextChar === '"' || nextChar === "\\") { + if (nextChar === '"') { current += nextChar; tokenStarted = true; index++; diff --git a/src/core/utils/command.spec.ts b/src/core/utils/command.spec.ts new file mode 100644 index 000000000..6a639081e --- /dev/null +++ b/src/core/utils/command.spec.ts @@ -0,0 +1,145 @@ +import "../../../tests/_mocks/fs.js"; +import "../../../tests/_mocks/posix.js"; +import { childProcess, mockInternalProcesses } from "../../../tests/_mocks/internal-process.js"; +import { vol, fs } from "memfs"; +import { execFileCommand, runCommand } from "./command.js"; +import * as fixtureFs from "node:fs"; +import * as fixtureOs from "node:os"; +import * as fixturePath from "node:path"; +import * as fixtureProcess from "node:process"; +import simulatedProcess from "node:process"; +import * as fixtureUrl from "node:url"; +import * as fixtureChildProcess from "node:child_process"; + +// Capture both module sets before collection. Native cases must not inherit the +// hoisted memfs, POSIX namespace or child-process interceptors used by unit cases. +const nativeModules = { + "node:fs": await vi.importActual("node:fs"), + "node:os": await vi.importActual("node:os"), + "node:path": await vi.importActual("node:path"), + "node:process": await vi.importActual("node:process"), + "node:url": await vi.importActual("node:url"), + "node:child_process": await vi.importActual("node:child_process"), +}; +const fixtureModules = { + "node:fs": fixtureFs, + "node:os": fixtureOs, + "node:path": fixturePath, + "node:process": fixtureProcess, + "node:url": fixtureUrl, + "node:child_process": fixtureChildProcess, +}; + +describe("command execution trust boundary", () => { + beforeEach(() => { + vol.reset(); + vol.fromJSON({ "/bin/sh": "inert system-shell fixture; never executed" }); + fs.chmodSync("/bin/sh", 0o755); + mockInternalProcesses("ok"); + }); + + it.each(["dab", "tools/dab"])("rejects non-absolute internal executable %s before execution", (binary) => { + expect(() => execFileCommand(binary, "/workspace", ["init"])).toThrow(); + expect(childProcess.execFileSync).not.toHaveBeenCalled(); + }); + + it("preserves an absolute executable, explicit cwd and literal argv including spaces", () => { + const args = ["init", "--config", "config with spaces.json", "--value", "literal&value"]; + execFileCommand("/trusted tools/dab", "/project with spaces", args); + expect(childProcess.execFileSync).toHaveBeenCalledWith( + "/trusted tools/dab", + args, + expect.objectContaining({ cwd: "/project with spaces", stdio: "inherit", env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }) }), + ); + expect(childProcess.execSync).not.toHaveBeenCalled(); + }); + + it("preserves intentional customer shell commands rather than treating them as internal tools", () => { + const command = "npm run build && npm run preview"; + runCommand(command, "/customer project"); + expect(childProcess.execSync).toHaveBeenCalledWith( + command, + expect.objectContaining({ cwd: "/customer project", shell: "/bin/sh", env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }) }), + ); + }); +}); + +describe("real internal argument boundary", () => { + const fs = nativeModules["node:fs"].default; + const os = nativeModules["node:os"].default; + const path = nativeModules["node:path"].default; + const process = nativeModules["node:process"].default; + const { fileURLToPath } = nativeModules["node:url"]; + let execInternalCommand: typeof import("./command.js").execInternalCommand; + let createToolShellExecutionSpec: typeof import("./executable.js").createToolShellExecutionSpec; + let prepareExecution: typeof import("./executable.js").prepareExecution; + let sandbox: string; + let report: string; + const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); + + beforeEach(async () => { + vi.resetModules(); + for (const [name, module] of Object.entries(nativeModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => nativeModules["node:fs"]); + vi.doMock("child_process", () => nativeModules["node:child_process"]); + vi.stubGlobal("process", process); + ({ execInternalCommand } = await import("./command.js")); + ({ createToolShellExecutionSpec, prepareExecution } = await import("./executable.js")); + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "swa argv-boundary-")); + report = path.join(sandbox, "observed argv.json"); + fs.writeFileSync(report, ""); + vi.stubEnv("SWA_COMPAT_REPORT", report); + vi.stubEnv("SWA_COMPAT_EXIT", "0"); + vi.stubEnv("SWA_COMPAT_VALUE", "expanded two words"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(sandbox, { recursive: true }); + for (const [name, module] of Object.entries(fixtureModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => fixtureFs); + vi.doMock("child_process", () => fixtureChildProcess); + vi.stubGlobal("process", simulatedProcess); + }); + + it.each(["native", "bound shell"])("keeps fixed UNC, space, quote and metacharacter argv literal through %s execution", (mode) => { + const args = [ + "--cert", + String.raw`\\fileserver\devcerts\local.pfx`, + "--spaced-cert", + String.raw`\\fileserver\dev certs\local.pfx`, + "--drive", + String.raw`C:\dev certs\local.pfx`, + "two words", + 'say "hello"', + "single 'quoted'", + "", + "trailing\\", + "$SWA_COMPAT_VALUE", + "%SWA_COMPAT_VALUE%", + "*.json", + ";", + "&&", + ">", + ]; + const execution = + mode === "native" + ? prepareExecution({ executable: process.execPath, args: [recorder, ...args], cwd: sandbox }) + : createToolShellExecutionSpec(process.execPath, [recorder, ...args], sandbox); + + execInternalCommand(execution); + + const record: { execPath: string; args: string[]; cwd: string } = JSON.parse(fs.readFileSync(report, "utf8")); + expect({ execPath: record.execPath, args: record.args, cwd: record.cwd }).toEqual({ + execPath: process.execPath, + args, + cwd: fs.realpathSync(sandbox), + }); + }); + + it("rejects a null byte at the actual native execution boundary before the recorder runs", () => { + const execution = prepareExecution({ executable: process.execPath, args: [recorder, "before\0after"], cwd: sandbox }); + expect(() => execInternalCommand(execution)).toThrow(/null bytes/); + expect(fs.readFileSync(report, "utf8")).toBe(""); + }); +}); diff --git a/src/core/utils/command.ts b/src/core/utils/command.ts index 6ab4bbd05..a26314e05 100644 --- a/src/core/utils/command.ts +++ b/src/core/utils/command.ts @@ -1,13 +1,17 @@ import process from "process"; -import { execFileSync, execSync } from "child_process"; +import { execFileSync, execSync, SpawnSyncOptions } from "child_process"; +import { getTrustedShell, InternalExecutionSpec, prepareExecution } from "./executable.js"; export function runCommand(command: string, cwd?: string) { + const shell = getTrustedShell(); + const { env } = prepareExecution({ executable: shell, args: [] }); execSync(command, { stdio: "inherit", cwd: cwd ?? process.cwd(), + shell, // Set CI to avoid extra NPM logs and potentially unwanted interactive modes env: { - ...process.env, + ...env, // Internal flag to avoid duplicating user messages SWA_CLI_INTERNAL_COMMAND: "1", }, @@ -15,15 +19,16 @@ export function runCommand(command: string, cwd?: string) { } export function execFileCommand(command: string, cwd?: string, args?: string[]) { - const child = execFileSync(command, args, { + return execInternalCommand({ executable: command, cwd, args: args ?? [] }); +} + +export function execInternalCommand(execution: InternalExecutionSpec) { + const spec = prepareExecution(execution); + const options: SpawnSyncOptions = { + cwd: spec.cwd ?? process.cwd(), stdio: "inherit", - cwd: cwd ?? process.cwd(), - // Set CI to avoid extra NPM logs and potentially unwanted interactive modes - env: { - ...process.env, - // Internal flag to avoid duplicating user messages - SWA_CLI_INTERNAL_COMMAND: "1", - }, - }); - return child; + env: { ...(spec.env ?? process.env), SWA_CLI_INTERNAL_COMMAND: "1" }, + windowsVerbatimArguments: spec.windowsVerbatimArguments, + }; + return execFileSync(spec.executable, spec.args, options); } diff --git a/src/core/utils/executable.spec.ts b/src/core/utils/executable.spec.ts new file mode 100644 index 000000000..3d99ddb6f --- /dev/null +++ b/src/core/utils/executable.spec.ts @@ -0,0 +1,891 @@ +import "../../../tests/_mocks/fs.js"; +import { posix as simulated } from "../../../tests/_mocks/posix.js"; +import { fs, vol } from "memfs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { + assertAbsoluteExecutable, + createShellExecutionSpec, + prepareExecution, + resolveTrustedExecutablePath, + resolveTrustedExecutable, + resolveTrustedPackageManager, + ExecutableResolutionOptions, + InternalExecutionSpec, +} from "./executable.js"; +import * as nodeFs from "node:fs"; +import * as fixtureOs from "node:os"; +import * as fixturePath from "node:path"; +import * as fixtureProcess from "node:process"; +import * as fixtureUrl from "node:url"; +import * as fixtureChildProcess from "node:child_process"; + +const nativeModules = { + "node:fs": await vi.importActual("node:fs"), + "node:os": await vi.importActual("node:os"), + "node:path": await vi.importActual("node:path"), + "node:process": await vi.importActual("node:process"), + "node:url": await vi.importActual("node:url"), + "node:child_process": await vi.importActual("node:child_process"), +}; +const fixtureModules = { + "node:fs": nodeFs, + "node:os": fixtureOs, + "node:path": fixturePath, + "node:process": fixtureProcess, + "node:url": fixtureUrl, + "node:child_process": fixtureChildProcess, +}; +const simulatedProcess = process; + +describe("trusted executable boundary (inert files only)", () => { + beforeEach(() => { + vol.reset(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(process, "cwd").mockReturnValue("/project"); + vol.fromJSON({ + "/project/func": "inert", + "/project/tools/func": "inert", + "/trusted/bin/func": "inert", + }); + for (const file of Object.keys(vol.toJSON())) fs.chmodSync(file, 0o755); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("honors configured POSIX PATH order without adding off-PATH CWD", () => { + // Arrange + vi.stubEnv("PATH", ":.:tools:/project:/project/tools:/trusted/bin"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/project/func"); + vi.stubEnv("PATH", "/trusted/bin"); + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + }); + + it("binds an explicit project PATH alias canonically but ignores it when off PATH", () => { + // Arrange + fs.symlinkSync("/project/tools", "/alias"); + vi.stubEnv("PATH", "/alias"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/project/tools/func"); + vi.stubEnv("PATH", "/trusted/bin"); + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + }); + + it("allows explicitly configured API PATH tools through their canonical alias", () => { + // Arrange + vol.fromJSON({ "/api-root/bin/func": "inert" }); + fs.chmodSync("/api-root/bin/func", 0o755); + fs.symlinkSync("/api-root", "/api-alias"); + vi.stubEnv("PATH", "/api-root/bin"); + // Act / Assert + const options = { cwd: "/api-alias" }; + expect(resolveTrustedExecutable("func", options)).toBe("/api-root/bin/func"); + expect(resolveTrustedExecutablePath("/api-alias/bin/func")).toBe("/api-root/bin/func"); + vi.stubEnv("PATH", "/trusted/bin"); + expect(resolveTrustedExecutable("func", options)).toBe("/trusted/bin/func"); + }); + + it("does not confuse a project-prefix sibling with a descendant", () => { + // Arrange + vol.fromJSON({ "/project-tools/func": "inert" }); + fs.chmodSync("/project-tools/func", 0o755); + vi.stubEnv("PATH", "/project-tools"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/project-tools/func"); + }); + + it("does not select a missing tool beneath an explicitly configured project alias", () => { + // Arrange + fs.symlinkSync("/project", "/cache-alias"); + // Act / Assert + vi.stubEnv("PATH", "/cache-alias/not-downloaded:/trusted/bin"); + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + expect(resolveTrustedExecutablePath("/cache-alias/not-downloaded/func")).toBeUndefined(); + }); + + it("keeps a future configured directory eligible once its executable exists", () => { + // Arrange + fs.symlinkSync("/trusted", "/cache-alias"); + // Act / Assert + vi.stubEnv("PATH", "/cache-alias/new/version"); + expect(resolveTrustedExecutable("func")).toBeUndefined(); + vol.fromJSON({ "/trusted/new/version/func": "inert" }); + fs.chmodSync("/trusted/new/version/func", 0o755); + expect(resolveTrustedExecutable("func")).toBe("/trusted/new/version/func"); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("func")).toBeUndefined(); + }); + + it("skips inaccessible PATH candidates but does not trust them", () => { + // Arrange + vi.stubEnv("PATH", "/inaccessible:/trusted/bin"); + const realpath = fs.realpathSync.bind(fs); + vi.spyOn(fs, "realpathSync").mockImplementation((file, ...args) => { + if (String(file).startsWith("/inaccessible")) throw Object.assign(new Error("denied"), { code: "EACCES" }); + return realpath(file, ...args); + }); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + }); + + it("does not inspect unrelated project roots when selecting an existing configured tool", () => { + // Arrange + vi.stubEnv("PATH", "/trusted/bin"); + const realpath = fs.realpathSync.bind(fs); + vi.spyOn(fs, "realpathSync").mockImplementation((file, ...args) => { + if (String(file) === "/denied-root") throw Object.assign(new Error("denied"), { code: "EACCES" }); + return realpath(file, ...args); + }); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + expect(fs.realpathSync).not.toHaveBeenCalledWith("/denied-root"); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("func")).toBeUndefined(); + }); + + it("preserves an explicitly selected wrapper's absolute project interpreter", () => { + // Arrange + vol.fromJSON({ "/trusted/bin/func": "#!/project/node\ninert", "/project/node": "inert native fixture" }); + fs.chmodSync("/trusted/bin/func", 0o755); + fs.chmodSync("/project/node", 0o755); + vi.stubEnv("PATH", "/trusted/bin"); + // Act / Assert + const executable = resolveTrustedExecutable("func")!; + expect(executable).toBe("/trusted/bin/func"); + expect(prepareExecution({ executable, args: ["start"] })).toMatchObject({ + executable: "/project/node", + args: ["/trusted/bin/func", "start"], + }); + }); + + it.each(["func", "./func"])("refuses a relative identity: %s", (file) => { + expect(() => assertAbsoluteExecutable(file)).toThrow(); + }); + + it.each(["/trusted/tool.cmd", "/trusted/tool.bat", "/trusted/tool.ps1"])( + "does not confuse an absolute launcher with a relative identity: %s", + (file) => { + expect(() => assertAbsoluteExecutable(file)).not.toThrow(); + }, + ); + + it("refuses an absolute script when its interpreter cannot be bound", () => { + vol.fromJSON({ "/trusted/bin/func": "#!/missing/interpreter\ninert" }); + expect(() => prepareExecution({ executable: "/trusted/bin/func", args: [] })).toThrow(/trusted script interpreter/); + expect(() => prepareExecution({ executable: "/trusted/tool.ps1", args: [] })).toThrow(/determine the interpreter/); + }); + + it("preserves the fixed trusted shebang loader and flags without removing configured project PATH", () => { + vol.fromJSON({ + "/trusted/bin/func": "#!/trusted/bin/node --loader-flag\ninert", + "/trusted/bin/node": "inert native fixture", + }); + fs.chmodSync("/trusted/bin/node", 0o755); + vi.stubEnv("PATH", "/project:/trusted/bin"); + const execution = prepareExecution({ executable: "/trusted/bin/func", args: ["start"] }); + expect({ executable: execution.executable, args: execution.args, searchPath: execution.env?.PATH }).toEqual({ + executable: "/trusted/bin/node", + args: ["--loader-flag", "/trusted/bin/func", "start"], + searchPath: "/project:/trusted/bin", + }); + }); + + it("retains future configured aliases and global alternatives without adding off-PATH CWD", () => { + vol.fromJSON({ + "/trusted/bin/func": "#!/trusted/bin/node\ninert", + "/trusted/bin/node": "inert native fixture", + }); + fs.chmodSync("/trusted/bin/node", 0o755); + fs.symlinkSync("/project", "/outside-project-alias"); + vi.stubEnv("PATH", "/outside-project-alias/future:/trusted/future:/trusted/bin"); + const prepared = prepareExecution({ executable: "/trusted/bin/func", args: [], cwd: "/project" }); + fs.mkdirSync("/project/future"); + fs.mkdirSync("/trusted/future"); + vol.fromJSON({ "/project/future/node": "inert project canary", "/trusted/future/node": "inert external identity" }); + fs.chmodSync("/project/future/node", 0o755); + fs.chmodSync("/trusted/future/node", 0o755); + vi.stubEnv("PATH", prepared.env!.PATH!); + expect(prepared.env!.PATH!.split(":")).toEqual(["/outside-project-alias/future", "/trusted/future", "/trusted/bin"]); + expect(resolveTrustedExecutable("node")).toBe("/project/future/node"); + vi.stubEnv("PATH", "/trusted/future:/trusted/bin"); + expect(resolveTrustedExecutable("node")).toBe("/trusted/future/node"); + }); + + it.each(["yarn", "pnpm"] as const)("selects the actual external %s wrapper instead of a neighboring Corepack entrypoint", (manager) => { + // Arrange + const script = `/trusted/bin/node_modules/corepack/dist/${manager}.js`; + vol.fromJSON({ + "/trusted/bin/node_modules/corepack/package.json": JSON.stringify({ name: "corepack", bin: { [manager]: `./dist/${manager}.js` } }), + [script]: "inert JS fixture", + [`/trusted/bin/${manager}`]: "inert external launcher", + }); + fs.chmodSync(`/trusted/bin/${manager}`, 0o755); + vi.stubEnv("PATH", "/trusted/bin"); + // Act / Assert + expect(resolveTrustedPackageManager(manager)).toEqual({ executable: `/trusted/bin/${manager}`, args: [] }); + }); + + it("never substitutes metadata for the configured launcher and keeps explicit aliases canonical", () => { + // Arrange + vol.fromJSON({ + "/trusted/bin/node_modules/npm/package.json": JSON.stringify({ name: "npm", bin: { npm: "bin/npm-cli.js" } }), + "/project/npm-cli.js": "inert JS fixture", + "/trusted/bin/npm": "inert external launcher", + }); + fs.chmodSync("/trusted/bin/npm", 0o755); + fs.mkdirSync("/trusted/bin/node_modules/npm/bin"); + fs.symlinkSync("/project/npm-cli.js", "/trusted/bin/node_modules/npm/bin/npm-cli.js"); + vi.stubEnv("PATH", "/trusted/bin"); + // Act / Assert + expect(resolveTrustedPackageManager("npm")).toEqual({ executable: "/trusted/bin/npm", args: [] }); + fs.unlinkSync("/trusted/bin/npm"); + fs.symlinkSync("/project/npm-cli.js", "/trusted/bin/npm"); + const aliased = resolveTrustedPackageManager("npm"); + expect(aliased.executable).toBe("/project/npm-cli.js"); + expect(() => prepareExecution(aliased)).toThrow(/determine the interpreter/); + }); +}); + +describe("simulated Windows known shim identities (not native proof)", () => { + const fixtures = new Map(); + const canonical = (file: string) => path.win32.normalize(file).toLowerCase(); + + beforeEach(() => { + fixtures.clear(); + vi.spyOn(os, "platform").mockReturnValue("win32"); + vi.spyOn(process, "cwd").mockReturnValue("C:\\project"); + vi.stubEnv("PATH", '"C:\\trusted";.;C:\\project'); + vi.stubEnv("SystemRoot", "C:\\Windows"); + vi.stubEnv("ComSpec", "C:\\project\\cmd.exe"); + for (const file of ["C:\\trusted", "C:\\project", "C:\\Windows\\System32", "C:\\Windows\\System32\\cmd.exe", "C:\\project\\cmd.exe"]) { + fixtures.set(canonical(file), "inert"); + } + vi.spyOn(fs, "realpathSync").mockImplementation(((file: unknown) => { + const key = canonical(String(file)); + if (fixtures.has(key)) return key; + throw Object.assign(new Error("missing inert fixture"), { code: "ENOENT" }); + }) as typeof fs.realpathSync); + vi.spyOn(fs, "statSync").mockReturnValue({ isFile: () => true } as any); + vi.spyOn(fs, "accessSync").mockReturnValue(undefined); + vi.spyOn(fs, "readFileSync").mockImplementation(((file: unknown) => fixtures.get(canonical(String(file)))!) as typeof fs.readFileSync); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + ["npm", "npm", "bin/npm-cli.js"], + ["yarn", "corepack", "dist/yarn.js"], + ["pnpm", "corepack", "dist/pnpm.js"], + ] as const)("selects the canonical external Windows %s wrapper instead of its neighboring entrypoint", (manager, provider, entry) => { + // Arrange: wrapper content is not parsed or executed. + const root = `C:\\trusted\\node_modules\\${provider}`; + const script = path.win32.join(root, entry); + fixtures.set(canonical(`C:\\trusted\\${manager}.cmd`), "inert wrapper"); + fixtures.set(canonical(`${root}\\package.json`), JSON.stringify({ name: provider, bin: { [manager]: entry } })); + fixtures.set(canonical(script), "inert JS"); + // Act / Assert + expect(resolveTrustedPackageManager(manager)).toEqual({ + executable: canonical(`C:\\trusted\\${manager}.cmd`), + args: [], + }); + }); + + it.each([ + ["COM before EXE", ".COM;.EXE;.BAT;.CMD", "func", ["func.com", "func.exe"], "func.com"], + ["BAT before CMD", ".COM;.EXE;.BAT;.CMD", "func", ["func.bat", "func.cmd"], "func.bat"], + ["caller CMD before EXE", ".CMD;.EXE;.COM;.BAT", "func", ["func.cmd", "func.exe"], "func.cmd"], + ["case-insensitive order/name/suffix", ".cMd;.eXe;.cOm;.bAt", "FuNc", ["FuNc.CmD", "FuNc.ExE"], "FuNc.CmD"], + ["explicit EXE does not append COM", ".COM;.EXE;.BAT;.CMD", "func.ExE", ["func.exe", "func.exe.com", "func.com"], "func.exe"], + ["undeclared EXE is not a fallback", ".CMD", "func", ["func.exe"], undefined], + ] as const)("caller PATHEXT lookup matches native cmd: %s", (_scenario, pathext, name, files, expected) => { + vi.stubEnv("PATH", "C:\\trusted"); + vi.stubEnv("PATHEXT", pathext); + for (const file of files) fixtures.set(canonical(path.win32.join("C:\\trusted", file)), "inert fixture"); + expect(resolveTrustedExecutable(name)).toBe(expected === undefined ? undefined : canonical(path.win32.join("C:\\trusted", expected))); + }); + + it("selects System32 cmd rather than project ComSpec for intentional commands", () => { + // Arrange + const command = "echo benign"; + // Act / Assert + expect(createShellExecutionSpec(command, "C:\\project")).toEqual({ + executable: "c:\\windows\\system32\\cmd.exe", + args: ["/d", "/s", "/c", `"${command}"`], + cwd: "C:\\project", + env: { ComSpec: "c:\\windows\\system32\\cmd.exe" }, + windowsVerbatimArguments: true, + }); + }); + + it("canonicalizes configured case-variant project PATH and ignores the same off-PATH tool", () => { + // Arrange + vi.stubEnv("PATH", "C:\\PROJECT\\tools"); + fixtures.set(canonical("C:\\project\\tools\\func.exe"), "inert"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe(canonical("C:\\project\\tools\\func.exe")); + vi.stubEnv("PATH", "C:\\trusted"); + expect(resolveTrustedExecutable("func")).toBeUndefined(); + }); +}); + +describe("configured tools at invocation versus operation cwd (inert fixtures)", () => { + const home = "/home/tester"; + const project = `${home}/project`; + + beforeEach(() => { + vi.resetModules(); + vol.reset(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + simulated.cwd = home; + vol.fromJSON({ [`${project}/swa-cli.config.json`]: "{}", [`${project}/func`]: "off-PATH CWD canary" }); + fs.chmodSync(`${project}/func`, 0o755); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function selectProject(invocation = home, selected = project): ExecutableResolutionOptions { + simulated.cwd = invocation; + return { cwd: selected }; + } + + function expectConfiguredTool(tool: string, options: ExecutableResolutionOptions) { + expect(resolveTrustedExecutable("func", options)).toBe(tool); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + } + + it("allows the standard HOME managed Functions path outside the configured project", async () => { + // Arrange + const tool = `${home}/.swa/core-tools/v4/func`; + vol.fromJSON({ [tool]: "inert native fixture" }); + const options = await selectProject(); + fs.chmodSync(tool, 0o755); + vi.stubEnv("PATH", `${home}/.swa/core-tools/v4`); + // Existing managed identities are canonical; unrelated project ancestry is not policy. + expect(resolveTrustedExecutablePath(tool)).toBe(tool); + expectConfiguredTool(tool, options); + }); + + it("preserves the canonical HOME runtime npm launcher outside the configured project", async () => { + // Arrange + const runtime = `${home}/.nvm/versions/node/v20.18.0`; + const script = `${runtime}/lib/node_modules/npm/bin/npm-cli.js`; + vol.fromJSON({ + [`${runtime}/lib/node_modules/npm/package.json`]: JSON.stringify({ name: "npm", bin: { npm: "bin/npm-cli.js" } }), + [script]: "inert JS fixture", + }); + fs.mkdirSync(`${runtime}/bin`, { recursive: true }); + fs.symlinkSync(script, `${runtime}/bin/npm`); + fs.chmodSync(script, 0o755); + vi.stubEnv("PATH", `${runtime}/bin`); + const options = await selectProject(); + // Act / Assert + expect(resolveTrustedPackageManager("npm", options)).toEqual({ executable: script, args: [], cwd: project }); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("npm", options)).toBeUndefined(); + }); + + it("allows a future configured HOME tool after creation without cache-specific exemptions", async () => { + // Arrange: no .swa/.nvm allowlist or recursive HOME exclusion is needed. + const tool = `${home}/global-tools/func`; + vi.stubEnv("PATH", `${home}/global-tools`); + const options = await selectProject(); + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + vol.fromJSON({ [tool]: "inert native fixture" }); + fs.chmodSync(tool, 0o755); + vi.stubEnv("PATH", `${home}/global-tools`); + // Act / Assert + expect(resolveTrustedExecutable("func", options)).toBe(tool); + }); + + it("honors an explicit original invocation PATH entry after chdir but not an off-PATH shadow", async () => { + // Arrange + vol.fromJSON({ [`${home}/func`]: "inert fixture" }); + fs.chmodSync(`${home}/func`, 0o755); + vi.stubEnv("PATH", home); + const options = await selectProject(); + // Act / Assert + expectConfiguredTool(`${home}/func`, options); + }); + + it("keeps an explicit invocation PATH alias canonical and ignores it when off PATH", async () => { + // Arrange + vol.fromJSON({ [`${home}/func`]: "inert fixture" }); + fs.chmodSync(`${home}/func`, 0o755); + fs.symlinkSync(home, "/invocation-alias"); + vi.stubEnv("PATH", "/invocation-alias"); + const options = await selectProject(); + // Act / Assert + expectConfiguredTool(`${home}/func`, options); + }); + + it("honors configured repository tools independently of Git marker ancestry", async () => { + // Arrange: repository evidence, not merely an arbitrary HOME ancestor. + const repo = "/repository"; + const selected = `${repo}/app`; + vol.fromJSON({ + [`${repo}/.git/HEAD`]: "ref: refs/heads/main", + [`${selected}/swa-cli.config.json`]: "{}", + [`${repo}/tools/func`]: "inert fixture", + }); + fs.chmodSync(`${repo}/tools/func`, 0o755); + vi.stubEnv("PATH", `${repo}/tools`); + const options = await selectProject(repo, selected); + // Act / Assert + expectConfiguredTool(`${repo}/tools/func`, options); + expect(resolveTrustedExecutablePath(`${repo}/tools/func`)).toBe(`${repo}/tools/func`); + }); + + it("honors configured tools beneath a worktree .git file without marker discovery", async () => { + // Arrange: the selected app is elsewhere; invocation repository evidence must not be erased. + const repo = "/worktree"; + vol.fromJSON({ + [`${repo}/.git`]: "gitdir: /git-metadata/worktrees/local\n", + "/git-metadata/worktrees/local/HEAD": "ref: refs/heads/main", + [`${repo}/nested/.keep`]: "", + [`${repo}/tools/func`]: "inert fixture", + }); + fs.chmodSync(`${repo}/tools/func`, 0o755); + vi.stubEnv("PATH", `${repo}/tools`); + const options = await selectProject(`${repo}/nested`); + // Act / Assert + expectConfiguredTool(`${repo}/tools/func`, options); + expect(resolveTrustedExecutablePath(`${repo}/tools/func`)).toBe(`${repo}/tools/func`); + }); + + it.each(["package.json", "swa-cli.config.json"])("honors configured tools beneath %s without a new project trust policy", async (marker) => { + // Arrange + const root = "/non-git-project"; + vol.fromJSON({ + [`${root}/${marker}`]: "{}", + [`${root}/nested/.keep`]: "", + [`${root}/tools/func`]: "inert fixture", + }); + fs.chmodSync(`${root}/tools/func`, 0o755); + vi.stubEnv("PATH", `${root}/tools`); + const options = await selectProject(`${root}/nested`); + // Act / Assert + expectConfiguredTool(`${root}/tools/func`, options); + expect(resolveTrustedExecutablePath(`${root}/tools/func`)).toBe(`${root}/tools/func`); + }); + + it("honors a selected-project PATH alias and canonical existing cache identity", async () => { + // Explicit project choices remain configured choices, not implicit CWD preemption. + vol.fromJSON({ [`${project}/tools/func`]: "inert fixture" }); + fs.chmodSync(`${project}/tools/func`, 0o755); + fs.symlinkSync(`${project}/tools`, "/selected-tools-alias"); + vi.stubEnv("PATH", "/selected-tools-alias"); + const options = await selectProject(); + // Act / Assert + expectConfiguredTool(`${project}/tools/func`, options); + expect(resolveTrustedExecutablePath("/selected-tools-alias/func")).toBe(`${project}/tools/func`); + }); + + it("selects canonical configured tools when the operation cwd itself is aliased", async () => { + // Arrange + const repo = "/canonical-repo"; + vol.fromJSON({ + [`${repo}/.git/HEAD`]: "ref: refs/heads/main", + [`${repo}/app/swa-cli.config.json`]: "{}", + [`${repo}/tools/func`]: "inert fixture", + }); + fs.chmodSync(`${repo}/tools/func`, 0o755); + fs.symlinkSync(`${repo}/app`, "/selected-app-alias"); + vi.stubEnv("PATH", `${repo}/tools`); + const options = await selectProject(home, "/selected-app-alias"); + // Act / Assert + expectConfiguredTool(`${repo}/tools/func`, options); + expect(resolveTrustedExecutablePath(`${repo}/tools/func`)).toBe(`${repo}/tools/func`); + }); + + it.each([".git", "package.json"])("does not inspect unrelated invocation %s markers during configured lookup", async (marker) => { + // Unrelated marker permission must not become a tool-lookup policy. + const denied = Object.assign(new Error("marker permission denied"), { code: "EACCES" }); + const stat = nodeFs.statSync; + vi.spyOn(nodeFs, "statSync").mockImplementation((file, ...args) => { + if (String(file) === `${home}/${marker}`) throw denied; + return stat(file, ...args); + }); + const tool = `${project}/tools/func`; + vol.fromJSON({ [tool]: "inert executable" }); + fs.chmodSync(tool, 0o755); + vi.stubEnv("PATH", "tools"); + // Relative PATH is interpreted at selected operation cwd, not invocation HOME. + expectConfiguredTool(tool, await selectProject()); + }); +}); + +describe("trusted external wrapper compatibility (benign native fixtures)", () => { + const fs = nativeModules["node:fs"].default; + const os = nativeModules["node:os"].default; + const path = nativeModules["node:path"].default; + const process = nativeModules["node:process"].default; + const { spawnSync } = nativeModules["node:child_process"]; + const { fileURLToPath } = nativeModules["node:url"]; + const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); + const windows = process.platform === "win32"; + let execInternalCommand: typeof import("./command.js").execInternalCommand; + let createShellExecutionSpec: typeof import("./executable.js").createShellExecutionSpec; + let prepareExecution: typeof import("./executable.js").prepareExecution; + let probeExecutable: typeof import("./executable.js").probeExecutable; + let resolveTrustedExecutable: typeof import("./executable.js").resolveTrustedExecutable; + let resolveTrustedPackageManager: typeof import("./executable.js").resolveTrustedPackageManager; + let sandbox: string; + let project: string; + let external: string; + let report: string; + let output: string; + + beforeEach(async () => { + vi.resetModules(); + for (const [name, module] of Object.entries(nativeModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => nativeModules["node:fs"]); + vi.doMock("child_process", () => nativeModules["node:child_process"]); + vi.stubGlobal("process", process); + ({ execInternalCommand } = await import("./command.js")); + ({ createShellExecutionSpec, prepareExecution, probeExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } = await import( + "./executable.js" + )); + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "swa-wrapper-compat-")); + project = path.join(sandbox, "project with spaces"); + external = path.join(sandbox, "external tools"); + report = path.join(sandbox, "launches.jsonl"); + output = path.join(sandbox, "wrapper stdout.txt"); + fs.mkdirSync(project); + fs.mkdirSync(external); + fs.copyFileSync(recorder, path.join(external, "record.cjs")); + vi.stubEnv("PATH", [project, external, path.dirname(process.execPath)].join(path.delimiter)); + vi.stubEnv("SWA_COMPAT_REPORT", report); + vi.stubEnv("SWA_COMPAT_CONTEXT", "inherited context"); + for (const key of ["SWA_COMPAT_WRAPPER", "SWA_COMPAT_PROBE", "SWA_COMPAT_EXIT", "NoDefaultCurrentDirectoryInExePath"]) { + vi.stubEnv(key, ""); + delete process.env[key]; + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(sandbox, { recursive: true }); + for (const [name, module] of Object.entries(fixtureModules)) vi.doMock(name, () => module); + vi.doMock("fs", () => nodeFs); + vi.doMock("child_process", () => fixtureChildProcess); + vi.stubGlobal("process", simulatedProcess); + }); + + function wrapper( + name: string, + redirect = false, + exit = 0, + nestedNode = false, + location = { directory: external, marker: "trusted external wrapper" }, + ) { + const file = path.join(location.directory, windows ? `${name}.cmd` : name); + const script = path.join(external, "record.cjs"); + const redirectOutput = redirect ? ` > "${output}"` : ""; + fs.writeFileSync( + file, + windows + ? [ + "@ECHO OFF", + "SETLOCAL", + `SET "SWA_COMPAT_WRAPPER=${location.marker}"`, + `SET "SWA_COMPAT_EXIT=${exit}"`, + ...(nestedNode + ? [ + 'SET "NODE_EXE=%~dp0node.exe"', + 'IF NOT EXIST "%NODE_EXE%" SET "NODE_EXE=node"', + `FOR /F "delims=" %%F IN ('CALL "%NODE_EXE%" "${script}" --version') DO SET "SWA_COMPAT_PROBE=%%F"`, + ] + : [`SET "NODE_EXE=${process.execPath}"`]), + `CALL "%NODE_EXE%" "${script}" --wrapper-flag %*${redirectOutput}`, + "EXIT /B %ERRORLEVEL%", + "", + ].join("\r\n") + : `#!/bin/sh\nSWA_COMPAT_WRAPPER="${location.marker}" SWA_COMPAT_EXIT=${exit} ${nestedNode ? "node" : `"${process.execPath}"`} "${script}" --wrapper-flag "$@"${redirectOutput}\n`, + { mode: 0o755 }, + ); + return fs.realpathSync(file); + } + + function packageEntrypoint() { + const root = path.join(external, "node_modules", "npm"); + fs.mkdirSync(path.join(root, "bin"), { recursive: true }); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "npm", bin: { npm: "bin/npm-cli.js" } })); + fs.copyFileSync(recorder, path.join(root, "bin", "npm-cli.js")); + } + + function records(): { execPath: string; args: string[]; cwd: string; wrapper: string | null; probe: string | null; context: string }[] { + return fs + .readFileSync(report, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + } + + function executeWithStatus(execution: InternalExecutionSpec): number { + try { + execInternalCommand(execution); + return 0; + } catch (error) { + if (!(error instanceof Error) || !("status" in error) || typeof error.status !== "number") throw error; + return error.status; + } + } + + it("executes the selected package wrapper instead of bypassing its flags, redirection, env and exit status", () => { + const selected = wrapper("npm", true, 17); + packageEntrypoint(); + const control = createShellExecutionSpec(`"${selected}" install`, project); + const child = spawnSync(control.executable, control.args, { + cwd: control.cwd, + env: process.env, + shell: false, + windowsVerbatimArguments: control.windowsVerbatimArguments, + timeout: 5000, + }); + expect(child.error).toBeUndefined(); + expect(child.status).toBe(17); + expect(records().at(-1)).toMatchObject({ args: ["--wrapper-flag", "install"], wrapper: "trusted external wrapper" }); + expect(fs.readFileSync(output, "utf8").trim()).toBe("compatibility wrapper stdout"); + fs.writeFileSync(report, ""); + fs.unlinkSync(output); + const execution = resolveTrustedPackageManager("npm"); + const status = executeWithStatus({ ...execution, args: [...execution.args, "install"], cwd: project }); + expect({ + status, + launch: records().at(-1), + stdout: fs.existsSync(output) ? fs.readFileSync(output, "utf8").trim() : undefined, + }).toEqual({ + status: 17, + launch: { + execPath: process.execPath, + args: ["--wrapper-flag", "install"], + cwd: fs.realpathSync(project), + wrapper: "trusted external wrapper", + probe: null, + context: "inherited context", + }, + stdout: "compatibility wrapper stdout", + }); + }); + + it.each([ + ["func", "4.0.0"], + ["dab", "Microsoft.DataApiBuilder 1.2.3"], + ])("executes the same trusted %s wrapper for version probing and launch", async (tool, version) => { + const expected = wrapper(tool); + vi.stubEnv("SWA_COMPAT_VERSION", version); + const resolutionOptions = {}; + const executable = resolveTrustedExecutable(tool, resolutionOptions); + expect(executable).toBe(expected); + if (!executable) throw new Error(`Missing fixture wrapper for ${tool}.`); + expect((await probeExecutable(executable, ["--version"], resolutionOptions)).stdout.trim()).toBe(version); + execInternalCommand({ executable, args: ["start"], cwd: project }); + expect(records()).toEqual([ + { + execPath: process.execPath, + args: ["--wrapper-flag", "--version"], + cwd: fs.realpathSync(process.cwd()), + wrapper: "trusted external wrapper", + probe: null, + context: "inherited context", + }, + { + execPath: process.execPath, + args: ["--wrapper-flag", "start"], + cwd: fs.realpathSync(project), + wrapper: "trusted external wrapper", + probe: null, + context: "inherited context", + }, + ]); + }); + + it.each([ + ["func", "4.0.0"], + ["dab", "Microsoft.DataApiBuilder 1.2.3"], + ["npm", "10.8.2"], + ])("honors caller's explicit absolute project PATH order for %s probe and launch", async (tool, version) => { + const local = path.join(project, tool === "npm" ? "node_modules/.bin" : ".tools"); + fs.mkdirSync(local, { recursive: true }); + const marker = "first caller PATH wrapper"; + const command = tool === "npm" ? "install" : "start"; + const status = tool === "npm" ? 17 : 0; + const expected = wrapper(tool, tool === "npm", status, false, { directory: local, marker }); + wrapper(tool, tool === "npm", status); + const callerPath = [local, external, path.dirname(process.execPath)].join(path.delimiter); + vi.stubEnv("PATH", callerPath); + vi.stubEnv("SWA_COMPAT_VERSION", version); + const resolutionOptions = {}; + + const baseline = createShellExecutionSpec(`"${tool}" ${command}`, project); + const control = spawnSync(baseline.executable, baseline.args, { + cwd: project, + env: { ...process.env, ...baseline.env }, + shell: false, + windowsVerbatimArguments: baseline.windowsVerbatimArguments, + timeout: 5000, + }); + expect(control.error).toBeUndefined(); + expect(control.status).toBe(status); + expect(records().at(-1)?.wrapper).toBe(marker); + fs.writeFileSync(report, ""); + + const execution = + tool === "npm" + ? resolveTrustedPackageManager("npm", resolutionOptions) + : { executable: resolveTrustedExecutable(tool, resolutionOptions)!, args: [] }; + const probe = await probeExecutable(execution.executable, ["--version"], resolutionOptions); + const actualStatus = executeWithStatus({ ...execution, args: [command], cwd: project }); + const observed = records(); + expect({ + canonical: execution.executable, + wrappers: observed.map((record) => record.wrapper), + args: observed.map((record) => record.args), + identities: observed.map((record) => record.execPath), + cwds: observed.map((record) => record.cwd), + probe: probe.stdout.trim(), + status: actualStatus, + callerPath: process.env.PATH, + redirected: tool === "npm" ? fs.readFileSync(output, "utf8").trim() : undefined, + }).toEqual({ + canonical: expected, + wrappers: [marker, marker], + args: [ + ["--wrapper-flag", "--version"], + ["--wrapper-flag", command], + ], + identities: [process.execPath, process.execPath], + cwds: [fs.realpathSync(process.cwd()), fs.realpathSync(project)], + probe: tool === "npm" ? "" : version, + status, + callerPath, + redirected: tool === "npm" ? "compatibility wrapper stdout" : undefined, + }); + }); + + it("binds relative caller PATH and the version probe to the tool's operation cwd", async () => { + const runtime = path.join(project, "runtime tools"); + fs.mkdirSync(runtime); + fs.writeFileSync( + path.join(runtime, windows ? "node.cmd" : "node"), + windows + ? `@ECHO OFF\r\nSETLOCAL\r\nSET "SWA_COMPAT_CONTEXT=relative PATH loader"\r\nCALL "${process.execPath}" %*\r\nEXIT /B %ERRORLEVEL%\r\n` + : `#!/bin/sh\nSWA_COMPAT_CONTEXT="relative PATH loader" exec "${process.execPath}" "$@"\n`, + { mode: 0o755 }, + ); + const expected = wrapper("func", false, 0, true); + const callerPath = [path.relative(project, external), path.relative(project, runtime)].join(path.delimiter); + vi.stubEnv("PATH", callerPath); + vi.stubEnv("SWA_COMPAT_VERSION", "4.0.0"); + const resolutionOptions = { cwd: project }; + const executable = resolveTrustedExecutable("func", resolutionOptions); + expect(executable).toBe(expected); + if (!executable) throw new Error("Missing relative PATH fixture."); + expect((await probeExecutable(executable, ["--version"], resolutionOptions)).stdout.trim()).toBe("4.0.0"); + execInternalCommand({ executable, args: ["start"], cwd: project }); + const argv = windows + ? [["--version"], ["--wrapper-flag", "--version"], ["--version"], ["--wrapper-flag", "start"]] + : [ + ["--wrapper-flag", "--version"], + ["--wrapper-flag", "start"], + ]; + expect(records().map(({ execPath, args, cwd, context }) => ({ execPath, args, cwd, context }))).toEqual( + argv.map((args) => ({ execPath: process.execPath, args, cwd: fs.realpathSync(project), context: "relative PATH loader" })), + ); + expect(process.env.PATH).toBe(callerPath); + }); + + it.skipIf(!windows)( + "native Windows: off-PATH CWD canaries cannot preempt wrappers or fixed cmd/Node loaders", + () => { + const resolutionOptions = { cwd: project }; + const trustedCmd = createShellExecutionSpec("echo fixture", project).executable; + fs.copyFileSync(process.execPath, path.join(project, "node.exe")); + fs.copyFileSync(trustedCmd, path.join(project, "cmd.exe")); + for (const name of ["func", "dab", "npm"]) { + wrapper(name, false, 0, false, { directory: project, marker: "off-PATH CWD canary" }); + } + vi.stubEnv("PATH", [external, path.dirname(process.execPath)].join(path.delimiter)); + vi.stubEnv("ComSpec", path.join(project, "cmd.exe")); + vi.stubEnv("SWA_COMPAT_VERSION", "4.0.0"); + for (const name of ["func", "dab", "npm"]) { + const expected = wrapper(name, name === "npm", 0, true); + fs.writeFileSync(report, ""); + const selected = + name === "npm" ? resolveTrustedPackageManager("npm", resolutionOptions).executable : resolveTrustedExecutable(name, resolutionOptions)!; + const prepared = prepareExecution({ executable: selected, args: ["start"], cwd: project }); + expect(prepared.executable).toBe(trustedCmd); + expect(prepared.env?.ComSpec).toBe(trustedCmd); + execInternalCommand(prepared); + expect(selected).toBe(expected); + const observed = records(); + expect(observed.map((record) => record.execPath.toLowerCase())).toEqual([process.execPath.toLowerCase(), process.execPath.toLowerCase()]); + expect(observed.map((record) => record.cwd)).toEqual([fs.realpathSync(project), fs.realpathSync(project)]); + expect(observed.map((record) => record.args)).toEqual([["--version"], ["--wrapper-flag", "start"]]); + expect(observed[1].wrapper).toBe("trusted external wrapper"); + expect(observed[1].probe).toBe("4.0.0"); + } + }, + 15000, + ); + + it.skipIf(!windows)( + "native Windows: future configured PATH aliases and global alternatives never add off-PATH CWD", + () => { + fs.copyFileSync(process.execPath, path.join(project, "node.exe")); + wrapper("npm", true, 0, true); + packageEntrypoint(); + vi.stubEnv("SWA_COMPAT_VERSION", "4.0.0"); + const alias = path.join(sandbox, "project junction"); + const missingProjectChild = path.join(alias, "created after binding"); + const futureGlobal = path.join(external, "future global"); + fs.symlinkSync(project, alias, "junction"); + vi.stubEnv("PATH", [missingProjectChild, futureGlobal, external, path.dirname(process.execPath)].join(path.delimiter)); + expect(fs.existsSync(path.join(external, "node.exe"))).toBe(false); + const execution = resolveTrustedPackageManager("npm"); + const prepared = prepareExecution({ ...execution, args: [...execution.args, "install"], cwd: project }); + fs.mkdirSync(missingProjectChild); + fs.linkSync(path.join(project, "node.exe"), path.join(missingProjectChild, "node.exe")); + fs.mkdirSync(futureGlobal); + const trustedNode = path.join(futureGlobal, "node.exe"); + fs.copyFileSync(process.execPath, trustedNode); + vi.stubEnv("PATH", [futureGlobal, external, path.dirname(process.execPath)].join(path.delimiter)); + const alternatives = [ + { execution: prepared, node: path.join(missingProjectChild, "node.exe") }, + { execution: prepareExecution({ ...execution, args: ["install"], cwd: project }), node: trustedNode }, + ]; + for (const { execution: bound, node } of alternatives) { + fs.writeFileSync(report, ""); + execInternalCommand(bound); + expect(records().map((record) => ({ ...record, execPath: fs.realpathSync(record.execPath).toLowerCase() }))).toEqual([ + { + execPath: fs.realpathSync(node).toLowerCase(), + args: ["--version"], + cwd: fs.realpathSync(project), + wrapper: "trusted external wrapper", + probe: null, + context: "inherited context", + }, + { + execPath: fs.realpathSync(node).toLowerCase(), + args: ["--wrapper-flag", "install"], + cwd: fs.realpathSync(project), + wrapper: "trusted external wrapper", + probe: "4.0.0", + context: "inherited context", + }, + ]); + } + }, + 15000, + ); +}); diff --git a/src/core/utils/executable.ts b/src/core/utils/executable.ts new file mode 100644 index 000000000..797ee0f45 --- /dev/null +++ b/src/core/utils/executable.ts @@ -0,0 +1,309 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; + +export interface InternalExecutionSpec { + executable: string; + args: string[]; + cwd?: string; + /** Overrides on input; a prepared environment, when present, is a complete snapshot. */ + env?: NodeJS.ProcessEnv; + /** Arguments have already been encoded for an absolute Windows shell. */ + windowsVerbatimArguments?: boolean; +} + +export interface ExecutableResolutionOptions { + /** Working directory for caller-relative PATH entries and the selected tool's probe. */ + cwd?: string; +} + +export class UnsupportedExecutableError extends Error {} + +function pathValue(env: NodeJS.ProcessEnv, name = "PATH"): string | undefined { + const windows = os.platform() === "win32"; + const key = Object.keys(env).find((key) => (windows ? key.toLowerCase() === name.toLowerCase() : key === name)); + return key ? env[key] : undefined; +} + +function executionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const [key, value] of Object.entries(overrides)) { + if (os.platform() === "win32") { + for (const existing of Object.keys(env)) { + if (existing.toLowerCase() === key.toLowerCase()) delete env[existing]; + } + } + env[key] = value; + } + if (os.platform() === "win32") { + for (const key of Object.keys(env)) { + if (["comspec", "nodefaultcurrentdirectoryinexepath"].includes(key.toLowerCase())) delete env[key]; + } + env.ComSpec = getTrustedWindowsSystemExecutable("cmd.exe"); + env.NoDefaultCurrentDirectoryInExePath = "1"; + } + return env; +} + +function platformPath() { + return os.platform() === "win32" ? path.win32 : path; +} + +function isAbsoluteExecutable(file: string): boolean { + if (os.platform() === "win32") { + // A rooted path without a drive still depends on the current drive. + return /^[a-z]:[\\/]/i.test(file) || /^\\\\[^\\]+\\[^\\]+\\/.test(file); + } + return path.isAbsolute(file); +} + +export function assertAbsoluteExecutable(file: string): void { + if (!isAbsoluteExecutable(file)) { + throw new Error(`Internal executable must be an absolute path: ${file}`); + } +} + +function realPath(file: string): string | undefined { + try { + return fs.realpathSync(file); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return undefined; + throw error; + } +} + +function executablePath(file: string): string | undefined { + if (!isAbsoluteExecutable(file)) return undefined; + + let canonical: string | undefined; + try { + canonical = realPath(file); + } catch (error) { + // An inaccessible PATH candidate cannot be selected; other absolute entries may still be usable. + if ((error as NodeJS.ErrnoException).code === "EACCES") return undefined; + throw error; + } + if (!canonical || !isAbsoluteExecutable(canonical)) return undefined; + return canonical; +} + +function pathDirectories(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string[] { + const windows = os.platform() === "win32"; + let value = pathValue(env); + if (value === undefined && !windows) { + const defaultEnv = { ...env }; + delete defaultEnv.PATH; + const result = spawnSync(getTrustedShell(), ["-c", 'printf "%s" "$PATH"'], { + cwd, + env: defaultEnv, + encoding: "utf8", + timeout: 5000, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error("Cannot determine the system shell's default PATH."); + value = result.stdout; + } + return ( + (value ?? "") + .split(windows ? ";" : ":") + .map((directory) => (windows ? directory.replace(/^"(.*)"$/, "$1") : directory)) + // Empty Windows entries do not declare CWD; normalize them before guarded cmd lookup. + .filter((directory) => !windows || directory !== "") + .map((directory) => platformPath().resolve(cwd, directory || ".")) + ); +} + +function isExecutableFile(file: string): boolean { + if (!fs.statSync(file).isFile()) return false; + try { + fs.accessSync(file, os.platform() === "win32" ? fs.constants.F_OK : fs.constants.X_OK); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "ENOENT") return false; + throw error; + } +} + +function shebang(file: string): { interpreter: string; argument?: string } | undefined { + if (!fs.existsSync(file)) return undefined; + const fd = fs.openSync(file, "r"); + const header = Buffer.alloc(4096); + try { + const length = fs.readSync(fd, header, 0, header.length, 0); + const line = header.subarray(0, length).toString("utf8").split(/\r?\n/, 1)[0]; + const match = /^#!\s*(\S+)(?:[ \t]+(.+?))?[ \t]*$/.exec(line); + return match ? { interpreter: match[1], argument: match[2] } : undefined; + } finally { + fs.closeSync(fd); + } +} + +/** + * Bind configured PATH order, including project-local tools, without Windows' extra CWD search. + * Relative entries resolve at lookup; canonical identities survive later working-directory changes. + */ +export function resolveTrustedExecutable(name: string, options: ExecutableResolutionOptions = {}): string | undefined { + if (!/^[a-z0-9_-]+(?:\.exe|\.com)?$/i.test(name)) { + throw new Error(`Expected an internal tool name, not a path: ${name}`); + } + const windows = os.platform() === "win32"; + const extensions = pathValue(process.env, "PATHEXT"); + // cmd supplies native/batch defaults only for absent or empty PATHEXT, not for an explicit list. + const names = + windows && !/\.(exe|com)$/i.test(name) + ? (extensions || ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter((extension) => /^\.(com|exe|bat|cmd)$/i.test(extension)) + .map((extension) => `${name}${extension.toLowerCase()}`) + : [name]; + for (const directory of pathDirectories(process.env, options.cwd)) { + for (const filename of names) { + const candidate = executablePath(platformPath().join(directory, filename)); + if (!candidate || !isExecutableFile(candidate)) continue; + return candidate; + } + } + return undefined; +} + +export function resolveTrustedExecutablePath(file: string): string | undefined { + const executable = executablePath(file); + return executable && isExecutableFile(executable) ? executable : undefined; +} + +/** Keep the selected launcher's behavior instead of bypassing it through package metadata. */ +export function resolveTrustedPackageManager(name: "npm" | "yarn" | "pnpm", options: ExecutableResolutionOptions = {}): InternalExecutionSpec { + const executable = resolveTrustedExecutable(name, options); + if (executable) return { executable, args: [], ...(options.cwd ? { cwd: options.cwd } : {}) }; + throw new Error(`Cannot find ${name} on the caller's PATH.`); +} + +/** Bind the shell used for caller syntax or a trusted external command shim. */ +export function getTrustedShell(): string { + if (os.platform() !== "win32") { + const shell = executablePath("/bin/sh"); + if (shell && isExecutableFile(shell)) return shell; + } else { + return getTrustedWindowsSystemExecutable("cmd.exe"); + } + throw new Error("Cannot locate an absolute system shell. Check your operating system installation."); +} + +export function getTrustedWindowsSystemExecutable(name: "cmd.exe" | "taskkill.exe"): string { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT; + if (os.platform() === "win32" && systemRoot && isAbsoluteExecutable(systemRoot)) { + const executable = executablePath(path.win32.join(systemRoot, "System32", name)); + if (executable && isExecutableFile(executable)) return executable; + } + throw new Error(`Cannot locate System32\\${name}. Check your Windows installation.`); +} + +export function createShellExecutionSpec(command: string, cwd: string): InternalExecutionSpec { + const executable = getTrustedShell(); + if (os.platform() === "win32") { + return { executable, args: ["/d", "/s", "/c", `"${command}"`], cwd, env: { ComSpec: executable }, windowsVerbatimArguments: true }; + } + return { executable, args: ["-c", command], cwd }; +} + +function internalEnvironment(overrides?: NodeJS.ProcessEnv, cwd = process.cwd()): NodeJS.ProcessEnv { + const env = executionEnvironment(overrides); + const windows = os.platform() === "win32"; + const directories = pathDirectories(env, cwd); + for (const key of Object.keys(env)) { + if (windows && key.toLowerCase() === "path") delete env[key]; + } + env[windows ? "Path" : "PATH"] = directories.join(windows ? ";" : ":"); + return env; +} + +function quoteArgument(value: string): string { + if (os.platform() !== "win32") return `'${value.replace(/'/g, "'\\''")}'`; + const quoted = `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`; + return quoted.replace(/[()%!^"<>&|,;*? \t]/g, "^$&"); +} + +function windowsVariableExpansion(env: NodeJS.ProcessEnv, cwd: string): "%" | "!" { + const shell = env.ComSpec; + if (!shell) throw new Error("Missing bound Windows command interpreter."); + const variable = "SWA_CLI_INTERNAL_DELAYED_EXPANSION"; + const result = spawnSync(shell, ["/d", "/s", "/c", `echo !${variable}!`], { + cwd, + env: { ...env, [variable]: "enabled" }, + encoding: "utf8", + shell: false, + windowsHide: true, + timeout: 5000, + }); + if (result.error) throw result.error; + const output = result.stdout?.trim(); + if (result.status === 0 && output === "enabled") return "!"; + if (result.status === 0 && output === `!${variable}!`) return "%"; + throw new Error("Cannot determine the trusted Windows command interpreter's variable expansion mode."); +} + +/** Only the CLI-owned prefix is encoded; the caller's explicit shell suffix is left intact. */ +export function createToolShellExecutionSpec(executable: string, args: string[], cwd: string, suffix = ""): InternalExecutionSpec { + assertAbsoluteExecutable(executable); + const env = internalEnvironment(undefined, cwd); + let prefix: string; + if (os.platform() === "win32") { + env.SWA_CLI_INTERNAL_EXECUTABLE = executable; + // Match the existing shell mode rather than disabling delayed expansion for user commands/wrappers. + const expansion = windowsVariableExpansion(env, cwd); + prefix = [`"${expansion}SWA_CLI_INTERNAL_EXECUTABLE${expansion}"`, ...args.map(quoteArgument)].join(" "); + } else { + const execution = prepareExecution({ executable, args, cwd }); + prefix = [execution.executable, ...execution.args].map(quoteArgument).join(" "); + } + return { ...createShellExecutionSpec(`${prefix}${suffix ? ` ${suffix}` : ""}`, cwd), env }; +} + +/** Native identities stay direct; script loaders receive absolute configured PATH entries. */ +export function prepareExecution(spec: InternalExecutionSpec): InternalExecutionSpec { + assertAbsoluteExecutable(spec.executable); + if (spec.windowsVerbatimArguments) return { ...spec, env: executionEnvironment(spec.env) }; + if (os.platform() === "win32" && /\.(?:cmd|bat)$/i.test(spec.executable)) { + return { + ...createToolShellExecutionSpec(spec.executable, spec.args, spec.cwd ?? process.cwd()), + env: { ...internalEnvironment(spec.env, spec.cwd), SWA_CLI_INTERNAL_EXECUTABLE: spec.executable }, + }; + } + const script = shebang(spec.executable); + if (script) { + const interpreter = executablePath(script.interpreter); + if (!interpreter || !isExecutableFile(interpreter)) { + throw new UnsupportedExecutableError(`Cannot bind a trusted script interpreter for ${spec.executable}.`); + } + return { + ...spec, + executable: interpreter, + args: [...(script.argument ? [script.argument] : []), spec.executable, ...spec.args], + env: internalEnvironment(spec.env, spec.cwd), + }; + } + if (/\.(?:ps1|js|cjs|mjs)$/i.test(spec.executable)) { + throw new UnsupportedExecutableError(`Cannot determine the interpreter for ${spec.executable}.`); + } + return { ...spec, env: executionEnvironment(spec.env) }; +} + +export function probeExecutable( + executable: string, + args: string[], + options: ExecutableResolutionOptions = {}, +): Promise<{ stdout: string; stderr: string }> { + const spec = prepareExecution({ executable, args, cwd: options.cwd }); + return promisify(execFile)(spec.executable, spec.args, { + encoding: "utf8", + shell: false, + ...(spec.cwd ? { cwd: spec.cwd } : {}), + ...(spec.env ? { env: spec.env } : {}), + ...(spec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); +} diff --git a/src/core/utils/process.spec.ts b/src/core/utils/process.spec.ts new file mode 100644 index 000000000..5cdbf5cf1 --- /dev/null +++ b/src/core/utils/process.spec.ts @@ -0,0 +1,242 @@ +import "../../../tests/_mocks/fs.js"; +import { childProcess } from "../../../tests/_mocks/internal-process.js"; +import { fs } from "memfs"; +import { EventEmitter } from "node:events"; +import os from "node:os"; +import process from "node:process"; +import { superviseProcesses, NamedExecutionSpec } from "./process.js"; + +const coreHook = vi.hoisted(() => ({ commands: [] as import("concurrently").Command[], options: vi.fn() })); +vi.mock("concurrently", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + concurrently: (...args: Parameters) => { + coreHook.options({ ...args[1] }); + const core = actual.concurrently(...args); + coreHook.commands = core.commands; + return core; + }, + }; +}); +vi.mock("./logger.js", () => ({ logger: { warn: vi.fn() } })); + +describe("direct process supervisor lifecycle (all launches/signals intercepted)", () => { + function child(pid: number) { + return Object.assign(new EventEmitter(), { pid, exitCode: null, signalCode: null }); + } + let first: ReturnType; + let second: ReturnType; + const commands: NamedExecutionSpec[] = [ + { + name: "first", + execution: { executable: "/trusted/node", args: ["literal space", ";"], cwd: "/project/app", env: { TEST_CONTEXT: "inherited" } }, + }, + { name: "second", execution: { executable: "/trusted/func", args: ["start"] } }, + ]; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(process, "kill").mockReturnValue(true); + first = child(10101); + second = child(10102); + childProcess.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("waits for all successful children rather than settling on the first close", async () => { + // Arrange + let settled = false; + const result = superviseProcesses(commands).then((events) => { + settled = true; + return events; + }); + // Act + first.emit("close", 0, null); + await Promise.resolve(); + expect(settled).toBe(false); + second.emit("close", 0, null); + // Assert + expect((await result).map((event) => event.command.name)).toEqual(["first", "second"]); + expect(process.kill).not.toHaveBeenCalled(); + }); + + it("waits for remaining children on ordinary nonzero exit without sibling termination", async () => { + // Arrange + let settled = false; + const result = superviseProcesses(commands).catch((events) => { + settled = true; + return events; + }); + // Act + first.emit("close", 2, null); + await Promise.resolve(); + expect(settled).toBe(false); + expect(process.kill).not.toHaveBeenCalled(); + second.emit("close", 0, null); + // Assert + expect((await result).map((event: { exitCode: number }) => event.exitCode)).toEqual([2, 0]); + }); + + it("passes direct argv, explicit cwd and inherited plus specified env without a shell", async () => { + // Arrange + const result = superviseProcesses(commands); + // Act + first.emit("close", 0, null); + second.emit("close", 0, null); + await result; + // Assert + expect(childProcess.spawn).toHaveBeenNthCalledWith( + 1, + "/trusted/node", + ["literal space", ";"], + expect.objectContaining({ + cwd: "/project/app", + env: { ...process.env, TEST_CONTEXT: "inherited" }, + stdio: "inherit", + shell: false, + windowsVerbatimArguments: undefined, + }), + ); + expect(childProcess.spawn.mock.calls[0][2]?.detached).not.toBe(true); + }); + + it("does not add parent signal listeners while the real named core owns running children", async () => { + // Arrange + const before = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + // Act: record while running, but close before asserting so RED cannot leak listeners. + const result = superviseProcesses(commands); + const running = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + first.emit("close", 0, null); + second.emit("close", 0, null); + await result; + // Assert + expect(running).toEqual(before); + }); + + it("leaves POSIX cleanup to the dependency default instead of installing a kill override", async () => { + // Arrange / Act + const result = superviseProcesses(commands); + const options = { ...coreHook.options.mock.calls[0][0] }; + first.emit("close", 0, null); + second.emit("close", 0, null); + await result; + // Assert: core fills defaults in place, so inspect what was supplied before forwarding. + expect(options.kill).toBeUndefined(); + }); + + it("waits for native close after an asynchronous spawn error without killing siblings", async () => { + // Arrange + const error = new Error("spawn failed"); + let settled = false; + const result = superviseProcesses(commands) + .catch((events) => events) + .then((events) => { + settled = true; + return events; + }); + // Act + first.emit("error", error); + await Promise.resolve(); + const beforeNativeClose = settled; + first.emit("close", 1, null); + second.emit("close", 0, null); + // Assert + expect((await result).map((event: { exitCode: number }) => event.exitCode)).toEqual([1, 0]); + expect(beforeNativeClose).toBe(false); + expect(process.kill).not.toHaveBeenCalled(); + }); + + it("leaves a synchronous spawn failure to the named core without adding rollback policy", () => { + // Arrange + childProcess.spawn + .mockReset() + .mockReturnValueOnce(first) + .mockImplementationOnce(() => { + throw new Error("sync spawn"); + }); + const launch = () => superviseProcesses(commands); + // Assert + expect(launch).toThrow("sync spawn"); + first.emit("close", 0, null); + expect(childProcess.spawn).toHaveBeenCalledTimes(2); + expect(process.kill).not.toHaveBeenCalled(); + }); + + it.each(["SIGINT", "SIGTERM"] as const)("keeps native %s closes core-owned without custom signal fields", async (signal) => { + // Arrange + const listeners = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + const result = superviseProcesses(commands).catch((events) => events); + // Act: native close notification only; no invented parent-signal controller. + first.emit("close", null, signal); + second.emit("close", null, signal); + const events = await result; + // Assert + expect(events.map((event: { exitCode: string; killed: boolean }) => [event.exitCode, event.killed])).toEqual([ + [signal, false], + [signal, false], + ]); + expect(process.kill).not.toHaveBeenCalled(); + expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(listeners); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not synthesize deadline completion before native close", async () => { + // Arrange + let settled = false; + const result = superviseProcesses(commands).then((events) => { + settled = true; + return events; + }); + // Act + await vi.advanceTimersByTimeAsync(10000); + const beforeNativeClose = settled; + first.emit("close", 0, null); + second.emit("close", 0, null); + await result; + // Assert + expect(beforeNativeClose).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses absolute System32 taskkill through actual core Command.kill despite CWD and ComSpec canaries", async () => { + // Arrange: simulated filesystem identities, no real cleanup executable. + vi.mocked(os.platform).mockReturnValue("win32"); + vi.stubEnv("SystemRoot", "C:\\Windows"); + vi.stubEnv("ComSpec", "C:\\project\\cmd.exe"); + vi.stubEnv("PATH", "C:\\trusted"); + vi.spyOn(fs, "realpathSync").mockImplementation(((file: unknown) => String(file)) as typeof fs.realpathSync); + vi.spyOn(fs, "statSync").mockReturnValue({ isFile: () => true } as any); + vi.spyOn(fs, "accessSync").mockReturnValue(undefined); + childProcess.execFile.mockImplementation((_file, _args, _options, callback) => callback(null)); + const windowsCommands = commands.map((command) => ({ ...command, execution: { ...command.execution, executable: "C:\\trusted\\node.exe" } })); + const result = superviseProcesses(windowsCommands).catch((events) => events); + // Act + coreHook.commands[0].kill("SIGINT"); + first.emit("close", 1, null); + second.emit("close", 1, null); + await result; + // Assert + expect(childProcess.execFile).toHaveBeenCalledWith( + "C:\\Windows\\System32\\taskkill.exe", + ["/PID", String(first.pid), "/T", "/F"], + { windowsHide: true }, + expect.any(Function), + ); + expect(process.kill).not.toHaveBeenCalled(); + expect(childProcess.execFile).toHaveBeenCalledTimes(1); + expect(childProcess.spawn.mock.calls[0][2]).toMatchObject({ shell: false }); + }); + + it("rejects empty or relative execution specs before launching", () => { + // Arrange / Act / Assert + expect(() => superviseProcesses([])).toThrow(/no commands/); + expect(() => superviseProcesses([{ name: "bad", execution: { executable: "node", args: [] } }])).toThrow(/absolute path/); + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/utils/process.ts b/src/core/utils/process.ts new file mode 100644 index 000000000..c5f40aa48 --- /dev/null +++ b/src/core/utils/process.ts @@ -0,0 +1,43 @@ +import { execFile, spawn } from "node:child_process"; +import { concurrently, CloseEvent } from "concurrently"; +import os from "node:os"; +import { getTrustedWindowsSystemExecutable, InternalExecutionSpec, prepareExecution } from "./executable.js"; +import { logger } from "./logger.js"; + +export interface NamedExecutionSpec { + name: string; + execution: InternalExecutionSpec; +} + +/** Bind native dispatch while leaving raw I/O and lifecycle handling to the named core. */ +export function superviseProcesses(commands: NamedExecutionSpec[]): Promise { + const executions = commands.map(({ execution }) => prepareExecution(execution)); + const taskkill = os.platform() === "win32" ? getTrustedWindowsSystemExecutable("taskkill.exe") : undefined; + const core = concurrently( + commands.map(({ name }, index) => ({ name, command: `swa-internal-${index}` })), + { + raw: true, + ...(taskkill + ? { + kill(pid: number) { + execFile(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (error) => { + if (error) logger.warn("Unable to stop a child process tree. Check the running emulator/tool processes."); + }); + }, + } + : {}), + spawn(key, spawnOptions) { + const execution = executions[Number(key.slice("swa-internal-".length))]; + if (!execution) throw new Error("Unknown internal execution identity."); + return spawn(execution.executable, execution.args, { + ...spawnOptions, + cwd: execution.cwd ?? spawnOptions.cwd, + env: execution.env ?? spawnOptions.env, + shell: false, + windowsVerbatimArguments: execution.windowsVerbatimArguments, + }); + }, + }, + ); + return core.result; +} diff --git a/tests/_mocks/internal-process.d.ts b/tests/_mocks/internal-process.d.ts new file mode 100644 index 000000000..815f9274a --- /dev/null +++ b/tests/_mocks/internal-process.d.ts @@ -0,0 +1,5 @@ +import { Mock } from "vitest"; + +export const childProcess: Record<"exec" | "execFile" | "execSync" | "execFileSync" | "spawn" | "spawnSync" | "fork", Mock>; +export function mockInternalProcesses(stdout?: string, error?: Error): void; +export function directLaunches(): { file: string; args: string[]; options: { cwd?: string; shell?: boolean; env?: Record } }[]; diff --git a/tests/_mocks/internal-process.js b/tests/_mocks/internal-process.js new file mode 100644 index 000000000..ddda21e3e --- /dev/null +++ b/tests/_mocks/internal-process.js @@ -0,0 +1,72 @@ +import { vi } from "vitest"; +import { promisify } from "node:util"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +// No fixture is ever executed. Capture both Node specifier forms, including +// APIs a future implementation may choose instead of exec. +const childProcess = vi.hoisted(() => ({ + exec: vi.fn(), + execFile: vi.fn(), + execSync: vi.fn(), + execFileSync: vi.fn(), + spawn: vi.fn(), + spawnSync: vi.fn(), + fork: vi.fn(), +})); +export { childProcess }; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...childProcess, default: { ...actual, ...childProcess } }; +}); +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ...childProcess, default: { ...actual, ...childProcess } }; +}); + +export function mockInternalProcesses(stdout = "", error = undefined) { + const makeChild = () => { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.kill = vi.fn(); + child.pid = 12345; + queueMicrotask(() => { + child.stdout.end(stdout); + child.stderr.end(); + child.emit("exit", error ? 1 : 0, null); + child.emit("close", error ? 1 : 0, null); + }); + return child; + }; + for (const fn of [childProcess.exec, childProcess.execFile]) { + fn.mockImplementation((...args) => { + const callback = args.at(-1); + if (typeof callback === "function") callback(error ?? null, stdout, ""); + return makeChild(); + }); + // Node's exec/execFile promisification returns {stdout, stderr}, not just + // stdout. Keep that contract while recording the original launch. + fn[promisify.custom] = (...args) => + new Promise((resolve, reject) => { + fn(...args, (err, out, stderr) => (err ? reject(err) : resolve({ stdout: out, stderr }))); + }); + } + for (const fn of [childProcess.execSync, childProcess.execFileSync]) { + fn.mockImplementation(() => { + if (error) throw error; + return Buffer.from(stdout); + }); + } + childProcess.spawn.mockImplementation(makeChild); + childProcess.fork.mockImplementation(makeChild); + childProcess.spawnSync.mockReturnValue({ status: error ? 1 : 0, stdout: Buffer.from(stdout), stderr: Buffer.alloc(0), error }); +} + +export function directLaunches() { + return [childProcess.execFile, childProcess.execFileSync, childProcess.spawn, childProcess.spawnSync].flatMap((fn) => + fn.mock.calls.map(([file, args, options]) => ({ file, args, options })), + ); +} diff --git a/tests/_mocks/posix.d.ts b/tests/_mocks/posix.d.ts new file mode 100644 index 000000000..c003826d6 --- /dev/null +++ b/tests/_mocks/posix.d.ts @@ -0,0 +1 @@ +export const posix: { cwd: string | undefined; path: (file: string) => string }; diff --git a/tests/_mocks/posix.js b/tests/_mocks/posix.js new file mode 100644 index 000000000..a57015805 --- /dev/null +++ b/tests/_mocks/posix.js @@ -0,0 +1,56 @@ +import { afterAll, vi } from "vitest"; +import process from "node:process"; + +// Import only in suites with POSIX fixtures, before importing the code under test. +// os.platform alone leaves native Windows path operations and captured constants +// inconsistent with those fixtures. This is simulation, not native Windows proof. +const fixture = vi.hoisted(() => ({ + cwd: undefined, + // memfs uses the same drive-free, slash-separated namespace on Windows. + path: (file) => file.replace(/\\/g, "/").replace(/^[a-z]:/i, ""), +})); +export { fixture as posix }; + +vi.mock("node:process", async (importOriginal) => { + const actual = await importOriginal(); + // Keep inherited EventEmitter APIs (on/removeListener are not own properties). + const process = Object.assign(Object.create(actual.default), { + cwd: () => fixture.cwd ?? fixture.path(actual.default.cwd()), + execPath: fixture.path(actual.default.execPath), + }); + // Match named and default imports, including module-time cwd capture. + return { ...actual, cwd: process.cwd, execPath: process.execPath, default: process }; +}); + +// Some CLI modules use global process, others import it. They must share env, +// execPath and exit spies. This is worker-local and restored after the suite. +vi.stubGlobal("process", process); +afterAll(() => vi.unstubAllGlobals()); + +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + const process = (await import("node:process")).default; + const path = { + ...actual.posix, + resolve: (...parts) => actual.posix.resolve(process.cwd(), ...parts), + // Keep explicit Windows simulation available to the shim identity cases. + win32: actual.win32, + }; + return { ...path, default: path }; +}); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + const os = { + ...actual.default, + platform: () => "linux", + homedir: () => fixture.path(actual.default.homedir()), + }; + return { ...actual, ...os, default: os }; +}); + +vi.mock("node:url", async (importOriginal) => { + const actual = await importOriginal(); + const fileURLToPath = (...args) => fixture.path(actual.fileURLToPath(...args)); + return { ...actual, fileURLToPath, default: { ...actual.default, fileURLToPath } }; +}); diff --git a/tests/fixtures/compatibility-recorder.cjs b/tests/fixtures/compatibility-recorder.cjs new file mode 100644 index 000000000..c89a10609 --- /dev/null +++ b/tests/fixtures/compatibility-recorder.cjs @@ -0,0 +1,18 @@ +const fs = require("node:fs"); + +if (!process.env.SWA_COMPAT_REPORT) throw new Error("SWA_COMPAT_REPORT is required."); + +const args = process.argv.slice(2); +fs.appendFileSync( + process.env.SWA_COMPAT_REPORT, + JSON.stringify({ + execPath: process.execPath, + args, + cwd: process.cwd(), + wrapper: process.env.SWA_COMPAT_WRAPPER ?? null, + probe: process.env.SWA_COMPAT_PROBE ?? null, + context: process.env.SWA_COMPAT_CONTEXT ?? null, + }) + "\n", +); +console.log(args.includes("--version") ? process.env.SWA_COMPAT_VERSION : "compatibility wrapper stdout"); +process.exitCode = args.includes("--version") ? 0 : Number(process.env.SWA_COMPAT_EXIT ?? "0");