Skip to content

install: send credentials embedded in --registry and registry env var URLs - #38796

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/119a638b/registry-url-userinfo
Aug 15, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
farm/119a638b/registry-url-userinfo

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A registry URL with credentials in it (http://user:pass@host/, or http://:token@host/) passed as --registry, or through BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY / npm_config_registry, sends no Authorization header. Against a registry that logs the header (bun 1.4.0, also main):
    • bun publish --registry http://alice:s3cret@127.0.0.1:PORT/ publishes with exit 0 and the registry sees PUT /ui-pkg authorization=null; same with npm_config_registry=....
    • bun pm view somepkg --registry http://alice:s3cret@... and bun install --registry http://alice:s3cret@...: GET /somepkg authorization=null.
    • The same URL as registry= in .npmrc sends Authorization: Basic YWxpY2U6czNjcmV0 on the manifest and the tarball. npm 11 sends Basic for the flag and the env var too (minipass-fetch passes URL userinfo as node's auth option).
  • Because the credentials stay inside the stored registry URL, they end up in the manifest URL and bun install prints them on failure: error: GET http://alice:s3cret@127.0.0.1:PORT/somepkg - 401.
  • Cause: the config file string forms go through the userinfo splitter (parse_registry_url_string_impl, src/api/lib.rs), which moves user:pass into NpmRegistry.username/password (or token) and strips it from the URL. The flag and env var paths did not:
    • src/install/PackageManager/PackageManagerOptions.rs, registry env vars: built Api::NpmRegistry { url: <raw value> } and called Scope::from_api, which does not read URL userinfo.
    • same file, --registry: stored the raw href into self.scope.url directly.
    • src/runtime/cli/publish_command.rs publish(): the "missing authentication" check accepted userinfo in the URL as credentials, so publish went ahead and sent the PUT without a header. The HTTP client (src/http) never reads URL userinfo.
  • Found while looking at the publish pre-flight check (publish: accept basic auth credentials from .npmrc and bunfig.toml #38776), not from a user report; the effects above are what the repro shows.
  • Also fixed here (folded in from install: keep BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN when --registry points at a different host #38764, cdca4ff): BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN were applied before --registry, so the host-mismatch check in the --registry block dropped them too and BUN_CONFIG_TOKEN=... bun install --registry <other host> failed with a 401.

Fix

  • NpmRegistry::from_url (src/options_types/schema.rs) is the splitter, moved next to the type so bun_install can call it; bun_api's parse_registry_url_string_impl (the bunfig and .npmrc entry point) delegates to it, so there is one parse for every registry URL string. bun_api loses its now unused bun_url dependency, and bun_ini's private has_credentials helper is replaced by the method the new call sites needed.
  • The registry env vars and --registry run their value through from_url:
    • credentials in the URL: the scope is rebuilt with Scope::from_api, exactly as for a config file registry string, so Scope.auth (Basic) or Scope.token (Bearer) is populated and the stored URL no longer contains the userinfo. They replace whatever the config files set up for that registry, which is the rule .npmrc already applies (a registry= line with userinfo wins over //host/:_authToken=: apply_registry_auth in src/ini/lib.rs skips registries that already have credentials).
    • no credentials in the URL: unchanged (env var keeps a same-host token, --registry keeps same-origin credentials and drops them otherwise); from_url returns the input href unchanged in that case.
  • The --registry block now runs before the token env vars are read (cdca4ff), so the env token survives a registry switch and layers on top of both overrides. Resulting order, lowest to highest: config files, credentials in the --registry / registry env var URL, BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN, --token.
  • publish() also accepts Scope.auth in its pre-flight check, which is where the Basic credentials land. Same one-line hunk as publish: accept basic auth credentials from .npmrc and bunfig.toml #38776 (the config file side of that check, reported in Bun Publish doesn't support token based login (Azure Devops) #17531); it merges in either order and closing those reports is left to publish: accept basic auth credentials from .npmrc and bunfig.toml #38776.
  • Why the split belongs at the string entry points rather than only inside Scope::from_api: the loaders need the split result before a scope exists. .npmrc loading decides whether host-keyed _authToken entries apply by looking at the parsed registry's credentials, and the two override layers decide whether to carry configured credentials over the same way. So the parse-time split is required regardless, and this change wires in the two string entry points that were missing from it. Two ways a URL with userinfo can still reach from_api unsplit remain: the bunfig object form registry = { url = "http://u:p@host/" } (documented with separate username/password/token fields) and a registry = "$VAR" whose value carries userinfo, since $VAR is only expanded inside from_api. Both need a split inside from_api itself, which is tracked separately and kept out of this change because install: canonical registry URL in Scope; redact secrets in bun audit registry URLs #38183 is rewriting that function's tail; this is also why the publish check keeps its URL userinfo clause.
  • Tests (every new case fails on the released bun and passes with this change):
    • test/cli/install/config-precedence.test.ts: bun install with --registry and with each of the three env vars, user:pass form (Basic arrives on every request and verdaccio accepts it, @needs-auth/test-pkg installs, password not in stderr) and :token@ form (Bearer); URL credentials replace a same-host .npmrc _authToken (flag and env var); from cdca4ff: BUN_CONFIG_TOKEN beats a token in the --registry URL, BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN apply to the --registry registry, and --registry drops the .npmrc token for the previous host but keeps BUN_CONFIG_TOKEN.
    • test/cli/install/bun-publish.test.ts, credentials in the registry url: --registry user:pass (PUT carries Basic, summary line has no userinfo), --registry :token@ (Bearer; previously "missing authentication"), the three env vars.
    • test/cli/install/redacted-config-logs.test.ts: the pm view test that asserted the password is masked in the 401 line now asserts Basic is sent and the printed URL has no userinfo (the masked form depended on this bug); a verbose fetch() case keeps the URL password masking of redacted_npm_url covered.
    • Also run with the debug build: all of bun-publish.test.ts, config-precedence.test.ts, redacted-config-logs.test.ts, npmrc.test.ts, the .env registry override and env var priority tests in bun-install-registry.test.ts; cargo clippy on the touched crates; the source lints.
  • Overlap with open work: Delete the schema::api mirror types and bun_api; one loader numbering across Rust/C++/JS #37095 adds the same from_url while deleting bun_api and schema.rs (whichever lands second rebases onto the other's copy; the call sites are the same either way); install: canonical registry URL in Scope; redact secrets in bun audit registry URLs #38183 touches the same --registry block for URL normalization.

Background

  • Api::NpmRegistry is the parsed config for one registry: url plus optional username/password/token, produced by bunfig and .npmrc loading. Scope::from_api (src/install/npm.rs) turns it into a Scope, the resolved registry the package manager uses: the URL, token (sent as Authorization: Bearer) and auth (base64 user:password, sent as Authorization: Basic; user keeps the plain pair for pm whoami). Requests only ever look at Scope.token / Scope.auth; a URL's userinfo is never sent by the HTTP client.
  • Options::load builds the default scope in layers: config files, the registry env vars, --registry, the token env vars, then the remaining CLI flags (--token). The registry env vars and --registry are the two layers that accept a registry URL string without going through the config parsers, which is why they are the ones changed.
  • URL::href_without_auth() is the existing helper the splitter uses to rebuild the URL without its userinfo. For a registry with no path it yields http://host//; every consumer strips or collapses the extra slash (this pre-dates the change and applies to the .npmrc form today), so it is left alone here.
Probe against a mock registry (released bun 1.4.0 vs this branch)
# released
bun publish --registry http://alice:s3cret@127.0.0.1:PORT/        -> exit 0,  REQ PUT /ui-pkg authorization=null
npm_config_registry=http://alice:s3cret@...  bun publish            -> exit 0,  REQ PUT /ui-pkg authorization=null
bun pm view somepkg version --registry http://alice:s3cret@...      ->          REQ GET /somepkg authorization=null
bun install --registry http://alice:s3cret@...  (registry answers 401)
    error: GET http://alice:s3cret@127.0.0.1:PORT/somepkg - 401
.npmrc registry=http://alice:s3cret@...  bun install                 ->          REQ GET /somepkg authorization=Basic YWxpY2U6czNjcmV0
npm view somepkg --registry http://alice:s3cret@...                 ->          REQ GET /somepkg authorization=Basic YWxpY2U6czNjcmV0

# this branch
bun publish --registry http://alice:s3cret@...                      -> Registry: http://127.0.0.1:PORT/   REQ PUT /ui-pkg authorization=Basic YWxpY2U6czNjcmV0
BUN_CONFIG_REGISTRY=http://alice:s3cret@...  bun publish            ->                                    REQ PUT /ui-pkg authorization=Basic YWxpY2U6czNjcmV0
bun publish --registry http://:tok123@...                           ->                                    REQ PUT /ui-pkg authorization=Bearer tok123
bun pm view somepkg version --registry http://alice:s3cret@...      ->                                    REQ GET /somepkg authorization=Basic YWxpY2U6czNjcmV0
bun pm whoami --registry http://alice:s3cret@...                    -> alice
bun publish --registry http://alice@...  (no password)              -> error: missing authentication (unchanged)
bunfig install.registry = "http://alice:s3cret@..." (string form)   -> still Basic, via the delegating parser

… URLs

A registry URL of the form http://user:pass@host/ (or http://:token@host/)
written in .npmrc or bunfig.toml is split into credentials and a bare URL
by the config parsers, so requests carry Authorization. The same URL given
through --registry or BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY /
npm_config_registry skipped that step: the userinfo stayed inside the
stored registry URL, where the HTTP client ignores it, so install, pm view
and publish sent no Authorization header (publish still proceeded because
its pre-flight check accepted the userinfo as credentials), and the
credentials were echoed in install error output.

Move the splitter onto the config type as NpmRegistry::from_url and run the
flag and env var URLs through it. Credentials found in the URL replace the
ones configured for the registry, matching how a registry= line with
userinfo beats host-keyed auth in .npmrc; a URL without credentials keeps
the previous behavior. publish's pre-flight check now also accepts
Scope.auth, which is where the Basic credentials land.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 10 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: a8c041fc-eae3-40e2-82fe-bdae9364c799

📥 Commits

Reviewing files that changed from the base of the PR and between 2c2ef7c and cdca4ff.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • src/api/Cargo.toml
  • src/api/lib.rs
  • src/ini/lib.rs
  • src/install/PackageManager/PackageManagerOptions.rs
  • src/options_types/schema.rs
  • src/runtime/cli/publish_command.rs
  • test/cli/install/bun-publish.test.ts
  • test/cli/install/config-precedence.test.ts
  • test/cli/install/redacted-config-logs.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 on the released bun 1.4.0 with a mock registry that logs the Authorization header: bun publish --registry http://alice:s3cret@127.0.0.1:PORT/, the same URL in npm_config_registry / BUN_CONFIG_REGISTRY, bun pm view ... --registry ... and bun install --registry ... all reach the registry with authorization=null; the same URL as registry= in .npmrc sends Basic YWxpY2U6czNjcmV0, and npm 11 sends Basic for the flag and env var forms.
  • Fix is in this PR: the flag and env var values go through the same splitter the config file string forms use (NpmRegistry::from_url), and publish's pre-flight check accepts Scope.auth (same hunk as publish: accept basic auth credentials from .npmrc and bunfig.toml #38776).
  • cdca4ff folds install: keep BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN when --registry points at a different host #38764 into this branch: --registry is applied before the token env vars, so BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN survive a registry switch and take precedence over credentials embedded in the URL. Precedence is now: config files < credentials in the --registry / env registry URL < token env vars < --token; the description lists the tests pinning each step.
  • Tests: config-precedence.test.ts (install: flag + env vars, Basic and Bearer forms, precedence), bun-publish.test.ts (credentials in the registry url), redacted-config-logs.test.ts (pm view). All new cases fail on the released bun and pass with the debug build.
  • Review: has_credentials duplicate in bun_ini removed (5a9e23d), comment nits addressed (44d4aec, 28f1489); no open threads.
  • CI on cdca4ff (build 97719): 177 of 179 jobs passed, the other two are darwin 14 aarch64 - test-bun shards still waiting for an agent; the annotated failures are retry/batch flakes in unrelated areas.
  • Not in this PR (tracked separately): the bunfig object form registry = { url = "http://u:p@host/" } and a registry = "$VAR" whose value carries userinfo still reach Scope::from_api unsplit; both need the split inside from_api, which install: canonical registry URL in Scope; redact secrets in bun audit registry URLs #38183 is currently rewriting.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun Publish doesn't support token based login (Azure Devops) #17531 - bun publish with bunfig [install.scopes] username/password (Azure DevOps PAT) fails with error: missing authentication (run bunx npm login) because those credentials land in Scope.auth; this PR's publish_command.rs change makes the pre-flight check accept registry.auth.
  2. Cannot publish to custom registry, npm can with the same config #18670 - the _auth / Nexus reports on this thread hit the same Basic-auth pre-flight gap that this PR relaxes (note: the original publishConfig.registry + _authToken report is tracked separately, so this may only be a partial fix).

Ruled out after checking the diff: #7416 (userinfo on a tarball dependency URL — this PR only touches the registry-config layer, not dependency URL resolution) and #34168 (npm_config_registry vs bunfig precedence, which this PR deliberately preserves).

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

Fixes #17531
Fixes #18670

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding Fixes lines for these:

Comment thread src/options_types/schema.rs Resolved
Comment thread src/api/lib.rs Resolved
Comment thread src/api/lib.rs Resolved
Comment thread src/options_types/schema.rs Resolved
@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:01 AM PT - Aug 15th, 2026

✅ @robobun, your commit 28f14893395478167d8022ba4abe780e2aef33a1 passed in Build #97098! 🎉


🧪   To try this PR locally:

bunx bun-pr 38796

That installs a local version of the PR into your bun-38796 executable, so you can run:

bun-38796 --bun

Comment thread src/options_types/schema.rs Resolved

@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 re-reviewed after the follow-up commits and didn't find any bugs — the earlier has_credentials dedup nit is addressed and the comment-cop items are all resolved. Because this changes how registry credentials are extracted and which credentials win when --registry/env-var userinfo meets a same-host .npmrc token, and it sits adjacent to #38764/#38183 which touch the same --registry block, a human sign-off on the precedence semantics would still be worthwhile.

What was reviewed:

  • NpmRegistry::from_url is a byte-for-byte move of the existing parse_registry_url_string_impl body; bun_options_types already depends on bun_url, and the bunfig/.npmrc entry point now delegates so those paths are unchanged.
  • The no-credentials branches in both the env-var and --registry blocks preserve prior behavior (same-host token carry / same-origin credential drop); from_url returns the input href unchanged there.
  • Credentials are stripped from the stored URL so they no longer reach error output or the dist.tarball manifest field — checked that the updated redacted-config-logs test still covers redacted_npm_url masking via the new verbose-fetch case.
Extended reasoning...

Overview

Routes --registry and the three registry env vars through the same URL-userinfo splitter that .npmrc/bunfig string forms already use, by moving that splitter onto NpmRegistry::from_url in bun_options_types. Adds NpmRegistry::has_credentials() (and removes the duplicate free function in bun_ini), drops the now-unused bun_url dep from bun_api, and adds registry.auth.is_empty() to publish's pre-flight NeedAuth check. ~15 new tests across three files exercise Basic/Bearer for the flag and each env var, precedence over a same-host .npmrc _authToken, and that the password no longer appears in output.

Security risks

This is credential-handling code. The change makes bun send Authorization headers it was previously silently dropping, using credentials the user explicitly put in the URL — matching what bun already does for registry= in .npmrc and what npm does for the flag/env var. Stripping userinfo from the stored URL is a net improvement (removes it from error lines and the published dist.tarball). No cross-origin carry: when the URL has credentials the scope is rebuilt from that URL alone; when it doesn't, the existing same-host/same-origin gates are byte-identical. I did not spot a leak or downgrade path.

Level of scrutiny

High — auth-adjacent, and it encodes a precedence decision (URL userinfo replaces a same-host _authToken, mirroring apply_registry_auth) that a maintainer should confirm is the intended rule for the CLI/env layers too. The PR also notes two open PRs (#38764, #38183) touching the same --registry block; a human is better placed to sequence those.

Other factors

The from_url body is a verbatim move; bun_options_types already depended on bun_url. The env-var block's switch from prev_scope.clone().token to core::mem::take(&mut self.scope.token) is equivalent since self.scope is overwritten on the next line. The updated redacted-config-logs test replaces an assertion that depended on the old bug (masked userinfo in the 401 URL) with one that asserts Basic is sent and the URL has no userinfo, and adds a verbose-fetch test so redacted_npm_url masking stays covered. My earlier nit and the comment-cop flags are all resolved in the current diff.

BUN_CONFIG_TOKEN / NPM_CONFIG_TOKEN were read before --registry ran, so
the host-mismatch check that drops credentials configured for the
previous registry also dropped the env token and the install failed with
a 401. The env token is not tied to a host; apply it after --registry has
picked the registry. It also takes precedence over a token embedded in the
--registry URL, matching what a configured token does over URL userinfo in
npm and what BUN_CONFIG_REGISTRY + BUN_CONFIG_TOKEN already do 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; my earlier has_credentials nit was addressed in 5a9e23d. Because this reorders credential precedence in Options::load (moving --registry ahead of the token env vars) and adopts the "URL credentials replace a same-host configured token" rule that the status comment explicitly flags for a maintainer to confirm, a human sign-off on that behavior choice is still warranted.

What was reviewed:

  • NpmRegistry::from_url is a pure move of the existing splitter; parse_registry_url_string_impl delegates to it, so bunfig/.npmrc string forms are unchanged.
  • The env-var path's no-credentials branch preserves the prior same-host token carry-forward; the --registry no-credentials branch keeps the same-origin token/auth/user retention from #36165.
  • cli.token still applies after both new blocks, so --token precedence is unchanged; Scope::from_api is what turns username/password into Scope.auth, so the publish registry.auth check is the right gate.
Extended reasoning...

Overview

The PR routes --registry and the BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY / npm_config_registry env vars through the same URL-userinfo splitter that bunfig and .npmrc already use, by hoisting that splitter onto NpmRegistry::from_url in src/options_types/schema.rs. src/api/lib.rs::parse_registry_url_string_impl becomes a one-line delegate (dropping bun_api's bun_url dep), src/ini/lib.rs swaps its private has_credentials for the new method, src/install/PackageManager/PackageManagerOptions.rs reorders Options::load so --registry is applied before the token env vars, and src/runtime/cli/publish_command.rs adds registry.auth to the pre-flight check. Tests cover install, publish and pm view for both the Basic (user:pass@) and Bearer (:token@) forms across the flag and all three env vars, plus precedence against .npmrc tokens and BUN_CONFIG_TOKEN.

Security risks

This is credential-handling code. The change starts sending an Authorization header that was previously dropped, which is the intended fix and matches npm's behavior and bun's own .npmrc handling. The userinfo is stripped from the stored URL, so it stops appearing in error output and in the published dist.tarball (both covered by tests). I did not find a path where credentials leak to a host other than the one they were embedded in — the env-var branch rebuilds the scope via Scope::from_api from the split registry, and the --registry branch either does the same (credentials present) or keeps the existing same-origin retention logic (credentials absent). No new external inputs are trusted beyond what config-file registry strings already accept.

Level of scrutiny

High. Two independent reasons: (1) auth/credential precedence is security-adjacent and easy to get subtly wrong across the config-file / env-var / flag layering; (2) the PR reorders the --registry block relative to the token env vars in Options::load, which is a deliberate precedence change (folded in from #38764). The author's own status comment flags one precedence rule — URL-embedded credentials replace rather than merge with a same-host configured token — as needing maintainer confirmation. That is a product decision, not a mechanical fix.

Other factors

CI on 28f1489 passed (177/179 jobs; the two remaining were queued macOS shards, no failures in the touched areas). Test coverage is strong: each new behavior has a test that asserts the exact Authorization header received by a mock registry, verdaccio actually accepts the Basic credentials for @needs-auth/test-pkg, and the reordering is pinned by "BUN_CONFIG_TOKEN beats credentials embedded in the --registry URL" and "--registry drops the _authToken of the .npmrc registry but keeps BUN_CONFIG_TOKEN". All prior review threads (my has_credentials dedup, the comment-cop long-comment nits) are resolved. Given the explicit maintainer-decision flag on the replace-vs-merge precedence and the security-sensitive surface, deferring rather than shadow-approving.

Jarred-Sumner pushed a commit that referenced this pull request Aug 15, 2026
…38824)

### Problem
- `registry = { url = "http://alice:s3cret@host/" }` in bunfig.toml (and
the same object shape under `[install.scopes]`) sends no `Authorization`
header: the registry sees `authorization: null` on the manifest and the
tarball request. The same URL written as the string form, `registry =
"http://alice:s3cret@host/"`, sends `Authorization: Basic
YWxpY2U6czNjcmV0` on both. `{ url = "http://:token@host/" }` drops the
token the same way.
- Because the credentials stay inside the stored URL, they are also
printed when a request fails: `error: GET
http://alice:s3cret@127.0.0.1:PORT/no-deps - 401`.
- Cause: `parse_registry_object` in `src/bunfig/bunfig.rs` copies `url`
into `NpmRegistry.url` verbatim. Only the string form runs the userinfo
splitter
(`bun_api::npm_registry::Parser::parse_registry_url_string_impl`,
`src/api/lib.rs`). Nothing downstream reads userinfo out of
`NpmRegistry.url`: `Scope::from_api` (`src/install/npm.rs`) builds the
header from `token` / `username` / `password` only, and the HTTP client
ignores userinfo in a request URL.

### Fix
- `parse_registry_object` now runs `url` through the same splitter the
string form uses (`parse_registry_url`, shared by both forms), so the
URL's credentials land in the credential fields and the stored URL no
longer carries them. The `match obj.get(b"url")` block is the fix; the
rest of the diff renames the string-form helper to take bytes.
- If the object has any `username` / `password` / `token` key, the
credentials taken from the URL are dropped before the keys are applied.
Objects with credential keys therefore produce exactly the `NpmRegistry`
they produce today (the URL's userinfo was always ignored for them); the
only configs whose behavior changes are the ones that sent nothing. This
has to be all-or-nothing rather than per field: `from_api` sends a token
in preference to a username/password pair, so merging per field would
let a `:token@` in the URL outrank `username` / `password` keys written
next to it, which works today. It is also the rule the `.npmrc` merge
already uses for the next layer down (`apply_registry_auth` in
`src/ini/lib.rs` only fills in `.npmrc` credentials when the registry
has none).
- Correct because `NpmRegistry` is read on the assumption that
credentials live in its fields and `url` carries none: `Scope::from_api`
only reads the fields, and the `.npmrc` merge decides whether to apply
`//host/:_authToken=` lines by checking those fields. The string form
establishes that shape at parse time; the object form was the one
producer that did not. Splitting at the same point makes a given `url`
text mean the same thing in both forms, including in the `.npmrc`
precedence check (tested).
- URLs without userinfo come back from the splitter byte for byte (it
returns the input href unchanged in that case), so existing object-form
configs, with or without a trailing slash, are unaffected.
- Not changed here: `--registry` and the `*_config_registry` env vars
(#38796), `registry = "$VAR"` where the variable's value carries
credentials (the expansion happens later, in `from_api`; filed
separately), and the `http://host//` spelling the splitter produces for
root-path URLs (#38812, cosmetic, same for both forms). No docs change:
the documented way to pass credentials in the object form stays the
explicit keys; this only makes the `url` value behave like the string
form's.
- Verified: `test/cli/install/config-precedence.test.ts` gains three
string/object pairs (`user:password` in the URL, `:token` in the URL,
`user:password` in a scoped registry URL), a `.npmrc` precedence test
(URL credentials in the object beat a `~/.npmrc` `_authToken` for the
same host), and a three-row table pinning that credential keys beat the
URL (`username`/`password` keys over `user:password` in the URL,
`username`/`password` keys over `:token` in the URL, `token` key over
`user:password` in the URL). Every test asserts the exact
`Authorization` header seen on each request and that the auth-only
package installs. On the released build the four url-only object tests
fail (`authorization: null`, 401, password in the error line); the
string rows and the keyed rows pass there, as they must, since that
behavior is unchanged. The `:token` row fails against a per-field merge,
which is the regression it guards. All 39 tests in the file pass with
this branch.
- Also run with the fix: the Registry URLs / scoped authentication /
same-origin tarball tests of `bun-install.test.ts`, `npmrc.test.ts`,
`bun-install-retry.test.ts`, `redacted-config-logs.test.ts`,
`bun-install-pathname-trailing-slash.test.ts`, `bun-run-bunfig.test.ts`,
`test/config/bunfig`, `cargo clippy -p bun_bunfig`, and the source
lints. In `bun-publish.test.ts` the lifecycle tests hit the local 5 s
default timeout both with and without this change (CI passes a longer
`--timeout`); the rest of the file passes.

### Background
- bunfig accepts a registry in two shapes: a string, `registry =
"<url>"`, and an object, `registry = { url, username, password, token
}`. Both `[install] registry` and each `[install.scopes]` entry go
through `parse_registry`, so one fix covers both.
- `api::NpmRegistry` (`src/options_types/schema.rs`) is the config-level
record produced by the bunfig and `.npmrc` loaders: `url` plus
`username` / `password` / `token`. After the loaders run, `.npmrc`
credential lines are merged into it, and the package manager turns it
into an `npm::registry::Scope`, which holds the final URL and the
pre-computed `Authorization` value. `from_api` uses `token` when it is
set and only otherwise builds Basic auth from `username` / `password`.
- The userinfo splitter takes `scheme://user:pass@host/path` and returns
an `NpmRegistry` with `username` / `password` set (or `token`, when the
user part is empty) and `url` rewritten without the userinfo. The bunfig
string form and the `.npmrc` `registry=` / `@scope:registry=` keys all
use it.

<details>
<summary>Probe: mock registry on port 0 logging the Authorization
header, released 1.4.0 vs this branch</summary>

```
registry = "http://alice:s3cret@127.0.0.1:PORT/"                    (string form)
  1.4.0:       /no-deps  Basic YWxpY2U6czNjcmV0   /no-deps/-/x-1.0.0.tgz  Basic YWxpY2U6czNjcmV0
  this branch: same

registry = { url = "http://alice:s3cret@127.0.0.1:PORT/" }
  1.4.0:       /no-deps  null                     /no-deps/-/x-1.0.0.tgz  null
  this branch: /no-deps  Basic YWxpY2U6czNjcmV0   /no-deps/-/x-1.0.0.tgz  Basic YWxpY2U6czNjcmV0

registry = { url = "http://:tok123@127.0.0.1:PORT/" }
  1.4.0:       null on both
  this branch: Bearer tok123 on both

registry = { url = "http://alice:s3cret@127.0.0.1:PORT/", token = "explicit" }
  1.4.0:       Bearer explicit on both
  this branch: same

registry = { url = "http://alice:s3cret@127.0.0.1:PORT/", password = "override" }
  1.4.0:       null on both (no username configured by keys)
  this branch: same

[install.scopes] myorg = { url = "http://bob:hunter2@127.0.0.1:PORT/" }
  1.4.0:       null on both
  this branch: Basic Ym9iOmh1bnRlcjI= on both (same as the scoped string form)

401 from the registry, object form:
  1.4.0:       error: GET http://alice:s3cret@127.0.0.1:PORT/no-deps - 401
  this branch: error: GET http://127.0.0.1:PORT//no-deps - 401   (the // is #38812)
```

</details>

<details>
<summary>Superseded first version</summary>

The first push merged the URL's credentials with the keys field by field
(a `password` key on top of `user:pass@` in the URL gave `user:<key>`).
Review pointed out that `{ url = "http://:t@host/", username = "u",
password = "p" }`, which sends `Basic u:p` today, would have started
sending `Bearer t`, because the URL's token survived next to the keys
and `from_api` prefers a token. The current version drops the URL's
credentials whenever a credential key is present, and the keyed rows in
the test table pin that.

</details>
@Jarred-Sumner
Jarred-Sumner merged commit 95cb693 into main Aug 15, 2026
10 of 11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/119a638b/registry-url-userinfo branch August 15, 2026 08:54
Jarred-Sumner added a commit that referenced this pull request Aug 15, 2026
… registry URLs (#38183)

### Problem
- A registry configured as `http:host:port/path/` (scheme followed by a
single colon, which `new URL()` and npm accept) fails every resolution
before a request is made:
  ```
error: Invalid package name "react": manifest URL
"http://host:port/path/react" is not on registry "http:host:port/path/"
  error: InvalidURL
  ```
- Same failure for the other spellings the WHATWG parser rewrites (probe
against a local server on the released 1.4.0, full output in the details
below): a `..` segment in the path, an unencoded space in the path,
backslashes, surrounding whitespace. Three entries of the "Registry
URLs" table in `bun-install.test.ts` (`https:example.org`,
`https://////example.com///`, `http://點看`) hit it too; the table did not
notice because `failed to resolve` is also printed after this rejection.
- An upper-case scheme passes the manifest check (it compares
case-insensitively) but fails `for_tarball`'s same-origin comparison
(`src/install/NetworkTask.rs`, `send_auth`), which is case-sensitive, so
the tarball is requested without the `Authorization` header.
- Cause: `Scope::from_api` (`src/install/npm.rs`) and the `--registry`
branch of `Options::load`
(`src/install/PackageManager/PackageManagerOptions.rs`) store the
registry href as written. `NetworkTask::for_manifest` builds the
manifest URL with `bun_url::join`, which runs the href through the
WHATWG parser, and then compares the result against `URL::parse` of the
stored string. `URL::parse` is Bun's lenient scanner: it only recognizes
a scheme followed by `://`, does not resolve `..`, percent-encode, strip
whitespace or lower-case, so for these spellings the two sides describe
different URLs (`http:host:port/...` parses as hostname `http` with no
protocol). The same stored string feeds `for_tarball`'s origin
comparison, `extract_tarball::build_url`, `url_is_under_registry` in
`bun.lock.rs`, the DNS prefetch and the `@@<hostname>` cache folder
name.

### Fix
- Adds `Scope::set_url`: stores the WHATWG serialization of the
configured URL (`bun_url::URL::from_string`, the same parser `join`
uses) and derives `url_hash` from it. `Scope::from_api`, the
`--registry` branch and the `parseManifest` test helper
(`src/install_jsc/npm_jsc.rs`) all build the URL through it, so there is
one place that decides what a `Scope` holds.
- Correct because the stored href now equals the base `join` resolves
against, so every consumer that compares with, concatenates onto or
hashes the href agrees with the URL actually requested. The on-registry
check itself is unchanged and still rejects a name that joins outside
the registry directory (tested).
- Credentials cannot reach anything new. The manifest request URL is
unchanged (`join` parsed its base with the WHATWG parser before this
change too, so `join(as written, name)` and `join(normalized, name)` are
the same URL); only the value it is compared against changes. Both
checks compare against the stored href, so the only origin a tarball
request can now carry credentials to is the normalized registry origin,
which is where the manifest request (which always carries them) already
went. Before, a non-canonical spelling could only make the comparisons
fail. The one request URL that does change is the
`extract_tarball::build_url` fallback (manifest or `bun.lock` entry
without a tarball URL), which concatenates onto the href and now
produces a URL on that same origin instead of a string that failed the
`http(s)://` prefix check.
- A string the WHATWG parser rejects is stored as written, so the
`Failed to join registry "<as written>"` diagnostics for the invalid
entries of the table are unchanged (the table still asserts them).
- `set_url` runs after `from_api` has split the `/:_authToken=` style
credentials off the path, because the WHATWG parser would percent-encode
them. This is also why the normalization is not applied earlier, in the
config loaders: the `.npmrc` / bunfig string form extracts userinfo
credentials with the lenient parser first (an unencoded `#` in a
password is accepted there today).
- Already-canonical URLs serialize to themselves, so their `url_hash`,
manifest cache files and cache folder names are unchanged;
`https://registry.npmjs.org/` in particular still hashes to
`DEFAULT_URL_HASH`. The hash changes only for spellings the parser
rewrites; of those, only upper-case spellings worked before, and for
them the cost is one re-download of the cache.
- Not covered, on purpose: `.npmrc` credential lines
(`//host/path/:_authToken=`) are matched against the registry URL in
`src/ini/lib.rs` before a `Scope` exists, still by lenient parse of the
string as written, so a registry spelled `https:host/path/` gets its
requests but not its `.npmrc` token. That matching is being reworked in
#33869; filed separately. Spellings where the lenient parser takes the
port for a `:key=value` credential suffix (`http:/host:port/`,
`http:////host:port/`) are still mangled by the credential stripping
that runs before `set_url`; without a port they work.
- Verified: `test/cli/install/bun-install.test.ts` ("Registry URLs"):
new `spellings the WHATWG parser rewrites` block (bunfig registry object
with a token for each spelling, asserting the paths and `Authorization`
header of the manifest and tarball requests plus the cache folder name;
`.npmrc registry=`; `--registry`; the rejection message for a name that
joins outside the registry), and the table's handled entries now also
assert the rejection did not happen. 12 tests fail on the released build
(9 with the error above, the upper-case one with `authorization: null`
on the tarball, the rejection test because the message quoted the raw
spelling), all pass with this change.
- Also run with the change: the rest of `bun-install.test.ts` (remaining
failures are the bitbucket/gitlab/`some.url` network tests and
`--registry CLI flag`, which fail identically on the released build in
this container), `npmrc.test.ts`, the registry/whoami/manifest-cache
tests of `bun-install-registry.test.ts`,
`bun-install-pathname-trailing-slash.test.ts`, `cargo clippy` on
`bun_install` and `bun_install_jsc`, and the source lints.

### Background
- `npm::registry::Scope` is the package manager's record of one registry
(the default one or an `[install.scopes]` entry): its URL, credentials
and `url_hash`. `url_hash` keys the manifest cache files and tells
whether the default registry was overridden, which switches cache folder
names from `name@version` to `name@version@@<hostname>`.
- Bun has two URL parsers. `bun_url::URL::parse` is a lenient,
allocation-free scanner over the input bytes that the HTTP client and
the package manager use to read components out of a URL they already
hold. `bun_url::join` / `URL::from_string` call WTF::URL, the WHATWG
parser behind `new URL()`, which normalizes (scheme and host case,
missing slashes, `.`/`..` segments, percent-encoding, IDN) and rejects
what it cannot parse. The lenient scanner gives correct answers on
WHATWG output; the bug was feeding it the user's input instead.
- The "is not on registry" check exists so that a dependency name that
joins to another origin (an alias like `npm:\\other-host\pkg`) cannot
make Bun send the scope's credentials there; the tarball check in
`for_tarball` does the same for `dist.tarball` URLs returned by the
registry. Both are same-origin comparisons of a request URL against the
stored registry URL.

<details>
<summary>Probe: registry spellings against a local server (released
1.4.0 vs this branch)</summary>

Each row configures the spelling in `bunfig.toml` with one dependency
and records what reaches the server.

| registry as written | 1.4.0 | this branch |
| --- | --- | --- |
| `http://localhost:PORT/some/path/` | `GET /some/path/react` | same |
| `http:localhost:PORT/some/path/` | no request, `is not on registry` |
`GET /some/path/react` |
| `http:\\localhost:PORT\some\path\` | no request, `is not on registry`
| `GET /some/path/react` |
| `http://localhost:PORT/some/x/../path/` | no request, `is not on
registry` | `GET /some/path/react` |
| `http://localhost:PORT/some path/` | no request, `is not on registry`
| `GET /some%20path/react` |
| ` http://localhost:PORT/some/path/` (leading space) | no request, `is
not on registry` | `GET /some/path/react` |
| `HTTP://LOCALHOST:PORT/some/path/` | `GET /some/path/react` (tarball
would be sent without `Authorization`) | same request, tarball
authorized (covered by the test) |
| `http:/localhost:PORT/some/path/` | stored as `http://http/localhost/`
by the credential-suffix stripping | unchanged (see Fix) |
| `http:////localhost:PORT/some/path/` | stored as
`http://localhost/localhost/` by the credential-suffix stripping |
unchanged (see Fix) |

</details>

---

## Also folded in: audit — redact secrets in the registry URLs printed
by `bun audit` / `bun audit fix` (from #38844)

#### Problem
- With a registry URL that carries a secret, `bun audit` and `bun audit
fix` print it. With
`npm_config_registry=http://alice:s3cret@127.0.0.1:PORT/` and a registry
answering 404, stderr is `error: POST
http://alice:s3cret@127.0.0.1:PORT/-/npm/v1/security/advisories/bulk -
404`. A token in the registry path (`http://host/npm_.../`) is printed
the same way, and reaches these lines from every config source,
including `.npmrc`.
- Four outputs format the registry href verbatim (`BStr::new`), where
the rest of the package manager formats such URLs with
`bun_core::fmt::redacted_npm_url` (`Npm::response_error` in
`src/install/npm.rs`, the verbose request trace in `src/http/lib.rs`,
`bun pm whoami`):
- `src/runtime/cli/audit_command.rs` `send_audit_request`: the `POST
<url> - <status or error>` line (the repro above).
- `src/runtime/cli/audit_command.rs` `report_non_json_response`:
`<registry> returned a non-JSON audit response`, reached from the
report, `--json` and `audit fix` paths.
- `src/install/audit_fix.rs` `print_unaudited`: `warn: <registry> did
not answer the audit request (<reason>); skipped <packages>`, printed by
both commands for a scoped registry that failed.
- `src/install/audit_fix/json.rs`: the `registry` field of each
`unaudited` entry in `bun audit fix --json`.
- The href is `scope.url.href()` as configured
(`AuditRegistry::from_scope`; `unaudited()` copies it into the
`UnauditedRegistry` record behind the last two outputs). `.npmrc` and
bunfig registry strings move `user:password@` out of it while loading,
the bunfig object form, the registry env vars and `--registry` keep it
(#38796 and #38834 change the latter two), and a token in the path stays
in it in every case.
- The 1.3.x binaries print `audit request failed (status N)` without a
URL; the URL in these lines came with the audit rewrite in #38333, so
this has not shipped in a release.

#### Fix
- The `POST` line and `report_non_json_response` format the URL with
`redacted_npm_url`: these quote the request URL, so they get the same
masked form as the manifest and tarball error lines
(`http://alice:******@host/...`, path token as `***`).
`AuditRegistry.href` itself stays raw because it is also what the
request is sent to.
- The skipped-registry record names a registry rather than quoting a
request, so `unaudited()` builds it from `href_without_auth()` (trailing
slash stripped), the same form `bun publish` prints as its registry:
credentials written into the URL are left out instead of masked, and the
record reads the same whichever config source the scope came from
(`http://host:PORT`, matching what `.npmrc` scopes already produced).
Its two emitters, the warning and the `--json` field, format it with
`redacted_npm_url` for tokens in the path (`http://host:PORT/***`); the
`--json` value is rendered into a buffer first because the JSON string
writer takes bytes. The `unaudited` array is new in #38333, so nothing
depends on the raw form.
- For a URL without a password, UUID or `npm_` token the output is byte
for byte unchanged; the 177 existing tests in `bun-audit.test.ts`, many
of which assert these exact lines with plain URLs, still pass.
- Not touched, same class elsewhere: `bun audit fix`'s `was not checked
for updates` lines repeat the install log's `GET <url> - <status>` text,
which #38817 redacts at its source; the registry URL lines in
`src/install/NetworkTask.rs` (`Failed to join registry ...`, `... is not
on registry ...`) and `src/install/pnpm.rs` (`fetching pnpm registry ...
from <url>`) are install-side and have been filed separately. This PR
and #38817 touch disjoint files.
- Tests: `test/cli/install/bun-audit.test.ts`, new `bun audit with a
secret in the registry URL` block, one test per output: the `POST` line,
the non-JSON line from the response check, the non-JSON line from the
parse step in report, `--json` and `fix` mode, the skipped-registry
warning and `unaudited[].registry` with a token in the registry path
(`.npmrc` scope), and the same two outputs with `user:password@` in the
URL (bunfig object-form scope, which keeps it), asserting the
credential-free form. The path-token cases use a token rather than a
password because a token reaches the audit command from every config
source; the credential case asserts the stripped form, which stays true
once #38796 / #38834 strip credentials earlier, so this PR does not
depend on their landing order. All five fail on this branch with `src/`
stashed (the token or password is printed); the credential case also
fails with only the `unaudited()` hunk removed (it then prints
`alice:******@`), and the `--json` case with only the `json.rs` hunk
removed; all 182 tests in the file pass with the change.
- Also ran `cargo clippy -p bun_install -p bun_runtime`, `cargo fmt
--check` on both crates, and `test/internal/source-lints`.

#### Background
- `bun audit` POSTs the lockfile's package versions to
`<registry>/-/npm/v1/security/advisories/bulk`. Packages whose scope
(`@foo/*`) is configured with its own registry are sent to that registry
instead; when a non-default registry fails to answer (HTTP error,
connection error, non-JSON body) its packages are reported as skipped
(one `UnauditedRegistry` record per registry, printed as the warning
and, by `audit fix --json`, as the `unaudited` entries) rather than
failing the command, while a failure from the default registry fails the
command with the `POST` or non-JSON line.
- `redacted_npm_url` (`src/bun_core/fmt.rs`) is a `Display` adapter over
URL bytes: the password of `scheme://user:password@host` is written as
one `*` per byte (the per-byte form is shared with the config-excerpt
redactor, which needs column alignment), and any UUID or `npm_`/`npms_`
token anywhere in the string as `***`; everything else is written
through unchanged. `Output::err_generic`, `warn!` and `pretty_errorln!`
take any `Display` argument, so it is a drop-in replacement for
`BStr::new` (`err_generic`'s `{s}`/`{f}` placeholder letters are
cosmetic; `{f}` is what the other `redacted_npm_url` call site uses).
- `URL::href_without_auth()` (`src/url/lib.rs`) rebuilds
`scheme://host[:port]/path/` from the parsed URL, dropping any userinfo;
it is what the config loaders use to store a registry URL whose
credentials were split out, and what `bun publish` prints as
`Registry:`. It currently yields `http://host//` for a root-path
registry (#38812 changes that to one slash); `unaudited()` strips
trailing slashes afterwards, so the record is `http://host:PORT` either
way. Tokens that are part of the path survive it, which is why the
record's emitters still go through `redacted_npm_url`.

<details>
<summary>Before / after on a debug build (404 registry, non-JSON
registry, scoped registry with a path token, scoped registry configured
as <code>{ url = "http://alice:s3cret@..." }</code>)</summary>

Before:

```
error: POST http://alice:s3cret@127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8/-/npm/v1/security/advisories/bulk - 404
error: http://alice:s3cret@127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 returned a non-JSON audit response
warn: http://127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://127.0.0.1:PORT/npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8","packages":["@foo/bar"],"reason":"404"}],...}
warn: http://alice:s3cret@localhost:PORT did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://alice:s3cret@localhost:PORT","packages":["@foo/bar"],"reason":"404"}],...}
```

After:

```
error: POST http://alice:******@127.0.0.1:PORT/***/-/npm/v1/security/advisories/bulk - 404
error: http://alice:******@127.0.0.1:PORT/*** returned a non-JSON audit response
warn: http://127.0.0.1:PORT/*** did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://127.0.0.1:PORT/***","packages":["@foo/bar"],"reason":"404"}],...}
warn: http://localhost:PORT did not answer the audit request (404); skipped @foo/bar
{"dryRun":false,...,"unaudited":[{"registry":"http://localhost:PORT","packages":["@foo/bar"],"reason":"404"}],...}
```

</details>

<details>
<summary>Earlier revision of this PR</summary>

The first revision applied `redacted_npm_url` to the `UnauditedRegistry`
record as well, so for the config sources that keep credentials in the
URL the warning and the `--json` field came out as
`http://alice:******@host` (username and password length, and a
different shape from the `.npmrc` case, which had no userinfo to begin
with). Review pointed out that every other place bun emits a registry
URL as data drops the credentials instead; the record is now built with
`href_without_auth()` and the per-byte masking is confined to the two
lines that quote the request URL.

</details>

Closes #38844

<!-- robobun:evidence:begin -->

---

**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-install.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
alii pushed a commit that referenced this pull request Aug 17, 2026
#39445)

Stacked on #36463 (the base branch is that PR's branch, so the diff here
is only the additions). Merging this into #36463 adds the behavior
changes listed below; #36463 itself now covers the #38333 install batch,
the optional-peer correction, and the TOML / `bun init` fixes, so this
PR no longer touches those.

### Problem
- These 1.3 to 1.4 behavior changes are not in the guide at `701b3e2a0`:
- MySQL: the first `caching_sha2_password` connection over plain TCP is
refused unless `allowPublicKeyRetrieval: true` (#31129; 1.3.14 requested
the key automatically, `MySQLConnection.zig` in the 1.3.14 tag). SQL
`tls` / `ssl` options now require TLS instead of falling back to
plaintext, and `?ssl=` / `?ssl-mode=` are read (`shared.ts` 1.3.14 only
read `?sslmode=`; #37669).
- Install: `~/.npmrc` fallback when `XDG_CONFIG_HOME` is set (#36289),
credentials in `--registry` / env / bunfig object URLs are sent and
outrank same-host `.npmrc` tokens (#38796, #38824), `bun outdated` exits
1 on fetch failures (#38809), new `dedupe` / `up` commands shadow
scripts of those names and `bun feedback` is removed (#38333, #38444),
`workspace:` ranges inside registry packages (#37669), isolated store
entry names (#39014).
- Runtime: `module.enableCompileCache()` / `NODE_COMPILE_CACHE`
implemented (#34660), `require()` / `import` not-found messages
(#34660), `AbortError` message without the period (#39277; 1.3.14's
`BunCommonStrings.h` has the period), GCM IV length (#34092),
`mkdtemp("")` (#34908), vm options (#38381), `server.reload` (#38697),
ICU 75/73 to 78 (#38013), Compression stream chunking (#38695),
`Bun.SQL` sqlite bindings (#35950).
- Bundler: `splitting` with `cjs` / `iife` is an error (#32685),
block-scoped `enum` lowers to `let` (#34249), exports emitted ascending
instead of descending (#35957; `doStep5.zig` in 1.3.14 used `sortDesc`),
minified `$` (#35668).
- The TOML integer bullet did not say what the limit or the fix is.

### Fix
- Adds a MySQL public key section (plus a summary table row), a TLS note
under the `PGSSLMODE` section, an `.npmrc` / credentials addendum to the
`bunfig.toml` section, a `module.enableCompileCache()` section, and the
rest as bullets in the existing lists.
- `docs/pm/overrides.mdx`: one-line change adding a pointer to this
guide in the existing `lockfileVersion` 3 limitation. (The base branch
briefly had a duplicate "Nested overrides" section; it removed that
itself in `8257d01acb`, and this PR was rebased over it.)
- Verification: each runtime claim was run against
`1.4.0-canary.1+8326d1bd3` (22 commits behind main; contains every
change referenced), and each install or bundler claim was checked
against the source on main, with the 1.3 side taken from the
`bun-v1.3.14` tag where the PR body did not state it. The
`/runtime/sql#mysql` and `/upgrade-to-1.4` links resolve. `prettier
--check` passes.

### Not included on purpose
- Lifecycle scripts no longer receiving `npm_package_name` /
`npm_package_version` / `npm_package_json` / `npm_config_local_prefix`
during `bun install`, and transitive `"*"` ranges no longer
deduplicating onto the root's version: regressions with open fixes
(#36690, #38110, #38770). They need either the fixes or a guide line
before release.
- Postgres `sslmode=prefer` / `allow` (including `PGSSLMODE=prefer`,
which 1.4 newly reads) hangs until the connection timeout against a
server without SSL because nothing sends the startup message after the
`N` reply. Same code in 1.3.14; filed as a bug instead of documented.

<details>
<summary>Commands used to verify the runtime claims</summary>

```
timers/promises setTimeout with an aborted signal             # "The operation was aborted"
bun req.cjs                                                   # Cannot find module ... Require stack:
bun b.mjs (import() of a missing package / relative file)      # Cannot find package 'x' imported from /path, ERR_MODULE_NOT_FOUND
bun a_static.mjs (unhandled static import)                    # printed line still: Cannot find package 'x' from '/path'
process.versions.icu                                          # 78.3
createCipheriv("aes-128-gcm", key, Buffer.alloc(129))         # ERR_CRYPTO_INVALID_IV
DecompressionStream of a 1 MiB gzip member                    # 16 chunks of 65536 bytes
new SQL("sqlite://:memory:") with ${[1,2]} / ${new Date()}    # Binding expected ...
fs.mkdtempSync("")                                            # EINVAL
vm.runInThisContext("1", [])                                  # ERR_INVALID_ARG_TYPE
NODE_COMPILE_CACHE=/tmp/cc bun cc.cjs                         # creates /tmp/cc/v1.4.0-x86_64-<sha>-<uid>
NODE_DISABLE_COMPILE_CACHE=1 + enableCompileCache()           # status 3 (DISABLED)
bun dedupe / bun up with package.json scripts of those names  # built-in command runs
bun feedback                                                  # Script not found "feedback"
Bun.build({ splitting: true, format: "cjs" })                 # Code splitting is currently only supported ...
bun build of a function-scoped enum and import * as ns         # let Color; exports a, m, z
new SQL({ url: "postgres://...", tls: true }) on a non-TLS server  # ERR_POSTGRES_TLS_NOT_AVAILABLE
Bun.TOML.parse("a = 9007199254740993")                        # Integer cannot be losslessly represented ...
```

</details>

<details>
<summary>Previous revision</summary>

The first revision of this PR (`3c5611454a`) also rewrote the package
manager section for #38333 / #38853 (nested overrides and
`lockfileVersion: 3`, the optional-peer correction, `bun update`,
`bunfig.toml` over `.npmrc`, `--filter`) and fixed the TOML date and
`bun init` lines. #36463 picked those up in its own commits the same
day, so this PR was rebased onto its new head and reduced to the items
above.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · docs-only change; test-proof not
applicable

<!-- robobun:evidence:end -->
dylan-conway pushed a commit that referenced this pull request Aug 17, 2026
…ile: tarballs relative to their folder package (#38867)

This PR bundles three independent install fixes (each was reviewed on
its own PR; folded here so they land together):

1. bound the resolution part of isolated store entry names (this PR's
original change)
2. send credentials embedded in a tarball URL as Basic authorization
(from #39025)
3. read `file:` tarballs relative to the `file:` folder package that
declares them (from #39017)

Rebased on main after #39014: the isolated-store test file now computes
expected git entry names through the same resolution cut
(`storeEntryName`), since a `git+http+++host+port+repo.git+<url
hash>+<sha>` resolution passes 80 bytes.

---

## 1. install: bound the resolution part of isolated store entry names

#### Problem

- With `linker = "isolated"`, a trusted git dependency with any
lifecycle script makes `bun install` fail on Windows with `error: Failed
to run script prepare due to error ENOENT` (exit 1). The same project
installs fine with the hoisted linker. This is the Windows failure of
the isolated case in #38810's test, which that PR currently skips on
Windows.
- A store entry is named `<name>@<resolution>` (`StoreKeyFormatter`,
`src/install/isolated_install/Store.rs`). For git, github, tarball and
folder dependencies the resolution is the whole URL or path with
separators turned into `+` (`src/install/repository.rs`
`StorePathFormatter`, `src/install/resolution.rs` `StorePathFormatter`),
plus the commit for git: a git dependency checked out from a temp
directory gets an entry like
`dep@git+file++++C++Users+AZUREU~1+AppData+Local+Temp+lc-repro+dep-repo+3c6955c70dbe1ff186a126c93c5e77c3ae7bd6b4`
(111 bytes here, 170+ in #38810's test), and the name grows with the
repository path.
- The package directory,
`<project>\node_modules\.bun\<entry>\node_modules\<name>`, is the cwd of
the package's lifecycle scripts (`Scripts::get_list` ->
`lifecycle_script_runner.rs` `SpawnOptions.cwd`). bun's own file
operations use long-path-capable NT paths, so the entry is created and
linked, but `CreateProcessW` does not accept a current directory longer
than `MAX_PATH`, and the spawn (libuv's `uv_spawn` on Windows) reports
that as ENOENT. Measured on Windows x64 with the released
`1.4.0-canary.1`: a package directory of 238 characters runs the script,
one of 268 characters fails as above.
- The same unbounded name also fails on every platform once it passes
`NAME_MAX`: a tarball URL or folder path of 250+ bytes makes the install
fail with `ENAMETOOLONG: File name too long: failed to link package`
(the case of #37470).

#### Fix

- `StoreKeyFormatter` now writes the resolution through
`write_resolution`: a resolution of at most 80 bytes
(`MAX_RESOLUTION_LEN`) is written unchanged; a longer one is written as
its first 63 bytes (backed up to a UTF-8 character boundary) followed by
`+` and the 16 hex digit wyhash of the full text (seed 0, so
`Bun.hash(text)` reproduces it), which is 80 bytes again. The `name@`
prefix and the `+<peer hash>` suffix are unchanged. A cut git entry
looks like
`dep@git+file++++C++Users+AZUREU~1+AppData+Local+Temp+lc-repro-aaaaa+ef7ad81a431ad661`
(the 268 character case from the measurement above; its package
directory is now 206 characters).
- Why this is correct:
- Every path that contains an entry name is formatted through this one
`Display` impl: the project store directory and the dependency symlinks
into it (`Installer.rs` `append_store_path` /
`append_store_node_modules_path` / `link_to_hidden_node_modules`), the
lifecycle script cwd (same `append_store_path`), the global store
directory name and the entry hash seeded from the name (`Installer.rs`
`append_global_store_entry_path`, `isolated_install.rs` entry hash),
`bun pm prune`'s set of expected directories (`prune.rs`
`push_store_entry_names`) and `bun pm licenses`'s lookup
(`pm_licenses_command.rs` `BunStore::lookup`, via `fmt_store_key`). They
all see the same cut name; nothing persists or parses the old one (the
lockfile stores resolutions, and an existing entry is detected by
re-deriving its name).
- The cut name stays a function of the resolution alone, so repeated
installs find the same entry, and two resolutions that agree on the
first 63 bytes still get distinct entries through the hash. The sink
hashes the text in whatever chunks the formatters write it in (the path
formatters write one character at a time); `Wyhash`'s streaming form is
chunk-invariant (`test_iterative_chunked_matches_oneshot` in
`src/wyhash/lib.rs`), which is what lets the tests compute the expected
names with `Bun.hash`.
- The cut happens inside the resolution only, so `prune.rs`
(`split_store_key`, `store_has_entries`, `store_link_target`, which
split the name at `@`) and `pm licenses` (which matches `<key>` or
`<key>+<peer hash>`) keep working without changes.
- Resolutions of 80 bytes or less are byte for byte what they were, so
versions and `github:` shorthands (`github+owner+repo+<sha>` is about 60
to 75 bytes) are unaffected on upgrade. Most full git URLs are over 80
bytes (`git+https+++github.com+` is 23 bytes and the commit adds 41), so
those entries are re-created once under the cut name on the first
install after upgrading, and the old directories stay until `bun pm
prune`, as for any re-resolved entry. The cut keeps the URL itself
readable:
`git-pkg@git+file++++tmp+licenses-git-repo_eXITMt+491eb29762d8d19b570b43+f87e35b31f207dd4`.
- 80 is the tunable here. The package directory is `<project> + 34 + 2 *
<name> + <resolution>` (+17 with peers) characters, so with 80 it fits
`MAX_PATH` whenever `<project> + 2 * <name>` is at most 145 (128 with
peers); #38810's case (61 character temp dir, 25 character name) ends up
at about 225 instead of about 290. It also makes the entry directory
name fit `NAME_MAX` together with every suffix the installers append
(`+<peer hash>`, `-<entry hash>`, `.tmp-<hex>`) for names up to 119
bytes, and with the peer suffix alone for names up to 157 bytes. For
comparison, pnpm's `virtual-store-dir-max-length` defaults to 120 bytes
for the whole directory name; 80 bytes of resolution plus a typical name
lands in the same range.
- #37470 (the same mechanism with a 200 byte limit on the whole
`name@resolution`, for the `NAME_MAX` case only) is closed in favor of
this PR: that limit does not help the `MAX_PATH` case (the failing names
above are 130 to 180 bytes), and a cut through `name@` would no longer
work with `bun pm prune` / `pm licenses` (see the `prune.rs` bullet
above). Its repro installs with this branch and its deep-directory case
is carried over below. What it covered and this PR does not is the name
part: a name over 119 bytes (157 in the project store) can still produce
an entry over `NAME_MAX` once its resolution is cut; if that should be
covered too, it would be the same sink applied to the name separately,
keeping the `@`. #36973 (documenting the layout) would need a sentence
about this rule; this PR adds a short one to
`docs/pm/isolated-installs.mdx`. #38810's Windows skip of its isolated
case can be removed once both land. #39014 changes the same text (it
removes URL credentials, and the hash is of the text as formatted), so
landing it in the same release as this avoids renaming those entries
twice.
- Verification, `long store entry names` in
`test/cli/install/isolated-install.test.ts`:
- a folder resolution of exactly 80 bytes is kept verbatim and one of 81
bytes is cut to the name computed with `Bun.hash`; two resolutions of
one package that only differ after the cut point get separate entries
that each resolve to their own package; a second install finds the same
entries
- a cut entry with a resolved peer gets `+<peer hash>` appended after
the cut name and links the peer
- a folder name made of 2 byte characters, positioned so that byte 63
falls inside a character, is cut at byte 62 and still installs (without
the character boundary loop this case panics with `a formatting trait
implementation returned an error`; the test only asserts the shape
because the spelling of non-ASCII bytes in these names is #32304's
subject)
- (carried over from #37470) a local tarball and a folder three 85 byte
directories deep, whose unbounded entry names would be 277 bytes,
install; the top-level symlinks and the `.bun/node_modules` fallback
link point at the cut names, and a second install finds the same entries
- a tarball URL of about 290 bytes installs with `install.globalStore`
enabled: the local entry has the cut name, links to `links/<cut
name>-<entry hash>`, and the package imports at runtime
- the reported case: a trusted `git+file://` dependency whose repository
directory name alone is 60 characters runs its postinstall script with
the isolated linker; the entry has the cut name
- Without the `Store.rs` change the first three tests and the git one
fail on the entry name (on Windows the git one fails with `error: Failed
to run script postinstall due to error ENOENT`, the reported message)
and the deep-directory and tarball ones exit 1 with `ENAMETOOLONG`; all
of them pass with `bun bd test` on Linux (the five original ones also on
a local Windows x64 build; the deep-directory one on the Windows lanes
in CI). #37470's repro from its description (`./x/../` x130 tarball
spec) installs with this branch with an 84 byte entry.
- Also run with the fix: the rest of `isolated-install.test.ts`,
`bun-pm-licenses.test.ts` (its `git dependency is listed (isolated)`
case now goes through a cut name on every platform, since the repo lives
in a temp dir), `bun-prune.test.ts`, `public-hoist-pattern.test.ts`,
`isolated-relink.test.ts`, `bun-install-native-binlink.test.ts`,
`config-version.test.ts`, and the PowerShell reproduction from the
report on Windows x64 (package directory of 268 characters before, 206
after, script runs).

#### Background

- Isolated linker: instead of hoisting, every package is materialized
once under `node_modules/.bun/<entry>/node_modules/<name>` and
everything else symlinks to it. `<entry>` is `<name>@<resolution>`, plus
`+<16 hex>` when the package was installed for a specific set of peer
dependencies. With `install.globalStore`, `node_modules/.bun/<entry>` is
itself a symlink to `<cache>/links/<entry>-<16 hex entry hash>`.
- Resolution: the lockfile's description of where a package came from.
It is the version for registry packages and the spec itself (folder
path, tarball URL, git URL plus commit) for everything else, which is
why only those entries get long.
- `MAX_PATH`: Windows' 260 character limit for paths passed to Win32
APIs. Applications can opt out of it for file operations (bun does, by
using `\\?\` / NT paths), but the current directory handed to
`CreateProcess` is still limited to it, and libuv maps the resulting
Win32 error to ENOENT.
- `NAME_MAX`: the 255 byte limit on one path component on Linux and
macOS (255 UTF-16 units on NTFS); exceeding it fails with `ENAMETOOLONG`
regardless of how long the whole path is.

<details>
<summary>Windows measurement with the released build
(1.4.0-canary.1)</summary>

Git repo and project created under `%TEMP%` with `linker = "isolated"`,
`trustedDependencies: ["dep"]` and `"prepare": "echo prepared >
prepared.txt"`; the temp directory name was lengthened to move the
package directory across the limit.

```
store entry: dep@git+file++++C++Users+AZUREU~1+AppData+Local+Temp+lc-repro-aaaaaaaaaaaaaaaaaaa+dep-repo+d1a8f7c4111533cb16b52a9edcb086329e6472b5
pkg dir len: 238
prepared.txt exists: True
exit: 0

store entry: dep@git+file++++C++Users+AZUREU~1+AppData+Local+Temp+lc-repro-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa+dep-repo+30b6ecc8d4d0e4fb2f3df2402556222bf6a5b7dc
pkg dir len: 268
pkg dir exists: True
prepared.txt exists: False
error: Failed to run script prepare due to error ENOENT
exit: 1
```

Same second layout with this PR's build:

```
store entry: dep@git+file++++C++Users+AZUREU~1+AppData+Local+Temp+lc-repro-aaaaa+ef7ad81a431ad661
pkg dir len: 206
prepared.txt exists: True
exit: 0
```

</details>

<!-- robobun:evidence:begin -->

---

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

<!-- robobun:evidence:end -->

---

## 2. install: send credentials embedded in a tarball URL as Basic
authorization (from #39025)

#### Problem
- A dependency declared as a tarball URL with credentials, `"no-deps":
"http://carol:s3cret@127.0.0.1:PORT/cdn/no-deps-1.0.0.tgz"`, is
downloaded without an `Authorization` header. A server that needs the
credentials answers 401 and the install fails with `error: GET
http://carol:s3cret@127.0.0.1:PORT/cdn/no-deps-1.0.0.tgz - 401` (bun
1.4.0 and `main`, hoisted and isolated linker). npm 11 installs the same
package.json and sends `Authorization: Basic Y2Fyb2w6czNjcmV0`
(`base64("carol:s3cret")`).
- The username-only form (`http://token@127.0.0.1:PORT/x.tgz`) does not
reach the server at all: the request goes to the hostname
`token@127.0.0.1`.
- Cause: `NetworkTask::for_tarball` (`src/install/NetworkTask.rs`) only
ever attaches the registry scope's configured token or `_auth`, and only
for npm packages whose tarball is on the registry's origin. Nothing
reads the URL's userinfo, and the HTTP client does not either (it only
turns the userinfo of a proxy URL into `Proxy-Authorization`). The
misrouted username-only form comes from `bun_url::URL::parse`, which
takes `token@127.0.0.1` for the hostname when the userinfo has no `:`
and a port follows (tracked separately; this change no longer depends on
how that parser splits the authority).
- Found while fixing the isolated store names of these URLs (#39014),
not from a user report.

#### Fix
- `for_tarball` splits the userinfo off the request URL before anything
else looks at it (`split_url_userinfo`: the authority runs from `://` to
the first `/`, `?` or `#`, the userinfo is everything in it up to the
last `@`, so the `@` of `/@scope/pkg/-/pkg.tgz` is not one) and sends it
as `Authorization: Basic base64(userinfo)`
(`basic_authorization_from_userinfo`, which appends `:` when the
userinfo has no password). The request URL is the URL without the
userinfo.
- Precedence: when the registry scope's credentials apply to the tarball
(npm package, tarball on the registry origin, scope has a token or
`_auth`), they are still sent and the URL's are not. This is npm's order
as well: npm-registry-fetch sets the header from the config, and node
only derives `Authorization` from the URL's `auth` when the request has
no such header.
- Why Basic of the userinfo as written: it is what npm sends, checked
against npm 11.16 for `user:pass`, `user` (sent as `user:`), `:pass` and
a percent-encoded password (sent undecoded); transcript below. It is
also how bun already treats credentials written into a registry URL
(#38796 stores the username and password bytes as given). pnpm was not
available offline. Two spellings are deliberately not npm's: a second
`:` inside the password is sent as written where npm percent-encodes it,
and `:token@` in a tarball URL is Basic like npm rather than the Bearer
that bun's registry URLs make of it; both are called out in the test
table and in the code comment.
- Why the URL is requested without the userinfo: `bun_url::URL::origin`
includes the userinfo and the HTTP client compares origins to decide
whether `Authorization` follows a redirect, so with the userinfo left
in, a redirect to the same host would drop the header (verified: with
only the header added, the redirect test below fails with
`authorization: null` on the second hop). It also fixes the
username-only form without touching `bun_url`, and the `GET <url> - 401`
line now prints the URL without the credentials. The cross-host rule is
unchanged: the HTTP client strips the header on a redirect to another
origin, same as for the registry token (test included).
- Behavior outside the request is unchanged: `package.json`, the
lockfile, the task id and the cache key still use the URL as written.
Documented in `docs/pm/cli/add.mdx`.
- Verified with `test/cli/install/bun-install.test.ts`,
`describe("credentials embedded in a tarball URL")`: 12 tests, 10 of
which fail on the unfixed build (the two guards, scoped path and
registry credentials taking precedence, pass on both). The rest of the
file is unchanged: the remaining failures locally are the tests that
need the public internet, identical with the unmodified binary.
- `cargo check -p bun_install`, `cargo clippy -p bun_install`, rustfmt
and prettier are clean.

#### Background
- Tarball dependency: a `package.json` entry whose version is an
`http(s)://` URL ending in `.tgz`/`.tar.gz`/`.tar`. Bun downloads it
directly; no registry manifest is involved, so the registry's configured
credentials never applied to it (`Authorization::NoAuthorization` at the
call sites in `PackageManagerEnqueue.rs`). Registry packages reach the
same `for_tarball` through their manifest's `dist.tarball` URL with
`AllowAuthorization`, which is the case where the registry scope's
credentials can apply.
- Userinfo: the `user:password@` part of a URL's authority (RFC 3986
section 3.2.1). HTTP never puts it on the wire; clients that honor it
(npm through minipass-fetch, curl, browsers) convert it into
`Authorization: Basic base64(user:password)`.
- `NetworkTask::for_tarball` builds one HTTP request per tarball
download: `url_buf` (the request URL) and `header_buf` (the headers,
built with `HeaderBuilder` in two passes, `count` then `append`, because
the buffer is allocated exactly once in between). Retries reuse the same
request, so the header is sent on every attempt.
- `bun_url::URL::parse` is the allocation-free splitter the HTTP client
and `for_tarball` use; it is not a WHATWG parser. Its `origin` is a
prefix of the input string, which is why it still contains the userinfo.

<details>
<summary>npm 11.16 against a local server logging the Authorization
header (same tarball, same package.json shape)</summary>

```
userinfo in the dependency URL   header npm sent
carol:s3cret@                    Basic Y2Fyb2w6czNjcmV0      = base64("carol:s3cret")
carol@                           Basic Y2Fyb2w6              = base64("carol:")
:s3cret@                         Basic OnMzY3JldA==          = base64(":s3cret")
carol:s3%40cret@                 Basic Y2Fyb2w6czMlNDBjcmV0  = base64("carol:s3%40cret"), not decoded
carol:s3:cret@                   Basic Y2Fyb2w6czMlM0FjcmV0  = base64("carol:s3%3Acret"), bun sends base64("carol:s3:cret")
carol:s3cret@ + 302 to same host Basic ... on both hops
```

bun 1.4.0-canary.1 (eabb96d) for the first row: the server logs
`auth=null` and bun prints `error: GET
http://carol:s3cret@127.0.0.1:PORT/cdn/no-deps-1.0.0.tgz - 401`.
</details>

---

## 3. install: read `file:` tarballs relative to the `file:` folder
package that declares them (from #39017)

#### Problem

- A `file:` folder dependency whose own package.json declares a local
tarball fails to install (released 1.4.0 canary and main, both linkers):
project package.json `{"dependencies":{"lib":"file:./vendor/lib"}}`,
`vendor/lib/package.json` declaring `"tool": "file:./tool.tgz"`,
`vendor/lib/tool.tgz` present:
  ```
  error: ENOENT extracting tarball from tool
  error: tool@file:./tool.tgz failed to resolve
  ```
- The path is read as `<project>/tool.tgz`. If that file happens to
exist it is installed as `tool` instead of the folder's copy, with no
error. The directory form of the same declaration (`"tool":
"file:./tool"`) is already resolved relative to `vendor/lib`.
- Cause: `enqueue_local_tarball`
(`src/install/PackageManager/PackageManagerEnqueue.rs`) picks the
directory a local tarball path is relative to, and the only declarer it
looked at was a workspace (`get_workspace_pkg_if_workspace_dep`). Every
other declarer, including a `file:` folder package, fell through to the
top-level dir.
- The same choice was also wrong in the other direction: a root
`overrides` / `resolutions` entry or catalog entry pointing a dependency
at `file:./x.tgz` was read relative to the workspace when the dependency
it applied to was declared by a workspace member, so `overrides: { bar:
"file:./bar.tgz" }` with `bar.tgz` in the project root failed with the
same ENOENT as soon as a workspace depended on `bar`. This is #25835
(`overrides`) and #25752 (`catalogs`); both reproduce as reported on the
released build and install with this change. The directory form of an
override is already resolved relative to the project (`Folder` arm of
`get_or_put_resolved_package`).

Fixes #25835
Fixes #25752

#### Fix

- `enqueue_local_tarball` now takes the base directory from
`local_tarball_base_dir`: the directory of the declaring package when it
is a workspace or a `file:` folder package and that package's own
specifier is the tarball path being read; the top-level dir in every
other case.
- Why the declaring package: a path in a package.json means a file next
to that package.json, which is what npm does for `file:` and what bun
already does for workspace declarers and for the directory form. A
`file:` folder package is read from the project like a workspace is, and
its `Resolution::Folder` payload is its directory relative to the
top-level dir (`folder_resolver.rs`, `NewResolver { folder_path: rel
}`), the same shape as a workspace's `Resolution::Workspace` payload, so
both are joined the same way. The only other `Resolution::Folder`
packages are the stubs created for `file:` directories declared by
something other than the root or a workspace (`Folder` arm of
`get_or_put_resolved_package`); those carry no dependency list, so they
are never the declarer of an edge.
- Why the "own specifier" condition: `overrides`, `resolutions` and
catalogs are only parsed from the root package.json (`Package.rs`,
`FEATURES.is_main`), and applying one leaves the declaring package's
stored edge untouched (the replacement is local to
`enqueue_dependency_with_main_and_success_fn`). So when the stored
edge's specifier is not the path being read, the root wrote the path and
the top-level dir is the only directory it can mean. Without this
condition, root overrides applied to a folder-declared dependency, which
work today only because of the bug, would start being read from the
folder.
- Why it is decided from the edge and not from the resolve pass's
`version_was_replaced`: `enqueue_local_tarball` is also reached from
`enqueue_tarball_for_reading` when a project with a `bun.lock` is
installed into an empty cache. The lockfile row keeps the path as
declared (`"tool": ["bar@./tool.tgz", ...]` under `"lib": [..., {
"dependencies": { "tool": "file:./tool.tgz" } }]`), and the edge's
declarer and specifier are available there too, so both passes compute
the same directory. Lockfile format is unchanged and existing lockfiles
keep working; the path in the row is joined onto a different directory
only for declarers that previously failed or installed the wrong file.
- Declarers extracted from the cache (registry, git, tarball packages)
still fall through to the top-level dir, as before; #38986 is changing
what happens to those separately.
`Lockfile::get_parent_pkg_of_dependency` is the same helper that PR
adds.
- Left as is: the lockfile identity of a local tarball is still the path
as written (`bar@./tool.tgz`), so two project packages declaring the
same relative path to two different files still share one row and one
read, the limitation workspaces already have. A package.json inside a
folder dependency that worked around this bug by writing a
project-relative path (`file:./vendor/lib/tool.tgz`) will now need the
path relative to itself, which is what npm requires for it as well.
- Verified with `test/cli/install/bun-install.test.ts`, `describe("file:
tarball declared by a file: folder dependency")`: the folder's tarball
is installed with the hoisted and with the isolated linker, and a root
override supplying the path is read from the project. Each test plants a
different tarball at the other candidate path and runs a fresh install
followed by `--frozen-lockfile` into an emptied cache, so both the
resolve pass and the install from `bun.lock` have to read the right
file. `test/cli/install/bun-workspaces.test.ts`, `relative tarballs >
from a root override / catalog entry applied to a workspace dependency`,
covers #25835 and #25752 the same way. The four folder/workspace tests
install the wrong tarball without the `src/` change (checked against a
debug build of main and against the released build); the
override-on-folder test passes before and after and pins that case.
- Also run with this change: `bun-workspaces.test.ts` (74 pass),
`overrides.test.ts` + `nested-overrides.test.ts` (151 pass),
`bun-lock.test.ts` (40 pass), `bun-install.test.ts -t
"tarball|tgz|file:|folder|override|resolutions"` (51 pass; `should treat
non-GitHub http(s) URLs as tarballs` fails identically on the released
build, it needs network), `isolated-install.test.ts`, `bun-add.test.ts`
and `bun-install-registry.test.ts` filtered to tarball/file tests (all
pass). `cargo clippy -p bun_install` is clean.

#### Background

- A `file:` dependency on a directory is a `Tag::Folder` dependency; one
on a `.tgz` is a `Tag::Tarball` dependency with a local URI. The latter
resolves to `Resolution::LocalTarball(<path as written>)`; the path
string is both the task id for reading it and the package's identity in
`bun.lock`.
- bun reads the package.json of the root, of workspace members and of
`file:` folder dependencies from disk (`Package::parse`), and stores for
each of the latter two a `Resolution::Workspace` / `Resolution::Folder`
whose payload is the package directory relative to the top-level dir.
Packages from the registry, git or a tarball get their dependency lists
from the manifest or the extracted archive instead and have no directory
in the project while they resolve.
- Dependency edges live in one flat buffer and every package owns a
contiguous slice of it, which is how the package that declared an edge
is found. The edge stores the specifier as declared; overrides,
resolutions and catalogs replace it only for the duration of resolving
that edge.
- `enqueue_local_tarball` is called from two places: the `Tarball` arm
of the resolve pass (no lockfile row yet, the tarball is read to learn
the package's name and dependencies) and `enqueue_tarball_for_reading`
during install (a row exists in `bun.lock`, but the extracted package is
missing from the cache). It computes the on-disk path on the main thread
and hands it to a thread pool task, which only reads the file.

Closes #39025
Closes #39017

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun added a commit that referenced this pull request Aug 21, 2026
#39445)

Stacked on #36463 (the base branch is that PR's branch, so the diff here
is only the additions). Merging this into #36463 adds the behavior
changes listed below; #36463 itself now covers the #38333 install batch,
the optional-peer correction, and the TOML / `bun init` fixes, so this
PR no longer touches those.

### Problem
- These 1.3 to 1.4 behavior changes are not in the guide at `701b3e2a0`:
- MySQL: the first `caching_sha2_password` connection over plain TCP is
refused unless `allowPublicKeyRetrieval: true` (#31129; 1.3.14 requested
the key automatically, `MySQLConnection.zig` in the 1.3.14 tag). SQL
`tls` / `ssl` options now require TLS instead of falling back to
plaintext, and `?ssl=` / `?ssl-mode=` are read (`shared.ts` 1.3.14 only
read `?sslmode=`; #37669).
- Install: `~/.npmrc` fallback when `XDG_CONFIG_HOME` is set (#36289),
credentials in `--registry` / env / bunfig object URLs are sent and
outrank same-host `.npmrc` tokens (#38796, #38824), `bun outdated` exits
1 on fetch failures (#38809), new `dedupe` / `up` commands shadow
scripts of those names and `bun feedback` is removed (#38333, #38444),
`workspace:` ranges inside registry packages (#37669), isolated store
entry names (#39014).
- Runtime: `module.enableCompileCache()` / `NODE_COMPILE_CACHE`
implemented (#34660), `require()` / `import` not-found messages
(#34660), `AbortError` message without the period (#39277; 1.3.14's
`BunCommonStrings.h` has the period), GCM IV length (#34092),
`mkdtemp("")` (#34908), vm options (#38381), `server.reload` (#38697),
ICU 75/73 to 78 (#38013), Compression stream chunking (#38695),
`Bun.SQL` sqlite bindings (#35950).
- Bundler: `splitting` with `cjs` / `iife` is an error (#32685),
block-scoped `enum` lowers to `let` (#34249), exports emitted ascending
instead of descending (#35957; `doStep5.zig` in 1.3.14 used `sortDesc`),
minified `$` (#35668).
- The TOML integer bullet did not say what the limit or the fix is.

### Fix
- Adds a MySQL public key section (plus a summary table row), a TLS note
under the `PGSSLMODE` section, an `.npmrc` / credentials addendum to the
`bunfig.toml` section, a `module.enableCompileCache()` section, and the
rest as bullets in the existing lists.
- `docs/pm/overrides.mdx`: one-line change adding a pointer to this
guide in the existing `lockfileVersion` 3 limitation. (The base branch
briefly had a duplicate "Nested overrides" section; it removed that
itself in `8257d01acb`, and this PR was rebased over it.)
- Verification: each runtime claim was run against
`1.4.0-canary.1+8326d1bd3` (22 commits behind main; contains every
change referenced), and each install or bundler claim was checked
against the source on main, with the 1.3 side taken from the
`bun-v1.3.14` tag where the PR body did not state it. The
`/runtime/sql#mysql` and `/upgrade-to-1.4` links resolve. `prettier
--check` passes.

### Not included on purpose
- Lifecycle scripts no longer receiving `npm_package_name` /
`npm_package_version` / `npm_package_json` / `npm_config_local_prefix`
during `bun install`, and transitive `"*"` ranges no longer
deduplicating onto the root's version: regressions with open fixes
(#36690, #38110, #38770). They need either the fixes or a guide line
before release.
- Postgres `sslmode=prefer` / `allow` (including `PGSSLMODE=prefer`,
which 1.4 newly reads) hangs until the connection timeout against a
server without SSL because nothing sends the startup message after the
`N` reply. Same code in 1.3.14; filed as a bug instead of documented.

<details>
<summary>Commands used to verify the runtime claims</summary>

```
timers/promises setTimeout with an aborted signal             # "The operation was aborted"
bun req.cjs                                                   # Cannot find module ... Require stack:
bun b.mjs (import() of a missing package / relative file)      # Cannot find package 'x' imported from /path, ERR_MODULE_NOT_FOUND
bun a_static.mjs (unhandled static import)                    # printed line still: Cannot find package 'x' from '/path'
process.versions.icu                                          # 78.3
createCipheriv("aes-128-gcm", key, Buffer.alloc(129))         # ERR_CRYPTO_INVALID_IV
DecompressionStream of a 1 MiB gzip member                    # 16 chunks of 65536 bytes
new SQL("sqlite://:memory:") with ${[1,2]} / ${new Date()}    # Binding expected ...
fs.mkdtempSync("")                                            # EINVAL
vm.runInThisContext("1", [])                                  # ERR_INVALID_ARG_TYPE
NODE_COMPILE_CACHE=/tmp/cc bun cc.cjs                         # creates /tmp/cc/v1.4.0-x86_64-<sha>-<uid>
NODE_DISABLE_COMPILE_CACHE=1 + enableCompileCache()           # status 3 (DISABLED)
bun dedupe / bun up with package.json scripts of those names  # built-in command runs
bun feedback                                                  # Script not found "feedback"
Bun.build({ splitting: true, format: "cjs" })                 # Code splitting is currently only supported ...
bun build of a function-scoped enum and import * as ns         # let Color; exports a, m, z
new SQL({ url: "postgres://...", tls: true }) on a non-TLS server  # ERR_POSTGRES_TLS_NOT_AVAILABLE
Bun.TOML.parse("a = 9007199254740993")                        # Integer cannot be losslessly represented ...
```

</details>

<details>
<summary>Previous revision</summary>

The first revision of this PR (`3c5611454a`) also rewrote the package
manager section for #38333 / #38853 (nested overrides and
`lockfileVersion: 3`, the optional-peer correction, `bun update`,
`bunfig.toml` over `.npmrc`, `--filter`) and fixed the TOML date and
`bun init` lines. #36463 picked those up in its own commits the same
day, so this PR was rebased onto its new head and reduced to the items
above.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · docs-only change; test-proof not
applicable

<!-- robobun:evidence:end -->
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.

2 participants