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 () => {