Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
137 changes: 103 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,43 @@
}
}

/// `<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 the key falls back to zeroes so names stay stable.
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(_) => return [0; 16],
},
Err(err) if err.get_errno() == sys::E::ENOENT => {}
Err(_) => return [0; 16],
}
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,
0o644,
) {

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

View check run for this annotation

Claude / Claude Code Review

.id created with mode 0o644; PR description and harness say 0o600

The `.id` file is opened with mode `0o644` here, but the PR description says "created (`O_EXCL`, mode 0600)" and the test-harness twin `installCacheFolderName` in test/harness.ts writes it with `{ flag: "wx", mode: 0o600 }`. The security impact is negligible (the id is only a keying salt), but the three sources should agree — either change this to `0o600` or update the description and harness.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
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 414 in src/install/PackageManager/PackageManagerDirectories.rs

View check run for this annotation

Claude / Claude Code Review

loop-exhaustion fallback returns random bytes, doc says zeroes

The doc comment says "if the file cannot be read or created at all the key falls back to zeroes so names stay stable", and the `Err(_) => return [0; 16]` arms honour that — but the loop-exhaustion fallthrough here calls `rand_bytes(&mut id)` and returns fresh random bytes instead. Reachable via 3× `Ok(0) => continue` or 3× ENOENT-then-EEXIST races; two processes that both hit it get different ids and orphan each other's fingerprinted entries, which is what the zero fallback is meant to prevent.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}

pub struct CacheDir {
pub path: Vec<u8>,
}
Expand Down Expand Up @@ -433,8 +476,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 +539,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 +670,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 +743,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 +797,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 +901,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 +921,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 +970,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 +991,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 +1002,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 +1334,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