-
Notifications
You must be signed in to change notification settings - Fork 5k
bunfig: look up XDG-conventional global config paths #30853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b1ff7f4
5278787
0f9d102
53f4109
b8a76ad
9ed8a9a
964eb1f
73b78e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,18 +17,75 @@ | |
|
|
||
| // ─── bunfig loading ────────────────────────────────────────────────────────── | ||
|
|
||
| /// Locate a global `bunfig.toml` using XDG Base Directory conventions with | ||
| /// back-compat fallbacks. Candidates are tried in order; the first existing | ||
| /// file wins: | ||
| /// | ||
| /// 1. `$XDG_CONFIG_HOME/bun/bunfig.toml` — XDG-conventional (app subdir) | ||
| /// 2. `$XDG_CONFIG_HOME/.bunfig.toml` — legacy hidden-file path | ||
| /// 3. `$HOME/.config/bun/bunfig.toml` — XDG spec default when `XDG_CONFIG_HOME` unset | ||
| /// 4. `$HOME/.config/.bunfig.toml` — legacy hidden-file under spec default | ||
| /// 5. `$HOME/.bunfig.toml` — original home dotfile | ||
| /// | ||
| /// (2) and (4) are retained because Bun previously documented them; new setups | ||
| /// should prefer (1)/(3), which follow the XDG Base Directory Specification | ||
| /// (<https://specifications.freedesktop.org/basedir-spec/latest/>). Candidates | ||
| /// under `$XDG_CONFIG_HOME` are existence-checked so we fall through to the | ||
| /// home dotfile; the final home-dotfile path is returned even if missing so | ||
| /// the downstream auto-load branch (which swallows "file not found" when | ||
| /// `auto_loaded=true`) can handle it uniformly. | ||
| fn get_home_config_path(buf: &mut PathBuffer) -> Option<&ZStr> { | ||
| let paths: [&[u8]; 1] = [b".bunfig.toml"]; | ||
| // Resolve the effective XDG base. When `$XDG_CONFIG_HOME` is unset **or | ||
| // empty** (per the XDG spec), apply the default `$HOME/.config`; own that | ||
| // in a small stack array so we can borrow it past the XDG-candidate loop. | ||
| // `$HOME` on every supported platform is well under a few hundred bytes, | ||
| // so 512 is ample — longer homes fall through to `$HOME/.bunfig.toml`. | ||
| // `get_not_empty()` mirrors the spec's "not set or empty" language — a | ||
| // bare `XDG_CONFIG_HOME=""` must be treated as unset, not as an | ||
| // empty-string base. | ||
|
Comment on lines
+38
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| let mut xdg_scratch = [0u8; 512]; | ||
| let xdg_base: Option<&[u8]> = match ( | ||
| env_var::XDG_CONFIG_HOME.get_not_empty(), | ||
| env_var::HOME.get_not_empty(), | ||
| ) { | ||
| (Some(data_dir), _) => Some(data_dir), | ||
| (None, Some(home_dir)) => { | ||
| const SUFFIX: &[u8] = b"/.config"; | ||
| let total = home_dir.len() + SUFFIX.len(); | ||
| if total <= xdg_scratch.len() { | ||
| xdg_scratch[..home_dir.len()].copy_from_slice(home_dir); | ||
| xdg_scratch[home_dir.len()..total].copy_from_slice(SUFFIX); | ||
| Some(&xdg_scratch[..total]) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
| (None, None) => None, | ||
| }; | ||
|
|
||
| if let Some(data_dir) = env_var::XDG_CONFIG_HOME.get() { | ||
| return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>( | ||
| data_dir, &mut **buf, &paths, | ||
| )); | ||
| if let Some(base) = xdg_base { | ||
| // Probe each XDG-relative candidate. Existence is checked first, then | ||
| // the winning path is re-materialized in `buf` for the caller. | ||
|
Comment on lines
+67
to
+68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| for rel in [b"bun/bunfig.toml" as &[u8], b".bunfig.toml"] { | ||
| let parts: [&[u8]; 1] = [rel]; | ||
| let path = | ||
| resolve_path::join_abs_string_buf_z::<platform::Auto>(base, &mut **buf, &parts); | ||
| if bun_sys::exists_z(path) { | ||
| // SAFETY: `buf` holds the NUL-terminated path; `path.len()` | ||
| // is the byte length excluding the trailing NUL. | ||
| let len = path.len(); | ||
| return Some(unsafe { ZStr::from_raw(buf.as_ptr(), len) }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if let Some(home_dir) = env_var::HOME.get() { | ||
| // Tail: `$HOME/.bunfig.toml`. Returned unconditionally (no existence | ||
| // check) to preserve prior behaviour — `load_bunfig(auto_loaded=true)` | ||
| // swallows "file not found" for this path. | ||
|
Comment on lines
+82
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code |
||
| if let Some(home_dir) = env_var::HOME.get_not_empty() { | ||
| let parts: [&[u8]; 1] = [b".bunfig.toml"]; | ||
| return Some(resolve_path::join_abs_string_buf_z::<platform::Auto>( | ||
| home_dir, &mut **buf, &paths, | ||
| home_dir, &mut **buf, &parts, | ||
| )); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| // Regression coverage for oven-sh/bun#30842: global `bunfig.toml` lookup must | ||
| // follow the XDG Base Directory Specification — the XDG-conventional | ||
| // `$XDG_CONFIG_HOME/bun/bunfig.toml` path, the spec default of | ||
| // `$HOME/.config` when `$XDG_CONFIG_HOME` is unset, and back-compat for the | ||
| // previously documented `$XDG_CONFIG_HOME/.bunfig.toml`. | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe, tempDir } from "harness"; | ||
| import { mkdirSync, writeFileSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
|
|
||
| // Observe global-bunfig loading via `[install.cache] dir = "<sentinel>"` and | ||
| // reading back the effective cache path with `bun pm cache`. Global config | ||
| // is read by install-related commands (`read_global_config()`) — `bun pm | ||
| // cache` qualifies, and it exits 0 without touching the network. | ||
|
|
||
| async function runPmCache(appDir: string, env: Record<string, string | undefined>) { | ||
| // Strip any inherited env that could mask the bunfig under test, then | ||
| // layer per-test values. An `undefined` value means "explicitly absent". | ||
| // | ||
| // - `BUN_INSTALL_CACHE_DIR` is set by the Buildkite runner | ||
| // (scripts/runner.node.mjs) and takes precedence over bunfig's | ||
| // `[install.cache].dir` in `fetch_cache_directory_path()`; drop it so | ||
| // our sentinel wins. | ||
| // - `BUN_INSTALL` / `XDG_CACHE_HOME` are checked after the bunfig option | ||
| // but we strip them defensively so the test signal is unambiguous. | ||
| // - `env_var::HOME` reads `USERPROFILE` on Windows (env_var.rs:138), so | ||
| // when a test passes `HOME`, mirror it into `USERPROFILE` so the | ||
| // spawned bun sees the same value on both platforms. `XDG_CONFIG_HOME` | ||
| // is honoured on Windows too (env_var.rs:177–180), so no special-casing | ||
| // is needed there. | ||
| const spawnEnv: Record<string, string> = { ...bunEnv }; | ||
| delete spawnEnv.HOME; | ||
| delete spawnEnv.XDG_CONFIG_HOME; | ||
| delete spawnEnv.USERPROFILE; | ||
| delete spawnEnv.BUN_INSTALL_CACHE_DIR; | ||
| delete spawnEnv.BUN_INSTALL; | ||
| delete spawnEnv.XDG_CACHE_HOME; | ||
| for (const [k, v] of Object.entries(env)) { | ||
| if (v !== undefined) spawnEnv[k] = v; | ||
| } | ||
|
robobun marked this conversation as resolved.
|
||
| if (spawnEnv.HOME !== undefined && spawnEnv.USERPROFILE === undefined) { | ||
| spawnEnv.USERPROFILE = spawnEnv.HOME; | ||
| } | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "pm", "cache"], | ||
| cwd: appDir, | ||
| env: spawnEnv, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| return { stdout: stdout.trim(), stderr, exitCode }; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| function writeBunfigCacheDir(path: string, cacheDir: string) { | ||
| mkdirSync(dirname(path), { recursive: true }); | ||
| writeFileSync(path, `[install.cache]\ndir = ${JSON.stringify(cacheDir)}\n`); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
robobun marked this conversation as resolved.
|
||
|
|
||
| describe.concurrent("global bunfig.toml XDG path lookup", () => { | ||
| test("loads $XDG_CONFIG_HOME/bun/bunfig.toml (XDG-conventional)", async () => { | ||
| using home = tempDir("bunfig-xdg-conventional", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const cacheDir = join(homeStr, "xdg-conventional-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| XDG_CONFIG_HOME: join(homeStr, ".config"), | ||
| }); | ||
| expect(stdout).toBe(cacheDir); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("loads $HOME/.config/bun/bunfig.toml via spec default when XDG_CONFIG_HOME is unset", async () => { | ||
| using home = tempDir("bunfig-xdg-default", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const cacheDir = join(homeStr, "spec-default-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| // XDG_CONFIG_HOME explicitly omitted — spec default of `$HOME/.config` | ||
| // should apply. | ||
| }); | ||
| expect(stdout).toBe(cacheDir); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("loads $XDG_CONFIG_HOME/.bunfig.toml (legacy back-compat)", async () => { | ||
| using home = tempDir("bunfig-xdg-legacy", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const cacheDir = join(homeStr, "xdg-legacy-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".config/.bunfig.toml"), cacheDir); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| XDG_CONFIG_HOME: join(homeStr, ".config"), | ||
| }); | ||
| expect(stdout).toBe(cacheDir); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("loads $HOME/.bunfig.toml when no XDG-base candidate exists", async () => { | ||
| using home = tempDir("bunfig-home-dotfile", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const cacheDir = join(homeStr, "home-dotfile-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".bunfig.toml"), cacheDir); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| // `~/.config/bun/bunfig.toml` (spec default) does not exist here, so | ||
| // we fall through to `$HOME/.bunfig.toml`. | ||
| }); | ||
| expect(stdout).toBe(cacheDir); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("$XDG_CONFIG_HOME/bun/bunfig.toml wins over $XDG_CONFIG_HOME/.bunfig.toml", async () => { | ||
| using home = tempDir("bunfig-xdg-priority", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const winnerCache = join(homeStr, "xdg-winner-cache"); | ||
| const loserCache = join(homeStr, "xdg-loser-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), winnerCache); | ||
| writeBunfigCacheDir(join(homeStr, ".config/.bunfig.toml"), loserCache); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| XDG_CONFIG_HOME: join(homeStr, ".config"), | ||
| }); | ||
| expect(stdout).toBe(winnerCache); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("empty XDG_CONFIG_HOME falls back to $HOME/.config (per XDG spec)", async () => { | ||
| // XDG spec: "If $XDG_CONFIG_HOME is either not set or empty, a default | ||
| // equal to $HOME/.config should be used." A bare `XDG_CONFIG_HOME=""` | ||
| // must be treated as unset, not as an empty-string base. | ||
| using home = tempDir("bunfig-xdg-empty", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const cacheDir = join(homeStr, "empty-xdg-default-cache"); | ||
| writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), cacheDir); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| XDG_CONFIG_HOME: "", | ||
| }); | ||
| expect(stdout).toBe(cacheDir); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("explicit XDG_CONFIG_HOME beats the $HOME/.config spec default", async () => { | ||
| using home = tempDir("bunfig-xdg-override", { "app/package.json": "{}" }); | ||
| const homeStr = String(home); | ||
| const customCache = join(homeStr, "custom-xdg-cache"); | ||
| const defaultCache = join(homeStr, "spec-default-cache"); | ||
| writeBunfigCacheDir(join(homeStr, "custom/bun/bunfig.toml"), customCache); | ||
| writeBunfigCacheDir(join(homeStr, ".config/bun/bunfig.toml"), defaultCache); | ||
|
|
||
| const { stdout, stderr, exitCode } = await runPmCache(join(homeStr, "app"), { | ||
| HOME: homeStr, | ||
| XDG_CONFIG_HOME: join(homeStr, "custom"), | ||
| }); | ||
| expect(stdout).toBe(customCache); | ||
| if (exitCode !== 0) expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code