install: fold the open bun install / pm / pack+publish PRs into one branch (171 PRs)

Draft
JA
1mo ago
+43634-12438

Folds the open bun install / bun pm / pack+publish PRs from Aug 12–17 into one branch so they can be reviewed and landed together. Each original PR is one squashed commit here (<title> (#N)), in dependency order, so git log/git blame attribute every hunk to its PR; conflict resolutions between overlapping PRs live in the later PR's commit. Cross-PR API reconciliation is in the mega-merge fixup: commits at the end, so the per-PR commits after #38804 (e2cf8c4305) do not build on their own until 0adcafaf8b — bisect across the fold with --first-parent on the fixups, not inside it.

171 PRs folded, 3 excluded (see below); #38557 was re-ported by hand onto the newer audit_command.rs. A simplification pass then roughly halved the net src/ addition (see below). cargo check --workspace and cargo clippy --workspace are clean, the debug build links, and all 94 test/cli/install/** files pass locally on Linux (debug+ASAN); see Verification.

Not folded

PRWhy
#37925semantic conflict with #39002 (which needs optional peers bound at load); #39002 is newer and explicitly calls this out
#38777security screen: make_executable() follows an absolute bin-target symlink out of a file:/link: package and chmods the real file (CVE-2019-16775 class) when the folder dep resolves to an ancestor dir; needs rework before merging
#38810dropped on request: installing a git dependency's devDependencies before prepare is rarely needed and runs a nested install (fetching and, via prepare, executing third-party devDependencies chosen by the git repo) outside the project's bunfig protections — too much attack surface for the benefit

Security screen

Every folded diff was reviewed for regressions (path traversal, credential leakage, TLS/integrity weakening, lifecycle-script trust widening, terminal injection, new unsafe on untrusted input). One PR was dropped for it (#38777 above) and one more on review (#38810). Items a reviewer may still want to eyeball — none were judged exploitable regressions:

  • #37851 (concern, low): bunx serialises concurrent installs into the shared /bunx-- dir with an flock on /.bunx-lock; the lock file is opened by path with O_CREAT|O_RDWR and without O_NOFOLLOW, adding a small symlink-following primitive inside the pre-existing TOCTOU window of the cache-dir trust check.
  • #38741 (concern, low): New bun.lock files are stamped lockfileVersion 1 instead of 2, so the two v2-only parse-time fail-closed checks (off-registry npm tarball URL without a supported integrity hash; unsafe git .bun-tag) no longer apply to any lockfile created after this lands. The reduction is defence-in-depth only: the checks are keyed on a digit the lockfile tamperer also controls, main already stamps v1 whenever bun's own output would trip them, a tamperer can supply a matching integrity for their own tarball anyway, and the git tag is re-validated at checkout/extract time (repository.rs:923, extract_tarball.rs:484; github tags stay checked at every version).
  • #38795 (concern, low): Default-trusted check now ignores the URL fragment; the claim that this is exactly as strict as before is slightly off because bun_url lets a ? after # leak the fragment into the request path, though no attacker-chosen tarball results.
  • #38800 (concern, low): .npmrc //host/path/ credentials are now resolved by request URL (npm nerf-dart semantics): scopes without credentials pick them up by registry URL, and tarballs on a host other than the registry get that host's own .npmrc credentials. Scope credentials still never leave the scope origin, but the by-URL match is scheme-agnostic and path-unnormalized, which slightly widens where a registry-controlled dist.tarball can make bun present a token.
  • #38990 (concern, low): package-lock file: targets inside a registry/tarball/git package are now migrated as package-relative folder rows without normalising the sub-path, so a hand-edited key with .. is no longer skipped at migration time and containment relies on the installer (hoisted linker checks it, with an overrides-name exemption; isolated linker does not).
  • #39084 (concern, low): Skips openat2/fchmodat2 on Android (kernel release contains 'android' OR both ANDROID_ROOT and ANDROID_DATA env vars set); symlink-safety semantics are preserved by the fallbacks, but the env-var trigger lets any environment downgrade RESOLVE_BENEATH/RESOLVE_IN_ROOT hardening on ordinary Linux.

Product-behaviour changes that need a maintainer decision before this lands: #38741 (new bun.lock files are stamped lockfileVersion: 1 again — its own PR defers this to a maintainer, and it obsoletes the open #36464; the sibling snapshots are already re-stamped inside the fold, so pulling it back out is one revert + fixlv), #38797 (libc filtering needs the full registry document for every optional dependency on every OS, since npm's abbreviated document omits libc; measured: @esbuild/linux-x64 43 KB → 54 KB gzipped, @rollup/rollup-linux-x64-gnu 40 KB → 58 KB, one-time per package per manifest cache, in exchange for not downloading the other libc's multi-MB tarballs on Linux and a host-independent lockfile), #38804 (a migrated lockfile and its package.json edits are only written when the command saves a lockfile), #38923 (bun pm ls lists what is installed and exits 1 without node_modules), #38813 (pack/publish resolve workspace:/catalog: from package.json files, not bun.lock).

Simplification pass (after folding)

The PRs were written independently, so the folded diff carried duplicate helpers and a lot of narrative comments. A follow-up pass (the simplify(...) commits) cut the src/ diff against main from +12,081 / −6,723 (net +5,358) to +10,284 / −7,559 (net +2,725) with no behaviour change intended and the full test/cli/install/** suite green before and after. Main items:

  • One Lockfile::packages_named / package_satisfying / patched_package_satisfying replaces nine "package_index → first candidate whose resolution satisfies a range and a predicate" helpers spread over lockfile.rs, bun.lock.rs, PackageManagerEnqueue.rs, update_transitive.rs and prune.rs.
  • update_transitive.rs (#38919/#38770) no longer re-implements the resolver's rules: it calls keep_locked_if_ahead, is_named_update_row (split out of should_update) and dedupe::applied_override directly, and its Plan/Planned/InstanceEdges/KeepLocked scaffolding is gone; reachability uses lockfile::reachable.
  • One checked NUL-terminated join (resolve_path::join_abs_string_buf_z_checked) and one spill helper replace bin.rs::join_z_checked, PackageManager::join_path_z, and pack's normalize_buf_spill; the bunx install lock (#37851) is a single InstallLock RAII type.
  • Isolated linker: one append_store_path_at(buf, entry, Which) family; PackageInstall::destination_dir_subpath_buf and the DestinationSubpath guard removed in favour of join_z_spill; staging installs re-target a PackageInstall instead of threading dest_subpath through every backend; remove_link shared.
  • yarn.rs: one dependency-section table instead of four loops, git URL parsing folded into Entry::set_git_source, root/workspace package.json parsing shared; pnpm.rs: one merge-based copy_into_root, one workspaces object path; finish_migration builds the one error shape.
  • Options/registry: RegistryPath, set_scope_registry and the second credential lookup removed in favour of scope_for_package_name + UrlAuth::find; fmt::for_terminal(x) = EscapeControlChars(Redacted(x)) at every call site; MinimumReleaseAgeExcludes is one list.
  • Multi-line justification comments added by the PRs trimmed to one line or removed throughout (SAFETY lines kept).

Two small behaviour fixes fell out of the review agents' cross-checks and are their own commits: bun audit fix now rewrites a matching rule in resolutions as well as overrides (the parser reads both since #38811), and a failed root package.json write says failed to write package.json: <errno> instead of for workspace ''.

Conflict resolutions that changed a PR's code (not just context)

  • #37713 × #38832: kept #37713's rule that a deferred peer never range-matches on sight (no * exemption); #38832's AppendedFor/settled_package_count model replaces the exact_pinned guard, with #38770's direct-dependency clause folded into the reusable predicate.
  • #38768 × #38892: resolve_peer_dep_version_based_among keeps #38768's candidate filter but drops the highest-candidate fallback #38892 removed.
  • #38767/#39002 vs #37925: #37925 excluded (see above).
  • #38857 vs main's collect_bundled_deps refactor: took #38857's per-version bundled_dependencies list.
  • #38878 (yarn migrator rewrite) absorbed #38767's yarn index change; #38803/#38948/#38985/#39001 re-applied onto the rewritten migrator (#39001's id remap now runs right after the append loop).
  • #38754/#38775/#38780/#38804 in pnpm.rs: one update_package_json_after_migration(lockfile: Option<&mut Lockfile>, …); everything is reported as “copied … in package.json” (pnpm keeps reading its own block), the write is deferred to lockfile save (#38804), and bun pm migrate on a read-only package.json reports the failed write but keeps the migrated bun.lock (#38745's intent).
  • #38322 re-based onto #38800's url_auth model instead of adding a second .npmrc credential table; --registry goes through the same set_default_registry path unless the URL itself carries credentials (#38834).
  • #38631/#38977 at shared print sites resolve to EscapeControlChars(redacted(…)) (redact inside, escape outside); one EscapeControlChars implementation (the multiline-capable one from #38536) is kept.
  • #38369 on top of the refactored isolated linker: symlink_dependencies returns Result<bool, TaskError> so LinkPathTooLong still names the package.
  • #38180: fail_fn removed as in the PR; the invalid-name path from #38615 now logs like the other root-resolution errors.
  • #38045 × #38369 (isolated linker): symlink entries build their target through append_dependency_path, which now joins with the ..-normalising checked join, so a link:/file: target that only overflows once joined reports LinkPathTooLong instead of hitting the "packages have no store path" unreachable.
  • #38780 × #38783: count_blocked_scripts reads lifecycle scripts from the final node_modules location for local (non-global-store) entries.
  • #38770 × #38919: bun update no longer treats a workspace member that was removed from workspaces as a direct-dependency owner, so its former deps can move.
  • #38954 × #38504: the long-bin-value tests set up their own directory per test (the file now runs under describe.concurrent).
  • #38754 × #38804: with the migrated package.json written at flush time, a read-only package.json fails the install with failed to write package.json: EACCES after the lockfile is saved (test updated).
  • #38185: bun update moo keeps a >=0.1.0 literal that still covers the new version (main's behaviour); the test asserted ^0.2.0.
  • #38766: the pthread_create LD_PRELOAD shim cannot interpose on ASAN builds (the executable's own interceptor wins), so that file skips under ASAN like it already does on musl.
  • #38504: tests other PRs added to bun-install-registry.test.ts against the old shared packageDir now call setupTest().

Folded PRs

Peer binding, hoisting, bun.lock load/round-trip, resolver dedupe (21)

  • #38767 install: bind peer edges in the hoister, the way loading bun.lock binds them
  • #38770 install: keep transitive rows on the copy a direct dependency resolves to
  • #38869 install: load root and workspace dependencies from bun.lock the way package.json parses them
  • #38892 install: keep a peer nothing in bun.lock satisfies where the file records it
  • #38901 install: re-resolve a peer row instead of binding it to a stale bun.lock leftover
  • #39002 install: keep the packages bun.lock holds only through optional peers when an install re-resolves
  • #39343 install: bind unbound required peers while hoisting, like optional peers
  • #39347 install: bind a multi-path package's edges from the bun.lock row that prints the binding
  • #38849 install: only re-resolve rows a package still owns when overrides or catalogs change
  • #37713 install: resolve deferred * peers after their siblings, not on arrival order
  • #38768 install: rebind ranged peers whose target the saved tree drops
  • #38832 install: resolve a range onto an existing version only when every install has it
  • #38848 install: keep a bundle's dependency out of a slot the bundling package resolves through
  • #38858 test(install): pin both no-deps versions instead of racing a range against a pin
  • #38837 install: keep bundled peer edges on their own bun.lock entry when loading
  • #38857 install: keep bundleDependencies from every dependency group when parsing a registry manifest
  • #38919 Hold a transitive update that would re-fork a deduped package
  • #38976 install: stop hoisting a dependency cycle forever
  • #39053 install: warn when the hoisted tree serves a peer from a root copy outside its range
  • #38741 install: stamp lockfileVersion 1 on new bun.lock files
  • #39190 test(install): establish the warm manifest cache before the unmet peer test re-resolves

bun update / bun add package.json edits, overrides (7)

  • #38763 install: print the update row for catalog entries moved by bun update
  • #38847 install: keep workspace and other non-registry entries as written on bun update
  • #38918 Report package.json sync in the bun update summary instead of (no changes)
  • #38331 install: ignore leading whitespace in dist-tag, bare folder and local tarball versions
  • #38811 install: apply both "overrides" and "resolutions" when package.json has both
  • #38866 install: keep the workspace: spelling given to bun add @workspace:
  • #38309 install: ignore leading whitespace wherever a dependency literal is classified or re-parsed

Lockfile migration (yarn / pnpm / npm) (17)

  • #38698 Fix package-lock.json migration for scope registries configured without a trailing slash
  • #38773 install: record package.json trustedDependencies when migrating a lockfile
  • #38775 install: keep the pnpm block in package.json when migrating from pnpm
  • #38795 install: fix default-trusted lifecycle scripts being blocked after yarn.lock migration
  • #38829 install: migrate the whole pnpm-workspace.yaml catalog into bun.lock
  • #38990 install: migrate a package's own file: directories from package-lock.json relative to the package
  • #38780 install: migrate pnpm onlyBuiltDependencies and report blocked scripts under the isolated linker
  • #38754 install: import pnpm-workspace.yaml even without a migratable pnpm-lock.yaml
  • #38781 install: skip workspace members whose package.json has no name
  • #38791 install: migrate pnpm importer rows in the shape package.json parsing produces
  • #38878 install: migrate workspace packages from yarn.lock
  • #38803 install: fix yarn.lock migration panic on tarball URLs with "/-/" right after the host
  • #38948 install: name yarn.lock npm: alias packages after the alias spec, not the tarball URL
  • #38985 install: migrate yarn.lock registry entries that have no resolved field
  • #39001 install: skip yarn.lock entries the migration cannot build a resolution for
  • #38861 install: migrate npm-shrinkwrap.json like package-lock.json
  • #38804 install: write a migrated lockfile and its package.json edits only when the command saves a lockfile

file: / link: / tarball / git local dependencies (16)

  • #38045 install: fail the isolated install when a link: dependency's target is missing
  • #38814 install: read the package.json of a file: dependency declared by a local file: package
  • #38816 install: keep file: folder paths declared by git and tarball packages relative to the package
  • #39011 install: drop the query string and fragment from a tarball URL's extraction folder name
  • #39016 install: cache file: tarballs under the integrity the lockfile pins
  • #38633 install: reject tarball, folder and git packages whose package.json name is invalid
  • #38798 install: read git+git:// resolutions back from bun.lock
  • #38850 install: resolve file: dependencies loaded from the lockfile relative to the top-level dir
  • #38856 install: link file: dependencies of registry packages from inside the package with the isolated linker
  • #38867 install: bound isolated store entry names; tarball URL credentials; file: tarballs relative to their folder package
  • #38986 install: refuse local tarball dependencies declared by packages installed from the cache
  • #38994 install(hoisted): link a file: override applied to a dependency of an installed package
  • #39023 install: refuse link: paths with .. or an absolute path declared by packages installed from the cache
  • #38681 install: name folder, tarball and git packages without a package.json name after their source
  • #38114 install: resolve file: and link: dependencies on the same directory as separate packages
  • #38702 install: stop leaking the displaced cache folder when a tarball is re-extracted

Path-buffer overflows: panic → error (15)

  • #38359 install: stop aborting on link: specifiers and patch paths longer than the path buffers
  • #38368 cli: stop aborting on --cwd and --tsconfig-override values longer than the path join buffer
  • #38390 install: stop panicking when the cache directory setting does not fit the path buffer
  • #38558 install: stop panicking when a progress bar name is longer than its buffer
  • #38571 install: stop aborting on linked package names that do not fit the symlink buffers
  • #38575 install: fail verification instead of panicking when /package.json does not fit the path buffer
  • #38616 pm ls --all: stop panicking on resolutions longer than 512 bytes
  • #38784 pack/publish: stop panicking on package.json bin and files entries longer than the path buffer
  • #38954 install: stop panicking on bin values longer than the path buffer
  • #38375 install: report ENAMETOOLONG for a --cwd value that does not fit the path buffer
  • #38369 install: fail the isolated install with ENAMETOOLONG when a link: target does not fit the path buffer
  • #38372 install: do not abort when $XDG_CONFIG_HOME or $HOME is too long for the .npmrc path buffer
  • #38413 install: stop aborting on -g when $BUN_INSTALL, $XDG_CACHE_HOME or $HOME does not fit the global directory path buffer
  • #38749 pack: report an error instead of panicking when --destination does not fit the path buffer
  • #38753 publish: report ENAMETOOLONG for a tarball path that does not fit the path buffer

Terminal control-character escaping and credential redaction (9)

  • #38536 pm view: escape control characters coming from the registry
  • #38817 install: redact secrets in the URLs printed for failed manifest and tarball downloads
  • #38997 install: redact the registry URL in the manifest/tarball URL errors and keep namedRegistries credentials out of bun.lock
  • #38525 install: escape control characters in the bun pm untrusted/trust script listing
  • #38615 install: reject dependency names containing control characters
  • #38673 http: escape control characters in the verbose request/response trace
  • #39358 pm licenses: escape control characters in text output, including the name@version column
  • #38631 install: escape control characters in resolutions, specifiers, bin names and registry error text
  • #38977 install: redact credentials in tarball and git URLs printed as resolutions and specifiers

Registry auth, .npmrc, proxies, URL parsing (16)

  • #38001 install: keep registry credentials across redirects to the same hostname
  • #38047 install: read NPM_CONFIG_USERCONFIG as the user-level .npmrc path
  • #38075 publish, pm whoami: route registry requests through http_proxy
  • #38310 install: make BUN_CONFIG_NO_VERIFY disable integrity verification when set
  • #38744 install: honor BUN_CONFIG_MAX_HTTP_REQUESTS
  • #38812 url: emit a single slash in href_without_auth() for root-path registry URLs
  • #39000 url: parse the host of a protocol-relative URL instead of leaving the authority in the path
  • #38759 install: say --network-concurrency defaults to 64 in help and docs
  • #38766 http: report a refused HTTP client thread as an error instead of a crash
  • #38834 install: send credentials embedded in a registry URL that comes from an env var
  • #39063 install: send Basic auth for a registry URL with a username but no password
  • #38916 install: replace an existing manifest cache entry atomically instead of unlinking it first
  • #38484 install: warn when a registry manifest advertises an integrity hash bun cannot use
  • #38093 publish, pm whoami, audit: honor NODE_TLS_REJECT_UNAUTHORIZED=0 like bun install
  • #38043 Normalize http_proxy/https_proxy env values through the WHATWG parser
  • #38800 install: resolve .npmrc //host/ credentials by request URL

bun pm subcommands, audit, help text (14)

  • #37922 bun why: print a package's dependents once, mark repeats as *deduped
  • #38025 pm pkg: support arrays in set and delete
  • #38649 install: format versions with the string buffer they were parsed from (bun outdated table, minimum-release-age messages)
  • #38836 audit: list --production, --omit, --dry-run and --cwd in bun audit --help
  • #38893 install: say --ignore-scripts also skips trusted dependencies' scripts in --help
  • #38952 pm: polish dedupe, prune, pm ls and pm licenses output
  • #39216 prune: add --check, a CI gate that exits 1 when anything would be removed
  • #39317 audit fix: never downgrade a package out of its installed release line
  • #38671 pm view: match the requested range against the buffer it was parsed from
  • #38923 Make bun pm ls list installed packages instead of lockfile entries
  • #39165 cli: load a package.json and name a lockfile load step from one place
  • #38915 install: describe the real --save default in bun link and bun unlink --help
  • #38785 audit fix: report and rewrite the overrides rule that holds a vulnerable version
  • #39217 Add --why to bun dedupe to show one level of dependents under each row

bun pm pack / bun publish (13)

  • #38071 run: set npm_package_name/version/json and npm_config_local_prefix per run
  • #38704 publish: resolve a relative tarball path against the invoking directory
  • #38716 pack, publish: include entries on filesystems whose readdir reports DT_UNKNOWN
  • #38721 pack: build the tarball in memory and write it out only once it is complete
  • #38776 publish: accept basic auth credentials from .npmrc and bunfig.toml
  • #38322 publish: honor publishConfig.registry and publishConfig["@scope:registry"]
  • #38707 pack: skip bins reached through symlinks; publish: do not read the readme through a symlink
  • #38715 pack: store tarball entry names byte for byte
  • #38720 pack: pack a file listed under several bin names once, strip the trailing slash from directories.bin
  • #38739 pm pack: resolve relative --destination and --filename against the invoking directory
  • #38813 pack/publish: resolve workspace: and catalog: specs from the package.json files, not bun.lock
  • #38835 pack: publish workspace: directory dependencies as the workspace's version
  • #38788 pack: publish workspace: aliases as npm: aliases

Linkers, bins, node_modules layout (9)

  • #38303 install(hoisted): track replaced folders instead of deferring and re-verifying nested skips
  • #38756 Resolve dangling node_modules symlinks as missing modules; isolated installs unlink failed optional dependencies
  • #38783 install: link packages into a staging directory and rename them into place
  • #39096 bunx: resolve directories.bin relative to the package
  • #38511 install: remove the node_modules links of a renamed or removed workspace
  • #38779 install: reinstall node_modules when switching between the isolated and hoisted linkers
  • #38972 install: keep linking the remaining bins when one bin or one dependency's bins fail to link
  • #37757 install: link over an empty directory left at node_modules/ with the isolated linker
  • #38018 install(windows): store an absolute target in .bunx shims when the package dir is on another drive

Lifecycle scripts, env, minimum-release-age, semver, auto-install (16)

  • #38648 install: drop the duplicated warn: label from the verbose tarball retry notice
  • #38098 install: fix node-gyp shim PATH entry after tempdir falls back to cache/.tmp
  • #38110 install: set npm_config_local_prefix and npm_package_config_* for lifecycle scripts
  • #38830 install: skip root lifecycle scripts and the yarn.lock write under --dry-run
  • #38859 install: remove the same-filesystem probe from $TMPDIR when the rename into the cache fails
  • #37895 install: print version before package name in the minimum-release-age error
  • #37909 install: support name@version entries in minimumReleaseAgeExcludes
  • #38659 install: do not resolve an exact version from an abbreviated manifest under minimum-release-age
  • #38688 semver: print the build tag from the printed version's buffer in the colored version diff
  • #38198 resolver: make auto-install use the version range from the project's package.json
  • #38182 run, install: honor --no-env-file / --env-file for scripts run through the bun exec hop
  • #38995 install: warn instead of failing when minimum-release-age blocks a peer dependency
  • #38687 semver: record cloned prerelease/build tag offsets against the whole destination buffer
  • #38180 auto-install: report why a root dependency could not be resolved
  • #38745 install: only open package.json for writing in the commands that rewrite it
  • #38797 install: filter optionalDependencies by libc and record it in the lockfile

Test-only changes (13)

  • #37919 test(install): serve URL tarball dependencies locally in bun-install.test.ts
  • #38185 test(install): keep bun-update-security-provider on the dummy registry, assert the update, run concurrently
  • #38255 test(install): give each long-label bun patch test its own install cache
  • #38467 test(bunx): install fixture packages from a local registry instead of real ones
  • #38580 test(install): don't require the refetched manifest to be cached before install exits
  • #38627 test(install): normalize the registry URL, not the bare port, in the lockb print snapshot
  • #38891 test(harness): let verdaccio pick its own port and fail start() when it exits early
  • #39022 test(install): run overrides.test.ts against the local registry
  • #39115 test(install): serve GitHub tarball fixtures locally in the GHSA-pfwx-36v6-832x and lifecycle script tests
  • #39232 test: add a harness isRoot and skip the chmod-based ls and Bun.file cases as root
  • #38464 test(install): speed up bun-install.test.ts and tighten its assertions
  • #38504 test(install): run bun-install-registry.test.ts concurrently and tighten its assertions
  • #39243 test/docker: wait for the docker daemon in the coordinator instead of failing on a one-shot probe

Platform-specific (5)

  • #37865 install: schedule the Windows hardlink task through the allocation's own pointer
  • #38567 install.ps1: make the "another bun.exe is already in PATH" check work
  • #39084 install: don't issue openat2/fchmodat2 on Android (seccomp SIGSYS)
  • #37851 bunx: serialize concurrent installs into the shared cache dir with a file lock
  • #39361 Fix bun install through subst drives, cross-drive junctions, and symlinked package.json

Verification

  • cargo check --workspace
  • cargo clippy --workspace --no-deps
  • debug build (Linux x64, ASAN)
  • test/cli/install/**, test/cli/run/**, test/regression/issue/*install* locally with the debug build. Remaining local-only failures are environmental and reproduce on main in this sandbox: bun-pm.test.ts read-only package.json cases (run as root; nobody cannot exec a binary under /root), thread-spawn-failure (LD_PRELOAD shim vs ASAN).
  • CI (first run on the pre-simplification head: 3 failing files, all addressed above — thread-spawn shim under ASAN, pnpm read-only package.json wording, update-security-provider range)