From 53cd6d916c011d48bfe89f55013b877659549eb5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:13:49 +0000 Subject: [PATCH 1/5] install: stop aborting on link: specifiers and patch paths longer than the path buffers The folder resolver normalized a link: name through a 1024 byte scratch buffer, appended "/package.json" and the NUL terminator to the absolute path without checking that they fit, and copied the name as written into a stack path buffer. The patch hash and apply tasks joined the patchedDependencies path into a path buffer the same way, and the hoisted installer did so for the link target. All of these indexed past the buffer for a long enough value in package.json and aborted the install. The resolver now normalizes into a spill buffer, fails the dependency with ENAMETOOLONG when the package.json path does not fit, and keeps the name on the heap. The patch tasks join into a spill buffer and let the OS reject the path; the stat failure is reported with its errno instead of a warning claiming the file is empty. The hoisted installer fails the package with ENAMETOOLONG when the link target does not fit. FileSystem::normalize was the resolver's only way into the 1024 byte scratch buffer and has no callers left. --- src/install/PackageInstaller.rs | 29 +++- src/install/patch_install.rs | 14 +- src/install/resolvers/folder_resolver.rs | 168 +++++++++++---------- src/resolver/lib.rs | 8 - test/cli/install/bun-install-patch.test.ts | 35 ++++- test/cli/install/bun-install.test.ts | 59 ++++++++ test/cli/install/bun-link.test.ts | 85 ++++++++++- 7 files changed, 293 insertions(+), 105 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 6c96867f593b..3bfb729b7f87 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1568,16 +1568,29 @@ impl<'a> PackageInstaller<'a> { installer.cache_dir = Fd::cwd(); } else { let global_link_dir = package_manager::global_link_dir_path(self.manager_mut()); + let sep_len = (global_link_dir[global_link_dir.len() - 1] != SEP) as usize; + let len = global_link_dir.len() + sep_len + folder.len(); + // `folder` is the `link:` specifier as written in package.json. + if len >= self.folder_path_buf.len() { + Output::err( + "ENAMETOOLONG", + "link path for package {} is too long", + (bstr::BStr::new(pkg_name.slice(string_buf!())),), + ); + self.summary.fail += 1; + self.increment_tree_install_count( + !IS_PENDING_PACKAGE_INSTALL, + self.current_tree_id, + log_level, + ); + return; + } let buf = self.folder_path_buf.as_mut_slice(); - let mut len = 0usize; - buf[len..len + global_link_dir.len()].copy_from_slice(global_link_dir); - len += global_link_dir.len(); - if global_link_dir[global_link_dir.len() - 1] != SEP { - buf[len] = SEP; - len += 1; + buf[..global_link_dir.len()].copy_from_slice(global_link_dir); + if sep_len != 0 { + buf[global_link_dir.len()] = SEP; } - buf[len..len + folder.len()].copy_from_slice(folder); - len += folder.len(); + buf[global_link_dir.len() + sep_len..len].copy_from_slice(folder); buf[len] = 0; // SAFETY: buf[len] == 0 written above installer.cache_dir_subpath = ZStr::from_buf(&self.folder_path_buf, len); diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index c5162af3567d..9dee34dc406a 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -393,9 +393,11 @@ impl PatchTask { let patchfile_path = &patch.patchfilepath; let mut absolute_patchfile_path_buf = PathBuffer::uninit(); + let mut absolute_patchfile_path_spill = Vec::new(); // 1. Parse the patch file - let absolute_patchfile_path = path::resolve_path::join_z_buf::( + let absolute_patchfile_path = path::resolve_path::join_z_buf_spill::( &mut absolute_patchfile_path_buf.0, + &mut absolute_patchfile_path_spill, &[dir, patchfile_path], ); // TODO: can the patch file be anything other than utf-8? @@ -608,9 +610,11 @@ impl PatchTask { let patchfile_path = &calc_hash.patchfile_path; let mut absolute_patchfile_path_buf = PathBuffer::uninit(); + let mut absolute_patchfile_path_spill = Vec::new(); // parse the patch file - let absolute_patchfile_path = path::resolve_path::join_z_buf::( + let absolute_patchfile_path = path::resolve_path::join_z_buf_spill::( &mut absolute_patchfile_path_buf.0, + &mut absolute_patchfile_path_spill, &[dir, patchfile_path], ); @@ -638,12 +642,10 @@ impl PatchTask { ); return None; } - bun_ast::add_warning_pretty!( - log, + log.add_error_fmt( None, Loc::EMPTY, - "patchfile {} is empty, please restore or delete it.", - BStr::new(absolute_patchfile_path.as_bytes()), + format_args!("failed to read patch file: {}", e), ); return None; } diff --git a/src/install/resolvers/folder_resolver.rs b/src/install/resolvers/folder_resolver.rs index d75d0c9c96df..4462fb3bb990 100644 --- a/src/install/resolvers/folder_resolver.rs +++ b/src/install/resolvers/folder_resolver.rs @@ -2,7 +2,7 @@ use core::fmt; use bun_core::fmt::QuotedFormatter; use bun_core::{ZStr, strings}; -use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR}; +use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR, resolve_path}; use bun_resolver::fs::FileSystem; use bun_semver::{self as semver, String as SemverString}; use bun_sys::{self, Fd, File, O}; @@ -48,34 +48,40 @@ impl<'a> fmt::Display for PackageWorkspaceSearchPathFormatter<'a> { )) .unwrap_or(workspace); + let search_path = self.manager.lockfile.str(str_to_use); + // SAFETY: joined[2..] is exactly MAX_PATH_BYTES bytes long. let joined_path: &mut PathBuffer = unsafe { &mut *joined.as_mut_ptr().add(2).cast::() }; - let mut paths = normalize_package_json_path( + let rel: &[u8] = match normalize_package_json_path( GlobalOrRelative::Relative(dependency::version::Tag::Workspace), joined_path, - self.manager.lockfile.str(str_to_use), - ); - - if !strings::starts_with_char(paths.rel, b'.') && !strings::starts_with_char(paths.rel, SEP) - { - joined[0] = b'.'; - joined[1] = SEP; - // `paths.rel` points into `joined[2..]`; extend the view backward - // by the two bytes just written via safe slicing of `joined`. - let n = paths.rel.len() + 2; - paths.rel = &joined[..n]; - } + search_path, + ) { + Some(paths) + if !strings::starts_with_char(paths.rel, b'.') + && !strings::starts_with_char(paths.rel, SEP) => + { + joined[0] = b'.'; + joined[1] = SEP; + // `paths.rel` points into `joined[2..]`; extend the view backward + // by the two bytes just written via safe slicing of `joined`. + &joined[..paths.rel.len() + 2] + } + Some(paths) => paths.rel, + // Too long to be a path; show it as written. + None => search_path, + }; if self.quoted { - let quoted = QuotedFormatter { text: paths.rel }; + let quoted = QuotedFormatter { text: rel }; fmt::Display::fmt("ed, f) } else { // `fmt::Formatter` only accepts `&str`, so non-UTF-8 path bytes are emitted lossily // (U+FFFD) via `bstr::BStr`'s Display. Both current callers pass // `quoted = true`, so this branch is unreached today; if a future // caller needs byte-exact output it must use an `io::Write` sink. - write!(f, "{}", bstr::BStr::new(paths.rel)) + write!(f, "{}", bstr::BStr::new(rel)) } } } @@ -92,10 +98,6 @@ pub struct Entry { // bun_collections::HashMap currently ignores the context/load-factor // type params (backed by std HashMap); identity hashing is a TODO(perf). -fn normalize(path: &[u8]) -> &[u8] { - FileSystem::instance().normalize(path) -} - pub(crate) fn hash(normalized_path: &[u8]) -> u64 { bun_wyhash::hash(normalized_path) } @@ -177,84 +179,82 @@ struct Paths<'a> { rel: &'a [u8], } +/// Returns `None` when the `package.json` path does not fit `joined`. +/// `non_normalized_path` is taken from the user's package.json, so it can be +/// arbitrarily long. fn normalize_package_json_path<'a>( global_or_relative: GlobalOrRelative<'_>, joined: &'a mut PathBuffer, non_normalized_path: &[u8], -) -> Paths<'a> { - let abs: &[u8]; - +) -> Option> { + let mut normalize_spill = Vec::new(); // We consider it valid if there is a package.json in the folder - let normalized: &[u8] = if non_normalized_path.len() == 1 && non_normalized_path[0] == b'.' { + let normalized: &[u8] = if non_normalized_path == b"." { non_normalized_path } else if bun_paths::is_absolute(non_normalized_path) { strings::trim_right(non_normalized_path, SEP_STR.as_bytes()) } else { - strings::trim_right(normalize(non_normalized_path), SEP_STR.as_bytes()) + strings::trim_right( + resolve_path::normalize_string_spill::( + &mut normalize_spill, + non_normalized_path, + ), + SEP_STR.as_bytes(), + ) }; const PACKAGE_JSON_LEN: usize = "/package.json".len(); - let rel: &[u8] = if strings::starts_with_char(normalized, b'.') { - let mut tempcat = PathBuffer::uninit(); - - tempcat[..normalized.len()].copy_from_slice(normalized); - tempcat[normalized.len()] = SEP; - tempcat[normalized.len() + 1..normalized.len() + PACKAGE_JSON_LEN] - .copy_from_slice(b"package.json"); - let parts: [&[u8]; 2] = [ - FileSystem::instance().top_level_dir(), - &tempcat[0..normalized.len() + PACKAGE_JSON_LEN], - ]; - abs = FileSystem::instance().abs_buf(&parts, joined); - FileSystem::instance().relative( - FileSystem::instance().top_level_dir(), - &abs[0..abs.len() - PACKAGE_JSON_LEN], - ) + // The last byte of `joined` is reserved for the NUL terminator. + let capacity = joined.len() - 1; + + let abs_len = if strings::starts_with_char(normalized, b'.') { + let parts: [&[u8]; 2] = [normalized, b"package.json"]; + FileSystem::instance() + .abs_buf_checked(&parts, &mut joined[..capacity])? + .len() } else { - let joined_len = joined.len(); - let mut remain: &mut [u8] = &mut joined[..]; - match &global_or_relative { - GlobalOrRelative::Global(path) | GlobalOrRelative::CacheFolder(path) => { - if !path.is_empty() { - let offset = path - .len() - .saturating_sub((path[path.len().saturating_sub(1)] == SEP) as usize); - if offset > 0 { - remain[0..offset].copy_from_slice(&path[0..offset]); - } - remain = &mut remain[offset..]; - if !normalized.is_empty() { - if (path[path.len() - 1] != SEP) && (normalized[0] != SEP) { - remain[0] = SEP; - remain = &mut remain[1..]; - } - } - } + let (prefix, needs_sep): (&[u8], bool) = match global_or_relative { + GlobalOrRelative::Global(path) | GlobalOrRelative::CacheFolder(path) + if !path.is_empty() => + { + let ends_with_sep = path[path.len() - 1] == SEP; + ( + &path[..path.len() - ends_with_sep as usize], + !normalized.is_empty() && !ends_with_sep && normalized[0] != SEP, + ) } - GlobalOrRelative::Relative(_) => {} + _ => (b"", false), + }; + let abs_len = prefix.len() + needs_sep as usize + normalized.len() + PACKAGE_JSON_LEN; + if abs_len > capacity { + return None; } - remain[..normalized.len()].copy_from_slice(normalized); - remain[normalized.len()] = SEP; - remain[normalized.len() + 1..normalized.len() + PACKAGE_JSON_LEN] - .copy_from_slice(b"package.json"); - let remain_after = remain.len() - (normalized.len() + PACKAGE_JSON_LEN); - // Compute abs len from remaining capacity. - let abs_len = joined_len - remain_after; - abs = &joined[0..abs_len]; - // We store the folder name without package.json - FileSystem::instance().relative( - FileSystem::instance().top_level_dir(), - &abs[0..abs.len() - PACKAGE_JSON_LEN], - ) + + let mut len = prefix.len(); + joined[..len].copy_from_slice(prefix); + if needs_sep { + joined[len] = SEP; + len += 1; + } + joined[len..len + normalized.len()].copy_from_slice(normalized); + len += normalized.len(); + joined[len] = SEP; + joined[len + 1..len + PACKAGE_JSON_LEN].copy_from_slice(b"package.json"); + abs_len }; - let abs_len = abs.len(); + + // We store the folder name without package.json + let rel = FileSystem::instance().relative( + FileSystem::instance().top_level_dir(), + &joined[..abs_len - PACKAGE_JSON_LEN], + ); joined[abs_len] = 0; - Paths { + Some(Paths { abs: ZStr::from_buf(joined, abs_len), rel, - } + }) } fn read_package_json_from_disk( @@ -386,7 +386,11 @@ pub(crate) fn get_or_put( let mut joined = PathBuffer::uninit(); #[cfg(windows)] let mut rel_buf = PathBuffer::uninit(); - let paths = normalize_package_json_path(global_or_relative, &mut joined, non_normalized_path); + let Some(paths) = + normalize_package_json_path(global_or_relative, &mut joined, non_normalized_path) + else { + return FolderResolution::Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)); + }; #[cfg(not(windows))] let abs = paths.abs; @@ -435,10 +439,12 @@ pub(crate) fn get_or_put( let result: crate::Result = match global_or_relative { GlobalOrRelative::Global(_) => 'global: { - let mut path = PathBuffer::uninit(); - path[..non_normalized_path.len()].copy_from_slice(non_normalized_path); + // `non_normalized_path` may point into the lockfile's string buffer, + // which `read_package_json_from_disk` grows before the resolver + // copies `folder_path` into it. + let folder_path: Box<[u8]> = Box::from(non_normalized_path); let mut resolver: SymlinkResolver = NewResolver { - folder_path: &path[0..non_normalized_path.len()], + folder_path: &folder_path, }; break 'global read_package_json_from_disk( manager, diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 208c1678127e..b9ef626b8bd1 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -427,14 +427,6 @@ pub mod fs { } } - /// Normalizes `str` in the shared scratch space, returning the input - /// unchanged when already normalized. - #[inline] - pub fn normalize<'a>(&self, str: &'a [u8]) -> &'a [u8] { - use bun_paths::resolve_path::{normalize_string, platform}; - normalize_string::(str) - } - /// The process-global directory-name interning store. #[inline] pub fn dirname_store(&self) -> &'static DirnameStore { diff --git a/test/cli/install/bun-install-patch.test.ts b/test/cli/install/bun-install-patch.test.ts index 663a31dd7eb3..c5ce35c9be8c 100644 --- a/test/cli/install/bun-install-patch.test.ts +++ b/test/cli/install/bun-install-patch.test.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; import { describe, expect, it, setDefaultTimeout, test } from "bun:test"; import { rmSync } from "fs"; -import { bunEnv, bunExe, normalizeBunSnapshot as normalizeBunSnapshot_, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot as normalizeBunSnapshot_, tempDir } from "harness"; import { join } from "path"; const normalizeBunSnapshot = (str: string) => { @@ -1119,3 +1119,36 @@ describe("patchedDependencies contents_hash", () => { expect({ hasB: mB.includes("TAIL_BBBB"), hasA: mB.includes("TAIL_AAAA") }).toEqual({ hasB: true, hasA: false }); }); }); + +describe("patchedDependencies path longer than the path buffer", () => { + // Hashing a patch joins its path onto the project directory in a path buffer + // (4096 bytes on Linux, 1024 on macOS, ~96 KiB on Windows). The join used to + // write past the buffer for a path that did not fit, aborting the install. + const longName = Buffer.alloc(100_000, "p").toString(); + + test("install reports the path instead of crashing", async () => { + using dir = tempDir("patch-path-too-long", { + "package.json": JSON.stringify({ + name: "patch-path-too-long", + patchedDependencies: { "is-odd@3.0.1": `patches/${longName}.patch` }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // The path is handed to the OS as is. POSIX rejects anything longer than + // PATH_MAX with ENAMETOOLONG; Windows may report it as missing instead. + if (!isWindows) { + expect(stderr).toContain("error: failed to read patch file: ENAMETOOLONG: "); + } + expect(stderr).toContain(`${longName}.patch`); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 583fceefe6c7..b17d9ab8205a 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -6,6 +6,7 @@ import { bunEnv, bunExe, bunEnv as env, + isLinux, isWindows, joinP, normalizeBunSnapshot, @@ -10082,6 +10083,64 @@ it("does not install transitive file: dependencies with overlong folder targets" expect(exitCode).toBe(1); }); +// Resolving a folder dependency appends "/package.json" and a NUL to its absolute path in a +// path buffer (4096 bytes on Linux, 1024 on macOS). The absolute path itself is checked +// against the buffer when package.json is parsed, but the appended bytes were not, so a +// folder path within 13 bytes of the buffer size aborted the install. Windows' buffer is +// larger than any path the OS accepts, so the boundary cannot be reached there. +describe.skipIf(isWindows)("file: dependency whose package.json path is around the path buffer size", () => { + const PATH_BUFFER_BYTES = isLinux ? 4096 : 1024; + + // A relative path of exactly `bytes` bytes made of one letter directory names, so that a + // path which fits the buffer is rejected by the OS as missing, not as too long. + function pathOfLength(bytes: number) { + const tail = bytes % 2 === 0 ? "dd" : "d"; + return Buffer.alloc(bytes - tail.length, "d/").toString() + tail; + } + + // `packageJsonPathBytes` is the length of `//package.json`. + async function installFolderDependency(projectDir: string, packageJsonPathBytes: number) { + const folder = pathOfLength( + packageJsonPathBytes - Buffer.byteLength(projectDir) - "/".length - "/package.json".length, + ); + await writeFile( + join(projectDir, "package.json"), + JSON.stringify({ name: "my-app", dependencies: { dep: `file:./${folder}` } }), + ); + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { folder, err, exitCode }; + } + + it("is looked up on disk when the path and its NUL terminator fit", async () => { + using dir = tempDir("file-dep-path-buffer-fits", {}); + + const { folder, err, exitCode } = await installFolderDependency(String(dir), PATH_BUFFER_BYTES - 1); + + expect(err).toContain(`error: Could not find package.json for "file:${folder}"`); + expect(exitCode).toBe(1); + }); + + it.each([ + ["exactly the buffer size, leaving no room for the NUL terminator", 0], + // The folder path alone fits; "/package.json" is what does not. + ['longer than the buffer by less than the appended "/package.json"', 8], + ])("fails with ENAMETOOLONG when it is %s", async (_, extraBytes) => { + using dir = tempDir("file-dep-path-buffer-overflow", {}); + + const { err, exitCode } = await installFolderDependency(String(dir), PATH_BUFFER_BYTES + extraBytes); + + expect(err).toContain("error: ENAMETOOLONG"); + expect(exitCode).toBe(1); + }); +}); + for (const field of ["resolutions", "overrides"]) { it(`installs a file: dependency pointing outside the project when it came from root package.json "${field}"`, async () => { // `overrides` / `resolutions` can only be declared in the root package.json, diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..5bbb10612f8b 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -1,5 +1,5 @@ import { file, spawn } from "bun"; -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { access, mkdir, writeFile } from "fs/promises"; import { bunExe, @@ -471,3 +471,86 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +// A `link:` target is normalized and joined onto the global link directory in +// fixed-size buffers (the normalizer's is 1024 bytes, the others are a path +// buffer). These used to be written without a length check, so a long enough +// specifier aborted `bun install` instead of failing the dependency. +describe("link: specifier longer than the path buffers", () => { + // Longer than every buffer on every platform (the Windows path buffer is ~96 KiB). + const LONG_SPEC_BYTES = 100_000; + + async function run(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, exitCode }; + } + + it("fails to resolve a name that does not fit", async () => { + const target = Buffer.alloc(LONG_SPEC_BYTES, "n").toString(); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: `link:${target}` } }), + ); + + const { err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain("error: ENAMETOOLONG"); + expect(err).toContain("error: bar@link:" + target.slice(0, 64)); + expect(exitCode).toBe(1); + }); + + // `x/../` segments normalize away, so this resolves to the linked package, but + // the specifier itself (which is what gets linked) is still too long for a path. + it("fails to install a linked package whose specifier only fits once normalized", async () => { + const link_name = basename(link_dir).slice("bun-link.".length); + await writeFile(join(link_dir, "package.json"), JSON.stringify({ name: link_name, version: "0.0.1" })); + const registered = await run(link_dir, "link"); + expect(registered.err).toBe(""); + expect(registered.out).toContain(`Success! Registered "${link_name}"`); + expect(registered.exitCode).toBe(0); + + try { + const target = Buffer.alloc(LONG_SPEC_BYTES, "x/../").toString() + link_name; + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { [link_name]: `link:${target}` } }), + ); + + const { out, err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain(`ENAMETOOLONG: link path for package ${link_name} is too long`); + expect(out).toContain("Failed to install 1 package"); + expect(await file(join(package_dir, "node_modules", link_name, "package.json")).exists()).toBe(false); + expect(exitCode).toBe(1); + } finally { + await run(link_dir, "unlink"); + } + }); + + it("still resolves a specifier that normalizes to a linked package", async () => { + const link_name = basename(link_dir).slice("bun-link.".length); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { [link_name]: "link:" + Buffer.alloc(LONG_SPEC_BYTES, "x/../").toString() + link_name }, + }), + ); + + // Nothing is registered under this name: resolution gets as far as looking + // the name up in the link directory, like `link:${link_name}` would. + const { err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain(`error: Package "${link_name}" is not linked`); + expect(err).not.toContain("ENAMETOOLONG"); + expect(exitCode).toBe(1); + }); +}); From 31dd068c2e53f07d4919247f7b2cf99c2f6d38d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 04:49:25 +0000 Subject: [PATCH 2/5] install: respect --silent when a link target does not fit the path buffer --- src/install/PackageInstaller.rs | 12 +++++++----- test/cli/install/bun-link.test.ts | 3 +++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 3bfb729b7f87..7fb7f1ce2c2e 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1572,11 +1572,13 @@ impl<'a> PackageInstaller<'a> { let len = global_link_dir.len() + sep_len + folder.len(); // `folder` is the `link:` specifier as written in package.json. if len >= self.folder_path_buf.len() { - Output::err( - "ENAMETOOLONG", - "link path for package {} is too long", - (bstr::BStr::new(pkg_name.slice(string_buf!())),), - ); + if log_level != Options::LogLevel::Silent { + Output::err( + "ENAMETOOLONG", + "link path for package {} is too long", + (bstr::BStr::new(pkg_name.slice(string_buf!())),), + ); + } self.summary.fail += 1; self.increment_tree_install_count( !IS_PENDING_PACKAGE_INSTALL, diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 5bbb10612f8b..0863bf41d813 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -529,6 +529,9 @@ describe("link: specifier longer than the path buffers", () => { expect(out).toContain("Failed to install 1 package"); expect(await file(join(package_dir, "node_modules", link_name, "package.json")).exists()).toBe(false); expect(exitCode).toBe(1); + + // Like the other per-package failures, the error respects --silent. + expect(await run(package_dir, "install", "--silent")).toEqual({ out: "", err: "", exitCode: 1 }); } finally { await run(link_dir, "unlink"); } From d7b2a8b59a9b085a1056c7492d121a27fbaa8a66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:00:17 +0000 Subject: [PATCH 3/5] install: build the workspace search path message without the in-place prefix --- src/install/resolvers/folder_resolver.rs | 26 +++++++++--------------- test/cli/install/bun-workspaces.test.ts | 1 + 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/install/resolvers/folder_resolver.rs b/src/install/resolvers/folder_resolver.rs index 4462fb3bb990..d3c11166a7bb 100644 --- a/src/install/resolvers/folder_resolver.rs +++ b/src/install/resolvers/folder_resolver.rs @@ -2,7 +2,7 @@ use core::fmt; use bun_core::fmt::QuotedFormatter; use bun_core::{ZStr, strings}; -use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR, resolve_path}; +use bun_paths::{self, PathBuffer, SEP, SEP_STR, resolve_path}; use bun_resolver::fs::FileSystem; use bun_semver::{self as semver, String as SemverString}; use bun_sys::{self, Fd, File, O}; @@ -35,7 +35,6 @@ pub(crate) struct PackageWorkspaceSearchPathFormatter<'a> { impl<'a> fmt::Display for PackageWorkspaceSearchPathFormatter<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut joined = [0u8; MAX_PATH_BYTES + 2]; // Caller constructs this formatter only when // `self.version.tag == .workspace`. let workspace = self.version.workspace(); @@ -50,23 +49,21 @@ impl<'a> fmt::Display for PackageWorkspaceSearchPathFormatter<'a> { let search_path = self.manager.lockfile.str(str_to_use); - // SAFETY: joined[2..] is exactly MAX_PATH_BYTES bytes long. - let joined_path: &mut PathBuffer = - unsafe { &mut *joined.as_mut_ptr().add(2).cast::() }; + let mut joined = PathBuffer::uninit(); + let mut dot_slash_rel = Vec::new(); let rel: &[u8] = match normalize_package_json_path( GlobalOrRelative::Relative(dependency::version::Tag::Workspace), - joined_path, + &mut joined, search_path, ) { Some(paths) if !strings::starts_with_char(paths.rel, b'.') && !strings::starts_with_char(paths.rel, SEP) => { - joined[0] = b'.'; - joined[1] = SEP; - // `paths.rel` points into `joined[2..]`; extend the view backward - // by the two bytes just written via safe slicing of `joined`. - &joined[..paths.rel.len() + 2] + dot_slash_rel.push(b'.'); + dot_slash_rel.push(SEP); + dot_slash_rel.extend_from_slice(paths.rel); + dot_slash_rel.as_slice() } Some(paths) => paths.rel, // Too long to be a path; show it as written. @@ -180,8 +177,6 @@ struct Paths<'a> { } /// Returns `None` when the `package.json` path does not fit `joined`. -/// `non_normalized_path` is taken from the user's package.json, so it can be -/// arbitrarily long. fn normalize_package_json_path<'a>( global_or_relative: GlobalOrRelative<'_>, joined: &'a mut PathBuffer, @@ -439,9 +434,8 @@ pub(crate) fn get_or_put( let result: crate::Result = match global_or_relative { GlobalOrRelative::Global(_) => 'global: { - // `non_normalized_path` may point into the lockfile's string buffer, - // which `read_package_json_from_disk` grows before the resolver - // copies `folder_path` into it. + // Copied because reading the package.json may reallocate the lockfile + // string buffer `non_normalized_path` points into. let folder_path: Box<[u8]> = Box::from(non_normalized_path); let mut resolver: SymlinkResolver = NewResolver { folder_path: &folder_path, diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 48903d67ec9f..86646551ee70 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -591,6 +591,7 @@ describe("workspace aliases", async () => { const err = await stderr.text(); if (version === "workspace:@org/b") { expect(err).toContain('Workspace dependency "a1" not found'); + expect(err).toMatch(/Searched in "\.[\\/]packages[\\/]pkg1[\\/]@org[\\/]b"/); } else { expect(err).toContain(`No matching version for workspace dependency "a1". Version: "${version}"`); } From 7aeeccd8e83b5961fecf93fe93443bb6ac776e20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:02:17 +0000 Subject: [PATCH 4/5] install: shorten the link name copy comment --- src/install/resolvers/folder_resolver.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/install/resolvers/folder_resolver.rs b/src/install/resolvers/folder_resolver.rs index d3c11166a7bb..e81504332ad2 100644 --- a/src/install/resolvers/folder_resolver.rs +++ b/src/install/resolvers/folder_resolver.rs @@ -434,8 +434,7 @@ pub(crate) fn get_or_put( let result: crate::Result = match global_or_relative { GlobalOrRelative::Global(_) => 'global: { - // Copied because reading the package.json may reallocate the lockfile - // string buffer `non_normalized_path` points into. + // `non_normalized_path` may alias the lockfile string buffer, which grows below. let folder_path: Box<[u8]> = Box::from(non_normalized_path); let mut resolver: SymlinkResolver = NewResolver { folder_path: &folder_path, From e908c01f36250b9849d94839a645fc8db879b2f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 05:09:50 +0000 Subject: [PATCH 5/5] test(install): drain stdout in the path buffer tests --- test/cli/install/bun-install-patch.test.ts | 2 +- test/cli/install/bun-install.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli/install/bun-install-patch.test.ts b/test/cli/install/bun-install-patch.test.ts index c5ce35c9be8c..b79de709cf38 100644 --- a/test/cli/install/bun-install-patch.test.ts +++ b/test/cli/install/bun-install-patch.test.ts @@ -1141,7 +1141,7 @@ describe("patchedDependencies path longer than the path buffer", () => { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // The path is handed to the OS as is. POSIX rejects anything longer than // PATH_MAX with ENAMETOOLONG; Windows may report it as missing instead. diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index b17d9ab8205a..d2cafd726c20 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -10114,7 +10114,7 @@ describe.skipIf(isWindows)("file: dependency whose package.json path is around t stderr: "pipe", env, }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { folder, err, exitCode }; }