From 1d030dbea93b8acedaad174f4fd88b4c3ffc3762 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Tue, 13 Oct 2026 03:44:51 +0000 Subject: [PATCH 1/8] fix(core): bind internal tool execution to trusted identities Prevent project-controlled executable discovery in automatic CLI workflows. Bind internal probes, installs and service launches to absolute identities with literal arguments while preserving deliberate user shell commands. Generate local TLS certificates in process to remove external tool discovery. Retain regression coverage for project scope, wrapper handling and owned process cleanup, and document the compatibility boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/www/docs/cli/swa-build.md | 6 + docs/www/docs/cli/swa-start.md | 4 + package-lock.json | 95 ------ package.json | 1 - src/cli/commands/build/build.security.spec.ts | 66 ++++ src/cli/commands/build/build.spec.ts | 46 ++- src/cli/commands/build/build.ts | 21 +- src/cli/commands/db/init/init.ts | 6 +- src/cli/commands/start/start.security.spec.ts | 159 +++++++++ src/cli/commands/start/start.ts | 170 +++++----- src/cli/index.security.spec.ts | 64 ++++ src/cli/index.ts | 22 +- src/core/dataApiBuilder/dab.ts | 85 +++-- src/core/dataApiBuilder/index.ts | 5 +- src/core/func-core-tools.spec.ts | 18 +- src/core/func-core-tools.ts | 55 +++- src/core/internal-tools.security.spec.ts | 96 ++++++ src/core/ssl.security.spec.ts | 150 +++++++++ src/core/ssl.ts | 81 +++-- src/core/utils/arguments.security.spec.ts | 23 ++ src/core/utils/cli-config.ts | 71 +++- src/core/utils/command.security.spec.ts | 31 ++ src/core/utils/command.ts | 22 +- src/core/utils/executable.security.spec.ts | 206 ++++++++++++ src/core/utils/executable.ts | 305 ++++++++++++++++++ src/core/utils/process.security.spec.ts | 183 +++++++++++ src/core/utils/process.ts | 150 +++++++++ src/core/utils/project-roots.security.spec.ts | 182 +++++++++++ src/core/utils/strings.ts | 39 +++ src/swa.d.ts | 1 - tests/_mocks/internal-process.d.ts | 5 + tests/_mocks/internal-process.js | 72 +++++ 32 files changed, 2123 insertions(+), 317 deletions(-) create mode 100644 src/cli/commands/build/build.security.spec.ts create mode 100644 src/cli/commands/start/start.security.spec.ts create mode 100644 src/cli/index.security.spec.ts create mode 100644 src/core/internal-tools.security.spec.ts create mode 100644 src/core/ssl.security.spec.ts create mode 100644 src/core/utils/arguments.security.spec.ts create mode 100644 src/core/utils/command.security.spec.ts create mode 100644 src/core/utils/executable.security.spec.ts create mode 100644 src/core/utils/executable.ts create mode 100644 src/core/utils/process.security.spec.ts create mode 100644 src/core/utils/process.ts create mode 100644 src/core/utils/project-roots.security.spec.ts create mode 100644 tests/_mocks/internal-process.d.ts create mode 100644 tests/_mocks/internal-process.js diff --git a/docs/www/docs/cli/swa-build.md b/docs/www/docs/cli/swa-build.md index 8b9f23792..06a7bb1c2 100644 --- a/docs/www/docs/cli/swa-build.md +++ b/docs/www/docs/cli/swa-build.md @@ -22,6 +22,12 @@ Installing dependencies is only supported for Node.js projects with a `package.j For other frameworks, you need to manually install dependencies. ::: +Automatic installation uses a global native npm, Yarn or pnpm installation, or its standard Node/Corepack entrypoint, outside your project directories. Standard Windows npm/Yarn/pnpm shims are supported through their installed Node entrypoints. + +If the CLI reports an unsupported custom wrapper, install the package manager globally outside the project or enable its standard Corepack shim. Project-local executables and arbitrary shell wrappers are not used for automatic installation. Explicit app/API build commands remain shell commands and can use your normal project scripts. + +Automatic installation invokes the selected installation's Node entrypoint directly; custom shell-wrapper flags or redirects are not applied. Run workflows that depend on those customizations manually or from an explicit build command. + ## Options Here are the options you can use with `swa build`: diff --git a/docs/www/docs/cli/swa-start.md b/docs/www/docs/cli/swa-start.md index 778e254a7..ade6d1b64 100644 --- a/docs/www/docs/cli/swa-start.md +++ b/docs/www/docs/cli/swa-start.md @@ -75,6 +75,10 @@ Then access the application with the emulated services from `http://localhost:42 If your project includes API functions, the CLI will check if the Azure Functions Core Tools are installed and available. If not, the CLI will download and install the right version of the Azure Functions Core Tools. +Installed Functions and Data API Builder tools must be native executables outside project directories; unsupported command wrappers use the CLI-managed download instead. You can also start a tool yourself and connect with `--api-devserver-url` or `--data-api-devserver-url`. + +`--func-args` is parsed as literal arguments, not a shell command. Use quotes to keep a value containing spaces together, for example `--func-args '--language-worker "argument with spaces"'`. Single/double quotes and escaped quotes/whitespace are supported; unmatched quotes are an error. Variables, wildcards and shell operators are not expanded. The `--run` option remains an intentional shell command, including npm/npx/Yarn shortcuts. + #### Start the API server automatically Run the CLI and provide the folder that contains the API backend (a valid Azure Functions App project): diff --git a/package-lock.json b/package-lock.json index 2e47f0c0c..089eb0b37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "open": "^8.4.2", "openid-client": "^6.7.1", "ora": "^5.4.1", - "pem": "^1.14.8", "prompts": "^2.4.2", "rimraf": "^5.0.7", "selfsigned": "^2.4.1", @@ -4076,14 +4075,6 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "engines": { - "node": "*" - } - }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -4564,14 +4555,6 @@ "node": ">= 8" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "engines": { - "node": "*" - } - }, "node_modules/crypto-random-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", @@ -5449,14 +5432,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-promisify": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", - "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -6906,11 +6881,6 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -8113,16 +8083,6 @@ "node": ">=14.18" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, "node_modules/memfs": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.9.3.tgz", @@ -11629,20 +11589,6 @@ "node": ">=0.10" } }, - "node_modules/pem": { - "version": "1.14.8", - "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.8.tgz", - "integrity": "sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==", - "dependencies": { - "es6-promisify": "^7.0.0", - "md5": "^2.3.0", - "os-tmpdir": "^1.0.2", - "which": "^2.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -17662,11 +17608,6 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, - "charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" - }, "check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -18021,11 +17962,6 @@ "which": "^2.0.1" } }, - "crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" - }, "crypto-random-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", @@ -18687,11 +18623,6 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true }, - "es6-promisify": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", - "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==" - }, "esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -19739,11 +19670,6 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, "is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -20661,16 +20587,6 @@ } } }, - "md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "requires": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, "memfs": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.9.3.tgz", @@ -23028,17 +22944,6 @@ "integrity": "sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==", "dev": true }, - "pem": { - "version": "1.14.8", - "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.8.tgz", - "integrity": "sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==", - "requires": { - "es6-promisify": "^7.0.0", - "md5": "^2.3.0", - "os-tmpdir": "^1.0.2", - "which": "^2.0.2" - } - }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index f2b3c8440..006fb2ace 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "open": "^8.4.2", "openid-client": "^6.7.1", "ora": "^5.4.1", - "pem": "^1.14.8", "prompts": "^2.4.2", "rimraf": "^5.0.7", "selfsigned": "^2.4.1", diff --git a/src/cli/commands/build/build.security.spec.ts b/src/cli/commands/build/build.security.spec.ts new file mode 100644 index 000000000..0a1520cbc --- /dev/null +++ b/src/cli/commands/build/build.security.spec.ts @@ -0,0 +1,66 @@ +import "../../../../tests/_mocks/fs.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../../../tests/_mocks/internal-process.js"; +import { vol, fs } from "memfs"; +import process from "node:process"; +import { build } from "./build.js"; +import { DEFAULT_CONFIG } from "../../../config.js"; + +vi.mock("../../../core/utils/workflow-config.js", () => ({ readWorkflowFile: () => undefined })); +vi.mock("../../../core/utils/options.js", () => ({ isUserOrConfigOption: () => false })); +vi.mock("../../../core/utils/file.js", async (importOriginal) => ({ + ...(await importOriginal()), + // Isolate installation policy from the unrelated finder, which strips + // the leading slash from absolute paths in the current implementation. + findUpPackageJsonDir: async () => "/workspace/app", +})); +vi.mock("../../../core/utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() }, +})); +vi.mock("../../../core/frameworks/detect.js", () => ({ detectProjectFolders: vi.fn(), generateConfiguration: vi.fn() })); + +describe("automatic dependency installs are internal, user build strings are deliberate", () => { + beforeEach(() => { + vol.reset(); + mockInternalProcesses(); + vi.spyOn(process, "cwd").mockReturnValue("/workspace"); + vi.stubEnv("PATH", "/workspace:.:/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", + }); + 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 its absolute identity rather than a project shell", async (manager, lock) => { + vol.fromJSON({ [`/workspace/app/${lock}`]: "" }); + 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`); + expect(directLaunches()).toContainEqual( + expect.objectContaining({ + file: `/trusted/bin/${manager}`, + args: ["install"], + options: expect.objectContaining({ cwd: "/workspace/app" }), + }), + ); + }); + + 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.spec.ts b/src/cli/commands/build/build.spec.ts index 0905b1030..25c2e752e 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -1,5 +1,7 @@ import "../../../../tests/_mocks/fs.js"; -import { vol } from "memfs"; +import { vol, fs } from "memfs"; +import path from "node:path"; +import process from "node:process"; import { build } from "./build.js"; import { DEFAULT_CONFIG } from "../../../config.js"; import { convertToNativePaths } from "../../../test.helpers.js"; @@ -19,14 +21,23 @@ vi.mock("../../../core/utils/command.js"); describe("swa build", () => { beforeEach(() => { 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(); + }); + + const contextFor = (cwd: string) => ({ untrustedRoots: expect.arrayContaining([path.resolve(cwd)]) }); + it("should run app build command", async () => { const command = await import("../../../core/utils/command.js"); command.runCommand = vi.fn().mockImplementation(vi.fn()); await build({ ...DEFAULT_CONFIG, appBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); }); it("should run npm install before build command", async () => { @@ -36,8 +47,12 @@ describe("swa build", () => { await build({ ...DEFAULT_CONFIG, appBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm install", "."); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); + 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", ".", contextFor(".")); + 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 +66,12 @@ describe("swa build", () => { appBuildCommand: "npm run something", }); - expect(command.runCommand).toHaveBeenCalledWith("npm install", "app"); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); + 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", ".", contextFor(".")); + expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(command.runCommand).mock.invocationCallOrder[0], + ); }); it("should run api build command", async () => { @@ -60,7 +79,7 @@ describe("swa build", () => { command.runCommand = vi.fn().mockImplementation(vi.fn()); await build({ ...DEFAULT_CONFIG, apiLocation: "api", apiBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); }); it("should run npm install before build command", async () => { @@ -70,9 +89,12 @@ 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.runCommand).toHaveBeenCalledWith("npm run something", "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", contextFor("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,7 +108,9 @@ describe("swa build", () => { apiBuildCommand: "npm run something", }); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "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", contextFor("api")); }); it("should run nothing", async () => { diff --git a/src/cli/commands/build/build.ts b/src/cli/commands/build/build.ts index 1a927ccad..732c39840 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 { swaCliConfigFilename } from "../../../core/utils/cli-config.js"; +import { execInternalCommand, runCommand } from "../../../core/utils/command.js"; +import { getProjectRoots, swaCliConfigFilename } from "../../../core/utils/cli-config.js"; +import { ExecutableResolutionOptions, resolveTrustedPackageManager } from "../../../core/utils/executable.js"; export async function build(options: SWACLIConfig) { const workflowConfig = readWorkflowFile(); @@ -79,15 +80,17 @@ export async function build(options: SWACLIConfig) { logger.log(`- App build command: ${chalk.green(appBuildCommand || "")}`); logger.log(`- API build command: ${chalk.green(apiBuildCommand || "")}`); + const resolvedOutput = appLocation && outputLocation ? path.resolve(appLocation, outputLocation) : undefined; + const resolutionOptions = { untrustedRoots: getProjectRoots(options, [appLocation, apiLocation, resolvedOutput]) }; if (appBuildCommand) { const packageJsonPath = await findUpPackageJsonDir(appLocation!, outputLocation!); if (packageJsonPath) { logger.log(`Found package.json in ${packageJsonPath}`); - await installNpmDependencies(packageJsonPath); + await installNpmDependencies(packageJsonPath, resolutionOptions); } logger.log(`Building app with ${chalk.green(appBuildCommand)} in ${chalk.dim(appLocation)} ...`); - runCommand(appBuildCommand, appLocation!); + runCommand(appBuildCommand, appLocation!, resolutionOptions); } if (apiBuildCommand) { @@ -95,11 +98,11 @@ export async function build(options: SWACLIConfig) { const packageJsonPath = await findUpPackageJsonDir(apiLocation!, "."); if (packageJsonPath) { logger.log(`Found package.json in ${packageJsonPath}`); - await installNpmDependencies(packageJsonPath); + await installNpmDependencies(packageJsonPath, resolutionOptions); } logger.log(`Building api with ${chalk.green(apiBuildCommand)} in ${chalk.dim(apiLocation)} ...`); - runCommand(apiBuildCommand, apiLocation!); + runCommand(apiBuildCommand, apiLocation!, resolutionOptions); } } @@ -131,13 +134,15 @@ async function detectPackageManager(basePath: string): Promise { +async function installNpmDependencies(packageJsonPath: string, options: ExecutableResolutionOptions): Promise { try { const packageManager = await detectPackageManager(packageJsonPath); logger.log(`Installing dependencies with "${packageManager} install"...`); - runCommand(`${packageManager} install`, packageJsonPath); + const spec = resolveTrustedPackageManager(packageManager, { ...options, untrustedRoots: [...(options.untrustedRoots ?? []), packageJsonPath] }); + execInternalCommand({ ...spec, args: [...spec.args, "install"], cwd: path.resolve(packageJsonPath) }); } catch (error) { logger.error(`Cannot install dependencies:`); logger.error(error as Error, true); + throw error; } } diff --git a/src/cli/commands/db/init/init.ts b/src/cli/commands/db/init/init.ts index e5db9ef00..757423275 100644 --- a/src/cli/commands/db/init/init.ts +++ b/src/cli/commands/db/init/init.ts @@ -12,6 +12,7 @@ import { import { execFileCommand } from "../../../../core/utils/command.js"; import { logger } from "../../../../core/utils/logger.js"; import { getDataApiBuilderBinaryPath } from "../../../../core/dataApiBuilder/index.js"; +import { getProjectRoots } from "../../../../core/utils/cli-config.js"; export async function init(options: SWACLIConfig) { let { databaseType, connectionString, cosmosdb_nosqlContainer, cosmosdb_nosqlDatabase } = options; @@ -44,13 +45,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({ untrustedRoots: getProjectRoots(options, [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[] = [ diff --git a/src/cli/commands/start/start.security.spec.ts b/src/cli/commands/start/start.security.spec.ts new file mode 100644 index 000000000..a522ed113 --- /dev/null +++ b/src/cli/commands/start/start.security.spec.ts @@ -0,0 +1,159 @@ +import "../../../../tests/_mocks/fs.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 { concurrently } from "concurrently"; +import { start } from "./start.js"; +import { DEFAULT_CONFIG } from "../../../config.js"; +import { getCertificate } from "../../../core/ssl.js"; + +vi.mock("concurrently", () => ({ concurrently: vi.fn() })); +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("../../../core/func-core-tools.js", () => ({ + getCoreToolsBinary: async () => "/trusted tools/func", + getNodeMajorVersion: () => 20, + detectTargetCoreToolsVersion: () => 4, + isCoreToolsVersionCompatible: () => true, +})); +vi.mock("../../../core/dataApiBuilder/index.js", () => ({ getDataApiBuilderBinaryPath: async () => "/trusted tools/dab" })); +vi.mock("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); +vi.mock("../../../core/utils/logger.js", () => ({ + logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn() }, +})); + +describe("start internal launches versus intentional customer commands", () => { + 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, + }; + + beforeEach(() => { + 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.mocked(concurrently).mockReturnValue({ result: Promise.resolve([]) } as any); + vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function internalShellCommands() { + return vi + .mocked(concurrently) + .mock.calls.flatMap(([commands]) => + (commands as { name?: string; command: string }[]).filter((command) => ["swa", "api", "dataApi"].includes(command.name ?? "")), + ); + } + + it("launches the emulator using current Node and literal server argv without the default shell adapter", async () => { + await start(options); + expect( + internalShellCommands() + .filter((command) => command.name === "swa") + .map((command) => command.command), + ).toEqual([]); + const emulator = directLaunches().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!.options?.shell).not.toBe(true); + }); + + it("launches Functions with the selected absolute identity, literal args and explicit API cwd", async () => { + await start(options); + expect(directLaunches()).toContainEqual( + expect.objectContaining({ + file: "/trusted tools/func", + args: ["start", "--cors", "*", "--port", "7071"], + options: expect.objectContaining({ cwd: options.apiLocation }), + }), + ); + expect( + internalShellCommands() + .filter((command) => command.name === "api") + .map((command) => command.command), + ).toEqual([]); + }); + + it("passes funcArgs quotes and metacharacters as literal argv through the Functions launch", async () => { + // Arrange + const funcArgs = '--name "two words" "" literal&value $VALUE'; + // Act + await start({ ...options, funcArgs }); + // Assert + expect(directLaunches()).toContainEqual( + expect.objectContaining({ + file: "/trusted tools/func", + args: ["start", "--cors", "*", "--port", "7071", "--name", "two words", "", "literal&value", "$VALUE"], + options: expect.objectContaining({ cwd: options.apiLocation, shell: false }), + }), + ); + }); + + it("rejects malformed funcArgs before spawning any emulator or tool", async () => { + // Arrange / Act + await start({ ...options, funcArgs: '"unclosed' }); + // Assert + expect(childProcess.spawn).not.toHaveBeenCalled(); + }); + + it("launches DAB with the selected absolute identity, literal args and explicit data cwd", async () => { + await start(options); + expect(directLaunches()).toContainEqual( + expect.objectContaining({ + file: "/trusted tools/dab", + args: ["start", "-c", "staticwebapp.database.config.json", "--no-https-redirect"], + options: expect.objectContaining({ cwd: options.dataApiLocation }), + }), + ); + expect( + internalShellCommands() + .filter((command) => command.name === "dataApi") + .map((command) => command.command), + ).toEqual([]); + }); + + 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 = [ + ...vi + .mocked(concurrently) + .mock.calls.flatMap(([commands]) => + (commands as { name?: string; command: string }[]).filter((command) => command.name === "run").map((command) => command.command), + ), + ...childProcess.exec.mock.calls.map(([command]) => command), + ...directLaunches().flatMap((launch) => launch.args), + ]; + expect(shells.some((command) => command.includes(run))).toBe(true); + expect(directLaunches()).toContainEqual( + expect.objectContaining({ file: "/bin/sh", args: ["-c", run], options: expect.objectContaining({ 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"); + }); +}); diff --git a/src/cli/commands/start/start.ts b/src/cli/commands/start/start.ts index c3552f2c7..f8acfe2be 100644 --- a/src/cli/commands/start/start.ts +++ b/src/cli/commands/start/start.ts @@ -1,4 +1,3 @@ -import { concurrently, CloseEvent, ConcurrentlyOptions } from "concurrently"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,6 +17,10 @@ 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 { getProjectRoots } from "../../../core/utils/cli-config.js"; +import { createShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; +import { NamedExecutionSpec, ProcessCloseEvent, superviseProcesses } from "../../../core/utils/process.js"; +import { parseCommandArguments } from "../../../core/utils/strings.js"; const packageInfo = loadPackageJson(); @@ -63,6 +66,15 @@ 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 funcArgv: string[]; + try { + funcArgv = parseCommandArguments(funcArgs ?? ""); + } catch (error) { + process.exitCode = 1; + logger.error(`Invalid --func-args: ${(error as Error).message}`, true); + return; + } let resolvedPortNumber = await isAcceptingTcpConnections({ host, port }); if (resolvedPortNumber === 0) { @@ -167,19 +179,29 @@ export async function start(options: SWACLIConfig) { } const isApiLocationExistsOnDisk = fs.existsSync(userWorkflowConfig?.apiLocation!); + const resolutionOptions = { + untrustedRoots: getProjectRoots(options, [ + appLocation, + outputLocation, + userWorkflowConfig?.appLocation, + userWorkflowConfig?.apiLocation, + userWorkflowConfig?.dataApiLocation, + dataApiLocation, + ]), + }; // 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(); + const funcBinary = await getCoreToolsBinary(resolutionOptions); const nodeMajorVersion = getNodeMajorVersion(); const targetVersion = detectTargetCoreToolsVersion(nodeMajorVersion); @@ -192,6 +214,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( @@ -199,25 +222,29 @@ 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) { - serveApiCommand = `cd "${userWorkflowConfig.apiLocation}" && "${funcBinary}" start --cors "*" --port ${apiPort} ${funcArgs ?? ""}`; + serveApi = { + executable: funcBinary, + args: ["start", "--cors", "*", "--port", String(apiPort), ...funcArgv], + cwd: path.resolve(userWorkflowConfig.apiLocation), + }; } } } } - 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(resolutionOptions); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -225,14 +252,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) { @@ -256,8 +287,11 @@ export async function start(options: SWACLIConfig) { } if (run) { - startupCommand = createStartupScriptCommand(run, options); + startupCommand = createStartupScriptCommand(run, options) ?? run; } + const startup = startupCommand + ? createShellExecutionSpec(startupCommand, path.resolve(userWorkflowConfig?.appLocation ?? appLocation), resolutionOptions) + : undefined; // resolve the following config to their absolute paths // note: the server will perform a search starting from this path @@ -296,94 +330,44 @@ 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" }, - ]; - - 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; + const env: NodeJS.ProcessEnv = { ...process.env, ...swaCLIEnv() }; + const commands: NamedExecutionSpec[] = [{ name: "swa", execution: { executable: process.execPath, args: [mshaPath], cwd: launchCwd, env } }]; - concurrentlyCommands.push({ command: `cd "${startupPath}" && ${startupCommand}`, name: "run", env, prefixColor: "gray.dim" }); - } + if (serveApi) commands.push({ name: "api", execution: { ...serveApi, env } }); + if (serveDataApi) commands.push({ name: "dataApi", execution: { ...serveDataApi, env } }); + if (startup) commands.push({ name: "run", execution: { ...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: 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 }; - const { result } = concurrently(concurrentlyCommands, concurrentlyOptions); - - await result - .then( - (errorEvent: CloseEvent[]) => { - const killedCommand = errorEvent.filter((event) => event.killed).pop(); - const exitCode = killedCommand?.exitCode; - logger.silly(`SWA emulator exited with code ${exitCode}`); - process.exit(); - }, - (errorEvent: CloseEvent[]) => { - const killedCommand = errorEvent.filter((event) => event.killed).pop(); - const commandName = killedCommand?.command.name; - const exitCode = killedCommand?.exitCode; - let commandMessage = ``; - switch (commandName) { - case "swa": - commandMessage = `SWA emulator exited with code ${exitCode}`; - break; - case "api": - commandMessage = `API server exited with code ${exitCode}`; - break; - case "dataApi": - commandMessage = `Data API server exited with code ${exitCode}`; - break; - case "run": - commandMessage = `the --run command exited with code ${exitCode}`; - break; - } - logger.error(`SWA emulator stopped because ${commandMessage}.`, true); - }, - ) - .catch((err: Error) => { - logger.error(err.message, true); - }); + try { + await superviseProcesses(commands, resolutionOptions); + logger.silly("SWA emulator exited with code 0"); + process.exit(); + } catch (error) { + process.exitCode = 1; + if (!Array.isArray(error)) { + logger.error((error as Error).message, true); + return; + } + const events = error as ProcessCloseEvent[]; + const interrupted = events.find((event) => event.terminationSignal); + if (interrupted && !events.some((event) => event.error)) { + process.exit(interrupted.terminationSignal === "SIGINT" ? 130 : 143); + return; + } + const failure = events.find((event) => event.error) ?? events.find((event) => event.exitCode !== 0); + const labels: Record = { swa: "SWA emulator", api: "API server", dataApi: "Data API server", run: "the --run command" }; + if (failure?.error) logger.error(failure.error); + logger.error(`SWA emulator stopped because ${labels[failure?.command.name ?? "swa"]} exited with code ${failure?.exitCode}.`, true); + } } diff --git a/src/cli/index.security.spec.ts b/src/cli/index.security.spec.ts new file mode 100644 index 000000000..6ea5e3a57 --- /dev/null +++ b/src/cli/index.security.spec.ts @@ -0,0 +1,64 @@ +import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; +import process from "node:process"; +import path from "node:path"; +import { swaMagic } from "./index.js"; +import { getCurrentSwaCliConfigFromFile } from "../core/utils/cli-config.js"; +import { promptOrUseDefault } from "../core/prompts.js"; + +// Keep the test at swaMagic's public boundary without importing login, +// credential stores, HTTP servers, or SSL initialization. +vi.mock("./commands/deploy/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/init/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/login/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/start/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/build/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/db/init/register.js", () => ({ default: vi.fn() })); +vi.mock("./commands/docs.js", () => ({ registerDocs: vi.fn() })); +vi.mock("../core/utils/update-notifier.js", () => ({ notifyOnUpdate: vi.fn() })); +vi.mock("../core/func-core-tools.js", () => ({ getNodeMajorVersion: () => 20 })); +vi.mock("../core/utils/options.js", () => ({ configureOptions: vi.fn() })); +vi.mock("../core/utils/cli-config.js", () => ({ getCurrentSwaCliConfigFromFile: vi.fn(), swaCliConfigFilename: "swa-cli.config.json" })); +vi.mock("../core/prompts.js", () => ({ promptOrUseDefault: vi.fn() })); +vi.mock("../core/utils/logger.js", () => ({ logger: { log: vi.fn(), error: vi.fn() } })); + +describe("magic-flow same-installation reentry", () => { + beforeEach(() => { + mockInternalProcesses(); + vi.spyOn(process, "cwd").mockReturnValue("/project with spaces"); + vi.mocked(getCurrentSwaCliConfigFromFile).mockReturnValue(undefined); + vi.mocked(promptOrUseDefault).mockResolvedValue({ deploy: true }); + }); + + 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$/); + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index bf855a931..d78706006 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"; @@ -71,7 +72,7 @@ export async function run(argv?: string[]) { .option("-g, --print-config", "print all resolved options", false) .action(async (_options: SWACLIConfig, command: Command) => { const options = await configureOptions(undefined, command.optsWithGlobals(), command, "init"); - swaMagic(options); + await swaMagic(options); }) .addHelpText( "after", @@ -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,9 +127,10 @@ export async function swaMagic(_options: SWACLIConfig) { } logger.log(`\n${chalk.cyan("→")} Running ${chalk.cyan("swa deploy")}...\n`); - runCommand("swa deploy"); - } catch (_) { - // Pokemon, go catch'em all! - // (Errors are already caught an displayed in individual commands) + reenter("deploy"); + } catch (error) { + logger.error("SWA workflow stopped before completion."); + logger.error(error as Error); + process.exitCode = 1; } } diff --git a/src/core/dataApiBuilder/dab.ts b/src/core/dataApiBuilder/dab.ts index 750bba6ca..021eba5ab 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,13 @@ 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, + resolveManagedExecutable, + resolveTrustedExecutable, + UnsupportedExecutableError, +} from "../utils/executable.js"; /** * Gets the filepath where the Microsoft.DataApiBuilder.exe is located @@ -27,7 +32,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 +42,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, options) }; } /** @@ -60,10 +56,14 @@ export async function installAndGetDataApiBuilder(): Promise<{ binaryPath: strin * @param platform Current OS * @returns Binary Path after downloading and extracting */ -async function downloadAndUnzipBinary(releaseMetadata: DataApiBuilderReleaseMetadata, platform: "win-x64" | "osx-x64" | "linux-x64") { +async function downloadAndUnzipBinary( + releaseMetadata: DataApiBuilderReleaseMetadata, + platform: "win-x64" | "osx-x64" | "linux-x64", + options: ExecutableResolutionOptions, +) { try { - const destDirectory = path.join(DATA_API_BUILDER_FOLDER, releaseMetadata.versionId); - const binaryPath = path.join(destDirectory, getDefaultDataApiBuilderBinaryForOS(platform)); + const destDirectory = path.resolve(DATA_API_BUILDER_FOLDER, releaseMetadata.versionId); + const binaryPath = resolveManagedExecutable(path.join(destDirectory, getDefaultDataApiBuilderBinaryForOS(platform)), options); if (!fs.existsSync(binaryPath)) { logger.silly(`Downloading the version ${releaseMetadata.versionId}`); @@ -83,10 +83,10 @@ async function downloadAndUnzipBinary(releaseMetadata: DataApiBuilderReleaseMeta fs.chmodSync(binaryPath, 0o755); } - return binaryPath; + return resolveManagedExecutable(binaryPath, options); } catch (ex) { - logger.error(`Unable to download/extract ${DATA_API_BUILDER_BINARY_NAME} binary. Exception ${ex}`); - return undefined; + logger.error(`Unable to download/extract ${DATA_API_BUILDER_BINARY_NAME} binary.`); + throw new Error(`Unable to install ${DATA_API_BUILDER_BINARY_NAME}. Check your network connection and retry. ${(ex as Error).message}`); } } @@ -112,24 +112,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,18 +145,27 @@ export function getDefaultDataApiBuilderBinaryForOS(platform: string): string { /** * Returns installed version if installed else undefined - * @param command package to know the version + * @param options Additional project-controlled roots to exclude from discovery * @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`); + let binaryPath: string | undefined; try { - const { stdout } = await promisify(exec)(`${command} --version`); - const version = stdout.split(" ")[1].split("\r")[0]; // parsing output which looks like this "Microsoft.DataApiBuilder 0.5.0" (specific to dab) - - return version; - } catch { + binaryPath = resolveTrustedExecutable(DATA_API_BUILDER_COMMAND, options); + } catch (error) { + if (!(error instanceof UnsupportedExecutableError)) throw error; + logger.warn(`${error.message} Using the CLI-managed Data API Builder download instead.`); + return undefined; + } + if (!binaryPath) return undefined; + try { + const { stdout } = await probeExecutable(binaryPath, ["--version"]); + const version = /^Microsoft\.DataApiBuilder\s+(\S+)/.exec(stdout.trim())?.[1]; + return version ? { binaryPath, version } : undefined; + } catch (error) { + logger.silly(`Unable to probe installed Data API Builder: ${(error as Error).message}`); 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..c1f0ede2d 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -1,7 +1,8 @@ import "../../tests/_mocks/fetch.js"; import "../../tests/_mocks/fs.js"; +import { childProcess, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; -import { vol } from "memfs"; +import { vol, fs } from "memfs"; import { sep } from "node:path"; import { PassThrough } from "node:stream"; import os from "node:os"; @@ -198,12 +199,23 @@ 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 }, + expect.any(Function), + ); + expect(childProcess.exec).not.toHaveBeenCalled(); }); it("should return the downloaded binary if there's no system binary", async () => { diff --git a/src/core/func-core-tools.ts b/src/core/func-core-tools.ts index a94553c69..e65d2b5fa 100644 --- a/src/core/func-core-tools.ts +++ b/src/core/func-core-tools.ts @@ -2,14 +2,19 @@ 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, + resolveManagedExecutable, + resolveTrustedExecutable, + UnsupportedExecutableError, +} from "./utils/executable.js"; const RELEASES_FEED_URL = "https://functionscdn.azureedge.net/public/cli-feed-v4.json"; const DEFAULT_FUNC_BINARY = "func"; @@ -35,7 +40,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 { @@ -62,7 +67,7 @@ export function detectTargetCoreToolsVersion(nodeVersion: number): number { return 4; } -// Cache the results of getInstalledSystemCoreToolsVersion +// Test override for the installed version; executable identity is still resolved independently. // This supports the testing inside of func-core-tools.spec.ts let installedSystemCoreToolsVersionIsCached: boolean = false; let cachedSystemCoreToolsVersion: number | undefined = undefined; @@ -77,15 +82,30 @@ 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; + } + + let binaryPath: string | undefined; + try { + binaryPath = resolveTrustedExecutable(DEFAULT_FUNC_BINARY, options); + } catch (error) { + if (!(error instanceof UnsupportedExecutableError)) throw error; + logger.warn(`${error.message} Using the CLI-managed Functions Core Tools download instead.`); + return undefined; } + 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); - } catch { + const { stdout } = await probeExecutable(binaryPath, ["--version"]); + const version = getMajorVersion(stdout.trim()); + return Number.isFinite(version) ? { binaryPath, version } : undefined; + } catch (error) { + logger.silly(`Unable to probe installed Functions Core Tools: ${(error as Error).message}`); return undefined; } } @@ -262,19 +282,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 = resolveManagedExecutable(getCoreToolBinaryPath(targetVersion), options); const downloadedVersion = getDownloadedCoreToolsVersion(targetVersion); if (downloadedVersion) { // Should we check for newer versions here? - return getCoreToolBinaryPath(targetVersion); + return managedBinary; } if (systemVersion && systemVersion !== targetVersion) { @@ -289,11 +311,10 @@ export async function getCoreToolsBinary(): Promise { try { await downloadCoreTools(targetVersion); logger.log(`\nDownloaded Function Core Tools successfully`); - return getCoreToolBinaryPath(targetVersion); + return resolveManagedExecutable(managedBinary, options); } catch (error: unknown) { logger.error(`Failed to download Functions Core Tools v${targetVersion}.`); logger.error(error as Error); - console.log(error); return undefined; } } diff --git a/src/core/internal-tools.security.spec.ts b/src/core/internal-tools.security.spec.ts new file mode 100644 index 000000000..622546759 --- /dev/null +++ b/src/core/internal-tools.security.spec.ts @@ -0,0 +1,96 @@ +import "../../tests/_mocks/fs.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 { getCoreToolsBinary, resetCachedInstalledSystemCoreToolsVersion } from "./func-core-tools.js"; +import { installAndGetDataApiBuilder } from "./dataApiBuilder/dab.js"; +import { fetchWithProxy } from "./utils/fetch-proxy.js"; +import { downloadAndValidateBinary } from "./download-binary-helper.js"; +import { DATA_API_BUILDER_FOLDER, DEFAULT_DATA_API_BUILDER_BINARY } from "./constants.js"; + +vi.mock("./utils/fetch-proxy.js", () => ({ fetchWithProxy: vi.fn() })); +vi.mock("./download-binary-helper.js", () => ({ downloadAndValidateBinary: vi.fn() })); +vi.mock("./utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn(), info: vi.fn() }, +})); + +describe("internal executable identity (no fixture execution)", () => { + beforeEach(() => { + vol.reset(); + resetCachedInstalledSystemCoreToolsVersion(); + vi.spyOn(os, "platform").mockReturnValue("linux"); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + vi.spyOn(process, "cwd").mockReturnValue("/workspace"); + // CWD, empty and relative entries are not trusted internal tool roots. + vi.stubEnv("PATH", "/workspace:.:relative-tools::/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); + 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 () => { + mockInternalProcesses("4.0.0"); + const binary = await getCoreToolsBinary(); + expect(binary).toBe("/trusted/bin/func"); + }); + + 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"); + }); + + 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 () => { + mockInternalProcesses("Microsoft.DataApiBuilder 1.2.3\r\n"); + const result = await installAndGetDataApiBuilder(); + expect(result.binaryPath).toBe("/trusted/bin/dab"); + }); + + 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/ssl.security.spec.ts b/src/core/ssl.security.spec.ts new file mode 100644 index 000000000..45a08ebdc --- /dev/null +++ b/src/core/ssl.security.spec.ts @@ -0,0 +1,150 @@ +import "../../tests/_mocks/fs.js"; +import childProcess from "node:child_process"; +import { createRequire } from "node:module"; +import { X509Certificate, createPrivateKey } from "node:crypto"; +import { vol, fs } from "memfs"; +import os from "node:os"; + +vi.mock("./utils/logger.js", () => ({ + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() }, +})); + +describe("SSL without executable discovery", () => { + let ssl: typeof import("./ssl.js"); + let importLaunches: { api: string; args: unknown[] }[]; + const launches: { api: string; args: unknown[] }[] = []; + const require = createRequire(import.meta.url); + + function interceptProcesses() { + // pem is CommonJS, so vi.mock alone cannot intercept its require(). Patch + // the actual Node object BEFORE loading ssl (or pem). + for (const api of ["spawn", "exec", "execFile", "execSync", "execFileSync", "fork"] as const) { + vi.spyOn(childProcess, api).mockImplementation(((...args: unknown[]) => { + launches.push({ api, args }); + throw new Error("External process disabled by SSL regression test"); + }) as any); + } + vi.spyOn(childProcess, "spawnSync").mockImplementation(((...args: unknown[]) => { + launches.push({ api: "spawnSync", args }); + return { status: 0, stdout: Buffer.from("OpenSSL 3.0.0"), stderr: Buffer.alloc(0) }; + }) as any); + } + + beforeAll(async () => { + interceptProcesses(); + ssl = await import("./ssl.js"); + importLaunches = launches.splice(0); + }); + + beforeEach(() => { + launches.length = 0; + interceptProcesses(); + // Never let the baseline generation path create temporary real files or + // run openssl. A pure in-process generator must still work when this + // external dependency is unavailable. + let pemPath: string; + try { + pemPath = require.resolve("pem"); + } catch (error) { + // GREEN may remove pem from the lockfile entirely. It is only an + // optional interception target, never a required generator fixture. + if ((error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") return; + throw error; + } + if (require.cache[pemPath]) { + vi.spyOn(require("pem"), "createCertificate").mockImplementation((...args: unknown[]) => + (args[1] as (error: Error) => void)(new Error("External certificate generator unavailable")), + ); + } + }); + + it("does not launch a subprocess when SSL is merely imported (even when SSL is disabled)", () => { + expect(importLaunches).toEqual([]); + }); + + it("generates a strong certificate with subject and SAN metadata without an external generator", async () => { + vol.reset(); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + const generated = ssl.getCertificate({ + selfSigned: true, + days: 365, + commonName: "localhost", + organization: "SWA regression", + organizationUnit: "Engineering", + emailAddress: "secure@microsoft.com", + altNames: ["localhost", "127.0.0.1"], + }); + await expect(generated).resolves.toBe("/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem"); + const result = await generated; + expect(launches).toEqual([]); + const content = fs.readFileSync(result, "utf8") as string; + const certificatePem = content.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/)?.[0] ?? content; + const certificate = new X509Certificate(certificatePem); + const key = createPrivateKey(content); + // One concept: generated certificate retains the public TLS contract. + expect(key.asymmetricKeyDetails?.modulusLength).toBeGreaterThanOrEqual(2048); + const forge = require("node-forge"); + expect(forge.pki.certificateFromPem(certificatePem).signatureOid).toBe(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(key)).toBe(true); + expect(Date.parse(certificate.validTo) - Date.parse(certificate.validFrom)).toBeGreaterThanOrEqual(364 * 24 * 60 * 60 * 1000); + }); + + it("reuses a fresh certificate cache without consulting the external generator", async () => { + vol.reset(); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + vol.fromJSON({ "/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem": "cached certificate fixture" }); + const before = launches.length; + await ssl.getCertificate({ selfSigned: true }); + expect(launches.length).toBe(before); + expect(fs.readFileSync("/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem", "utf8")).toBe("cached certificate fixture"); + }); + + it("clamps small RSA requests and preserves IPv6 SAN plus short requested validity", async () => { + // Arrange + vol.reset(); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + // Act + const file = await ssl.getCertificate({ keyBitsize: 1024, days: 2, commonName: "local.test", altNames: ["::1", "127.0.0.1"] }); + const content = fs.readFileSync(file, "utf8") as string; + const certificate = new X509Certificate(content); + // Assert: generated TLS material respects the strong-certificate contract. + expect(createPrivateKey(content).asymmetricKeyDetails?.modulusLength).toBeGreaterThanOrEqual(2048); + expect(certificate.subjectAltName).toContain("IP Address:0:0:0:0:0:0:0:1"); + expect(Date.parse(certificate.validTo) - Date.parse(certificate.validFrom)).toBe(2 * 24 * 60 * 60 * 1000); + expect(launches).toEqual([]); + }); + + it("regenerates an age-expired cache in process", async () => { + // Arrange: policy is file age, not certificate notAfter. + vol.reset(); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + const cache = "/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem"; + vol.fromJSON({ [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; + }); + // Act + const file = await ssl.getCertificate({ days: 1 }); + // Assert + expect(file).toBe(cache); + expect(() => new X509Certificate(fs.readFileSync(file, "utf8") as string)).not.toThrow(); + expect(launches).toEqual([]); + }); + + it.each([0, -1, 1.5])("rejects invalid validity %s without executing a generator", async (days) => { + // Arrange + vol.reset(); + vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); + // Act / Assert + await expect(ssl.getCertificate({ days })).rejects.toThrow(/positive integer/); + expect(launches).toEqual([]); + }); +}); diff --git a/src/core/ssl.ts b/src/core/ssl.ts index 4290f4480..6da935344 100644 --- a/src/core/ssl.ts +++ b/src/core/ssl.ts @@ -1,7 +1,8 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import pem from "pem"; +import { isIP } from "node:net"; +import selfsigned from "selfsigned"; import { logger } from "./utils/logger.js"; const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; @@ -25,7 +26,7 @@ export interface PEMOptions { /** Set the name of the serial file, without extension. - only together with options.serviceCertificate and never in tandem with options.serial */ serialFile?: string; - /** Hash function to use (either md5 sha1 or sha256, defaults to sha256) */ + /** Built-in certificates always use SHA256. */ hash?: string; /** CSR for the certificate, if not defined a new one is generated */ @@ -79,15 +80,11 @@ export interface PEMOptions { altNames?: string[]; } -interface SSLCertificate extends Pick { - certificate: string; -} - /** * 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. * - * @param options PEM options (passed to the "`pem`" npm library) + * @param options Subject, validity and SAN options for an in-process self-signed certificate. * @returns The abosulte filepath to the PEM file from cache. */ export async function getCertificate(options: PEMOptions): Promise { @@ -97,14 +94,14 @@ export async function getCertificate(options: PEMOptions): Promise { try { logger.silly(`Checking for cached certificate at ${cachePath}`); - const [stat, unsignedCertificate] = await Promise.all([fs.stat(cachePath), fs.readFile(cachePath, "utf8")]); + const [stat] = await Promise.all([fs.stat(cachePath), fs.readFile(cachePath, "utf8")]); if (Date.now() - stat.ctime.valueOf() > ONE_MONTH) { throw new Error("Certificate is too old, a new one will be generated."); } logger.silly("Certificate is valid, using it."); - return unsignedCertificate; + return cachePath; } catch { logger.silly("No cached certificate found."); @@ -113,7 +110,7 @@ export async function getCertificate(options: PEMOptions): Promise { logger.silly("Writing certificate to cache..."); - const pemContent = [unsignedCertificate.csr, unsignedCertificate.serviceKey, unsignedCertificate.certificate].join("\n"); + const pemContent = [unsignedCertificate.private, unsignedCertificate.cert].join("\n"); await fs.mkdir(cacheDir, { recursive: true }); await fs.writeFile(cachePath, pemContent); @@ -122,14 +119,62 @@ export async function getCertificate(options: PEMOptions): Promise { } } -async function createCertificate(options: PEMOptions): Promise { +async function createCertificate(options: PEMOptions): Promise<{ private: string; cert: string }> { + const unsupported = [ + "serviceCertificate", + "serviceKey", + "serviceKeyPassword", + "serial", + "serialFile", + "csr", + "clientKeyPassword", + "extFile", + "config", + "clientKey", + "csrConfigFile", + ] as const; + if (unsupported.some((option) => options[option] !== undefined)) { + throw new Error("Custom signing keys/CSR options are not supported by the built-in certificate generator. Use --ssl-cert and --ssl-key."); + } + const commonName = options.commonName ?? "localhost"; + const attrs = [ + { name: "commonName", value: commonName }, + { name: "countryName", value: options.country }, + { name: "stateOrProvinceName", value: options.state }, + { name: "localityName", value: options.locality }, + { name: "organizationName", value: options.organization }, + { name: "organizationalUnitName", value: options.organizationUnit }, + { name: "emailAddress", value: options.emailAddress }, + ].filter((attribute): attribute is { name: string; value: string } => attribute.value !== undefined); + const altNames = [...new Set([commonName, ...(options.altNames ?? [])])].map((name) => + isIP(name) ? { type: 7, ip: name } : { type: 2, value: name }, + ); + const keySize = Math.max(2048, options.keyBitsize ?? 2048); + const days = options.days ?? 365; + if (!Number.isInteger(keySize) || !Number.isInteger(days) || days < 1) { + throw new Error("Certificate key size and validity must be positive integer values."); + } return new Promise((resolve, reject) => { - pem.createCertificate(options, (err: Error, keys: SSLCertificate) => { - if (err) { - reject(err); - } else { - resolve(keys); - } - }); + selfsigned.generate( + attrs, + { + keySize, + days, + algorithm: "sha256", + extensions: [ + { name: "basicConstraints", cA: false }, + { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, + { name: "extKeyUsage", serverAuth: true }, + { name: "subjectAltName", altNames }, + ], + }, + (err, keys) => { + if (err) { + reject(err); + } else { + resolve(keys); + } + }, + ); }); } diff --git a/src/core/utils/arguments.security.spec.ts b/src/core/utils/arguments.security.spec.ts new file mode 100644 index 000000000..176a22bc6 --- /dev/null +++ b/src/core/utils/arguments.security.spec.ts @@ -0,0 +1,23 @@ +import { parseCommandArguments } from "./strings.js"; + +describe("literal function arguments", () => { + it.each([ + ["", []], + [" \t\n ", []], + ["--name \"two words\" 'single quoted'", ["--name", "two words", "single quoted"]], + ["\"\" '' tail", ["", "", "tail"]], + ['a"b c"d', ["ab cd"]], + [String.raw`two\ words \"literal\"`, ["two words", '"literal"']], + [String.raw`C:\tools\func --file "C:\path with spaces\file"`, [String.raw`C:\tools\func`, "--file", String.raw`C:\path with spaces\file`]], + ["$VALUE *.json ; && > plain", ["$VALUE", "*.json", ";", "&&", ">", "plain"]], + ["'C:\\tools\\func' trailing\\", [String.raw`C:\tools\func`, "trailing\\"]], + ])("preserves literal tokens for %j", (input, expected) => { + // Arrange / Act / Assert: no expansion or process execution. + expect(parseCommandArguments(input as string)).toEqual(expected); + }); + + it.each(['"unterminated', "'unterminated", "before\0after"])("rejects malformed input %j before launch", (input) => { + // Arrange / Act / Assert + expect(() => parseCommandArguments(input)).toThrow(/unterminated quote|null character/); + }); +}); diff --git a/src/core/utils/cli-config.ts b/src/core/utils/cli-config.ts index 1235cd0e0..d978e7e07 100644 --- a/src/core/utils/cli-config.ts +++ b/src/core/utils/cli-config.ts @@ -1,8 +1,9 @@ import chalk from "chalk"; -import { existsSync, promises as fsPromises } from "node:fs"; +import { existsSync, promises as fsPromises, readFileSync, realpathSync, statSync } from "node:fs"; import * as path from "node:path"; import * as process from "node:process"; import { logger } from "./logger.js"; +import { ExecutableExclusion } from "./executable.js"; const { readFile, writeFile } = fsPromises; export const swaCliConfigSchemaUrl = "https://aka.ms/azure/static-web-apps-cli/schema"; @@ -12,6 +13,74 @@ export const swaCliConfigFilename = "swa-cli.config.json"; * Holds the current configuration of the CLI loaded from the `swa-cli.config.json` file. */ let currentSwaCliConfigFromFile: SWACLIConfigInfo | undefined; +const invocationCwd = process.cwd(); + +function projectMarker(file: string) { + try { + return statSync(file); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return undefined; + throw error; + } +} + +function evidencedRoot(location: string, hasMarker: (directory: string) => boolean): string | undefined { + let directory = path.resolve(location); + while (true) { + try { + directory = realpathSync(directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; + } + if (hasMarker(directory)) return directory; + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +function repositoryRoot(location: string): string | undefined { + return evidencedRoot(location, (directory) => { + const marker = path.join(directory, ".git"); + const stat = projectMarker(marker); + return stat?.isDirectory() === true || (stat?.isFile() === true && /^gitdir:\s+\S/.test(readFileSync(marker, "utf8"))); + }); +} + +function isProjectDirectory(directory: string): boolean { + return [swaCliConfigFilename, "package.json"].some((name) => { + const marker = path.join(directory, name); + return projectMarker(marker)?.isFile(); + }); +} + +/** Carry exact invocation/search directories separately from genuine recursive project roots. */ +export function getProjectRoots(options: SWACLIConfig, locations: readonly (string | undefined)[] = []): ExecutableExclusion[] { + const cwd = process.cwd(); + const configPath = currentSwaCliConfigFromFile?.filePath ?? options.config; + const resolvedConfig = configPath ? path.resolve(invocationCwd, configPath) : undefined; + const roots = [ + ...(resolvedConfig && (currentSwaCliConfigFromFile || projectMarker(resolvedConfig)?.isFile()) ? [path.dirname(resolvedConfig)] : []), + options.appLocation, + options.apiLocation, + options.dataApiLocation, + options.swaConfigLocation, + ...locations, + ]; + const projectRoots = new Set(roots.filter((root): root is string => !!root && !/^https?:\/\//i.test(root)).map((root) => path.resolve(root))); + const searchDirectories = [...new Set([invocationCwd, cwd].map((directory) => path.resolve(directory)))]; + for (const directory of searchDirectories) { + const project = evidencedRoot(directory, isProjectDirectory); + if (project) projectRoots.add(project); + } + for (const location of [...projectRoots, ...searchDirectories]) { + const repo = repositoryRoot(location); + if (repo) projectRoots.add(repo); + } + return [...projectRoots, ...searchDirectories.map((directory) => ({ directory }))]; +} /** * Get the current configuration of the CLI stored in `{@link currentSwaCliConfigFromFile}`. diff --git a/src/core/utils/command.security.spec.ts b/src/core/utils/command.security.spec.ts new file mode 100644 index 000000000..9cc9480a0 --- /dev/null +++ b/src/core/utils/command.security.spec.ts @@ -0,0 +1,31 @@ +import { childProcess, mockInternalProcesses } from "../../../tests/_mocks/internal-process.js"; +import { execFileCommand, runCommand } from "./command.js"; + +describe("command execution trust boundary", () => { + beforeEach(() => 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", env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }) }), + ); + }); +}); diff --git a/src/core/utils/command.ts b/src/core/utils/command.ts index 6ab4bbd05..92ebd09b9 100644 --- a/src/core/utils/command.ts +++ b/src/core/utils/command.ts @@ -1,10 +1,12 @@ import process from "process"; import { execFileSync, execSync } from "child_process"; +import { assertAbsoluteExecutable, ExecutableResolutionOptions, getTrustedShell, InternalExecutionSpec } from "./executable.js"; -export function runCommand(command: string, cwd?: string) { +export function runCommand(command: string, cwd?: string, options: ExecutableResolutionOptions = {}) { execSync(command, { stdio: "inherit", cwd: cwd ?? process.cwd(), + shell: getTrustedShell({ ...options, untrustedRoots: [...(options.untrustedRoots ?? []), ...(cwd ? [{ directory: cwd }] : [])] }), // Set CI to avoid extra NPM logs and potentially unwanted interactive modes env: { ...process.env, @@ -15,15 +17,15 @@ 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(spec: InternalExecutionSpec) { + assertAbsoluteExecutable(spec.executable); + if (spec.windowsVerbatimArguments) throw new Error("Windows customer-shell specs must use the asynchronous process supervisor."); + return execFileSync(spec.executable, spec.args, { + 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", - }, + env: { ...process.env, ...spec.env, SWA_CLI_INTERNAL_COMMAND: "1" }, }); - return child; } diff --git a/src/core/utils/executable.security.spec.ts b/src/core/utils/executable.security.spec.ts new file mode 100644 index 000000000..9f2a31f23 --- /dev/null +++ b/src/core/utils/executable.security.spec.ts @@ -0,0 +1,206 @@ +import "../../../tests/_mocks/fs.js"; +import { fs, vol } from "memfs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { + assertAbsoluteExecutable, + createShellExecutionSpec, + resolveManagedExecutable, + resolveTrustedExecutable, + resolveTrustedPackageManager, +} from "./executable.js"; + +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("ignores empty, relative, cwd and project-descendant PATH entries", () => { + // Arrange + vi.stubEnv("PATH", ":.:tools:/project:/project/tools:/trusted/bin"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + }); + + it("rejects a PATH alias to project descendants", () => { + // Arrange + fs.symlinkSync("/project/tools", "/alias"); + vi.stubEnv("PATH", "/alias"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBeUndefined(); + }); + + it("rejects aliases of separately supplied API roots", () => { + // 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 + expect(resolveTrustedExecutable("func", { untrustedRoots: ["/api-alias"] })).toBeUndefined(); + }); + + 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("rejects a managed missing-file path whose existing parent aliases the project", () => { + // Arrange + fs.symlinkSync("/project", "/cache-alias"); + // Act / Assert + expect(() => resolveManagedExecutable("/cache-alias/not-downloaded/func")).toThrow(/inside the project/); + }); + + it("binds a missing managed filename to its trusted canonical parent", () => { + // Arrange + fs.symlinkSync("/trusted", "/cache-alias"); + // Act / Assert + expect(resolveManagedExecutable("/cache-alias/new/version/func")).toBe("/trusted/new/version/func"); + }); + + 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("fails closed when a supplied exclusion root cannot be canonicalized", () => { + // 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", { untrustedRoots: ["/denied-root"] })).toThrow("denied"); + }); + + it("rejects unknown interpreter scripts instead of executing their shebang", () => { + // Arrange + vol.fromJSON({ "/trusted/bin/func": "#!/usr/bin/env node\ninert" }); + fs.chmodSync("/trusted/bin/func", 0o755); + vi.stubEnv("PATH", "/trusted/bin"); + // Act / Assert + expect(() => resolveTrustedExecutable("func")).toThrow(/Unsupported internal tool script/); + }); + + it.each(["func", "./func", "/trusted/tool.cmd", "/trusted/tool.bat", "/trusted/tool.ps1"])( + "refuses a relative identity or shell shim: %s", + (file) => { + // Arrange / Act / Assert + expect(() => assertAbsoluteExecutable(file)).toThrow(); + }, + ); + + it.each(["yarn", "pnpm"] as const)("binds a known %s Corepack script to current Node", (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", + }); + vi.stubEnv("PATH", "/trusted/bin"); + // Act / Assert + expect(resolveTrustedPackageManager(manager)).toEqual({ executable: process.execPath, args: [script] }); + }); + + it("rejects a recognized package entrypoint aliased into the project", () => { + // 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", + }); + 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")).toThrow(/Cannot find a supported npm/); + }); +}); + +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:\\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)("binds a standard Windows %s shim to current Node and recognized script", (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: process.execPath, args: [canonical(script)] }); + }); + + 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", + windowsVerbatimArguments: true, + }); + }); + + it("rejects case-variant project descendants", () => { + // Arrange + vi.stubEnv("PATH", "C:\\PROJECT\\tools"); + fixtures.set(canonical("C:\\project\\tools\\func.exe"), "inert"); + // Act / Assert + expect(resolveTrustedExecutable("func")).toBeUndefined(); + }); +}); diff --git a/src/core/utils/executable.ts b/src/core/utils/executable.ts new file mode 100644 index 000000000..ecd448014 --- /dev/null +++ b/src/core/utils/executable.ts @@ -0,0 +1,305 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +export interface InternalExecutionSpec { + executable: string; + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + /** Used only by the explicit Windows customer-shell adapter. */ + windowsVerbatimArguments?: boolean; +} + +export interface ExecutableDirectoryExclusion { + /** An exact search directory, not its descendants. */ + directory: string; +} + +export type ExecutableExclusion = string | ExecutableDirectoryExclusion; + +export interface ExecutableResolutionOptions { + /** Strings exclude project trees; directory entries exclude only canonical search directories. */ + untrustedRoots?: readonly ExecutableExclusion[]; +} + +export class UnsupportedExecutableError extends Error {} + +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}`); + } + if (/\.(?:cmd|bat|ps1)$/i.test(file)) { + throw new UnsupportedExecutableError( + `Command shims cannot be used as internal executables: ${file}. Use a native executable or a supported Node entrypoint.`, + ); + } +} + +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 containsPath(root: string, file: string): boolean { + const paths = platformPath(); + // Conservative case folding also covers case-insensitive Unix volumes. + const normalize = (value: string) => + paths + .normalize(value) + .replace(/^\\\\\?\\UNC\\/i, "\\\\") + .replace(/^\\\\\?\\([a-z]:\\)/i, "$1") + .toLowerCase(); + const relative = paths.relative(normalize(root), normalize(file)); + return relative === "" || (!relative.startsWith(`..${paths.sep}`) && relative !== ".." && !paths.isAbsolute(relative)); +} + +function samePath(first: string, second: string): boolean { + return containsPath(first, second) && containsPath(second, first); +} + +function matchesDirectory(directory: string, exclusion: ExecutableDirectoryExclusion): boolean { + const root = platformPath().resolve(exclusion.directory); + return samePath(root, directory) || samePath(realPath(root) ?? root, directory); +} + +function excludedPath(file: string, options: ExecutableResolutionOptions): boolean { + const paths = platformPath(); + const entries = options.untrustedRoots ?? []; + const roots = entries.filter((entry): entry is string => typeof entry === "string").map((root) => paths.resolve(root)); + const cwd = paths.resolve(process.cwd()); + // Legacy callers assume cwd is their project. Explicit contexts classify it without that assumption. + if (!entries.some((entry) => typeof entry !== "string" && matchesDirectory(cwd, entry))) roots.push(cwd); + return roots.some((root) => containsPath(root, file) || containsPath(realPath(root) ?? root, file)); +} + +function excludedSearchDirectory(directory: string, options: ExecutableResolutionOptions): boolean { + return (options.untrustedRoots ?? []).some((entry) => typeof entry !== "string" && matchesDirectory(directory, entry)); +} + +function trustedPath(file: string, options: ExecutableResolutionOptions): string | undefined { + if (!isAbsoluteExecutable(file)) return undefined; + if (excludedPath(file, options)) 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; + if (excludedPath(canonical, options)) return undefined; + return canonical; +} + +/** Bind a managed path even before download, while checking existing parent-directory aliases. */ +export function resolveManagedExecutable(file: string, options: ExecutableResolutionOptions = {}): string { + assertAbsoluteExecutable(file); + const paths = platformPath(); + const suffix: string[] = []; + let parent = file; + let canonical = realPath(parent); + while (!canonical) { + const next = paths.dirname(parent); + if (next === parent) throw new Error(`Cannot resolve managed executable directory: ${file}`); + suffix.unshift(paths.basename(parent)); + parent = next; + canonical = realPath(parent); + } + const resolved = paths.join(canonical, ...suffix); + if (excludedPath(file, options) || excludedPath(resolved, options)) { + throw new Error("The managed tool directory resolves inside the project. Move the CLI tool cache outside the project and retry."); + } + assertAbsoluteExecutable(resolved); + return resolved; +} + +function pathDirectories(options: ExecutableResolutionOptions): string[] { + const windows = os.platform() === "win32"; + const pathKey = Object.keys(process.env).find((key) => (windows ? key.toLowerCase() === "path" : key === "PATH")); + return (pathKey ? process.env[pathKey] ?? "" : "") + .split(windows ? ";" : ":") + .map((directory) => (windows ? directory.replace(/^"(.*)"$/, "$1") : directory)) + .filter(isAbsoluteExecutable) + .filter((directory) => { + if (excludedPath(directory, options) || excludedSearchDirectory(directory, options)) return false; + let canonical: string | undefined; + try { + canonical = realPath(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EACCES") return false; + throw error; + } + return !canonical || (!excludedPath(canonical, options) && !excludedSearchDirectory(canonical, options)); + }); +} + +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 assertNativeExecutable(file: string): void { + assertAbsoluteExecutable(file); + // Script interpreters may rediscover node or a shell after cwd changes. + const fd = fs.openSync(file, "r"); + const header = Buffer.alloc(2); + try { + fs.readSync(fd, header, 0, header.length, 0); + } finally { + fs.closeSync(fd); + } + if (header.toString() === "#!" || /\.(?:js|cjs|mjs)$/i.test(file)) { + throw new UnsupportedExecutableError(`Unsupported internal tool script: ${file}. Install a native tool or use the CLI-managed download.`); + } +} + +/** + * Resolve once, before changing cwd. Never use which/where or implicit cwd/PATHEXT. + * Returned identities are canonical so directory symlinks/junctions cannot hide project tools. + */ +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 names = windows && !/\.(exe|com)$/i.test(name) ? [`${name}.exe`, `${name}.com`, `${name}.cmd`, `${name}.bat`, `${name}.ps1`] : [name]; + let unsupported: UnsupportedExecutableError | undefined; + for (const directory of pathDirectories(options)) { + for (const filename of names) { + const candidate = trustedPath(platformPath().join(directory, filename), options); + if (!candidate || excludedSearchDirectory(platformPath().dirname(candidate), options) || !isExecutableFile(candidate)) continue; + try { + assertNativeExecutable(candidate); + return candidate; + } catch (error) { + if (!(error instanceof UnsupportedExecutableError)) throw error; + unsupported = error; + } + } + } + if (unsupported) throw unsupported; + return undefined; +} + +const PACKAGE_MANAGER_ENTRYPOINTS = { + npm: ["bin/npm-cli.js"], + yarn: ["bin/yarn.js"], + pnpm: ["bin/pnpm.cjs", "bin/pnpm.js"], +} as const; + +/** + * Bind known package-manager scripts to the already-running Node, not their shell shims. + * Installations without a recognized entrypoint fail explicitly; shell-wrapper customizations are not executed. + */ +export function resolveTrustedPackageManager( + name: keyof typeof PACKAGE_MANAGER_ENTRYPOINTS, + options: ExecutableResolutionOptions = {}, +): InternalExecutionSpec { + const paths = platformPath(); + const windows = os.platform() === "win32"; + for (const directory of pathDirectories(options)) { + const launcher = trustedPath(paths.join(directory, windows ? `${name}.cmd` : name), options); + if (launcher && excludedSearchDirectory(paths.dirname(launcher), options)) continue; + const providers = [ + { packageName: name, entries: PACKAGE_MANAGER_ENTRYPOINTS[name] }, + { packageName: "corepack", entries: [`dist/${name}.js`] }, + ]; + for (const { packageName, entries } of providers) { + const packageRoots = [paths.join(directory, "node_modules", packageName)]; + if (launcher) { + for (const entry of entries) { + const segments = entry.split("/"); + if (launcher.toLowerCase().endsWith(paths.join(...segments).toLowerCase())) { + packageRoots.unshift(paths.dirname(paths.dirname(launcher))); + } + } + } + for (const root of packageRoots) { + const manifest = trustedPath(paths.join(root, "package.json"), options); + if (!manifest) continue; + const metadata: { name?: string; bin?: string | Record } = JSON.parse(fs.readFileSync(manifest, "utf8")); + if (metadata.name !== packageName) continue; + const entry = typeof metadata.bin === "string" ? metadata.bin : metadata.bin?.[name]; + if (!entry || !entries.some((supported) => supported === entry.replace(/^\.\//, ""))) continue; + const script = trustedPath(paths.join(root, entry), options); + if (script && !excludedSearchDirectory(paths.dirname(script), options) && fs.statSync(script).isFile()) { + return { executable: process.execPath, args: [script] }; + } + } + } + } + let executable: string | undefined; + try { + executable = resolveTrustedExecutable(name, options); + } catch (error) { + if (!(error instanceof UnsupportedExecutableError)) throw error; + throw new UnsupportedExecutableError( + `Unsupported ${name} shim. Install ${name} globally outside the project with its Node entrypoint, or use a native installation.`, + ); + } + if (executable) return { executable, args: [] }; + throw new Error(`Cannot find a supported ${name} installation outside the project. Install ${name} globally and retry.`); +} + +/** System shells are used only for deliberate customer command strings. */ +export function getTrustedShell(options: ExecutableResolutionOptions = {}): string { + if (os.platform() !== "win32") { + const shell = trustedPath("/bin/sh", options); + if (shell && isExecutableFile(shell)) return shell; + } else { + return getTrustedWindowsSystemExecutable("cmd.exe", options); + } + throw new Error("Cannot locate a trusted absolute system shell outside the project. Check your operating system installation."); +} + +export function getTrustedWindowsSystemExecutable(name: "cmd.exe" | "taskkill.exe", options: ExecutableResolutionOptions = {}): string { + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT; + if (os.platform() === "win32" && systemRoot && isAbsoluteExecutable(systemRoot)) { + const executable = trustedPath(path.win32.join(systemRoot, "System32", name), options); + if (executable && isExecutableFile(executable)) return executable; + } + throw new Error(`Cannot locate trusted System32\\${name} outside the project. Check your Windows installation.`); +} + +export function createShellExecutionSpec(command: string, cwd: string, options: ExecutableResolutionOptions = {}): InternalExecutionSpec { + const executable = getTrustedShell(options); + if (os.platform() === "win32") { + return { executable, args: ["/d", "/s", "/c", `"${command}"`], cwd, windowsVerbatimArguments: true }; + } + return { executable, args: ["-c", command], cwd }; +} + +export function probeExecutable(executable: string, args: string[]): Promise<{ stdout: string; stderr: string }> { + assertAbsoluteExecutable(executable); + return promisify(execFile)(executable, args, { encoding: "utf8", shell: false }); +} diff --git a/src/core/utils/process.security.spec.ts b/src/core/utils/process.security.spec.ts new file mode 100644 index 000000000..6ea1b9d76 --- /dev/null +++ b/src/core/utils/process.security.spec.ts @@ -0,0 +1,183 @@ +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"; + +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", ";"], { + cwd: "/project/app", + env: { ...process.env, TEST_CONTEXT: "inherited" }, + stdio: "inherit", + shell: false, + detached: true, + windowsVerbatimArguments: undefined, + }); + }); + + it("rolls back owned siblings on asynchronous spawn error", async () => { + // Arrange + const error = new Error("spawn failed"); + const result = superviseProcesses(commands).catch((events) => events); + // Act + first.emit("error", error); + second.emit("close", null, "SIGTERM"); + // Assert + expect((await result)[0].error).toBe(error); + expect(process.kill).toHaveBeenCalledWith(-second.pid, "SIGTERM"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops later launches and cleans earlier children after synchronous spawn failure", async () => { + // Arrange + childProcess.spawn + .mockReset() + .mockReturnValueOnce(first) + .mockImplementationOnce(() => { + throw new Error("sync spawn"); + }); + const result = superviseProcesses([...commands, { name: "never", execution: commands[0].execution }]).catch((events) => events); + // Act + first.emit("close", null, "SIGTERM"); + // Assert + expect((await result)[2]).toMatchObject({ command: { name: "never" }, killed: true, exitCode: 1 }); + expect(childProcess.spawn).toHaveBeenCalledTimes(2); + expect(process.kill).toHaveBeenCalledWith(-first.pid, "SIGTERM"); + }); + + it.each(["SIGINT", "SIGTERM"] as const)("cleans owned groups and removes %s listeners and timers", async (signal) => { + // Arrange + const listeners = [process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]; + const result = superviseProcesses(commands).catch((events) => events); + // Act: emit the EventEmitter notification, never send a real OS signal. + process.emit(signal); + first.emit("close", null, signal); + second.emit("close", null, signal); + const events = await result; + // Assert + expect(events.every((event: { terminationSignal: string }) => event.terminationSignal === signal)).toBe(true); + expect(process.kill).toHaveBeenCalledWith(-first.pid, signal); + expect(process.kill).toHaveBeenCalledWith(-first.pid, "SIGKILL"); + expect([process.listenerCount("SIGINT"), process.listenerCount("SIGTERM")]).toEqual(listeners); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bounds escalation and completion even when force-kill never produces close", async () => { + // Arrange + const result = superviseProcesses(commands).catch((events) => events); + // Act + process.emit("SIGTERM"); + await vi.advanceTimersByTimeAsync(5000); + expect(process.kill).toHaveBeenCalledWith(-second.pid, "SIGKILL"); + await vi.advanceTimersByTimeAsync(5000); + // Assert + expect((await result).every((event: { error: Error }) => /cleanup timed out/.test(event.error.message))).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses absolute System32 taskkill with literal owned PID argv on simulated Windows", async () => { + // Arrange: simulated filesystem identities, no real cleanup executable. + vi.mocked(os.platform).mockReturnValue("win32"); + vi.stubEnv("SystemRoot", "C:\\Windows"); + 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 + process.emit("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.spawn.mock.calls[0][2]).toMatchObject({ shell: false, detached: false }); + }); + + it("rejects empty or relative execution specs before launching", async () => { + // Arrange / Act / Assert + await expect(superviseProcesses([])).rejects.toThrow(/No processes/); + await expect(superviseProcesses([{ name: "bad", execution: { executable: "node", args: [] } }])).rejects.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..600c79d79 --- /dev/null +++ b/src/core/utils/process.ts @@ -0,0 +1,150 @@ +import { ChildProcess, execFile, spawn } from "node:child_process"; +import os from "node:os"; +import process from "node:process"; +import { assertAbsoluteExecutable, ExecutableResolutionOptions, getTrustedWindowsSystemExecutable, InternalExecutionSpec } from "./executable.js"; +import { logger } from "./logger.js"; + +export interface NamedExecutionSpec { + name: string; + execution: InternalExecutionSpec; +} + +export interface ProcessCloseEvent { + command: { name: string }; + exitCode: number | NodeJS.Signals; + killed: boolean; + terminationSignal?: NodeJS.Signals; + error?: Error; +} + +interface RunningProcess { + child: ChildProcess; + finished: boolean; + terminationSignal?: NodeJS.Signals; + finish: (exitCode: number | NodeJS.Signals, error?: Error) => void; +} + +/** + * Raw-I/O supervision preserves wait-for-all completion, without a shell-string adapter. + * Only spawn errors and parent signals trigger coordinated cleanup; ordinary exits do not. + */ +export async function superviseProcesses(commands: NamedExecutionSpec[], options: ExecutableResolutionOptions = {}): Promise { + if (!commands.length) throw new Error("No processes were provided."); + commands.forEach(({ execution }) => assertAbsoluteExecutable(execution.executable)); + const windows = os.platform() === "win32"; + const taskkill = windows ? getTrustedWindowsSystemExecutable("taskkill.exe", options) : undefined; + const running: RunningProcess[] = []; + let stopping: NodeJS.Signals | undefined; + let escalation: NodeJS.Timeout | undefined; + let deadline: NodeJS.Timeout | undefined; + + const kill = (child: ChildProcess, signal: NodeJS.Signals) => { + if (!child.pid) return; + if (taskkill) { + execFile(taskkill, ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }, (error) => { + if (error && child.exitCode === null && child.signalCode === null) { + logger.warn("Unable to stop a child process tree. Check the running emulator/tool processes."); + } + }); + } else { + try { + // Each child owns its group, including descendants created by customer shells. + process.kill(-child.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + }; + + const stop = (signal: NodeJS.Signals) => { + if (stopping) return; + stopping = signal; + for (const entry of running) { + if (!entry.finished) { + entry.terminationSignal = signal; + try { + kill(entry.child, signal); + } catch (error) { + logger.warn(`Unable to stop a child process: ${(error as Error).message}`); + } + } + } + escalation = setTimeout(() => { + for (const entry of running) { + if (!entry.finished) { + try { + kill(entry.child, "SIGKILL"); + } catch (error) { + logger.warn(`Unable to stop a child process: ${(error as Error).message}`); + } + } + } + deadline = setTimeout(() => { + for (const entry of running) { + if (!entry.finished) entry.finish(1, new Error("Child process cleanup timed out. Check the running emulator/tool processes.")); + } + }, 5000); + }, 5000); + }; + const onInterrupt = () => stop("SIGINT"); + const onTerminate = () => stop("SIGTERM"); + process.on("SIGINT", onInterrupt); + process.on("SIGTERM", onTerminate); + + try { + const results = commands.map( + ({ name, execution }) => + new Promise((resolve) => { + if (stopping) { + resolve({ command: { name }, exitCode: 1, killed: true, terminationSignal: stopping }); + return; + } + let child: ChildProcess; + try { + child = spawn(execution.executable, execution.args, { + cwd: execution.cwd ?? process.cwd(), + env: { ...process.env, ...execution.env }, + stdio: "inherit", + shell: false, + detached: !windows, + windowsVerbatimArguments: execution.windowsVerbatimArguments, + }); + } catch (error) { + stop("SIGTERM"); + resolve({ command: { name }, exitCode: 1, killed: false, error: error as Error }); + return; + } + const entry: RunningProcess = { + child, + finished: false, + finish: (exitCode, error) => { + if (entry.finished) return; + entry.finished = true; + if (entry.terminationSignal && !windows) { + try { + kill(entry.child, "SIGKILL"); + } catch (cleanupError) { + logger.warn(`Unable to stop remaining child processes: ${(cleanupError as Error).message}`); + } + } + resolve({ command: { name }, exitCode, killed: !!entry.terminationSignal, terminationSignal: entry.terminationSignal, error }); + }, + }; + running.push(entry); + child.once("close", (code: number | null, signal: NodeJS.Signals | null) => entry.finish(signal ?? code ?? 1)); + child.once("error", (error: Error) => { + stop("SIGTERM"); + entry.finish(1, error); + }); + }), + ); + const events = await Promise.all(results); + if (events.some((event) => event.exitCode !== 0 || event.error || event.terminationSignal)) throw events; + return events; + } finally { + process.removeListener("SIGINT", onInterrupt); + process.removeListener("SIGTERM", onTerminate); + if (escalation) clearTimeout(escalation); + if (deadline) clearTimeout(deadline); + } +} diff --git a/src/core/utils/project-roots.security.spec.ts b/src/core/utils/project-roots.security.spec.ts new file mode 100644 index 000000000..794c602c1 --- /dev/null +++ b/src/core/utils/project-roots.security.spec.ts @@ -0,0 +1,182 @@ +import "../../../tests/_mocks/fs.js"; +import { fs, vol } from "memfs"; +import os from "node:os"; +import process from "node:process"; +import * as nodeFs from "node:fs"; +import { resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } from "./executable.js"; + +const simulated = vi.hoisted(() => ({ cwd: "/home/tester" })); +vi.mock("node:process", async (importOriginal) => { + const actual = await importOriginal(); + const cwd = () => simulated.cwd; + // Named and default imports must observe the same chdir in this isolated worker. + return { ...actual, cwd, default: { ...actual.default, cwd } }; +}); + +describe("invocation directory versus selected project trust (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`]: "{}" }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function selectProject(invocation = home, selected = project) { + simulated.cwd = invocation; + // The module captures the original invocation cwd, before config loading/chdir. + const { getProjectRoots } = await import("./cli-config.js"); + simulated.cwd = selected; + return { untrustedRoots: getProjectRoots({ config: `${selected}/swa-cli.config.json`, appLocation: selected }) }; + } + + 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(); + // Act / Assert: HOME is an exact search-directory exclusion, not a recursive project root. + expect(resolveManagedExecutable(tool, options)).toBe(tool); + }); + + it("binds HOME runtime npm outside the configured project to current Node", 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`); + vi.stubEnv("PATH", `${runtime}/bin`); + const options = await selectProject(); + // Act / Assert + expect(resolveTrustedPackageManager("npm", options)).toEqual({ executable: process.execPath, args: [script] }); + }); + + it("allows an ordinary HOME tool outside the project without cache-specific exemptions", async () => { + // Arrange: the distinction is root classification, not a .swa/.nvm allowlist. + const tool = `${home}/global-tools/func`; + vol.fromJSON({ [tool]: "inert native fixture" }); + fs.chmodSync(tool, 0o755); + vi.stubEnv("PATH", `${home}/global-tools`); + const options = await selectProject(); + // Act / Assert + expect(resolveTrustedExecutable("func", options)).toBe(tool); + }); + + it("excludes a tool directly in the original invocation directory after chdir", async () => { + // Arrange + vol.fromJSON({ [`${home}/func`]: "inert fixture" }); + fs.chmodSync(`${home}/func`, 0o755); + vi.stubEnv("PATH", home); + const options = await selectProject(); + // Act / Assert + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it("excludes a canonical alias of the original invocation search directory", 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it("retains recursive exclusion when invocation is a real repository root", 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it("retains recursive repository scope for nested invocation under a valid worktree .git file", 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it.each(["package.json", "swa-cli.config.json"])("retains recursive non-Git project scope for nested invocation beneath %s", 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it("rejects actual selected-project descendants reached through an outside alias", async () => { + // Arrange: invocation HOME has only exact scope, but selected project remains recursive. + 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it("retains canonical repository ancestry when the selected project is a directory alias", 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 + expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + }); + + it.each([".git", "package.json"])("fails closed when invocation %s evidence cannot be inspected", async (marker) => { + // Arrange: intercept the named fs API used by marker discovery, not a real filesystem permission. + 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); + }); + // Act / Assert + await expect(selectProject()).rejects.toBe(denied); + }); +}); diff --git a/src/core/utils/strings.ts b/src/core/utils/strings.ts index 010ff72ce..e350ad79b 100644 --- a/src/core/utils/strings.ts +++ b/src/core/utils/strings.ts @@ -23,3 +23,42 @@ export function removeTrailingPathSep(filePath: string): string { } export const hasSpaces = (str: string): boolean => str.indexOf(" ") !== -1; + +/** Split literal argv without variable, wildcard, substitution or operator expansion. */ +export function parseCommandArguments(value: string): string[] { + if (value.includes("\0")) throw new Error("Arguments cannot contain a null character."); + const args: string[] = []; + let token = ""; + let started = false; + let quote: "'" | '"' | undefined; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + if (character === "\\" && quote !== "'") { + const next = value[index + 1]; + // Preserve ordinary backslashes in Windows paths, but support escaped quotes/whitespace. + if (next === "\\" || next === '"' || (!quote && (next === "'" || (next !== undefined && /\s/.test(next))))) { + token += next; + index++; + } else { + token += character; + } + started = true; + } else if (quote) { + if (character === quote) quote = undefined; + else token += character; + } else if (character === "'" || character === '"') { + quote = character; + started = true; + } else if (/\s/.test(character)) { + if (started) args.push(token); + token = ""; + started = false; + } else { + token += character; + started = true; + } + } + if (quote) throw new Error("Arguments contain an unterminated quote. Close the quote or escape a literal quote."); + if (started) args.push(token); + return args; +} diff --git a/src/swa.d.ts b/src/swa.d.ts index c6df14d87..a6e92bdd9 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -5,7 +5,6 @@ declare global { } declare module "json-source-map"; -declare module "pem"; declare interface StaticSiteClientEnv { // StaticSitesClient env vars 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..6f52b5827 --- /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); + child.emit("close", error ? 1 : 0); + }); + 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 })), + ); +} From 80df8b964243e5403091ba0b5bc8460cce8143e3 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Fri, 16 Oct 2026 05:03:08 +0000 Subject: [PATCH 2/8] test: make tool fixtures platform-independent Keep POSIX test fixtures consistent across platforms while retaining explicit Windows cases and exact executable identity assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/commands/build/build.security.spec.ts | 1 + src/cli/commands/build/build.spec.ts | 1 + src/cli/commands/start/start.security.spec.ts | 1 + src/core/func-core-tools.spec.ts | 23 ++------ src/core/internal-tools.security.spec.ts | 1 + src/core/ssl.security.spec.ts | 1 + src/core/utils/command.security.spec.ts | 1 + src/core/utils/executable.security.spec.ts | 1 + src/core/utils/project-roots.security.spec.ts | 9 +-- tests/_mocks/posix.d.ts | 1 + tests/_mocks/posix.js | 56 +++++++++++++++++++ 11 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 tests/_mocks/posix.d.ts create mode 100644 tests/_mocks/posix.js diff --git a/src/cli/commands/build/build.security.spec.ts b/src/cli/commands/build/build.security.spec.ts index 0a1520cbc..b887288f8 100644 --- a/src/cli/commands/build/build.security.spec.ts +++ b/src/cli/commands/build/build.security.spec.ts @@ -1,4 +1,5 @@ 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"; diff --git a/src/cli/commands/build/build.spec.ts b/src/cli/commands/build/build.spec.ts index 25c2e752e..707499a0b 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -1,4 +1,5 @@ import "../../../../tests/_mocks/fs.js"; +import "../../../../tests/_mocks/posix.js"; import { vol, fs } from "memfs"; import path from "node:path"; import process from "node:process"; diff --git a/src/cli/commands/start/start.security.spec.ts b/src/cli/commands/start/start.security.spec.ts index a522ed113..ea7b1e5cd 100644 --- a/src/cli/commands/start/start.security.spec.ts +++ b/src/cli/commands/start/start.security.spec.ts @@ -1,4 +1,5 @@ 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"; diff --git a/src/core/func-core-tools.spec.ts b/src/core/func-core-tools.spec.ts index c1f0ede2d..c6bd37ce2 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -1,9 +1,9 @@ import "../../tests/_mocks/fetch.js"; 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 { sep } from "node:path"; import { PassThrough } from "node:stream"; import os from "node:os"; import * as fct from "./func-core-tools.js"; @@ -227,12 +227,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 the downloaded binary if the system binary is incompatible", async () => { @@ -243,12 +238,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"); }); // SKIPPED: Does not work because we cannot mock adm-zip right now @@ -283,12 +273,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/internal-tools.security.spec.ts b/src/core/internal-tools.security.spec.ts index 622546759..05d96367a 100644 --- a/src/core/internal-tools.security.spec.ts +++ b/src/core/internal-tools.security.spec.ts @@ -1,4 +1,5 @@ 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"; diff --git a/src/core/ssl.security.spec.ts b/src/core/ssl.security.spec.ts index 45a08ebdc..6d70cd565 100644 --- a/src/core/ssl.security.spec.ts +++ b/src/core/ssl.security.spec.ts @@ -1,4 +1,5 @@ import "../../tests/_mocks/fs.js"; +import "../../tests/_mocks/posix.js"; import childProcess from "node:child_process"; import { createRequire } from "node:module"; import { X509Certificate, createPrivateKey } from "node:crypto"; diff --git a/src/core/utils/command.security.spec.ts b/src/core/utils/command.security.spec.ts index 9cc9480a0..ac91e70ba 100644 --- a/src/core/utils/command.security.spec.ts +++ b/src/core/utils/command.security.spec.ts @@ -1,3 +1,4 @@ +import "../../../tests/_mocks/posix.js"; import { childProcess, mockInternalProcesses } from "../../../tests/_mocks/internal-process.js"; import { execFileCommand, runCommand } from "./command.js"; diff --git a/src/core/utils/executable.security.spec.ts b/src/core/utils/executable.security.spec.ts index 9f2a31f23..48ed6367c 100644 --- a/src/core/utils/executable.security.spec.ts +++ b/src/core/utils/executable.security.spec.ts @@ -1,4 +1,5 @@ import "../../../tests/_mocks/fs.js"; +import "../../../tests/_mocks/posix.js"; import { fs, vol } from "memfs"; import os from "node:os"; import path from "node:path"; diff --git a/src/core/utils/project-roots.security.spec.ts b/src/core/utils/project-roots.security.spec.ts index 794c602c1..5df32b82e 100644 --- a/src/core/utils/project-roots.security.spec.ts +++ b/src/core/utils/project-roots.security.spec.ts @@ -1,18 +1,11 @@ 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 process from "node:process"; import * as nodeFs from "node:fs"; import { resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } from "./executable.js"; -const simulated = vi.hoisted(() => ({ cwd: "/home/tester" })); -vi.mock("node:process", async (importOriginal) => { - const actual = await importOriginal(); - const cwd = () => simulated.cwd; - // Named and default imports must observe the same chdir in this isolated worker. - return { ...actual, cwd, default: { ...actual.default, cwd } }; -}); - describe("invocation directory versus selected project trust (inert fixtures)", () => { const home = "/home/tester"; const project = `${home}/project`; 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 } }; +}); From 2e179fdbebc622a09eab777f75365777f0bcdbad Mon Sep 17 00:00:00 2001 From: Long Hao Date: Fri, 16 Oct 2026 05:19:06 +0000 Subject: [PATCH 3/8] test: provide an explicit shell fixture for command tests Keep command tests independent of host shell files and assert the selected shell path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/utils/command.security.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/utils/command.security.spec.ts b/src/core/utils/command.security.spec.ts index ac91e70ba..4ee0b58ce 100644 --- a/src/core/utils/command.security.spec.ts +++ b/src/core/utils/command.security.spec.ts @@ -1,9 +1,16 @@ +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"; describe("command execution trust boundary", () => { - beforeEach(() => mockInternalProcesses("ok")); + 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(); @@ -26,7 +33,7 @@ describe("command execution trust boundary", () => { runCommand(command, "/customer project"); expect(childProcess.execSync).toHaveBeenCalledWith( command, - expect.objectContaining({ cwd: "/customer project", env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }) }), + expect.objectContaining({ cwd: "/customer project", shell: "/bin/sh", env: expect.objectContaining({ SWA_CLI_INTERNAL_COMMAND: "1" }) }), ); }); }); From 366057cdaaee9883280a5ed5f1879522d25c27f8 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Mon, 19 Oct 2026 03:38:51 +0000 Subject: [PATCH 4/8] fix(core): preserve quoted Windows UNC arguments Keep backslash pairs literal inside double-quoted Windows paths while preserving existing quote and unquoted whitespace escapes. Cover UNC paths, UNC paths with spaces, drive paths and escaped quotes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/utils/arguments.security.spec.ts | 4 ++++ src/core/utils/strings.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/utils/arguments.security.spec.ts b/src/core/utils/arguments.security.spec.ts index 176a22bc6..58669a09e 100644 --- a/src/core/utils/arguments.security.spec.ts +++ b/src/core/utils/arguments.security.spec.ts @@ -9,6 +9,10 @@ describe("literal function arguments", () => { ['a"b c"d', ["ab cd"]], [String.raw`two\ words \"literal\"`, ["two words", '"literal"']], [String.raw`C:\tools\func --file "C:\path with spaces\file"`, [String.raw`C:\tools\func`, "--file", String.raw`C:\path with spaces\file`]], + [String.raw`--useHttps --cert "\\fileserver\devcerts\local.pfx"`, ["--useHttps", "--cert", String.raw`\\fileserver\devcerts\local.pfx`]], + [String.raw`--cert "\\fileserver\dev certs\local.pfx"`, ["--cert", String.raw`\\fileserver\dev certs\local.pfx`]], + [String.raw`--cert "C:\dev certs\local.pfx"`, ["--cert", String.raw`C:\dev certs\local.pfx`]], + [String.raw`--name "say \"hello\""`, ["--name", 'say "hello"']], ["$VALUE *.json ; && > plain", ["$VALUE", "*.json", ";", "&&", ">", "plain"]], ["'C:\\tools\\func' trailing\\", [String.raw`C:\tools\func`, "trailing\\"]], ])("preserves literal tokens for %j", (input, expected) => { diff --git a/src/core/utils/strings.ts b/src/core/utils/strings.ts index e350ad79b..480885587 100644 --- a/src/core/utils/strings.ts +++ b/src/core/utils/strings.ts @@ -35,8 +35,8 @@ export function parseCommandArguments(value: string): string[] { const character = value[index]; if (character === "\\" && quote !== "'") { const next = value[index + 1]; - // Preserve ordinary backslashes in Windows paths, but support escaped quotes/whitespace. - if (next === "\\" || next === '"' || (!quote && (next === "'" || (next !== undefined && /\s/.test(next))))) { + // Preserve backslash pairs in quoted Windows paths, but support escaped quotes/whitespace. + if (next === '"' || (!quote && (next === "\\" || next === "'" || (next !== undefined && /\s/.test(next))))) { token += next; index++; } else { From 7592246e9c8ae4833abd514e5373f464bbc9e3ed Mon Sep 17 00:00:00 2001 From: Long Hao Date: Mon, 19 Oct 2026 10:36:38 +0000 Subject: [PATCH 5/8] fix(core): preserve compatibility in trusted tool execution Preserve caller PATH selection, actual wrapper behavior and Functions shell arguments while preventing implicit current-directory tool preemption. Restore baseline certificate options and retain fixed runtime and managed-tool identities. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/www/docs/cli/swa-build.md | 6 - docs/www/docs/cli/swa-start.md | 4 - package-lock.json | 95 +++++ package.json | 1 + src/cli/commands/build/build.security.spec.ts | 27 +- src/cli/commands/build/build.spec.ts | 28 +- src/cli/commands/build/build.ts | 6 +- src/cli/commands/db/init/init.ts | 9 +- .../start/start.compatibility.spec.ts | 99 +++++ src/cli/commands/start/start.security.spec.ts | 89 ++--- src/cli/commands/start/start.ts | 53 ++- src/cli/index.security.spec.ts | 55 +++ src/cli/index.ts | 2 + src/core/dataApiBuilder/dab.ts | 21 +- src/core/func-core-tools.ts | 19 +- src/core/internal-tools.security.spec.ts | 11 +- src/core/ssl.security.spec.ts | 359 +++++++++++------ src/core/ssl.ts | 128 +++--- src/core/utils/arguments.security.spec.ts | 85 ++-- src/core/utils/command.ts | 25 +- .../utils/executable.compatibility.spec.ts | 358 +++++++++++++++++ src/core/utils/executable.security.spec.ts | 119 ++++-- src/core/utils/executable.ts | 363 ++++++++++++------ src/core/utils/process.ts | 197 +++++----- src/core/utils/project-roots.security.spec.ts | 73 ++-- src/core/utils/strings.ts | 39 -- src/swa.d.ts | 1 + tests/_mocks/internal-process.js | 4 +- tests/fixtures/compatibility-recorder.cjs | 18 + 29 files changed, 1633 insertions(+), 661 deletions(-) create mode 100644 src/cli/commands/start/start.compatibility.spec.ts create mode 100644 src/core/utils/executable.compatibility.spec.ts create mode 100644 tests/fixtures/compatibility-recorder.cjs diff --git a/docs/www/docs/cli/swa-build.md b/docs/www/docs/cli/swa-build.md index 06a7bb1c2..8b9f23792 100644 --- a/docs/www/docs/cli/swa-build.md +++ b/docs/www/docs/cli/swa-build.md @@ -22,12 +22,6 @@ Installing dependencies is only supported for Node.js projects with a `package.j For other frameworks, you need to manually install dependencies. ::: -Automatic installation uses a global native npm, Yarn or pnpm installation, or its standard Node/Corepack entrypoint, outside your project directories. Standard Windows npm/Yarn/pnpm shims are supported through their installed Node entrypoints. - -If the CLI reports an unsupported custom wrapper, install the package manager globally outside the project or enable its standard Corepack shim. Project-local executables and arbitrary shell wrappers are not used for automatic installation. Explicit app/API build commands remain shell commands and can use your normal project scripts. - -Automatic installation invokes the selected installation's Node entrypoint directly; custom shell-wrapper flags or redirects are not applied. Run workflows that depend on those customizations manually or from an explicit build command. - ## Options Here are the options you can use with `swa build`: diff --git a/docs/www/docs/cli/swa-start.md b/docs/www/docs/cli/swa-start.md index ade6d1b64..778e254a7 100644 --- a/docs/www/docs/cli/swa-start.md +++ b/docs/www/docs/cli/swa-start.md @@ -75,10 +75,6 @@ Then access the application with the emulated services from `http://localhost:42 If your project includes API functions, the CLI will check if the Azure Functions Core Tools are installed and available. If not, the CLI will download and install the right version of the Azure Functions Core Tools. -Installed Functions and Data API Builder tools must be native executables outside project directories; unsupported command wrappers use the CLI-managed download instead. You can also start a tool yourself and connect with `--api-devserver-url` or `--data-api-devserver-url`. - -`--func-args` is parsed as literal arguments, not a shell command. Use quotes to keep a value containing spaces together, for example `--func-args '--language-worker "argument with spaces"'`. Single/double quotes and escaped quotes/whitespace are supported; unmatched quotes are an error. Variables, wildcards and shell operators are not expanded. The `--run` option remains an intentional shell command, including npm/npx/Yarn shortcuts. - #### Start the API server automatically Run the CLI and provide the folder that contains the API backend (a valid Azure Functions App project): diff --git a/package-lock.json b/package-lock.json index 089eb0b37..2e47f0c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "open": "^8.4.2", "openid-client": "^6.7.1", "ora": "^5.4.1", + "pem": "^1.14.8", "prompts": "^2.4.2", "rimraf": "^5.0.7", "selfsigned": "^2.4.1", @@ -4075,6 +4076,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -4555,6 +4564,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/crypto-random-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", @@ -5432,6 +5449,14 @@ "node": ">= 0.4" } }, + "node_modules/es6-promisify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", + "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -6881,6 +6906,11 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -8083,6 +8113,16 @@ "node": ">=14.18" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/memfs": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.9.3.tgz", @@ -11589,6 +11629,20 @@ "node": ">=0.10" } }, + "node_modules/pem": { + "version": "1.14.8", + "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.8.tgz", + "integrity": "sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==", + "dependencies": { + "es6-promisify": "^7.0.0", + "md5": "^2.3.0", + "os-tmpdir": "^1.0.2", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -17608,6 +17662,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" + }, "check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -17962,6 +18021,11 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, "crypto-random-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", @@ -18623,6 +18687,11 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true }, + "es6-promisify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", + "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==" + }, "esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -19670,6 +19739,11 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -20587,6 +20661,16 @@ } } }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "memfs": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.9.3.tgz", @@ -22944,6 +23028,17 @@ "integrity": "sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==", "dev": true }, + "pem": { + "version": "1.14.8", + "resolved": "https://registry.npmjs.org/pem/-/pem-1.14.8.tgz", + "integrity": "sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==", + "requires": { + "es6-promisify": "^7.0.0", + "md5": "^2.3.0", + "os-tmpdir": "^1.0.2", + "which": "^2.0.2" + } + }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index 006fb2ace..f2b3c8440 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "open": "^8.4.2", "openid-client": "^6.7.1", "ora": "^5.4.1", + "pem": "^1.14.8", "prompts": "^2.4.2", "rimraf": "^5.0.7", "selfsigned": "^2.4.1", diff --git a/src/cli/commands/build/build.security.spec.ts b/src/cli/commands/build/build.security.spec.ts index b887288f8..037417626 100644 --- a/src/cli/commands/build/build.security.spec.ts +++ b/src/cli/commands/build/build.security.spec.ts @@ -24,7 +24,7 @@ describe("automatic dependency installs are internal, user build strings are del vol.reset(); mockInternalProcesses(); vi.spyOn(process, "cwd").mockReturnValue("/workspace"); - vi.stubEnv("PATH", "/workspace:.:/trusted/bin"); + vi.stubEnv("PATH", "/trusted/bin"); vol.fromJSON({ "/bin/sh": "inert system-shell fixture; never executed", "/workspace/app/package.json": "{}", @@ -34,6 +34,12 @@ describe("automatic dependency installs are internal, user build strings are del "/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); }); @@ -46,17 +52,16 @@ describe("automatic dependency installs are internal, user build strings are del ["npm", "package-lock.json"], ["yarn", "yarn.lock"], ["pnpm", "pnpm-lock.yaml"], - ])("runs automatic %s install through its absolute identity rather than a project shell", async (manager, lock) => { + ])("runs automatic %s install through the caller-selected identity without off-PATH CWD preemption", async (manager, lock) => { vol.fromJSON({ [`/workspace/app/${lock}`]: "" }); - 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`); - expect(directLaunches()).toContainEqual( - expect.objectContaining({ - file: `/trusted/bin/${manager}`, - args: ["install"], - options: expect.objectContaining({ cwd: "/workspace/app" }), - }), - ); + 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 () => { diff --git a/src/cli/commands/build/build.spec.ts b/src/cli/commands/build/build.spec.ts index 707499a0b..950d5b262 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -48,7 +48,12 @@ describe("swa build", () => { await build({ ...DEFAULT_CONFIG, appBuildCommand: "npm run something" }); - expect(command.execInternalCommand).toHaveBeenCalledWith({ executable: "/trusted/bin/npm", args: ["install"], cwd: process.cwd() }); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: process.cwd(), + resolutionOptions: { ...contextFor("."), cwd: process.cwd() }, + }); expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( @@ -67,7 +72,12 @@ describe("swa build", () => { appBuildCommand: "npm run something", }); - expect(command.execInternalCommand).toHaveBeenCalledWith({ executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("app") }); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("app"), + resolutionOptions: { ...contextFor("."), cwd: path.resolve("app") }, + }); expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( @@ -90,7 +100,12 @@ describe("swa build", () => { await build({ ...DEFAULT_CONFIG, apiLocation: "api", apiBuildCommand: "npm run something" }); - expect(command.execInternalCommand).toHaveBeenCalledWith({ executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("api") }); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("api"), + resolutionOptions: { ...contextFor("api"), cwd: path.resolve("api") }, + }); expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( @@ -109,7 +124,12 @@ describe("swa build", () => { apiBuildCommand: "npm run something", }); - expect(command.execInternalCommand).toHaveBeenCalledWith({ executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("api") }); + expect(command.execInternalCommand).toHaveBeenCalledWith({ + executable: "/trusted/bin/npm", + args: ["install"], + cwd: path.resolve("api"), + resolutionOptions: { ...contextFor("api"), cwd: path.resolve("api") }, + }); expect(command.runCommand).toHaveBeenCalledTimes(1); expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); }); diff --git a/src/cli/commands/build/build.ts b/src/cli/commands/build/build.ts index 732c39840..ccb1eb531 100644 --- a/src/cli/commands/build/build.ts +++ b/src/cli/commands/build/build.ts @@ -138,7 +138,11 @@ async function installNpmDependencies(packageJsonPath: string, options: Executab try { const packageManager = await detectPackageManager(packageJsonPath); logger.log(`Installing dependencies with "${packageManager} install"...`); - const spec = resolveTrustedPackageManager(packageManager, { ...options, untrustedRoots: [...(options.untrustedRoots ?? []), packageJsonPath] }); + const spec = resolveTrustedPackageManager(packageManager, { + ...options, + cwd: path.resolve(packageJsonPath), + untrustedRoots: [...(options.untrustedRoots ?? []), packageJsonPath], + }); execInternalCommand({ ...spec, args: [...spec.args, "install"], cwd: path.resolve(packageJsonPath) }); } catch (error) { logger.error(`Cannot install dependencies:`); diff --git a/src/cli/commands/db/init/init.ts b/src/cli/commands/db/init/init.ts index 757423275..7f337da83 100644 --- a/src/cli/commands/db/init/init.ts +++ b/src/cli/commands/db/init/init.ts @@ -20,7 +20,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; } @@ -45,7 +45,8 @@ export async function init(options: SWACLIConfig) { logger.log(`Creating ${DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME} configuration file`, "swa"); - const dataApiBinary = await getDataApiBuilderBinaryPath({ untrustedRoots: getProjectRoots(options, [directory]) }); + const resolutionOptions = { untrustedRoots: getProjectRoots(options, [directory]), cwd: path.resolve(directory) }; + const dataApiBinary = await getDataApiBuilderBinaryPath(resolutionOptions); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -88,7 +89,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 @@ -110,7 +111,7 @@ export async function init(options: SWACLIConfig) { // todo:DAB CLI doesn't return an error code when it fails, so we need to allow stdio to be inherited (this will be fixed in the March release) // It would be better to have our own logs since DAB CLI refers to itself in its success messages // which may lead to confusion for swa cli users ex: `SUGGESTION: Use 'dab add [entity-name] [options]' to add new entities in your config.` - execFileCommand(dataApiBinary, directory, args); + execFileCommand(dataApiBinary, directory, args, resolutionOptions); // not logging anything here since DAB CLI logs success messages or error messages and we can't catch an error } diff --git a/src/cli/commands/start/start.compatibility.spec.ts b/src/cli/commands/start/start.compatibility.spec.ts new file mode 100644 index 000000000..dc14b2d18 --- /dev/null +++ b/src/cli/commands/start/start.compatibility.spec.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { DEFAULT_CONFIG } from "../../../config.js"; +import { createShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; +import { superviseProcesses } from "../../../core/utils/process.js"; +import { start } from "./start.js"; + +vi.mock("../../../core/utils/process.js", () => ({ superviseProcesses: vi.fn() })); +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("../../../core/func-core-tools.js", () => ({ + getCoreToolsBinary: async () => process.execPath, + getNodeMajorVersion: () => 20, + detectTargetCoreToolsVersion: () => 4, + isCoreToolsVersionCompatible: () => true, +})); +vi.mock("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); +vi.mock("../../../core/utils/logger.js", () => ({ + logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn() }, +})); + +describe("Functions raw shell suffix compatibility (benign native argv recorder)", () => { + let sandbox: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + 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 }); + }); + + it("preserves variable expansion and a shell operator 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"; + process.env.SWA_COMPAT_CERT = String.raw`\\fileserver\dev certs\local.pfx`; + const variable = (name: string) => (process.platform === "win32" ? `%${name}%` : `$${name}`); + const file = path.join(api, "certificate with spaces.pfx"); + const suffix = `--file "${file}" --cert "${variable("SWA_COMPAT_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", process.env.SWA_COMPAT_CERT, "--name", "expanded two words"], + cwd: fs.realpathSync(api), + suffixCompleted: true, + }; + // Positive control: the baseline raw suffix is executed by the actual trusted OS shell. + expect(execute(createShellExecutionSpec(`"${process.execPath}" start --cors "*" --port 7071 ${suffix}`, api))).toEqual(expected); + fs.writeFileSync(report, ""); + fs.unlinkSync(marker); + + 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.security.spec.ts b/src/cli/commands/start/start.security.spec.ts index ea7b1e5cd..ddfcfa8eb 100644 --- a/src/cli/commands/start/start.security.spec.ts +++ b/src/cli/commands/start/start.security.spec.ts @@ -4,12 +4,10 @@ import { childProcess, directLaunches, mockInternalProcesses } from "../../../.. import { vol, fs } from "memfs"; import process from "node:process"; import path from "node:path"; -import { concurrently } from "concurrently"; import { start } from "./start.js"; import { DEFAULT_CONFIG } from "../../../config.js"; import { getCertificate } from "../../../core/ssl.js"; -vi.mock("concurrently", () => ({ concurrently: vi.fn() })); 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, @@ -47,7 +45,6 @@ describe("start internal launches versus intentional customer commands", () => { vol.mkdirSync(options.apiLocation, { recursive: true }); vol.mkdirSync(options.dataApiLocation, { recursive: true }); mockInternalProcesses(); - vi.mocked(concurrently).mockReturnValue({ result: Promise.resolve([]) } as any); vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); }); @@ -55,100 +52,74 @@ describe("start internal launches versus intentional customer commands", () => { process.env = originalEnv; }); - function internalShellCommands() { - return vi - .mocked(concurrently) - .mock.calls.flatMap(([commands]) => - (commands as { name?: string; command: string }[]).filter((command) => ["swa", "api", "dataApi"].includes(command.name ?? "")), - ); + 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("/bin/sh"); + expect(launch?.shell).toBe(false); + expect(launch?.args[0]).toBe("-c"); + expect(launch?.args[1]).toMatch(/^'\/trusted tools\/func' 'start' '--cors' '\*' '--port' '7071'/); + return launch!; } it("launches the emulator using current Node and literal server argv without the default shell adapter", async () => { await start(options); - expect( - internalShellCommands() - .filter((command) => command.name === "swa") - .map((command) => command.command), - ).toEqual([]); - const emulator = directLaunches().find((launch) => launch.file === process.execPath); + 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!.options?.shell).not.toBe(true); + expect(emulator!.shell).toBe(false); + expect(launches().some(({ args }) => args.some((arg: string) => arg.startsWith("swa-internal-")))).toBe(false); }); - it("launches Functions with the selected absolute identity, literal args and explicit API cwd", async () => { + it("launches Functions with a bound absolute prefix and explicit API cwd", async () => { + process.env.PATH = `${options.apiLocation}:/bin`; await start(options); - expect(directLaunches()).toContainEqual( - expect.objectContaining({ - file: "/trusted tools/func", - args: ["start", "--cors", "*", "--port", "7071"], - options: expect.objectContaining({ cwd: options.apiLocation }), - }), - ); - expect( - internalShellCommands() - .filter((command) => command.name === "api") - .map((command) => command.command), - ).toEqual([]); + expect(functionsLaunch().args[1]).toBe("'/trusted tools/func' 'start' '--cors' '*' '--port' '7071'"); + const api = directLaunches().find(({ options: launchOptions }) => launchOptions?.cwd === options.apiLocation)!; + expect(api.options?.env?.PATH).toBe(`${options.apiLocation}:/bin`); }); - it("passes funcArgs quotes and metacharacters as literal argv through the Functions launch", async () => { + it("preserves the raw funcArgs shell suffix without changing the selected Functions prefix", async () => { // Arrange - const funcArgs = '--name "two words" "" literal&value $VALUE'; + const funcArgs = '--name "two words" "" "$VALUE" && echo done'; // Act await start({ ...options, funcArgs }); // Assert - expect(directLaunches()).toContainEqual( - expect.objectContaining({ - file: "/trusted tools/func", - args: ["start", "--cors", "*", "--port", "7071", "--name", "two words", "", "literal&value", "$VALUE"], - options: expect.objectContaining({ cwd: options.apiLocation, shell: false }), - }), - ); + expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' ${funcArgs}`); }); - it("rejects malformed funcArgs before spawning any emulator or tool", async () => { + it("delegates unmatched suffix quotes to the bound shell instead of applying the unused argv parser", async () => { // Arrange / Act await start({ ...options, funcArgs: '"unclosed' }); // Assert - expect(childProcess.spawn).not.toHaveBeenCalled(); + expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' "unclosed`); + expect(launches().some(({ file }) => file === process.execPath)).toBe(true); }); it("launches DAB with the selected absolute identity, literal args and explicit data cwd", async () => { await start(options); - expect(directLaunches()).toContainEqual( + expect(launches()).toContainEqual( expect.objectContaining({ file: "/trusted tools/dab", args: ["start", "-c", "staticwebapp.database.config.json", "--no-https-redirect"], - options: expect.objectContaining({ cwd: options.dataApiLocation }), + cwd: options.dataApiLocation, + shell: false, }), ); - expect( - internalShellCommands() - .filter((command) => command.name === "dataApi") - .map((command) => command.command), - ).toEqual([]); }); 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 = [ - ...vi - .mocked(concurrently) - .mock.calls.flatMap(([commands]) => - (commands as { name?: string; command: string }[]).filter((command) => command.name === "run").map((command) => command.command), - ), - ...childProcess.exec.mock.calls.map(([command]) => command), - ...directLaunches().flatMap((launch) => launch.args), - ]; + const shells = [...childProcess.exec.mock.calls.map(([command]) => command), ...launches().flatMap((launch) => launch.args)]; expect(shells.some((command) => command.includes(run))).toBe(true); - expect(directLaunches()).toContainEqual( - expect.objectContaining({ file: "/bin/sh", args: ["-c", run], options: expect.objectContaining({ shell: false }) }), - ); + 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 () => { diff --git a/src/cli/commands/start/start.ts b/src/cli/commands/start/start.ts index f8acfe2be..e5a45d631 100644 --- a/src/cli/commands/start/start.ts +++ b/src/cli/commands/start/start.ts @@ -18,9 +18,8 @@ import { swaCLIEnv } from "../../../core/env.js"; import { getCertificate } from "../../../core/ssl.js"; import { loadPackageJson } from "../../../core/utils/json.js"; import { getProjectRoots } from "../../../core/utils/cli-config.js"; -import { createShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; +import { createShellExecutionSpec, createToolShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; import { NamedExecutionSpec, ProcessCloseEvent, superviseProcesses } from "../../../core/utils/process.js"; -import { parseCommandArguments } from "../../../core/utils/strings.js"; const packageInfo = loadPackageJson(); @@ -67,14 +66,6 @@ export async function start(options: SWACLIConfig) { let useDataApiDevServer: string | undefined | null = undefined; let startupCommand: string | undefined | null = undefined; const launchCwd = process.cwd(); - let funcArgv: string[]; - try { - funcArgv = parseCommandArguments(funcArgs ?? ""); - } catch (error) { - process.exitCode = 1; - logger.error(`Invalid --func-args: ${(error as Error).message}`, true); - return; - } let resolvedPortNumber = await isAcceptingTcpConnections({ host, port }); if (resolvedPortNumber === 0) { @@ -200,8 +191,8 @@ export async function start(options: SWACLIConfig) { 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(resolutionOptions); + // Check the caller's PATH before using the managed download. + const funcBinary = await getCoreToolsBinary({ ...resolutionOptions, cwd: path.resolve(userWorkflowConfig.apiLocation) }); const nodeMajorVersion = getNodeMajorVersion(); const targetVersion = detectTargetCoreToolsVersion(nodeMajorVersion); @@ -227,11 +218,13 @@ export async function start(options: SWACLIConfig) { // serve the api if and only if the user provides a folder via the --api-location flag if (isApiLocationExistsOnDisk) { - serveApi = { - executable: funcBinary, - args: ["start", "--cors", "*", "--port", String(apiPort), ...funcArgv], - cwd: path.resolve(userWorkflowConfig.apiLocation), - }; + serveApi = createToolShellExecutionSpec( + funcBinary, + ["start", "--cors", "*", "--port", String(apiPort)], + path.resolve(userWorkflowConfig.apiLocation), + funcArgs ?? "", + resolutionOptions, + ); } } } @@ -244,7 +237,7 @@ export async function start(options: SWACLIConfig) { dataApiPort = parseUrl(useDataApiDevServer)?.port; } else { if (dataApiLocation) { - const dataApiBinary = await getDataApiBuilderBinaryPath(resolutionOptions); + const dataApiBinary = await getDataApiBuilderBinaryPath({ ...resolutionOptions, cwd: path.resolve(dataApiLocation) }); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -258,6 +251,7 @@ export async function start(options: SWACLIConfig) { executable: dataApiBinary, args: ["start", "-c", DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME, "--no-https-redirect"], cwd: path.resolve(dataApiLocation), + resolutionOptions, }; dataApiPort = DEFAULT_CONFIG.dataApiPort; } @@ -269,14 +263,17 @@ export async function start(options: SWACLIConfig) { if (ssl) { if (sslCert === undefined && sslKey === undefined) { logger.warn(`WARNING: Using built-in UNSIGNED certificate. DO NOT USE IN PRODUCTION!`); - const pemFilepath = await getCertificate({ - selfSigned: true, - days: 365, - commonName: host, - organization: `Azure Static Web Apps CLI ${packageInfo.version}`, - organizationUnit: "Azure Engineering", - emailAddress: `secure@microsoft.com`, - }); + const pemFilepath = await getCertificate( + { + selfSigned: true, + days: 365, + commonName: host, + organization: `Azure Static Web Apps CLI ${packageInfo.version}`, + organizationUnit: "Azure Engineering", + emailAddress: `secure@microsoft.com`, + }, + resolutionOptions, + ); sslCert = pemFilepath; sslKey = pemFilepath; } else { @@ -333,9 +330,9 @@ export async function start(options: SWACLIConfig) { const env: NodeJS.ProcessEnv = { ...process.env, ...swaCLIEnv() }; const commands: NamedExecutionSpec[] = [{ name: "swa", execution: { executable: process.execPath, args: [mshaPath], cwd: launchCwd, env } }]; - if (serveApi) commands.push({ name: "api", execution: { ...serveApi, env } }); + 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 } }); + if (startup) commands.push({ name: "run", execution: { ...startup, env: { ...env, ...startup.env } } }); logger.silly(`Starting the SWA emulator with the following configuration:`); logger.silly({ diff --git a/src/cli/index.security.spec.ts b/src/cli/index.security.spec.ts index 6ea5e3a57..0d13bfc14 100644 --- a/src/cli/index.security.spec.ts +++ b/src/cli/index.security.spec.ts @@ -1,4 +1,7 @@ import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; +import dotenv from "dotenv"; +import fs from "node:fs"; +import os from "node:os"; import process from "node:process"; import path from "node:path"; import { swaMagic } from "./index.js"; @@ -7,6 +10,7 @@ import { promptOrUseDefault } from "../core/prompts.js"; // Keep the test at swaMagic's public boundary without importing login, // credential stores, HTTP servers, or SSL initialization. +vi.mock("dotenv", () => ({ default: { config: vi.fn() } })); vi.mock("./commands/deploy/register.js", () => ({ default: vi.fn() })); vi.mock("./commands/init/register.js", () => ({ default: vi.fn() })); vi.mock("./commands/login/register.js", () => ({ default: vi.fn() })); @@ -29,6 +33,10 @@ describe("magic-flow same-installation reentry", () => { 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([]); @@ -61,4 +69,51 @@ describe("magic-flow same-installation reentry", () => { expect(commands).toHaveLength(1); expect(commands[0]).toMatch(/(?:^| )build$/); }); + + it.each([false, true])("captures caller PATH before project dotenv and preserves it across reentry (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 }); + vi.spyOn(process, "cwd").mockReturnValue(project); + vi.stubEnv("PATH", present ? inherited : ""); + if (!present) delete process.env.PATH; + vi.stubEnv("SWA_CLI_INTERNAL_PATH_ABSENT", ""); + delete process.env.SWA_CLI_INTERNAL_PATH_ABSENT; + childProcess.spawnSync.mockReturnValue({ status: 0, stdout: "/usr/bin:/bin", stderr: "" }); + vi.mocked(dotenv.config).mockImplementation(() => { + process.env.PATH = injected; + return { parsed: { PATH: injected } }; + }); + for (let generation = 0; generation < 2; generation++) { + vi.resetModules(); + const cli = await import("./index.js"); + const resolver = await import("../core/utils/executable.js"); + expect(process.env.PATH).toBe(injected); + expect(resolver.resolveTrustedExecutable(tool, { cwd: project })).toBe(present ? fs.realpathSync(path.join(inherited, filename)) : undefined); + if (generation === 0) { + 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."); + const value = process.platform === "win32" ? env.Path ?? env.PATH : env.PATH; + expect(value).toBe(present ? inherited : process.platform === "win32" ? "" : undefined); + expect(env.SWA_CLI_INTERNAL_PATH_ABSENT).toBe(!present && process.platform === "win32" ? "1" : undefined); + vi.stubEnv("PATH", value ?? ""); + if (value === undefined) delete process.env.PATH; + vi.stubEnv("SWA_CLI_INTERNAL_PATH_ABSENT", env.SWA_CLI_INTERNAL_PATH_ABSENT ?? ""); + if (env.SWA_CLI_INTERNAL_PATH_ABSENT === undefined) delete process.env.SWA_CLI_INTERNAL_PATH_ABSENT; + } + } + } finally { + fs.rmSync(sandbox, { recursive: true }); + } + }); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index d78706006..1d72ef610 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,6 @@ import dotenv from "dotenv"; +import { captureCallerPath } from "../core/utils/executable.js"; +captureCallerPath(); dotenv.config(); import process from "node:process"; diff --git a/src/core/dataApiBuilder/dab.ts b/src/core/dataApiBuilder/dab.ts index 021eba5ab..4768964f6 100644 --- a/src/core/dataApiBuilder/dab.ts +++ b/src/core/dataApiBuilder/dab.ts @@ -14,13 +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, - resolveManagedExecutable, - resolveTrustedExecutable, - UnsupportedExecutableError, -} from "../utils/executable.js"; +import { ExecutableResolutionOptions, probeExecutable, resolveManagedExecutable, resolveTrustedExecutable } from "../utils/executable.js"; /** * Gets the filepath where the Microsoft.DataApiBuilder.exe is located @@ -145,23 +139,16 @@ export function getDefaultDataApiBuilderBinaryForOS(platform: string): string { /** * Returns installed version if installed else undefined - * @param options Additional project-controlled roots to exclude from discovery + * @param options Execution context for system interpreters and managed paths * @returns installed version */ async function getInstalledDataApiBuilder(options: ExecutableResolutionOptions): Promise<{ binaryPath: string; version: string } | undefined> { logger.silly(`Running ${DATA_API_BUILDER_COMMAND} --version`); - let binaryPath: string | undefined; - try { - binaryPath = resolveTrustedExecutable(DATA_API_BUILDER_COMMAND, options); - } catch (error) { - if (!(error instanceof UnsupportedExecutableError)) throw error; - logger.warn(`${error.message} Using the CLI-managed Data API Builder download instead.`); - return undefined; - } + const binaryPath = resolveTrustedExecutable(DATA_API_BUILDER_COMMAND, options); if (!binaryPath) return undefined; try { - const { stdout } = await probeExecutable(binaryPath, ["--version"]); + const { stdout } = await probeExecutable(binaryPath, ["--version"], options); const version = /^Microsoft\.DataApiBuilder\s+(\S+)/.exec(stdout.trim())?.[1]; return version ? { binaryPath, version } : undefined; } catch (error) { diff --git a/src/core/func-core-tools.ts b/src/core/func-core-tools.ts index e65d2b5fa..fbda7877d 100644 --- a/src/core/func-core-tools.ts +++ b/src/core/func-core-tools.ts @@ -8,13 +8,7 @@ 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, - resolveManagedExecutable, - resolveTrustedExecutable, - UnsupportedExecutableError, -} from "./utils/executable.js"; +import { ExecutableResolutionOptions, probeExecutable, resolveManagedExecutable, resolveTrustedExecutable } from "./utils/executable.js"; const RELEASES_FEED_URL = "https://functionscdn.azureedge.net/public/cli-feed-v4.json"; const DEFAULT_FUNC_BINARY = "func"; @@ -87,21 +81,14 @@ async function getInstalledSystemCoreTools(options: ExecutableResolutionOptions) return undefined; } - let binaryPath: string | undefined; - try { - binaryPath = resolveTrustedExecutable(DEFAULT_FUNC_BINARY, options); - } catch (error) { - if (!(error instanceof UnsupportedExecutableError)) throw error; - logger.warn(`${error.message} Using the CLI-managed Functions Core Tools download instead.`); - return undefined; - } + const binaryPath = resolveTrustedExecutable(DEFAULT_FUNC_BINARY, options); if (!binaryPath) return undefined; if (installedSystemCoreToolsVersionIsCached && cachedSystemCoreToolsVersion !== undefined) { return { binaryPath, version: cachedSystemCoreToolsVersion }; } try { - const { stdout } = await probeExecutable(binaryPath, ["--version"]); + const { stdout } = await probeExecutable(binaryPath, ["--version"], options); const version = getMajorVersion(stdout.trim()); return Number.isFinite(version) ? { binaryPath, version } : undefined; } catch (error) { diff --git a/src/core/internal-tools.security.spec.ts b/src/core/internal-tools.security.spec.ts index 05d96367a..c7ee186b9 100644 --- a/src/core/internal-tools.security.spec.ts +++ b/src/core/internal-tools.security.spec.ts @@ -24,8 +24,7 @@ describe("internal executable identity (no fixture execution)", () => { vi.spyOn(os, "platform").mockReturnValue("linux"); vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); vi.spyOn(process, "cwd").mockReturnValue("/workspace"); - // CWD, empty and relative entries are not trusted internal tool roots. - vi.stubEnv("PATH", "/workspace:.:relative-tools::/trusted/bin"); + vi.stubEnv("PATH", "/trusted/bin"); vol.fromJSON({ "/workspace/func": "inert fixture", "/workspace/dab": "inert fixture", @@ -53,9 +52,11 @@ describe("internal executable identity (no fixture execution)", () => { }); 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("/trusted/bin/func"); + 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 () => { @@ -76,9 +77,11 @@ describe("internal executable identity (no fixture execution)", () => { }); 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("/trusted/bin/dab"); + 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 () => { diff --git a/src/core/ssl.security.spec.ts b/src/core/ssl.security.spec.ts index 6d70cd565..0f7d62a5c 100644 --- a/src/core/ssl.security.spec.ts +++ b/src/core/ssl.security.spec.ts @@ -1,151 +1,286 @@ -import "../../tests/_mocks/fs.js"; -import "../../tests/_mocks/posix.js"; -import childProcess from "node:child_process"; -import { createRequire } from "node:module"; -import { X509Certificate, createPrivateKey } from "node:crypto"; -import { vol, fs } from "memfs"; +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"; -vi.mock("./utils/logger.js", () => ({ - logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() }, -})); - -describe("SSL without executable discovery", () => { - let ssl: typeof import("./ssl.js"); - let importLaunches: { api: string; args: unknown[] }[]; - const launches: { api: string; args: unknown[] }[] = []; - const require = createRequire(import.meta.url); - - function interceptProcesses() { - // pem is CommonJS, so vi.mock alone cannot intercept its require(). Patch - // the actual Node object BEFORE loading ssl (or pem). - for (const api of ["spawn", "exec", "execFile", "execSync", "execFileSync", "fork"] as const) { - vi.spyOn(childProcess, api).mockImplementation(((...args: unknown[]) => { - launches.push({ api, args }); - throw new Error("External process disabled by SSL regression test"); - }) as any); - } - vi.spyOn(childProcess, "spawnSync").mockImplementation(((...args: unknown[]) => { - launches.push({ api: "spawnSync", args }); - return { status: 0, stdout: Buffer.from("OpenSSL 3.0.0"), stderr: Buffer.alloc(0) }; - }) as any); - } +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 () => { - interceptProcesses(); - ssl = await import("./ssl.js"); - importLaunches = launches.splice(0); + 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(() => { - launches.length = 0; - interceptProcesses(); - // Never let the baseline generation path create temporary real files or - // run openssl. A pure in-process generator must still work when this - // external dependency is unavailable. - let pemPath: string; - try { - pemPath = require.resolve("pem"); - } catch (error) { - // GREEN may remove pem from the lockfile entirely. It is only an - // optional interception target, never a required generator fixture. - if ((error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") return; - throw error; + 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]; } - if (require.cache[pemPath]) { - vi.spyOn(require("pem"), "createCertificate").mockImplementation((...args: unknown[]) => - (args[1] as (error: Error) => void)(new Error("External certificate generator unavailable")), - ); + 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`); + }); } }); - it("does not launch a subprocess when SSL is merely imported (even when SSL is disabled)", () => { - expect(importLaunches).toEqual([]); + 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("reuses a fresh cache 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(cache); + expect(moduleLoader.load).not.toHaveBeenCalled(); + expect(fs.readFileSync(cache, "utf8")).toBe("cached certificate fixture"); }); - it("generates a strong certificate with subject and SAN metadata without an external generator", async () => { - vol.reset(); - vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); - const generated = ssl.getCertificate({ + 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", - emailAddress: "secure@microsoft.com", altNames: ["localhost", "127.0.0.1"], - }); - await expect(generated).resolves.toBe("/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem"); - const result = await generated; - expect(launches).toEqual([]); - const content = fs.readFileSync(result, "utf8") as string; - const certificatePem = content.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/)?.[0] ?? content; - const certificate = new X509Certificate(certificatePem); - const key = createPrivateKey(content); - // One concept: generated certificate retains the public TLS contract. - expect(key.asymmetricKeyDetails?.modulusLength).toBeGreaterThanOrEqual(2048); - const forge = require("node-forge"); - expect(forge.pki.certificateFromPem(certificatePem).signatureOid).toBe(forge.pki.oids.sha256WithRSAEncryption); + }; + 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(key)).toBe(true); + expect(certificate.checkPrivateKey(createPrivateKey(content))).toBe(true); expect(Date.parse(certificate.validTo) - Date.parse(certificate.validFrom)).toBeGreaterThanOrEqual(364 * 24 * 60 * 60 * 1000); }); - it("reuses a fresh certificate cache without consulting the external generator", async () => { - vol.reset(); - vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); - vol.fromJSON({ "/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem": "cached certificate fixture" }); - const before = launches.length; - await ssl.getCertificate({ selfSigned: true }); - expect(launches.length).toBe(before); - expect(fs.readFileSync("/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem", "utf8")).toBe("cached certificate fixture"); + 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("clamps small RSA requests and preserves IPv6 SAN plus short requested validity", async () => { - // Arrange - vol.reset(); - vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); - // Act - const file = await ssl.getCertificate({ keyBitsize: 1024, days: 2, commonName: "local.test", altNames: ["::1", "127.0.0.1"] }); - const content = fs.readFileSync(file, "utf8") as string; - const certificate = new X509Certificate(content); - // Assert: generated TLS material respects the strong-certificate contract. - expect(createPrivateKey(content).asymmetricKeyDetails?.modulusLength).toBeGreaterThanOrEqual(2048); - expect(certificate.subjectAltName).toContain("IP Address:0:0:0:0:0:0:0:1"); - expect(Date.parse(certificate.validTo) - Date.parse(certificate.validFrom)).toBe(2 * 24 * 60 * 60 * 1000); - expect(launches).toEqual([]); - }); - - it("regenerates an age-expired cache in process", async () => { - // Arrange: policy is file age, not certificate notAfter. - vol.reset(); - vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); - const cache = "/home/tester/.swa/certificates/ssl/swa-cli-UNSIGNED.pem"; - vol.fromJSON({ [cache]: "old inert cache" }); + 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; }); - // Act - const file = await ssl.getCertificate({ days: 1 }); - // Assert - expect(file).toBe(cache); - expect(() => new X509Certificate(fs.readFileSync(file, "utf8") as string)).not.toThrow(); - expect(launches).toEqual([]); + 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.each([0, -1, 1.5])("rejects invalid validity %s without executing a generator", async (days) => { - // Arrange - vol.reset(); - vi.spyOn(os, "homedir").mockReturnValue("/home/tester"); - // Act / Assert - await expect(ssl.getCertificate({ days })).rejects.toThrow(/positive integer/); - expect(launches).toEqual([]); + it("rejects a project OpenSSL override before loading or probing pem", async () => { + process.env.OPENSSL_BIN = path.join(project, "bin", process.platform === "win32" ? "openssl.exe" : "openssl"); + const ssl = await import("./ssl.js"); + await expect(ssl.getCertificate({ selfSigned: true })).rejects.toThrow(/trusted native OpenSSL/); + expect(moduleLoader.load).not.toHaveBeenCalled(); }); }); diff --git a/src/core/ssl.ts b/src/core/ssl.ts index 6da935344..f9682deae 100644 --- a/src/core/ssl.ts +++ b/src/core/ssl.ts @@ -1,8 +1,9 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { isIP } from "node:net"; -import selfsigned from "selfsigned"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { ExecutableResolutionOptions, prepareExecution, resolveTrustedExecutable, resolveTrustedExecutablePath } from "./utils/executable.js"; import { logger } from "./utils/logger.js"; const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; @@ -26,7 +27,7 @@ export interface PEMOptions { /** Set the name of the serial file, without extension. - only together with options.serviceCertificate and never in tandem with options.serial */ serialFile?: string; - /** Built-in certificates always use SHA256. */ + /** Hash function to use (either md5 sha1 or sha256, defaults to sha256) */ hash?: string; /** CSR for the certificate, if not defined a new one is generated */ @@ -80,14 +81,68 @@ export interface PEMOptions { altNames?: string[]; } +interface SSLCertificate { + csr: string; + serviceKey: string; + certificate: string; +} + +interface Pem { + config(options: { pathOpenSSL: string }): void; + createCertificate(options: PEMOptions, callback: (error: Error | null, certificate: SSLCertificate) => void): void; +} + +let pem: Pem | undefined; + +function isPem(value: unknown): value is Pem { + return ( + !!value && + typeof value === "object" && + "config" in value && + typeof value.config === "function" && + "createCertificate" in value && + typeof value.createCertificate === "function" + ); +} + +function certificateGenerator(resolutionOptions: ExecutableResolutionOptions): Pem { + if (pem) return pem; + const configured = process.env.OPENSSL_BIN; + const executable = + configured && /[\\/]/.test(configured) + ? resolveTrustedExecutablePath(configured, resolutionOptions) + : resolveTrustedExecutable(configured || "openssl", resolutionOptions); + if (!executable || prepareExecution({ executable, args: [], resolutionOptions }).executable !== executable) { + throw new Error("Cannot find a trusted native OpenSSL executable outside the project. 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: unknown = createRequire(import.meta.url)("pem"); + if (!isPem(loaded)) { + throw new Error("The installed pem module does not expose its certificate API."); + } + 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. * - * @param options Subject, validity and SAN options for an in-process self-signed certificate. + * @param options PEM options (passed to the "`pem`" npm library) * @returns The abosulte filepath to the PEM file from cache. */ -export async function getCertificate(options: PEMOptions): Promise { +export async function getCertificate(options: PEMOptions, resolutionOptions: ExecutableResolutionOptions = {}): Promise { const cacheDir = path.join(os.homedir(), ".swa", "certificates", "ssl"); const cachePath = path.join(cacheDir, "swa-cli-UNSIGNED.pem"); @@ -106,11 +161,11 @@ export async function getCertificate(options: PEMOptions): Promise { logger.silly("No cached certificate found."); logger.silly("Creating new certificate..."); - const unsignedCertificate = await createCertificate(options); + const unsignedCertificate = await createCertificate(options, resolutionOptions); logger.silly("Writing certificate to cache..."); - const pemContent = [unsignedCertificate.private, unsignedCertificate.cert].join("\n"); + const pemContent = [unsignedCertificate.csr, unsignedCertificate.serviceKey, unsignedCertificate.certificate].join("\n"); await fs.mkdir(cacheDir, { recursive: true }); await fs.writeFile(cachePath, pemContent); @@ -119,62 +174,9 @@ export async function getCertificate(options: PEMOptions): Promise { } } -async function createCertificate(options: PEMOptions): Promise<{ private: string; cert: string }> { - const unsupported = [ - "serviceCertificate", - "serviceKey", - "serviceKeyPassword", - "serial", - "serialFile", - "csr", - "clientKeyPassword", - "extFile", - "config", - "clientKey", - "csrConfigFile", - ] as const; - if (unsupported.some((option) => options[option] !== undefined)) { - throw new Error("Custom signing keys/CSR options are not supported by the built-in certificate generator. Use --ssl-cert and --ssl-key."); - } - const commonName = options.commonName ?? "localhost"; - const attrs = [ - { name: "commonName", value: commonName }, - { name: "countryName", value: options.country }, - { name: "stateOrProvinceName", value: options.state }, - { name: "localityName", value: options.locality }, - { name: "organizationName", value: options.organization }, - { name: "organizationalUnitName", value: options.organizationUnit }, - { name: "emailAddress", value: options.emailAddress }, - ].filter((attribute): attribute is { name: string; value: string } => attribute.value !== undefined); - const altNames = [...new Set([commonName, ...(options.altNames ?? [])])].map((name) => - isIP(name) ? { type: 7, ip: name } : { type: 2, value: name }, - ); - const keySize = Math.max(2048, options.keyBitsize ?? 2048); - const days = options.days ?? 365; - if (!Number.isInteger(keySize) || !Number.isInteger(days) || days < 1) { - throw new Error("Certificate key size and validity must be positive integer values."); - } +async function createCertificate(options: PEMOptions, resolutionOptions: ExecutableResolutionOptions): Promise { + const generator = certificateGenerator(resolutionOptions); return new Promise((resolve, reject) => { - selfsigned.generate( - attrs, - { - keySize, - days, - algorithm: "sha256", - extensions: [ - { name: "basicConstraints", cA: false }, - { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, - { name: "extKeyUsage", serverAuth: true }, - { name: "subjectAltName", altNames }, - ], - }, - (err, keys) => { - if (err) { - reject(err); - } else { - resolve(keys); - } - }, - ); + generator.createCertificate(options, (error, certificate) => (error ? reject(error) : resolve(certificate))); }); } diff --git a/src/core/utils/arguments.security.spec.ts b/src/core/utils/arguments.security.spec.ts index 58669a09e..cb1c99427 100644 --- a/src/core/utils/arguments.security.spec.ts +++ b/src/core/utils/arguments.security.spec.ts @@ -1,27 +1,68 @@ -import { parseCommandArguments } from "./strings.js"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { execInternalCommand } from "./command.js"; +import { createToolShellExecutionSpec, prepareExecution } from "./executable.js"; -describe("literal function arguments", () => { - it.each([ - ["", []], - [" \t\n ", []], - ["--name \"two words\" 'single quoted'", ["--name", "two words", "single quoted"]], - ["\"\" '' tail", ["", "", "tail"]], - ['a"b c"d', ["ab cd"]], - [String.raw`two\ words \"literal\"`, ["two words", '"literal"']], - [String.raw`C:\tools\func --file "C:\path with spaces\file"`, [String.raw`C:\tools\func`, "--file", String.raw`C:\path with spaces\file`]], - [String.raw`--useHttps --cert "\\fileserver\devcerts\local.pfx"`, ["--useHttps", "--cert", String.raw`\\fileserver\devcerts\local.pfx`]], - [String.raw`--cert "\\fileserver\dev certs\local.pfx"`, ["--cert", String.raw`\\fileserver\dev certs\local.pfx`]], - [String.raw`--cert "C:\dev certs\local.pfx"`, ["--cert", String.raw`C:\dev certs\local.pfx`]], - [String.raw`--name "say \"hello\""`, ["--name", 'say "hello"']], - ["$VALUE *.json ; && > plain", ["$VALUE", "*.json", ";", "&&", ">", "plain"]], - ["'C:\\tools\\func' trailing\\", [String.raw`C:\tools\func`, "trailing\\"]], - ])("preserves literal tokens for %j", (input, expected) => { - // Arrange / Act / Assert: no expansion or process execution. - expect(parseCommandArguments(input as string)).toEqual(expected); +describe("real internal argument boundary", () => { + let sandbox: string; + let report: string; + const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); + + beforeEach(() => { + 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 }); + }); + + 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.each(['"unterminated', "'unterminated", "before\0after"])("rejects malformed input %j before launch", (input) => { - // Arrange / Act / Assert - expect(() => parseCommandArguments(input)).toThrow(/unterminated quote|null character/); + 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 92ebd09b9..a3c3debb1 100644 --- a/src/core/utils/command.ts +++ b/src/core/utils/command.ts @@ -1,31 +1,34 @@ import process from "process"; -import { execFileSync, execSync } from "child_process"; -import { assertAbsoluteExecutable, ExecutableResolutionOptions, getTrustedShell, InternalExecutionSpec } from "./executable.js"; +import { execFileSync, execSync, SpawnSyncOptions } from "child_process"; +import { ExecutableResolutionOptions, getTrustedShell, InternalExecutionSpec, prepareExecution } from "./executable.js"; export function runCommand(command: string, cwd?: string, options: ExecutableResolutionOptions = {}) { + const shell = getTrustedShell({ ...options, untrustedRoots: [...(options.untrustedRoots ?? []), ...(cwd ? [{ directory: cwd }] : [])] }); execSync(command, { stdio: "inherit", cwd: cwd ?? process.cwd(), - shell: getTrustedShell({ ...options, untrustedRoots: [...(options.untrustedRoots ?? []), ...(cwd ? [{ directory: cwd }] : [])] }), + shell, // Set CI to avoid extra NPM logs and potentially unwanted interactive modes env: { ...process.env, + ...(process.platform === "win32" ? { ComSpec: shell } : {}), // Internal flag to avoid duplicating user messages SWA_CLI_INTERNAL_COMMAND: "1", }, }); } -export function execFileCommand(command: string, cwd?: string, args?: string[]) { - return execInternalCommand({ executable: command, cwd, args: args ?? [] }); +export function execFileCommand(command: string, cwd?: string, args?: string[], resolutionOptions?: ExecutableResolutionOptions) { + return execInternalCommand({ executable: command, cwd, args: args ?? [], resolutionOptions }); } -export function execInternalCommand(spec: InternalExecutionSpec) { - assertAbsoluteExecutable(spec.executable); - if (spec.windowsVerbatimArguments) throw new Error("Windows customer-shell specs must use the asynchronous process supervisor."); - return execFileSync(spec.executable, spec.args, { +export function execInternalCommand(execution: InternalExecutionSpec) { + const spec = prepareExecution(execution); + const options: SpawnSyncOptions = { cwd: spec.cwd ?? process.cwd(), stdio: "inherit", - env: { ...process.env, ...spec.env, SWA_CLI_INTERNAL_COMMAND: "1" }, - }); + 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.compatibility.spec.ts b/src/core/utils/executable.compatibility.spec.ts new file mode 100644 index 000000000..ff56e14d4 --- /dev/null +++ b/src/core/utils/executable.compatibility.spec.ts @@ -0,0 +1,358 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { execInternalCommand } from "./command.js"; +import { + createShellExecutionSpec, + InternalExecutionSpec, + prepareExecution, + probeExecutable, + resolveTrustedExecutable, + resolveTrustedPackageManager, +} from "./executable.js"; + +const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); +const windows = process.platform === "win32"; + +describe("trusted external wrapper compatibility (benign native fixtures)", () => { + let sandbox: string; + let project: string; + let external: string; + let report: string; + let output: string; + + beforeEach(() => { + 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 }); + }); + + 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", { untrustedRoots: [project] }); + 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 = { untrustedRoots: [project] }; + 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, resolutionOptions }); + 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 = { untrustedRoots: [project] }; + + const baseline = createShellExecutionSpec(`"${tool}" ${command}`, project, resolutionOptions); + 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: [], resolutionOptions }; + 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, untrustedRoots: [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, resolutionOptions }); + 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 = { untrustedRoots: [project] }; + const trustedCmd = createShellExecutionSpec("echo fixture", project, resolutionOptions).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, resolutionOptions }); + 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", { untrustedRoots: [project] }); + 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.security.spec.ts b/src/core/utils/executable.security.spec.ts index 48ed6367c..27d12298c 100644 --- a/src/core/utils/executable.security.spec.ts +++ b/src/core/utils/executable.security.spec.ts @@ -7,6 +7,7 @@ import process from "node:process"; import { assertAbsoluteExecutable, createShellExecutionSpec, + prepareExecution, resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager, @@ -29,29 +30,37 @@ describe("trusted executable boundary (inert files only)", () => { vi.unstubAllEnvs(); }); - it("ignores empty, relative, cwd and project-descendant PATH entries", () => { + 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("rejects a PATH alias to project descendants", () => { + 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")).toBeUndefined(); + expect(resolveTrustedExecutable("func")).toBe("/project/tools/func"); + vi.stubEnv("PATH", "/trusted/bin"); + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); }); - it("rejects aliases of separately supplied API roots", () => { + it("allows configured API PATH tools without relaxing managed API-root exclusions", () => { // 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 - expect(resolveTrustedExecutable("func", { untrustedRoots: ["/api-alias"] })).toBeUndefined(); + const options = { untrustedRoots: ["/api-alias"] }; + expect(resolveTrustedExecutable("func", options)).toBe("/api-root/bin/func"); + expect(() => resolveManagedExecutable("/api-root/bin/func", options)).toThrow(/inside the project/); + vi.stubEnv("PATH", "/trusted/bin"); + expect(resolveTrustedExecutable("func", options)).toBe("/trusted/bin/func"); }); it("does not confuse a project-prefix sibling with a descendant", () => { @@ -89,7 +98,7 @@ describe("trusted executable boundary (inert files only)", () => { expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); }); - it("fails closed when a supplied exclusion root cannot be canonicalized", () => { + it("fails closed when a managed-path exclusion root cannot be canonicalized", () => { // Arrange vi.stubEnv("PATH", "/trusted/bin"); const realpath = fs.realpathSync.bind(fs); @@ -98,49 +107,106 @@ describe("trusted executable boundary (inert files only)", () => { return realpath(file, ...args); }); // Act / Assert - expect(() => resolveTrustedExecutable("func", { untrustedRoots: ["/denied-root"] })).toThrow("denied"); + expect(() => resolveManagedExecutable("/trusted/bin/not-downloaded/func", { untrustedRoots: ["/denied-root"] })).toThrow("denied"); }); - it("rejects unknown interpreter scripts instead of executing their shebang", () => { + it("rejects a trusted wrapper whose interpreter resolves inside the project", () => { // Arrange - vol.fromJSON({ "/trusted/bin/func": "#!/usr/bin/env node\ninert" }); + vol.fromJSON({ "/trusted/bin/func": "#!/project/node\ninert", "/project/node": "inert native fixture" }); fs.chmodSync("/trusted/bin/func", 0o755); vi.stubEnv("PATH", "/trusted/bin"); // Act / Assert - expect(() => resolveTrustedExecutable("func")).toThrow(/Unsupported internal tool script/); + const executable = resolveTrustedExecutable("func")!; + expect(executable).toBe("/trusted/bin/func"); + expect(() => prepareExecution({ executable, args: ["start"] })).toThrow(/trusted script interpreter/); }); - it.each(["func", "./func", "/trusted/tool.cmd", "/trusted/tool.bat", "/trusted/tool.ps1"])( - "refuses a relative identity or shell shim: %s", + 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) => { - // Arrange / Act / Assert - expect(() => assertAbsoluteExecutable(file)).toThrow(); + expect(() => assertAbsoluteExecutable(file)).not.toThrow(); }, ); - it.each(["yarn", "pnpm"] as const)("binds a known %s Corepack script to current Node", (manager) => { + 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("binds future caller PATH aliases canonically while retaining managed exclusions and global alternatives", () => { + 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(["/project/future", "/trusted/future", "/trusted/bin"]); + expect(resolveTrustedExecutable("node")).toBe("/project/future/node"); + expect(() => resolveManagedExecutable("/outside-project-alias/future/node")).toThrow(/inside the project/); + 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: process.execPath, args: [script] }); + expect(resolveTrustedPackageManager(manager)).toEqual({ executable: `/trusted/bin/${manager}`, args: [], resolutionOptions: {} }); }); - it("rejects a recognized package entrypoint aliased into the project", () => { + 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")).toThrow(/Cannot find a supported npm/); + expect(resolveTrustedPackageManager("npm")).toEqual({ executable: "/trusted/bin/npm", args: [], resolutionOptions: {} }); + 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/); }); }); @@ -155,7 +221,9 @@ describe("simulated Windows known shim identities (not native proof)", () => { vi.stubEnv("PATH", '"C:\\trusted";.;C:\\project'); vi.stubEnv("SystemRoot", "C:\\Windows"); vi.stubEnv("ComSpec", "C:\\project\\cmd.exe"); - for (const file of ["C:\\Windows\\System32\\cmd.exe", "C:\\project\\cmd.exe"]) fixtures.set(canonical(file), "inert"); + 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; @@ -174,7 +242,7 @@ describe("simulated Windows known shim identities (not native proof)", () => { ["npm", "npm", "bin/npm-cli.js"], ["yarn", "corepack", "dist/yarn.js"], ["pnpm", "corepack", "dist/pnpm.js"], - ] as const)("binds a standard Windows %s shim to current Node and recognized script", (manager, provider, entry) => { + ] 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); @@ -182,7 +250,11 @@ describe("simulated Windows known shim identities (not native proof)", () => { 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: process.execPath, args: [canonical(script)] }); + expect(resolveTrustedPackageManager(manager)).toEqual({ + executable: canonical(`C:\\trusted\\${manager}.cmd`), + args: [], + resolutionOptions: {}, + }); }); it("selects System32 cmd rather than project ComSpec for intentional commands", () => { @@ -193,15 +265,18 @@ describe("simulated Windows known shim identities (not native proof)", () => { executable: "c:\\windows\\system32\\cmd.exe", args: ["/d", "/s", "/c", `"${command}"`], cwd: "C:\\project", + env: { ComSpec: "c:\\windows\\system32\\cmd.exe" }, windowsVerbatimArguments: true, }); }); - it("rejects case-variant project descendants", () => { + 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(); }); }); diff --git a/src/core/utils/executable.ts b/src/core/utils/executable.ts index ecd448014..dbe5aec86 100644 --- a/src/core/utils/executable.ts +++ b/src/core/utils/executable.ts @@ -2,32 +2,76 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; -import { execFile } from "node:child_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; - /** Used only by the explicit Windows customer-shell adapter. */ + resolutionOptions?: ExecutableResolutionOptions; + /** Arguments have already been encoded for an absolute Windows shell. */ windowsVerbatimArguments?: boolean; } export interface ExecutableDirectoryExclusion { - /** An exact search directory, not its descendants. */ + /** An exact invocation directory, not a recursive project boundary. */ directory: string; } export type ExecutableExclusion = string | ExecutableDirectoryExclusion; export interface ExecutableResolutionOptions { - /** Strings exclude project trees; directory entries exclude only canonical search directories. */ + /** Working directory for caller-relative PATH entries and the selected tool's probe. */ + cwd?: string; + /** Project boundaries for fixed system identities and managed paths, not configured PATH choices. */ untrustedRoots?: readonly ExecutableExclusion[]; } export class UnsupportedExecutableError extends Error {} +let callerPath: { value: string | undefined } | undefined; +const absentCallerPath = "SWA_CLI_INTERNAL_PATH_ABSENT"; + +function pathValue(env: NodeJS.ProcessEnv): string | undefined { + const windows = os.platform() === "win32"; + const key = Object.keys(env).find((name) => (windows ? name.toLowerCase() === "path" : name === "PATH")); + return key ? env[key] : undefined; +} + +/** Capture inherited selection before project dotenv values can fill a missing PATH. */ +export function captureCallerPath(): void { + let value = pathValue(process.env); + if (os.platform() === "win32" && process.env[absentCallerPath] === "1" && value === "") { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") delete process.env[key]; + } + value = undefined; + } + delete process.env[absentCallerPath]; + callerPath = { value }; +} + +function markAbsentCallerPath(env: NodeJS.ProcessEnv): void { + delete env[absentCallerPath]; + if (os.platform() === "win32" && callerPath && callerPath.value === undefined) env[absentCallerPath] = "1"; +} + +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; + } + return env; +} + function platformPath() { return os.platform() === "win32" ? path.win32 : path; } @@ -44,11 +88,6 @@ export function assertAbsoluteExecutable(file: string): void { if (!isAbsoluteExecutable(file)) { throw new Error(`Internal executable must be an absolute path: ${file}`); } - if (/\.(?:cmd|bat|ps1)$/i.test(file)) { - throw new UnsupportedExecutableError( - `Command shims cannot be used as internal executables: ${file}. Use a native executable or a supported Node entrypoint.`, - ); - } } function realPath(file: string): string | undefined { @@ -93,13 +132,8 @@ function excludedPath(file: string, options: ExecutableResolutionOptions): boole return roots.some((root) => containsPath(root, file) || containsPath(realPath(root) ?? root, file)); } -function excludedSearchDirectory(directory: string, options: ExecutableResolutionOptions): boolean { - return (options.untrustedRoots ?? []).some((entry) => typeof entry !== "string" && matchesDirectory(directory, entry)); -} - -function trustedPath(file: string, options: ExecutableResolutionOptions): string | undefined { +function executablePath(file: string): string | undefined { if (!isAbsoluteExecutable(file)) return undefined; - if (excludedPath(file, options)) return undefined; let canonical: string | undefined; try { @@ -110,25 +144,35 @@ function trustedPath(file: string, options: ExecutableResolutionOptions): string throw error; } if (!canonical || !isAbsoluteExecutable(canonical)) return undefined; - if (excludedPath(canonical, options)) return undefined; return canonical; } -/** Bind a managed path even before download, while checking existing parent-directory aliases. */ -export function resolveManagedExecutable(file: string, options: ExecutableResolutionOptions = {}): string { - assertAbsoluteExecutable(file); +function trustedPath(file: string, options: ExecutableResolutionOptions): string | undefined { + if (excludedPath(file, options)) return undefined; + const canonical = executablePath(file); + return canonical && !excludedPath(canonical, options) ? canonical : undefined; +} + +function canonicalPath(file: string): string | undefined { const paths = platformPath(); const suffix: string[] = []; let parent = file; let canonical = realPath(parent); while (!canonical) { const next = paths.dirname(parent); - if (next === parent) throw new Error(`Cannot resolve managed executable directory: ${file}`); + if (next === parent) return undefined; suffix.unshift(paths.basename(parent)); parent = next; canonical = realPath(parent); } - const resolved = paths.join(canonical, ...suffix); + return paths.join(canonical, ...suffix); +} + +/** Bind a managed path even before download, while checking existing parent-directory aliases. */ +export function resolveManagedExecutable(file: string, options: ExecutableResolutionOptions = {}): string { + assertAbsoluteExecutable(file); + const resolved = canonicalPath(file); + if (!resolved) throw new Error(`Cannot resolve managed executable directory: ${file}`); if (excludedPath(file, options) || excludedPath(resolved, options)) { throw new Error("The managed tool directory resolves inside the project. Move the CLI tool cache outside the project and retry."); } @@ -136,24 +180,40 @@ export function resolveManagedExecutable(file: string, options: ExecutableResolu return resolved; } -function pathDirectories(options: ExecutableResolutionOptions): string[] { +function pathDirectories(options: ExecutableResolutionOptions, env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string[] { const windows = os.platform() === "win32"; - const pathKey = Object.keys(process.env).find((key) => (windows ? key.toLowerCase() === "path" : key === "PATH")); - return (pathKey ? process.env[pathKey] ?? "" : "") - .split(windows ? ";" : ":") - .map((directory) => (windows ? directory.replace(/^"(.*)"$/, "$1") : directory)) - .filter(isAbsoluteExecutable) - .filter((directory) => { - if (excludedPath(directory, options) || excludedSearchDirectory(directory, options)) return false; - let canonical: string | undefined; - try { - canonical = realPath(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EACCES") return false; - throw error; - } - return !canonical || (!excludedPath(canonical, options) && !excludedSearchDirectory(canonical, options)); + let value = callerPath ? callerPath.value : pathValue(env); + if (value === undefined && !windows) { + const defaultEnv = { ...env }; + delete defaultEnv.PATH; + const result = spawnSync(getTrustedShell(options), ["-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 || ".")) + .flatMap((directory) => { + let canonical: string | undefined; + try { + canonical = canonicalPath(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EACCES") return []; + throw error; + } + return canonical ? [canonical] : []; + }) + ); } function isExecutableFile(file: string): boolean { @@ -168,24 +228,23 @@ function isExecutableFile(file: string): boolean { } } -function assertNativeExecutable(file: string): void { - assertAbsoluteExecutable(file); - // Script interpreters may rediscover node or a shell after cwd changes. +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(2); + const header = Buffer.alloc(4096); try { - fs.readSync(fd, header, 0, header.length, 0); + 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); } - if (header.toString() === "#!" || /\.(?:js|cjs|mjs)$/i.test(file)) { - throw new UnsupportedExecutableError(`Unsupported internal tool script: ${file}. Install a native tool or use the CLI-managed download.`); - } } /** - * Resolve once, before changing cwd. Never use which/where or implicit cwd/PATHEXT. - * Returned identities are canonical so directory symlinks/junctions cannot hide project tools. + * 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)) { @@ -193,85 +252,29 @@ export function resolveTrustedExecutable(name: string, options: ExecutableResolu } const windows = os.platform() === "win32"; const names = windows && !/\.(exe|com)$/i.test(name) ? [`${name}.exe`, `${name}.com`, `${name}.cmd`, `${name}.bat`, `${name}.ps1`] : [name]; - let unsupported: UnsupportedExecutableError | undefined; - for (const directory of pathDirectories(options)) { + for (const directory of pathDirectories(options, process.env, options.cwd)) { for (const filename of names) { - const candidate = trustedPath(platformPath().join(directory, filename), options); - if (!candidate || excludedSearchDirectory(platformPath().dirname(candidate), options) || !isExecutableFile(candidate)) continue; - try { - assertNativeExecutable(candidate); - return candidate; - } catch (error) { - if (!(error instanceof UnsupportedExecutableError)) throw error; - unsupported = error; - } + const candidate = executablePath(platformPath().join(directory, filename)); + if (!candidate || !isExecutableFile(candidate)) continue; + return candidate; } } - if (unsupported) throw unsupported; return undefined; } -const PACKAGE_MANAGER_ENTRYPOINTS = { - npm: ["bin/npm-cli.js"], - yarn: ["bin/yarn.js"], - pnpm: ["bin/pnpm.cjs", "bin/pnpm.js"], -} as const; +export function resolveTrustedExecutablePath(file: string, options: ExecutableResolutionOptions = {}): string | undefined { + const executable = trustedPath(file, options); + return executable && isExecutableFile(executable) ? executable : undefined; +} -/** - * Bind known package-manager scripts to the already-running Node, not their shell shims. - * Installations without a recognized entrypoint fail explicitly; shell-wrapper customizations are not executed. - */ -export function resolveTrustedPackageManager( - name: keyof typeof PACKAGE_MANAGER_ENTRYPOINTS, - options: ExecutableResolutionOptions = {}, -): InternalExecutionSpec { - const paths = platformPath(); - const windows = os.platform() === "win32"; - for (const directory of pathDirectories(options)) { - const launcher = trustedPath(paths.join(directory, windows ? `${name}.cmd` : name), options); - if (launcher && excludedSearchDirectory(paths.dirname(launcher), options)) continue; - const providers = [ - { packageName: name, entries: PACKAGE_MANAGER_ENTRYPOINTS[name] }, - { packageName: "corepack", entries: [`dist/${name}.js`] }, - ]; - for (const { packageName, entries } of providers) { - const packageRoots = [paths.join(directory, "node_modules", packageName)]; - if (launcher) { - for (const entry of entries) { - const segments = entry.split("/"); - if (launcher.toLowerCase().endsWith(paths.join(...segments).toLowerCase())) { - packageRoots.unshift(paths.dirname(paths.dirname(launcher))); - } - } - } - for (const root of packageRoots) { - const manifest = trustedPath(paths.join(root, "package.json"), options); - if (!manifest) continue; - const metadata: { name?: string; bin?: string | Record } = JSON.parse(fs.readFileSync(manifest, "utf8")); - if (metadata.name !== packageName) continue; - const entry = typeof metadata.bin === "string" ? metadata.bin : metadata.bin?.[name]; - if (!entry || !entries.some((supported) => supported === entry.replace(/^\.\//, ""))) continue; - const script = trustedPath(paths.join(root, entry), options); - if (script && !excludedSearchDirectory(paths.dirname(script), options) && fs.statSync(script).isFile()) { - return { executable: process.execPath, args: [script] }; - } - } - } - } - let executable: string | undefined; - try { - executable = resolveTrustedExecutable(name, options); - } catch (error) { - if (!(error instanceof UnsupportedExecutableError)) throw error; - throw new UnsupportedExecutableError( - `Unsupported ${name} shim. Install ${name} globally outside the project with its Node entrypoint, or use a native installation.`, - ); - } - if (executable) return { executable, args: [] }; - throw new Error(`Cannot find a supported ${name} installation outside the project. Install ${name} globally and retry.`); +/** 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: [], resolutionOptions: options, ...(options.cwd ? { cwd: options.cwd } : {}) }; + throw new Error(`Cannot find ${name} on the caller's PATH.`); } -/** System shells are used only for deliberate customer command strings. */ +/** Bind the shell used for caller syntax or a trusted external command shim. */ export function getTrustedShell(options: ExecutableResolutionOptions = {}): string { if (os.platform() !== "win32") { const shell = trustedPath("/bin/sh", options); @@ -294,12 +297,132 @@ export function getTrustedWindowsSystemExecutable(name: "cmd.exe" | "taskkill.ex export function createShellExecutionSpec(command: string, cwd: string, options: ExecutableResolutionOptions = {}): InternalExecutionSpec { const executable = getTrustedShell(options); if (os.platform() === "win32") { - return { executable, args: ["/d", "/s", "/c", `"${command}"`], cwd, windowsVerbatimArguments: true }; + return { executable, args: ["/d", "/s", "/c", `"${command}"`], cwd, env: { ComSpec: executable }, windowsVerbatimArguments: true }; } return { executable, args: ["-c", command], cwd }; } -export function probeExecutable(executable: string, args: string[]): Promise<{ stdout: string; stderr: string }> { +function internalEnvironment(options: ExecutableResolutionOptions, overrides?: NodeJS.ProcessEnv, cwd = process.cwd()): NodeJS.ProcessEnv { + const env = executionEnvironment(overrides); + const windows = os.platform() === "win32"; + const directories = pathDirectories(options, env, cwd); + for (const key of Object.keys(env)) { + if (windows && ["path", "comspec", "nodefaultcurrentdirectoryinexepath"].includes(key.toLowerCase())) delete env[key]; + } + env[windows ? "Path" : "PATH"] = directories.join(windows ? ";" : ":"); + if (windows) { + env.ComSpec = getTrustedWindowsSystemExecutable("cmd.exe", options); + env.NoDefaultCurrentDirectoryInExePath = "1"; + } + markAbsentCallerPath(env); + 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 = "", + options: ExecutableResolutionOptions = {}, +): InternalExecutionSpec { assertAbsoluteExecutable(executable); - return promisify(execFile)(executable, args, { encoding: "utf8", shell: false }); + const context = { untrustedRoots: [...(options.untrustedRoots ?? []), { directory: cwd }] }; + const env = internalEnvironment(context, 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, resolutionOptions: context }); + prefix = [execution.executable, ...execution.args].map(quoteArgument).join(" "); + } + return { ...createShellExecutionSpec(`${prefix}${suffix ? ` ${suffix}` : ""}`, cwd, context), env }; +} + +/** Native identities stay direct; script loaders retain canonical caller-selected PATH entries. */ +export function prepareExecution(spec: InternalExecutionSpec): InternalExecutionSpec { + assertAbsoluteExecutable(spec.executable); + if (spec.windowsVerbatimArguments) return { ...spec, env: executionEnvironment(spec.env) }; + const options = { + untrustedRoots: [ + ...(spec.resolutionOptions?.untrustedRoots ?? []), + ...(spec.cwd ? [spec.resolutionOptions ? { directory: spec.cwd } : spec.cwd] : []), + ], + }; + if (os.platform() === "win32" && /\.(?:cmd|bat)$/i.test(spec.executable)) { + return { + ...createToolShellExecutionSpec(spec.executable, spec.args, spec.cwd ?? process.cwd(), "", options), + env: { ...internalEnvironment(options, spec.env, spec.cwd), SWA_CLI_INTERNAL_EXECUTABLE: spec.executable }, + }; + } + const script = shebang(spec.executable); + if (script) { + const interpreter = trustedPath(script.interpreter, options); + 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(options, spec.env, spec.cwd), + }; + } + if (/\.(?:ps1|js|cjs|mjs)$/i.test(spec.executable)) { + throw new UnsupportedExecutableError(`Cannot determine the interpreter for ${spec.executable}.`); + } + if (!callerPath) return spec.env ? { ...spec, env: executionEnvironment(spec.env) } : spec; + // Preserve absence too: a reentered CLI must capture PATH before loading its own project dotenv. + const env = executionEnvironment(spec.env); + for (const key of Object.keys(env)) { + if (os.platform() === "win32" ? key.toLowerCase() === "path" : key === "PATH") delete env[key]; + } + if (callerPath.value !== undefined) env[os.platform() === "win32" ? "Path" : "PATH"] = callerPath.value; + // Windows spawn fills an omitted PATH from the parent; the entry hook restores this placeholder's absence. + else if (os.platform() === "win32") env.Path = ""; + markAbsentCallerPath(env); + return { ...spec, env }; +} + +export function probeExecutable( + executable: string, + args: string[], + options: ExecutableResolutionOptions = {}, +): Promise<{ stdout: string; stderr: string }> { + const spec = prepareExecution({ executable, args, cwd: options.cwd, resolutionOptions: options }); + 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.ts b/src/core/utils/process.ts index 600c79d79..6e0b62fc2 100644 --- a/src/core/utils/process.ts +++ b/src/core/utils/process.ts @@ -1,7 +1,9 @@ -import { ChildProcess, execFile, spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { concurrently, Command, CloseEvent } from "concurrently"; import os from "node:os"; import process from "node:process"; -import { assertAbsoluteExecutable, ExecutableResolutionOptions, getTrustedWindowsSystemExecutable, InternalExecutionSpec } from "./executable.js"; +import { ExecutableResolutionOptions, getTrustedWindowsSystemExecutable, InternalExecutionSpec, prepareExecution } from "./executable.js"; import { logger } from "./logger.js"; export interface NamedExecutionSpec { @@ -9,79 +11,83 @@ export interface NamedExecutionSpec { execution: InternalExecutionSpec; } -export interface ProcessCloseEvent { - command: { name: string }; - exitCode: number | NodeJS.Signals; - killed: boolean; +export interface ProcessCloseEvent extends Pick { terminationSignal?: NodeJS.Signals; error?: Error; } -interface RunningProcess { - child: ChildProcess; - finished: boolean; - terminationSignal?: NodeJS.Signals; - finish: (exitCode: number | NodeJS.Signals, error?: Error) => void; -} - /** - * Raw-I/O supervision preserves wait-for-all completion, without a shell-string adapter. + * The named concurrently core retains raw I/O and wait-for-all completion. * Only spawn errors and parent signals trigger coordinated cleanup; ordinary exits do not. */ export async function superviseProcesses(commands: NamedExecutionSpec[], options: ExecutableResolutionOptions = {}): Promise { if (!commands.length) throw new Error("No processes were provided."); - commands.forEach(({ execution }) => assertAbsoluteExecutable(execution.executable)); + const executions = commands.map(({ execution }) => prepareExecution(execution)); const windows = os.platform() === "win32"; const taskkill = windows ? getTrustedWindowsSystemExecutable("taskkill.exe", options) : undefined; - const running: RunningProcess[] = []; + let running: Command[] = []; + const owned = new Map(); + const finished = new Set(); + const errors = new Map(); + const signals = new Map(); let stopping: NodeJS.Signals | undefined; let escalation: NodeJS.Timeout | undefined; let deadline: NodeJS.Timeout | undefined; - const kill = (child: ChildProcess, signal: NodeJS.Signals) => { - if (!child.pid) return; + const kill = (pid: number, signal = "SIGTERM") => { + const index = owned.get(pid); + if (index === undefined) throw new Error("Refusing to stop an unowned process."); if (taskkill) { - execFile(taskkill, ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }, (error) => { - if (error && child.exitCode === null && child.signalCode === null) { + execFile(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (error) => { + if (error && !finished.has(index)) { logger.warn("Unable to stop a child process tree. Check the running emulator/tool processes."); } }); } else { try { // Each child owns its group, including descendants created by customer shells. - process.kill(-child.pid, signal); + process.kill(-pid, signal); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } } }; + const finish = (command: Command, error?: Error) => { + if (finished.has(command.index)) return; + if (error) errors.set(command.index, error); + const now = new Date(); + command.close.next({ + command, + index: command.index, + exitCode: 1, + killed: signals.has(command.index), + timings: { startDate: now, endDate: now, durationSeconds: 0 }, + }); + // A spawn error may also produce a later native close event; count the command only once. + command.close.complete(); + }; + + const terminate = (signal: NodeJS.Signals) => { + for (const command of running) { + if (finished.has(command.index)) continue; + signals.set(command.index, stopping ?? signal); + try { + command.kill(signal); + } catch (error) { + logger.warn(`Unable to stop a child process: ${(error as Error).message}`); + } + } + }; const stop = (signal: NodeJS.Signals) => { if (stopping) return; stopping = signal; - for (const entry of running) { - if (!entry.finished) { - entry.terminationSignal = signal; - try { - kill(entry.child, signal); - } catch (error) { - logger.warn(`Unable to stop a child process: ${(error as Error).message}`); - } - } - } + terminate(signal); escalation = setTimeout(() => { - for (const entry of running) { - if (!entry.finished) { - try { - kill(entry.child, "SIGKILL"); - } catch (error) { - logger.warn(`Unable to stop a child process: ${(error as Error).message}`); - } - } - } + terminate("SIGKILL"); deadline = setTimeout(() => { - for (const entry of running) { - if (!entry.finished) entry.finish(1, new Error("Child process cleanup timed out. Check the running emulator/tool processes.")); + for (const command of running) { + finish(command, new Error("Child process cleanup timed out. Check the running emulator/tool processes.")); } }, 5000); }, 5000); @@ -91,56 +97,75 @@ export async function superviseProcesses(commands: NamedExecutionSpec[], options process.on("SIGINT", onInterrupt); process.on("SIGTERM", onTerminate); + const closeEvent = (event: CloseEvent): ProcessCloseEvent => ({ + ...event, + terminationSignal: signals.get(event.index), + error: errors.get(event.index), + killed: event.killed || signals.has(event.index), + }); try { - const results = commands.map( - ({ name, execution }) => - new Promise((resolve) => { - if (stopping) { - resolve({ command: { name }, exitCode: 1, killed: true, terminationSignal: stopping }); - return; - } - let child: ChildProcess; + const core = concurrently( + commands.map(({ name }, index) => ({ name, command: `swa-internal-${index}` })), + { + raw: true, + controllers: [ + { + handle(coreCommands) { + running = coreCommands; + for (const command of running) { + command.close.subscribe(() => { + finished.add(command.index); + if (!windows && signals.has(command.index) && command.pid) { + try { + kill(command.pid, "SIGKILL"); + } catch (error) { + logger.warn(`Unable to stop remaining child processes: ${(error as Error).message}`); + } + } + }); + } + return { commands: running }; + }, + }, + ], + kill, + spawn(key) { + const index = Number(key.slice("swa-internal-".length)); + const execution = executions[index]; + if (!execution) throw new Error("Unknown internal execution identity."); try { - child = spawn(execution.executable, execution.args, { - cwd: execution.cwd ?? process.cwd(), - env: { ...process.env, ...execution.env }, - stdio: "inherit", - shell: false, - detached: !windows, - windowsVerbatimArguments: execution.windowsVerbatimArguments, - }); + if (!stopping) { + const child = spawn(execution.executable, execution.args, { + cwd: execution.cwd ?? process.cwd(), + env: execution.env ?? process.env, + stdio: "inherit", + shell: false, + detached: !windows, + windowsVerbatimArguments: execution.windowsVerbatimArguments, + }); + if (child.pid) owned.set(child.pid, index); + child.once("error", (error: Error) => { + stop("SIGTERM"); + finish(running[index], error); + }); + return child; + } } catch (error) { + errors.set(index, error instanceof Error ? error : new Error(String(error))); stop("SIGTERM"); - resolve({ command: { name }, exitCode: 1, killed: false, error: error as Error }); - return; } - const entry: RunningProcess = { - child, - finished: false, - finish: (exitCode, error) => { - if (entry.finished) return; - entry.finished = true; - if (entry.terminationSignal && !windows) { - try { - kill(entry.child, "SIGKILL"); - } catch (cleanupError) { - logger.warn(`Unable to stop remaining child processes: ${(cleanupError as Error).message}`); - } - } - resolve({ command: { name }, exitCode, killed: !!entry.terminationSignal, terminationSignal: entry.terminationSignal, error }); - }, - }; - running.push(entry); - child.once("close", (code: number | null, signal: NodeJS.Signals | null) => entry.finish(signal ?? code ?? 1)); - child.once("error", (error: Error) => { - stop("SIGTERM"); - entry.finish(1, error); - }); - }), + signals.set(index, stopping!); + queueMicrotask(() => finish(running[index], errors.get(index))); + return Object.assign(new EventEmitter(), { stdin: null, stdout: null, stderr: null }); + }, + }, ); - const events = await Promise.all(results); - if (events.some((event) => event.exitCode !== 0 || event.error || event.terminationSignal)) throw events; - return events; + try { + return (await core.result).map(closeEvent); + } catch (error) { + if (!Array.isArray(error)) throw error; + throw error.map(closeEvent); + } } finally { process.removeListener("SIGINT", onInterrupt); process.removeListener("SIGTERM", onTerminate); diff --git a/src/core/utils/project-roots.security.spec.ts b/src/core/utils/project-roots.security.spec.ts index 5df32b82e..641296722 100644 --- a/src/core/utils/project-roots.security.spec.ts +++ b/src/core/utils/project-roots.security.spec.ts @@ -2,9 +2,8 @@ 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 process from "node:process"; import * as nodeFs from "node:fs"; -import { resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } from "./executable.js"; +import { ExecutableResolutionOptions, resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } from "./executable.js"; describe("invocation directory versus selected project trust (inert fixtures)", () => { const home = "/home/tester"; @@ -30,6 +29,12 @@ describe("invocation directory versus selected project trust (inert fixtures)", return { untrustedRoots: getProjectRoots({ config: `${selected}/swa-cli.config.json`, appLocation: 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`; @@ -39,7 +44,7 @@ describe("invocation directory versus selected project trust (inert fixtures)", expect(resolveManagedExecutable(tool, options)).toBe(tool); }); - it("binds HOME runtime npm outside the configured project to current Node", async () => { + 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`; @@ -52,7 +57,7 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", `${runtime}/bin`); const options = await selectProject(); // Act / Assert - expect(resolveTrustedPackageManager("npm", options)).toEqual({ executable: process.execPath, args: [script] }); + expect(resolveTrustedPackageManager("npm", options)).toEqual({ executable: script, args: [], resolutionOptions: options }); }); it("allows an ordinary HOME tool outside the project without cache-specific exemptions", async () => { @@ -66,17 +71,17 @@ describe("invocation directory versus selected project trust (inert fixtures)", expect(resolveTrustedExecutable("func", options)).toBe(tool); }); - it("excludes a tool directly in the original invocation directory after chdir", async () => { + 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 - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${home}/func`, options); }); - it("excludes a canonical alias of the original invocation search directory", async () => { + 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); @@ -84,10 +89,10 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", "/invocation-alias"); const options = await selectProject(); // Act / Assert - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${home}/func`, options); }); - it("retains recursive exclusion when invocation is a real repository root", async () => { + it("honors configured repository tools while retaining the recursive managed boundary", async () => { // Arrange: repository evidence, not merely an arbitrary HOME ancestor. const repo = "/repository"; const selected = `${repo}/app`; @@ -100,10 +105,11 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", `${repo}/tools`); const options = await selectProject(repo, selected); // Act / Assert - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${repo}/tools/func`, options); + expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); }); - it("retains recursive repository scope for nested invocation under a valid worktree .git file", async () => { + it("retains managed repository scope beneath a valid worktree .git file without banning configured tools", async () => { // Arrange: the selected app is elsewhere; invocation repository evidence must not be erased. const repo = "/worktree"; vol.fromJSON({ @@ -116,25 +122,30 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", `${repo}/tools`); const options = await selectProject(`${repo}/nested`); // Act / Assert - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${repo}/tools/func`, options); + expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); }); - it.each(["package.json", "swa-cli.config.json"])("retains recursive non-Git project scope for nested invocation beneath %s", 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 - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); - }); + it.each(["package.json", "swa-cli.config.json"])( + "retains managed non-Git project scope beneath %s without banning configured tools", + 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(() => resolveManagedExecutable(`${root}/tools/func`, options)).toThrow(/inside the project/); + }, + ); - it("rejects actual selected-project descendants reached through an outside alias", async () => { + it("honors a selected-project PATH alias while rejecting that alias as a managed cache", async () => { // Arrange: invocation HOME has only exact scope, but selected project remains recursive. vol.fromJSON({ [`${project}/tools/func`]: "inert fixture" }); fs.chmodSync(`${project}/tools/func`, 0o755); @@ -142,10 +153,11 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", "/selected-tools-alias"); const options = await selectProject(); // Act / Assert - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${project}/tools/func`, options); + expect(() => resolveManagedExecutable("/selected-tools-alias/func", options)).toThrow(/inside the project/); }); - it("retains canonical repository ancestry when the selected project is a directory alias", async () => { + it("retains canonical managed ancestry for an aliased project without banning configured repository tools", async () => { // Arrange const repo = "/canonical-repo"; vol.fromJSON({ @@ -158,7 +170,8 @@ describe("invocation directory versus selected project trust (inert fixtures)", vi.stubEnv("PATH", `${repo}/tools`); const options = await selectProject(home, "/selected-app-alias"); // Act / Assert - expect(resolveTrustedExecutable("func", options)).toBeUndefined(); + expectConfiguredTool(`${repo}/tools/func`, options); + expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); }); it.each([".git", "package.json"])("fails closed when invocation %s evidence cannot be inspected", async (marker) => { diff --git a/src/core/utils/strings.ts b/src/core/utils/strings.ts index 480885587..010ff72ce 100644 --- a/src/core/utils/strings.ts +++ b/src/core/utils/strings.ts @@ -23,42 +23,3 @@ export function removeTrailingPathSep(filePath: string): string { } export const hasSpaces = (str: string): boolean => str.indexOf(" ") !== -1; - -/** Split literal argv without variable, wildcard, substitution or operator expansion. */ -export function parseCommandArguments(value: string): string[] { - if (value.includes("\0")) throw new Error("Arguments cannot contain a null character."); - const args: string[] = []; - let token = ""; - let started = false; - let quote: "'" | '"' | undefined; - for (let index = 0; index < value.length; index++) { - const character = value[index]; - if (character === "\\" && quote !== "'") { - const next = value[index + 1]; - // Preserve backslash pairs in quoted Windows paths, but support escaped quotes/whitespace. - if (next === '"' || (!quote && (next === "\\" || next === "'" || (next !== undefined && /\s/.test(next))))) { - token += next; - index++; - } else { - token += character; - } - started = true; - } else if (quote) { - if (character === quote) quote = undefined; - else token += character; - } else if (character === "'" || character === '"') { - quote = character; - started = true; - } else if (/\s/.test(character)) { - if (started) args.push(token); - token = ""; - started = false; - } else { - token += character; - started = true; - } - } - if (quote) throw new Error("Arguments contain an unterminated quote. Close the quote or escape a literal quote."); - if (started) args.push(token); - return args; -} diff --git a/src/swa.d.ts b/src/swa.d.ts index a6e92bdd9..c6df14d87 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -5,6 +5,7 @@ declare global { } declare module "json-source-map"; +declare module "pem"; declare interface StaticSiteClientEnv { // StaticSitesClient env vars diff --git a/tests/_mocks/internal-process.js b/tests/_mocks/internal-process.js index 6f52b5827..ddda21e3e 100644 --- a/tests/_mocks/internal-process.js +++ b/tests/_mocks/internal-process.js @@ -36,8 +36,8 @@ export function mockInternalProcesses(stdout = "", error = undefined) { queueMicrotask(() => { child.stdout.end(stdout); child.stderr.end(); - child.emit("exit", error ? 1 : 0); - child.emit("close", error ? 1 : 0); + child.emit("exit", error ? 1 : 0, null); + child.emit("close", error ? 1 : 0, null); }); return child; }; 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"); From f590570b75779d68512fbb24f6b7793d20d23e2f Mon Sep 17 00:00:00 2001 From: Long Hao Date: Mon, 19 Oct 2026 12:08:49 +0000 Subject: [PATCH 6/8] fix(core): preserve caller PATHEXT lookup order Respect inherited Windows extension lookup order and preserve its present, empty or absent state through internal loaders and CLI reentry without restoring implicit current-directory lookup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/core/utils/executable.security.spec.ts | 14 +++++++++ src/core/utils/executable.ts | 34 +++++++++++++++------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/core/utils/executable.security.spec.ts b/src/core/utils/executable.security.spec.ts index 27d12298c..9e1d2ce19 100644 --- a/src/core/utils/executable.security.spec.ts +++ b/src/core/utils/executable.security.spec.ts @@ -257,6 +257,20 @@ describe("simulated Windows known shim identities (not native proof)", () => { }); }); + 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"; diff --git a/src/core/utils/executable.ts b/src/core/utils/executable.ts index dbe5aec86..6737bcca2 100644 --- a/src/core/utils/executable.ts +++ b/src/core/utils/executable.ts @@ -32,16 +32,16 @@ export interface ExecutableResolutionOptions { export class UnsupportedExecutableError extends Error {} -let callerPath: { value: string | undefined } | undefined; +let callerPath: { value: string | undefined; extensions: string | undefined } | undefined; const absentCallerPath = "SWA_CLI_INTERNAL_PATH_ABSENT"; -function pathValue(env: NodeJS.ProcessEnv): string | undefined { +function pathValue(env: NodeJS.ProcessEnv, name = "PATH"): string | undefined { const windows = os.platform() === "win32"; - const key = Object.keys(env).find((name) => (windows ? name.toLowerCase() === "path" : name === "PATH")); + const key = Object.keys(env).find((key) => (windows ? key.toLowerCase() === name.toLowerCase() : key === name)); return key ? env[key] : undefined; } -/** Capture inherited selection before project dotenv values can fill a missing PATH. */ +/** Capture inherited lookup configuration before project dotenv can fill missing PATH/PATHEXT. */ export function captureCallerPath(): void { let value = pathValue(process.env); if (os.platform() === "win32" && process.env[absentCallerPath] === "1" && value === "") { @@ -51,12 +51,18 @@ export function captureCallerPath(): void { value = undefined; } delete process.env[absentCallerPath]; - callerPath = { value }; + callerPath = { value, extensions: pathValue(process.env, "PATHEXT") }; } -function markAbsentCallerPath(env: NodeJS.ProcessEnv): void { +function preserveCallerLookup(env: NodeJS.ProcessEnv): void { delete env[absentCallerPath]; - if (os.platform() === "win32" && callerPath && callerPath.value === undefined) env[absentCallerPath] = "1"; + if (os.platform() !== "win32" || !callerPath) return; + for (const key of Object.keys(env)) { + if (key.toLowerCase() === "pathext") delete env[key]; + } + // An undefined override prevents composed command environments from restoring project PATHEXT. + env.PATHEXT = callerPath.extensions; + if (callerPath.value === undefined) env[absentCallerPath] = "1"; } function executionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { @@ -251,7 +257,15 @@ export function resolveTrustedExecutable(name: string, options: ExecutableResolu throw new Error(`Expected an internal tool name, not a path: ${name}`); } const windows = os.platform() === "win32"; - const names = windows && !/\.(exe|com)$/i.test(name) ? [`${name}.exe`, `${name}.com`, `${name}.cmd`, `${name}.bat`, `${name}.ps1`] : [name]; + const extensions = callerPath ? callerPath.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(options, process.env, options.cwd)) { for (const filename of names) { const candidate = executablePath(platformPath().join(directory, filename)); @@ -314,7 +328,7 @@ function internalEnvironment(options: ExecutableResolutionOptions, overrides?: N env.ComSpec = getTrustedWindowsSystemExecutable("cmd.exe", options); env.NoDefaultCurrentDirectoryInExePath = "1"; } - markAbsentCallerPath(env); + preserveCallerLookup(env); return env; } @@ -408,7 +422,7 @@ export function prepareExecution(spec: InternalExecutionSpec): InternalExecution if (callerPath.value !== undefined) env[os.platform() === "win32" ? "Path" : "PATH"] = callerPath.value; // Windows spawn fills an omitted PATH from the parent; the entry hook restores this placeholder's absence. else if (os.platform() === "win32") env.Path = ""; - markAbsentCallerPath(env); + preserveCallerLookup(env); return { ...spec, env }; } From b0de705eb52864349f8ee4c4953459e5438959af Mon Sep 17 00:00:00 2001 From: Long Hao Date: Tue, 20 Oct 2026 01:32:46 +0000 Subject: [PATCH 7/8] fix(core): minimize executable lookup remediation Remove unrelated project policy, lookup provenance and custom lifecycle behavior while retaining the reported implicit-CWD lookup fixes and original wrapper, argument, certificate and process compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/commands/build/build.spec.ts | 18 +- src/cli/commands/build/build.ts | 23 +- src/cli/commands/db/init/init.ts | 6 +- src/cli/commands/start/start.ts | 101 ++++----- src/cli/index.security.spec.ts | 49 ++-- src/cli/index.ts | 11 +- src/core/dataApiBuilder/dab.ts | 29 ++- src/core/func-core-tools.spec.ts | 8 +- src/core/func-core-tools.ts | 19 +- src/core/ssl.security.spec.ts | 16 +- src/core/ssl.ts | 52 ++--- src/core/utils/cli-config.ts | 71 +----- src/core/utils/command.ts | 14 +- .../utils/executable.compatibility.spec.ts | 24 +- src/core/utils/executable.security.spec.ts | 49 ++-- src/core/utils/executable.ts | 209 ++++-------------- src/core/utils/process.security.spec.ts | 137 ++++++++---- src/core/utils/process.ts | 190 +++------------- src/core/utils/project-roots.security.spec.ts | 99 +++++---- 19 files changed, 422 insertions(+), 703 deletions(-) diff --git a/src/cli/commands/build/build.spec.ts b/src/cli/commands/build/build.spec.ts index 950d5b262..b8082c44b 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -31,14 +31,12 @@ describe("swa build", () => { vi.unstubAllEnvs(); }); - const contextFor = (cwd: string) => ({ untrustedRoots: expect.arrayContaining([path.resolve(cwd)]) }); - it("should run app build command", async () => { const command = await import("../../../core/utils/command.js"); command.runCommand = vi.fn().mockImplementation(vi.fn()); await build({ ...DEFAULT_CONFIG, appBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); }); it("should run npm install before build command", async () => { @@ -52,10 +50,9 @@ describe("swa build", () => { executable: "/trusted/bin/npm", args: ["install"], cwd: process.cwd(), - resolutionOptions: { ...contextFor("."), cwd: process.cwd() }, }); expect(command.runCommand).toHaveBeenCalledTimes(1); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(command.runCommand).mock.invocationCallOrder[0], ); @@ -76,10 +73,9 @@ describe("swa build", () => { executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("app"), - resolutionOptions: { ...contextFor("."), cwd: path.resolve("app") }, }); expect(command.runCommand).toHaveBeenCalledTimes(1); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", ".", contextFor(".")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "."); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(command.runCommand).mock.invocationCallOrder[0], ); @@ -90,7 +86,7 @@ describe("swa build", () => { command.runCommand = vi.fn().mockImplementation(vi.fn()); await build({ ...DEFAULT_CONFIG, apiLocation: "api", apiBuildCommand: "npm run something" }); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); }); it("should run npm install before build command", async () => { @@ -104,10 +100,9 @@ describe("swa build", () => { executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("api"), - resolutionOptions: { ...contextFor("api"), cwd: path.resolve("api") }, }); expect(command.runCommand).toHaveBeenCalledTimes(1); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); expect(vi.mocked(command.execInternalCommand).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(command.runCommand).mock.invocationCallOrder[0], ); @@ -128,10 +123,9 @@ describe("swa build", () => { executable: "/trusted/bin/npm", args: ["install"], cwd: path.resolve("api"), - resolutionOptions: { ...contextFor("api"), cwd: path.resolve("api") }, }); expect(command.runCommand).toHaveBeenCalledTimes(1); - expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api", contextFor("api")); + expect(command.runCommand).toHaveBeenCalledWith("npm run something", "api"); }); it("should run nothing", async () => { diff --git a/src/cli/commands/build/build.ts b/src/cli/commands/build/build.ts index ccb1eb531..67a2ec689 100644 --- a/src/cli/commands/build/build.ts +++ b/src/cli/commands/build/build.ts @@ -6,8 +6,8 @@ import { isUserOrConfigOption } from "../../../core/utils/options.js"; import { logger } from "../../../core/utils/logger.js"; import { readWorkflowFile } from "../../../core/utils/workflow-config.js"; import { execInternalCommand, runCommand } from "../../../core/utils/command.js"; -import { getProjectRoots, swaCliConfigFilename } from "../../../core/utils/cli-config.js"; -import { ExecutableResolutionOptions, resolveTrustedPackageManager } from "../../../core/utils/executable.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(); @@ -80,17 +80,15 @@ export async function build(options: SWACLIConfig) { logger.log(`- App build command: ${chalk.green(appBuildCommand || "")}`); logger.log(`- API build command: ${chalk.green(apiBuildCommand || "")}`); - const resolvedOutput = appLocation && outputLocation ? path.resolve(appLocation, outputLocation) : undefined; - const resolutionOptions = { untrustedRoots: getProjectRoots(options, [appLocation, apiLocation, resolvedOutput]) }; if (appBuildCommand) { const packageJsonPath = await findUpPackageJsonDir(appLocation!, outputLocation!); if (packageJsonPath) { logger.log(`Found package.json in ${packageJsonPath}`); - await installNpmDependencies(packageJsonPath, resolutionOptions); + await installNpmDependencies(packageJsonPath); } logger.log(`Building app with ${chalk.green(appBuildCommand)} in ${chalk.dim(appLocation)} ...`); - runCommand(appBuildCommand, appLocation!, resolutionOptions); + runCommand(appBuildCommand, appLocation!); } if (apiBuildCommand) { @@ -98,11 +96,11 @@ export async function build(options: SWACLIConfig) { const packageJsonPath = await findUpPackageJsonDir(apiLocation!, "."); if (packageJsonPath) { logger.log(`Found package.json in ${packageJsonPath}`); - await installNpmDependencies(packageJsonPath, resolutionOptions); + await installNpmDependencies(packageJsonPath); } logger.log(`Building api with ${chalk.green(apiBuildCommand)} in ${chalk.dim(apiLocation)} ...`); - runCommand(apiBuildCommand, apiLocation!, resolutionOptions); + runCommand(apiBuildCommand, apiLocation!); } } @@ -134,19 +132,14 @@ async function detectPackageManager(basePath: string): Promise { +async function installNpmDependencies(packageJsonPath: string): Promise { try { const packageManager = await detectPackageManager(packageJsonPath); logger.log(`Installing dependencies with "${packageManager} install"...`); - const spec = resolveTrustedPackageManager(packageManager, { - ...options, - cwd: path.resolve(packageJsonPath), - untrustedRoots: [...(options.untrustedRoots ?? []), 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); - throw error; } } diff --git a/src/cli/commands/db/init/init.ts b/src/cli/commands/db/init/init.ts index 7f337da83..7e046ff53 100644 --- a/src/cli/commands/db/init/init.ts +++ b/src/cli/commands/db/init/init.ts @@ -12,7 +12,6 @@ import { import { execFileCommand } from "../../../../core/utils/command.js"; import { logger } from "../../../../core/utils/logger.js"; import { getDataApiBuilderBinaryPath } from "../../../../core/dataApiBuilder/index.js"; -import { getProjectRoots } from "../../../../core/utils/cli-config.js"; export async function init(options: SWACLIConfig) { let { databaseType, connectionString, cosmosdb_nosqlContainer, cosmosdb_nosqlDatabase } = options; @@ -45,8 +44,7 @@ export async function init(options: SWACLIConfig) { logger.log(`Creating ${DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME} configuration file`, "swa"); - const resolutionOptions = { untrustedRoots: getProjectRoots(options, [directory]), cwd: path.resolve(directory) }; - const dataApiBinary = await getDataApiBuilderBinaryPath(resolutionOptions); + const dataApiBinary = await getDataApiBuilderBinaryPath({ cwd: path.resolve(directory) }); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -111,7 +109,7 @@ export async function init(options: SWACLIConfig) { // todo:DAB CLI doesn't return an error code when it fails, so we need to allow stdio to be inherited (this will be fixed in the March release) // It would be better to have our own logs since DAB CLI refers to itself in its success messages // which may lead to confusion for swa cli users ex: `SUGGESTION: Use 'dab add [entity-name] [options]' to add new entities in your config.` - execFileCommand(dataApiBinary, directory, args, resolutionOptions); + execFileCommand(dataApiBinary, directory, args); // not logging anything here since DAB CLI logs success messages or error messages and we can't catch an error } diff --git a/src/cli/commands/start/start.ts b/src/cli/commands/start/start.ts index e5a45d631..ec07bee58 100644 --- a/src/cli/commands/start/start.ts +++ b/src/cli/commands/start/start.ts @@ -1,3 +1,4 @@ +import { CloseEvent } from "concurrently"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,9 +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 { getProjectRoots } from "../../../core/utils/cli-config.js"; import { createShellExecutionSpec, createToolShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; -import { NamedExecutionSpec, ProcessCloseEvent, superviseProcesses } from "../../../core/utils/process.js"; +import { NamedExecutionSpec, superviseProcesses } from "../../../core/utils/process.js"; const packageInfo = loadPackageJson(); @@ -170,16 +170,6 @@ export async function start(options: SWACLIConfig) { } const isApiLocationExistsOnDisk = fs.existsSync(userWorkflowConfig?.apiLocation!); - const resolutionOptions = { - untrustedRoots: getProjectRoots(options, [ - appLocation, - outputLocation, - userWorkflowConfig?.appLocation, - userWorkflowConfig?.apiLocation, - userWorkflowConfig?.dataApiLocation, - dataApiLocation, - ]), - }; // handle the API location config let serveApi: InternalExecutionSpec | undefined; @@ -192,7 +182,7 @@ export async function start(options: SWACLIConfig) { } else { if (apiLocation && userWorkflowConfig?.apiLocation) { // Check the caller's PATH before using the managed download. - const funcBinary = await getCoreToolsBinary({ ...resolutionOptions, cwd: path.resolve(userWorkflowConfig.apiLocation) }); + const funcBinary = await getCoreToolsBinary({ cwd: path.resolve(userWorkflowConfig.apiLocation) }); const nodeMajorVersion = getNodeMajorVersion(); const targetVersion = detectTargetCoreToolsVersion(nodeMajorVersion); @@ -223,7 +213,6 @@ export async function start(options: SWACLIConfig) { ["start", "--cors", "*", "--port", String(apiPort)], path.resolve(userWorkflowConfig.apiLocation), funcArgs ?? "", - resolutionOptions, ); } } @@ -237,7 +226,7 @@ export async function start(options: SWACLIConfig) { dataApiPort = parseUrl(useDataApiDevServer)?.port; } else { if (dataApiLocation) { - const dataApiBinary = await getDataApiBuilderBinaryPath({ ...resolutionOptions, cwd: path.resolve(dataApiLocation) }); + const dataApiBinary = await getDataApiBuilderBinaryPath({ cwd: path.resolve(dataApiLocation) }); if (!dataApiBinary) { logger.error( `Could not find or install ${DATA_API_BUILDER_BINARY_NAME} binary. @@ -251,7 +240,6 @@ export async function start(options: SWACLIConfig) { executable: dataApiBinary, args: ["start", "-c", DATA_API_BUILDER_DEFAULT_CONFIG_FILE_NAME, "--no-https-redirect"], cwd: path.resolve(dataApiLocation), - resolutionOptions, }; dataApiPort = DEFAULT_CONFIG.dataApiPort; } @@ -263,17 +251,14 @@ export async function start(options: SWACLIConfig) { if (ssl) { if (sslCert === undefined && sslKey === undefined) { logger.warn(`WARNING: Using built-in UNSIGNED certificate. DO NOT USE IN PRODUCTION!`); - const pemFilepath = await getCertificate( - { - selfSigned: true, - days: 365, - commonName: host, - organization: `Azure Static Web Apps CLI ${packageInfo.version}`, - organizationUnit: "Azure Engineering", - emailAddress: `secure@microsoft.com`, - }, - resolutionOptions, - ); + const pemFilepath = await getCertificate({ + selfSigned: true, + days: 365, + commonName: host, + organization: `Azure Static Web Apps CLI ${packageInfo.version}`, + organizationUnit: "Azure Engineering", + emailAddress: `secure@microsoft.com`, + }); sslCert = pemFilepath; sslKey = pemFilepath; } else { @@ -284,11 +269,9 @@ export async function start(options: SWACLIConfig) { } if (run) { - startupCommand = createStartupScriptCommand(run, options) ?? run; + startupCommand = createStartupScriptCommand(run, options); } - const startup = startupCommand - ? createShellExecutionSpec(startupCommand, path.resolve(userWorkflowConfig?.appLocation ?? appLocation), resolutionOptions) - : undefined; + 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 @@ -346,25 +329,39 @@ export async function start(options: SWACLIConfig) { }, }); - try { - await superviseProcesses(commands, resolutionOptions); - logger.silly("SWA emulator exited with code 0"); - process.exit(); - } catch (error) { - process.exitCode = 1; - if (!Array.isArray(error)) { - logger.error((error as Error).message, true); - return; - } - const events = error as ProcessCloseEvent[]; - const interrupted = events.find((event) => event.terminationSignal); - if (interrupted && !events.some((event) => event.error)) { - process.exit(interrupted.terminationSignal === "SIGINT" ? 130 : 143); - return; - } - const failure = events.find((event) => event.error) ?? events.find((event) => event.exitCode !== 0); - const labels: Record = { swa: "SWA emulator", api: "API server", dataApi: "Data API server", run: "the --run command" }; - if (failure?.error) logger.error(failure.error); - logger.error(`SWA emulator stopped because ${labels[failure?.command.name ?? "swa"]} exited with code ${failure?.exitCode}.`, true); - } + const result = superviseProcesses(commands); + + await result + .then( + (errorEvent: CloseEvent[]) => { + const killedCommand = errorEvent.filter((event) => event.killed).pop(); + const exitCode = killedCommand?.exitCode; + logger.silly(`SWA emulator exited with code ${exitCode}`); + process.exit(); + }, + (errorEvent: CloseEvent[]) => { + const killedCommand = errorEvent.filter((event) => event.killed).pop(); + const commandName = killedCommand?.command.name; + const exitCode = killedCommand?.exitCode; + let commandMessage = ``; + switch (commandName) { + case "swa": + commandMessage = `SWA emulator exited with code ${exitCode}`; + break; + case "api": + commandMessage = `API server exited with code ${exitCode}`; + break; + case "dataApi": + commandMessage = `Data API server exited with code ${exitCode}`; + break; + case "run": + commandMessage = `the --run command exited with code ${exitCode}`; + break; + } + logger.error(`SWA emulator stopped because ${commandMessage}.`, true); + }, + ) + .catch((err: Error) => { + logger.error(err.message, true); + }); } diff --git a/src/cli/index.security.spec.ts b/src/cli/index.security.spec.ts index 0d13bfc14..329f9f14a 100644 --- a/src/cli/index.security.spec.ts +++ b/src/cli/index.security.spec.ts @@ -70,7 +70,7 @@ describe("magic-flow same-installation reentry", () => { expect(commands[0]).toMatch(/(?:^| )build$/); }); - it.each([false, true])("captures caller PATH before project dotenv and preserves it across reentry (present=%s)", async (present) => { + 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"); @@ -82,36 +82,39 @@ describe("magic-flow same-installation reentry", () => { 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; - vi.stubEnv("SWA_CLI_INTERNAL_PATH_ABSENT", ""); - delete process.env.SWA_CLI_INTERNAL_PATH_ABSENT; childProcess.spawnSync.mockReturnValue({ status: 0, stdout: "/usr/bin:/bin", stderr: "" }); vi.mocked(dotenv.config).mockImplementation(() => { process.env.PATH = injected; return { parsed: { PATH: injected } }; }); - for (let generation = 0; generation < 2; generation++) { - vi.resetModules(); - const cli = await import("./index.js"); - const resolver = await import("../core/utils/executable.js"); - expect(process.env.PATH).toBe(injected); - expect(resolver.resolveTrustedExecutable(tool, { cwd: project })).toBe(present ? fs.realpathSync(path.join(inherited, filename)) : undefined); - if (generation === 0) { - 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."); - const value = process.platform === "win32" ? env.Path ?? env.PATH : env.PATH; - expect(value).toBe(present ? inherited : process.platform === "win32" ? "" : undefined); - expect(env.SWA_CLI_INTERNAL_PATH_ABSENT).toBe(!present && process.platform === "win32" ? "1" : undefined); - vi.stubEnv("PATH", value ?? ""); - if (value === undefined) delete process.env.PATH; - vi.stubEnv("SWA_CLI_INTERNAL_PATH_ABSENT", env.SWA_CLI_INTERNAL_PATH_ABSENT ?? ""); - if (env.SWA_CLI_INTERNAL_PATH_ABSENT === undefined) delete process.env.SWA_CLI_INTERNAL_PATH_ABSENT; - } - } + 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 1d72ef610..f74a8f470 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,4 @@ import dotenv from "dotenv"; -import { captureCallerPath } from "../core/utils/executable.js"; -captureCallerPath(); dotenv.config(); import process from "node:process"; @@ -74,7 +72,7 @@ export async function run(argv?: string[]) { .option("-g, --print-config", "print all resolved options", false) .action(async (_options: SWACLIConfig, command: Command) => { const options = await configureOptions(undefined, command.optsWithGlobals(), command, "init"); - await swaMagic(options); + swaMagic(options); }) .addHelpText( "after", @@ -130,9 +128,8 @@ export async function swaMagic(_options: SWACLIConfig) { logger.log(`\n${chalk.cyan("→")} Running ${chalk.cyan("swa deploy")}...\n`); reenter("deploy"); - } catch (error) { - logger.error("SWA workflow stopped before completion."); - logger.error(error as Error); - process.exitCode = 1; + } catch (_) { + // Pokemon, go catch'em all! + // (Errors are already caught an displayed in individual commands) } } diff --git a/src/core/dataApiBuilder/dab.ts b/src/core/dataApiBuilder/dab.ts index 4768964f6..481dd3118 100644 --- a/src/core/dataApiBuilder/dab.ts +++ b/src/core/dataApiBuilder/dab.ts @@ -14,7 +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, resolveManagedExecutable, resolveTrustedExecutable } from "../utils/executable.js"; +import { ExecutableResolutionOptions, probeExecutable, resolveTrustedExecutable, resolveTrustedExecutablePath } from "../utils/executable.js"; /** * Gets the filepath where the Microsoft.DataApiBuilder.exe is located @@ -41,7 +41,7 @@ export async function installAndGetDataApiBuilder(options: ExecutableResolutionO return { binaryPath: installed.binaryPath }; } - return { binaryPath: await downloadAndUnzipBinary(releaseMetadata, platform, options) }; + return { binaryPath: await downloadAndUnzipBinary(releaseMetadata, platform) }; } /** @@ -50,14 +50,10 @@ export async function installAndGetDataApiBuilder(options: ExecutableResolutionO * @param platform Current OS * @returns Binary Path after downloading and extracting */ -async function downloadAndUnzipBinary( - releaseMetadata: DataApiBuilderReleaseMetadata, - platform: "win-x64" | "osx-x64" | "linux-x64", - options: ExecutableResolutionOptions, -) { +async function downloadAndUnzipBinary(releaseMetadata: DataApiBuilderReleaseMetadata, platform: "win-x64" | "osx-x64" | "linux-x64") { try { const destDirectory = path.resolve(DATA_API_BUILDER_FOLDER, releaseMetadata.versionId); - const binaryPath = resolveManagedExecutable(path.join(destDirectory, getDefaultDataApiBuilderBinaryForOS(platform)), options); + const binaryPath = path.join(destDirectory, getDefaultDataApiBuilderBinaryForOS(platform)); if (!fs.existsSync(binaryPath)) { logger.silly(`Downloading the version ${releaseMetadata.versionId}`); @@ -77,10 +73,12 @@ async function downloadAndUnzipBinary( fs.chmodSync(binaryPath, 0o755); } - return resolveManagedExecutable(binaryPath, options); + 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.`); - throw new Error(`Unable to install ${DATA_API_BUILDER_BINARY_NAME}. Check your network connection and retry. ${(ex as Error).message}`); + logger.error(`Unable to download/extract ${DATA_API_BUILDER_BINARY_NAME} binary. Exception ${ex}`); + throw ex; } } @@ -139,7 +137,7 @@ export function getDefaultDataApiBuilderBinaryForOS(platform: string): string { /** * Returns installed version if installed else undefined - * @param options Execution context for system interpreters and managed paths + * @param options Working directory for configured PATH lookup and probing * @returns installed version */ async function getInstalledDataApiBuilder(options: ExecutableResolutionOptions): Promise<{ binaryPath: string; version: string } | undefined> { @@ -149,10 +147,9 @@ async function getInstalledDataApiBuilder(options: ExecutableResolutionOptions): if (!binaryPath) return undefined; try { const { stdout } = await probeExecutable(binaryPath, ["--version"], options); - const version = /^Microsoft\.DataApiBuilder\s+(\S+)/.exec(stdout.trim())?.[1]; - return version ? { binaryPath, version } : undefined; - } catch (error) { - logger.silly(`Unable to probe installed Data API Builder: ${(error as Error).message}`); + const version = stdout.split(" ")[1].split("\r")[0]; // parsing output which looks like this "Microsoft.DataApiBuilder 0.5.0" (specific to dab) + return { binaryPath, version }; + } catch { return undefined; } } diff --git a/src/core/func-core-tools.spec.ts b/src/core/func-core-tools.spec.ts index c6bd37ce2..39b1b42f2 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -212,7 +212,7 @@ describe("funcCoreTools", () => { expect(childProcess.execFile).toHaveBeenCalledWith( "/trusted/bin/func", ["--version"], - { encoding: "utf8", shell: false }, + { encoding: "utf8", shell: false, env: { ...process.env } }, expect.any(Function), ); expect(childProcess.exec).not.toHaveBeenCalled(); @@ -222,8 +222,9 @@ describe("funcCoreTools", () => { 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(); @@ -234,8 +235,9 @@ describe("funcCoreTools", () => { 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(); expect(binary).toBe("/home/user/.swa/core-tools/v4/func"); diff --git a/src/core/func-core-tools.ts b/src/core/func-core-tools.ts index fbda7877d..be784f4e6 100644 --- a/src/core/func-core-tools.ts +++ b/src/core/func-core-tools.ts @@ -8,7 +8,7 @@ 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, resolveManagedExecutable, resolveTrustedExecutable } from "./utils/executable.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"; @@ -61,7 +61,7 @@ export function detectTargetCoreToolsVersion(nodeVersion: number): number { return 4; } -// Test override for the installed version; executable identity is still resolved independently. +// Cache the results of getInstalledSystemCoreToolsVersion // This supports the testing inside of func-core-tools.spec.ts let installedSystemCoreToolsVersionIsCached: boolean = false; let cachedSystemCoreToolsVersion: number | undefined = undefined; @@ -88,11 +88,9 @@ async function getInstalledSystemCoreTools(options: ExecutableResolutionOptions) return { binaryPath, version: cachedSystemCoreToolsVersion }; } try { - const { stdout } = await probeExecutable(binaryPath, ["--version"], options); - const version = getMajorVersion(stdout.trim()); - return Number.isFinite(version) ? { binaryPath, version } : undefined; - } catch (error) { - logger.silly(`Unable to probe installed Functions Core Tools: ${(error as Error).message}`); + const { stdout: version } = await probeExecutable(binaryPath, ["--version"], options); + return { binaryPath, version: getMajorVersion(version) }; + } catch { return undefined; } } @@ -279,11 +277,11 @@ export async function getCoreToolsBinary(options: ExecutableResolutionOptions = } const targetVersion = detectTargetCoreToolsVersion(nodeVersion); - const managedBinary = resolveManagedExecutable(getCoreToolBinaryPath(targetVersion), options); + const managedBinary = getCoreToolBinaryPath(targetVersion); const downloadedVersion = getDownloadedCoreToolsVersion(targetVersion); if (downloadedVersion) { // Should we check for newer versions here? - return managedBinary; + return resolveTrustedExecutablePath(managedBinary); } if (systemVersion && systemVersion !== targetVersion) { @@ -298,10 +296,11 @@ export async function getCoreToolsBinary(options: ExecutableResolutionOptions = try { await downloadCoreTools(targetVersion); logger.log(`\nDownloaded Function Core Tools successfully`); - return resolveManagedExecutable(managedBinary, options); + return resolveTrustedExecutablePath(managedBinary); } catch (error: unknown) { logger.error(`Failed to download Functions Core Tools v${targetVersion}.`); logger.error(error as Error); + console.log(error); return undefined; } } diff --git a/src/core/ssl.security.spec.ts b/src/core/ssl.security.spec.ts index 0f7d62a5c..1f6dff55f 100644 --- a/src/core/ssl.security.spec.ts +++ b/src/core/ssl.security.spec.ts @@ -105,12 +105,12 @@ describe("SSL trusted bootstrap and baseline PEM delegation", () => { expect(moduleLoader.load).not.toHaveBeenCalled(); }); - it("reuses a fresh cache without requiring OpenSSL or loading pem", async () => { + 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(cache); + expect(await ssl.getCertificate({ selfSigned: true })).toBe("cached certificate fixture"); expect(moduleLoader.load).not.toHaveBeenCalled(); expect(fs.readFileSync(cache, "utf8")).toBe("cached certificate fixture"); }); @@ -277,10 +277,14 @@ describe("SSL trusted bootstrap and baseline PEM delegation", () => { expect(fs.existsSync(cache)).toBe(false); }); - it("rejects a project OpenSSL override before loading or probing pem", async () => { - process.env.OPENSSL_BIN = path.join(project, "bin", process.platform === "win32" ? "openssl.exe" : "openssl"); + 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 expect(ssl.getCertificate({ selfSigned: true })).rejects.toThrow(/trusted native OpenSSL/); - expect(moduleLoader.load).not.toHaveBeenCalled(); + 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 f9682deae..e2c234511 100644 --- a/src/core/ssl.ts +++ b/src/core/ssl.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { createRequire } from "node:module"; -import { ExecutableResolutionOptions, prepareExecution, resolveTrustedExecutable, resolveTrustedExecutablePath } from "./utils/executable.js"; +import { resolveTrustedExecutable, resolveTrustedExecutablePath } from "./utils/executable.js"; import { logger } from "./utils/logger.js"; const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; @@ -81,9 +81,7 @@ export interface PEMOptions { altNames?: string[]; } -interface SSLCertificate { - csr: string; - serviceKey: string; +interface SSLCertificate extends Pick { certificate: string; } @@ -94,36 +92,22 @@ interface Pem { let pem: Pem | undefined; -function isPem(value: unknown): value is Pem { - return ( - !!value && - typeof value === "object" && - "config" in value && - typeof value.config === "function" && - "createCertificate" in value && - typeof value.createCertificate === "function" - ); -} - -function certificateGenerator(resolutionOptions: ExecutableResolutionOptions): Pem { +function certificateGenerator(): Pem { if (pem) return pem; const configured = process.env.OPENSSL_BIN; const executable = configured && /[\\/]/.test(configured) - ? resolveTrustedExecutablePath(configured, resolutionOptions) - : resolveTrustedExecutable(configured || "openssl", resolutionOptions); - if (!executable || prepareExecution({ executable, args: [], resolutionOptions }).executable !== executable) { - throw new Error("Cannot find a trusted native OpenSSL executable outside the project. Check your OpenSSL installation."); + ? 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: unknown = createRequire(import.meta.url)("pem"); - if (!isPem(loaded)) { - throw new Error("The installed pem module does not expose its certificate API."); - } + const loaded: Pem = createRequire(import.meta.url)("pem"); loaded.config({ pathOpenSSL: executable }); pem = loaded; return loaded; @@ -142,26 +126,26 @@ function certificateGenerator(resolutionOptions: ExecutableResolutionOptions): P * @param options PEM options (passed to the "`pem`" npm library) * @returns The abosulte filepath to the PEM file from cache. */ -export async function getCertificate(options: PEMOptions, resolutionOptions: ExecutableResolutionOptions = {}): Promise { +export async function getCertificate(options: PEMOptions): Promise { const cacheDir = path.join(os.homedir(), ".swa", "certificates", "ssl"); const cachePath = path.join(cacheDir, "swa-cli-UNSIGNED.pem"); try { logger.silly(`Checking for cached certificate at ${cachePath}`); - const [stat] = await Promise.all([fs.stat(cachePath), fs.readFile(cachePath, "utf8")]); + const [stat, unsignedCertificate] = await Promise.all([fs.stat(cachePath), fs.readFile(cachePath, "utf8")]); if (Date.now() - stat.ctime.valueOf() > ONE_MONTH) { throw new Error("Certificate is too old, a new one will be generated."); } logger.silly("Certificate is valid, using it."); - return cachePath; + return unsignedCertificate; } catch { logger.silly("No cached certificate found."); logger.silly("Creating new certificate..."); - const unsignedCertificate = await createCertificate(options, resolutionOptions); + const unsignedCertificate = await createCertificate(options); logger.silly("Writing certificate to cache..."); @@ -174,9 +158,15 @@ export async function getCertificate(options: PEMOptions, resolutionOptions: Exe } } -async function createCertificate(options: PEMOptions, resolutionOptions: ExecutableResolutionOptions): Promise { - const generator = certificateGenerator(resolutionOptions); +async function createCertificate(options: PEMOptions): Promise { + const generator = certificateGenerator(); return new Promise((resolve, reject) => { - generator.createCertificate(options, (error, certificate) => (error ? reject(error) : resolve(certificate))); + generator.createCertificate(options, (err, keys) => { + if (err) { + reject(err); + } else { + resolve(keys); + } + }); }); } diff --git a/src/core/utils/cli-config.ts b/src/core/utils/cli-config.ts index d978e7e07..1235cd0e0 100644 --- a/src/core/utils/cli-config.ts +++ b/src/core/utils/cli-config.ts @@ -1,9 +1,8 @@ import chalk from "chalk"; -import { existsSync, promises as fsPromises, readFileSync, realpathSync, statSync } from "node:fs"; +import { existsSync, promises as fsPromises } from "node:fs"; import * as path from "node:path"; import * as process from "node:process"; import { logger } from "./logger.js"; -import { ExecutableExclusion } from "./executable.js"; const { readFile, writeFile } = fsPromises; export const swaCliConfigSchemaUrl = "https://aka.ms/azure/static-web-apps-cli/schema"; @@ -13,74 +12,6 @@ export const swaCliConfigFilename = "swa-cli.config.json"; * Holds the current configuration of the CLI loaded from the `swa-cli.config.json` file. */ let currentSwaCliConfigFromFile: SWACLIConfigInfo | undefined; -const invocationCwd = process.cwd(); - -function projectMarker(file: string) { - try { - return statSync(file); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") return undefined; - throw error; - } -} - -function evidencedRoot(location: string, hasMarker: (directory: string) => boolean): string | undefined { - let directory = path.resolve(location); - while (true) { - try { - directory = realpathSync(directory); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "ENOTDIR") throw error; - } - if (hasMarker(directory)) return directory; - const parent = path.dirname(directory); - if (parent === directory) return undefined; - directory = parent; - } -} - -function repositoryRoot(location: string): string | undefined { - return evidencedRoot(location, (directory) => { - const marker = path.join(directory, ".git"); - const stat = projectMarker(marker); - return stat?.isDirectory() === true || (stat?.isFile() === true && /^gitdir:\s+\S/.test(readFileSync(marker, "utf8"))); - }); -} - -function isProjectDirectory(directory: string): boolean { - return [swaCliConfigFilename, "package.json"].some((name) => { - const marker = path.join(directory, name); - return projectMarker(marker)?.isFile(); - }); -} - -/** Carry exact invocation/search directories separately from genuine recursive project roots. */ -export function getProjectRoots(options: SWACLIConfig, locations: readonly (string | undefined)[] = []): ExecutableExclusion[] { - const cwd = process.cwd(); - const configPath = currentSwaCliConfigFromFile?.filePath ?? options.config; - const resolvedConfig = configPath ? path.resolve(invocationCwd, configPath) : undefined; - const roots = [ - ...(resolvedConfig && (currentSwaCliConfigFromFile || projectMarker(resolvedConfig)?.isFile()) ? [path.dirname(resolvedConfig)] : []), - options.appLocation, - options.apiLocation, - options.dataApiLocation, - options.swaConfigLocation, - ...locations, - ]; - const projectRoots = new Set(roots.filter((root): root is string => !!root && !/^https?:\/\//i.test(root)).map((root) => path.resolve(root))); - const searchDirectories = [...new Set([invocationCwd, cwd].map((directory) => path.resolve(directory)))]; - for (const directory of searchDirectories) { - const project = evidencedRoot(directory, isProjectDirectory); - if (project) projectRoots.add(project); - } - for (const location of [...projectRoots, ...searchDirectories]) { - const repo = repositoryRoot(location); - if (repo) projectRoots.add(repo); - } - return [...projectRoots, ...searchDirectories.map((directory) => ({ directory }))]; -} /** * Get the current configuration of the CLI stored in `{@link currentSwaCliConfigFromFile}`. diff --git a/src/core/utils/command.ts b/src/core/utils/command.ts index a3c3debb1..a26314e05 100644 --- a/src/core/utils/command.ts +++ b/src/core/utils/command.ts @@ -1,25 +1,25 @@ import process from "process"; import { execFileSync, execSync, SpawnSyncOptions } from "child_process"; -import { ExecutableResolutionOptions, getTrustedShell, InternalExecutionSpec, prepareExecution } from "./executable.js"; +import { getTrustedShell, InternalExecutionSpec, prepareExecution } from "./executable.js"; -export function runCommand(command: string, cwd?: string, options: ExecutableResolutionOptions = {}) { - const shell = getTrustedShell({ ...options, untrustedRoots: [...(options.untrustedRoots ?? []), ...(cwd ? [{ directory: cwd }] : [])] }); +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, - ...(process.platform === "win32" ? { ComSpec: shell } : {}), + ...env, // Internal flag to avoid duplicating user messages SWA_CLI_INTERNAL_COMMAND: "1", }, }); } -export function execFileCommand(command: string, cwd?: string, args?: string[], resolutionOptions?: ExecutableResolutionOptions) { - return execInternalCommand({ executable: command, cwd, args: args ?? [], resolutionOptions }); +export function execFileCommand(command: string, cwd?: string, args?: string[]) { + return execInternalCommand({ executable: command, cwd, args: args ?? [] }); } export function execInternalCommand(execution: InternalExecutionSpec) { diff --git a/src/core/utils/executable.compatibility.spec.ts b/src/core/utils/executable.compatibility.spec.ts index ff56e14d4..24cb24ce8 100644 --- a/src/core/utils/executable.compatibility.spec.ts +++ b/src/core/utils/executable.compatibility.spec.ts @@ -124,7 +124,7 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = expect(fs.readFileSync(output, "utf8").trim()).toBe("compatibility wrapper stdout"); fs.writeFileSync(report, ""); fs.unlinkSync(output); - const execution = resolveTrustedPackageManager("npm", { untrustedRoots: [project] }); + const execution = resolveTrustedPackageManager("npm"); const status = executeWithStatus({ ...execution, args: [...execution.args, "install"], cwd: project }); expect({ status, @@ -150,12 +150,12 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = ])("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 = { untrustedRoots: [project] }; + 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, resolutionOptions }); + execInternalCommand({ executable, args: ["start"], cwd: project }); expect(records()).toEqual([ { execPath: process.execPath, @@ -191,9 +191,9 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = const callerPath = [local, external, path.dirname(process.execPath)].join(path.delimiter); vi.stubEnv("PATH", callerPath); vi.stubEnv("SWA_COMPAT_VERSION", version); - const resolutionOptions = { untrustedRoots: [project] }; + const resolutionOptions = {}; - const baseline = createShellExecutionSpec(`"${tool}" ${command}`, project, resolutionOptions); + const baseline = createShellExecutionSpec(`"${tool}" ${command}`, project); const control = spawnSync(baseline.executable, baseline.args, { cwd: project, env: { ...process.env, ...baseline.env }, @@ -209,7 +209,7 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = const execution = tool === "npm" ? resolveTrustedPackageManager("npm", resolutionOptions) - : { executable: resolveTrustedExecutable(tool, resolutionOptions)!, args: [], 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(); @@ -253,12 +253,12 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = 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, untrustedRoots: [project] }; + 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, resolutionOptions }); + execInternalCommand({ executable, args: ["start"], cwd: project }); const argv = windows ? [["--version"], ["--wrapper-flag", "--version"], ["--version"], ["--wrapper-flag", "start"]] : [ @@ -274,8 +274,8 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = it.skipIf(!windows)( "native Windows: off-PATH CWD canaries cannot preempt wrappers or fixed cmd/Node loaders", () => { - const resolutionOptions = { untrustedRoots: [project] }; - const trustedCmd = createShellExecutionSpec("echo fixture", project, resolutionOptions).executable; + 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"]) { @@ -289,7 +289,7 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = fs.writeFileSync(report, ""); const selected = name === "npm" ? resolveTrustedPackageManager("npm", resolutionOptions).executable : resolveTrustedExecutable(name, resolutionOptions)!; - const prepared = prepareExecution({ executable: selected, args: ["start"], cwd: project, resolutionOptions }); + const prepared = prepareExecution({ executable: selected, args: ["start"], cwd: project }); expect(prepared.executable).toBe(trustedCmd); expect(prepared.env?.ComSpec).toBe(trustedCmd); execInternalCommand(prepared); @@ -318,7 +318,7 @@ describe("trusted external wrapper compatibility (benign native fixtures)", () = 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", { untrustedRoots: [project] }); + 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")); diff --git a/src/core/utils/executable.security.spec.ts b/src/core/utils/executable.security.spec.ts index 9e1d2ce19..b8e2abf75 100644 --- a/src/core/utils/executable.security.spec.ts +++ b/src/core/utils/executable.security.spec.ts @@ -8,7 +8,7 @@ import { assertAbsoluteExecutable, createShellExecutionSpec, prepareExecution, - resolveManagedExecutable, + resolveTrustedExecutablePath, resolveTrustedExecutable, resolveTrustedPackageManager, } from "./executable.js"; @@ -49,16 +49,16 @@ describe("trusted executable boundary (inert files only)", () => { expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); }); - it("allows configured API PATH tools without relaxing managed API-root exclusions", () => { + 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 = { untrustedRoots: ["/api-alias"] }; + const options = { cwd: "/api-alias" }; expect(resolveTrustedExecutable("func", options)).toBe("/api-root/bin/func"); - expect(() => resolveManagedExecutable("/api-root/bin/func", options)).toThrow(/inside the project/); + expect(resolveTrustedExecutablePath("/api-alias/bin/func")).toBe("/api-root/bin/func"); vi.stubEnv("PATH", "/trusted/bin"); expect(resolveTrustedExecutable("func", options)).toBe("/trusted/bin/func"); }); @@ -72,18 +72,26 @@ describe("trusted executable boundary (inert files only)", () => { expect(resolveTrustedExecutable("func")).toBe("/project-tools/func"); }); - it("rejects a managed missing-file path whose existing parent aliases the project", () => { + it("does not select a missing tool beneath an explicitly configured project alias", () => { // Arrange fs.symlinkSync("/project", "/cache-alias"); // Act / Assert - expect(() => resolveManagedExecutable("/cache-alias/not-downloaded/func")).toThrow(/inside the project/); + 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("binds a missing managed filename to its trusted canonical parent", () => { + it("keeps a future configured directory eligible once its executable exists", () => { // Arrange fs.symlinkSync("/trusted", "/cache-alias"); // Act / Assert - expect(resolveManagedExecutable("/cache-alias/new/version/func")).toBe("/trusted/new/version/func"); + 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", () => { @@ -98,7 +106,7 @@ describe("trusted executable boundary (inert files only)", () => { expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); }); - it("fails closed when a managed-path exclusion root cannot be canonicalized", () => { + 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); @@ -107,18 +115,25 @@ describe("trusted executable boundary (inert files only)", () => { return realpath(file, ...args); }); // Act / Assert - expect(() => resolveManagedExecutable("/trusted/bin/not-downloaded/func", { untrustedRoots: ["/denied-root"] })).toThrow("denied"); + expect(resolveTrustedExecutable("func")).toBe("/trusted/bin/func"); + expect(fs.realpathSync).not.toHaveBeenCalledWith("/denied-root"); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("func")).toBeUndefined(); }); - it("rejects a trusted wrapper whose interpreter resolves inside the project", () => { + 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"] })).toThrow(/trusted script interpreter/); + expect(prepareExecution({ executable, args: ["start"] })).toMatchObject({ + executable: "/project/node", + args: ["/trusted/bin/func", "start"], + }); }); it.each(["func", "./func"])("refuses a relative identity: %s", (file) => { @@ -153,7 +168,7 @@ describe("trusted executable boundary (inert files only)", () => { }); }); - it("binds future caller PATH aliases canonically while retaining managed exclusions and global alternatives", () => { + 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", @@ -168,9 +183,8 @@ describe("trusted executable boundary (inert files only)", () => { fs.chmodSync("/project/future/node", 0o755); fs.chmodSync("/trusted/future/node", 0o755); vi.stubEnv("PATH", prepared.env!.PATH!); - expect(prepared.env!.PATH!.split(":")).toEqual(["/project/future", "/trusted/future", "/trusted/bin"]); + expect(prepared.env!.PATH!.split(":")).toEqual(["/outside-project-alias/future", "/trusted/future", "/trusted/bin"]); expect(resolveTrustedExecutable("node")).toBe("/project/future/node"); - expect(() => resolveManagedExecutable("/outside-project-alias/future/node")).toThrow(/inside the project/); vi.stubEnv("PATH", "/trusted/future:/trusted/bin"); expect(resolveTrustedExecutable("node")).toBe("/trusted/future/node"); }); @@ -186,7 +200,7 @@ describe("trusted executable boundary (inert files only)", () => { fs.chmodSync(`/trusted/bin/${manager}`, 0o755); vi.stubEnv("PATH", "/trusted/bin"); // Act / Assert - expect(resolveTrustedPackageManager(manager)).toEqual({ executable: `/trusted/bin/${manager}`, args: [], resolutionOptions: {} }); + expect(resolveTrustedPackageManager(manager)).toEqual({ executable: `/trusted/bin/${manager}`, args: [] }); }); it("never substitutes metadata for the configured launcher and keeps explicit aliases canonical", () => { @@ -201,7 +215,7 @@ describe("trusted executable boundary (inert files only)", () => { 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: [], resolutionOptions: {} }); + 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"); @@ -253,7 +267,6 @@ describe("simulated Windows known shim identities (not native proof)", () => { expect(resolveTrustedPackageManager(manager)).toEqual({ executable: canonical(`C:\\trusted\\${manager}.cmd`), args: [], - resolutionOptions: {}, }); }); diff --git a/src/core/utils/executable.ts b/src/core/utils/executable.ts index 6737bcca2..797ee0f45 100644 --- a/src/core/utils/executable.ts +++ b/src/core/utils/executable.ts @@ -11,60 +11,23 @@ export interface InternalExecutionSpec { cwd?: string; /** Overrides on input; a prepared environment, when present, is a complete snapshot. */ env?: NodeJS.ProcessEnv; - resolutionOptions?: ExecutableResolutionOptions; /** Arguments have already been encoded for an absolute Windows shell. */ windowsVerbatimArguments?: boolean; } -export interface ExecutableDirectoryExclusion { - /** An exact invocation directory, not a recursive project boundary. */ - directory: string; -} - -export type ExecutableExclusion = string | ExecutableDirectoryExclusion; - export interface ExecutableResolutionOptions { /** Working directory for caller-relative PATH entries and the selected tool's probe. */ cwd?: string; - /** Project boundaries for fixed system identities and managed paths, not configured PATH choices. */ - untrustedRoots?: readonly ExecutableExclusion[]; } export class UnsupportedExecutableError extends Error {} -let callerPath: { value: string | undefined; extensions: string | undefined } | undefined; -const absentCallerPath = "SWA_CLI_INTERNAL_PATH_ABSENT"; - 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; } -/** Capture inherited lookup configuration before project dotenv can fill missing PATH/PATHEXT. */ -export function captureCallerPath(): void { - let value = pathValue(process.env); - if (os.platform() === "win32" && process.env[absentCallerPath] === "1" && value === "") { - for (const key of Object.keys(process.env)) { - if (key.toLowerCase() === "path") delete process.env[key]; - } - value = undefined; - } - delete process.env[absentCallerPath]; - callerPath = { value, extensions: pathValue(process.env, "PATHEXT") }; -} - -function preserveCallerLookup(env: NodeJS.ProcessEnv): void { - delete env[absentCallerPath]; - if (os.platform() !== "win32" || !callerPath) return; - for (const key of Object.keys(env)) { - if (key.toLowerCase() === "pathext") delete env[key]; - } - // An undefined override prevents composed command environments from restoring project PATHEXT. - env.PATHEXT = callerPath.extensions; - if (callerPath.value === undefined) env[absentCallerPath] = "1"; -} - function executionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env }; for (const [key, value] of Object.entries(overrides)) { @@ -75,6 +38,13 @@ function executionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.Process } 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; } @@ -106,38 +76,6 @@ function realPath(file: string): string | undefined { } } -function containsPath(root: string, file: string): boolean { - const paths = platformPath(); - // Conservative case folding also covers case-insensitive Unix volumes. - const normalize = (value: string) => - paths - .normalize(value) - .replace(/^\\\\\?\\UNC\\/i, "\\\\") - .replace(/^\\\\\?\\([a-z]:\\)/i, "$1") - .toLowerCase(); - const relative = paths.relative(normalize(root), normalize(file)); - return relative === "" || (!relative.startsWith(`..${paths.sep}`) && relative !== ".." && !paths.isAbsolute(relative)); -} - -function samePath(first: string, second: string): boolean { - return containsPath(first, second) && containsPath(second, first); -} - -function matchesDirectory(directory: string, exclusion: ExecutableDirectoryExclusion): boolean { - const root = platformPath().resolve(exclusion.directory); - return samePath(root, directory) || samePath(realPath(root) ?? root, directory); -} - -function excludedPath(file: string, options: ExecutableResolutionOptions): boolean { - const paths = platformPath(); - const entries = options.untrustedRoots ?? []; - const roots = entries.filter((entry): entry is string => typeof entry === "string").map((root) => paths.resolve(root)); - const cwd = paths.resolve(process.cwd()); - // Legacy callers assume cwd is their project. Explicit contexts classify it without that assumption. - if (!entries.some((entry) => typeof entry !== "string" && matchesDirectory(cwd, entry))) roots.push(cwd); - return roots.some((root) => containsPath(root, file) || containsPath(realPath(root) ?? root, file)); -} - function executablePath(file: string): string | undefined { if (!isAbsoluteExecutable(file)) return undefined; @@ -153,46 +91,13 @@ function executablePath(file: string): string | undefined { return canonical; } -function trustedPath(file: string, options: ExecutableResolutionOptions): string | undefined { - if (excludedPath(file, options)) return undefined; - const canonical = executablePath(file); - return canonical && !excludedPath(canonical, options) ? canonical : undefined; -} - -function canonicalPath(file: string): string | undefined { - const paths = platformPath(); - const suffix: string[] = []; - let parent = file; - let canonical = realPath(parent); - while (!canonical) { - const next = paths.dirname(parent); - if (next === parent) return undefined; - suffix.unshift(paths.basename(parent)); - parent = next; - canonical = realPath(parent); - } - return paths.join(canonical, ...suffix); -} - -/** Bind a managed path even before download, while checking existing parent-directory aliases. */ -export function resolveManagedExecutable(file: string, options: ExecutableResolutionOptions = {}): string { - assertAbsoluteExecutable(file); - const resolved = canonicalPath(file); - if (!resolved) throw new Error(`Cannot resolve managed executable directory: ${file}`); - if (excludedPath(file, options) || excludedPath(resolved, options)) { - throw new Error("The managed tool directory resolves inside the project. Move the CLI tool cache outside the project and retry."); - } - assertAbsoluteExecutable(resolved); - return resolved; -} - -function pathDirectories(options: ExecutableResolutionOptions, env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string[] { +function pathDirectories(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string[] { const windows = os.platform() === "win32"; - let value = callerPath ? callerPath.value : pathValue(env); + let value = pathValue(env); if (value === undefined && !windows) { const defaultEnv = { ...env }; delete defaultEnv.PATH; - const result = spawnSync(getTrustedShell(options), ["-c", 'printf "%s" "$PATH"'], { + const result = spawnSync(getTrustedShell(), ["-c", 'printf "%s" "$PATH"'], { cwd, env: defaultEnv, encoding: "utf8", @@ -209,16 +114,6 @@ function pathDirectories(options: ExecutableResolutionOptions, env: NodeJS.Proce // Empty Windows entries do not declare CWD; normalize them before guarded cmd lookup. .filter((directory) => !windows || directory !== "") .map((directory) => platformPath().resolve(cwd, directory || ".")) - .flatMap((directory) => { - let canonical: string | undefined; - try { - canonical = canonicalPath(directory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EACCES") return []; - throw error; - } - return canonical ? [canonical] : []; - }) ); } @@ -257,7 +152,7 @@ export function resolveTrustedExecutable(name: string, options: ExecutableResolu throw new Error(`Expected an internal tool name, not a path: ${name}`); } const windows = os.platform() === "win32"; - const extensions = callerPath ? callerPath.extensions : pathValue(process.env, "PATHEXT"); + 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) @@ -266,7 +161,7 @@ export function resolveTrustedExecutable(name: string, options: ExecutableResolu .filter((extension) => /^\.(com|exe|bat|cmd)$/i.test(extension)) .map((extension) => `${name}${extension.toLowerCase()}`) : [name]; - for (const directory of pathDirectories(options, process.env, options.cwd)) { + 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; @@ -276,59 +171,54 @@ export function resolveTrustedExecutable(name: string, options: ExecutableResolu return undefined; } -export function resolveTrustedExecutablePath(file: string, options: ExecutableResolutionOptions = {}): string | undefined { - const executable = trustedPath(file, options); +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: [], resolutionOptions: options, ...(options.cwd ? { cwd: options.cwd } : {}) }; + 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(options: ExecutableResolutionOptions = {}): string { +export function getTrustedShell(): string { if (os.platform() !== "win32") { - const shell = trustedPath("/bin/sh", options); + const shell = executablePath("/bin/sh"); if (shell && isExecutableFile(shell)) return shell; } else { - return getTrustedWindowsSystemExecutable("cmd.exe", options); + return getTrustedWindowsSystemExecutable("cmd.exe"); } - throw new Error("Cannot locate a trusted absolute system shell outside the project. Check your operating system installation."); + throw new Error("Cannot locate an absolute system shell. Check your operating system installation."); } -export function getTrustedWindowsSystemExecutable(name: "cmd.exe" | "taskkill.exe", options: ExecutableResolutionOptions = {}): string { +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 = trustedPath(path.win32.join(systemRoot, "System32", name), options); + const executable = executablePath(path.win32.join(systemRoot, "System32", name)); if (executable && isExecutableFile(executable)) return executable; } - throw new Error(`Cannot locate trusted System32\\${name} outside the project. Check your Windows installation.`); + throw new Error(`Cannot locate System32\\${name}. Check your Windows installation.`); } -export function createShellExecutionSpec(command: string, cwd: string, options: ExecutableResolutionOptions = {}): InternalExecutionSpec { - const executable = getTrustedShell(options); +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(options: ExecutableResolutionOptions, overrides?: NodeJS.ProcessEnv, cwd = process.cwd()): NodeJS.ProcessEnv { +function internalEnvironment(overrides?: NodeJS.ProcessEnv, cwd = process.cwd()): NodeJS.ProcessEnv { const env = executionEnvironment(overrides); const windows = os.platform() === "win32"; - const directories = pathDirectories(options, env, cwd); + const directories = pathDirectories(env, cwd); for (const key of Object.keys(env)) { - if (windows && ["path", "comspec", "nodefaultcurrentdirectoryinexepath"].includes(key.toLowerCase())) delete env[key]; + if (windows && key.toLowerCase() === "path") delete env[key]; } env[windows ? "Path" : "PATH"] = directories.join(windows ? ";" : ":"); - if (windows) { - env.ComSpec = getTrustedWindowsSystemExecutable("cmd.exe", options); - env.NoDefaultCurrentDirectoryInExePath = "1"; - } - preserveCallerLookup(env); return env; } @@ -358,16 +248,9 @@ function windowsVariableExpansion(env: NodeJS.ProcessEnv, cwd: string): "%" | "! } /** 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 = "", - options: ExecutableResolutionOptions = {}, -): InternalExecutionSpec { +export function createToolShellExecutionSpec(executable: string, args: string[], cwd: string, suffix = ""): InternalExecutionSpec { assertAbsoluteExecutable(executable); - const context = { untrustedRoots: [...(options.untrustedRoots ?? []), { directory: cwd }] }; - const env = internalEnvironment(context, undefined, cwd); + const env = internalEnvironment(undefined, cwd); let prefix: string; if (os.platform() === "win32") { env.SWA_CLI_INTERNAL_EXECUTABLE = executable; @@ -375,31 +258,25 @@ export function createToolShellExecutionSpec( const expansion = windowsVariableExpansion(env, cwd); prefix = [`"${expansion}SWA_CLI_INTERNAL_EXECUTABLE${expansion}"`, ...args.map(quoteArgument)].join(" "); } else { - const execution = prepareExecution({ executable, args, cwd, resolutionOptions: context }); + const execution = prepareExecution({ executable, args, cwd }); prefix = [execution.executable, ...execution.args].map(quoteArgument).join(" "); } - return { ...createShellExecutionSpec(`${prefix}${suffix ? ` ${suffix}` : ""}`, cwd, context), env }; + return { ...createShellExecutionSpec(`${prefix}${suffix ? ` ${suffix}` : ""}`, cwd), env }; } -/** Native identities stay direct; script loaders retain canonical caller-selected PATH entries. */ +/** 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) }; - const options = { - untrustedRoots: [ - ...(spec.resolutionOptions?.untrustedRoots ?? []), - ...(spec.cwd ? [spec.resolutionOptions ? { directory: spec.cwd } : spec.cwd] : []), - ], - }; if (os.platform() === "win32" && /\.(?:cmd|bat)$/i.test(spec.executable)) { return { - ...createToolShellExecutionSpec(spec.executable, spec.args, spec.cwd ?? process.cwd(), "", options), - env: { ...internalEnvironment(options, spec.env, spec.cwd), SWA_CLI_INTERNAL_EXECUTABLE: spec.executable }, + ...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 = trustedPath(script.interpreter, options); + const interpreter = executablePath(script.interpreter); if (!interpreter || !isExecutableFile(interpreter)) { throw new UnsupportedExecutableError(`Cannot bind a trusted script interpreter for ${spec.executable}.`); } @@ -407,23 +284,13 @@ export function prepareExecution(spec: InternalExecutionSpec): InternalExecution ...spec, executable: interpreter, args: [...(script.argument ? [script.argument] : []), spec.executable, ...spec.args], - env: internalEnvironment(options, spec.env, spec.cwd), + 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}.`); } - if (!callerPath) return spec.env ? { ...spec, env: executionEnvironment(spec.env) } : spec; - // Preserve absence too: a reentered CLI must capture PATH before loading its own project dotenv. - const env = executionEnvironment(spec.env); - for (const key of Object.keys(env)) { - if (os.platform() === "win32" ? key.toLowerCase() === "path" : key === "PATH") delete env[key]; - } - if (callerPath.value !== undefined) env[os.platform() === "win32" ? "Path" : "PATH"] = callerPath.value; - // Windows spawn fills an omitted PATH from the parent; the entry hook restores this placeholder's absence. - else if (os.platform() === "win32") env.Path = ""; - preserveCallerLookup(env); - return { ...spec, env }; + return { ...spec, env: executionEnvironment(spec.env) }; } export function probeExecutable( @@ -431,7 +298,7 @@ export function probeExecutable( args: string[], options: ExecutableResolutionOptions = {}, ): Promise<{ stdout: string; stderr: string }> { - const spec = prepareExecution({ executable, args, cwd: options.cwd, resolutionOptions: options }); + const spec = prepareExecution({ executable, args, cwd: options.cwd }); return promisify(execFile)(spec.executable, spec.args, { encoding: "utf8", shell: false, diff --git a/src/core/utils/process.security.spec.ts b/src/core/utils/process.security.spec.ts index 6ea1b9d76..5cdbf5cf1 100644 --- a/src/core/utils/process.security.spec.ts +++ b/src/core/utils/process.security.spec.ts @@ -6,6 +6,19 @@ 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)", () => { @@ -78,30 +91,68 @@ describe("direct process supervisor lifecycle (all launches/signals intercepted) second.emit("close", 0, null); await result; // Assert - expect(childProcess.spawn).toHaveBeenNthCalledWith(1, "/trusted/node", ["literal space", ";"], { - cwd: "/project/app", - env: { ...process.env, TEST_CONTEXT: "inherited" }, - stdio: "inherit", - shell: false, - detached: true, - windowsVerbatimArguments: undefined, - }); + 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("rolls back owned siblings on asynchronous spawn error", async () => { + it("waits for native close after an asynchronous spawn error without killing siblings", async () => { // Arrange const error = new Error("spawn failed"); - const result = superviseProcesses(commands).catch((events) => events); + let settled = false; + const result = superviseProcesses(commands) + .catch((events) => events) + .then((events) => { + settled = true; + return events; + }); // Act first.emit("error", error); - second.emit("close", null, "SIGTERM"); + await Promise.resolve(); + const beforeNativeClose = settled; + first.emit("close", 1, null); + second.emit("close", 0, null); // Assert - expect((await result)[0].error).toBe(error); - expect(process.kill).toHaveBeenCalledWith(-second.pid, "SIGTERM"); - expect(vi.getTimerCount()).toBe(0); + expect((await result).map((event: { exitCode: number }) => event.exitCode)).toEqual([1, 0]); + expect(beforeNativeClose).toBe(false); + expect(process.kill).not.toHaveBeenCalled(); }); - it("stops later launches and cleans earlier children after synchronous spawn failure", async () => { + it("leaves a synchronous spawn failure to the named core without adding rollback policy", () => { // Arrange childProcess.spawn .mockReset() @@ -109,49 +160,56 @@ describe("direct process supervisor lifecycle (all launches/signals intercepted) .mockImplementationOnce(() => { throw new Error("sync spawn"); }); - const result = superviseProcesses([...commands, { name: "never", execution: commands[0].execution }]).catch((events) => events); - // Act - first.emit("close", null, "SIGTERM"); + const launch = () => superviseProcesses(commands); // Assert - expect((await result)[2]).toMatchObject({ command: { name: "never" }, killed: true, exitCode: 1 }); + expect(launch).toThrow("sync spawn"); + first.emit("close", 0, null); expect(childProcess.spawn).toHaveBeenCalledTimes(2); - expect(process.kill).toHaveBeenCalledWith(-first.pid, "SIGTERM"); + expect(process.kill).not.toHaveBeenCalled(); }); - it.each(["SIGINT", "SIGTERM"] as const)("cleans owned groups and removes %s listeners and timers", async (signal) => { + 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: emit the EventEmitter notification, never send a real OS signal. - process.emit(signal); + // 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.every((event: { terminationSignal: string }) => event.terminationSignal === signal)).toBe(true); - expect(process.kill).toHaveBeenCalledWith(-first.pid, signal); - expect(process.kill).toHaveBeenCalledWith(-first.pid, "SIGKILL"); + 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("bounds escalation and completion even when force-kill never produces close", async () => { + it("does not synthesize deadline completion before native close", async () => { // Arrange - const result = superviseProcesses(commands).catch((events) => events); + let settled = false; + const result = superviseProcesses(commands).then((events) => { + settled = true; + return events; + }); // Act - process.emit("SIGTERM"); - await vi.advanceTimersByTimeAsync(5000); - expect(process.kill).toHaveBeenCalledWith(-second.pid, "SIGKILL"); - await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(10000); + const beforeNativeClose = settled; + first.emit("close", 0, null); + second.emit("close", 0, null); + await result; // Assert - expect((await result).every((event: { error: Error }) => /cleanup timed out/.test(event.error.message))).toBe(true); + expect(beforeNativeClose).toBe(false); expect(vi.getTimerCount()).toBe(0); }); - it("uses absolute System32 taskkill with literal owned PID argv on simulated Windows", async () => { + 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); @@ -159,7 +217,7 @@ describe("direct process supervisor lifecycle (all launches/signals intercepted) const windowsCommands = commands.map((command) => ({ ...command, execution: { ...command.execution, executable: "C:\\trusted\\node.exe" } })); const result = superviseProcesses(windowsCommands).catch((events) => events); // Act - process.emit("SIGINT"); + coreHook.commands[0].kill("SIGINT"); first.emit("close", 1, null); second.emit("close", 1, null); await result; @@ -171,13 +229,14 @@ describe("direct process supervisor lifecycle (all launches/signals intercepted) expect.any(Function), ); expect(process.kill).not.toHaveBeenCalled(); - expect(childProcess.spawn.mock.calls[0][2]).toMatchObject({ shell: false, detached: false }); + expect(childProcess.execFile).toHaveBeenCalledTimes(1); + expect(childProcess.spawn.mock.calls[0][2]).toMatchObject({ shell: false }); }); - it("rejects empty or relative execution specs before launching", async () => { + it("rejects empty or relative execution specs before launching", () => { // Arrange / Act / Assert - await expect(superviseProcesses([])).rejects.toThrow(/No processes/); - await expect(superviseProcesses([{ name: "bad", execution: { executable: "node", args: [] } }])).rejects.toThrow(/absolute path/); + 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 index 6e0b62fc2..c5f40aa48 100644 --- a/src/core/utils/process.ts +++ b/src/core/utils/process.ts @@ -1,9 +1,7 @@ import { execFile, spawn } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { concurrently, Command, CloseEvent } from "concurrently"; +import { concurrently, CloseEvent } from "concurrently"; import os from "node:os"; -import process from "node:process"; -import { ExecutableResolutionOptions, getTrustedWindowsSystemExecutable, InternalExecutionSpec, prepareExecution } from "./executable.js"; +import { getTrustedWindowsSystemExecutable, InternalExecutionSpec, prepareExecution } from "./executable.js"; import { logger } from "./logger.js"; export interface NamedExecutionSpec { @@ -11,165 +9,35 @@ export interface NamedExecutionSpec { execution: InternalExecutionSpec; } -export interface ProcessCloseEvent extends Pick { - terminationSignal?: NodeJS.Signals; - error?: Error; -} - -/** - * The named concurrently core retains raw I/O and wait-for-all completion. - * Only spawn errors and parent signals trigger coordinated cleanup; ordinary exits do not. - */ -export async function superviseProcesses(commands: NamedExecutionSpec[], options: ExecutableResolutionOptions = {}): Promise { - if (!commands.length) throw new Error("No processes were provided."); +/** 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 windows = os.platform() === "win32"; - const taskkill = windows ? getTrustedWindowsSystemExecutable("taskkill.exe", options) : undefined; - let running: Command[] = []; - const owned = new Map(); - const finished = new Set(); - const errors = new Map(); - const signals = new Map(); - let stopping: NodeJS.Signals | undefined; - let escalation: NodeJS.Timeout | undefined; - let deadline: NodeJS.Timeout | undefined; - - const kill = (pid: number, signal = "SIGTERM") => { - const index = owned.get(pid); - if (index === undefined) throw new Error("Refusing to stop an unowned process."); - if (taskkill) { - execFile(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (error) => { - if (error && !finished.has(index)) { - logger.warn("Unable to stop a child process tree. Check the running emulator/tool processes."); - } - }); - } else { - try { - // Each child owns its group, including descendants created by customer shells. - process.kill(-pid, signal); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; - } - } - }; - - const finish = (command: Command, error?: Error) => { - if (finished.has(command.index)) return; - if (error) errors.set(command.index, error); - const now = new Date(); - command.close.next({ - command, - index: command.index, - exitCode: 1, - killed: signals.has(command.index), - timings: { startDate: now, endDate: now, durationSeconds: 0 }, - }); - // A spawn error may also produce a later native close event; count the command only once. - command.close.complete(); - }; - - const terminate = (signal: NodeJS.Signals) => { - for (const command of running) { - if (finished.has(command.index)) continue; - signals.set(command.index, stopping ?? signal); - try { - command.kill(signal); - } catch (error) { - logger.warn(`Unable to stop a child process: ${(error as Error).message}`); - } - } - }; - const stop = (signal: NodeJS.Signals) => { - if (stopping) return; - stopping = signal; - terminate(signal); - escalation = setTimeout(() => { - terminate("SIGKILL"); - deadline = setTimeout(() => { - for (const command of running) { - finish(command, new Error("Child process cleanup timed out. Check the running emulator/tool processes.")); - } - }, 5000); - }, 5000); - }; - const onInterrupt = () => stop("SIGINT"); - const onTerminate = () => stop("SIGTERM"); - process.on("SIGINT", onInterrupt); - process.on("SIGTERM", onTerminate); - - const closeEvent = (event: CloseEvent): ProcessCloseEvent => ({ - ...event, - terminationSignal: signals.get(event.index), - error: errors.get(event.index), - killed: event.killed || signals.has(event.index), - }); - try { - const core = concurrently( - commands.map(({ name }, index) => ({ name, command: `swa-internal-${index}` })), - { - raw: true, - controllers: [ - { - handle(coreCommands) { - running = coreCommands; - for (const command of running) { - command.close.subscribe(() => { - finished.add(command.index); - if (!windows && signals.has(command.index) && command.pid) { - try { - kill(command.pid, "SIGKILL"); - } catch (error) { - logger.warn(`Unable to stop remaining child processes: ${(error as Error).message}`); - } - } - }); - } - return { commands: running }; - }, - }, - ], - kill, - spawn(key) { - const index = Number(key.slice("swa-internal-".length)); - const execution = executions[index]; - if (!execution) throw new Error("Unknown internal execution identity."); - try { - if (!stopping) { - const child = spawn(execution.executable, execution.args, { - cwd: execution.cwd ?? process.cwd(), - env: execution.env ?? process.env, - stdio: "inherit", - shell: false, - detached: !windows, - windowsVerbatimArguments: execution.windowsVerbatimArguments, + 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."); }); - if (child.pid) owned.set(child.pid, index); - child.once("error", (error: Error) => { - stop("SIGTERM"); - finish(running[index], error); - }); - return child; - } - } catch (error) { - errors.set(index, error instanceof Error ? error : new Error(String(error))); - stop("SIGTERM"); + }, } - signals.set(index, stopping!); - queueMicrotask(() => finish(running[index], errors.get(index))); - return Object.assign(new EventEmitter(), { stdin: null, stdout: null, stderr: null }); - }, + : {}), + 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, + }); }, - ); - try { - return (await core.result).map(closeEvent); - } catch (error) { - if (!Array.isArray(error)) throw error; - throw error.map(closeEvent); - } - } finally { - process.removeListener("SIGINT", onInterrupt); - process.removeListener("SIGTERM", onTerminate); - if (escalation) clearTimeout(escalation); - if (deadline) clearTimeout(deadline); - } + }, + ); + return core.result; } diff --git a/src/core/utils/project-roots.security.spec.ts b/src/core/utils/project-roots.security.spec.ts index 641296722..8ba4a45cb 100644 --- a/src/core/utils/project-roots.security.spec.ts +++ b/src/core/utils/project-roots.security.spec.ts @@ -3,9 +3,9 @@ import { posix as simulated } from "../../../tests/_mocks/posix.js"; import { fs, vol } from "memfs"; import os from "node:os"; import * as nodeFs from "node:fs"; -import { ExecutableResolutionOptions, resolveManagedExecutable, resolveTrustedExecutable, resolveTrustedPackageManager } from "./executable.js"; +import { ExecutableResolutionOptions, resolveTrustedExecutable, resolveTrustedExecutablePath, resolveTrustedPackageManager } from "./executable.js"; -describe("invocation directory versus selected project trust (inert fixtures)", () => { +describe("configured tools at invocation versus operation cwd (inert fixtures)", () => { const home = "/home/tester"; const project = `${home}/project`; @@ -14,19 +14,17 @@ describe("invocation directory versus selected project trust (inert fixtures)", vol.reset(); vi.spyOn(os, "platform").mockReturnValue("linux"); simulated.cwd = home; - vol.fromJSON({ [`${project}/swa-cli.config.json`]: "{}" }); + vol.fromJSON({ [`${project}/swa-cli.config.json`]: "{}", [`${project}/func`]: "off-PATH CWD canary" }); + fs.chmodSync(`${project}/func`, 0o755); }); afterEach(() => { vi.unstubAllEnvs(); }); - async function selectProject(invocation = home, selected = project) { + function selectProject(invocation = home, selected = project): ExecutableResolutionOptions { simulated.cwd = invocation; - // The module captures the original invocation cwd, before config loading/chdir. - const { getProjectRoots } = await import("./cli-config.js"); - simulated.cwd = selected; - return { untrustedRoots: getProjectRoots({ config: `${selected}/swa-cli.config.json`, appLocation: selected }) }; + return { cwd: selected }; } function expectConfiguredTool(tool: string, options: ExecutableResolutionOptions) { @@ -40,8 +38,11 @@ describe("invocation directory versus selected project trust (inert fixtures)", const tool = `${home}/.swa/core-tools/v4/func`; vol.fromJSON({ [tool]: "inert native fixture" }); const options = await selectProject(); - // Act / Assert: HOME is an exact search-directory exclusion, not a recursive project root. - expect(resolveManagedExecutable(tool, options)).toBe(tool); + 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 () => { @@ -54,19 +55,24 @@ describe("invocation directory versus selected project trust (inert fixtures)", }); 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: [], resolutionOptions: options }); + expect(resolveTrustedPackageManager("npm", options)).toEqual({ executable: script, args: [], cwd: project }); + vi.stubEnv("PATH", "/off-path"); + expect(resolveTrustedExecutable("npm", options)).toBeUndefined(); }); - it("allows an ordinary HOME tool outside the project without cache-specific exemptions", async () => { - // Arrange: the distinction is root classification, not a .swa/.nvm allowlist. + 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`); - const options = await selectProject(); // Act / Assert expect(resolveTrustedExecutable("func", options)).toBe(tool); }); @@ -92,7 +98,7 @@ describe("invocation directory versus selected project trust (inert fixtures)", expectConfiguredTool(`${home}/func`, options); }); - it("honors configured repository tools while retaining the recursive managed boundary", async () => { + 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`; @@ -106,10 +112,10 @@ describe("invocation directory versus selected project trust (inert fixtures)", const options = await selectProject(repo, selected); // Act / Assert expectConfiguredTool(`${repo}/tools/func`, options); - expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); + expect(resolveTrustedExecutablePath(`${repo}/tools/func`)).toBe(`${repo}/tools/func`); }); - it("retains managed repository scope beneath a valid worktree .git file without banning configured tools", async () => { + 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({ @@ -123,30 +129,27 @@ describe("invocation directory versus selected project trust (inert fixtures)", const options = await selectProject(`${repo}/nested`); // Act / Assert expectConfiguredTool(`${repo}/tools/func`, options); - expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); + 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.each(["package.json", "swa-cli.config.json"])( - "retains managed non-Git project scope beneath %s without banning configured tools", - 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(() => resolveManagedExecutable(`${root}/tools/func`, options)).toThrow(/inside the project/); - }, - ); - - it("honors a selected-project PATH alias while rejecting that alias as a managed cache", async () => { - // Arrange: invocation HOME has only exact scope, but selected project remains recursive. + 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"); @@ -154,10 +157,10 @@ describe("invocation directory versus selected project trust (inert fixtures)", const options = await selectProject(); // Act / Assert expectConfiguredTool(`${project}/tools/func`, options); - expect(() => resolveManagedExecutable("/selected-tools-alias/func", options)).toThrow(/inside the project/); + expect(resolveTrustedExecutablePath("/selected-tools-alias/func")).toBe(`${project}/tools/func`); }); - it("retains canonical managed ancestry for an aliased project without banning configured repository tools", async () => { + it("selects canonical configured tools when the operation cwd itself is aliased", async () => { // Arrange const repo = "/canonical-repo"; vol.fromJSON({ @@ -171,18 +174,22 @@ describe("invocation directory versus selected project trust (inert fixtures)", const options = await selectProject(home, "/selected-app-alias"); // Act / Assert expectConfiguredTool(`${repo}/tools/func`, options); - expect(() => resolveManagedExecutable(`${repo}/tools/func`, options)).toThrow(/inside the project/); + expect(resolveTrustedExecutablePath(`${repo}/tools/func`)).toBe(`${repo}/tools/func`); }); - it.each([".git", "package.json"])("fails closed when invocation %s evidence cannot be inspected", async (marker) => { - // Arrange: intercept the named fs API used by marker discovery, not a real filesystem permission. + 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); }); - // Act / Assert - await expect(selectProject()).rejects.toBe(denied); + 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()); }); }); From 2d07d9cbfe390136f40557069ee1c21be7fd3c43 Mon Sep 17 00:00:00 2001 From: Long Hao Date: Tue, 20 Oct 2026 02:29:22 +0000 Subject: [PATCH 8/8] test: consolidate specs by source module Keep one conventional colocated spec per source module while preserving every existing case and platform condition. Isolate mocked and native test setup so the consolidated layout retains real subprocess coverage without production changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli/commands/build/build.security.spec.ts | 72 -- src/cli/commands/build/build.spec.ts | 99 +- .../start/start.compatibility.spec.ts | 99 -- src/cli/commands/start/start.security.spec.ts | 131 --- src/cli/commands/start/start.spec.ts | 262 +++++ src/cli/index.security.spec.ts | 122 --- src/cli/index.spec.ts | 164 +++- src/core/dataApiBuilder/dab.spec.ts | 76 +- src/core/func-core-tools.spec.ts | 68 +- src/core/internal-tools.security.spec.ts | 100 -- .../{ssl.security.spec.ts => ssl.spec.ts} | 0 src/core/utils/arguments.security.spec.ts | 68 -- src/core/utils/command.security.spec.ts | 39 - src/core/utils/command.spec.ts | 145 +++ .../utils/executable.compatibility.spec.ts | 358 ------- src/core/utils/executable.security.spec.ts | 309 ------ src/core/utils/executable.spec.ts | 891 ++++++++++++++++++ ...ocess.security.spec.ts => process.spec.ts} | 0 src/core/utils/project-roots.security.spec.ts | 195 ---- 19 files changed, 1681 insertions(+), 1517 deletions(-) delete mode 100644 src/cli/commands/build/build.security.spec.ts delete mode 100644 src/cli/commands/start/start.compatibility.spec.ts delete mode 100644 src/cli/commands/start/start.security.spec.ts create mode 100644 src/cli/commands/start/start.spec.ts delete mode 100644 src/cli/index.security.spec.ts delete mode 100644 src/core/internal-tools.security.spec.ts rename src/core/{ssl.security.spec.ts => ssl.spec.ts} (100%) delete mode 100644 src/core/utils/arguments.security.spec.ts delete mode 100644 src/core/utils/command.security.spec.ts create mode 100644 src/core/utils/command.spec.ts delete mode 100644 src/core/utils/executable.compatibility.spec.ts delete mode 100644 src/core/utils/executable.security.spec.ts create mode 100644 src/core/utils/executable.spec.ts rename src/core/utils/{process.security.spec.ts => process.spec.ts} (100%) delete mode 100644 src/core/utils/project-roots.security.spec.ts diff --git a/src/cli/commands/build/build.security.spec.ts b/src/cli/commands/build/build.security.spec.ts deleted file mode 100644 index 037417626..000000000 --- a/src/cli/commands/build/build.security.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 { build } from "./build.js"; -import { DEFAULT_CONFIG } from "../../../config.js"; - -vi.mock("../../../core/utils/workflow-config.js", () => ({ readWorkflowFile: () => undefined })); -vi.mock("../../../core/utils/options.js", () => ({ isUserOrConfigOption: () => false })); -vi.mock("../../../core/utils/file.js", async (importOriginal) => ({ - ...(await importOriginal()), - // Isolate installation policy from the unrelated finder, which strips - // the leading slash from absolute paths in the current implementation. - findUpPackageJsonDir: async () => "/workspace/app", -})); -vi.mock("../../../core/utils/logger.js", () => ({ - logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn() }, -})); -vi.mock("../../../core/frameworks/detect.js", () => ({ detectProjectFolders: vi.fn(), generateConfiguration: vi.fn() })); - -describe("automatic dependency installs are internal, user build strings are deliberate", () => { - beforeEach(() => { - 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.spec.ts b/src/cli/commands/build/build.spec.ts index b8082c44b..9ec606e71 100644 --- a/src/cli/commands/build/build.spec.ts +++ b/src/cli/commands/build/build.spec.ts @@ -1,26 +1,29 @@ 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 path from "node:path"; import process from "node:process"; -import { build } from "./build.js"; 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); @@ -148,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/start/start.compatibility.spec.ts b/src/cli/commands/start/start.compatibility.spec.ts deleted file mode 100644 index dc14b2d18..000000000 --- a/src/cli/commands/start/start.compatibility.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { DEFAULT_CONFIG } from "../../../config.js"; -import { createShellExecutionSpec, InternalExecutionSpec } from "../../../core/utils/executable.js"; -import { superviseProcesses } from "../../../core/utils/process.js"; -import { start } from "./start.js"; - -vi.mock("../../../core/utils/process.js", () => ({ superviseProcesses: vi.fn() })); -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("../../../core/func-core-tools.js", () => ({ - getCoreToolsBinary: async () => process.execPath, - getNodeMajorVersion: () => 20, - detectTargetCoreToolsVersion: () => 4, - isCoreToolsVersionCompatible: () => true, -})); -vi.mock("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); -vi.mock("../../../core/utils/logger.js", () => ({ - logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn() }, -})); - -describe("Functions raw shell suffix compatibility (benign native argv recorder)", () => { - let sandbox: string; - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - 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 }); - }); - - it("preserves variable expansion and a shell operator 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"; - process.env.SWA_COMPAT_CERT = String.raw`\\fileserver\dev certs\local.pfx`; - const variable = (name: string) => (process.platform === "win32" ? `%${name}%` : `$${name}`); - const file = path.join(api, "certificate with spaces.pfx"); - const suffix = `--file "${file}" --cert "${variable("SWA_COMPAT_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", process.env.SWA_COMPAT_CERT, "--name", "expanded two words"], - cwd: fs.realpathSync(api), - suffixCompleted: true, - }; - // Positive control: the baseline raw suffix is executed by the actual trusted OS shell. - expect(execute(createShellExecutionSpec(`"${process.execPath}" start --cors "*" --port 7071 ${suffix}`, api))).toEqual(expected); - fs.writeFileSync(report, ""); - fs.unlinkSync(marker); - - 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.security.spec.ts b/src/cli/commands/start/start.security.spec.ts deleted file mode 100644 index ddfcfa8eb..000000000 --- a/src/cli/commands/start/start.security.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 { start } from "./start.js"; -import { DEFAULT_CONFIG } from "../../../config.js"; -import { getCertificate } from "../../../core/ssl.js"; - -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("../../../core/func-core-tools.js", () => ({ - getCoreToolsBinary: async () => "/trusted tools/func", - getNodeMajorVersion: () => 20, - detectTargetCoreToolsVersion: () => 4, - isCoreToolsVersionCompatible: () => true, -})); -vi.mock("../../../core/dataApiBuilder/index.js", () => ({ getDataApiBuilderBinaryPath: async () => "/trusted tools/dab" })); -vi.mock("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); -vi.mock("../../../core/utils/logger.js", () => ({ - logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: vi.fn() }, -})); - -describe("start internal launches versus intentional customer commands", () => { - 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, - }; - - beforeEach(() => { - 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); - }); - - afterEach(() => { - process.env = originalEnv; - }); - - 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("/bin/sh"); - expect(launch?.shell).toBe(false); - expect(launch?.args[0]).toBe("-c"); - expect(launch?.args[1]).toMatch(/^'\/trusted tools\/func' 'start' '--cors' '\*' '--port' '7071'/); - return launch!; - } - - 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); - }); - - 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[1]).toBe("'/trusted tools/func' 'start' '--cors' '*' '--port' '7071'"); - const api = directLaunches().find(({ options: launchOptions }) => launchOptions?.cwd === options.apiLocation)!; - expect(api.options?.env?.PATH).toBe(`${options.apiLocation}:/bin`); - }); - - it("preserves the raw funcArgs shell suffix without changing the selected Functions prefix", async () => { - // Arrange - const funcArgs = '--name "two words" "" "$VALUE" && echo done'; - // Act - await start({ ...options, funcArgs }); - // Assert - expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' ${funcArgs}`); - }); - - it("delegates unmatched suffix quotes to the bound shell instead of applying the unused argv parser", async () => { - // Arrange / Act - await start({ ...options, funcArgs: '"unclosed' }); - // Assert - expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' "unclosed`); - expect(launches().some(({ file }) => file === process.execPath)).toBe(true); - }); - - 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, - }), - ); - }); - - 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"); - }); -}); diff --git a/src/cli/commands/start/start.spec.ts b/src/cli/commands/start/start.spec.ts new file mode 100644 index 000000000..16010ccec --- /dev/null +++ b/src/cli/commands/start/start.spec.ts @@ -0,0 +1,262 @@ +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 { DEFAULT_CONFIG } from "../../../config.js"; +import { getCertificate } from "../../../core/ssl.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 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("../../../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("../../../core/ssl.js", () => ({ getCertificate: vi.fn() })); +vi.mock("../../../core/utils/logger.js", () => ({ + logger: { silly: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn(), log: 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, + }; + + 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); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + 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("/bin/sh"); + expect(launch?.shell).toBe(false); + expect(launch?.args[0]).toBe("-c"); + expect(launch?.args[1]).toMatch(/^'\/trusted tools\/func' 'start' '--cors' '\*' '--port' '7071'/); + return launch!; + } + + 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); + }); + + 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[1]).toBe("'/trusted tools/func' 'start' '--cors' '*' '--port' '7071'"); + const api = directLaunches().find(({ options: launchOptions }) => launchOptions?.cwd === options.apiLocation)!; + expect(api.options?.env?.PATH).toBe(`${options.apiLocation}:/bin`); + }); + + it("preserves the raw funcArgs shell suffix without changing the selected Functions prefix", async () => { + // Arrange + const funcArgs = '--name "two words" "" "$VALUE" && echo done'; + // Act + await start({ ...options, funcArgs }); + // Assert + expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' ${funcArgs}`); + }); + + it("delegates unmatched suffix quotes to the bound shell instead of applying the unused argv parser", async () => { + // Arrange / Act + await start({ ...options, funcArgs: '"unclosed' }); + // Assert + expect(functionsLaunch().args[1]).toBe(`'/trusted tools/func' 'start' '--cors' '*' '--port' '7071' "unclosed`); + expect(launches().some(({ file }) => file === process.execPath)).toBe(true); + }); + + 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, + }), + ); + }); + + 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 raw shell suffix 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 createShellExecutionSpec: typeof import("../../../core/utils/executable.js").createShellExecutionSpec; + 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")); + ({ createShellExecutionSpec } = 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("preserves variable expansion and a shell operator 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"; + process.env.SWA_COMPAT_CERT = String.raw`\\fileserver\dev certs\local.pfx`; + const variable = (name: string) => (process.platform === "win32" ? `%${name}%` : `$${name}`); + const file = path.join(api, "certificate with spaces.pfx"); + const suffix = `--file "${file}" --cert "${variable("SWA_COMPAT_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", process.env.SWA_COMPAT_CERT, "--name", "expanded two words"], + cwd: fs.realpathSync(api), + suffixCompleted: true, + }; + // Positive control: the baseline raw suffix is executed by the actual trusted OS shell. + expect(execute(createShellExecutionSpec(`"${process.execPath}" start --cors "*" --port 7071 ${suffix}`, api))).toEqual(expected); + fs.writeFileSync(report, ""); + fs.unlinkSync(marker); + + 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/index.security.spec.ts b/src/cli/index.security.spec.ts deleted file mode 100644 index 329f9f14a..000000000 --- a/src/cli/index.security.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; -import dotenv from "dotenv"; -import fs from "node:fs"; -import os from "node:os"; -import process from "node:process"; -import path from "node:path"; -import { swaMagic } from "./index.js"; -import { getCurrentSwaCliConfigFromFile } from "../core/utils/cli-config.js"; -import { promptOrUseDefault } from "../core/prompts.js"; - -// Keep the test at swaMagic's public boundary without importing login, -// credential stores, HTTP servers, or SSL initialization. -vi.mock("dotenv", () => ({ default: { config: vi.fn() } })); -vi.mock("./commands/deploy/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/init/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/login/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/start/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/build/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/db/init/register.js", () => ({ default: vi.fn() })); -vi.mock("./commands/docs.js", () => ({ registerDocs: vi.fn() })); -vi.mock("../core/utils/update-notifier.js", () => ({ notifyOnUpdate: vi.fn() })); -vi.mock("../core/func-core-tools.js", () => ({ getNodeMajorVersion: () => 20 })); -vi.mock("../core/utils/options.js", () => ({ configureOptions: vi.fn() })); -vi.mock("../core/utils/cli-config.js", () => ({ getCurrentSwaCliConfigFromFile: vi.fn(), swaCliConfigFilename: "swa-cli.config.json" })); -vi.mock("../core/prompts.js", () => ({ promptOrUseDefault: vi.fn() })); -vi.mock("../core/utils/logger.js", () => ({ logger: { log: vi.fn(), error: vi.fn() } })); - -describe("magic-flow same-installation reentry", () => { - beforeEach(() => { - 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.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/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/func-core-tools.spec.ts b/src/core/func-core-tools.spec.ts index 39b1b42f2..9c330dfb1 100644 --- a/src/core/func-core-tools.spec.ts +++ b/src/core/func-core-tools.spec.ts @@ -1,11 +1,12 @@ import "../../tests/_mocks/fetch.js"; import "../../tests/_mocks/fs.js"; import "../../tests/_mocks/posix.js"; -import { childProcess, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; +import { childProcess, directLaunches, mockInternalProcesses } from "../../tests/_mocks/internal-process.js"; 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"; @@ -47,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(); diff --git a/src/core/internal-tools.security.spec.ts b/src/core/internal-tools.security.spec.ts deleted file mode 100644 index c7ee186b9..000000000 --- a/src/core/internal-tools.security.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -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 { getCoreToolsBinary, resetCachedInstalledSystemCoreToolsVersion } from "./func-core-tools.js"; -import { installAndGetDataApiBuilder } from "./dataApiBuilder/dab.js"; -import { fetchWithProxy } from "./utils/fetch-proxy.js"; -import { downloadAndValidateBinary } from "./download-binary-helper.js"; -import { DATA_API_BUILDER_FOLDER, DEFAULT_DATA_API_BUILDER_BINARY } from "./constants.js"; - -vi.mock("./utils/fetch-proxy.js", () => ({ fetchWithProxy: vi.fn() })); -vi.mock("./download-binary-helper.js", () => ({ downloadAndValidateBinary: vi.fn() })); -vi.mock("./utils/logger.js", () => ({ - logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), silly: vi.fn(), info: vi.fn() }, -})); - -describe("internal executable identity (no fixture execution)", () => { - beforeEach(() => { - 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); - 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"); - }); - - 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/ssl.security.spec.ts b/src/core/ssl.spec.ts similarity index 100% rename from src/core/ssl.security.spec.ts rename to src/core/ssl.spec.ts diff --git a/src/core/utils/arguments.security.spec.ts b/src/core/utils/arguments.security.spec.ts deleted file mode 100644 index cb1c99427..000000000 --- a/src/core/utils/arguments.security.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; -import { execInternalCommand } from "./command.js"; -import { createToolShellExecutionSpec, prepareExecution } from "./executable.js"; - -describe("real internal argument boundary", () => { - let sandbox: string; - let report: string; - const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); - - beforeEach(() => { - 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 }); - }); - - 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.security.spec.ts b/src/core/utils/command.security.spec.ts deleted file mode 100644 index 4ee0b58ce..000000000 --- a/src/core/utils/command.security.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -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"; - -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" }) }), - ); - }); -}); 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/executable.compatibility.spec.ts b/src/core/utils/executable.compatibility.spec.ts deleted file mode 100644 index 24cb24ce8..000000000 --- a/src/core/utils/executable.compatibility.spec.ts +++ /dev/null @@ -1,358 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { execInternalCommand } from "./command.js"; -import { - createShellExecutionSpec, - InternalExecutionSpec, - prepareExecution, - probeExecutable, - resolveTrustedExecutable, - resolveTrustedPackageManager, -} from "./executable.js"; - -const recorder = fileURLToPath(new URL("../../../tests/fixtures/compatibility-recorder.cjs", import.meta.url)); -const windows = process.platform === "win32"; - -describe("trusted external wrapper compatibility (benign native fixtures)", () => { - let sandbox: string; - let project: string; - let external: string; - let report: string; - let output: string; - - beforeEach(() => { - 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 }); - }); - - 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.security.spec.ts b/src/core/utils/executable.security.spec.ts deleted file mode 100644 index b8e2abf75..000000000 --- a/src/core/utils/executable.security.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -import "../../../tests/_mocks/fs.js"; -import "../../../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, -} from "./executable.js"; - -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(); - }); -}); 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/process.security.spec.ts b/src/core/utils/process.spec.ts similarity index 100% rename from src/core/utils/process.security.spec.ts rename to src/core/utils/process.spec.ts diff --git a/src/core/utils/project-roots.security.spec.ts b/src/core/utils/project-roots.security.spec.ts deleted file mode 100644 index 8ba4a45cb..000000000 --- a/src/core/utils/project-roots.security.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -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 * as nodeFs from "node:fs"; -import { ExecutableResolutionOptions, resolveTrustedExecutable, resolveTrustedExecutablePath, resolveTrustedPackageManager } from "./executable.js"; - -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()); - }); -});