Skip to content

install: cache file: tarballs under the integrity the lockfile pins - #39016

Open
robobun wants to merge 1 commit into
mainfrom
farm/a9b6ed4d/local-tarball-cache-by-integrity
Open

robobun wants to merge 1 commit into
mainfrom
farm/a9b6ed4d/local-tarball-cache-by-integrity

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • With a bun.lock present, a file:pkg.tgz dependency is installed from whatever the shared install cache holds under @T@<hash of "pkg.tgz">@@@1. Two projects that both depend on file:pkg.tgz but ship different tarballs overwrite each other's entry, and the next lockfile install of one project gets the other project's tarball; the same happens to one project switching between branches whose pkg.tgz differs. bun install reports 1 package installed, and the integrity in bun.lock is never compared against anything.
  • Cause: cached_tarball_folder_name_print (src/install/PackageManager/PackageManagerDirectories.rs) hashes the resolution string, and a local tarball's resolution is the path as written in package.json (relative to the project, or to the workspace), which is not an identity across projects. Only extraction verifies the integrity, and a lockfile install only extracts when the entry is missing (PackageInstall::package_missing_from_cache, and the isolated installer's probe in isolated_install.rs).

Fix

  • Local tarballs are cached under the integrity recorded for the package: @T@sha512-<first 16 digest bytes as hex>@@@1 (cached_local_tarball_folder_name), so the name equals sha512sum pkg.tgz | cut -c1-32. URL tarballs keep their URL-keyed names.
  • Extraction names the entry after the integrity it just verified, or computed when the lockfile had none (ExtractTarball::lockfile_integrity, passed into move_to_cache_directory like the GitHub resolved name), which is also the value written to the lockfile. So an entry is only reused for the bytes the lockfile pins, different tarballs at the same path coexist, and identical tarballs in different projects share one entry. This is the same model as git checkouts, which are cached under their commit rather than under the URL they were requested by.
  • A lockfile written before bun recorded tarball integrity cannot name an entry: the name is empty, both installers treat that as a cache miss and extract (package_missing_from_cache, the isolated probe; determine_preinstall_state already treated an empty name this way). The hoisted installer already recorded a computed integrity before re-running the waiting installs; the isolated installer now receives the extraction result (on_extract_store_installer) and does the same (Installer::record_extracted_integrity), so the entry can be named, the lockfile is re-saved with the integrity, and the next install is a cache hit. Under --frozen-lockfile such a lockfile keeps re-extracting the tarball and is left untouched.
  • compute_cache_dir_and_subpath (bun patch, bun patch --commit) takes the package's integrity and reports a missing one instead of diffing or copying against the cache root. In the normal flow it is present, because bun patch installs first and that install records and saves it.
  • Integrity::Tag::name() is shared between the folder name and Display; the Display output is unchanged.
  • Verified with test/cli/install/bun-install-tarball-integrity.test.ts, local tarball cache entries for both linkers: two projects sharing a cache keep their tarballs apart, a project reinstalling from a lockfile after another build of the tarball was extracted from the same path gets the pinned build, and a lockfile without an integrity installs from the tarball, records the integrity, and is then served from the entry it recorded. All six fail on the unfixed build at the installed-contents assertion.
  • Also run with the debug build: the rest of that file, bun-patch.test.ts (includes the file: tarball patch flow, which exercises the patched ..._patch_hash= entries), bun-install-patch.test.ts, bun-lock.test.ts, the tarball tests of bun-install.test.ts (except the one fetching from the public internet, which fails identically without this change), bun-add.test.ts and isolated-install.test.ts, and the file: tarball migration tests in migrate.test.ts and pnpm-lock-v9.test.ts. Manually: --frozen-lockfile with a lockfile lacking the integrity (correct contents, lockfile untouched), bun patch plus --commit starting from such a lockfile, workspace-relative file: tarballs with both linkers, two aliases of one tarball, and bun add ./pkg.tgz. cargo clippy -p bun_install is clean.
  • Existing caches are unaffected: the old @T@<path hash> entries are simply no longer looked up for local tarballs; each local tarball is extracted once more on its next install.

Background

  • Install cache: ~/.bun/install/cache (or BUN_INSTALL_CACHE_DIR) is shared by every project on the machine. Each package is extracted once into a folder there (name@version@@@1 for npm, @G@<commit> for git, @T@... for tarballs) and installs copy, hardlink or clone out of it; a cache hit is a directory probe, and the tarball bytes are verified only when the folder is created. @@@1 is the cache layout version.
  • Tarball integrity: for file: and URL tarballs bun hashes the tarball on first extraction and stores sha512-... as the third element of the package's bun.lock entry (["pkg@pkg.tgz", {}, "sha512-..."]); later extractions are verified against it. Lockfiles written before this was recorded, and some migrated lockfiles, have no integrity for these packages.
  • Install phase callbacks: when a package has to be extracted during the install phase, the installs waiting on it are queued on the extraction task and re-run when it completes (PackageInstaller::install_enqueued_packages_after_extraction for the hoisted linker, Installer::on_package_extracted for the isolated one). The extraction result (ExtractData) carries the integrity that was verified or computed.
  • Isolated installer threading: its per-package tasks run on the thread pool holding &Lockfile views, so the lockfile is only ever written through narrowed raw pointers from the main thread. record_extracted_integrity writes one row through the column's raw pointer (items_raw) before any task of that package starts.
Reproduction (no network)
export BUN_INSTALL_CACHE_DIR=/tmp/tgzcache
for v in one two; do
  mkdir -p $v/src/package
  echo '{"name":"pkg","version":"1.0.0"}' > $v/src/package/package.json
  echo "module.exports = '$v'" > $v/src/package/index.js
  tar -C $v/src -czf $v/pkg.tgz package
  echo '{"name":"app","dependencies":{"pkg":"file:pkg.tgz"}}' > $v/package.json
done
(cd one && bun install && bun -e 'console.log(require("pkg"))')   # one
(cd two && bun install && bun -e 'console.log(require("pkg"))')   # two
(cd one && rm -rf node_modules && bun install && bun -e 'console.log(require("pkg"))')

Before: the last line prints two (the cache holds one @T@85f77fa09e18a07f@@@1, last written by project two). After: one, and the cache holds one @T@sha512-...@@@1 entry per tarball.

A file: tarball's cache entry was named after the hash of its resolution
string, which is the path as written in package.json ("pkg.tgz"). That path
names a different tarball in every project sharing the cache, and an install
from a lockfile never reads the tarball itself, so whichever project extracted
last had its tarball installed into every other project depending on the same
relative path, with the lockfile's integrity never consulted.

Name the entry after the integrity recorded for the package instead
(@t@sha512-<digest prefix>@@@1), the same way git checkouts are named after
their commit. Extraction names the entry after the integrity it verified (or
computed, on first install), so an entry is only ever reused for the bytes the
lockfile pins, and different tarballs at the same path coexist. A lockfile
that predates tarball integrity cannot name an entry; such a package is
extracted, and both installers record the computed integrity before the
waiting installs run (the hoisted installer already did; the isolated one now
receives the extraction result and does the same), so the lockfile is re-saved
with it and the next install is a cache hit.

bun patch and patch --commit report a missing integrity instead of diffing or
copying against a folder that cannot be named. URL tarballs are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d1d2abc-3bfc-403f-8534-2c387b1c523c

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 7a66998.

📒 Files selected for processing (14)
  • src/install/PackageInstall.rs
  • src/install/PackageInstaller.rs
  • src/install/PackageManager.rs
  • src/install/PackageManager/PackageManagerDirectories.rs
  • src/install/PackageManager/PackageManagerLifecycle.rs
  • src/install/PackageManager/patchPackage.rs
  • src/install/PackageManager/runTasks.rs
  • src/install/TarballStream.rs
  • src/install/extract_tarball.rs
  • src/install/integrity.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • src/install/patch_install.rs
  • test/cli/install/bun-install-tarball-integrity.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on main with the two-project script in the description (project one's lockfile install printed two), with both linkers.
  • test/cli/install/bun-install-tarball-integrity.test.ts (local tarball cache entries, 6 tests) fails on the unfixed build at the installed-contents assertions and passes with this branch; the suites listed in the description pass with the debug build.
  • Self-review of the diff is in progress; anything it turns up will be pushed here.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug-hunt found no issues. Because it changes the shared install-cache key model for file: tarballs across 14 files, threads ExtractData through both installers, and adds an unsafe raw-pointer write into the lockfile's meta column while pool tasks hold &[Meta] views (record_extracted_integrity), a human look would still be worthwhile.

What was reviewed:

  • Cache-entry naming: cached_local_tarball_folder_name_print and every LocalTarball call site now key on integrity; empty name is treated as a miss in package_missing_from_cache, the isolated probe, and determine_preinstall_state (which already handled it).
  • Integrity write-back: hoisted path already did this; the new isolated record_extracted_integrity mirrors it and sets FORCE_SAVE_LOCKFILE, which --frozen-lockfile still supersedes at save time (install_with_manager.rs:2223).
  • Integrity::Display refactor via Tag::name() — output unchanged (sha512-<b64>==).
  • compute_cache_dir_and_subpath now hard-exits on missing integrity for LocalTarball instead of returning an empty subpath into bun patch.
Extended reasoning...

Overview

The PR fixes a real correctness bug: local file: tarballs were cached under a hash of their relative path (e.g. pkg.tgz), so any two projects — or two branches of one project — that used the same relative path but different tarball bytes shared and overwrote one cache entry. The fix keys the entry on the tarball's integrity (@T@sha512-<first 16 digest bytes hex>@@@1), the same content-addressed model already used for git commits. It touches 14 files: the folder-name printers, both installers (hoisted and isolated), the buffered and streaming extractors, bun patch / bun patch --commit, the RunTasksCallbacks trait signature, and adds six new integration tests across both linkers.

Security risks

Low. The change makes the cache more content-addressed — an entry can only be reused for the exact bytes the lockfile pinned, which is strictly tighter than before. Sixteen bytes (128 bits) of SHA-512 is well beyond collision range for a local-machine cache. No new untrusted-input parsing; the integrity value is either read from the project's own lockfile or computed from bytes on disk.

Level of scrutiny

High. This is core package-manager cache logic that every bun install with a file: tarball hits, and it involves:

  • A design choice (integrity-keyed for local tarballs, URL-keyed for remote) a maintainer should ratify.
  • New unsafe in Installer::record_extracted_integrity that writes one Meta row via items_raw while other packages' pool tasks concurrently hold &[Meta] over the same column. The SAFETY comment argues those tasks only touch other rows' bytes and this package's tasks haven't started; that argument reads correctly to me but deserves a second pair of eyes on the Stacked-Borrows and thread-affinity claims.
  • A new Global::exit(1) inside compute_cache_dir_and_subpath (reachable from bun patch) — the PR argues the normal flow always installs first so integrity is present, but a human should confirm no other caller can hit it unexpectedly.

Other factors

The PR is exceptionally well-documented and thoroughly tested (six new tests covering both linkers, cross-project collisions, branch switching, and legacy lockfiles without integrity), and the author ran the surrounding suites. --frozen-lockfile behavior was checked: record_extracted_integrity sets FORCE_SAVE_LOCKFILE, but save_lockfile_only (install_with_manager.rs:2223) short-circuits on frozen_lockfile() regardless, matching the description. No prior human reviews on the timeline. Given the scope and the concurrency argument, deferring rather than approving.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the three points raised above, for whoever picks this up:

Why local tarballs are keyed differently from URL tarballs. The cache is keyed by resolution identity everywhere else (name@version, @G@<commit>, the URL hash for URL tarballs), and a file: resolution is the only kind whose string is not an identity: it is relative to the project (or workspace) that declares it. Keying those entries by the integrity the lockfile records is the smallest change that gives them an identity, and it is the same thing git checkouts already do with their commit. URL tarballs are left as they are so that lockfiles without an integrity (which this change makes re-read the local file) do not start re-downloading.

record_extracted_integrity (Installer.rs:250). The write goes through the column's root pointer (items_raw) precisely so no &mut Lockfile or &mut [Meta] is formed while pool tasks hold &Lockfile; this is the same shape as the existing narrowed trusted_dependencies write in Task::run. The concurrency argument, with the places to check:

  • It runs on the main thread, inside run_tasks (Wait::is_done), after the entry loop in install_isolated_packages has finished. The main-thread reads of meta.integrity through long-lived views (the global store hashing while building the store, and the probe at isolated_install.rs:2343 in the loop) all happen before any extraction is reported; everything after that (new_apply_patch_hash, saving the lockfile) reads through a fresh borrow.
  • The tasks of the package being written have not started: every entry of that package is in the task queue being drained here, and start_task (which schedules through the thread pool and so orders the write before the task's reads) runs after the write.
  • Running tasks of other packages read integrity only for their own package (Installer.rs:1185). The one place a task looks at another package's row is the native binlink check in the Binaries step (maybe_replace_node_modules_path), which reads arch/os, not integrity, and that step comes after CheckIfBlocked, i.e. after the dependency is Done. So no concurrent access touches the bytes being written.

compute_cache_dir_and_subpath exiting. Callers and why the integrity is present:

  • new_apply_patch_hash during an install: reached from the hoisted installer only after package_missing_from_cache returned false (which now requires a name, hence an integrity) or from the post-extraction callback, which records the integrity first; from the isolated installer likewise after the probe or after on_package_extracted recorded it. The network-task callers construct it for the package being downloaded, which is never a local tarball.
  • prepare_patch (bun patch <pkg>): runs after the install that bun patch performs, which records the integrity in memory for any local tarball it installs (tarball packages are always reinstalled today).
  • do_patch_commit (bun patch --commit): reads bun.lock from disk. It is missing only if the lockfile predates tarball integrity and the preceding bun patch was run with --frozen-lockfile (so the recorded value was not saved); that is the case the message covers, and following it (bun install) fixes it. Before this change the same situation silently diffed against whatever was under the path-keyed name.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant