Skip to content

install: fix bun patch --commit cache path for git, github, and tarball dependencies - #37124

Closed
robobun wants to merge 6 commits into
mainfrom
farm/b1dbd6b9/patch-commit-github-cache-path
Closed

install: fix bun patch --commit cache path for git, github, and tarball dependencies#37124
robobun wants to merge 6 commits into
mainfrom
farm/b1dbd6b9/patch-commit-github-cache-path

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Repro

printf '{"name":"r","dependencies":{"@types/betterdiscord":"github:zerthox/betterdiscord-types"}}' > package.json
bun install
bun patch @types/betterdiscord
echo '// x' >> node_modules/@types/betterdiscord/index.d.ts
bun patch --commit 'node_modules/@types/betterdiscord'
error: failed to make diff error: Could not access '/root/.bun/install/cache/@GH@@@@1'

exit code 2 and no patch is written. The pristine copy actually lives at @GH@Zerthox-betterdiscord-types-7ee79af@@@1. The same failure hits git dependencies (Could not access '.../@G@') and tarball dependencies (Could not access '.../@T@0000000000000000@@@1'). npm registry dependencies patch fine.

Cause

do_patch_commit loads its own copy of the lockfile (it runs before the install flow populates manager.lockfile), then hands the package's Resolution to compute_cache_dir_and_subpath, which resolved the resolution's strings against manager.lockfile's string buffer. That buffer is empty at this point. Lockfile strings longer than 7 bytes are stored as offsets into their lockfile's string buffer, so repository.resolved / tarball URLs sliced against the wrong buffer come back empty, and the computed cache folder name collapses to just its prefix and cache-version suffix (@GH@ + `` + @@@1). npm resolutions were unaffected because the version is stored inline in the `Resolution` value.

This mismatch predates the Rust port: the same flow segfaulted in v1.1.42 (#18792), where manager.lockfile was an undefined pointer rather than an empty lockfile.

Fix

compute_cache_dir_and_subpath now takes the string buffer that owns the resolution's strings (resolution_string_bytes: Option<&[u8]>). The two bun patch --commit call sites pass the locally loaded lockfile's buffer; bun patch and the patch-apply task keep resolving against manager.lockfile (their resolutions come from it). All non-npm arms (git, github, local/remote tarball, folder, workspace, symlink) go through the new parameter.

Windows: patch filename escaping

With the cache path fixed, the same flow on Windows hit a second failure: the patch filename is {name}@{resolution}.patch, and git/github resolution labels contain : (pkg@github:owner/repo#sha.patch), which NTFS rejects in filenames (STATUS_OBJECT_NAME_INVALID when renaming the patch into patches/). escape_patch_filename now escapes the NTFS-reserved printable characters (: ? * " < > |) the same way it already escapes /. This applies on every OS so a patches/ directory committed from macOS or Linux stays checkoutable on Windows.

Known limitation: workspaces with the isolated linker

In a workspace using the isolated linker (the default for new projects), the reinstall that bun patch --commit runs after writing the patch hangs. That hang is a pre-existing bun install bug, reproducible on released bun with no patch commands involved: a workspace with an existing patchedDependencies entry for a github: dependency hangs the same way on a plain bun install (it completes with --linker hoisted). With this fix, bun patch --commit --linker hoisted completes end to end in workspaces; the isolated-linker install hang is tracked separately.

Verification

Three new tests in test/cli/install/bun-patch.test.ts run the full install, bun patch, edit, bun patch --commit flow offline and assert the patch file contents, the patchedDependencies key, and the reinstalled patched package:

  • github: dependency served by a local tarball server via GITHUB_API_URL
  • git dependency cloned from a local server over git's dumb HTTP protocol
  • local file: tarball, committed by package name to also cover the name-based lookup path

All three fail on bun 1.4.0-canary.1 with the Could not access errors above and pass with this change, on Linux and Windows. The rest of bun-patch.test.ts (34 tests) and bun-install-patch.test.ts (18 tests) pass.

Fixes #18792
Fixes #17945


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-patch.test.ts

…ll dependencies

bun patch --commit loads its own copy of the lockfile, but
compute_cache_dir_and_subpath resolved the Resolution's strings against
manager.lockfile, which is still empty at that point in the patch-commit
flow. Strings longer than 7 bytes are stored as offsets into the string
buffer, so every non-npm resolution produced a wrong pristine-copy path
(github: deps computed '@gh@@@@1' instead of '@gh@<owner>-<repo>-<sha>@@@1')
and the diff step failed with:

  error: failed to make diff error: Could not access '~/.bun/install/cache/@gh@@@@1'

npm deps were unaffected because their version is stored inline.

Pass the string buffer that owns the resolution's strings into
compute_cache_dir_and_subpath; the patch-commit call sites hand over the
locally loaded lockfile's buffer, everyone else keeps manager.lockfile.

Fixes #18792
@coderabbitai

coderabbitai Bot commented Aug 7, 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: 24 minutes

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: ae409755-f216-4dcb-898d-429ef065c6dd

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and 9821c08.

📒 Files selected for processing (4)
  • src/install/PackageManager/PackageManagerDirectories.rs
  • src/install/PackageManager/patchPackage.rs
  • src/install/patch_install.rs
  • test/cli/install/bun-patch.test.ts

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread src/install/PackageManager/PackageManagerDirectories.rs Outdated
Comment thread src/install/PackageManager/PackageManagerDirectories.rs
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Crash on patch commit #17945 - Segfault stack is exactly this PR's code path (doPatchCommitcomputeCacheDirAndSubpathcachedTarballFolderNamestringHash), caused by slicing a non-npm resolution's strings against the empty manager.lockfile string buffer.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17945

🤖 Generated with Claude Code

Comment thread test/cli/install/bun-patch.test.ts
Comment thread test/cli/install/bun-patch.test.ts Outdated
Git and github resolution labels put ':' in the generated patch filename
(pkg@github:owner/repo#sha.patch). NTFS rejects ':' in filenames (it is
the alternate-data-stream separator), so the rename of the freshly
written patch into patches/ failed with OBJECT_NAME_INVALID on Windows,
and a patches/ directory committed from another OS could not be checked
out on Windows at all. Escape the NTFS-reserved printable characters
the same way '/' already is.

Previously unreachable for git/github deps because the flow failed at
the cache-path step this branch fixes.
Comment thread src/install/PackageManager/patchPackage.rs Outdated
Comment thread src/install/PackageManager/patchPackage.rs

@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.

Both concerns from my earlier pass are addressed — escape_patch_filename now covers the NTFS-reserved printable characters on every OS, and the git-dependency test isolates git config and asserts on exit code only. I didn't find anything further, but this touches package-manager cache-path resolution and changes patch filename generation on all platforms, so a human should sign off.

What was reviewed:

  • Traced all five compute_cache_dir_and_subpath call sites: the two do_patch_commit sites correctly pass the locally-loaded lockfile's string buffer; prepare_patch and new_apply_patch_hash correctly pass None (their resolutions come from manager.lockfile).
  • Verified the git/github/tarball arms now call the same *_print helpers the previous wrappers delegated to, so the folder-name format is unchanged when None is passed.
  • Escape table: hex codes are correct; existing patchedDependencies entries are unaffected since the path is stored verbatim in package.json.
  • Git test's GIT_CONFIG_GLOBAL points at a nonexistent regular path (not the null device), which git treats as empty — the caveat in test-changed.test.ts is specific to NUL.
Extended reasoning...

Overview

The PR fixes bun patch --commit for git, github, and tarball dependencies. do_patch_commit loads its own lockfile copy, but compute_cache_dir_and_subpath was resolving the resolution's string-buffer offsets against manager.lockfile (empty at that point), collapsing cache folder names to just their prefix (@GH@@@@1). The fix adds an Option<&[u8]> parameter for the owning string buffer and threads it through all non-npm arms. A follow-up commit (responding to my earlier review) extends escape_patch_filename to percent-encode the seven NTFS-reserved printable characters so the resulting patch filenames are valid on Windows and portable when committed from POSIX.

Files: PackageManagerDirectories.rs (parameter + per-arm buffer selection), patchPackage.rs (two do_patch_commit call sites pass the local buffer; two prepare_patch sites pass None; escape table extended), patch_install.rs (new_apply_patch_hash passes None), and three new hermetic tests in bun-patch.test.ts.

Security risks

None identified. The change is read-path only (deriving a cache subpath from lockfile data the process already trusts). The escape-table extension narrows the set of characters that reach the filesystem in patch filenames — strictly a hardening. No new external input is parsed.

Level of scrutiny

Medium-high. src/install/ is a critical subsystem and compute_cache_dir_and_subpath is shared across bun patch, bun patch --commit, and the patch-apply task. I traced each call site to confirm the buffer it passes matches the lockfile the resolution came from, and confirmed the git/github/tarball arms now inline the exact body the previous wrapper functions delegated to (so None preserves prior behavior byte-for-byte). The escape change is mechanical but changes generated filenames for git/github deps on all platforms; I confirmed it doesn't affect lookup of existing patches (paths are stored verbatim in patchedDependencies, not derived from the key).

Other factors

My previous review's blocking finding (unescaped colon → NTFS rename failure) and hermeticity nit (ambient git config) were both addressed in follow-up commits, and robobun reports the three new tests pass on a Windows machine. The tests are offline (local Bun.serve for the GitHub tarball and git dumb-HTTP protocol; file: tarball), use per-test BUN_INSTALL_CACHE_DIR, assert patch file contents and the reinstalled result, and cover both the path-arg and name-arg lookup branches. Deferring because the change spans a shared install helper and alters patch filename generation — worth a human look even though I believe it's correct.

Seven arms of compute_cache_dir_and_subpath repeated the same
Some/None match on resolution_string_bytes; a future arm repeating it
wrong would reintroduce the wrong-buffer bug. One generic helper over
bun_semver::Slicable makes the choice in a single place.
Comment thread src/install/PackageManager/PackageManagerDirectories.rs

@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.

Both earlier findings are addressed — escape_patch_filename now covers the NTFS-reserved characters and the git setup is isolated from ambient config. No new issues found this pass, but this touches core install paths and introduces a user-visible patch-filename format for git/github deps (plus the noted isolated-linker limitation), so worth a human look.

What was reviewed:

  • Traced the new resolution_string_bytes param through all 5 call sites — the two do_patch_commit sites correctly pass the locally-loaded lockfile's buffer; prepare_patch and PatchTask pass None and their resolutions do come from manager.lockfile.
  • Confirmed the direct cached_{git,github,tarball}_folder_name_print calls preserve the exact prefix/cache-version/patch-hash layout of the wrappers they replace.
  • Verified the git-setup loop now only asserts exit code (dropping the strict stderr === "") and sets GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL + core.autocrlf false.
Extended reasoning...

Overview

The PR fixes bun patch --commit for git/github/tarball dependencies by threading the correct lockfile string buffer into compute_cache_dir_and_subpath (previously it always sliced against manager.lockfile, which is empty in the commit flow). It also extends escape_patch_filename to cover NTFS-reserved characters so the resulting patch filenames are valid on Windows, and adds three offline end-to-end tests (github via GITHUB_API_URL, git via dumb-HTTP, local tarball).

Prior feedback

My earlier review flagged two issues, both now fixed: (1) the unescaped colon in git/github patch filenames would fail the rename on NTFS — f36a471 adds : ? * " < > | to the escape table and the tests assert the generated path contains none of them; (2) the git setup was reading ambient ~/.gitconfig7a18c49 sets GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL, adds core.autocrlf false, and relaxes the setup assertions to exit-code only. All comment-cop flags on doc-comment length are resolved.

Security risks

None identified. The change is read-path only (which string buffer to slice against) plus filename percent-escaping. No new untrusted-input parsing or path construction from external data beyond what the existing flow already did.

Level of scrutiny

Medium-high. This is core bun install/bun patch infrastructure, and the escaping change defines the on-disk/committed patch filename format for git/github deps for the first time (previously unreachable due to the crash). The PR also explicitly scopes out an isolated-linker install hang as pre-existing — that scoping decision is reasonable but should be acknowledged by a maintainer.

Other factors

The fix is well-targeted and the mechanism is clearly explained. The three new tests are hermetic (local servers, per-test cache dir), exercise both the path-arg and name-arg commit flows, and assert patch contents + patchedDependencies key + reinstalled file. I verified the direct *_print calls produce byte-identical output to the wrappers they bypass (git has no cache-version suffix, github/tarball do). Deferring rather than approving because the change is not mechanical and touches a critical, cross-platform path.

Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
…rs (#37469)

## Repro

```sh
mkdir probe && cd probe
cp <bun repo>/test/cli/install/bar-0.0.2.tgz .
bun -e 'require("fs").writeFileSync("package.json", JSON.stringify({ name: "foo", dependencies: { bar: "./" + "x/../".repeat(130) + "bar-0.0.2.tgz" } }))'
bun install
```

```
Resolved, downloaded and extracted [1]
panic: unreachable: Error
```

`bun install` aborts (SIGABRT, exit 134) after the tarball has been
resolved and extracted. The spec normalizes to `./bar-0.0.2.tgz`, which
exists; a real 618 byte relative path behaves the same (that is how this
was found, see #37462). The same abort happens for a remote tarball
whose URL is longer than 512 bytes, a `file:` folder at a long path, and
a workspace package whose version has a ~500 byte prerelease tag.
`--lockfile-only` succeeds, so it is the link step. Reproduced with `bun
1.4.0-canary.1` and main.

Top frame: `Result::expect` in
`PackageInstaller::install_package_with_name_and_resolution`
(`src/install/PackageInstaller.rs:1340`), called from `install_package`
/ `hoisted_install::install_hoisted_packages`.

## Cause

While linking each package, the hoisted installer formats the package's
version label into `let mut resolution_buf = [0u8; 512]` with
`buf_print(..).expect("unreachable")`. For npm packages the label is the
version; for tarball, folder and git packages it is the spec they were
resolved from (stored verbatim), and for workspace packages it is the
workspace's own version. Those are user supplied and have no length
bound, so the overflow `buf_print` reports is reachable and the `expect`
turns it into the panic. Both branches (workspace version and
resolution) have it.

The isolated linker already builds this label in a `Vec`
(`Installer::package_patch_info`) and is not affected by this panic. It
fails such installs with `ENAMETOOLONG` instead, because the store
directory name embeds the spec; that is a separate problem and not
touched here.

## Fix

`print_package_version` keeps the 512 byte stack buffer as the
allocation-free path and, only when the label does not fit, formats it
into a `Vec` that lives next to the buffer (the same shape as
`resolve_path::join_z_buf_spill`). The linking loop runs once per
installed package, so the common case still does not allocate.

Spilling is the only correct behavior for this label: it is compared
against the installed `package.json` version and hashed as the version
half of the `name@version` `patchedDependencies` key, so a truncated
label would silently fail verification or miss a patch, and rejecting
the package would refuse a valid install (nothing on disk is named after
the label; the tarball cache folder is a hash of it). The new
patchedDependencies test below checks the spilled label byte for byte by
keying a patch with it.

`bun patch` formats the same labels into 1024 byte buffers with the same
`expect` at four sites in `src/install/PackageManager/patchPackage.rs`,
all reproducible the same way once the install succeeds (verified with a
build that only had the installer change):

* `pkg_info_for_name_and_version` (`bun patch <name>@<version>` compares
the label of every package with that name; panicked with `Resolution
name too long`),
* the multiple-packages-with-this-name loops in `prepare_patch` and
`do_patch_commit` (`bun patch <path>` when another package with the same
name has a long label),
* the `name@label` key in `do_patch_commit`, which is also the patch
file name.

These format into a `Vec` (`print_resolution_label`, reused across a
candidate loop). In `do_patch_commit` the key `Vec` is returned as the
`patch_key` directly and the file name is built from it, replacing the
re-slicing of the shared buffer; the bytes are unchanged (the key was
already valid UTF-8, so the former `BStr` round trip was a copy).
Committing a package whose label is this long still cannot succeed,
since the patch file would be named after the label, but it now exits 1
with an error instead of aborting; today it fails before that at the
diff step for tarball packages (#37124), which this does not change
either way.

## Tests

`test/cli/install/bun-workspaces.test.ts`, "packages whose version label
is longer than 512 bytes" (hoisted linker): local tarball, remote
tarball served from a local `Bun.serve`, workspace package with a long
prerelease version (the other branch), and a `patchedDependencies` entry
keyed by the long spec that must actually be applied.
`test/cli/install/bun-patch.test.ts`, "packages whose label is longer
than 1024 bytes": `bun patch <name>@<label>`, `bun patch <path>` plus
`--commit` when a same-named package has a long label (the long one is
listed first, so the loops format it), and `--commit` of a long-labeled
package exiting 1 without touching `package.json`. The long specs use
`x/../` repeated so nothing long is ever created on disk.

All seven fail on the unfixed build (`panic: unreachable: Error` during
the install) and pass with the fix; with only the installer change
applied, the three `bun patch` tests fail at the `bun patch` step
instead, so each patch site is covered on its own. Both files pass in
full, as does `bun-install-patch.test.ts`; `cargo clippy -p bun_install`
is clean and `cargo check -p bun_install` passes for
`x86_64-pc-windows-msvc` and `x86_64-apple-darwin`.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Folded into #38269 together with the other two bun patch fixes (rebased; the fix is reshaped there — see that PR's description).

Jarred-Sumner added a commit that referenced this pull request Aug 14, 2026
…fix bun patch for non-npm deps and isolated hang (#38269)

### What does this PR do?

Install-cache and `bun patch` robustness, consolidated from #37124,
#37136 and #37145 (rebased and reshaped; #32749 from the same batch
landed separately).

**Git dependency cache folders are built in a staging dir and renamed on
success.** `Repository::checkout` cloned straight into
`<cache>/@g@<sha>` and checked out in place; `Repository::download`
cloned the bare mirror straight into `<cache>/<hash>.git`. An install
(or its git child — seen OOM-killed in CI) dying between steps left a
folder at the trusted name: an empty `@G@` folder resolves as an *empty
package* through the "git dependency without package.json" path (exit 0,
`bun.lock` name falls back to the URL basename), and a half-cloned
mirror fails every later `git fetch`. Both now build under a temporary
sibling inside the cache dir (`CacheStaging`, same-filesystem so it's
the same `renameat_concurrently` ladder tarball extraction uses) and are
renamed into place only when complete; failures remove the staging dir.

**Cache hits require the entry's completion marker.** One helper,
`is_package_in_cache_at(cache_dir, folder, tag)`: npm folders must
contain `package.json`, git checkouts must contain the `.bun-tag`
written last, everything else stays a directory probe. Used by
`checkout()`'s resolve-time hit, `determine_preinstall_state`, and the
hoisted and isolated installers — previously every git hit was a bare
directory probe (`.bun-tag` in the cache was written but never read),
and `determine_preinstall_state` didn't probe `package.json` for npm
either. Folders left by older versions are re-cloned instead of
installed. Deletes the hoisted installer's unsafe in-place edit of the
shared folder-name buffer and the isolated installer's append/truncate
copy. Since the tag is now the marker, `checkout()` unlinks anything a
repo checked in as `.bun-tag`, creates it `O_EXCL|O_NOFOLLOW`, and fails
the checkout rather than publish an untaggable folder (a repo shipping a
symlink named `.bun-tag` now gets a real tag; its target is still never
written — test updated). Existing bare mirrors are not validated
structurally; there's no marker for them.

**`bun patch --commit` on git, github and tarball dependencies** failed
with `Could not access '.../@gh@@@@1'` and wrote nothing (#18792,
#17945): it loads its own lockfile but computed the cache path against
the empty `manager.lockfile`. The loaded lockfile is moved into the
manager before the path is computed (`install_with_manager` reloads it
afterwards, as it already does for `bun update`; the double parse is
left alone). Patch filenames additionally escape NTFS-reserved
characters, which only these resolutions contain. Fixes #18792, fixes
#17945.

**Isolated linker hang** — a plain `bun install` in a workspace with a
`patchedDependencies` entry for a git/github dependency hung forever:
the installer treated every patched package as missing and re-enqueued a
download the resolve phase had already completed, parking on a drained
task list. It now probes the unpatched folder (computed with no patch
hash) like everything else; also fixes removing an entry.

**Tests:** github/git/tarball `patch --commit` flows; add → cold cache →
remove → re-add of a patch under isolated for github, git and npm (the
suspected stale-`.bun-tag-<hash>` skip on re-add did not reproduce, so
these just pin the cycle); git checkout failure leaves only the mirror
in the cache and an empty folder at the cache name is re-cloned; a
pre-existing test pinned to the bogus `--commit` cache path now asserts
the step that genuinely fails.

### How did you verify your code works?

`bun-install` + `bun-install-registry` (427), `isolated-install` (65),
`bun-install-patch`, `bun-patch` all pass locally; new tests fail on
release bun (half-built `@G@` folder left behind; `--commit` error;
isolated hang). Each fix was also driven by hand with the debug binary:
`patch --commit` on a `file:` tarball fails on main's binary and
succeeds here; a workspace with a patched local git dep installs,
requires as patched, survives remove and re-add, and re-clones an
emptied cache folder, with no `.tmp` residue. Windows rename/escaping
legs are left to CI. Local gotcha: these files need `HOME` pointed at an
empty dir if `~/.npmrc` sets `install-strategy=hoisted`.


---

**Added after CI** (`25855c0fe97`): the npm add/remove/re-add test
failed on Linux — the hardlink and copyfile backends overlay files onto
an existing isolated store entry, so removing a patch kept every file
the patched build had *added* (and its `.bun-tag-<hash>`); clonefile
replaces the tree, which is why macOS passed and why the earlier "did
not reproduce" was wrong — this is the staleness #37136 mentioned. The
task now deletes the previous project-local package tree before
rebuilding an entry (only reached when the entry needs a rebuild, so
warm installs don't pay for it); the npm cycle test pins the hardlink
backend. Also from review: `checkout()` uses `delete_tree` for a
checked-in `.bun-tag`, so a directory under that name is replaced
instead of failing the install (test added).
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.

segfault when attempting to patch package Crash on patch commit

2 participants