Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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 one is known. 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
130 changes: 98 additions & 32 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 @@ -337,7 +339,11 @@
unsafe { (*this).cache_directory_path = ZBox::from_bytes(&cache_dir.path) };

match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) {
Ok(d) => return d,
Ok(d) => {
// SAFETY: see fn safety contract.
unsafe { (*this).cache_directory_id = read_or_create_cache_directory_id(&d) };
return d;

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

View check run for this annotation

Claude / Claude Code Review

Stale SAFETY contract omits new cache_directory_id write

The `# Safety` doc comments on `get_cache_directory_raw` ("only the disjoint `cache_directory`, `cache_directory_path`, `options.enable`, and `env` fields are projected") and `ensure_cache_directory` ("only `options.enable`, `options.cache_directory` (read), `env`, and `cache_directory_path` are touched") were not updated for the new `(*this).cache_directory_id = …` writes at lines 344 and 370, so the inline `// SAFETY: see fn safety contract` points at a contract that doesn't cover this field.
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}
Err(_) => {
// SAFETY: narrow `&mut enable` projection; disjoint from
// any `&options.{registries,scope}` the caller may hold.
Expand All @@ -359,7 +365,11 @@
};

match Dir::cwd().make_open_path(b"node_modules/.cache", Default::default()) {
Ok(d) => return d,
Ok(d) => {
// SAFETY: see fn safety contract.
unsafe { (*this).cache_directory_id = read_or_create_cache_directory_id(&d) };
return d;
}
Err(err) => {
bun_core::pretty_errorln!(
"<r><red>error<r>: bun is unable to write files: {}",
Expand All @@ -371,6 +381,36 @@
}
}

/// `<cache>/.id`: 16 random bytes created once per cache directory; npm cache
/// entry fingerprints are keyed with it. An unreadable file is replaced,
/// which only means fingerprinted entries are fetched again.
fn read_or_create_cache_directory_id(cache_dir: &Dir) -> integrity::CacheDirId {
let name = bun_core::zstr!(".id");
let read = |id: &mut integrity::CacheDirId| -> bool {
File::openat(cache_dir.fd(), name.as_bytes(), sys::O::RDONLY, 0)
.and_then(|file| file.read_all(id))
.is_ok_and(|len| len == id.len())
};
let mut id: integrity::CacheDirId = [0; 16];
for _ in 0..2 {
if read(&mut id) {
return id;
}
bun_boringssl_sys::rand_bytes(&mut id);
let _ = sys::unlinkat(cache_dir.fd(), name);
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,
_ => continue,
}
}
id
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}

pub struct CacheDir {
pub path: Vec<u8>,
}
Expand Down Expand Up @@ -433,8 +473,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 +536,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 +667,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 +740,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 +794,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 +898,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 +918,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 +967,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 +988,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 +999,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 +1331,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