diff --git a/extensions/browser/src/browser/chrome.executables.ts b/extensions/browser/src/browser/chrome.executables.ts index 9a45e92a0a9..d5ea5de6120 100644 --- a/extensions/browser/src/browser/chrome.executables.ts +++ b/extensions/browser/src/browser/chrome.executables.ts @@ -574,9 +574,8 @@ export function findGoogleChromeExecutableLinux(): BrowserExecutable | null { export function findChromeExecutableWindows(): BrowserExecutable | null { const localAppData = process.env.LOCALAPPDATA ?? ""; const programFiles = process.env.ProgramFiles ?? "C:\\Program Files"; - // Must use bracket notation: variable name contains parentheses + // Must use bracket notation: variable name contains parentheses. const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; - const joinWin = path.win32.join; const candidates: Array = []; diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index 62d8cdb8e0c..6acc816b737 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -254,4 +254,40 @@ describe("resolveSystemNodeInfo", () => { expect(warning).toContain("below the required Node 22.14+"); expect(warning).toContain(darwinNode); }); + + it("uses validated custom Program Files roots on Windows", async () => { + const customNode = "D:\\Programs\\nodejs\\node.exe"; + mockNodePathPresent(customNode); + + const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" }); + const result = await resolveSystemNodeInfo({ + env: { + ProgramFiles: "D:\\Programs", + "ProgramFiles(x86)": "E:\\Programs (x86)", + }, + platform: "win32", + execFile, + }); + + expect(result?.path).toBe(customNode); + }); + + it("prefers ProgramW6432 over ProgramFiles on Windows", async () => { + const preferredNode = "D:\\Programs\\nodejs\\node.exe"; + const x86Node = "E:\\Programs (x86)\\nodejs\\node.exe"; + mockNodePathPresent(preferredNode, x86Node); + + const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" }); + const result = await resolveSystemNodeInfo({ + env: { + ProgramFiles: "E:\\Programs (x86)", + "ProgramFiles(x86)": "E:\\Programs (x86)", + ProgramW6432: "D:\\Programs", + }, + platform: "win32", + execFile, + }); + + expect(result?.path).toBe(preferredNode); + }); }); diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index b5928037981..e55e2fe47af 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { isSupportedNodeVersion } from "../infra/runtime-guard.js"; import { resolveStableNodePath } from "../infra/stable-node-path.js"; +import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js"; const VERSION_MANAGER_MARKERS = [ "/.nvm/", @@ -47,12 +48,9 @@ function buildSystemNodeCandidates( } if (platform === "win32") { const pathModule = getPathModule(platform); - const programFiles = env.ProgramFiles ?? "C:\\Program Files"; - const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"; - return [ - pathModule.join(programFiles, "nodejs", "node.exe"), - pathModule.join(programFilesX86, "nodejs", "node.exe"), - ]; + return getWindowsProgramFilesRoots(env).map((root) => + pathModule.join(root, "nodejs", "node.exe"), + ); } return []; } diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 0e8d9dcc431..2a99b89d11c 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isGatewayArgv } from "../infra/gateway-process-argv.js"; import { findVerifiedGatewayListenerPidsOnPortSync } from "../infra/gateway-processes.js"; import { inspectPortUsage } from "../infra/ports.js"; +import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { killProcessTree } from "../process/kill-tree.js"; import { sleep } from "../utils.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; @@ -438,11 +439,7 @@ async function terminateGatewayProcessTree(pid: number, graceMs: number): Promis killProcessTree(pid, { graceMs }); return; } - const taskkillPath = path.join( - process.env.SystemRoot ?? "C:\\Windows", - "System32", - "taskkill.exe", - ); + const taskkillPath = path.join(getWindowsInstallRoots().systemRoot, "System32", "taskkill.exe"); spawnSync(taskkillPath, ["/T", "/PID", String(pid)], { stdio: "ignore", timeout: 5_000, diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index c91e84e7d5b..159e253b44a 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -123,8 +123,8 @@ describe("ensureOpenClawCliOnPath", () => { expect(process.env.PATH).toBe("/bin"); }); - it("prepends mise shims when available", () => { - const { tmp, appBinDir, appCli } = setupAppCliRoot("case-mise"); + it("appends mise shims after system dirs", () => { + const { tmp, appCli } = setupAppCliRoot("case-mise"); const miseDataDir = path.join(tmp, "mise"); const shimsDir = path.join(miseDataDir, "shims"); setDir(miseDataDir); @@ -140,10 +140,10 @@ describe("ensureOpenClawCliOnPath", () => { homeDir: tmp, platform: "darwin", }); - const appBinIndex = updated.indexOf(appBinDir); + const usrBinIndex = updated.indexOf("/usr/bin"); const shimsIndex = updated.indexOf(shimsDir); - expect(appBinIndex).toBeGreaterThanOrEqual(0); - expect(shimsIndex).toBeGreaterThan(appBinIndex); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(shimsIndex).toBeGreaterThan(usrBinIndex); }); it.each([ @@ -222,7 +222,85 @@ describe("ensureOpenClawCliOnPath", () => { expect(updated.indexOf(xdgBinHome)).toBeLessThan(updated.indexOf(localBin)); }); - it("prepends Linuxbrew dirs when present", () => { + it("places ~/.local/bin AFTER /usr/bin to prevent PATH hijack", () => { + const { tmp, appCli } = setupAppCliRoot("case-path-hijack"); + const localBin = path.join(tmp, ".local", "bin"); + setDir(path.join(tmp, ".local")); + setDir(localBin); + + process.env.PATH = "/usr/bin:/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + delete process.env.XDG_BIN_HOME; + + const updated = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "linux", + }); + const usrBinIndex = updated.indexOf("/usr/bin"); + const localBinIndex = updated.indexOf(localBin); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(localBinIndex).toBeGreaterThanOrEqual(0); + expect(localBinIndex).toBeGreaterThan(usrBinIndex); + }); + + it("places all user-writable home dirs after system dirs", () => { + const { tmp, appCli } = setupAppCliRoot("case-user-writable-after-system"); + const localBin = path.join(tmp, ".local", "bin"); + const pnpmBin = path.join(tmp, ".local", "share", "pnpm"); + const bunBin = path.join(tmp, ".bun", "bin"); + const yarnBin = path.join(tmp, ".yarn", "bin"); + setDir(path.join(tmp, ".local")); + setDir(localBin); + setDir(path.join(tmp, ".local", "share")); + setDir(pnpmBin); + setDir(path.join(tmp, ".bun")); + setDir(bunBin); + setDir(path.join(tmp, ".yarn")); + setDir(yarnBin); + + process.env.PATH = "/usr/bin:/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + delete process.env.XDG_BIN_HOME; + + const updated = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "linux", + }); + const usrBinIndex = updated.indexOf("/usr/bin"); + for (const userDir of [localBin, pnpmBin, bunBin, yarnBin]) { + const idx = updated.indexOf(userDir); + expect(idx, `${userDir} should come after /usr/bin`).toBeGreaterThan(usrBinIndex); + } + }); + + it("appends Homebrew dirs after immutable OS dirs", () => { + const { tmp, appCli } = setupAppCliRoot("case-homebrew-after-system"); + setDir("/opt/homebrew/bin"); + setDir("/usr/local/bin"); + + process.env.PATH = "/usr/bin:/bin"; + delete process.env.OPENCLAW_PATH_BOOTSTRAPPED; + delete process.env.HOMEBREW_PREFIX; + delete process.env.HOMEBREW_BREW_FILE; + delete process.env.XDG_BIN_HOME; + + const updated = bootstrapPath({ + execPath: appCli, + cwd: tmp, + homeDir: tmp, + platform: "darwin", + }); + const usrBinIndex = updated.indexOf("/usr/bin"); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(updated.indexOf("/opt/homebrew/bin")).toBeGreaterThan(usrBinIndex); + expect(updated.indexOf("/usr/local/bin")).toBeGreaterThan(usrBinIndex); + }); + + it("appends Linuxbrew dirs after system dirs", () => { const tmp = abs("/tmp/openclaw-path/case-linuxbrew"); const execDir = path.join(tmp, "exec"); setDir(tmp); @@ -247,7 +325,9 @@ describe("ensureOpenClawCliOnPath", () => { homeDir: tmp, platform: "linux", }); - expect(parts[0]).toBe(linuxbrewBin); - expect(parts[1]).toBe(linuxbrewSbin); + const usrBinIndex = parts.indexOf("/usr/bin"); + expect(usrBinIndex).toBeGreaterThanOrEqual(0); + expect(parts.indexOf(linuxbrewBin)).toBeGreaterThan(usrBinIndex); + expect(parts.indexOf(linuxbrewSbin)).toBeGreaterThan(usrBinIndex); }); }); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index f00201a9625..3efe0084c09 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -81,26 +81,30 @@ function candidateBinDirs(opts: EnsureOpenClawPathOpts): { prepend: string[]; ap } } + // Only immutable OS directories go in prepend so they take priority over + // user-writable locations, preventing PATH hijack of system binaries. + prepend.push("/usr/bin", "/bin"); + + // User-writable / package-manager directories are appended so they never + // shadow trusted OS binaries. + // This includes Brew/Homebrew dirs, which are useful for finding `openclaw` + // in launchd/minimal environments but must not be treated as trusted. + append.push(...resolveBrewPathDirs({ homeDir })); const miseDataDir = process.env.MISE_DATA_DIR ?? path.join(homeDir, ".local", "share", "mise"); const miseShims = path.join(miseDataDir, "shims"); if (isDirectory(miseShims)) { - prepend.push(miseShims); + append.push(miseShims); } - - prepend.push(...resolveBrewPathDirs({ homeDir })); - - // Common global install locations (macOS first). if (platform === "darwin") { - prepend.push(path.join(homeDir, "Library", "pnpm")); + append.push(path.join(homeDir, "Library", "pnpm")); } if (process.env.XDG_BIN_HOME) { - prepend.push(process.env.XDG_BIN_HOME); + append.push(process.env.XDG_BIN_HOME); } - prepend.push(path.join(homeDir, ".local", "bin")); - prepend.push(path.join(homeDir, ".local", "share", "pnpm")); - prepend.push(path.join(homeDir, ".bun", "bin")); - prepend.push(path.join(homeDir, ".yarn", "bin")); - prepend.push("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"); + append.push(path.join(homeDir, ".local", "bin")); + append.push(path.join(homeDir, ".local", "share", "pnpm")); + append.push(path.join(homeDir, ".bun", "bin")); + append.push(path.join(homeDir, ".yarn", "bin")); return { prepend: prepend.filter(isDirectory), append: append.filter(isDirectory) }; } diff --git a/src/infra/resolve-system-bin.test.ts b/src/infra/resolve-system-bin.test.ts new file mode 100644 index 00000000000..a94310baf0e --- /dev/null +++ b/src/infra/resolve-system-bin.test.ts @@ -0,0 +1,303 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { _getTrustedDirs, _resetResolveSystemBin, resolveSystemBin } from "./resolve-system-bin.js"; +import { + _resetWindowsInstallRootsForTests, + getWindowsInstallRoots, + getWindowsProgramFilesRoots, +} from "./windows-install-roots.js"; + +let executables: Set; + +beforeEach(() => { + executables = new Set(); + _resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); +}); + +afterEach(() => { + _resetResolveSystemBin(); + _resetWindowsInstallRootsForTests(); +}); + +describe("resolveSystemBin", () => { + it("returns null when binary is not in any trusted directory", () => { + expect(resolveSystemBin("nonexistent")).toBeNull(); + }); + + if (process.platform !== "win32") { + it("resolves a binary found in /usr/bin", () => { + executables.add("/usr/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg"); + }); + + it("does NOT resolve a binary found in /usr/local/bin with strict trust", () => { + executables.add("/usr/local/bin/openssl"); + expect(resolveSystemBin("openssl")).toBeNull(); + expect(resolveSystemBin("openssl", { trust: "strict" })).toBeNull(); + }); + + it("does NOT resolve a binary found in /opt/homebrew/bin with strict trust", () => { + executables.add("/opt/homebrew/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBeNull(); + expect(resolveSystemBin("ffmpeg", { trust: "strict" })).toBeNull(); + }); + + it("does NOT resolve a binary from a user-writable directory like ~/.local/bin", () => { + executables.add("/home/testuser/.local/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBeNull(); + }); + + it("prefers /usr/bin over /usr/local/bin (first match wins)", () => { + executables.add("/usr/bin/openssl"); + executables.add("/usr/local/bin/openssl"); + expect(resolveSystemBin("openssl")).toBe("/usr/bin/openssl"); + }); + + it("caches results across calls", () => { + executables.add("/usr/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg"); + + executables.delete("/usr/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBe("/usr/bin/ffmpeg"); + }); + + it("supports extraDirs for caller-specific paths", () => { + const customDir = "/custom/system/bin"; + executables.add(`${customDir}/mytool`); + expect(resolveSystemBin("mytool", { extraDirs: [customDir] })).toBe(`${customDir}/mytool`); + }); + + it("extraDirs results do not poison the cache for callers without extraDirs", () => { + const untrustedDir = "/home/user/.local/bin"; + executables.add(`${untrustedDir}/ffmpeg`); + + expect(resolveSystemBin("ffmpeg", { extraDirs: [untrustedDir] })).toBe( + `${untrustedDir}/ffmpeg`, + ); + expect(resolveSystemBin("ffmpeg")).toBeNull(); + }); + } + + if (process.platform === "darwin") { + it("resolves a binary in /opt/homebrew/bin with standard trust on macOS", () => { + executables.add("/opt/homebrew/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg"); + }); + + it("resolves a binary in /usr/local/bin with standard trust on macOS", () => { + executables.add("/usr/local/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg"); + }); + + it("prefers /usr/bin over /opt/homebrew/bin with standard trust", () => { + executables.add("/usr/bin/ffmpeg"); + executables.add("/opt/homebrew/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/bin/ffmpeg"); + }); + + it("standard trust results do not poison the strict cache", () => { + executables.add("/opt/homebrew/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/opt/homebrew/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg")).toBeNull(); + }); + + it("extraDirs composes with standard trust", () => { + const customDir = "/opt/custom/bin"; + executables.add(`${customDir}/mytool`); + expect(resolveSystemBin("mytool", { trust: "standard", extraDirs: [customDir] })).toBe( + `${customDir}/mytool`, + ); + }); + } + + if (process.platform === "linux") { + it("resolves a binary in /usr/local/bin with standard trust on Linux", () => { + executables.add("/usr/local/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/local/bin/ffmpeg"); + }); + + it("prefers /usr/bin over /usr/local/bin with standard trust on Linux", () => { + executables.add("/usr/bin/ffmpeg"); + executables.add("/usr/local/bin/ffmpeg"); + expect(resolveSystemBin("ffmpeg", { trust: "standard" })).toBe("/usr/bin/ffmpeg"); + }); + } +}); + +describe("trusted directory list", () => { + it("never includes user-writable home directories", () => { + const dirs = _getTrustedDirs(); + for (const dir of dirs) { + expect(dir, `${dir} should not be user-writable`).not.toMatch(/\.(local|bun|yarn)/); + expect(dir, `${dir} should not be a pnpm dir`).not.toContain("pnpm"); + } + }); + + if (process.platform !== "win32") { + it("includes base Unix system directories only", () => { + const dirs = _getTrustedDirs(); + expect(dirs).toContain("/usr/bin"); + expect(dirs).toContain("/bin"); + expect(dirs).toContain("/usr/sbin"); + expect(dirs).toContain("/sbin"); + expect(dirs).not.toContain("/usr/local/bin"); + }); + + it("ignores env-controlled NIX_PROFILES entries, including direct store paths", () => { + const saved = process.env.NIX_PROFILES; + try { + process.env.NIX_PROFILES = + "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1 /tmp/evil /home/user/.nix-profile /nix/var/nix/profiles/default"; + _resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); + const dirs = _getTrustedDirs(); + expect(dirs).not.toContain("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffmpeg-7.1/bin"); + expect(dirs).not.toContain("/tmp/evil/bin"); + expect(dirs).not.toContain("/home/user/.nix-profile/bin"); + expect(dirs).not.toContain("/nix/var/nix/profiles/default/bin"); + } finally { + if (saved === undefined) { + delete process.env.NIX_PROFILES; + } else { + process.env.NIX_PROFILES = saved; + } + _resetResolveSystemBin(); + } + }); + } + + if (process.platform === "darwin") { + it("does not include /opt/homebrew/bin in strict trust on macOS", () => { + expect(_getTrustedDirs("strict")).not.toContain("/opt/homebrew/bin"); + expect(_getTrustedDirs("strict")).not.toContain("/usr/local/bin"); + }); + + it("includes /opt/homebrew/bin and /usr/local/bin in standard trust on macOS", () => { + const dirs = _getTrustedDirs("standard"); + expect(dirs).toContain("/opt/homebrew/bin"); + expect(dirs).toContain("/usr/local/bin"); + }); + + it("places Homebrew dirs after system dirs in standard trust", () => { + const dirs = [..._getTrustedDirs("standard")]; + const usrBinIdx = dirs.indexOf("/usr/bin"); + const brewIdx = dirs.indexOf("/opt/homebrew/bin"); + const localIdx = dirs.indexOf("/usr/local/bin"); + expect(usrBinIdx).toBeGreaterThanOrEqual(0); + expect(brewIdx).toBeGreaterThan(usrBinIdx); + expect(localIdx).toBeGreaterThan(usrBinIdx); + }); + + it("standard trust is a superset of strict trust on macOS", () => { + const strict = _getTrustedDirs("strict"); + const standard = _getTrustedDirs("standard"); + for (const dir of strict) { + expect(standard, `standard trust should include strict dir ${dir}`).toContain(dir); + } + }); + } + + if (process.platform === "linux") { + it("includes Linux system-managed directories", () => { + const dirs = _getTrustedDirs(); + expect(dirs).toContain("/run/current-system/sw/bin"); + expect(dirs).toContain("/snap/bin"); + }); + + it("includes /usr/local/bin in standard trust on Linux", () => { + const dirs = _getTrustedDirs("standard"); + expect(dirs).toContain("/usr/local/bin"); + }); + + it("places /usr/local/bin after /usr/bin in standard trust on Linux", () => { + const dirs = [..._getTrustedDirs("standard")]; + const usrBinIdx = dirs.indexOf("/usr/bin"); + const usrLocalBinIdx = dirs.indexOf("/usr/local/bin"); + expect(usrBinIdx).toBeGreaterThanOrEqual(0); + expect(usrLocalBinIdx).toBeGreaterThan(usrBinIdx); + }); + } + + if (process.platform !== "darwin" && process.platform !== "linux") { + it("standard trust equals strict trust on platforms without expansion", () => { + const strict = _getTrustedDirs("strict"); + const standard = _getTrustedDirs("standard"); + expect(standard).toEqual(strict); + }); + } + + if (process.platform === "win32") { + it("includes Windows system directories", () => { + const dirs = _getTrustedDirs(); + expect(dirs).toContain(path.win32.join(getWindowsInstallRoots().systemRoot, "System32")); + }); + + it("includes Program Files OpenSSL and ffmpeg paths", () => { + const dirs = _getTrustedDirs(); + for (const programFilesRoot of getWindowsProgramFilesRoots()) { + expect(dirs).toContain(path.win32.join(programFilesRoot, "OpenSSL-Win64", "bin")); + expect(dirs).toContain(path.win32.join(programFilesRoot, "ffmpeg", "bin")); + } + }); + + it("uses validated Windows install roots from HKLM values", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: (key, valueName) => { + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && + valueName === "SystemRoot" + ) { + return "D:\\Windows"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramFilesDir" + ) { + return "D:\\Program Files"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramW6432Dir" + ) { + return "D:\\Program Files"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramFilesDir (x86)" + ) { + return "E:\\Program Files (x86)"; + } + return null; + }, + }); + + _resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); + const dirs = _getTrustedDirs(); + expect(dirs).toContain(path.win32.join("D:\\Windows", "System32")); + expect(dirs).toContain(path.win32.join("D:\\Program Files", "OpenSSL-Win64", "bin")); + expect(dirs).toContain(path.win32.join("E:\\Program Files (x86)", "OpenSSL", "bin")); + }); + + it("falls back safely when HKLM values are unavailable", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: () => null, + }); + + _resetResolveSystemBin((p: string) => executables.has(path.resolve(p))); + const dirs = _getTrustedDirs(); + const normalizedDirs = dirs.map((dir) => dir.toLowerCase()); + expect(normalizedDirs).toContain(path.win32.join("C:\\Windows", "System32").toLowerCase()); + expect(normalizedDirs).toContain( + path.win32.join("C:\\Program Files", "OpenSSL-Win64", "bin").toLowerCase(), + ); + expect(normalizedDirs).toContain( + path.win32.join("C:\\Program Files (x86)", "OpenSSL", "bin").toLowerCase(), + ); + }); + + it("does not include Unix paths on Windows", () => { + const dirs = _getTrustedDirs(); + expect(dirs).not.toContain("/usr/bin"); + expect(dirs).not.toContain("/bin"); + }); + } +}); diff --git a/src/infra/resolve-system-bin.ts b/src/infra/resolve-system-bin.ts new file mode 100644 index 00000000000..712528ab4b8 --- /dev/null +++ b/src/infra/resolve-system-bin.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import path from "node:path"; +import { getWindowsInstallRoots, getWindowsProgramFilesRoots } from "./windows-install-roots.js"; + +/** + * Trust level for system binary resolution. + * - "strict": Only fixed OS-managed directories. Use for security-critical + * binaries like openssl where a compromised binary has high impact. + * - "standard": Strict dirs plus common local-admin/package-manager + * directories appended after system dirs. Use for tool binaries like + * ffmpeg that are rarely available via the OS itself. + */ +export type SystemBinTrust = "strict" | "standard"; + +// Unix directories where OS-managed or system-installed binaries live. +// User-writable or package-manager-managed directories are excluded so that +// attacker-planted binaries cannot shadow legitimate system executables. +const UNIX_BASE_TRUSTED_DIRS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] as const; + +// Package-manager directories appended in "standard" trust on macOS. +// These come after strict dirs so OS binaries always take priority. +// Could be acceptable for tooling binaries like ffmpeg but NOT for +// security-critical ones like openssl — callers needing higher +// assurance should stick with "strict". +const DARWIN_STANDARD_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"] as const; +const LINUX_STANDARD_DIRS = ["/usr/local/bin"] as const; + +// Windows extensions to probe when searching for executables. +const WIN_PATHEXT = [".exe", ".cmd", ".bat", ".com"] as const; + +const resolvedCacheStrict = new Map(); +const resolvedCacheStandard = new Map(); + +function defaultIsExecutable(filePath: string): boolean { + try { + if (process.platform === "win32") { + fs.accessSync(filePath, fs.constants.R_OK); + } else { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +let isExecutableFn: (filePath: string) => boolean = defaultIsExecutable; + +/** + * Build the trusted-dir list for Windows. Only system-managed directories + * are included; user-profile paths like %LOCALAPPDATA% are excluded. + */ +function buildWindowsTrustedDirs(): readonly string[] { + const dirs: string[] = []; + const { systemRoot } = getWindowsInstallRoots(); + dirs.push(path.win32.join(systemRoot, "System32")); + dirs.push(path.win32.join(systemRoot, "SysWOW64")); + + for (const programFilesRoot of getWindowsProgramFilesRoots()) { + // Trust the machine's validated Program Files roots rather than assuming C:. + dirs.push(path.win32.join(programFilesRoot, "OpenSSL-Win64", "bin")); + dirs.push(path.win32.join(programFilesRoot, "OpenSSL", "bin")); + dirs.push(path.win32.join(programFilesRoot, "ffmpeg", "bin")); + } + + return dirs; +} + +/** + * Build the trusted-dir list for Unix (macOS, Linux, etc.), extending + * UNIX_BASE_TRUSTED_DIRS with platform/environment-specific paths. + * + * Strict: only fixed OS-managed directories. + * + * Standard: strict dirs plus platform package-manager directories appended + * after, so OS binaries always take priority. + */ +function buildUnixTrustedDirs(trust: SystemBinTrust): readonly string[] { + const dirs: string[] = [...UNIX_BASE_TRUSTED_DIRS]; + const platform = process.platform; + + if (platform === "linux") { + // Fixed NixOS system profile path. Never derive trust from NIX_PROFILES: + // env-controlled Nix store/profile entries can be attacker-selected. + // Callers that intentionally rely on non-default Nix paths must opt in via extraDirs. + dirs.push("/run/current-system/sw/bin"); + dirs.push("/snap/bin"); + } + + // "standard" trust widens the search for non-security-critical tools in + // common local-admin/package-manager directories, while keeping strict dirs + // first so OS binaries always take priority. + if (trust === "standard") { + if (platform === "darwin") { + dirs.push(...DARWIN_STANDARD_DIRS); + } else if (platform === "linux") { + dirs.push(...LINUX_STANDARD_DIRS); + } + } + + return dirs; +} + +let trustedDirsStrict: readonly string[] | null = null; +let trustedDirsStandard: readonly string[] | null = null; + +function getTrustedDirs(trust: SystemBinTrust): readonly string[] { + if (process.platform === "win32") { + // Windows does not currently widen "standard" beyond the registry-backed + // system roots; both trust levels intentionally share the same set today. + trustedDirsStrict ??= buildWindowsTrustedDirs(); + return trustedDirsStrict; + } + if (trust === "standard") { + trustedDirsStandard ??= buildUnixTrustedDirs("standard"); + return trustedDirsStandard; + } + trustedDirsStrict ??= buildUnixTrustedDirs("strict"); + return trustedDirsStrict; +} + +/** + * Resolve a binary name to an absolute path by searching only trusted system + * directories. Returns `null` when the binary is not found. Results are cached + * for the lifetime of the process. + * + * This MUST be used instead of bare binary names in `execFile`/`spawn` calls + * for internal infrastructure binaries (ffmpeg, ffprobe, openssl, etc.) to + * prevent PATH-hijack attacks via user-writable directories. + */ +export function resolveSystemBin( + name: string, + opts?: { trust?: SystemBinTrust; extraDirs?: readonly string[] }, +): string | null { + const trust = opts?.trust ?? "strict"; + const hasExtra = (opts?.extraDirs?.length ?? 0) > 0; + const cache = trust === "standard" ? resolvedCacheStandard : resolvedCacheStrict; + + if (!hasExtra) { + const cached = cache.get(name); + if (cached !== undefined) { + return cached; + } + } + + const dirs = [...getTrustedDirs(trust), ...(opts?.extraDirs ?? [])]; + const isWin = process.platform === "win32"; + const hasExt = isWin && path.win32.extname(name).length > 0; + + for (const dir of dirs) { + if (isWin && !hasExt) { + for (const ext of WIN_PATHEXT) { + const candidate = path.win32.join(dir, name + ext); + if (isExecutableFn(candidate)) { + if (!hasExtra) { + cache.set(name, candidate); + } + return candidate; + } + } + } else { + const candidate = path.join(dir, name); + if (isExecutableFn(candidate)) { + if (!hasExtra) { + cache.set(name, candidate); + } + return candidate; + } + } + } + + return null; +} + +/** Visible for tests: the computed trusted directories. */ +export function _getTrustedDirs(trust: SystemBinTrust = "strict"): readonly string[] { + return getTrustedDirs(trust); +} + +/** Reset cache and optionally override the executable-check function (for tests). */ +export function _resetResolveSystemBin(overrideIsExecutable?: (p: string) => boolean): void { + resolvedCacheStrict.clear(); + resolvedCacheStandard.clear(); + trustedDirsStrict = null; + trustedDirsStandard = null; + isExecutableFn = overrideIsExecutable ?? defaultIsExecutable; +} diff --git a/src/infra/tls/gateway.ts b/src/infra/tls/gateway.ts index 9912a243fb2..d4ff5c2116d 100644 --- a/src/infra/tls/gateway.ts +++ b/src/infra/tls/gateway.ts @@ -6,6 +6,7 @@ import tls from "node:tls"; import { promisify } from "node:util"; import type { GatewayTlsConfig } from "../../config/types.gateway.js"; import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js"; +import { resolveSystemBin } from "../resolve-system-bin.js"; import { normalizeFingerprint } from "./fingerprint.js"; const execFileAsync = promisify(execFile); @@ -41,7 +42,13 @@ async function generateSelfSignedCert(params: { if (keyDir !== certDir) { await ensureDir(keyDir); } - await execFileAsync("openssl", [ + const opensslBin = resolveSystemBin("openssl"); + if (!opensslBin) { + throw new Error( + "openssl not found in trusted system directories. Install it in an OS-managed location.", + ); + } + await execFileAsync(opensslBin, [ "req", "-x509", "-newkey", diff --git a/src/infra/windows-install-roots.test.ts b/src/infra/windows-install-roots.test.ts new file mode 100644 index 00000000000..55466bb0469 --- /dev/null +++ b/src/infra/windows-install-roots.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + _private, + _resetWindowsInstallRootsForTests, + getWindowsInstallRoots, + getWindowsProgramFilesRoots, + normalizeWindowsInstallRoot, +} from "./windows-install-roots.js"; + +afterEach(() => { + _resetWindowsInstallRootsForTests(); +}); + +describe("normalizeWindowsInstallRoot", () => { + it("normalizes validated local Windows roots", () => { + expect(normalizeWindowsInstallRoot(" D:/Apps/Program Files/ ")).toBe("D:\\Apps\\Program Files"); + }); + + it("rejects invalid or overly broad values", () => { + expect(normalizeWindowsInstallRoot("relative\\path")).toBeNull(); + expect(normalizeWindowsInstallRoot("\\\\server\\share\\Program Files")).toBeNull(); + expect(normalizeWindowsInstallRoot("D:\\")).toBeNull(); + expect(normalizeWindowsInstallRoot("D:\\Apps;E:\\Other")).toBeNull(); + }); +}); + +describe("getWindowsInstallRoots", () => { + it("prefers HKLM registry roots over process environment values", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: (key, valueName) => { + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && + valueName === "SystemRoot" + ) { + return "D:\\Windows"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramFilesDir" + ) { + return "E:\\Programs"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramFilesDir (x86)" + ) { + return "F:\\Programs (x86)"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramW6432Dir" + ) { + return "E:\\Programs"; + } + return null; + }, + }); + + const originalEnv = process.env; + let roots; + try { + process.env = { + ...originalEnv, + SystemRoot: "C:\\PoisonedWindows", + ProgramFiles: "C:\\Poisoned Programs", + "ProgramFiles(x86)": "C:\\Poisoned Programs (x86)", + ProgramW6432: "C:\\Poisoned Programs", + }; + roots = getWindowsInstallRoots(); + } finally { + process.env = originalEnv; + } + + expect(roots).toEqual({ + systemRoot: "D:\\Windows", + programFiles: "E:\\Programs", + programFilesX86: "F:\\Programs (x86)", + programW6432: "E:\\Programs", + }); + }); + + it("uses explicit env roots without consulting HKLM", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: (key, valueName) => { + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" && + valueName === "SystemRoot" + ) { + return "D:\\Windows"; + } + if ( + key === "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion" && + valueName === "ProgramFilesDir" + ) { + return "E:\\Programs"; + } + return null; + }, + }); + + const roots = getWindowsInstallRoots({ + SystemRoot: "G:\\Windows", + ProgramFiles: "H:\\Programs", + "ProgramFiles(x86)": "I:\\Programs (x86)", + ProgramW6432: "H:\\Programs", + }); + + expect(roots).toEqual({ + systemRoot: "G:\\Windows", + programFiles: "H:\\Programs", + programFilesX86: "I:\\Programs (x86)", + programW6432: "H:\\Programs", + }); + }); + + it("falls back to validated env roots when registry lookup is unavailable", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: () => null, + }); + + const roots = getWindowsInstallRoots({ + systemroot: "D:\\Windows\\", + programfiles: "E:\\Programs", + "PROGRAMFILES(X86)": "F:\\Programs (x86)\\", + programw6432: "E:\\Programs", + }); + + expect(roots).toEqual({ + systemRoot: "D:\\Windows", + programFiles: "E:\\Programs", + programFilesX86: "F:\\Programs (x86)", + programW6432: "E:\\Programs", + }); + }); + + it("falls back to defaults when registry and env roots are invalid", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: () => "relative\\path", + }); + + const roots = getWindowsInstallRoots({ + SystemRoot: "relative\\Windows", + ProgramFiles: "\\\\server\\share\\Program Files", + "ProgramFiles(x86)": "D:\\", + ProgramW6432: "C:\\Programs;D:\\Other", + }); + + expect(roots).toEqual({ + systemRoot: "C:\\Windows", + programFiles: "C:\\Program Files", + programFilesX86: "C:\\Program Files (x86)", + programW6432: null, + }); + }); +}); + +describe("getWindowsProgramFilesRoots", () => { + it("prefers ProgramW6432 and dedupes roots case-insensitively", () => { + _resetWindowsInstallRootsForTests({ + queryRegistryValue: () => null, + }); + + expect( + getWindowsProgramFilesRoots({ + ProgramW6432: "D:\\Programs", + ProgramFiles: "d:\\Programs\\", + "ProgramFiles(x86)": "E:\\Programs (x86)", + }), + ).toEqual(["D:\\Programs", "E:\\Programs (x86)"]); + }); +}); + +describe("locateWindowsRegExe", () => { + it("prefers SystemRoot and WINDIR candidates over arbitrary drive scans", () => { + expect( + _private.getWindowsRegExeCandidates({ + SystemRoot: "D:\\Windows", + WINDIR: "E:\\Windows", + }), + ).toEqual([ + "D:\\Windows\\System32\\reg.exe", + "E:\\Windows\\System32\\reg.exe", + "C:\\Windows\\System32\\reg.exe", + ]); + }); + + it("dedupes equivalent roots case-insensitively", () => { + expect( + _private.getWindowsRegExeCandidates({ + SystemRoot: "D:\\Windows\\", + windir: "d:\\windows", + }), + ).toEqual(["D:\\Windows\\System32\\reg.exe", "C:\\Windows\\System32\\reg.exe"]); + }); +}); diff --git a/src/infra/windows-install-roots.ts b/src/infra/windows-install-roots.ts new file mode 100644 index 00000000000..888f2bd250a --- /dev/null +++ b/src/infra/windows-install-roots.ts @@ -0,0 +1,263 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_SYSTEM_ROOT = "C:\\Windows"; +const DEFAULT_PROGRAM_FILES = "C:\\Program Files"; +const DEFAULT_PROGRAM_FILES_X86 = "C:\\Program Files (x86)"; +const WINDOWS_NT_CURRENT_VERSION_KEY = "HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; +const WINDOWS_CURRENT_VERSION_KEY = "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion"; +const REG_QUERY_TIMEOUT_MS = 5_000; + +type QueryRegistryValue = (key: string, valueName: string) => string | null; +type IsReadableFile = (filePath: string) => boolean; + +type WindowsInstallRootsTestOverrides = { + queryRegistryValue?: QueryRegistryValue; + isReadableFile?: IsReadableFile; +}; + +export type WindowsInstallRoots = { + systemRoot: string; + programFiles: string; + programFilesX86: string; + programW6432: string | null; +}; + +let queryRegistryValueFn: QueryRegistryValue = defaultQueryRegistryValue; +let isReadableFileFn: IsReadableFile = defaultIsReadableFile; +let cachedProcessInstallRoots: WindowsInstallRoots | null = null; + +function defaultIsReadableFile(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function trimTrailingSeparators(value: string): string { + const parsed = path.win32.parse(value); + let trimmed = value; + while (trimmed.length > parsed.root.length && /[\\/]/.test(trimmed.at(-1) ?? "")) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +/** + * Windows install roots should be local absolute directories, not drive-relative + * paths, UNC shares, or PATH-like lists that could widen trust unexpectedly. + */ +export function normalizeWindowsInstallRoot(raw: string | undefined): string | null { + if (typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + if ( + !trimmed || + trimmed.includes("\0") || + trimmed.includes("\r") || + trimmed.includes("\n") || + trimmed.includes(";") + ) { + return null; + } + const normalized = trimTrailingSeparators(path.win32.normalize(trimmed)); + if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) { + return null; + } + const parsed = path.win32.parse(normalized); + if (!/^[A-Za-z]:\\$/.test(parsed.root)) { + return null; + } + if (normalized.length <= parsed.root.length) { + return null; + } + return normalized; +} + +function getEnvValueCaseInsensitive( + env: Record, + expectedKey: string, +): string | undefined { + const direct = env[expectedKey]; + if (direct !== undefined) { + return direct; + } + const upper = expectedKey.toUpperCase(); + const actualKey = Object.keys(env).find((key) => key.toUpperCase() === upper); + return actualKey ? env[actualKey] : undefined; +} + +function getWindowsRegExeCandidates(env: Record): readonly string[] { + const seen = new Set(); + const candidates: string[] = []; + for (const root of [ + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")), + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")), + DEFAULT_SYSTEM_ROOT, + ]) { + if (!root) { + continue; + } + const key = root.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + candidates.push(path.win32.join(root, "System32", "reg.exe")); + } + return candidates; +} + +function locateWindowsRegExe(env: Record = process.env): string | null { + for (const candidate of getWindowsRegExeCandidates(env)) { + if (isReadableFileFn(candidate)) { + return candidate; + } + } + return null; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parseRegQueryValue(stdout: string, valueName: string): string | null { + const pattern = new RegExp(`^\\s*${escapeRegex(valueName)}\\s+REG_[A-Z0-9_]+\\s+(.+)$`, "im"); + const match = stdout.match(pattern); + return match?.[1]?.trim() || null; +} + +function runRegQuery( + regExe: string, + key: string, + valueName: string, + use64BitView: boolean, +): string { + const args = ["query", key, "/v", valueName]; + if (use64BitView) { + args.push("/reg:64"); + } + return execFileSync(regExe, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: REG_QUERY_TIMEOUT_MS, + windowsHide: true, + }); +} + +function defaultQueryRegistryValue(key: string, valueName: string): string | null { + const regExe = locateWindowsRegExe(process.env); + if (!regExe) { + return null; + } + + for (const use64BitView of [true, false]) { + try { + const stdout = runRegQuery(regExe, key, valueName, use64BitView); + const parsed = parseRegQueryValue(stdout, valueName); + if (parsed) { + return parsed; + } + } catch { + // Keep trying alternate registry views or fallbacks below. + } + } + return null; +} + +function getRegistryInstallRoots(): Partial { + return { + systemRoot: + normalizeWindowsInstallRoot( + queryRegistryValueFn(WINDOWS_NT_CURRENT_VERSION_KEY, "SystemRoot") ?? undefined, + ) ?? undefined, + programFiles: + normalizeWindowsInstallRoot( + queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramFilesDir") ?? undefined, + ) ?? undefined, + programFilesX86: + normalizeWindowsInstallRoot( + queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramFilesDir (x86)") ?? undefined, + ) ?? undefined, + programW6432: + normalizeWindowsInstallRoot( + queryRegistryValueFn(WINDOWS_CURRENT_VERSION_KEY, "ProgramW6432Dir") ?? undefined, + ) ?? undefined, + }; +} + +function buildWindowsInstallRoots( + env: Record, + useRegistryRoots: boolean, +): WindowsInstallRoots { + const registryRoots = useRegistryRoots ? getRegistryInstallRoots() : {}; + const envProgramW6432 = normalizeWindowsInstallRoot( + getEnvValueCaseInsensitive(env, "ProgramW6432"), + ); + const programW6432 = registryRoots.programW6432 ?? envProgramW6432 ?? null; + + return { + systemRoot: + registryRoots.systemRoot ?? + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ?? + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ?? + DEFAULT_SYSTEM_ROOT, + programFiles: + registryRoots.programFiles ?? + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "ProgramFiles")) ?? + programW6432 ?? + DEFAULT_PROGRAM_FILES, + programFilesX86: + registryRoots.programFilesX86 ?? + normalizeWindowsInstallRoot(getEnvValueCaseInsensitive(env, "ProgramFiles(x86)")) ?? + DEFAULT_PROGRAM_FILES_X86, + programW6432, + }; +} + +export function getWindowsInstallRoots( + env: Record = process.env, +): WindowsInstallRoots { + if (env === process.env) { + cachedProcessInstallRoots ??= buildWindowsInstallRoots(env, true); + return cachedProcessInstallRoots; + } + return buildWindowsInstallRoots(env, false); +} + +export function getWindowsProgramFilesRoots( + env: Record = process.env, +): readonly string[] { + const roots = getWindowsInstallRoots(env); + const seen = new Set(); + const result: string[] = []; + for (const value of [roots.programW6432, roots.programFiles, roots.programFilesX86]) { + if (!value) { + continue; + } + const key = value.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(value); + } + return result; +} + +export function _resetWindowsInstallRootsForTests( + overrides: WindowsInstallRootsTestOverrides = {}, +): void { + queryRegistryValueFn = overrides.queryRegistryValue ?? defaultQueryRegistryValue; + isReadableFileFn = overrides.isReadableFile ?? defaultIsReadableFile; + cachedProcessInstallRoots = null; +} + +export const _private = { + getWindowsRegExeCandidates, + locateWindowsRegExe, +}; diff --git a/src/media/ffmpeg-exec.ts b/src/media/ffmpeg-exec.ts index 1710a9dfbf5..b3bad702432 100644 --- a/src/media/ffmpeg-exec.ts +++ b/src/media/ffmpeg-exec.ts @@ -1,5 +1,6 @@ import { execFile, type ExecFileOptions } from "node:child_process"; import { promisify } from "node:util"; +import { resolveSystemBin } from "../infra/resolve-system-bin.js"; import { MEDIA_FFMPEG_MAX_BUFFER_BYTES, MEDIA_FFMPEG_TIMEOUT_MS, @@ -23,9 +24,24 @@ function resolveExecOptions( }; } +function requireSystemBin(name: string): string { + const resolved = resolveSystemBin(name, { trust: "standard" }); + if (!resolved) { + const hint = + process.platform === "darwin" + ? "e.g. brew install ffmpeg" + : "e.g. apt install ffmpeg / dnf install ffmpeg"; + throw new Error( + `${name} not found in trusted system directories. ` + + `Install it via your system package manager (${hint}).`, + ); + } + return resolved; +} + export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise { const { stdout } = await execFileAsync( - "ffprobe", + requireSystemBin("ffprobe"), args, resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options), ); @@ -34,7 +50,7 @@ export async function runFfprobe(args: string[], options?: MediaExecOptions): Pr export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise { const { stdout } = await execFileAsync( - "ffmpeg", + requireSystemBin("ffmpeg"), args, resolveExecOptions(MEDIA_FFMPEG_TIMEOUT_MS, options), );