diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index d50a74d1d5d9..111b43954ef1 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -18,6 +18,21 @@ To configure Bun globally, you can also create a `.bunfig.toml` file at one of t If Bun finds both a global and a local `bunfig`, it shallow-merges them, with local overriding global. CLI flags override `bunfig` settings where applicable. +## System-wide configuration + +For corporate or shared environments where an administrator wants to enforce default `bunfig.toml` settings across every user on a machine, Bun loads a system-wide config first (lowest priority, overridden by global and project configs). + +Bun looks for the system config at: + +- `/etc/bunfig.toml` on POSIX systems (Linux, macOS) +- `%ALLUSERSPROFILE%\bunfig.toml` on Windows (typically `C:\ProgramData\bunfig.toml`) + +Auto-discovery of these default paths only applies to package-manager commands (`bun install`, `bun add`, `bun remove`, `bunx`, etc.) — same scope as the `$HOME/.bunfig.toml` lookup above, and for the same reason: every other command path (`bun run`, `bun test`, `bun file.ts`, compiled standalone binaries) would otherwise pay a filesystem probe on every invocation. + +To apply a system config across all commands, set `BUN_SYSTEM_CONFIG` to an absolute path. The environment variable is honored on every command path, including compiled standalone binaries. Pointing `BUN_SYSTEM_CONFIG` at a non-existent or malformed file is treated as an error (fail loudly), so policy typos are caught immediately. + +Merge order is **system → home → project**; later overrides earlier, with the same shallow-merge semantics as the home/project merge. + ## Runtime Top-level fields in `bunfig.toml` configure Bun's runtime behavior. diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index e23ab1c4a49a..dfe34e64e907 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -194,18 +194,19 @@ process.env.AWESOME; // => string Bun reads these environment variables to configure aspects of its behavior. -| Name | Description | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NODE_TLS_REJECT_UNAUTHORIZED` | `NODE_TLS_REJECT_UNAUTHORIZED=0` disables SSL certificate validation. Useful for testing and debugging, but be very hesitant to use it in production. Node.js introduced this variable; Bun keeps the name for compatibility. | -| `BUN_CONFIG_VERBOSE_FETCH` | If `BUN_CONFIG_VERBOSE_FETCH=curl`, then fetch requests log the URL, method, request headers and response headers to the console. This also works with `node:http`. `BUN_CONFIG_VERBOSE_FETCH=1` is equivalent to `BUN_CONFIG_VERBOSE_FETCH=curl` except without the `curl` output. | -| `BUN_RUNTIME_TRANSPILER_CACHE_PATH` | The runtime transpiler caches the transpiled output of source files larger than 4 KB, which makes CLIs using Bun load faster. If `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is set, Bun writes the cache to that directory. If it is set to an empty string or the string `"0"`, caching is disabled. If it is unset, Bun writes the cache to the platform-specific cache directory. | -| `TMPDIR` | Bun occasionally requires a directory to store intermediate assets during bundling or other operations. If unset, defaults to the platform-specific temporary directory: `/tmp` on Linux, `/private/tmp` on macOS. | -| `NO_COLOR` | If `NO_COLOR=1`, then ANSI color output is [disabled](https://no-color.org/). | -| `FORCE_COLOR` | If `FORCE_COLOR=1`, then ANSI color output is forced on, even if `NO_COLOR` is set. | -| `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by fetch and `bun install`. Defaults to `256`. Lower it if you run into rate limits or connection issues. | -| `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD` | If `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=true`, then `bun --watch` does not clear the console on reload | -| `DO_NOT_TRACK` | Disable uploading crash reports to `bun.report` on crash. On macOS & Windows, crash report uploads are enabled by default. Bun sends no other telemetry, though we plan to add some. If `DO_NOT_TRACK=1`, then auto-uploading crash reports and telemetry are both [disabled](https://do-not-track.dev/). | -| `BUN_OPTIONS` | Prepends command-line arguments to any Bun execution. For example, `BUN_OPTIONS="--hot"` makes `bun run dev` behave like `bun --hot run dev`. | +| Name | Description | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NODE_TLS_REJECT_UNAUTHORIZED` | `NODE_TLS_REJECT_UNAUTHORIZED=0` disables SSL certificate validation. Useful for testing and debugging, but be very hesitant to use it in production. Node.js introduced this variable; Bun keeps the name for compatibility. | +| `BUN_CONFIG_VERBOSE_FETCH` | If `BUN_CONFIG_VERBOSE_FETCH=curl`, then fetch requests log the URL, method, request headers and response headers to the console. This also works with `node:http`. `BUN_CONFIG_VERBOSE_FETCH=1` is equivalent to `BUN_CONFIG_VERBOSE_FETCH=curl` except without the `curl` output. | +| `BUN_RUNTIME_TRANSPILER_CACHE_PATH` | The runtime transpiler caches the transpiled output of source files larger than 4 KB, which makes CLIs using Bun load faster. If `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is set, Bun writes the cache to that directory. If it is set to an empty string or the string `"0"`, caching is disabled. If it is unset, Bun writes the cache to the platform-specific cache directory. | +| `TMPDIR` | Bun occasionally requires a directory to store intermediate assets during bundling or other operations. If unset, defaults to the platform-specific temporary directory: `/tmp` on Linux, `/private/tmp` on macOS. | +| `NO_COLOR` | If `NO_COLOR=1`, then ANSI color output is [disabled](https://no-color.org/). | +| `FORCE_COLOR` | If `FORCE_COLOR=1`, then ANSI color output is forced on, even if `NO_COLOR` is set. | +| `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by fetch and `bun install`. Defaults to `256`. Lower it if you run into rate limits or connection issues. | +| `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD` | If `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=true`, then `bun --watch` does not clear the console on reload | +| `DO_NOT_TRACK` | Disable uploading crash reports to `bun.report` on crash. On macOS & Windows, crash report uploads are enabled by default. Bun sends no other telemetry, though we plan to add some. If `DO_NOT_TRACK=1`, then auto-uploading crash reports and telemetry are both [disabled](https://do-not-track.dev/). | +| `BUN_OPTIONS` | Prepends command-line arguments to any Bun execution. For example, `BUN_OPTIONS="--hot"` makes `bun run dev` behave like `bun --hot run dev`. | +| `BUN_SYSTEM_CONFIG` | Absolute path to a system-wide `bunfig.toml` file that is loaded before the user's home and project bunfigs. Lets administrators enforce default settings in shared environments. If unset, Bun falls back to `/etc/bunfig.toml` on POSIX or `%ALLUSERSPROFILE%\bunfig.toml` on Windows, but only for package-manager commands (`bun install`, `bun add`, `bunx`, etc.). Set `BUN_SYSTEM_CONFIG` explicitly to apply a system config to every command. See [System-wide configuration](/docs/runtime/bunfig#system-wide-configuration). | ## Runtime transpiler caching diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 1c5082acfb23..ecf9cba1a5cb 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -39,6 +39,8 @@ use crate::ZStr; // ────────────────────────────────────────────────────────────────────────────── new!(pub AGENT: string, "AGENT", {}); +// Windows "All Users" profile root; `%ALLUSERSPROFILE%\bunfig.toml` is the system bunfig default. +platform_specific_new!(pub ALLUSERSPROFILE: string, posix = None, windows = "ALLUSERSPROFILE", {}); new!(pub BUN_AGENT_RULE_DISABLED: boolean, "BUN_AGENT_RULE_DISABLED", { default: false }); new!(pub BUN_COMPILE_TARGET_TARBALL_URL: string, "BUN_COMPILE_TARGET_TARBALL_URL", {}); new!(pub BUN_CONFIG_DISABLE_COPY_FILE_RANGE: boolean, "BUN_CONFIG_DISABLE_COPY_FILE_RANGE", { default: false }); @@ -111,6 +113,8 @@ new!(pub BUN_POSTGRES_SOCKET_MONITOR: string, "BUN_POSTGRES_SOCKET_MONITOR", {}) new!(pub BUN_POSTGRES_SOCKET_MONITOR_READER: string, "BUN_POSTGRES_SOCKET_MONITOR_READER", {}); new!(pub BUN_RUNTIME_TRANSPILER_CACHE_PATH: string, "BUN_RUNTIME_TRANSPILER_CACHE_PATH", {}); new!(pub BUN_SSG_DISABLE_STATIC_ROUTE_VISITOR: boolean, "BUN_SSG_DISABLE_STATIC_ROUTE_VISITOR", { default: false }); +// Absolute path to a system-wide bunfig.toml, loaded before home/project configs. +new!(pub BUN_SYSTEM_CONFIG: string, "BUN_SYSTEM_CONFIG", {}); new!(pub BUN_TCC_OPTIONS: string, "BUN_TCC_OPTIONS", {}); // Standard C compiler environment variable for include paths (colon-separated). // Used by bun:ffi's TinyCC integration for systems like NixOS. diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index d0a388d3ef7c..e3965ee609dd 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -17,16 +17,83 @@ use crate::bunfig::Bunfig; // ─── bunfig loading ────────────────────────────────────────────────────────── +/// Result of looking up the system bunfig path. +struct SystemConfigResult<'a> { + path: Option<&'a ZStr>, + /// `true` if the path came from `BUN_SYSTEM_CONFIG` (admin opt-in). + /// Explicit paths fail loudly; auto-discovered defaults are best-effort. + is_explicit: bool, +} + +fn get_system_config_path(buf: &mut PathBuffer) -> SystemConfigResult<'_> { + if let Some(custom_path) = env_var::BUN_SYSTEM_CONFIG.get_not_empty() { + // Require absolute paths so system-wide policy isn't cwd-dependent. + if !resolve_path::Platform::AUTO.is_absolute(custom_path) { + Output::err_generic( + "BUN_SYSTEM_CONFIG must be an absolute path, got: \"{s}\"", + (BStr::new(custom_path),), + ); + Global::exit(1); + } + if custom_path.len() < bun_paths::MAX_PATH_BYTES { + buf[..custom_path.len()].copy_from_slice(custom_path); + buf[custom_path.len()] = 0; + let len = custom_path.len(); + return SystemConfigResult { + path: Some(ZStr::from_buf(&buf[..], len)), + is_explicit: true, + }; + } + return SystemConfigResult { + path: None, + is_explicit: true, + }; + } + + // `ALLUSERSPROFILE` is declared `posix = None`; attribute-`#[cfg]` removes + // the accessor call before type-check on POSIX, like other `posix = None` + // users (SYSTEMROOT, WINDIR). + #[cfg(windows)] + { + if let Some(all_users) = env_var::ALLUSERSPROFILE.get_not_empty() { + let paths: [&[u8]; 1] = [b"bunfig.toml"]; + let joined = resolve_path::join_abs_string_buf_z::( + all_users, &mut **buf, &paths, + ); + return SystemConfigResult { + path: Some(joined), + is_explicit: false, + }; + } + SystemConfigResult { + path: None, + is_explicit: false, + } + } + #[cfg(not(windows))] + { + // POSIX: /etc/bunfig.toml. + let system_path: &[u8] = b"/etc/bunfig.toml"; + buf[..system_path.len()].copy_from_slice(system_path); + buf[system_path.len()] = 0; + let len = system_path.len(); + SystemConfigResult { + path: Some(ZStr::from_buf(&buf[..], len)), + is_explicit: false, + } + } +} + fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> { let paths: [&[u8]; 1] = [b".bunfig.toml"]; - if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get() { + if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get_not_empty() { return Some(resolve_path::join_abs_string_buf_z::( data_dir, &mut **buf, &paths, )); } - if let Some(home_dir) = env_var::HOME.get() { + if let Some(home_dir) = env_var::HOME.get_not_empty() { return Some(resolve_path::join_abs_string_buf_z::( home_dir, &mut **buf, &paths, )); @@ -38,11 +105,26 @@ fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> { fn load_bunfig( cmd: CommandTag, auto_loaded: bool, + is_project: bool, config_path: &ZStr, ctx: Context<'_>, ) -> Result<(), crate::Error> { + // Intern `config_path` in the process-lifetime `FilenameStore`: + // `Bunfig::parse` stores `Location.file` borrows of `source.path.text` in + // the process-global `ctx.log`, and those diagnostics can print after the + // caller's `PathBuffer` frame is gone — borrowing the stack buffer would be + // a stack-use-after-return. The interned slice is genuinely `'static` + // (never-freed BSS singleton, reachable for LeakSanitizer); the trailing + // NUL lets it back a `ZStr`. + let interned = bun_resolver::fs::FilenameStore::instance() + .append_parts(&[config_path.as_bytes(), b"\0"]) + .map_err(|_| bun_alloc::AllocError)?; + // SAFETY: `interned` ends in the NUL byte appended above; `from_raw` takes + // the length excluding it. + let owned_path = unsafe { ZStr::from_raw(interned.as_ptr(), interned.len() - 1) }; + let source = - match bun_ast::to_source(config_path, bun_ast::ToSourceOptions { convert_bom: true }) { + match bun_ast::to_source(owned_path, bun_ast::ToSourceOptions { convert_bom: true }) { Ok(s) => s, Err(err) => { if auto_loaded { @@ -51,7 +133,7 @@ fn load_bunfig( bun_core::pretty_errorln!( "{}\nwhile reading config \"{}\"", err, - BStr::new(config_path.as_bytes()), + BStr::new(owned_path.as_bytes()), ); Global::exit(1); } @@ -76,19 +158,99 @@ fn load_bunfig( // SAFETY: same as above; runs on the same thread. unsafe { (*log_ptr).level = lvl }; }); - ctx.debug.loaded_bunfig = true; + // Only project configs mark loaded_bunfig; run_command/standalone/repl use + // it to decide whether project bunfig.toml still needs loading. + if is_project { + ctx.debug.loaded_bunfig = true; + } Bunfig::parse(cmd, &source, ctx) } +/// Load the system-wide bunfig (lowest priority). Auto-discovered paths are +/// best-effort (warn-and-continue on errors so a broken /etc/bunfig.toml +/// doesn't brick every bun invocation on the host). Explicit BUN_SYSTEM_CONFIG +/// fails loudly so admin typos surface immediately. +pub fn load_system_bunfig(cmd: CommandTag, ctx: Context<'_>) -> Result<(), crate::Error> { + if ctx.has_loaded_system_config { + return Ok(()); + } + ctx.has_loaded_system_config = true; + + let mut config_buf = PathBuffer::uninit(); + let result = get_system_config_path(&mut config_buf); + if result.is_explicit && result.path.is_none() { + Output::err_generic("BUN_SYSTEM_CONFIG path is too long", ()); + Global::exit(1); + } + if let Some(path) = result.path { + let log_ptr: *mut bun_ast::Log = ctx.log; + // SAFETY: process-global Log; see load_bunfig note. + let errors_before = unsafe { (*log_ptr).errors }; + + // Not project-level; explicit paths must fail loudly on a missing file. + let load_result = load_bunfig(cmd, !result.is_explicit, false, path, ctx); + + match load_result { + Ok(()) => {} + Err(err) => { + if result.is_explicit { + return Err(err); + } + // Auto-discovered: warn and continue. Bunfig::parse mutates ctx + // in place as it walks keys, so settings before the failing key + // may already be applied — reflect that honestly. + // SAFETY: process-global Log. + let log = unsafe { &mut *log_ptr }; + if log.has_any() { + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + } + bun_core::warn!( + "aborted parsing auto-discovered system bunfig at \"{}\" ({}); keys before the error may have been applied", + BStr::new(path.as_bytes()), + err.name(), + ); + log.reset(); + return Ok(()); + } + } + + // TOML lexer errors reach ctx.log without propagating as Zig/Rust + // errors. Check for that separately. + // SAFETY: process-global Log. + let log = unsafe { &mut *log_ptr }; + if log.errors > errors_before { + if result.is_explicit { + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + Output::err_generic( + "failed to parse BUN_SYSTEM_CONFIG at \"{s}\"", + (BStr::new(path.as_bytes()),), + ); + Global::exit(1); + } + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + bun_core::warn!( + "aborted parsing auto-discovered system bunfig at \"{}\"; keys before the error may have been applied", + BStr::new(path.as_bytes()), + ); + log.reset(); + } + } + Ok(()) +} + fn load_global_bunfig(cmd: CommandTag, ctx: Context<'_>) -> Result<(), crate::Error> { if ctx.has_loaded_global_config { return Ok(()); } ctx.has_loaded_global_config = true; + // Load system-wide config first (lowest priority). + load_system_bunfig(cmd, ctx)?; + let mut config_buf = PathBuffer::uninit(); if let Some(path) = get_home_config_path(&mut config_buf) { - load_bunfig(cmd, true, path, ctx)?; + // Home config is not project-level. + load_bunfig(cmd, true, false, path, ctx)?; } Ok(()) } @@ -118,13 +280,15 @@ pub fn load_config_path( } } - load_bunfig(cmd, auto_loaded, config_path, ctx) + // This is the project-level config path. + load_bunfig(cmd, auto_loaded, true, config_path, ctx) } #[cold] -fn report_bunfig_load_failure(log: *mut bun_ast::Log, err: crate::Error) -> ! { +pub fn report_bunfig_load_failure(ctx: Context<'_>, err: crate::Error) -> ! { + let log_ptr: *mut bun_ast::Log = ctx.log; // SAFETY: process-global Log; see `load_bunfig` note. - let log = unsafe { &mut *log }; + let log = unsafe { &mut *log_ptr }; if log.has_any() { let _ = log.print(std::ptr::from_mut(Output::error_writer())); Output::print_error("\n"); @@ -138,8 +302,18 @@ pub fn load_config( user_config_path_: Option<&[u8]>, ctx: Context<'_>, ) -> Result<(), crate::Error> { - // If running as a standalone executable with autoloadBunfig disabled, skip config loading - // unless an explicit config path was provided via --config + // An explicit BUN_SYSTEM_CONFIG is an administrator override: load it + // before the standalone DISABLE_AUTOLOAD_BUNFIG check so compiled binaries + // honor it, while the flag still blocks home/project autoload below. + let has_explicit_system_config = env_var::BUN_SYSTEM_CONFIG.get_not_empty().is_some(); + if has_explicit_system_config || cmd.read_global_config() { + if let Err(err) = load_system_bunfig(cmd, ctx) { + report_bunfig_load_failure(ctx, err); + } + } + + // If running as a standalone executable with autoloadBunfig disabled, skip further + // config loading unless an explicit --config path was provided. if user_config_path_.is_none() { if let Some(graph) = StandaloneModuleGraph::get() { // SAFETY: `get()` returns a non-null process-global pointer when Some. @@ -157,8 +331,9 @@ pub fn load_config( ctx.has_loaded_global_config = true; if let Some(path) = get_home_config_path(&mut config_buf) { - if let Err(err) = load_config_path(cmd, true, path, ctx) { - report_bunfig_load_failure(ctx.log, err); + // Home config is not project-level. + if let Err(err) = load_bunfig(cmd, true, false, path, ctx) { + report_bunfig_load_failure(ctx, err); } } } @@ -221,7 +396,7 @@ pub fn load_config( let config_path = ZStr::from_buf(&config_buf[..], config_path_len); if let Err(err) = load_config_path(cmd, auto_loaded, config_path, ctx) { - report_bunfig_load_failure(ctx.log, err); + report_bunfig_load_failure(ctx, err); } Ok(()) } diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 4190731b8ed8..9509c9a0f09f 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -44,6 +44,7 @@ pub struct ContextData { pub preloads: Vec>, pub has_loaded_global_config: bool, + pub has_loaded_system_config: bool, } impl Default for ContextData { @@ -84,6 +85,7 @@ impl Default for ContextData { no_exit_on_error: false, preloads: Vec::new(), has_loaded_global_config: false, + has_loaded_system_config: false, } } } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index ab0fc39e9b37..f32ac9058d8f 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -763,7 +763,10 @@ pub(crate) static Bun__Node__UseSystemCA: core::sync::atomic::AtomicBool = // their private helpers moved to `bun_bunfig::arguments` so `bun_install` can // call them without a tier-6 dependency. Re-export here so existing // `crate::cli::arguments::load_config*` callers are unaffected. -pub use bun_bunfig::arguments::{load_config, load_config_path, load_config_with_cmd_args}; +pub use bun_bunfig::arguments::{ + load_config, load_config_path, load_config_with_cmd_args, load_system_bunfig, + report_bunfig_load_failure, +}; /// node aliases `-pe` to `--print --eval` as a whole token (node_options.cc): /// it can't be a short in either runtime, being ambiguous with `-p` carrying diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 87501ccffec9..4d33b18be0f6 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1116,8 +1116,22 @@ Full documentation is available at https://bun.com/docs/cli/run bun_analytics::features::standalone_executable.fetch_add(1, Ordering::Relaxed); bun_ast::initialize_store(); - // Load bunfig.toml unless disabled by compile flags. Config loading - // with execArgv is handled earlier in `Command::start` via `init()`. + // An explicit BUN_SYSTEM_CONFIG is honored even when + // DISABLE_AUTOLOAD_BUNFIG is set; default-path probing stays off for + // standalone binaries. The has_loaded_system_config guard makes this a + // no-op when load_config already ran via the execArgv branch. + if bun_core::env_var::BUN_SYSTEM_CONFIG + .get_not_empty() + .is_some() + { + if let Err(err) = arguments::load_system_bunfig(CommandTag::RunCommand, ctx) { + arguments::report_bunfig_load_failure(ctx, err); + } + } + + // Load project bunfig.toml unless disabled by compile flags. Config + // loading with execArgv is handled earlier in `Command::start` via + // `init()`. if !ctx.debug.loaded_bunfig && !graph.flags.contains(GraphFlags::DISABLE_AUTOLOAD_BUNFIG) { arguments::load_config_path( CommandTag::RunCommand, diff --git a/test/config/bunfig/system-config.test.ts b/test/config/bunfig/system-config.test.ts new file mode 100644 index 000000000000..3d5c5261bc9f --- /dev/null +++ b/test/config/bunfig/system-config.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "path"; + +// Feature #28726: system-wide bunfig.toml support via `BUN_SYSTEM_CONFIG` +// or platform default (/etc/bunfig.toml on POSIX, %ALLUSERSPROFILE%\bunfig.toml +// on Windows). Merge order is system → home → project; later overrides earlier. +// +// Every subtest passes `BUN_SYSTEM_CONFIG` explicitly so none of them read the +// real `/etc/bunfig.toml` on the CI host, and every subtest uses a freshly- +// allocated tempDir to avoid cross-test bleed. + +describe("system-wide bunfig.toml", () => { + test("system config preload is applied via BUN_SYSTEM_CONFIG", async () => { + using dir = tempDir("system-bunfig-preload", { + "system-bunfig.toml": `preload = ["./preload.ts"]`, + "preload.ts": `(globalThis as any).SYSTEM_PRELOADED = true;`, + "index.ts": `console.log("preloaded:" + !!(globalThis as any).SYSTEM_PRELOADED);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/system-bunfig.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("preloaded:true"); + expect(exitCode).toBe(0); + }); + + test("project bunfig overrides system bunfig preload completely", async () => { + // system-preload writes a marker file as an irreversible side effect. + // If project bunfig truly replaces the preload list, the marker must not exist. + using dir = tempDir("system-bunfig-override", { + "system-bunfig.toml": `preload = ["./system-preload.ts"]`, + "bunfig.toml": `preload = ["./project-preload.ts"]`, + "system-preload.ts": `require("fs").writeFileSync(require("path").join(process.cwd(), "system-ran.txt"), "yes");`, + "project-preload.ts": `(globalThis as any).FROM = "project";`, + "index.ts": `console.log("from:" + (globalThis as any).FROM);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/system-bunfig.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("from:project"); + // The system preload must NOT have run — project bunfig replaced it + expect(existsSync(join(String(dir), "system-ran.txt"))).toBe(false); + expect(exitCode).toBe(0); + }); + + test("explicit BUN_SYSTEM_CONFIG with bad path fails loudly", async () => { + using dir = tempDir("system-bunfig-bad", { + "index.ts": `console.log("should not run");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/nonexistent.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Explicit override must error with the offending path, not silently ignore. + expect(stdout).not.toContain("should not run"); + expect(stderr).toContain("while reading config"); + expect(stderr).toContain("nonexistent.toml"); + expect(exitCode).not.toBe(0); + }); + + test("malformed BUN_SYSTEM_CONFIG fails loudly and prints the path", async () => { + // Two regressions in one: + // 1. Policy typos must fail loudly. TOML parse/validation errors propagate + // as Err from Bunfig::parse; for an explicit BUN_SYSTEM_CONFIG, + // load_system_bunfig surfaces them instead of warning, so the process + // exits nonzero. Previously the TOML parse error was logged but the + // process exited 0, which silently disables the admin policy. + // 2. loadBunfig used to stash the caller's PathBuffer slice in ctx.log + // (via Source.path.text), and the later error print read freed stack + // memory after the frame was gone — stack-use-after-return on ASAN. + // Under ASAN (bun bd) the bad path showed up as poison/garbage bytes + // where the filename should be. The fix dupes the config path onto + // the allocator so the log-borrowed pointer stays valid. + using dir = tempDir("system-bunfig-malformed", { + // Unclosed TOML section header makes TOML.parse log a caret-style + // error referencing source.path.text — the exact UAF trigger. + "system-bunfig.toml": `[install\n`, + "index.ts": `console.log("ran");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/system-bunfig.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Script must not run — admin policy typos can't be allowed to pass through. + expect(stdout).not.toContain("ran"); + // The readable path must appear in stderr after the caret diagnostic. + // Before the UAF fix, the `at :line:col` line printed whatever bytes + // remained on the freed stack where the PathBuffer used to live — ASAN + // poison or random values (pointers, stale heap) following the frame. + // Asserting the exact filename:line:col shape rejects all of those while + // accepting the clean output produced by the fix. + expect(stderr).toMatch(/at [^\n]*system-bunfig\.toml:1:\d+/); + expect(stderr).toContain("failed to load bunfig"); + expect(exitCode).not.toBe(0); + }); + + test("system config define is applied", async () => { + using dir = tempDir("system-bunfig-define", { + "system-bunfig.toml": ` +[define] +"process.env.SYSTEM_DEFINED" = "'from-system-config'" +`, + "index.ts": `console.log("val:" + process.env.SYSTEM_DEFINED);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/system-bunfig.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("val:from-system-config"); + expect(exitCode).toBe(0); + }); + + test("bun run loads project bunfig.toml even when system config is set", async () => { + // Regression test for loaded_bunfig poisoning: system config loading must not + // set ctx.debug.loaded_bunfig, which is used as a guard in run_command.rs + // (RunCommand::boot_standalone) to load project bunfig.toml. If system config + // incorrectly poisons loaded_bunfig, `bun run script.ts` silently skips the + // project bunfig.toml, inverting the documented config priority (system < project). + using dir = tempDir("system-bunfig-run-priority", { + "system-bunfig.toml": ` +[define] +"globalThis.TIER" = "'system'" +`, + "bunfig.toml": ` +[define] +"globalThis.TIER" = "'project'" +`, + "script.ts": `console.log("tier:" + (globalThis as any).TIER);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "script.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: `${dir}/system-bunfig.toml` }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Project bunfig.toml must override system config. + // If loaded_bunfig is poisoned, stdout would be "tier:system". + expect(stdout).toContain("tier:project"); + expect(exitCode).toBe(0); + }); + + test("BUN_SYSTEM_CONFIG rejects relative paths", async () => { + using dir = tempDir("system-bunfig-relative", { + "index.ts": `console.log("should not run");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: "./relative-bunfig.toml" }, + cwd: String(dir), + stderr: "pipe", + }); + + const [_stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("absolute path"); + expect(exitCode).not.toBe(0); + }); + + test("BUN_SYSTEM_CONFIG empty string is treated as unset", async () => { + // Smoke test: BUN_SYSTEM_CONFIG="" must not trigger the "must be an + // absolute path" error (it would if loadSystemBunfig treated "" as set). + using dir = tempDir("system-bunfig-empty", { + "index.ts": `console.log("works");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: "" }, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("works"); + expect(exitCode).toBe(0); + }); + + test.skipIf(!isWindows)( + "BUN_SYSTEM_CONFIG='' does not enable auto-discovery for non-package-manager commands", + async () => { + // Load-bearing regression test for the `.getNotEmpty()` check at + // loadConfig: if BUN_SYSTEM_CONFIG="" were treated as set (i.e. + // replacing the check with .get()), the gate `has_explicit_system_config + // or readGlobalConfig()` would enable system-config auto-discovery for + // commands that should not probe it (AutoCommand, RunCommand, TestCommand). + // The loadSystemBunfig call would reach getSystemConfigPath, fall through + // to the platform default, and load %ALLUSERSPROFILE%\bunfig.toml. + // + // To actually detect this regression we need a sentinel the system + // config can set and a way to observe it. [install].cache + `bun pm + // cache` would be ideal, but pm cache is a PackageManagerCommand which + // already probes the system path through the readGlobalConfig() branch. + // Instead we use `[define]`, applied during AutoCommand parse, and + // run `bun index.ts` which prints the defined value. If "" were + // treated as set on AutoCommand, the define from the sentinel bunfig + // would apply and stdout would show "sentinel"; with the fix it must + // show "undefined". + // + // Only runs on Windows because ALLUSERSPROFILE is env-overridable there; + // POSIX hardcodes /etc/bunfig.toml which isn't writeable from tests. + using dir = tempDir("system-bunfig-empty-sentinel", { + "allusers/bunfig.toml": `[define]\n"globalThis.SENTINEL_SYSTEM_LOADED" = "'sentinel'"\n`, + "project/index.ts": `console.log("SENTINEL=" + (globalThis as any).SENTINEL_SYSTEM_LOADED);`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: { + ...bunEnv, + BUN_SYSTEM_CONFIG: "", + ALLUSERSPROFILE: join(String(dir), "allusers"), + USERPROFILE: join(String(dir), "no-home"), + HOME: join(String(dir), "no-home"), + XDG_CONFIG_HOME: join(String(dir), "no-home"), + }, + cwd: join(String(dir), "project"), + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The sentinel define must NOT have been applied — AutoCommand should + // never probe the system bunfig on its own, and BUN_SYSTEM_CONFIG="" is + // not an opt-in. + expect(stdout.trim()).toBe("SENTINEL=undefined"); + expect(exitCode).toBe(0); + }, + ); + + test("package-manager command merges system + home bunfigs (readGlobalConfig path)", async () => { + // Package-manager commands (InstallCommand/BunxCommand/etc.) have + // readGlobalConfig() == true, so loadConfig() dispatches through + // loadGlobalBunfig() which loads the system config first and then the + // home config on top. Every other surviving test either runs through + // AutoCommand/RunCommand (readGlobalConfig() == false) or has no home + // config — so without this test the readGlobalConfig-true branch at + // bunfig/arguments.rs::load_config and load_global_bunfig's system→home + // ordering have zero coverage. + // + // We verify the ordering by giving each tier a distinct `[install] cache` + // directory and reading it back with `bun pm cache`, which prints the + // resolved cache path without hitting the network. Cache dirs live + // inside the tempDir so they: + // - are absolute on every platform (hardcoded `/tmp/...` would be + // drive-relative on Windows and symlink-aliased on macOS) + // - get cleaned up with the tempDir instead of polluting the host + using dir = tempDir("system-bunfig-pkg-merge", { + "package.json": `{"name": "test", "version": "1.0.0"}`, + }); + const sysCachePath = join(String(dir), "sys-cache"); + const homeCachePath = join(String(dir), "home-cache"); + await Bun.write(join(String(dir), "sys.toml"), `[install]\ncache = ${JSON.stringify(sysCachePath)}\n`); + await Bun.write(join(String(dir), "xdg", ".bunfig.toml"), `[install]\ncache = ${JSON.stringify(homeCachePath)}\n`); + + // System + home: home wins (matches documented "later overrides earlier"). + // Explicitly unset `BUN_INSTALL_CACHE_DIR` and `BUN_INSTALL` — the test + // runner sets the former to a shared tempdir, and both short-circuit the + // bunfig `[install].cache` lookup in fetchCacheDirectoryPath. Undefined + // values drop the var from the spawn env. + await using mergeProc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: undefined, + BUN_INSTALL: undefined, + BUN_SYSTEM_CONFIG: join(String(dir), "sys.toml"), + XDG_CONFIG_HOME: join(String(dir), "xdg"), + }, + cwd: String(dir), + stderr: "pipe", + }); + const [mergeOut, _mergeErr, mergeExit] = await Promise.all([ + mergeProc.stdout.text(), + mergeProc.stderr.text(), + mergeProc.exited, + ]); + // Basename is sufficient: if home config wasn't read, mergeOut would + // contain `sys-cache` (or the platform default like `.bun/install/cache`). + expect(mergeOut).toContain("home-cache"); + expect(mergeOut).not.toContain("sys-cache"); + expect(mergeExit).toBe(0); + + // System only (XDG points nowhere): system config applies, proving + // loadSystemBunfig ran through the readGlobalConfig branch. + await using sysOnlyProc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: undefined, + BUN_INSTALL: undefined, + BUN_SYSTEM_CONFIG: join(String(dir), "sys.toml"), + XDG_CONFIG_HOME: join(String(dir), "nonexistent"), + }, + cwd: String(dir), + stderr: "pipe", + }); + const [sysOut, _sysErr, sysExit] = await Promise.all([ + sysOnlyProc.stdout.text(), + sysOnlyProc.stderr.text(), + sysOnlyProc.exited, + ]); + expect(sysOut).toContain("sys-cache"); + expect(sysExit).toBe(0); + }); + + test.skipIf(!isWindows)("auto-discovered system bunfig with validation error warns but does not crash", async () => { + // Auto-discovered /etc/bunfig.toml (POSIX) / %ALLUSERSPROFILE%\bunfig.toml + // (Windows) must not hard-crash every package-manager invocation when the + // sysadmin typos it — the feature is opt-in via BUN_SYSTEM_CONFIG, and the + // default-path probe stays best-effort. Only the Windows default-path is + // overridable via env (ALLUSERSPROFILE); on POSIX the path is hardcoded + // /etc/bunfig.toml, so this only runs on Windows. + // + // The system bunfig lives in `allusers/bunfig.toml` (pointed at by + // ALLUSERSPROFILE); the project dir uses a separate subdirectory with its + // own package.json so auto-loaded project bunfig.toml lookup in cwd + // doesn't re-load the same broken file and re-trigger the failure path. + using dir = tempDir("system-bunfig-auto-broken", { + "allusers/bunfig.toml": `[install]\nauto = "bogus-value"\n`, + "project/package.json": `{"name": "test", "version": "1.0.0"}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache"], + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: undefined, + BUN_INSTALL: undefined, + // No BUN_SYSTEM_CONFIG — forces the auto-discovered path via + // %ALLUSERSPROFILE%\bunfig.toml. + BUN_SYSTEM_CONFIG: undefined, + ALLUSERSPROFILE: join(String(dir), "allusers"), + // Neutralise home config lookup so only the system path is stressed. + // bun.env_var.HOME resolves to USERPROFILE on Windows, so override + // both in case getHomeConfigPath changes its precedence order later. + XDG_CONFIG_HOME: join(String(dir), "no-home"), + USERPROFILE: join(String(dir), "no-home"), + HOME: join(String(dir), "no-home"), + }, + cwd: join(String(dir), "project"), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Auto-discovered: we warn about the broken file but still run. + expect(stderr).toContain("aborted parsing auto-discovered system bunfig"); + // `bun pm cache` printed *some* cache directory — i.e. the process + // continued past the broken bunfig. + expect(stdout.trim().length).toBeGreaterThan(0); + expect(exitCode).toBe(0); + }); + + // A compiled standalone binary runs through `boot_standalone`, a different + // code path than the normal CLI dispatch the other tests exercise. It must + // still honor an explicit BUN_SYSTEM_CONFIG (docs promise system config is + // applied "on every command path, including compiled standalone binaries"). + // The binary is built without a preload; the system config's preload runs + // only because boot_standalone loaded it at runtime. + // Higher per-test timeout because `bun build --compile` copies + rewrites the + // entire bun binary (~1GB under debug+ASAN), which blows the 5s default. + test("compiled standalone binary honors BUN_SYSTEM_CONFIG", async () => { + using dir = tempDir("system-bunfig-standalone", { + "system-bunfig.toml": `preload = ["./sys-preload.ts"]`, + "sys-preload.ts": `console.log("SYSTEM_PRELOAD_RAN");`, + "app.ts": `console.log("app ran");`, + }); + const out = join(String(dir), "app" + (isWindows ? ".exe" : "")); + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "app.ts", "--outfile", out], + cwd: String(dir), + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [, buildStderr, buildExit] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect(buildStderr).not.toContain("error:"); + expect(buildExit).toBe(0); + + await using proc = Bun.spawn({ + cmd: [out], + env: { ...bunEnv, BUN_SYSTEM_CONFIG: join(String(dir), "system-bunfig.toml") }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Preload from the system config ran before the app, proving boot_standalone + // loaded and applied BUN_SYSTEM_CONFIG for the standalone binary. + expect(stdout).toContain("SYSTEM_PRELOAD_RAN"); + expect(stdout).toContain("app ran"); + expect(exitCode).toBe(0); + }, 60_000); +});