Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pm/cli/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ Environment variables take priority over `bunfig.toml`.

Bun uses the fastest installation method available on the target platform: `clonefile` on macOS and `hardlink` on Linux. You can change the installation method with the `--backend` flag. When unavailable or on error, `clonefile` and `hardlink` fall back to a platform-specific implementation of copying files.

Bun stores installed packages from npm in `~/.bun/install/cache/${name}@${version}`. If the semver version has a `build` or a `pre` tag, Bun replaces it with a hash of that value. This reduces the chances of errors from long file paths, but complicates figuring out where a package was installed on disk.
Bun stores installed packages from npm in `~/.bun/install/cache/${name}@${version}@@@2`, followed by `.` and a short fingerprint of the tarball's integrity hash when the registry or lockfile provides one and integrity verification is enabled (it is omitted with `--no-verify`, and for the rare package name long enough that the folder name would exceed the filesystem's name-length limit). If the semver version has a `build` or a `pre` tag, Bun replaces it with a hash of that value. This reduces the chances of errors from long file paths, but complicates figuring out where a package was installed on disk.

When the `node_modules` folder exists, Bun decides whether to install a package by checking that the `"name"` and `"version"` in its `package.json` at the expected `node_modules` location match the expected name and version. It uses a custom JSON parser which stops parsing as soon as it finds `"name"` and `"version"`.

Expand Down
2 changes: 1 addition & 1 deletion docs/pm/global-cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Global cache"
description: "How Bun stores and manages packages in its global cache"
---

Bun stores every package downloaded from the registry in a global cache at `~/.bun/install/cache`, or the path set by the `BUN_INSTALL_CACHE_DIR` environment variable. Packages live in subdirectories named like `${name}@${version}`, so multiple versions of a package can be cached.
Bun stores every package downloaded from the registry in a global cache at `~/.bun/install/cache`, or the path set by the `BUN_INSTALL_CACHE_DIR` environment variable. Packages live in subdirectories named like `${name}@${version}`, so multiple versions of a package can be cached side by side. When the registry or lockfile provides an integrity hash for the tarball, a short fingerprint of it is appended to the directory name as well, so entries for the same name and version whose tarballs have different digests are kept apart.

<Accordion title="Configuring cache behavior">

Expand Down
2 changes: 1 addition & 1 deletion docs/pm/global-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ The on-disk layout adds one level of indirection compared to [isolated installs]

```bash tree layout icon="list-tree"
~/.bun/install/cache/
├── react@18.3.1@@@1/ # Package cache (unchanged)
├── react@18.3.1@@@2.<fingerprint>/ # Package cache
│ └── ...package files...
└── links/ # Global virtual store
└── react@18.3.1-5664d3cd670b3205/ # <storepath>-<entry hash>
Expand Down
3 changes: 2 additions & 1 deletion src/install/PackageInstaller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1425,13 +1425,14 @@ impl<'a> PackageInstaller<'a> {

match resolution.tag {
resolution::Tag::Npm => {
installer.cache_dir = package_manager::get_cache_directory(self.manager_mut());
installer.cache_dir_subpath = package_manager::cached_npm_package_folder_name(
self.manager_mut(),
pkg_name.slice(string_buf!()),
resolution.npm().version,
&self.metas[package_id as usize].integrity,
patch_contents_hash,
);
installer.cache_dir = package_manager::get_cache_directory(self.manager_mut());
}
resolution::Tag::Git => {
installer.cache_dir_subpath = package_manager::cached_git_folder_name(
Expand Down
3 changes: 3 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ bun_output::declare_scope!(PackageManager, hidden);
pub struct PackageManager {
pub(crate) cache_directory: Option<bun_sys::Dir>,
pub(crate) cache_directory_path: ZBox, // owned; process lifetime via the leaked singleton
pub(crate) cache_directory_id: crate::integrity::CacheDirId,
pub root_dir: &'static mut fs::DirEntry,
// allocator dropped per §Allocators (was `bun.default_allocator`). For the
// handful of sites that allocated AST nodes via `Expr.allocate(manager.allocator, …)`
Expand Down Expand Up @@ -1857,6 +1858,7 @@ pub fn init(

wr!(cache_directory, None);
wr!(cache_directory_path, ZBox::from_bytes(b""));
wr!(cache_directory_id, [0; 16]);
wr!(options, options);
wr!(
active_lifecycle_scripts,
Expand Down Expand Up @@ -2284,6 +2286,7 @@ fn init_with_runtime_once(

wr!(cache_directory, None);
wr!(cache_directory_path, ZBox::from_bytes(b""));
wr!(cache_directory_id, [0; 16]);
wr!(
options,
Options {
Expand Down
138 changes: 104 additions & 34 deletions src/install/PackageManager/PackageManagerDirectories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
use crate::lockfile_real::package::PackageColumns;
use crate::repository::Repository;
use bun_core::ZStr;
use bun_core::strings;
use bun_core::{Global, Output, ZBox, env_var, fmt as bun_fmt};
use bun_dotenv::Loader as DotEnvLoader;
use bun_install::integrity::{self, Integrity};
use bun_install::lockfile::{Format as LockfileFormat, LoadResult, Lockfile};
use bun_install::resolution::Tag as ResolutionTag;
use bun_install::{PackageID, Resolution};
Expand Down Expand Up @@ -83,8 +85,8 @@
/// Raw-pointer entry for callers that hold a disjoint `&mut this.manifests`
/// borrow (see `PackageManifestMap::by_name_hash_allow_expired`). Never
/// materializes a `&mut PackageManager` covering the whole struct — only the
/// disjoint `cache_directory`, `cache_directory_path`, `options.enable`, and
/// `env` fields are projected, so an outstanding `&mut manifests` derived
/// disjoint `cache_directory`, `cache_directory_id`, `cache_directory_path`,
/// `options.enable`, and `env` fields are projected, so an outstanding `&mut manifests` derived
/// from the same provenance root stays valid under Stacked Borrows.
///
/// # Safety
Expand All @@ -101,8 +103,12 @@
// `options.enable`/`options.cache_directory`/`env`/`cache_directory_path`.
let d = unsafe { ensure_cache_directory(this) };
let fd = d.fd();
// SAFETY: as above; single writer.
unsafe { (*this).cache_directory = Some(d) };
let id = read_or_create_cache_directory_id(&d);
// SAFETY: as above; single writer of `cache_directory` / `cache_directory_id`.
unsafe {
(*this).cache_directory_id = id;
(*this).cache_directory = Some(d);
}
fd
}

Expand Down Expand Up @@ -371,6 +377,44 @@
}
}

/// `<cache>/.id`: 16 random bytes created once per cache directory; npm cache
/// entry fingerprints are keyed with it. A truncated file is replaced (its
/// fingerprinted entries are then fetched again); if the file cannot be read
/// or created at all, a per-process random key is used and this run does not
/// reuse fingerprinted entries.
fn read_or_create_cache_directory_id(cache_dir: &Dir) -> integrity::CacheDirId {
let name = bun_core::zstr!(".id");
let mut id: integrity::CacheDirId = [0; 16];
for _ in 0..3 {
match File::openat(cache_dir.fd(), name.as_bytes(), sys::O::RDONLY, 0) {
Ok(file) => match file.read_all(&mut id) {
Ok(len) if len == id.len() => return id,
// just created by a concurrent install that has not written it yet
Ok(0) => continue,
Ok(_) => {
let _ = sys::unlinkat(cache_dir.fd(), name);
}
Err(_) => break,
},
Err(err) if err.get_errno() == sys::E::ENOENT => {}
Err(_) => break,
}
bun_boringssl_sys::rand_bytes(&mut id);
match File::openat(
cache_dir.fd(),
name.as_bytes(),
sys::O::WRONLY | sys::O::CREAT | sys::O::EXCL,
0o600,
) {
Ok(file) if file.write_all(&id).is_ok() => return id,
// created concurrently by another install: read theirs
_ => {}
}
}
bun_boringssl_sys::rand_bytes(&mut id);
id

Check warning on line 415 in src/install/PackageManager/PackageManagerDirectories.rs

View check run for this annotation

Claude / Claude Code Review

Persistently empty .id file is never repaired

The `Ok(0) => continue` arm never unlinks or falls through to the `O_CREAT|O_EXCL` block, so a persistently 0-byte `.id` (process killed between the exclusive create and `write_all`) is never repaired — every later process reads `Ok(0)` three times and falls through to a per-run `rand_bytes` key, so fingerprinted cache entries never hit and orphans accumulate. On the final iteration treat `Ok(0)` like the `Ok(_)` truncated case (unlink and fall through), or write to a temp name and `renameat` so
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}

pub struct CacheDir {
pub path: Vec<u8>,
}
Expand Down Expand Up @@ -433,8 +477,8 @@
/// Append-only cursor over a caller-owned `&mut [u8]`. All writers are
/// infallible: the destination is always a `PathBuffer` (`MAX_PATH_BYTES`,
/// asserted ≥ 1024 elsewhere) and the longest possible payload here —
/// `name@u64.u64.u64-16hex+16HEX@@@<ver>_patch_hash=16hex\0` plus an
/// `@@host__16hex` scope suffix — is bounded well under that. Debug builds
/// `name@u64.u64.u64-16hex+16HEX@@@<ver>.13base32_patch_hash=16hex\0`
/// plus an `@@host__16hex` scope suffix — is bounded well under that. Debug builds
/// keep the bounds check; release elides it so no panic-format code is
/// reachable from this module.
struct ByteCursor<'a> {
Expand Down Expand Up @@ -496,6 +540,18 @@
}
}

/// `.` + 13 chars of lowercase base32: a keyed fingerprint of the digest
/// the entry was fetched for, so entries fetched for different digests of
/// one version coexist.
#[inline(always)]
fn put_fingerprint(&mut self, fingerprint: u64) {
const CHARS: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
self.put_byte(b'.');
for i in 0..13 {
self.put_byte(CHARS[((fingerprint >> (60 - 5 * i)) & 31) as usize]);
}
}

/// `_patch_hash={x}` when set.
#[inline(always)]
fn put_patch_hash(&mut self, hash: Option<u64>) {
Expand Down Expand Up @@ -615,45 +671,53 @@
buf: &'a mut [u8],
name: &[u8],
version: Semver::Version,
integrity: &Integrity,
patch_hash: Option<u64>,
) -> &'a ZStr {
let scope = this.scope_for_package_name(name);

if scope.name.is_empty() && !this.options.did_override_default_scope {
let include_version_number = true;
return cached_npm_package_folder_print_basename(
buf,
name,
version,
patch_hash,
include_version_number,
);
}

let include_version_number = false;
let spanned_len =
cached_npm_package_folder_print_basename(buf, name, version, None, include_version_number)
.as_bytes()
.len();
// reshaped for borrowck — resume the cursor at the basename's
// tail instead of holding the returned `&ZStr` across the re-borrow.
let scope_url = scope.url.url();
let mut w = ByteCursor {
buf,
at: spanned_len,
};
let available = w.buf.len() - spanned_len;
if scope_url.hostname.len() > 32 || available < 64 {
let visible_hostname = &scope_url.hostname[..scope_url.hostname.len().min(12)];
w.put(b"@@");
w.put(visible_hostname);
w.put(b"__");
w.put_u64_hex16::<true>(Semver::semver_string::Builder::string_hash(scope_url.href));
} else {
w.put(b"@@");
w.put(scope_url.hostname);
if !scope.name.is_empty() || this.options.did_override_default_scope {
let scope_url = scope.url.url();
let available = w.buf.len() - spanned_len;
if scope_url.hostname.len() > 32 || available < 64 {
let visible_hostname = &scope_url.hostname[..scope_url.hostname.len().min(12)];
w.put(b"@@");
w.put(visible_hostname);
w.put(b"__");
w.put_u64_hex16::<true>(Semver::semver_string::Builder::string_hash(scope_url.href));
} else {
w.put(b"@@");
w.put(scope_url.hostname);
}
}
w.put_cache_version(Some(CacheVersion::CURRENT));
debug_assert!(this.cache_directory.is_some());
// With verification off nothing vouches for the digest, so such entries
// share the digest-less name and a verifying install fetches its own copy.
if this.options.do_.contains(options::Do::VERIFY_INTEGRITY)
&& let Some(fingerprint) = integrity.fingerprint(&this.cache_directory_id)
{
// NAME_MAX: leave the fingerprint off rather than produce a component
// the filesystem rejects (only reachable with ~200-byte package names).
// Decided on the unpatched name so both shapes share the same prefix.
const SUFFIXES_LEN: usize = ".".len() + 13 + "_patch_hash=".len() + 16;
let component_start =
strings::last_index_of_char(&w.buf[..w.at], b'/').map_or(0, |i| i + 1);
if w.at - component_start + SUFFIXES_LEN <= 255 {
w.put_fingerprint(fingerprint);
}
}
w.put_patch_hash(patch_hash);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
w.finish_z()
}
Expand All @@ -680,13 +744,15 @@
this: &PackageManager,
name: &[u8],
version: Semver::Version,
integrity: &Integrity,
patch_hash: Option<u64>,
) -> &'static ZStr {
cached_npm_package_folder_name_print(
this,
cached_package_folder_name_buf(),
name,
version,
integrity,
patch_hash,
)
}
Expand Down Expand Up @@ -732,7 +798,7 @@
) -> &'a ZStr {
let mut w = ByteCursor::new(buf);
w.put(b"@T@");
w.put_u64_hex16::<true>(Semver::semver_string::Builder::string_hash(url));
w.put_u64_hex16::<true>(integrity::sha256_prefix_u64(&[url]));
w.put_cache_version(Some(CacheVersion::CURRENT));
w.put_patch_hash(patch_hash);
w.finish_z()
Expand Down Expand Up @@ -836,14 +902,17 @@
buf: &'a mut PathBuffer,
package_name: &[u8],
version: Semver::Version,
integrity: &Integrity,
) -> Result<&'a mut [u8], Error> {
let cache_dir: Fd = get_cache_directory(this);
let mut cache_path_buf = PathBuffer::uninit();

let cache_path = cached_npm_package_folder_name_print(
this,
&mut cache_path_buf.0[..],
package_name,
version,
integrity,
None,
);
let cache_path_len = cache_path.as_bytes().len();
Expand All @@ -853,8 +922,6 @@

cache_path_buf[package_name.len()] = SEP;

let cache_dir: Fd = get_cache_directory(this);

#[cfg(windows)]
{
let _ = cache_dir;
Expand Down Expand Up @@ -904,8 +971,9 @@
// mutably (for `get_cache_directory`), so the `&this.lockfile`
// borrow can't be held across it. Copy the name out first.
let package_name = this.lockfile.str(&package_name_).to_vec();
let integrity = this.lockfile.packages.items_meta()[package_id as usize].integrity;

path_for_cached_npm_path(this, buf, &package_name, npm.version)
path_for_cached_npm_path(this, buf, &package_name, npm.version, &integrity)
Comment thread
claude[bot] marked this conversation as resolved.
}
_ => Ok(&mut buf.0[..0]),
}
Expand All @@ -924,6 +992,7 @@
manager: &mut PackageManager,
pkg_name: &[u8],
resolution: &Resolution,
integrity: &Integrity,
folder_path_buf: &'a mut PathBuffer,
patch_hash: Option<u64>,
) -> CacheDirAndSubpath<'a> {
Expand All @@ -934,8 +1003,9 @@
match resolution.tag {
ResolutionTag::Npm => {
let version = resolution.npm().version;
cache_dir_subpath = cached_npm_package_folder_name(manager, name, version, patch_hash);
cache_dir = get_cache_directory(manager);
cache_dir_subpath =
cached_npm_package_folder_name(manager, name, version, integrity, patch_hash);
}
ResolutionTag::Git => {
let git = resolution.git();
Expand Down Expand Up @@ -1265,7 +1335,7 @@

pub(crate) struct CacheVersion;
impl CacheVersion {
pub(crate) const CURRENT: usize = 1;
pub(crate) const CURRENT: usize = 2;
}

// ────────────────────────────── helpers ───────────────────────────────────────
Expand Down
7 changes: 6 additions & 1 deletion src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,11 @@ pub fn enqueue_package_for_download(
task_context: TaskCallbackContext,
patch_name_and_version_hash: Option<u64>,
) -> Result<(), EnqueuePackageForDownloadError> {
let task_id = Task::Id::for_npm_package(name, version);
let task_id = Task::Id::for_npm_package(
name,
version,
&this.lockfile.packages.items_meta()[package_id as usize].integrity,
);
if this.network_task_has_failed(task_id) {
return Err(EnqueuePackageForDownloadError::AlreadyFailed);
}
Expand Down Expand Up @@ -2097,6 +2101,7 @@ fn get_or_put_resolved_package_with_find_result(
let task_id = Task::Id::for_npm_package(
this.lockfile.str(&name),
package.resolution.npm().version,
&package.meta.integrity,
);
debug_assert!(!this.network_dedupe_map.contains(&task_id));

Expand Down
3 changes: 3 additions & 0 deletions src/install/PackageManager/PackageManagerLifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ impl PackageManager {
break 'brk Some(patched_dep.patchfile_hash().unwrap());
};

let _ = directories::get_cache_directory(self);

// SAFETY: each arm reads the union variant that matches the
// `pkg.resolution.tag` just dispatched on; `Resolution` is
// zero-initialised (`Value::zero()`) so even a stale tag yields
Expand All @@ -156,6 +158,7 @@ impl PackageManager {
self,
name,
pkg.resolution.npm().version,
&pkg.meta.integrity,
patch_hash,
)
}
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageManager/PackageManagerResolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ impl PackageManager {
&mut buf,
package_name,
installed_version,
&crate::Integrity::default(),
) {
Comment thread
claude[bot] marked this conversation as resolved.
Ok(p) => p,
Err(err) => {
Expand Down
Loading