Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
183 changes: 149 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::bun_fs::FileSystem;
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 @@ pub fn get_cache_directory(this: &mut PackageManager) -> Fd {
/// 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`, `options.log_level`, 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,30 @@ pub(crate) unsafe fn get_cache_directory_raw(this: *mut PackageManager) -> Fd {
// `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, id_err) = read_or_create_cache_directory_id(&d);
if let Some(err) = id_err {
// SAFETY: as above; `options.log_level` and `cache_directory_path` are
// covered by the caller contract and only read here.
let (log_level, cache_directory_path) = unsafe {
(
(*this).options.log_level,
(*this).cache_directory_path.as_bytes(),
)
};
if log_level != LogLevel::Silent {
bun_core::pretty_errorln!(
"<r><yellow>warn<r>: {} accessing {}{}.id; packages fetched by this install will not be reused from the cache",
bun_fmt::s(err.name()),
bun_fmt::s(cache_directory_path),
bun_fmt::s(path::SEP_STR.as_bytes()),
);
Comment thread
claude[bot] marked this conversation as resolved.
}
}
// 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 +395,71 @@ unsafe fn ensure_cache_directory(this: *mut PackageManager) -> Dir {
}
}

/// `<cache>/.id`: 16 random bytes created once per cache directory; npm cache
/// entry fingerprints are keyed with it. A file left empty or truncated gets a
/// fresh key written into it (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, Option<sys::Error>) {
let name = bun_core::zstr!(".id");
let mut id: integrity::CacheDirId = [0; 16];
let mut last_err: Option<sys::Error> = None;
for attempt 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, None),
// empty: another install may be between creating and writing it
Ok(0) if attempt < 2 => continue,
// still empty, or a wrong length: write a fresh key in place
Ok(_) => {
bun_boringssl_sys::rand_bytes(&mut id);
match File::openat(
cache_dir.fd(),
name.as_bytes(),
sys::O::WRONLY | sys::O::TRUNC,
0,
)
.and_then(|file| file.write_all(&id))
{
Ok(()) => return (id, None),
Err(err) => {
last_err = Some(err);
break;
}
}
}
Err(err) => {
last_err = Some(err);
break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
},
Err(err) if err.get_errno() == sys::E::ENOENT => {}
Err(err) => {
last_err = Some(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,
)
.and_then(|file| file.write_all(&id))
{
Ok(()) => return (id, None),
// created concurrently by another install: read theirs
Err(err) if err.get_errno() == sys::E::EEXIST => {}
Err(err) => last_err = Some(err),
}
}
bun_boringssl_sys::rand_bytes(&mut id);
(id, last_err)
}

pub struct CacheDir {
pub path: Vec<u8>,
}
Expand Down Expand Up @@ -433,8 +522,8 @@ pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Optio
/// 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 +585,18 @@ impl<'a> ByteCursor<'a> {
}
}

/// `.` + 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 +716,53 @@ pub fn cached_npm_package_folder_name_print<'a>(
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 +789,15 @@ pub fn cached_npm_package_folder_name(
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 +843,7 @@ pub fn cached_tarball_folder_name_print<'a>(
) -> &'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 +947,17 @@ pub fn path_for_cached_npm_path<'a>(
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 +967,6 @@ pub fn path_for_cached_npm_path<'a>(

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 +1016,9 @@ pub fn path_for_resolution<'a>(
// 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 +1037,7 @@ pub fn compute_cache_dir_and_subpath<'a>(
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 +1048,9 @@ pub fn compute_cache_dir_and_subpath<'a>(
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 +1380,7 @@ pub fn write_yarn_lock(this: &mut PackageManager) -> Result<(), Error> {

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

// ────────────────────────────── helpers ───────────────────────────────────────
Expand Down
Loading