diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e1524675ffb9..b948f59913ec 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -389,6 +389,24 @@ pub struct PackageManager { pub global_dir: Option, pub(crate) global_link_dir_path: Box<[u8]>, + /// Names of packages registered via `bun link`, read from + /// `/` once per install. Populated on the main thread + /// before any install worker starts; after that it's read-only and can + /// be accessed lock-free from worker threads. Lookups return early + /// when the set is empty (no active links) — the common case on dev + /// machines without `bun link` configured, and unconditional on CI. + /// See `populate_linked_names_cache` / `linked_package_path`. + pub linked_names: bun_collections::StringHashMap<()>, + pub linked_names_populated: bool, + /// Windows-only fast-path companion to `linked_names`: on Windows the + /// readdir entry is WTF-16 so we can't key it into the UTF-8 hashmap, + /// but we *can* record whether the global link dir is nonempty during + /// the same readdir pass. `linked_package_path` consults this to skip + /// the per-call `GetFileAttributesW` when no links exist — restoring + /// the "zero syscalls when no packages are linked" guarantee that the + /// POSIX fast path in `linked_names` provides. + pub linked_names_any_on_windows: bool, + pub(crate) on_wake: WakeHandler, pub(crate) peer_dependencies: LinearFifo>, @@ -2102,6 +2120,12 @@ pub fn init( wr!(global_link_dir, None); wr!(global_dir, None); wr!(global_link_dir_path, Box::default()); + wr!( + linked_names, + bun_collections::StringHashMap::<()>::default() + ); + wr!(linked_names_populated, false); + wr!(linked_names_any_on_windows, false); wr!(on_wake, WakeHandler::default()); wr!( peer_dependencies, @@ -2546,6 +2570,12 @@ fn init_with_runtime_once( wr!(global_link_dir, None); wr!(global_dir, None); wr!(global_link_dir_path, Box::default()); + wr!( + linked_names, + bun_collections::StringHashMap::<()>::default() + ); + wr!(linked_names_populated, false); + wr!(linked_names_any_on_windows, false); wr!(on_wake, WakeHandler::default()); wr!( peer_dependencies, diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 4861484c4b21..0cc35cd8a08d 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -6,6 +6,8 @@ use crate::Error; use crate::bun_fs::FileSystem; use crate::lockfile_real::package::PackageColumns; use crate::repository::Repository; +#[cfg(not(windows))] +use bun_core::UnwrapOrOom; use bun_core::ZStr; use bun_core::{Global, Output, ZBox, env_var, fmt as bun_fmt}; use bun_dotenv::Loader as DotEnvLoader; @@ -16,6 +18,7 @@ use bun_paths::{self as path, AbsPath, PathBuffer, SEP}; use bun_semver::{self as Semver, String as SemverString}; #[cfg(windows)] use bun_sys::FdDirExt; +use bun_sys::FdExt as _; use bun_sys::{self as sys, Dir, Fd, File}; use crate::bun_progress::Node as ProgressNode; @@ -852,6 +855,487 @@ pub fn global_link_dir_path(this: &mut PackageManager) -> &[u8] { &this.global_link_dir_path } +/// Copy a dirent name into `buf` with a trailing NUL so it can be passed +/// to `*at` syscalls. POSIX caps `d_name` at 255 bytes; returns `None` +/// for anything longer (cannot be a valid link registration). +#[cfg(not(windows))] +fn name_zstr<'a>(buf: &'a mut [u8; 256], name: &[u8]) -> Option<&'a bun_core::ZStr> { + if name.len() >= buf.len() { + return None; + } + buf[..name.len()].copy_from_slice(name); + buf[name.len()] = 0; + Some(bun_core::ZStr::from_slice_with_nul(&buf[..name.len() + 1])) +} + +/// True when the directory behind `fd` resolves inside `root`. The global +/// link dir is shared with `bun add -g`, and a global install run with the +/// isolated linker drops `/node_modules/` as a symlink into +/// the global dir's own `.bun//` store — following it and finding +/// a directory is not enough to call it a `bun link` registration. A real +/// registration always points at a producer tree outside the link dir, so +/// reject targets that resolve back inside it (treating an unresolvable +/// target as inside, the benign direction: a skipped link falls back to the +/// registry, a misclassified global install substitutes versions). +fn resolves_inside(fd: Fd, root: &[u8]) -> bool { + let mut buf = PathBuffer::uninit(); + match sys::get_fd_path(fd, &mut buf) { + Ok(p) => p.starts_with(root) && (p.len() == root.len() || p[root.len()] == SEP), + Err(_) => true, + } +} + +/// `resolves_inside` checks the fully-resolved path, which is not enough +/// with `globalStore` enabled: the global install's store entry is then an +/// absolute symlink into `/links/`, so the top-level entry's chain +/// resolves outside the link dir even though it is a store entry. The +/// *immediate* target discriminates in every mode: `bun link` writes a +/// single absolute symlink to the producer tree (link_command.rs), while +/// the isolated linker's top-level entries are relative `.bun/...` +/// symlinks on POSIX (`Symlinker.target` is `RelPath`) and +/// absolute-inside-the-link-dir junctions on Windows. +fn link_target_is_outside(target: &[u8], link_dir_root: &[u8]) -> bool { + if !path::is_absolute(target) { + return false; + } + !(target.starts_with(link_dir_root) + && (target.len() == link_dir_root.len() || target[link_dir_root.len()] == SEP)) +} + +/// Returns true when `entry` at `dir_fd` should be treated as a +/// `bun link` registration: a symlink whose target is an existing +/// directory outside `link_dir_root` (see `resolves_inside`). On +/// filesystems that don't populate `getdents64`'s `d_type` field +/// (NFS / FUSE / XFS with ftype=0), `kind` arrives as `Unknown`; +/// disambiguate with `lstatat` before following. A dangling link +/// (producer dir moved/deleted without `bun unlink`) would otherwise +/// make the installer skip the registry download and then fail ENOENT +/// in the worker with no fallback. +/// +/// POSIX-only. On Windows the whole populate_linked_names_cache flow +/// short-circuits to the reparse-point fast path and falls through to +/// the per-call `GetFileAttributesW` check in `linked_package_path`. +#[cfg(not(windows))] +fn is_linked_entry( + kind: sys::EntryKind, + dir_fd: Fd, + name: &bun_core::ZStr, + link_dir_root: &[u8], +) -> bool { + let is_symlink = match kind { + sys::EntryKind::SymLink => true, + sys::EntryKind::Unknown => match sys::lstatat(dir_fd, name) { + Ok(st) => sys::posix::s_islnk(st.st_mode as u32), + Err(_) => false, + }, + _ => return false, + }; + if !is_symlink { + return false; + } + { + let mut target_buf = PathBuffer::uninit(); + match sys::readlinkat(dir_fd, name, &mut target_buf.0[..]) { + Ok(n) => { + if !link_target_is_outside(&target_buf.0[..n], link_dir_root) { + return false; + } + } + Err(_) => return false, + } + } + // Follow the symlink and confirm it resolves to a readable directory. + // Matches what the installer worker will do (open_dir_for_iteration on the + // producer path) — same success/failure outcome here as there. + match bun_sys::openat(dir_fd, name, bun_sys::O::DIRECTORY | bun_sys::O::RDONLY, 0) { + bun_sys::Result::Ok(fd) => { + let _close = bun_sys::CloseOnDrop::new(fd); + !resolves_inside(fd, link_dir_root) + } + bun_sys::Result::Err(_) => false, + } +} + +/// Read the global link dir once and populate `this.linked_names` with +/// every registered package name (including scoped names as +/// `@scope/name`). Must be called on the main thread before any install +/// worker touches `linked_package_path`; after that the map is +/// read-only and lock-free. +/// +/// Safe to call repeatedly; subsequent calls are no-ops. +pub fn populate_linked_names_cache(this: &mut PackageManager) { + if this.linked_names_populated { + return; + } + this.linked_names_populated = true; + + // Best-effort, read-only: for users who have never run `bun link`, + // the global link dir may not exist (or may be unreadable). Probe + // without creating anything — `open_global_dir` mkdirs the whole + // tree, which would make every isolated install create + // `/node_modules/` even on machines with no links. Missing + // or unreadable → leave the cache empty so `linked_package_path` + // short-circuits to null with no further syscalls. + let root_fd = if this.global_link_dir_path.is_empty() { + let mut global_buf = PathBuffer::uninit(); + let Some(global_path) = + options::global_dir_path(this.options.explicit_global_directory, &mut global_buf) + else { + return; + }; + let mut link_buf = PathBuffer::uninit(); + let parts: [&[u8]; 1] = [b"node_modules"]; + let link_path = bun_paths::resolve_path::join_abs_string_buf::< + bun_paths::resolve_path::platform::Auto, + >(global_path, &mut link_buf.0, &parts); + let fd = match bun_sys::open_dir_for_iteration(Fd::cwd(), link_path) { + bun_sys::Result::Ok(fd) => fd, + bun_sys::Result::Err(_) => return, + }; + // Canonicalize the same way `global_link_dir` does so producer + // lookups join against an identical base. + let mut buf = PathBuffer::uninit(); + let path_ = match sys::get_fd_path(fd, &mut buf) { + Ok(p) => p, + Err(_) => { + fd.close(); + return; + } + }; + this.global_link_dir_path = Box::<[u8]>::from(bun_core::handle_oom( + FileSystem::instance().dirname_store().append(path_), + )); + fd + } else { + match bun_sys::open_dir_for_iteration(Fd::cwd(), &this.global_link_dir_path) { + bun_sys::Result::Ok(fd) => fd, + bun_sys::Result::Err(_) => return, + } + }; + let _close_root = bun_sys::CloseOnDrop::new(root_fd); + + let mut iter = bun_sys::dir_iterator::iterate(root_fd); + loop { + let entry = match iter.next() { + bun_sys::Result::Err(_) => return, + bun_sys::Result::Ok(None) => return, + bun_sys::Result::Ok(Some(e)) => e, + }; + let name = entry.name.slice_u8(); + if name.is_empty() { + continue; + } + + // Scope dirs (`@scope`) contain the actual links nested one level + // deeper; flatten to `@scope/name` in the cache. Accept `Unknown` + // too: readdir on NFS / FUSE / XFS-ftype=0 returns `DT_UNKNOWN` + // for every entry, and rejecting it here would drop real scope + // dirs. `open_dir_for_iteration` below will reject non-dirs as + // EACCES/ENOTDIR and we'll just skip them. + if name[0] == b'@' + && (entry.kind == sys::EntryKind::Directory || entry.kind == sys::EntryKind::Unknown) + { + #[cfg(windows)] + { + // WTF-16 name; skip scope flattening on Windows for now. + // Falls through to the GetFileAttributesW path in + // `linked_package_path`. Record the scope dir as a + // potential link parent for the Windows fast path. + this.linked_names_any_on_windows = true; + continue; + } + #[cfg(not(windows))] + { + let mut scope_name_buf = [0u8; 256]; + let Some(scope_name_z) = name_zstr(&mut scope_name_buf, name) else { + continue; + }; + let scope_fd = match bun_sys::open_dir_for_iteration(root_fd, scope_name_z) { + bun_sys::Result::Ok(fd) => fd, + bun_sys::Result::Err(_) => continue, + }; + let _close_scope = bun_sys::CloseOnDrop::new(scope_fd); + + let mut scope_iter = bun_sys::dir_iterator::iterate(scope_fd); + loop { + let scope_entry = match scope_iter.next() { + bun_sys::Result::Err(_) => break, + bun_sys::Result::Ok(None) => break, + bun_sys::Result::Ok(Some(e)) => e, + }; + let sub_name = scope_entry.name.slice_u8(); + if sub_name.is_empty() { + continue; + } + // Only symlinks — the global link dir is shared + // with `bun add -g`, which drops real directories + // under the same path. A real directory there means + // a global install, not a link. + let mut sub_name_buf = [0u8; 256]; + let Some(sub_name_z) = name_zstr(&mut sub_name_buf, sub_name) else { + continue; + }; + if !is_linked_entry( + scope_entry.kind, + scope_fd, + sub_name_z, + &this.global_link_dir_path, + ) { + continue; + } + let mut full: Vec = Vec::with_capacity(name.len() + 1 + sub_name.len()); + full.extend_from_slice(name); + full.push(b'/'); + full.extend_from_slice(sub_name); + this.linked_names.put(&full, ()).unwrap_or_oom(); + } + continue; + } + } + + #[cfg(windows)] + { + // Over-approximate: any reparse-point entry at this level is a + // candidate link. `linked_package_path`'s per-call + // GetFileAttributesW will still reject false positives by + // path. We just need enough signal to skip the syscall when + // zero links exist. + if entry.kind == sys::EntryKind::SymLink { + this.linked_names_any_on_windows = true; + } + continue; + } + #[cfg(not(windows))] + { + let mut name_buf = [0u8; 256]; + let Some(name_z) = name_zstr(&mut name_buf, name) else { + continue; + }; + if !is_linked_entry(entry.kind, root_fd, name_z, &this.global_link_dir_path) { + continue; + } + this.linked_names.put(name, ()).unwrap_or_oom(); + } + } +} + +/// If `/` exists (typically a symlink created +/// by `bun link` from the producer dir), write its absolute path into +/// `buf` and return it. Otherwise `None`. Scoped names (`@scope/name`) +/// are handled because `join_abs_string_buf_z` preserves the `/`. +/// +/// Shared `&PackageManager` so it's safe to call from any install-worker +/// thread: `populate_linked_names_cache` ran once on the main thread +/// before workers started, and the map / `global_link_dir_path` / +/// `linked_names_any_on_windows` are read-only thereafter. This is the +/// entry point workers must use — forming `&mut PackageManager` on a +/// task thread is UB per the `Task::run` SAFETY contract. +/// +/// Performance: +/// - **POSIX**: single hashmap check, zero syscalls. Short-circuits to +/// `None` if `linked_names` is empty or doesn't contain the name. +/// - **Windows**: the readdir in `populate_linked_names_cache` yields +/// WTF-16 we can't key into the UTF-8 map, so a per-call +/// `GetFileAttributesW` + dangling-junction check is required when +/// `linked_names_any_on_windows` is set. With no active links the +/// fast-path flag is false and we return `None` with no syscalls. +pub fn linked_package_path<'a>( + this: &'a PackageManager, + pkg_name: &[u8], + buf: &'a mut PathBuffer, +) -> Option<&'a bun_core::ZStr> { + if pkg_name.is_empty() { + return None; + } + // Cache must be populated before any worker calls this (asserted + // at the top of install_isolated_packages). + if !this.linked_names_populated { + return None; + } + // Populate couldn't set up the global link dir — no links on + // this machine. Don't try to re-init; that path needs `&mut` + // and `Global::exit(1)`s on failure. + if this.global_link_dir_path.is_empty() { + return None; + } + + // POSIX: hashmap keyed by UTF-8 name. + #[cfg(not(windows))] + { + if this.linked_names.is_empty() { + return None; + } + if !this.linked_names.contains_key(pkg_name) { + return None; + } + let dir_path_ref: &[u8] = &this.global_link_dir_path; + let joined = path::resolve_path::join_abs_string_buf_z::( + dir_path_ref, + buf, + &[pkg_name], + ); + Some(joined) + } + + // Windows: readdir yielded WTF-16 we couldn't key into the UTF-8 + // map; `linked_names_any_on_windows` records whether any + // link-shaped entry was seen. Short-circuit if none; otherwise + // re-probe via GetFileAttributesW (read-only, safe on workers — + // `global_link_dir_path` is immutable post-populate). + #[cfg(windows)] + { + if !this.linked_names_any_on_windows { + return None; + } + let dir_path_ref: &[u8] = &this.global_link_dir_path; + let joined = path::resolve_path::join_abs_string_buf_z::( + dir_path_ref, + buf, + &[pkg_name], + ); + match sys::get_file_attributes(joined) { + Some(attrs) if attrs.is_reparse_point => {} + _ => return None, + } + { + let mut target_buf = PathBuffer::uninit(); + match sys::readlink(joined, &mut target_buf.0[..]) { + Ok(n) => { + if !link_target_is_outside(&target_buf.0[..n], dir_path_ref) { + return None; + } + } + Err(_) => return None, + } + } + match bun_sys::open_dir_for_iteration(Fd::cwd(), joined) { + bun_sys::Result::Ok(fd) => { + let _close = bun_sys::CloseOnDrop::new(fd); + if resolves_inside(fd, dir_path_ref) { + return None; + } + Some(joined) + } + bun_sys::Result::Err(_) => None, + } + } +} + +/// Non-cached fallback: main-thread-only. Must hold `&mut PackageManager` +/// because `global_link_dir(this)` lazy-initializes +/// `global_link_dir` / `global_link_dir_path` on first call. Callers on +/// worker threads must use [`linked_package_path`] (read-only) instead. +/// +/// Used by: +/// - Windows, where the readdir in `populate_linked_names_cache` can't +/// key WTF-16 names into the UTF-8 hashmap — every call falls through +/// to `GetFileAttributesW` against the joined path. +/// - Any caller outside the isolated-install flow that hasn't run +/// `populate_linked_names_cache` (`bun link` / `bun unlink` themselves, +/// resolver probes) — main thread only by construction. +pub fn linked_package_path_mut<'a>( + this: &'a mut PackageManager, + pkg_name: &[u8], + buf: &'a mut PathBuffer, +) -> Option<&'a bun_core::ZStr> { + if pkg_name.is_empty() { + return None; + } + + // If populate ran and left the cache empty because the global link + // dir couldn't be set up (no writable profile, locked-down + // container, etc.), don't re-attempt via `global_link_dir` — that + // path `Global::exit(1)`s on setup failure, which would turn a + // plain npm-only install on Windows into a hard exit. + if this.linked_names_populated && this.global_link_dir_path.is_empty() { + return None; + } + + // POSIX fast path: the readdir in populate already UTF-8-keyed the + // registered names into linked_names, so after populate has run we + // can answer without touching the filesystem. The linked_pkg_ids + // bitset build in isolated_install.rs calls this once per + // root/workspace direct dependency; this check skips the lstat + // below for every direct dep whose name is not link-registered + // (the vast majority). + #[cfg(not(windows))] + { + if this.linked_names_populated && !this.linked_names.contains_key(pkg_name) { + return None; + } + } + + // Windows fast path. `populate_linked_names_cache` already paid the + // readdir cost and recorded whether any link-shaped entry was seen; + // short-circuit GetFileAttributesW when none exist. + #[cfg(windows)] + { + if this.linked_names_populated && !this.linked_names_any_on_windows { + return None; + } + } + + let _ = global_link_dir(this); + let dir_path_ref: &[u8] = &this.global_link_dir_path; + let joined = path::resolve_path::join_abs_string_buf_z::( + dir_path_ref, + buf, + &[pkg_name], + ); + + // The global link dir is shared with `bun add -g` (same root — + // `/node_modules/`), and on POSIX a hoisted global + // install lands here as a real directory. Treat only symlinks / + // reparse-points as registered links. + let is_link: bool = { + #[cfg(windows)] + { + match sys::get_file_attributes(joined) { + Some(attrs) => attrs.is_reparse_point, + None => return None, + } + } + #[cfg(not(windows))] + { + match sys::lstat(joined) { + Ok(st) => sys::posix::s_islnk(st.st_mode as u32), + Err(_) => return None, + } + } + }; + if !is_link { + return None; + } + + { + let mut target_buf = PathBuffer::uninit(); + match sys::readlink(joined, &mut target_buf.0[..]) { + Ok(n) => { + if !link_target_is_outside(&target_buf.0[..n], dir_path_ref) { + return None; + } + } + Err(_) => return None, + } + } + + // Follow the symlink and confirm it resolves to a readable + // directory outside the link dir (`resolves_inside` rejects + // isolated-linker global installs). A dangling symlink (producer + // deleted without `bun unlink`) would otherwise make the installer + // skip the registry download and fail ENOENT in the worker. + match bun_sys::open_dir_for_iteration(Fd::cwd(), joined) { + bun_sys::Result::Ok(fd) => { + let _close = bun_sys::CloseOnDrop::new(fd); + if resolves_inside(fd, dir_path_ref) { + return None; + } + Some(joined) + } + bun_sys::Result::Err(_) => None, + } +} + // ────────────────────────── cached path resolution ──────────────────────────── pub fn path_for_cached_npm_path<'a>( diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..3ce775c8845a 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -298,49 +298,56 @@ pub use crate::config_version::ConfigVersion; pub use bun_install_types::DependencyGroup; pub use bun_install_types::NodeLinker::NodeLinker; -// mkdir -p + open the dir. Callers store the raw `Fd` (`options.global_bin_dir: Fd`). -pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result { +/// The global directory path by option/env precedence +/// (`BUN_INSTALL_GLOBAL_DIR` → bunfig `install.globalDir` → +/// `$BUN_INSTALL/install/global` → `$XDG_CACHE_HOME|$HOME/.bun/install/global`), +/// computed without touching the filesystem. `None` mirrors +/// `Error::NoGlobalDirectoryFound`. +pub fn global_dir_path<'a>( + explicit_global_dir: &'a [u8], + buf: &'a mut PathBuffer, +) -> Option<&'a [u8]> { use bun_paths::{platform, resolve_path::join_abs_string_buf}; - use bun_sys::{Dir, OpenDirOptions}; if let Some(home_dir) = env_var::BUN_INSTALL_GLOBAL_DIR.get() { - return Dir::cwd() - .make_open_path(home_dir, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + return Some(home_dir); } if !explicit_global_dir.is_empty() { - return Dir::cwd() - .make_open_path(explicit_global_dir, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + return Some(explicit_global_dir); } if let Some(home_dir) = env_var::BUN_INSTALL.get() { - let mut buf = PathBuffer::uninit(); let parts: [&[u8]; 2] = [b"install", b"global"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + return Some(join_abs_string_buf::( + home_dir, &mut buf.0, &parts, + )); } if let Some(home_dir) = env_var::XDG_CACHE_HOME .get() .or_else(|| env_var::HOME.get()) { - let mut buf = PathBuffer::uninit(); let parts: [&[u8]; 3] = [b".bun", b"install", b"global"]; - let path = join_abs_string_buf::(home_dir, &mut buf.0, &parts); - return Dir::cwd() - .make_open_path(path, OpenDirOptions::default()) - .map(|d| d.into_raw()) - .map_err(Into::into); + return Some(join_abs_string_buf::( + home_dir, &mut buf.0, &parts, + )); } - Err(crate::Error::NoGlobalDirectoryFound) + None +} + +// mkdir -p + open the dir. Callers store the raw `Fd` (`options.global_bin_dir: Fd`). +pub fn open_global_dir(explicit_global_dir: &[u8]) -> crate::Result { + use bun_sys::{Dir, OpenDirOptions}; + + let mut buf = PathBuffer::uninit(); + let path = global_dir_path(explicit_global_dir, &mut buf) + .ok_or(crate::Error::NoGlobalDirectoryFound)?; + Dir::cwd() + .make_open_path(path, OpenDirOptions::default()) + .map(|d| d.into_raw()) + .map_err(Into::into) } pub(crate) fn open_global_bin_dir(opts_: Option<&Api::BunInstall>) -> crate::Result { diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 26f662acfc4c..eb5868333e0b 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -1150,6 +1150,14 @@ pub(crate) fn install_isolated_packages( ) -> Result { analytics::features::isolated_bun_install.fetch_add(1, Ordering::Relaxed); + // Populate the linked-names cache once, on the main thread, before any + // install worker calls `linked_package_path`. Replaces a per-dependency + // `lstat` with a single readdir of the global link dir; short-circuits + // every subsequent lookup when nothing is linked (the common case on + // CI and dev machines without active links). See + // `populate_linked_names_cache` in PackageManager/PackageManagerDirectories.rs. + crate::package_manager_real::directories::populate_linked_names_cache(manager); + // Take a raw pointer so column borrows below don't tie up `&mut manager` // (which owns the lockfile). let lockfile: *mut Lockfile = &raw mut *manager.lockfile; @@ -1163,6 +1171,73 @@ pub(crate) fn install_isolated_packages( } else { Timings::Quiet }; + + // Store entries an active `bun link` overrides: the resolutions of root / + // workspace *direct* dependencies whose resolved package name is + // link-registered. Keying the override on name alone would stamp the + // producer's working tree into every `.bun/@/` entry in the + // lockfile — when the tree contains the linked name at more than one + // version (direct `react@18` plus a transitive `react@16`), the + // mismatched copies would break at runtime. The hoisted linker only ever + // replaces the top-level `node_modules/`; gating on direct-dep + // resolutions preserves that: transitive different-version copies keep + // their registry bytes. + let linked_pkg_ids: DynamicBitSet = { + let mut set = DynamicBitSet::init_empty(lockfile.packages.len())?; + let any_links = if cfg!(windows) { + manager.linked_names_any_on_windows + } else { + !manager.linked_names.is_empty() + }; + if any_links + // A global install IS the link dir: `bun add -g` of a + // link-registered name must install registry bytes (hoisted + // parity: the registration gets clobbered, not sourced from + // the producer's working tree). + && !manager.options.global + && PackageInstall::supported_method() != crate::package_install::Method::Symlink + { + let pkgs = lockfile.packages.slice(); + let pkg_dependency_slices = pkgs.items_dependencies(); + let pkg_names = pkgs.items_name(); + let resolutions = &lockfile.buffers.resolutions[..]; + let dependencies = &lockfile.buffers.dependencies[..]; + let string_buf = &lockfile.buffers.string_bytes[..]; + + // Root first, then every workspace package root declares. + let mut scan_targets: Vec = vec![0]; + for dep_idx in pkg_dependency_slices[0].begin()..pkg_dependency_slices[0].end() { + if !dependencies[dep_idx as usize].behavior.is_workspace() { + continue; + } + let res = resolutions[dep_idx as usize]; + if res != invalid_package_id { + scan_targets.push(res as usize); + } + } + for pid in scan_targets { + for dep_idx in pkg_dependency_slices[pid].begin()..pkg_dependency_slices[pid].end() + { + let res = resolutions[dep_idx as usize]; + if res == invalid_package_id || set.is_set(res as usize) { + continue; + } + let mut link_buf = PathBuffer::uninit(); + if crate::package_manager_real::directories::linked_package_path_mut( + manager, + pkg_names[res as usize].slice(string_buf), + &mut link_buf, + ) + .is_some() + { + set.set(res as usize); + } + } + } + } + set + }; + let store: Store = build_store( &*manager, &*lockfile, @@ -1299,6 +1374,22 @@ pub(crate) fn install_isolated_packages( { break 'eligible false; } + // An active `bun link` sources the body from the + // producer's live working tree, which is mutable + // by design. Materializing that into the shared + // content-addressed `/links//` would + // poison every other consumer on the machine that + // resolves the same lockfile closure, and the + // override's pre-delete would wipe the shared + // entry under them. Force link-overridden + // packages project-local. `linked_pkg_ids` is + // already empty under `--backend=symlink` (the + // override never fires there) and contains only + // direct-dep resolutions, so transitive + // same-name entries keep GVS eligibility. + if linked_pkg_ids.is_set(pkg_id as usize) { + break 'eligible false; + } break 'eligible true; } _ => false, @@ -2070,6 +2161,7 @@ pub(crate) fn install_isolated_packages( bun_core::ZStr::from_slice_with_nul(b) }), global_store_tmp_suffix: fast_random(), + linked_pkg_ids, summary: Default::default(), task_queue: Default::default(), }; @@ -2215,12 +2307,23 @@ pub(crate) fn install_isolated_packages( let uses_global_store = installer.entry_uses_global_store(entry_id); + // An active `bun link` whose direct-dep resolution is this + // entry means the producer dir is the source of truth — + // override the cache-based materialization regardless of + // whether the store dir already exists. `linked_pkg_ids` + // was built on the main thread before the installer + // (empty under `--backend=symlink`; direct-dep + // resolutions only, so transitive same-name entries at + // other versions keep their registry bytes). + let has_active_link = installer.linked_pkg_ids.is_set(pkg_id as usize); + let needs_install = installer.manager().options.enable.force_install() // A freshly-created `node_modules/.bun` only implies the // *project-local* entries are missing; global virtual- // store entries persist across `rm -rf node_modules` and // should still take the cheap symlink-only path. || (is_new_bun_modules && !uses_global_store) + || has_active_link || matches!(patch_info, installer::PatchInfo::Remove(_)) || 'needs_install: { let mut store_path: AbsPath = AbsPath::init_top_level_dir(); @@ -2246,7 +2349,33 @@ pub(crate) fn install_isolated_packages( let exists = sys::exists_z(store_path.slice_z()); break 'needs_install match &patch_info { - installer::PatchInfo::None => !exists, + installer::PatchInfo::None => { + if !exists { + true + } else { + // `bun unlink` has no uninstall step: a + // `.bun-link` marker (dropped by the + // link override in Installer.rs) with + // no active link means the body still + // holds the producer's files — rebuild + // from the registry. Only reached when + // `has_active_link` is false (it + // short-circuits the chain above). + // Entries that regain global-store + // eligibility rebuild via the global + // existence miss; this covers ones + // that stay project-local (trusted + // deps, `globalStore` off — the + // default). Mirrors the + // `PatchInfo::Remove` un-patch + // recovery. + store_path.set_length(scope_for_patch_tag_path); + store_path + .append(installer::LINK_OVERRIDE_MARKER) + .assume_ok(); + sys::exists_z(store_path.slice_z()) + } + } // checked above installer::PatchInfo::Remove(_) => unreachable!(), installer::PatchInfo::Patch(patch) => { @@ -2327,6 +2456,20 @@ pub(crate) fn install_isolated_packages( continue; } + // `link_package` will source from the producer dir via + // `linked_package_path`; skip the cache-fetch dance entirely + // (mirrors how `.folder` is handled — no registry traffic + // needed when the body comes from an on-disk producer). + // Without this, the main thread still enqueues a download + // whose extracted bytes the worker never reads, and an + // offline / unpublished-package install (the canonical + // `bun link` case) would fail at the fetch step instead of + // succeeding from the producer on disk. + if has_active_link { + installer.start_task(entry_id); + continue; + } + // Downloads only produce the unpatched folder; `apply_package_patch` derives the rest. // SAFETY: each arm reads the union field that `pkg_res_tag` // (== `pkg_res.tag`) names as active. diff --git a/src/install/isolated_install/FileCopier.rs b/src/install/isolated_install/FileCopier.rs index 2cbed827c689..1bc5d56c101f 100644 --- a/src/install/isolated_install/FileCopier.rs +++ b/src/install/isolated_install/FileCopier.rs @@ -31,17 +31,22 @@ impl FileCopier { src_path: AbsPathAutoOs, dest_subpath: PathAutoOs, skip_dirnames: &[&OSPathSlice], + ) -> Result { + Self::init_with_skip(src_dir, src_path, dest_subpath, &[], skip_dirnames) + } + + pub(crate) fn init_with_skip( + src_dir: Fd, + src_path: AbsPathAutoOs, + dest_subpath: PathAutoOs, + skip_filenames: &[&OSPathSlice], + skip_dirnames: &[&OSPathSlice], ) -> Result { Ok(FileCopier { src_path, dest_subpath, walker: { - let mut w = walker_skippable::walk( - src_dir, - // bun.default_allocator → deleted (global mimalloc) - &[], - skip_dirnames, - )?; + let mut w = walker_skippable::walk(src_dir, skip_filenames, skip_dirnames)?; w.resolve_unknown_entry_types = true; w }, diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index ce346a116b37..1321c8019011 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -58,6 +58,14 @@ type DefaultAbsPath = AbsPath; /// usable in `const_format::concatcp!`, so spell the literal. const NODE_MODULES_BUN: &str = "node_modules/.bun"; +/// Dropped inside a store entry's package body when it was materialized from +/// a `bun link` producer instead of the registry. `bun unlink` has no +/// uninstall step, so the next install's `needs_install` check (in +/// isolated_install.rs) uses this to detect a producer-sourced body whose +/// link is gone and rebuild it, mirroring how `PatchInfo::Remove` recovers an +/// un-patched entry. +pub(crate) const LINK_OVERRIDE_MARKER: &[u8] = b".bun-link"; + bun_output::declare_scope!(IsolatedInstaller, hidden); macro_rules! debug { ($($args:tt)*) => { bun_output::scoped_log!(IsolatedInstaller, $($args)*) }; @@ -99,6 +107,15 @@ pub struct Installer<'a> { /// Built before tasks spawn and only read concurrently afterwards. pub(crate) trusted_dependencies_from_update_requests: ArrayHashMap, + /// Package ids whose store entries an active `bun link` overrides: the + /// resolutions of root/workspace direct dependencies whose resolved name + /// is link-registered. Name-only matching would stamp the producer tree + /// into every version of the name in the lockfile; this keeps transitive + /// different-version copies registry-sourced (hoisted-linker parity). + /// Built on the main thread before tasks spawn and only read + /// concurrently afterwards. + pub linked_pkg_ids: DynamicBitSet, + /// Absolute path to the global virtual store (`/links`). When /// non-null, npm/git/tarball entries are materialized once into this /// directory and `node_modules/.bun/` becomes a symlink into @@ -1108,6 +1125,283 @@ impl Task { } tag => { + // If an active `bun link` overrides this store entry + // (direct-dep resolution of a link-registered name; see + // `linked_pkg_ids`) and the user did not opt into the symlink + // backend, source the body from the producer dir instead of + // the registry tarball cache. Under isolated linking the + // top-level symlink-only contract isn't available, so without + // this the `.bun/` body stays pinned to the + // (stale) tarball cache while the top-level symlink points at + // the producer. The bitset gate also keeps transitive + // same-name entries at other versions registry-sourced even + // when the task was started for unrelated reasons (fresh + // `.bun`, force install). + // + // Relaxed load of `supported_backend` is okay because it's an + // optimization hint; a stale read is harmless (same rationale + // as the cache-backend switch further down). + if installer.linked_pkg_ids.is_set(pkg_id as usize) + && InstallMethod::from_u8( + installer.supported_backend.load(Ordering::Relaxed), + ) != InstallMethod::Symlink + { + let mut linked_buf = paths::path_buffer_pool::get(); + let producer_path_opt = { + // Worker thread: must not form `&mut PackageManager` + // (the Task::run SAFETY contract at the top of + // this file forbids it — concurrent workers would + // alias it). `populate_linked_names_cache` ran on + // the main thread before workers started and the + // linked_names map is read-only thereafter, so the + // shared-ref entry point is race-free here. + let manager = manager_ref.get(); + match directories::linked_package_path( + manager, + pkg_name.slice(string_buf), + &mut *linked_buf, + ) { + Some(p) => { + // Copy bytes so we don't hold &manager across + // the install work below. + let bytes = p.as_bytes(); + Some((bytes.as_ptr(), bytes.len())) + } + None => None, + } + }; + if let Some((ptr, len)) = producer_path_opt { + // SAFETY: the `linked_buf` pool guard lives until the + // end of this arm and the pooled buffer doesn't move, + // so the pointer is valid for the remainder of the + // Step::LinkPackage match arm. + let producer_path: &[u8] = + unsafe { core::slice::from_raw_parts(ptr, len) }; + + let folder_dir = match bun_sys::open_dir_for_iteration( + Fd::cwd(), + producer_path, + ) { + sys::Result::Ok(fd) => fd, + sys::Result::Err(err) => { + return Ok(Yield::failure(TaskError::LinkPackage(err))); + } + }; + let _folder_dir_guard = sys::CloseOnDrop::new(folder_dir); + + // Force copyfile here — producer tree is mutable and + // lifecycle scripts would otherwise propagate back + // through a shared inode. The backend switch falls + // back to copyfile on EXDEV in any case. + let mut src_path = OsAutoAbsPath::init(); + #[cfg(windows)] + { + let buf = src_path.buf(); + let cap = buf.len(); + let ptr = buf.as_mut_ptr(); + // SAFETY: FFI — valid handle + writable buffer. + let src_path_len = unsafe { + bun_sys::windows::GetFinalPathNameByHandleW( + folder_dir.native(), + ptr, + u32::try_from(cap).expect("int cast"), + 0, + ) + }; + if src_path_len == 0 || src_path_len as usize >= cap { + use bun_sys::windows::Win32ErrorExt as _; + let err: sys::SystemErrno = if src_path_len == 0 { + bun_sys::windows::Win32Error::get() + .to_system_errno() + .unwrap_or(sys::SystemErrno::EUNKNOWN) + } else { + sys::SystemErrno::ENAMETOOLONG + }; + return Ok(Yield::failure(TaskError::LinkPackage( + sys::Error { + errno: err as _, + syscall: sys::Tag::copyfile, + ..Default::default() + }, + ))); + } + src_path.set_length(src_path_len as usize); + } + #[cfg(not(windows))] + { + let _ = src_path.append_join(producer_path); + } + + // Stale-GVS-symlink detachment. The `continue` at + // the bottom of this override block bypasses the + // post-switch detachment further down (the + // `append_local_store_entry_path` / `is_stale_link` + // block after the cache-backend switch); without + // this an entry that was GVS-eligible on the + // previous install would still have + // `node_modules/.bun/` as a symlink into + // `/links//`, and FileCopier's writes + // would land IN the shared cache under every + // consumer on the machine. + let mut local = AutoPath::init_top_level_dir(); + installer + .append_local_store_entry_path(&mut local, self.entry_id); + let is_stale_link: bool = { + #[cfg(windows)] + { + if let Some(a) = + sys::get_file_attributes(local.slice_z()) + { + a.is_reparse_point + } else { + false + } + } + #[cfg(not(windows))] + { + if let Ok(st) = sys::lstat(local.slice_z()) { + sys::posix::s_islnk(st.st_mode as u32) + } else { + false + } + } + }; + if is_stale_link { + let remove_err: Option = { + #[cfg(windows)] + { + 'win: { + if let Some(_e) = + sys::rmdir(local.slice_z()).err() + { + if let Some(e) = + sys::unlink(local.slice_z()).err() + { + break 'win Some(e); + } + } + break 'win None; + } + } + #[cfg(not(windows))] + { + sys::unlink(local.slice_z()).err() + } + }; + if let Some(e) = remove_err { + if e.get_errno() != sys::Errno::ENOENT { + // Do NOT proceed: the subsequent + // FileCopier writes through `dest` + // (node_modules/.bun//…) + // would resolve through the still- + // live symlink into the shared + // `/links/` entry under every + // consumer on the machine. Fail the + // task so the user sees the + // AV/sharing-violation or EACCES + // instead of silently mutating the + // shared cache. + return Ok(Yield::failure(TaskError::LinkPackage( + e, + ))); + } + } + } + + // Wipe the entry dir first — linked packages re- + // materialize straight to the final path (no + // staging rename). Files the producer has since + // deleted would otherwise persist across + // reinstalls. + // + // Link-overridden entries are forced GVS-ineligible + // by the eligibility carve-out in isolated_install.rs + // (`linked_pkg_ids.is_set` implies `entry_hash == 0`), + // so `append_real_store_path` here is guaranteed + // project-local. If that ever regressed, this + // delete_tree would silently wipe a machine-wide + // `/links//` entry. + debug_assert!( + !installer.entry_uses_global_store(self.entry_id) + ); + let mut final_path = AutoPath::init(); + installer.append_real_store_path( + &mut final_path, + self.entry_id, + Which::Final, + ); + let _ = Fd::cwd().delete_tree(final_path.slice()); + + let mut dest = OsAutoPath::init(); + installer.append_store_path(&mut dest, self.entry_id); + + // Default excludes for the producer's working tree + // (lockfiles, VCS state, env files, OS junk) that + // `bun pm pack` would also drop. Full pack parity + // (`package.json#files` whitelists, `.npmignore` + // rules) is not implemented for linked producers. + let skip_dirs: &[&paths::OSPathSlice] = &[ + bun_paths::os_path_literal!("node_modules"), + bun_paths::os_path_literal!(".git"), + bun_paths::os_path_literal!(".hg"), + bun_paths::os_path_literal!(".svn"), + bun_paths::os_path_literal!("CVS"), + ]; + let skip_files: &[&paths::OSPathSlice] = &[ + bun_paths::os_path_literal!(".DS_Store"), + bun_paths::os_path_literal!(".gitignore"), + bun_paths::os_path_literal!(".npmignore"), + bun_paths::os_path_literal!(".npmrc"), + bun_paths::os_path_literal!(".lock-wscript"), + bun_paths::os_path_literal!("npm-debug.log"), + bun_paths::os_path_literal!("bunfig.toml"), + bun_paths::os_path_literal!(".env.production"), + bun_paths::os_path_literal!("package-lock.json"), + bun_paths::os_path_literal!("yarn.lock"), + bun_paths::os_path_literal!("pnpm-lock.yaml"), + bun_paths::os_path_literal!("bun.lockb"), + bun_paths::os_path_literal!("bun.lock"), + ]; + + let mut file_copier = FileCopier::init_with_skip( + folder_dir, + src_path.into_sep::<{ PathSeparators::AUTO }>(), + dest.into_sep::<{ PathSeparators::AUTO }>(), + skip_files, + skip_dirs, + )?; + match file_copier.copy() { + sys::Result::Ok(()) => {} + sys::Result::Err(err) => { + return Ok(Yield::failure(TaskError::LinkPackage(err))); + } + } + + // Failing the task on a marker write error + // (rather than ignoring it) keeps the + // unlink-recovery guarantee: without the + // marker a later unlinked install would + // keep this producer body forever. + let mut marker = AutoPath::init(); + installer.append_store_path(&mut marker, self.entry_id); + marker.append(LINK_OVERRIDE_MARKER).assume_ok(); + if let sys::Result::Err(err) = sys::File::openat( + Fd::cwd(), + marker.slice(), + sys::O::WRONLY + | sys::O::CREAT + | sys::O::TRUNC + | if cfg!(windows) { 0 } else { sys::O::NOFOLLOW }, + 0o664, + ) { + return Ok(Yield::failure(TaskError::LinkPackage(err))); + } + + step = self.next_step(current_step); + continue; + } + } + let patch_info = installer.package_patch_info(pkg_name, pkg_name_hash, &pkg_res)?; diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 1b64257c99a8..ddfdf3fef096 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -1,8 +1,8 @@ import { file, spawn, write } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, lstatSync, readlinkSync, statSync } from "fs"; -import { mkdir, readlink, rm, symlink } from "fs/promises"; -import { VerdaccioRegistry, bunEnv, bunExe, readdirSorted, runBunInstall, tempDir } from "harness"; +import { mkdir, readdir, readlink, rm, stat, symlink } from "fs/promises"; +import { VerdaccioRegistry, bunEnv, bunExe, isWindows, readdirSorted, runBunInstall, tempDir } from "harness"; import { createRequire } from "module"; import { dirname, join } from "path"; @@ -3102,3 +3102,965 @@ describe("hoist", () => { ); }); }); + +describe("bun link integration", () => { + // `bun link` writes into the global link dir under BUN_INSTALL. Give each + // test its own to keep the user's real `~/.bun` clean and avoid collisions + // between parallel tests that register the same package name. Caller owns + // the directory lifetime via a `using home = tempDir(...)` declaration so + // the harness cleans up global-link fixtures on test exit. + // + // Override `BUN_INSTALL_GLOBAL_DIR` (precedes `BUN_INSTALL` inside + // `openGlobalDir`) and `BUN_INSTALL_CACHE_DIR` (precedes bunfig's + // `install.cache` inside `fetchCacheDirectoryPath`). On CI, the + // runner pre-points `BUN_INSTALL_CACHE_DIR` at a shared tmpdir to + // warm the per-package tarball cache across tests — under the + // isolated linker that cache also contains the global virtual + // store (`/links/-/...`), so one linked + // test's producer-materialized body persists into every subsequent + // test's install whose `no-deps@1.0.0` lockfile closure hashes to + // the same entry key, and the "marker must be absent" assertions + // flap. Pin everything inside the per-test tempDir. + function hermeticEnv(home: string) { + return { + ...bunEnv, + BUN_INSTALL: home, + BUN_INSTALL_GLOBAL_DIR: join(home, "install", "global"), + BUN_INSTALL_CACHE_DIR: join(home, "install", "cache"), + HOME: home, + XDG_CONFIG_HOME: home, + USERPROFILE: home, + }; + } + + // Recursively list every file under `dir` as a POSIX-separator relative + // path, so capability tests can compare the installed `.bun//` body + // against the set of files `bun pm pack --dry-run` reports. Shared by + // all tests that assert file-set parity; extract to avoid drift if the + // traversal contract changes (follow symlinks, depth limits, etc.). + async function listFilesRecursive(dir: string, prefix = ""): Promise { + const out: string[] = []; + for (const name of await readdir(dir)) { + const abs = join(dir, name); + const rel = prefix ? `${prefix}/${name}` : name; + const s = await stat(abs); + if (s.isDirectory()) { + out.push(...(await listFilesRecursive(abs, rel))); + } else { + out.push(rel); + } + } + return out; + } + + // The registry's `no-deps@1.0.0` tarball contains only `package.json` and + // `index.js`. Producer adds `marker.js` — presence of that file in + // `node_modules/.bun/no-deps@1.0.0/node_modules/no-deps/` is proof the + // body was sourced from the producer dir, not the registry tarball cache. + // Returned to the caller, which wraps it with `using` for disposal. + // Don't `using` here — that would dispose on function return, before + // the test body can use the producer. + async function setupLinkedNoDeps(env: NodeJS.ProcessEnv) { + const producer = tempDir("linkpkg-producer-", { + "package.json": JSON.stringify({ name: "no-deps", version: "1.0.0" }), + "index.js": "module.exports = 'FROM_PRODUCER';", + "marker.js": "module.exports = 'FROM_PRODUCER_MARKER';", + }); + + // On any throw before `return`, the caller's `using` never binds, so + // dispose here to keep the producer dir from leaking on persistent + // CI runners. + try { + await using linkProc = spawn({ + cmd: [bunExe(), "link"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [linkStdout, linkStderr, linkExit] = await Promise.all([ + linkProc.stdout.text(), + linkProc.stderr.text(), + linkProc.exited, + ]); + if (linkExit !== 0) { + throw new Error(`bun link failed:\nstdout: ${linkStdout}\nstderr: ${linkStderr}`); + } + return producer; + } catch (e) { + producer[Symbol.dispose](); + throw e; + } + } + + test("isolated: npm-resolved dep honors active bun link", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-a", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "marker.js"))).toBe(true); + expect(await file(join(bodyDir, "marker.js")).text()).toContain("FROM_PRODUCER_MARKER"); + expect(await file(join(bodyDir, "index.js")).text()).toContain("FROM_PRODUCER"); + void producer; + }); + + test("isolated: scoped dep honors active bun link", async () => { + // Scoped registrations live one level deeper in the global link dir + // (`/@scope/name`); the link scan flattens them to + // `@scope/name` cache keys via a separate branch from the unscoped + // path, so cover it with a scoped producer. + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = tempDir("linkpkg-scoped-producer-", { + "package.json": JSON.stringify({ name: "@types/is-number", version: "1.0.0" }), + "index.js": "module.exports = 'FROM_PRODUCER';", + "marker.js": "module.exports = 'FROM_PRODUCER_MARKER';", + }); + await using linkProc = spawn({ + cmd: [bunExe(), "link"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, linkStderr, linkExit] = await Promise.all([ + linkProc.stdout.text(), + linkProc.stderr.text(), + linkProc.exited, + ]); + expect(linkStderr).not.toContain("error:"); + expect(linkExit).toBe(0); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-scoped", + dependencies: { "@types/is-number": "1.0.0" }, + }), + ); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + // Assert through the top-level symlink so the store-path encoding for + // scoped names stays an implementation detail. + const topLevel = join(packageDir, "node_modules", "@types", "is-number"); + expect(await file(join(topLevel, "marker.js")).text()).toContain("FROM_PRODUCER_MARKER"); + expect(await file(join(topLevel, "index.js")).text()).toContain("FROM_PRODUCER"); + }); + + test("isolated: catalog-resolved dep honors active bun link", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-b", + workspaces: { + packages: ["packages/*"], + catalog: { "no-deps": "1.0.0" }, + }, + }), + ), + write( + join(packageDir, "packages", "app", "package.json"), + JSON.stringify({ + name: "app", + dependencies: { "no-deps": "catalog:" }, + }), + ), + ]); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "marker.js"))).toBe(true); + expect(await file(join(bodyDir, "marker.js")).text()).toContain("FROM_PRODUCER_MARKER"); + void producer; + }); + + test("isolated: producer rebuild propagates on reinstall", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-c", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + const runInstall = async () => { + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + }; + + await runInstall(); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(await file(join(bodyDir, "marker.js")).text()).toContain("FROM_PRODUCER_MARKER"); + + // Mutate producer; reinstall should see fresh content, not the + // content-addressed snapshot from the first install. + await write(join(String(producer), "marker.js"), "module.exports = 'FROM_PRODUCER_REBUILT';"); + + await runInstall(); + expect(await file(join(bodyDir, "marker.js")).text()).toContain("FROM_PRODUCER_REBUILT"); + }); + + test("isolated: bun unlink restores registry contents on reinstall", async () => { + // Default config keeps the entry project-local (no global virtual + // store), so without the `.bun-link` marker the reinstall's existence + // check would pass through the producer-copied body and keep the stale + // `marker.js` forever. + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-h", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + const runInstall = async () => { + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + }; + + await runInstall(); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "marker.js"))).toBe(true); + expect(existsSync(join(bodyDir, ".bun-link"))).toBe(true); + + await using unlinkProc = spawn({ + cmd: [bunExe(), "unlink"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, unlinkStderr, unlinkExit] = await Promise.all([ + unlinkProc.stdout.text(), + unlinkProc.stderr.text(), + unlinkProc.exited, + ]); + expect(unlinkStderr).not.toContain("error:"); + expect(unlinkExit).toBe(0); + + await runInstall(); + + expect(existsSync(join(bodyDir, "marker.js"))).toBe(false); + expect(existsSync(join(bodyDir, ".bun-link"))).toBe(false); + expect(existsSync(join(bodyDir, "package.json"))).toBe(true); + }); + + test("isolated: global install with isolated linker is not treated as bun link", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + // `bun add -g` with the isolated linker drops + // `/node_modules/no-deps` as a symlink into the global dir's + // own `.bun` store. Link detection must not treat that as a + // `bun link` registration, or every consumer project would get the + // globally installed version copied over its lockfile-resolved one. + using globalCwd = tempDir("global-add-cwd-", {}); + await write( + join(String(home), ".bunfig.toml"), + Bun.TOML.stringify({ + install: { linker: "isolated", registry: registry.registryUrl() }, + }), + ); + await using addProc = spawn({ + cmd: [bunExe(), "add", "-g", "no-deps@2.0.0"], + cwd: String(globalCwd), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [addStdout, addStderr, addExit] = await Promise.all([ + addProc.stdout.text(), + addProc.stderr.text(), + addProc.exited, + ]); + expect(addStderr).not.toContain("error:"); + expect(addExit).toBe(0); + // Sanity: the global entry landed as a symlink, the exact shape the + // link scan must reject. (Windows materializes a junction; the + // per-call attribute check there is covered by the same + // target-resolution rejection.) + const globalEntry = join(String(home), "install", "global", "node_modules", "no-deps"); + if (!isWindows) { + expect(lstatSync(globalEntry).isSymbolicLink()).toBe(true); + } + void addStdout; + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-i", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(await file(join(bodyDir, "package.json")).json()).toMatchObject({ version: "1.0.0" }); + expect(existsSync(join(bodyDir, ".bun-link"))).toBe(false); + }); + + test("isolated: globalStore global install is not treated as bun link", async () => { + // With `globalStore` enabled the global install's store entry is an + // absolute symlink into `/links/`, so the top-level entry's + // full resolution escapes the link dir. The scan must discriminate on + // the immediate symlink target (relative `.bun/...`), not the + // resolved path, or the global version substitutes into consumers. + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + using globalCwd = tempDir("global-add-cwd-", {}); + await write( + join(String(home), ".bunfig.toml"), + Bun.TOML.stringify({ + install: { linker: "isolated", globalStore: true, registry: registry.registryUrl() }, + }), + ); + await using addProc = spawn({ + cmd: [bunExe(), "add", "-g", "no-deps@2.0.0"], + cwd: String(globalCwd), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, addStderr, addExit] = await Promise.all([addProc.stdout.text(), addProc.stderr.text(), addProc.exited]); + expect(addStderr).not.toContain("error:"); + expect(addExit).toBe(0); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-gvs", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(await file(join(bodyDir, "package.json")).json()).toMatchObject({ version: "1.0.0" }); + expect(existsSync(join(bodyDir, ".bun-link"))).toBe(false); + }); + + test("isolated: bun add -g installs registry bytes for a link-registered name", async () => { + // A global install IS the link dir: `bun add -g` of a name that is + // also link-registered must install registry bytes, not the + // producer's working tree (hoisted parity: the registration gets + // clobbered, not sourced). + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + using globalCwd = tempDir("global-add-cwd-", {}); + await write( + join(String(home), ".bunfig.toml"), + Bun.TOML.stringify({ + install: { linker: "isolated", registry: registry.registryUrl() }, + }), + ); + await using addProc = spawn({ + cmd: [bunExe(), "add", "-g", "no-deps@1.0.0"], + cwd: String(globalCwd), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, addStderr, addExit] = await Promise.all([addProc.stdout.text(), addProc.stderr.text(), addProc.exited]); + expect(addStderr).not.toContain("error:"); + expect(addExit).toBe(0); + + const globalBody = join( + String(home), + "install", + "global", + "node_modules", + ".bun", + "no-deps@1.0.0", + "node_modules", + "no-deps", + ); + expect(existsSync(join(globalBody, "package.json"))).toBe(true); + expect(existsSync(join(globalBody, "marker.js"))).toBe(false); + expect(existsSync(join(globalBody, ".bun-link"))).toBe(false); + void producer; + }); + + test("isolated: no link registered → body sourced from registry tarball", async () => { + // Hermetic env with no `bun link` registered — the registry's + // `no-deps@1.0.0` tarball has no `marker.js`, so its absence in the body + // confirms the producer path was never consulted. + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-e", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "package.json"))).toBe(true); + expect(existsSync(join(bodyDir, "marker.js"))).toBe(false); + // The linked-names probe is read-only: an install on a machine that + // never ran `bun link` must not create the global link dir tree. + expect(existsSync(join(String(home), "install", "global"))).toBe(false); + }); + + // Windows dropped from this one: `symlink.isSupported()` is false on + // Windows (BackendSupport.windows carries only hardlink + copyfile), so + // `--backend=symlink` is silently discarded by CommandLineArguments and + // the backend stays at the Windows default (hardlink). The override fires + // and marker.js ends up in the store — expected platform behavior, not a + // regression this test can usefully assert. + test.skipIf(isWindows)("isolated: --backend=symlink bypasses the bun link override", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-f", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=symlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + // With symlink backend the user opted into shared-writable semantics; our + // override deliberately does not fire, so the body must be the registry + // tarball (no marker.js). + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "marker.js"))).toBe(false); + // Top-level symlink still points at the isolated store entry — a regression + // from --backend=symlink to a flat hoisted layout would break here even + // though the body-check above would still pass. + expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBe(true); + void producer; + }); + + // Dangling link (producer deleted without `bun unlink`) — a common dev + // workflow slip. Pre-fix this hard-failed every isolated install that + // resolved the same package name, with an ENOENT from the installer + // worker opening the orphan link and no registry fallback. Accept it + // as a registration only when the target resolves. + // + // Windows dropped: the staged dangling state and the lstat + // isSymbolicLink() assertions assume the registration is a symlink. + // On Windows `bun link` registers a junction and the installer detects + // dangling registrations through the GetFileAttributesW probe in + // linked_package_path, a different path this POSIX-shaped fixture + // doesn't validate. + test.skipIf(isWindows)("isolated: dangling bun link is ignored, registry download still runs", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + // Link a producer, then delete the producer dir WITHOUT bun unlink. + // The symlink at /no-deps now dangles. + { + using doomed = await setupLinkedNoDeps(env); + await rm(String(doomed), { recursive: true, force: true }); + // Confirm the dangling state: the link exists but its target doesn't. + const linkPath = join(String(home), "install", "global", "node_modules", "no-deps"); + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(existsSync(linkPath)).toBe(false); + } + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-dangling", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + expect(existsSync(join(bodyDir, "package.json"))).toBe(true); + // Registry tarball, not the deleted producer. + expect(existsSync(join(bodyDir, "marker.js"))).toBe(false); + }); + + test("isolated: link overrides only the direct-dep resolution, not other versions of the name", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + using producer = await setupLinkedNoDeps(env); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + // `one-dep@1.0.0` depends on `no-deps@1.0.1`, so the lockfile resolves + // two versions of the linked name: 1.0.0 (direct) and 1.0.1 + // (transitive). Only the direct resolution may take the producer body — + // stamping it into the transitive copy would hand `one-dep` a version + // it never asked for (hoisted-linker parity: `npm link` only replaces + // the top-level `node_modules/`). + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-multiversion", + dependencies: { "no-deps": "1.0.0", "one-dep": "1.0.0" }, + }), + ); + + await using p = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([p.stdout.text(), p.stderr.text(), p.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const directBody = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + const transitiveBody = join(packageDir, "node_modules", ".bun", "no-deps@1.0.1", "node_modules", "no-deps"); + // Direct resolution: producer body. + expect(existsSync(join(directBody, "marker.js"))).toBe(true); + // Transitive different-version copy: registry tarball, no marker. + expect(existsSync(join(transitiveBody, "package.json"))).toBe(true); + expect(existsSync(join(transitiveBody, "marker.js"))).toBe(false); + void producer; + }); + + // Capability: the installed entry must reflect what a published package + // would contain — NOT the producer's working tree. + // + // The source of truth is `bun pm pack --dry-run`: whatever that reports + // as the published file set IS the set we must materialize in the + // consumer's `.bun///`. Expressing the contract this way + // binds the linker's behavior to bun's own publish semantics (which + // already handle `package.json#files`, `.npmignore`, default excludes, + // etc.), so the test doesn't go stale when those rules evolve. + // The current implementation applies only a default basename skip list + // (Installer.rs) on every platform, so `files` whitelists and + // `.npmignore` are not honored yet; these tests stay todo until pack's + // publishable-path selection is ported to the linked-producer copy. + test.todo("isolated: .bun entry contains exactly what `bun pm pack` would publish", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + // Producer shaped like a real repo: publishable content at top level + // plus a pile of things `bun publish` would strip. + using producer = tempDir("linkpkg-realrepo-", { + "package.json": JSON.stringify({ name: "no-deps", version: "1.0.0" }), + "README.md": "# content\n", + "index.js": "module.exports = 'FROM_PRODUCER';", + "dist/lib.js": "module.exports = 'BUILT_OUTPUT';", + ".git/HEAD": "ref: refs/heads/main\n", + ".git/config": "[core]\n", + ".github/workflows/ci.yml": "name: ci\n", + ".vscode/settings.json": "{}", + ".idea/workspace.xml": "", + ".DS_Store": "\x00", + "src/index.ts": "export const x = 1;", + "node_modules/leaked-dep/package.json": JSON.stringify({ name: "leaked-dep", version: "1.0.0" }), + }); + + // Ask bun what it considers the published file set. + await using packProc = spawn({ + cmd: [bunExe(), "pm", "pack", "--dry-run"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [packStdout, packStderr, packExit] = await Promise.all([ + packProc.stdout.text(), + packProc.stderr.text(), + packProc.exited, + ]); + if (packExit !== 0) throw new Error(`bun pm pack failed:\n${packStderr}`); + const publishedFiles = new Set(); + for (const line of packStdout.split("\n")) { + // `bun pm pack --dry-run` lists entries as: "packed " + const m = line.match(/^packed\s+\S+\s+(.+?)\s*$/); + if (m) publishedFiles.add(m[1]); + } + // Sanity: pack MUST report package.json, otherwise the test's source + // of truth is broken and the comparison below is meaningless. + expect(publishedFiles.has("package.json")).toBe(true); + + await using linkProc = spawn({ + cmd: [bunExe(), "link"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, linkStderr, linkExit] = await Promise.all([ + linkProc.stdout.text(), + linkProc.stderr.text(), + linkProc.exited, + ]); + if (linkExit !== 0) throw new Error(`bun link failed: ${linkStderr}`); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-filter", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + const installedFiles = new Set(await listFilesRecursive(bodyDir)); + + // Capability assertion: installed contents === publishable contents. + // Sort for a readable diff on failure. + const sortedInstalled = [...installedFiles].sort(); + const sortedPublished = [...publishedFiles].sort(); + expect(sortedInstalled).toEqual(sortedPublished); + }); + + // Real producers restrict the published tree with `package.json#files`. + // Content-ui (the motivating repo) uses `"files": ["dist"]` — everything + // else (src/, docs/, .storybook/, config files) is dev-only and must not + // end up inside a consumer's `.bun///`. Same capability + // assertion as the prior test; the producer shape is what changes. + test.todo("isolated: .bun entry honors producer's package.json#files whitelist", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + using producer = tempDir("linkpkg-fileswl-", { + "package.json": JSON.stringify({ name: "no-deps", version: "1.0.0", files: ["dist"] }), + "README.md": "# content\n", + "dist/bundle.js": "module.exports = 'BUILT';", + "src/index.ts": "export {}", + "docs/guide.md": "# docs\n", + ".storybook/main.js": "module.exports = {};", + ".github/workflows/ci.yml": "name: ci\n", + "tsconfig.json": "{}", + "vite.config.ts": "export default {};", + }); + + await using packProc = spawn({ + cmd: [bunExe(), "pm", "pack", "--dry-run"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [packStdout, packStderr, packExit] = await Promise.all([ + packProc.stdout.text(), + packProc.stderr.text(), + packProc.exited, + ]); + if (packExit !== 0) throw new Error(`bun pm pack failed:\n${packStderr}`); + const publishedFiles = new Set(); + for (const line of packStdout.split("\n")) { + const m = line.match(/^packed\s+\S+\s+(.+?)\s*$/); + if (m) publishedFiles.add(m[1]); + } + // Sanity: `files: ["dist"]` should restrict to package.json + README + + // dist/*. If bun pm pack reports more than that, either the test's + // producer shape drifted or publish semantics changed — either way + // the capability assertion below is no longer a useful test. + expect([...publishedFiles].sort()).toEqual(["README.md", "dist/bundle.js", "package.json"]); + + await using linkProc = spawn({ + cmd: [bunExe(), "link"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, linkStderr, linkExit] = await Promise.all([ + linkProc.stdout.text(), + linkProc.stderr.text(), + linkProc.exited, + ]); + if (linkExit !== 0) throw new Error(`bun link failed: ${linkStderr}`); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-consumer-files", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + const installedFiles = new Set(await listFilesRecursive(bodyDir)); + + expect([...installedFiles].sort()).toEqual([...publishedFiles].sort()); + }); + + // Regression: when a producer has `files: ["build/**/*"]` AND a directory + // sibling at root that shares its name with a directory nested under + // `build/`, the root-level whitelist must not strip the nested copy. + // Real-world repro: @amboss/design-system has both `/assets/` (dev + // SVG sources, excluded from publish) and `/build/esm/web-tokens/ + // assets/` (built JSON shipped via files). A basename-only skip list + // drops both; npm publish (and we) must keep the nested one. + test.todo("isolated: root-only `files` exclusion does not strip same-named nested dirs", async () => { + using home = tempDir("link-home-", {}); + const env = hermeticEnv(String(home)); + + using producer = tempDir("linkpkg-nested-same-name-", { + "package.json": JSON.stringify({ name: "no-deps", version: "1.0.0", files: ["build/**/*"] }), + "README.md": "# nested\n", + // Root `assets/` — excluded by `files`. + "assets/icon.svg": "", + // Nested `assets/` under whitelisted `build/` — must survive. + "build/esm/web-tokens/assets/icons.json": "{}", + "build/esm/web-tokens/assets/logo.json": "{}", + "build/esm/index.js": "module.exports = 'BUILT';", + // Other dev junk siblings to assets/ at root, also excluded. + "src/index.ts": "export {}", + "docs/guide.md": "# docs\n", + }); + + await using linkProc = spawn({ + cmd: [bunExe(), "link"], + cwd: String(producer), + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, linkStderr, linkExit] = await Promise.all([ + linkProc.stdout.text(), + linkProc.stderr.text(), + linkProc.exited, + ]); + if (linkExit !== 0) throw new Error(`bun link failed: ${linkStderr}`); + + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + await write( + packageJson, + JSON.stringify({ + name: "isolated-link-nested-same-name", + dependencies: { "no-deps": "1.0.0" }, + }), + ); + + await using installProc = spawn({ + cmd: [bunExe(), "install", "--backend=hardlink"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([ + installProc.stdout.text(), + installProc.stderr.text(), + installProc.exited, + ]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + + const bodyDir = join(packageDir, "node_modules", ".bun", "no-deps@1.0.0", "node_modules", "no-deps"); + const installedFiles = new Set(await listFilesRecursive(bodyDir)); + + // Nested assets/ under build/ must be present. + expect(installedFiles.has("build/esm/web-tokens/assets/icons.json")).toBe(true); + expect(installedFiles.has("build/esm/web-tokens/assets/logo.json")).toBe(true); + // Root assets/ must be excluded. + expect(installedFiles.has("assets/icon.svg")).toBe(false); + // Other excluded siblings stay out. + expect(installedFiles.has("src/index.ts")).toBe(false); + expect(installedFiles.has("docs/guide.md")).toBe(false); + }); +});