install: do not abort when $XDG_CONFIG_HOME or $HOME is too long for the .npmrc path buffer

Closed
RO
2mo ago
+70-12

Problem

  • bun install, bun pm ..., bun publish and the other commands that go through PackageManager::init abort at startup when $XDG_CONFIG_HOME (or, when that directory has no .npmrc, $HOME) is MAX_PATH_BYTES - 7 bytes or longer (4089 on Linux, 1017 on macOS):
    • exactly MAX_PATH_BYTES - 7 bytes: panic: index out of bounds: the len is 4096 but the index is 4096
    • longer: panic: range end index 5000 out of range for slice of length 4095, top frame bun_paths::resolve_path::normalize_string_generic_tz (src/paths/resolve_path.rs:1071), called from bun_install::package_manager_real::init
  • Cause: src/install/PackageManager.rs:1887 and :1894 join the environment value and .npmrc into a stack PathBuffer with the unchecked join_abs_string_buf_z. It normalizes into the buffer without a size check and then writes the NUL terminator at result.len(), so a result of exactly MAX_PATH_BYTES - 1 bytes fails on the NUL write and anything longer overflows inside the normalization.
  • Reproduced on the release build of main (XDG_CONFIG_HOME=<short dir without .npmrc> HOME=/aaaa...<5000 bytes> bun pm cache in any directory with a package.json, exit 134). The values are environment input, so this should never be a crash.

Fix

  • New user_npmrc_path(dir, buf) builds dir/.npmrc with join_abs_string_buf_checked into buf[..MAX_PATH_BYTES - 1], writes the NUL itself, and returns None when the result does not fit. Both candidates ($XDG_CONFIG_HOME, then $HOME) use it; None leaves global_len at 0, which is the existing "no user-level .npmrc" path.
  • Why treating it as absent is right: a path that does not fit is MAX_PATH_BYTES bytes or longer, which open() rejects with ENAMETOOLONG (bun_sys::openat_a checks the same >= MAX_PATH_BYTES bound itself), and load_npmrc_config already skips user-level candidates that fail to open (src/ini/lib.rs:1232). So the outcome is exactly what a larger buffer would have produced, and no .npmrc that could be read before is skipped now: the longest directory that fits, MAX_PATH_BYTES - 8 bytes, still loads, and its .npmrc path is the longest one open() accepts.
  • The parts literal changes from ./.npmrc to .npmrc; the join normalizes both to the same path.
  • Verified with three new cases in test/cli/install/npmrc.test.ts ("user .npmrc lookup > $HOME longer than the path buffer"): the longest $HOME that fits (built as a real directory of exactly MAX_PATH_BYTES - 8 bytes) still has its .npmrc honored, $HOME one byte longer is skipped, $HOME longer than the whole buffer is skipped. On the unfixed build the last two fail with the two panics above; with the fix all 34 tests in the file pass. Skipped on Windows, where the buffer (~96 KiB) is longer than any environment value.
  • The tests drive the $HOME candidate (with $XDG_CONFIG_HOME pointing at a short directory) because the global .bunfig.toml lookup in src/bunfig/arguments.rs reads $XDG_CONFIG_HOME (or $HOME when it is unset) before this code with the same unchecked join and a longer file name, so an oversized value there still aborts earlier; #38370 fixes that site. Both candidates here share user_npmrc_path, and the existing lookup tests in the same describe cover the $XDG_CONFIG_HOME candidate when the path fits.
  • cargo clippy -p bun_install and rustfmt --check are clean.

Background

  • PathBuffer is bun's fixed stack buffer for path syscalls, MAX_PATH_BYTES long (the platform PATH_MAX: 4096 on Linux, 1024 on macOS). Paths in it are NUL-terminated, so the longest path it can hold is MAX_PATH_BYTES - 1 bytes, which is also the longest path open() accepts.
  • resolve_path::join_abs_string_buf_z(cwd, buf, parts) is path.resolve into a caller buffer plus a NUL terminator; it assumes the result fits. join_abs_string_buf_checked is the variant for input of unbounded length: it normalizes first and returns None instead of writing when the result is longer than buf, so it has to be given a buffer one byte short of the NUL slot the caller fills in.
  • User-level .npmrc lookup: PackageManager::init uses $XDG_CONFIG_HOME/.npmrc when that file exists, otherwise $HOME/.npmrc (#36289), and passes the result together with the project .npmrc to load_npmrc_config, which ignores candidates it cannot read.