install: leave URL credentials out of isolated store entry names - #39014
Conversation
With the isolated linker, a package installed from a tarball or git URL gets a node_modules/.bun entry named after the whole URL, so a password in the userinfo or a token in the query string became part of a directory name (and of every realpath, stack trace and `bun pm` line derived from it). Tarball and repository URLs are now written into the store name without their userinfo and query string. When either part was present, the wyhash of the complete URL is appended in their place, so URLs that differ only in those parts still get separate entries. URLs without either part keep the names they had.
|
Warning Review limit reached
Next review available in: 28 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status: reproduced on 1.4.0-canary.1 with a local tarball server ( The one design call to look at is the third and fourth bullets under Fix: URLs with a userinfo or a query string are renamed once (this includes |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the on-disk store naming scheme (renaming every existing entry whose URL carries userinfo, including the very common git+ssh://git@… form) and hand-parses URL authority/query boundaries for a credential-redaction purpose, a maintainer look at those design choices would still be worthwhile.
What was reviewed:
fmt_store_urlparsing: authority-start via first:+//, authority-end via first/?#, userinfo via last@in authority, query via first?after host — matches RFC 3986 andbun_core::find_url_password; scp-likeuser@host:pathhandled byauthority_start = 0.- Uniqueness preserved: hash of the full URL is appended whenever anything is stripped, so
?v=1vs?v=2still separate; plain URLs keep their existing names byte-for-byte (no hash appended). - Consumers:
bun pm prune'ssplit_store_keysplits at the first non-leading@and the new names contain none in the version part;bun pm licensesand the global store re-derive names through the same formatter. bun_semver::StorePathFormatterfield change (str+buf→bytes) — the only external constructor wasString::fmt_store_path, updated in the same commit;install/lib.rshas its own unrelatedStorePathFormatter.
Extended reasoning...
Overview
The PR stops the isolated linker from writing URL-embedded credentials into directory names under node_modules/.bun. It adds fmt_store_url in src/install/resolution.rs (used for remote-tarball resolutions and, via src/install/repository.rs, for the repo part of git resolutions), which emits the URL with its userinfo and query string removed and, when either was present, appends a 16-hex wyhash of the complete URL. src/semver/lib.rs refactors StorePathFormatter to accept a raw byte slice via a free fmt_store_path so the two kept URL pieces are spelled with the exact same character mapping as before. ~220 lines of new tests in test/cli/install/isolated-install.test.ts cover tarball and git dependencies with plain URLs (name unchanged), passwords, query-string tokens, both together, query-string-only disambiguation, the global store, and lockfile round-tripping.
Security risks
The change is redactive: it removes secrets from disk paths rather than introducing any new trust boundary. The URL is still stored in full in the lockfile (unchanged, tested), so no functional auth is affected. The hand-rolled URL parsing is only used to derive a directory name — if it mis-splits, the worst case is a longer or shorter readable prefix, and the appended full-URL hash still keeps distinct resolutions in distinct directories. I did not find a way for two previously-distinct resolutions to collide onto the same store entry.
Level of scrutiny
Medium-high. The Rust change itself is small and self-contained (one new formatter, two call-site swaps, one struct-field refactor with no external constructors), but it is a deliberate change to the isolated store's on-disk naming, which has downstream consumers (bun pm prune, bun pm licenses, the global store, #38867's length bounding) and a one-time migration effect. In particular, every git+ssh://git@host/… dependency — a very common form where git@ is not secret — will be renamed with a hash suffix on the next install. The PR calls this out and argues it cannot be distinguished from a token-as-username, which is reasonable, but it is exactly the kind of tradeoff a maintainer should sign off on.
Other factors
Test coverage is thorough: exact expected names computed with Bun.hash, symlink targets, lockfile contents, runtime import, and second-install stability are all asserted; the two "plain URL" cases prove existing names are byte-identical. The PR description states the rest of isolated-install.test.ts, bun-pm-licenses.test.ts, and bun-prune.test.ts still pass. No prior review comments to address. Given the on-disk layout change and the credential-handling context, deferring to a human rather than auto-approving.
|
@coderabbitai review |
|
Problem
--linker isolated, a dependency declared as a tarball URL with credentials, e.g."direct": "http://carol:s3cret@127.0.0.1:PORT/cdn/direct-1.0.0.tgz?token=npm_a1b2...", is installed into a store directory literally namednode_modules/.bun/no-deps@http+++carol+s3cret@127.0.0.1+PORT+cdn+direct-1.0.0.tgz+token=npm_a1b2...(reproduced on 1.4.0-canary.1 and current main). A git dependency such asgit+https://carol:s3cret@host/org/repo.gitgetsrepo@git+https+++carol+s3cret@host+org+repo.git+<commit>.import.meta.url,bun pm licensespaths, the--verboselink failure messages, andls node_modules/.bun. The output redaction in install: redact credentials in tarball and git URLs printed as resolutions and specifiers #38977 cannot cover this: these paths really exist on disk.<name>@<resolution store path>(src/install/isolated_install/Store.rsStoreKeyFormatter). For a remote tarball the store path was the whole URL with/ \ : # ?turned into+(src/install/resolution.rsStorePathFormatter, viabun_semver'sString::fmt_store_pathinsrc/semver/lib.rs), and for git it was the whole repository URL plus the commit (src/install/repository.rsStorePathFormatter). Nothing removed the userinfo or the query string.Fix
src/install/resolution.rs: newfmt_store_url, used for the remote tarball store path and, insrc/install/repository.rs, for the repository part of the git store path. It writes the URL without its userinfo and without its query string, and when either of them was present it appends+<16 hex wyhash of the complete URL>. The git commit suffix is unchanged and still follows. Examples:http://carol:s3cret@h/p.tgz?token=xbecomesno-deps@http+++h+p.tgz+<url hash>; the same URL without credentials staysno-deps@http+++h+p.tgz; a credentialed git URL becomesrepo@git+https+++host+org+repo.git+<url hash>+<commit>.src/semver/lib.rs:String::fmt_store_pathnow delegates to a byte-slicefmt_store_path, so the two kept pieces of the URL are spelled exactly the way the whole URL was before (one character mapping, no copy of it).user:password@, a bare token as the username,?token=/ signed URLs); host, path and scheme are not secret, so they stay readable. The userinfo is delimited with RFC 3986's rule, the same onefind_url_passwordinbun_coreuses: the authority runs fromscheme://(or from the start of an scp-likeuser@host:path) to the first/,?or#, and the userinfo is everything in it up to the last@. The whole userinfo goes, not only the password, becausehttps://TOKEN@host/...is a documented way of passing tokens.bun_url::URL::parsewas not used for this because it does not recognize the userinfo ofuser@host:portat all.pkg.tgz?v=1andpkg.tgz?v=2are different packages and must not share a directory, and even for git, where the commit usually disambiguates,resolvedcan be empty for packages migrated from pnpm/yarn lockfiles. Without either part there is nothing to disambiguate, so no hash is appended and every existing entry for a plain tarball,git+file://,github:or registry package keeps its name byte for byte.git+ssh://git@host/...form, whosegit@is not a secret but cannot be told apart from a token); the nextbun installlinks them under the new name andbun pm pruneremoves the old directories, since it removes every store entry the lockfile does not produce. The lockfile itself is untouched.bun pm prunesplits at@(src/install/prune.rssplit_store_key; the names now also contain no second@),bun pm licensesre-derives the name through the same formatter (src/runtime/cli/pm_licenses_command.rs), and the global store derives itslinks/<name>-<entry hash>directory from the same name, so it is fixed as well. install: bound isolated store entry names; tarball URL credentials; file: tarballs relative to their folder package #38867 (bounding long names) would apply on top of this name.test/cli/install/isolated-install.test.ts, describestore entry names of URL dependencies:Bun.hash, thenode_moduleslink points into it,bun.lockstill contains the full URL, the package imports at runtime, and a second install keeps the name?v=get two entries and each alias resolves to its own versioninstall.globalStore, thelinks/directory name is built from the credential-free namehttp://token@host:port/x.tgz) is exercised through the git cases only: the tarball downloader currently sends that URL with the userinfo still in the Host header and gets a 400, which is tracked separately (as is the fact that the downloader never sends URL credentials at all); neither affects this change+x=yname and now asserts the hashed one; its point (no literal?in the name, package resolves at runtime) is unchangedbun bd test. Also run with the change: the rest ofisolated-install.test.ts(75 pass),bun-pm-licenses.test.ts(79 pass, it asserts the unchanged name of a plain tarball entry),bun-prune.test.ts(109 pass),bun-install-git-deps.test.ts(7 pass),cargo clippyandcargo fmt --checkonbun_installandbun_semver, andtest/internal/source-lints.Background
node_modules/.bun/<entry>/node_modules/<name>and everything that depends on it gets a symlink to that directory.<entry>is<package name>@<store path of the resolution>, optionally followed by+<peer hash>; withinstall.globalStorethe entry is itself a symlink into<cache>/links/<entry>-<entry hash>.name@1.2.3; for tarball and git dependencies it is the URL as written in package.json (git: plus the resolved commit), which is what became the directory name here./,\,:,#and?with+(bun_semver'sStorePathFormatter);http://h/p.tgzreadshttp+++h+p.tgz.user:password@part of a URL's authority (scheme://userinfo@host:port/path?query#fragment).bun_wyhash::hash, whatBun.hash()computes), which is how the tests compute the expected names; the store already uses it for the peer hash suffix.Reproduction on the released build
package.jsonwith{"dependencies": {"direct": "http://carol:s3cret@127.0.0.1:PORT/cdn/direct-1.0.0.tgz?token=npm_a1b2c3d4e5f6"}}, aBun.serveanswering that path with a tarball,bunfig.tomlwithinstall.linker = "isolated":With this change the entry is
no-deps@http+++127.0.0.1+35767+cdn+direct-1.0.0.tgz+<16 hex>. The cache folder for the same tarball was already credential-free (@T@<hash>).