diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 64c9eedd31e..d055b57c2f1 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -827,6 +827,14 @@ Unix-only check. A failure aborts while the tray and proxy are still running. A then stopped before files are replaced; an installed service is rebuilt and started automatically, while a foreground installation prints `ocx start` as the next step. Dashboard update records redact profile/cache paths and UID/GID values before they are persisted. + +On Windows, Scoop's default `nodejs` and `nodejs-lts` `npm` installations can be used +from the user home directory when their `current` junction stays inside the Node app +directory; the default persistent `bin` directory is also supported. Running inside +that installation (including its resolved version directory or persistent `bin`), +project-local `npm`, `NO_JUNCTION` version-directory entries, and custom Scoop roots +under the home remain excluded. + If the install step fails, the previous version stays installed and its service is restarted; the terminal output names the next step, and [Update Failed on Windows](/troubleshooting/update-failed/) covers finishing the update and the folders a failed attempt can leave behind. diff --git a/src/update/npm-invocation.mjs b/src/update/npm-invocation.mjs index 0103167dd54..b2084d9afb7 100644 --- a/src/update/npm-invocation.mjs +++ b/src/update/npm-invocation.mjs @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { win32 } from "node:path"; const CMD_META = /([()%!^"`<>&|;, *?])/g; @@ -38,11 +38,41 @@ export function resolveNpmCommand( ) { if (platform !== "win32") return "npm"; const exists = deps.exists ?? existsSync; + const realpath = deps.realpath ?? realpathSync.native; const cwd = deps.cwd ?? process.cwd(); const trustedRoots = [env.APPDATA, env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"], env.USERPROFILE && win32.join(env.USERPROFILE, "scoop", "shims")] .filter(root => typeof root === "string" && win32.isAbsolute(root)); const trustedEntry = entry => trustedRoots.some(root => isInside(root, entry) && !isInside(root, cwd)); + // Scoop installs Node's npm in the app tree, not in scoop/shims. Admit only + // the two Node apps' current npm directories, never arbitrary Scoop apps or + // a launch directory inside the installation itself. + const scoopNodeRoots = typeof env.USERPROFILE === "string" && win32.isAbsolute(env.USERPROFILE) + ? ["nodejs", "nodejs-lts"].map(app => win32.join(env.USERPROFILE, "scoop", "apps", app)) + : []; + const trustedScoopNodeEntry = (entry, candidate) => { + const app = scoopNodeRoots.find(root => { + const current = win32.join(root, "current"); + return isSamePath(current, entry) || isSamePath(win32.join(current, "bin"), entry); + }); + if (!app) return null; + try { + const appPath = realpath(app); + const currentPath = realpath(win32.join(app, "current")); + const entryPath = realpath(entry); + const candidatePath = realpath(candidate); + const cwdPath = realpath(cwd); + const persistBin = win32.join(env.USERPROFILE, "scoop", "persist", win32.basename(app), "bin"); + const insideInstall = isInside(currentPath, entryPath); + const persistedBin = isSamePath(entry, win32.join(app, "current", "bin")) + && isSamePath(entryPath, persistBin); + return !isSamePath(appPath, currentPath) && isInside(appPath, currentPath) + && (insideInstall || persistedBin) && isInside(entryPath, candidatePath) + && !isInside(currentPath, cwdPath) && !isInside(entryPath, cwdPath); + } catch { + return false; + } + }; const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") .split(";") .filter(Boolean); @@ -54,10 +84,12 @@ export function resolveNpmCommand( for (const entry of pathEntries) { if (!win32.isAbsolute(entry)) continue; if (isSamePath(entry, cwd)) continue; - if (isInside(cwd, entry) && !trustedEntry(entry)) continue; for (const extension of extensions) { const candidate = win32.join(entry, `npm${extension.toLowerCase()}`); - if (exists(candidate)) return win32.resolve(candidate); + if (!exists(candidate)) continue; + const scoopTrust = trustedScoopNodeEntry(entry, candidate); + if (scoopTrust === false || (scoopTrust === null && isInside(cwd, entry) && !trustedEntry(entry))) continue; + return win32.resolve(candidate); } } return null; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 23c0562120d..f18422ff717 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -344,6 +344,16 @@ starts the proxy outside the updater's process tree, so that proxy has to take t held through the repair's health wait, the lease kept it from starting, and recovery fell through to a second, directly started proxy (#5760). The recovery decision is made again after the release. +On Windows, `src/update/npm-invocation.mjs` admits only the exact +`%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries +from outside that Node installation. It resolves the junction, PATH entry, npm candidate, +and cwd to physical paths; `current` must remain within its Scoop app directory and +the npm candidate within the admitted entry. `current\bin` may point to the default +`%USERPROFILE%\scoop\persist\nodejs{,-lts}\bin`; cwd inside that persistent bin +is excluded too. Unreadable paths fail closed. Other Scoop apps, version-directory +PATH entries (`NO_JUNCTION`), custom home-root Scoop installs, arbitrary descendants, +and cwd inside the resolved Node installation are not admitted. + The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, because npm's strict script policy plans the global tree before it creates the prefix layout diff --git a/tests/update/update-npm-invocation.test.ts b/tests/update/update-npm-invocation.test.ts index 1f271059bee..f1f5e8c9565 100644 --- a/tests/update/update-npm-invocation.test.ts +++ b/tests/update/update-npm-invocation.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { npmInvocation, resolveNpmCommand, @@ -59,13 +62,83 @@ describe("Windows npm update invocation", () => { test.each([ ["C:\\Users\\dev", "C:\\Users\\dev\\AppData\\Local\\Volta\\bin", "LOCALAPPDATA", "C:\\Users\\dev\\AppData\\Local"], ["C:\\", "C:\\Program Files\\nodejs", "ProgramFiles", "C:\\Program Files"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs-lts\\current", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs-lts\\current\\bin", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs\\current", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs\\current\\bin", "USERPROFILE", "C:\\Users\\dev"], ])("resolves trusted npm from broad cwd %s", (cwd, directory, envKey, root) => { const npm = `${directory}\\npm.cmd`; expect(resolveNpmCommand("win32", { PATH: directory, PATHEXT: ".CMD", [envKey]: root }, { - cwd, exists: path => path === npm, + cwd, exists: path => path === npm, realpath: path => path, })).toBe(npm); }); + test("does not trust a Scoop npm inside the launch tree or arbitrary Scoop apps", () => { + const home = "C:\\Users\\dev"; + const root = `${home}\\scoop\\apps\\nodejs-lts\\current`; + const npm = `${root}\\bin\\npm.cmd`; + const env = { PATH: `${root}\\bin`, USERPROFILE: home, PATHEXT: ".CMD" }; + expect(resolveNpmCommand("win32", env, { cwd: root, exists: path => path === npm })).toBeNull(); + expect(resolveNpmCommand("win32", { + ...env, PATH: `${home}\\scoop\\apps\\other\\current\\bin`, + }, { cwd: home, exists: () => true })).toBeNull(); + }); + + test.skipIf(process.platform !== "win32")("resolves real Scoop junctions but rejects resolved cwd and redirected targets", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-scoop-node-")); + try { + const app = join(home, "scoop", "apps", "nodejs-lts"); + const version = join(app, "24.1.0"); + const current = join(app, "current"); + const bin = join(current, "bin"); + mkdirSync(join(version, "bin"), { recursive: true }); + writeFileSync(join(version, "bin", "npm.cmd"), "@echo off\r\n"); + symlinkSync(version, current, "junction"); + const env = { PATH: bin, USERPROFILE: home, PATHEXT: ".CMD", SystemRoot: "C:\\Windows" }; + const npm = join(bin, "npm.cmd"); + + expect(resolveNpmCommand("win32", env, { cwd: home })).toBe(npm); + expect(resolveNpmCommand("win32", env, { cwd: version })).toBeNull(); + expect(npmInvocation(["--version"], "win32", env, { cwd: join(version, "bin") })).toBeNull(); + + rmSync(join(version, "bin"), { recursive: true }); + const persistBin = join(home, "scoop", "persist", "nodejs-lts", "bin"); + mkdirSync(persistBin, { recursive: true }); + writeFileSync(join(persistBin, "npm.cmd"), "@echo off\r\n"); + symlinkSync(persistBin, join(version, "bin"), "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBe(npm); + expect(resolveNpmCommand("win32", env, { cwd: persistBin })).toBeNull(); + + rmSync(join(version, "bin")); + const outsideBin = join(home, "other-bin"); + mkdirSync(outsideBin); + writeFileSync(join(outsideBin, "npm.cmd"), "@echo off\r\n"); + symlinkSync(outsideBin, join(version, "bin"), "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBeNull(); + + rmSync(current); + const outside = join(home, "other-app"); + mkdirSync(join(outside, "bin"), { recursive: true }); + writeFileSync(join(outside, "bin", "npm.cmd"), "@echo off\r\n"); + symlinkSync(outside, current, "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBeNull(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("does not admit NO_JUNCTION or a custom Scoop root under the home", () => { + const home = "C:\\Users\\dev"; + for (const entry of [ + `${home}\\scoop\\apps\\nodejs-lts\\24.1.0\\bin`, + `${home}\\custom-scoop\\apps\\nodejs-lts\\current\\bin`, + ]) { + expect(resolveNpmCommand("win32", { PATH: entry, USERPROFILE: home, PATHEXT: ".CMD" }, { + cwd: home, exists: () => true, + })).toBeNull(); + } + }); + test("ignores npm candidates in current-directory subtrees", () => { const projectNpm = `${cwd}\\node_modules\\.bin\\npm.cmd`; const env = {