Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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}`, followed by a 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.

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}`, followed by a fingerprint of the tarball's integrity hash when the registry or lockfile provides one, so multiple versions of a package can be cached.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

<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@@@1_integrity=<hex>/ # Package cache (unchanged)
│ └── ...package files...
└── links/ # Global virtual store
└── react@18.3.1-5664d3cd670b3205/ # <storepath>-<entry hash>
Expand Down
1 change: 1 addition & 0 deletions src/install/PackageInstaller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,7 @@ impl<'a> PackageInstaller<'a> {
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());
Expand Down
65 changes: 38 additions & 27 deletions src/install/PackageManager/PackageManagerDirectories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::repository::Repository;
use bun_core::ZStr;
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 @@ -433,8 +434,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>_integrity=24hex_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 +497,16 @@ impl<'a> ByteCursor<'a> {
}
}

/// `_integrity=<hex>` — leading bytes of the digest the entry was fetched
/// for, so entries fetched for different digests of one version coexist.
#[inline(always)]
fn put_integrity(&mut self, integrity: &Integrity) {
if let Some(fingerprint) = integrity.fingerprint() {
self.put(b"_integrity=");
self.at += bun_fmt::bytes_to_hex_lower(fingerprint, &mut self.buf[self.at..]);
}
}

/// `_patch_hash={x}` when set.
#[inline(always)]
fn put_patch_hash(&mut self, hash: Option<u64>) {
Expand Down Expand Up @@ -615,45 +626,38 @@ 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));
w.put_integrity(integrity);
w.put_patch_hash(patch_hash);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
w.finish_z()
}
Expand All @@ -680,13 +684,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 +738,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,6 +842,7 @@ 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 mut cache_path_buf = PathBuffer::uninit();

Expand All @@ -844,6 +851,7 @@ pub fn path_for_cached_npm_path<'a>(
&mut cache_path_buf.0[..],
package_name,
version,
integrity,
None,
);
let cache_path_len = cache_path.as_bytes().len();
Expand Down Expand Up @@ -904,8 +912,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 +933,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,7 +944,8 @@ 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_subpath =
cached_npm_package_folder_name(manager, name, version, integrity, patch_hash);
cache_dir = get_cache_directory(manager);
}
ResolutionTag::Git => {
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
1 change: 1 addition & 0 deletions src/install/PackageManager/PackageManagerLifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,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 @@ -204,10 +204,11 @@
let mut buf = PathBuffer::uninit();
let npm_package_path = match super::path_for_cached_npm_path(
self,
&mut buf,
package_name,
installed_version,
&crate::Integrity::default(),
) {

Check failure on line 211 in src/install/PackageManager/PackageManagerResolution.rs

View check run for this annotation

Claude / Claude Code Review

resolve_from_disk_cache never finds cached npm packages (offline auto-install broken)

`resolve_from_disk_cache` passes `&Integrity::default()` to `path_for_cached_npm_path`, so it looks up `<name>/<version>@@@1` — but the version-index symlink is now written at `<name>/<version>@@@1_integrity=<hex>` (extract_tarball.rs derives `dest_name` from the integrity-suffixed `folder_name`; the PR's own bun-run-dir.test.ts asserts this). `readlinkat` therefore ENOENTs on every entry, and since this is the sole disk-cache resolution path for `OfflineMode::Offline` (resolver.rs:3634), `bun -
Comment thread
claude[bot] marked this conversation as resolved.
Ok(p) => p,
Err(err) => {
bun_core::debug!("error getting path for cached npm path: {}", err.name());
Expand Down
4 changes: 4 additions & 0 deletions src/install/PackageManager/patchPackage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ pub fn do_patch_commit(
manager,
&name,
&resolution_clone,
&actual_package.meta.integrity,
&mut folder_path_buf,
None,
);
Expand Down Expand Up @@ -289,6 +290,7 @@ pub fn do_patch_commit(
manager,
&pkg_name_slice,
&resolution_clone,
&pkg.meta.integrity,
&mut folder_path_buf,
None,
);
Expand Down Expand Up @@ -889,6 +891,7 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> {
manager,
&name,
&actual_package.resolution,
&actual_package.meta.integrity,
&mut folder_path_buf,
existing_patchfile_hash,
);
Expand Down Expand Up @@ -949,6 +952,7 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> {
manager,
&pkg_name,
&pkg_resolution,
&pkg.meta.integrity,
&mut folder_path_buf,
existing_patchfile_hash,
);
Expand Down
16 changes: 10 additions & 6 deletions src/install/PackageManagerTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ impl Id {
self.0
}

pub(crate) fn for_npm_package(package_name: &[u8], package_version: semver::Version) -> Id {
pub(crate) fn for_npm_package(
package_name: &[u8],
package_version: semver::Version,
integrity: &crate::Integrity,
) -> Id {
let mut hasher = Wyhash11::init(0);
hasher.update(b"npm-package:");
hasher.update(package_name);
Expand All @@ -117,6 +121,9 @@ impl Id {
core::mem::size_of::<semver::Version>(),
)
});
if let Some(fingerprint) = integrity.fingerprint() {
hasher.update(fingerprint);
}
Id(hasher.final_())
}

Expand All @@ -134,13 +141,10 @@ impl Id {
Id(hasher.final_())
}

// These cannot change:
// We persist them to the filesystem.
// Persisted to the filesystem: this is the name of the bare clone in the cache directory.
pub(crate) fn for_git_clone(url: &[u8]) -> Id {
let mut hasher = Wyhash11::init(0);
hasher.update(url);
// @truncate to u61 then widen to u64 — keep low 61 bits
Id((4u64 << 61) | (hasher.final_() & ((1u64 << 61) - 1)))
Id((4u64 << 61) | (crate::integrity::sha256_prefix_u64(url) & ((1u64 << 61) - 1)))
}

pub(crate) fn for_git_checkout(url: &[u8], resolved: &[u8]) -> Id {
Expand Down
1 change: 1 addition & 0 deletions src/install/extract_tarball.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ impl ExtractTarball {
&mut bufs.folder_name_buf,
name,
self.resolution.npm().version,
&self.integrity,
None,
)
.as_bytes()
Expand Down
20 changes: 20 additions & 0 deletions src/install/integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ impl Default for Integrity {

const EMPTY_DIGEST_BUF: [u8; DIGEST_BUF_LEN] = [0u8; DIGEST_BUF_LEN];

pub const FINGERPRINT_LEN: usize = 12;

/// First 8 bytes of SHA-256 over `bytes`, for naming cache entries after a URL.
pub fn sha256_prefix_u64(bytes: &[u8]) -> u64 {
let mut hasher = Crypto::SHA256::init();
hasher.update(bytes);
let mut digest = [0u8; SHA256_DIGEST_LEN];
hasher.r#final(&mut digest);
u64::from_be_bytes(digest[..8].try_into().expect("infallible: size matches"))
}

const DIGEST_BUF_LEN: usize = {
let mut m = SHA1_DIGEST_LEN;
if SHA512_DIGEST_LEN > m {
Expand Down Expand Up @@ -171,6 +182,15 @@ impl Integrity {
&self.value[0..self.tag.digest_len()]
}

/// Leading digest bytes used to tell cache entries for the same
/// name@version apart; `None` when there is no supported digest.
pub fn fingerprint(&self) -> Option<&[u8]> {
if !self.tag.is_supported() {
return None;
}
Some(&self.value[..FINGERPRINT_LEN])
}

/// Compute a sha512 integrity hash from raw bytes (e.g. a downloaded tarball).
pub(crate) fn for_bytes(bytes: &[u8]) -> Integrity {
const LEN: usize = SHA512_DIGEST_LEN;
Expand Down
2 changes: 2 additions & 0 deletions src/install/isolated_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,7 @@ pub(crate) fn install_isolated_packages(
let pkg_names = pkgs.items_name();
let pkg_name_hashes = pkgs.items_name_hash();
let pkg_resolutions = pkgs.items_resolution();
let pkg_metas = pkgs.items_meta();

let mut seen_entry_ids: HashMap<store::entry::Id, ()> = HashMap::default();
seen_entry_ids.reserve(store.entries.len());
Expand Down Expand Up @@ -2318,6 +2319,7 @@ pub(crate) fn install_isolated_packages(
installer.manager(),
pkg_name.slice(string_buf),
pkg_res.npm().version,
&pkg_metas[pkg_id as usize].integrity,
patch_info.contents_hash(),
),
ResolutionTag::Git => package_manager::cached_git_folder_name(
Expand Down
1 change: 1 addition & 0 deletions src/install/isolated_install/Installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ impl Task {
manager,
pkg_name.slice(string_buf),
pkg_res.npm().version,
&pkg_metas[pkg_id as usize].integrity,
patch_info.contents_hash(),
),
ResolutionTag::Git => directories::cached_git_folder_name(
Expand Down
Loading
Loading