diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs index acad8798c87..38458ccc592 100644 --- a/desktop/src-tauri/src/cli_command_posix.rs +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -94,6 +94,51 @@ pub fn render_shim(cli: &Path, owner: &str) -> Result { pub fn render_helper(bin: &Path) -> Result { Ok(HELPER.replace("@BIN@", "e(bin)?)) } +const TEMPORARY_ROOTS: [&str; 6] = [ + "/tmp", + "/private/tmp", + "/var/tmp", + "/private/var/tmp", + "/var/folders", + "/private/var/folders", +]; +/// A caller-selected TMPDIR only proves transience when it resolves inside a recognized +/// temporary root or is itself a shared sticky directory. An arbitrary override such as +/// TMPDIR=$HOME can name an ordinary ancestor of a permanent install. +fn environment_temp_root(temp_dir: &Path) -> Option { + let real = fs::canonicalize(temp_dir).ok()?; + if TEMPORARY_ROOTS + .iter() + .map(Path::new) + .any(|root| real.starts_with(root)) + { + return Some(real); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if fs::metadata(&real) + .map(|m| m.permissions().mode() & 0o1777 == 0o1777) + .unwrap_or(false) + { + return Some(real); + } + } + None +} +fn is_temporary_bundle_path(path: &Path, temp_dir: &Path) -> bool { + let env_root = environment_temp_root(temp_dir); + TEMPORARY_ROOTS + .iter() + .map(Path::new) + .chain(env_root.as_deref()) + .any(|root| path.starts_with(root)) + || path.starts_with("/Volumes") +} +fn has_app_translocation_marker(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str().to_str() == Some("AppTranslocation")) +} pub fn block(shell: &str, root: &Path) -> Result { let body = if shell == "fish" { format!( @@ -148,10 +193,9 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { if debug { return Err("development-launch".into()); } - let raw = text(exe)?; - if raw.starts_with("/Volumes/") - || raw.starts_with("/private/var/folders/") && raw.contains("/AppTranslocation/") - || raw.contains("/tmp/.mount_") + text(exe)?; + if has_app_translocation_marker(exe) + || is_temporary_bundle_path(exe, &std::env::temp_dir()) || std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { @@ -159,7 +203,7 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { } let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; let s = text(&real)?; - if s.starts_with("/Volumes/") || s.contains("/AppTranslocation/") || s.contains("/tmp/.mount_") + if has_app_translocation_marker(&real) || is_temporary_bundle_path(&real, &std::env::temp_dir()) { return Err("temporary-bundle".into()); } @@ -206,6 +250,82 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { version: version.into(), }) } + +#[cfg(test)] +mod temporary_bundle_path_tests { + use super::{has_app_translocation_marker, is_temporary_bundle_path}; + use std::path::Path; + + #[test] + fn temporary_bundle_roots_are_component_aware() { + let temp_dir = Path::new("/var/folders/test/app"); + for path in [ + "/tmp/App.app/Contents/MacOS/app", + "/private/tmp/App.app/Contents/MacOS/app", + "/var/tmp/App.app/Contents/MacOS/app", + "/private/var/tmp/App.app/Contents/MacOS/app", + "/var/folders/xx/App.app/Contents/MacOS/app", + "/private/var/folders/xx/App.app/Contents/MacOS/app", + "/Volumes/Installer/App.app/Contents/MacOS/app", + ] { + assert!( + is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); + } + for path in [ + "/Applications/OpenCodex.app/Contents/MacOS/app", + "/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app", + "/tmp-old/OpenCodex.app/Contents/MacOS/app", + "/private/var/folder-old/OpenCodex.app/Contents/MacOS/app", + ] { + assert!( + !is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); + } + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + assert!(has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation/OpenCodex.app/Contents/MacOS/app" + ))); + assert!(!has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation-old/OpenCodex.app/Contents/MacOS/app" + ))); + } + + #[test] + fn environment_temp_dir_must_prove_transience() { + // An ordinary ancestor named by a custom TMPDIR must not misclassify a stable + // install; "/" resolves but is not a shared sticky directory, and a missing + // path cannot be verified as temporary at all. + for temp_dir in ["/", "/definitely/absent/ocx-tmpdir"] { + let temp_dir = Path::new(temp_dir); + assert!( + !is_temporary_bundle_path( + Path::new("/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + assert!( + !is_temporary_bundle_path( + Path::new("/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + } + // The recognized temporary roots still prove transience on their own. + let temp_dir = Path::new("/var/folders/test/app"); + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + } +} // Return exact block range, including its line terminator. Reject malformed or changed markers. fn range(bytes: &[u8], expected: &str) -> Result>> { let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; diff --git a/src/lib/bun-path-runtime.d.mts b/src/lib/bun-path-runtime.d.mts index 88d3aa94d75..bb0bdd31108 100644 --- a/src/lib/bun-path-runtime.d.mts +++ b/src/lib/bun-path-runtime.d.mts @@ -5,7 +5,16 @@ export interface DesktopCliIo { export interface PathBunIo extends DesktopCliIo { now?: () => number; realpath?: (path: string) => string; - stat?: (path: string) => { isFile(): boolean; mode: number }; + stat?: (path: string) => { isFile(): boolean; mode: number; uid?: number }; + lstat?: (path: string) => { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid?: number; + }; + readlink?: (path: string) => string; + geteuid?: () => number | undefined; isRealBunBinary?: (path: string) => boolean; spawnSync?: (path: string, args: string[], options: { env: NodeJS.ProcessEnv; diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 729e86901cd..20bbf7cd140 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { accessSync, constants, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { posix, win32 } from "node:path"; import { performance } from "node:perf_hooks"; @@ -7,6 +7,7 @@ import { isRealBunBinary } from "./bun-binary-validator.mjs"; const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const MAX_VERSION_BYTES = 4096; +const MAX_SYMLINK_EXPANSIONS = 40; const IDENTITY_PROBE = "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"; function stableVersion(value) { @@ -17,6 +18,62 @@ function stableVersion(value) { return numbers.every(Number.isSafeInteger) ? numbers : null; } +function trustedPosixCandidate(candidate, canonical, io) { + const lstat = io.lstat ?? lstatSync; + const readlink = io.readlink ?? readlinkSync; + const uid = io.geteuid?.() ?? process.geteuid?.(); + if (!Number.isSafeInteger(uid) || uid < 0) return false; + + const walk = path => { + let pending = path.slice(1).split("/"); + let current = "/"; + let expansions = 0; + while (pending.length) { + const [component, ...remaining] = pending; + pending = remaining; + if (!component || component === ".") continue; + if (component === "..") { + current = posix.dirname(current); + continue; + } + const entry = posix.join(current, component); + const info = lstat(entry); + if (!Number.isSafeInteger(info.uid) || (info.uid !== 0 && info.uid !== uid)) return null; + if (info.isSymbolicLink()) { + if (++expansions > MAX_SYMLINK_EXPANSIONS) return null; + const target = readlink(entry); + pending = target.startsWith("/") + ? [...target.slice(1).split("/"), ...pending] + : [ + ...(current === "/" ? [] : current.slice(1).split("/")), + ...target.split("/"), + ...pending, + ]; + current = "/"; + continue; + } + if (remaining.length || pending.length) { + // No writable-ancestor exemption: a shared sticky directory passes its own + // ownership check but lets another user replace a child entry between the + // ancestry walk and the exec probe, substituting an unvalidated binary. + if (!info.isDirectory() || (info.mode & 0o022) !== 0) return null; + } else if (!info.isFile() || (info.mode & 0o022) !== 0) { + return null; + } + current = entry; + } + return current; + }; + + const resolved = walk(candidate); + if (!resolved || (io.realpath ?? realpathSync.native)(resolved) !== canonical) return false; + const parent = lstat(posix.dirname(canonical)); + return parent.isDirectory() + && Number.isSafeInteger(parent.uid) + && (parent.uid === 0 || parent.uid === uid) + && (parent.mode & 0o022) === 0; +} + /** Discover the first absolute PATH Bun passing version-policy and identity checks. */ export function findPathBun({ env = process.env, platform = process.platform, pinnedVersion, @@ -54,14 +111,16 @@ export function findPathBun({ if (now() >= end) break; if (!directory || !absolute(directory)) continue; try { - const path = realpath(paths.join(directory, platform === "win32" ? "bun.exe" : "bun")); + const candidatePath = paths.join(directory, platform === "win32" ? "bun.exe" : "bun"); + const path = realpath(candidatePath); if (!absolute(path)) continue; + if (platform !== "win32" && !trustedPosixCandidate(candidatePath, path, io)) continue; const identity = platform === "win32" ? path.toLowerCase() : path; if (seen.has(identity)) continue; seen.add(identity); const file = stat(path); if (!file.isFile()) continue; - if (platform !== "win32" && ((file.mode & 0o022) || (stat(paths.dirname(path)).mode & 0o022))) continue; + if (platform !== "win32" && (file.mode & 0o022)) continue; access(path, constants.X_OK); if (!validate(path)) continue; const version = probe(path, ["--version"]); diff --git a/structure/desktop-terminal-command.md b/structure/desktop-terminal-command.md index 7a1b6c1ad68..2003a27bd53 100644 --- a/structure/desktop-terminal-command.md +++ b/structure/desktop-terminal-command.md @@ -27,6 +27,10 @@ and final managed zsh/bash/fish blocks. Reconcile separates desired rc targets f ownership; remove walks the recorded list and preserves modified blocks and unsafe files. An rc file with an ACL is never rewritten (`rc-acl-present`). The shim captures exported Anthropic slot names with an argv-bound random proof before dotenv; failure keeps stripping. +Persistent bundle registration rejects raw and canonical locations under system and OS temporary +directories. A caller-selected TMPDIR counts only when it resolves inside a recognized temporary +root or a shared sticky directory, so ordinary `/Applications` and user Applications bundles +remain eligible even beneath a custom temporary-directory override. `desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only the owned entry and broadcasts Environment; a top-level `notifyPending` debt survives crashes and diff --git a/structure/runtime.md b/structure/runtime.md index d49f43597b9..93b4b058e10 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -120,7 +120,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 533178a7069..7b74ec4cff7 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; import { findPathBun } from "../../src/lib/bun-path-runtime.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -25,8 +25,26 @@ afterEach(() => { }); afterAll(() => { removeTreeWithRetry(tmp); + for (const root of trustedPathRoots) removeTreeWithRetry(root); }); +// findPathBun rejects a PATH candidate beneath any group/other-writable ancestor, +// which includes the shared temp root on POSIX ("/tmp" is 1777). A fixture that is +// supposed to exercise a *trusted* PATH dir therefore cannot live under tmpdir(). +const trustedPathRoots: string[] = []; +function trustedPathRoot(prefix: string): string { + if (process.platform === "win32") { + const root = mkdtempSync(join(tmpdir(), prefix)); + trustedPathRoots.push(root); + return root; + } + const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); + mkdirSync(base, { recursive: true, mode: 0o700 }); + const root = mkdtempSync(join(base, prefix)); + trustedPathRoots.push(root); + return root; +} + describe("isRealBunBinary (size gate vs placeholder stub)", () => { it("rejects the ~450-byte ASCII placeholder stub", () => { const stub = join(tmp, "bun-stub.exe"); @@ -55,7 +73,10 @@ describe("isRealBunBinary (size gate vs placeholder stub)", () => { describe("bundledBunPath / durableBunPath", () => { it("PATH-selected process provenance survives durable selection and project dotenv", () => { const root = join(tmp, "path-dotenv"); - const pathDir = join(root, "path"); + mkdirSync(root, { recursive: true }); + // The PATH fixture needs trusted ancestors (see trustedPathRoot); everything + // else in this case (HOME, dotenv, consumer script) stays under tmp. + const pathDir = join(trustedPathRoot("path-dotenv-"), "path"); mkdirSync(pathDir, { recursive: true }); const bun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); copyFileSync(process.execPath, bun); diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 330f9dd04c3..ba9d14c21ce 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { bundledBunPath } from "../../src/lib/bun-runtime"; import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; @@ -395,6 +395,16 @@ describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { }, EFFECTIVE_RUNTIME_TEST_TIMEOUT_MS); }); +function pathFixtureRoot(prefix: string): string { + if (process.platform === "win32") return mkdtempSync(join(tmpdir(), prefix)); + // A trusted-executable fixture cannot live beneath a shared temp ancestor whose + // owner is neither root nor this user (common in user-namespace sandboxes). + // Keep the launcher package isolated from this checkout's installed Bun. + const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); + mkdirSync(base, { recursive: true, mode: 0o700 }); + return mkdtempSync(join(base, prefix)); +} + function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n") { const calls: Array<{ path: string; args: string[]; timeout: number; shell: boolean; maxBuffer: number }> = []; let clock = 0; @@ -402,6 +412,15 @@ function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n now: () => clock, realpath: path => path, stat: () => ({ isFile: () => true, mode: 0o755 }), + lstat: path => ({ + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun") && !path.endsWith("/alias"), + isSymbolicLink: () => path.endsWith("/alias"), + mode: path.endsWith("/bun") ? 0o755 : path.endsWith("/alias") ? 0o777 : 0o755, + uid: 0, + }), + readlink: () => "/first", + geteuid: () => 1000, access: () => {}, isRealBunBinary: () => true, spawnSync: (path, args, options) => { @@ -505,10 +524,42 @@ describe("failure-only Desktop CLI pointer", () => { }); describe("real PATH Bun validation", () => { + test("normalizes case-variant macOS PATH spelling after validating its ancestry", () => { + const fixture = pathProbeFixture("darwin"); + fixture.io.realpath = path => path.replace("/OPT/fixture", "/opt/fixture"); + expect(fixture.find({ PATH: "/OPT/fixture/bin" })).toEqual({ path: "/opt/fixture/bin/bun", version: "1.4.0" }); + expect(fixture.calls).toHaveLength(2); + }); + + test("a foreign-owned entry below a root-owned sticky ancestor is refused before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.lstat = path => ({ + isFile: () => path.endsWith("/bun"), isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + uid: path === "/tmp/foreign" ? 2000 : 0, + mode: path === "/tmp" ? 0o1777 : 0o755, + }); + expect(fixture.find({ PATH: "/tmp/foreign/bin" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + + test("a user-owned entry below a shared sticky ancestor is refused before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.lstat = path => ({ + isFile: () => path.endsWith("/bun"), isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + uid: path === "/tmp" ? 0 : 1000, + mode: path === "/tmp" ? 0o1777 : 0o755, + }); + expect(fixture.find({ PATH: "/tmp/own/bin" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + test.each(["file-group", "file-world", "parent-group", "parent-world"])("rejects POSIX %s write access before spawning", kind => { const fixture = pathProbeFixture(); - fixture.io.stat = path => ({ - isFile: () => true, + const trustedLstat = fixture.io.lstat!; + fixture.io.lstat = path => ({ + ...trustedLstat(path), mode: 0o755 | ((kind.startsWith("file") === path.endsWith("/bun")) ? (kind.endsWith("group") ? 0o020 : 0o002) : 0), }); @@ -516,6 +567,33 @@ describe("real PATH Bun validation", () => { expect(fixture.calls).toHaveLength(0); }); + test("rejects a writable original PATH symlink directory before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/untrusted/bun" : path; + fixture.io.readlink = () => "/untrusted"; + fixture.io.lstat = path => path === "/alias" + ? { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true, mode: 0o777, uid: 1000 } + : path === "/untrusted" + ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false, mode: 0o777, uid: 1000 } + : { + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + mode: 0o755, + uid: 0, + }; + expect(fixture.find({ PATH: "/alias" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + + test("accepts a trusted original PATH symlink directory", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/trusted/bun" : path; + fixture.io.readlink = () => "/trusted"; + expect(fixture.find({ PATH: "/alias" })?.path).toBe("/trusted/bun"); + expect(fixture.calls).toHaveLength(2); + }); + test("Windows does not interpret POSIX write mode bits", () => { const fixture = pathProbeFixture("win32"); fixture.io.stat = () => ({ isFile: () => true, mode: 0o777 }); @@ -568,7 +646,7 @@ describe("real PATH Bun validation", () => { const visited: string[] = []; fixture.io.realpath = path => { visited.push(path); return path; }; expect(fixture.find({ PATH: ":.:relative:~/bin:/trusted" })?.path).toBe("/trusted/bun"); - expect(visited).toEqual(["/trusted/bun"]); + expect(visited).toEqual(["/trusted/bun", "/trusted/bun"]); }); test("Windows accepts UNC bun.exe and deduplicates case aliases", () => { @@ -582,7 +660,7 @@ describe("real PATH Bun validation", () => { }); test.skipIf(process.platform === "win32")("real padded version-only script fails the identity check", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-identity-")); + const root = pathFixtureRoot("ocx-path-identity-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nif [ "$1" = "--version" ]; then printf "1.4.0\\n"; else exit 1; fi\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); @@ -592,7 +670,7 @@ describe("real PATH Bun validation", () => { }); test.skipIf(process.platform === "win32")("real writable file and resolved parent are rejected", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-mode-")); + const root = pathFixtureRoot("ocx-path-mode-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nprintf "1.4.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); @@ -605,8 +683,69 @@ describe("real PATH Bun validation", () => { } finally { removeTree(root); } }); + test.skipIf(process.platform === "win32")("writable original PATH directory is rejected before spawning its symlinked Bun", () => { + const root = pathFixtureRoot("ocx-path-symlink-dir-"); + try { + const trusted = join(root, "trusted"); + const writable = join(root, "writable"); + mkdirSync(trusted, { mode: 0o700 }); + mkdirSync(writable, { mode: 0o700 }); + const binary = join(trusted, "bun"); + const link = join(writable, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + chmodSync(writable, 0o777); + symlinkSync(binary, link); + let spawns = 0; + const selected = findPathBun({ + env: { PATH: writable }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + geteuid: () => process.geteuid?.(), + spawnSync: () => { spawns++; return { status: 0, stdout: "1.4.2" }; }, + }, + }); + expect(selected).toBeNull(); + expect(spawns).toBe(0); + } finally { removeTree(root); } + }); + + test.skipIf(process.platform === "win32")("trusted Bun symlink is accepted", () => { + const root = pathFixtureRoot("ocx-path-trusted-symlink-"); + try { + const directory = join(root, "bin"); + mkdirSync(directory, { mode: 0o700 }); + const binary = join(root, "bun-real"); + const link = join(directory, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + symlinkSync(binary, link); + const selected = findPathBun({ + env: { PATH: directory }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + stat: statSync, + geteuid: () => process.geteuid?.(), + access: () => {}, + isRealBunBinary: () => true, + spawnSync: (_path, args) => ({ + status: 0, + stdout: args[0] === "--version" ? "1.4.2" : "1.4.2", + }), + }, + }); + expect(selected).toEqual({ path: realpathSync.native(binary), version: "1.4.2" }); + } finally { removeTree(root); } + }); + test("real PATH validation rejects the small stub and accepts the complete executable", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-bun-")); + const root = pathFixtureRoot("ocx-path-bun-"); try { const first = join(root, "first"), last = join(root, "last"); mkdirSync(first); mkdirSync(last); @@ -615,7 +754,11 @@ describe("real PATH Bun validation", () => { if (process.platform === "win32") copyFileSync(process.execPath, join(last, name)); else writeFileSync(join(last, name), '#!/bin/sh\ncase "$1" in --version|-e) printf "1.4.0\\n";; *) exit 1;; esac\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(join(first, name), 0o755); chmodSync(join(last, name), 0o755); - const selected = findPathBun({ env: { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }, pinnedVersion: "1.4.2", deadlineMs: process.platform === "win32" ? INTERNAL_DEADLINE_MS : 2000 }); + const env = { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }; + // Windows keeps the inherited variable under its stored case (e.g. `Path`); + // the resolver's case-insensitive lookup would reach it before this override. + for (const key of Object.keys(env)) if (key !== "PATH" && key.toLowerCase() === "path") delete env[key]; + const selected = findPathBun({ env, pinnedVersion: "1.4.2", deadlineMs: process.platform === "win32" ? INTERNAL_DEADLINE_MS : 2000 }); expect(selected?.path).toBe(realpathSync.native(join(last, name))); expect(selected?.version).toMatch(/^1\.(?:[4-9]|[1-9]\d+)\./); } finally { removeTree(root); } @@ -633,7 +776,7 @@ function warmCopiedBun(path: string): void { } function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-path-launcher-"))); + const root = realpathSync.native(pathFixtureRoot("ocx-path-launcher-")); try { const node = spawnSync("node", ["-p", "process.execPath"], { encoding: "utf8", windowsHide: true }).stdout.trim(); const version = spawnSync(process.execPath, ["--version"], { encoding: "utf8", windowsHide: true }).stdout.trim(); @@ -712,7 +855,7 @@ describe.skipIf(!nodeAvailable)("PATH fallback negative activation", () => { expect(result.stderr).not.toContain("using PATH Bun"); }); test.skipIf(process.platform === "win32")("real version probe returns null within the deadline", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-timeout-")); + const root = pathFixtureRoot("ocx-path-timeout-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nexec /bin/sleep 5\n#' + "x".repeat(REAL_BUN_MIN_BYTES));