diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs
index 3aad03b5f197..f0e0baaf8f3c 100644
--- a/src/install/PackageManager.rs
+++ b/src/install/PackageManager.rs
@@ -21,7 +21,7 @@ use bun_event_loop::{self, AnyEventLoop, EventLoopHandle};
use bun_http as http;
use bun_ini as ini;
use bun_paths::resolve_path::{self, PosixToWinNormalizer, platform};
-use bun_paths::{DELIMITER, PathBuffer, SEP, SEP_STR};
+use bun_paths::{DELIMITER, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR};
use bun_semver as Semver;
use bun_sys::{self, Fd};
use bun_threading::{ThreadPool, UnboundedQueue, thread_pool};
@@ -1434,6 +1434,22 @@ pub(crate) fn get() -> *mut PackageManager {
// init
// ──────────────────────────────────────────────────────────────────────────
+/// `
/.npmrc` for the user-level `.npmrc` candidates in [`init`]. `dir` is
+/// `$XDG_CONFIG_HOME` or `$HOME`, so it can be longer than `buf`; a path that
+/// does not fit could not be opened either, and `None` makes the caller treat
+/// it like a missing file.
+fn user_npmrc_path<'a>(dir: &[u8], buf: &'a mut PathBuffer) -> Option<&'a ZStr> {
+ // The last byte is reserved for the NUL terminator.
+ let len = resolve_path::join_abs_string_buf_checked::(
+ dir,
+ &mut buf[..MAX_PATH_BYTES - 1],
+ &[b".npmrc"],
+ )?
+ .len();
+ buf[len] = 0;
+ Some(ZStr::from_buf(&buf[..], len))
+}
+
/// Returns `&'static mut PackageManager` — the process-singleton (held in
/// `holder::RAW_PTR`) is leaked for the process lifetime and `init()` is called
/// exactly once on the single CLI dispatch thread. Every
@@ -1877,24 +1893,18 @@ pub fn init(
let npmrc_local = ZBox::from_bytes(b".npmrc");
let mut buf = PathBuffer::uninit();
- let parts = [b"./.npmrc" as &[u8]];
// npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep
// `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists.
let mut global_len: usize = 0;
if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() {
- let p =
- resolve_path::join_abs_string_buf_z::(xdg_dir, &mut buf, &parts);
- if bun_sys::exists_z(p) {
- global_len = p.len();
- }
+ global_len = user_npmrc_path(xdg_dir, &mut buf)
+ .filter(|p| bun_sys::exists_z(p))
+ .map_or(0, ZStr::len);
}
if global_len == 0 {
if let Some(home_dir) = bun_core::env_var::HOME.get_not_empty() {
- global_len = resolve_path::join_abs_string_buf_z::(
- home_dir, &mut buf, &parts,
- )
- .len();
+ global_len = user_npmrc_path(home_dir, &mut buf).map_or(0, ZStr::len);
}
}
diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts
index 4360eca00eab..5718509855f6 100644
--- a/test/cli/install/npmrc.test.ts
+++ b/test/cli/install/npmrc.test.ts
@@ -1,7 +1,8 @@
import { write } from "bun";
import { afterAll, beforeAll, describe, expect, it, test } from "bun:test";
+import { mkdirSync, writeFileSync } from "fs";
import { rm } from "fs/promises";
-import { VerdaccioRegistry, bunExe, bunEnv as env, tempDir } from "harness";
+import { VerdaccioRegistry, bunExe, bunEnv as env, isLinux, isWindows, tempDir } from "harness";
import { join } from "path";
const { iniInternals } = require("bun:internal-for-testing");
const { loadNpmrc } = iniInternals;
@@ -222,6 +223,53 @@ registry = http://localhost:${registry.port}/
const result = await publishDryRun(String(dir), { XDG_CONFIG_HOME: "" });
expect(result).toEqual(usesRegistry(1));
});
+
+ // The candidate paths are built in a buffer of MAX_PATH_BYTES (4096 on Linux, 1024 on the
+ // other POSIX platforms; on Windows it is larger than any environment variable can be), so
+ // the longest directory whose "/.npmrc" and NUL terminator still fit is MAX_PATH_BYTES - 8
+ // bytes. A longer directory cannot contain an openable .npmrc and counts as having none.
+ describe.skipIf(isWindows)("$HOME longer than the path buffer", () => {
+ const MAX_PATH_BYTES = isLinux ? 4096 : 1024;
+ const LONGEST_HOME = MAX_PATH_BYTES - 8;
+
+ // xdg/ has no .npmrc, so the lookup falls through to $HOME. The global bunfig.toml lookup
+ // starts at $XDG_CONFIG_HOME too; the bunfig there keeps it away from the oversized $HOME,
+ // which is not what is under test here.
+ const xdg = { "xdg/.bunfig.toml": "" };
+ const envWith = (dir: string, home: string) => ({ XDG_CONFIG_HOME: join(dir, "xdg"), HOME: home });
+
+ // An absolute path of exactly `length` bytes. Nothing exists there.
+ const missingHome = (length: number) => "/" + Buffer.alloc(length - 1, "a").toString();
+
+ it.concurrent("reads $HOME/.npmrc from the longest $HOME that fits", async () => {
+ using dir = tempDir("npmrc-home-longest", { ...pkg, ...xdg });
+ // Pad `/home/` out to exactly LONGEST_HOME bytes with nested directories of 200
+ // bytes each (NAME_MAX is 255); the last byte is never a separator.
+ const prefix = join(String(dir), "home") + "/";
+ const padding = Buffer.alloc(LONGEST_HOME - Buffer.byteLength(prefix), "a");
+ for (let i = 200; i < padding.length - 1; i += 201) padding[i] = "/".charCodeAt(0);
+ const home = prefix + padding.toString();
+ expect(Buffer.byteLength(home)).toBe(LONGEST_HOME);
+ mkdirSync(home, { recursive: true });
+ writeFileSync(join(home, ".npmrc"), npmrc(1));
+
+ const result = await publishDryRun(String(dir), envWith(String(dir), home));
+ expect(result).toEqual(usesRegistry(1));
+ });
+
+ // The project .npmrc is the only one left, and shows the command still ran normally.
+ it.concurrent("skips a $HOME one byte longer than fits", async () => {
+ using dir = tempDir("npmrc-home-one-too-long", { ...pkg, ...xdg, "pkg/.npmrc": npmrc(3) });
+ const result = await publishDryRun(String(dir), envWith(String(dir), missingHome(LONGEST_HOME + 1)));
+ expect(result).toEqual(usesRegistry(3));
+ });
+
+ it.concurrent("skips a $HOME longer than the whole buffer", async () => {
+ using dir = tempDir("npmrc-home-too-long", { ...pkg, ...xdg, "pkg/.npmrc": npmrc(3) });
+ const result = await publishDryRun(String(dir), envWith(String(dir), missingHome(MAX_PATH_BYTES + 1000)));
+ expect(result).toEqual(usesRegistry(3));
+ });
+ });
});
it("package config overrides home config", async () => {