From d45fe32011eb27e4324f3bcfcdb33530d39b4380 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:03:44 +0000 Subject: [PATCH 001/258] install: bind peer edges in the hoister, the way loading bun.lock binds them (#38767) --- src/install/lockfile.rs | 10 + src/install/lockfile/Tree.rs | 24 +- src/install/lockfile/bun.lock.rs | 3 + src/install/yarn.rs | 223 +----------------- test/cli/install/hoist.test.ts | 68 +++++- test/cli/install/isolated-install.test.ts | 53 +++++ test/cli/install/migration/migrate.test.ts | 55 +++++ .../migration/yarn-lock-migration.test.ts | 72 ++++++ 8 files changed, 288 insertions(+), 220 deletions(-) diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index da5691de7ee9..755ec191a69a 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -1734,6 +1734,16 @@ impl Lockfile { debug_assert!( SemverStringBuilder::string_hash(self.str(&package.name)) == package.name_hash ); + // The hoister binds peers by scanning `package_index` under the package name. + debug_assert!( + match self.package_index.get(&package.name_hash) { + Some(PackageIndexEntry::Id(id)) => *id as usize == i, + Some(PackageIndexEntry::Ids(ids)) => ids.iter().any(|&id| id as usize == i), + None => false, + }, + "package {} is not in package_index under its own name", + i + ); debug_assert!( package .dependencies diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 7143e9260626..b34e2f19b0b7 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -7,7 +7,7 @@ use bun_core::ZStr; use bun_paths::{MAX_PATH_BYTES, PathBuffer, SEP}; use crate::lockfile::package::PackageColumns as _; -use crate::lockfile::{DepSorter, DependencyIDList, DependencyIDSlice, Lockfile}; +use crate::lockfile::{DepSorter, DependencyIDList, DependencyIDSlice, Lockfile, bun_lock}; use crate::package_manager::{PackageManager, WorkspaceFilter}; use crate::{ Dependency, DependencyID, PackageID, PackageNameHash, Resolution, invalid_dependency_id, @@ -493,6 +493,27 @@ impl<'a, const METHOD: BuilderMethod> Builder<'a, METHOD> { self.lockfile().buffers.string_bytes.as_slice() } + /// Binds a peer edge the way loading `bun.lock` does, whatever the resolver, a previous + /// lockfile or a migration left it bound to, so every tree built from these packages agrees. + fn bind_peer(&mut self, dep_id: DependencyID) { + let dependency: &Dependency = &self.dependencies[dep_id as usize]; + if !dependency.behavior.is_peer() { + return; + } + let lockfile_ref = self.lockfile; + let lockfile: &Lockfile = lockfile_ref.get(); + if let Some(pkg_id) = bun_lock::resolve_peer_dep_version_based( + dependency, + &lockfile.catalogs, + &lockfile.package_index, + &lockfile.overrides, + lockfile.packages.items_resolution(), + lockfile.buffers.string_bytes.as_slice(), + ) { + self.resolutions[dep_id as usize] = pkg_id; + } + } + /// Flatten the multi-dimensional ArrayList of package IDs into a single easily serializable array pub(crate) fn clean(&mut self) -> Result { let mut total: u32 = 0; @@ -698,6 +719,7 @@ impl Tree { let sort_buf_len = builder.sort_buf.len(); 'dep: for sort_idx in 0..sort_buf_len { let dep_id = builder.sort_buf[sort_idx]; + builder.bind_peer(dep_id); let pkg_id = builder.resolutions[dep_id as usize]; // filter out disabled dependencies diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 1f25d8cc6a06..148c058002c3 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -3353,6 +3353,9 @@ fn deferred_peer_range<'a>( /// re-keys isolated-linker store entries (and global-store entry hashes) /// on warm installs. /// +/// The hoister applies the same binding to every peer edge it processes +/// (`tree::Builder::bind_peer`), so a saved tree is the tree its reload rebuilds. +/// /// Peers whose name matches a workspace package need no special casing /// even though the fresh resolver binds them to the workspace before any /// deferral (`'resolve_from_workspace`): the version scan below picks an diff --git a/src/install/yarn.rs b/src/install/yarn.rs index cbcc3613b691..5dfb95a04f08 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use std::io::Write as _; use crate::Error; -use bun_collections::{HashMap, StringHashMap}; +use bun_collections::StringHashMap; use bun_install::bin::Bin; use bun_install::dependency::{self, Dependency, DependencyExt as _}; use bun_install::install::{self, DependencyID, PackageID, PackageManager}; @@ -26,7 +26,7 @@ use bun_install::npm; // `bun_install::resolution` stub keeps `Value` as a struct-of-fields and has no `init`. use crate::bun_json; use crate::repository::Repository; -use crate::resolution_real::{Resolution, Tag as ResolutionTag, TaggedValue as ResolutionValue}; +use crate::resolution_real::{Resolution, TaggedValue as ResolutionValue}; use crate::versioned_url::VersionedURL; use bun_core::strings; use bun_paths::PathBuffer; @@ -1380,222 +1380,9 @@ pub(crate) fn migrate_yarn_lockfile<'a>( } } - for (base_name, versions) in scoped_packages.iter_mut() { - let base_name: &[u8] = base_name.as_ref(); - - versions.sort_by_key(|a| a.package_id); - - let original_name_hash = string_hash(base_name); - // `remove` drops the value (and thus the `Ids` Vec) automatically. - let _ = this.package_index.remove(&original_name_hash); - } - - for (base_name, versions) in scoped_packages.iter() { - let base_name: &[u8] = base_name.as_ref(); - - for version_info in versions.iter() { - let package_id = version_info.package_id; - - let mut found_in_index = false; - for (_, index_value) in this.package_index.iter() { - match index_value { - lockfile::PackageIndexEntry::Id(id) => { - if *id == package_id { - found_in_index = true; - break; - } - } - lockfile::PackageIndexEntry::Ids(ids) => { - for id in ids.iter() { - if *id == package_id { - found_in_index = true; - break; - } - } - if found_in_index { - break; - } - } - } - } - - if !found_in_index { - let mut fallback_name = Vec::new(); - write!( - &mut fallback_name, - "{}#{}", - bstr::BStr::new(base_name), - package_id - ) - .expect("unreachable"); - - let fallback_hash = string_hash(&fallback_name); - this.get_or_put_id(package_id, fallback_hash)?; - } - } - } - - let mut package_names: Vec<&[u8]> = vec![b"".as_slice(); next_package_id as usize]; - - for (yarn_idx, entry) in yarn_lock.entries.iter().enumerate() { - let package_id = yarn_entry_to_package_id[yarn_idx]; - if package_names[package_id as usize].is_empty() { - package_names[package_id as usize] = Entry::get_name_from_spec(entry.specs[0]); - } - } - - let mut root_packages: StringHashMap = StringHashMap::new(); - - let mut usage_count: StringHashMap = StringHashMap::new(); - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - for dep_entry in yarn_lock.entries.iter() { - if let Some(deps) = &dep_entry.dependencies { - for (dep_name_key, _) in deps.iter() { - if dep_name_key.as_ref() == base_name { - let count = usage_count.get(base_name).copied().unwrap_or(0); - usage_count.put(base_name, count + 1)?; - } - } - } - } - } - - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - if root_packages.get(base_name).is_none() { - root_packages.put(base_name, package_id)?; - let name_hash = string_hash(base_name); - this.get_or_put_id(package_id, name_hash)?; - } - } - - let mut scoped_names: HashMap> = HashMap::new(); - let mut scoped_count: u32 = 0; - for entry_idx in 0..yarn_lock.entries.len() { - let package_id = yarn_entry_to_package_id[entry_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - let base_name = package_names[package_id as usize]; - - if let Some(root_pkg_id) = root_packages.get(base_name).copied() { - if root_pkg_id == package_id { - continue; - } - } else { - continue; - } - - let mut scoped_name: Option> = None; - for (dep_entry_idx, dep_entry) in yarn_lock.entries.iter().enumerate() { - let dep_package_id = yarn_entry_to_package_id[dep_entry_idx]; - if dep_package_id == install::INVALID_PACKAGE_ID { - continue; - } - - if let Some(deps) = &dep_entry.dependencies { - for (dep_name_key, _) in deps.iter() { - if dep_name_key.as_ref() == base_name { - if dep_package_id != package_id { - let parent_name = package_names[dep_package_id as usize]; - - let mut potential_name = Vec::new(); - write!( - &mut potential_name, - "{}/{}", - bstr::BStr::new(parent_name), - bstr::BStr::new(base_name) - ) - .expect("unreachable"); - - let mut name_already_used = false; - for existing_name in scoped_names.values() { - if existing_name.as_slice() == potential_name.as_slice() { - name_already_used = true; - break; - } - } - - if !name_already_used { - scoped_name = Some(potential_name); - break; - } - // else: potential_name dropped - } - } - } - if scoped_name.is_some() { - break; - } - } - } - - if scoped_name.is_none() { - let pkg_resolution = this.packages.get(package_id as usize).resolution; - let version_str: Vec = match pkg_resolution.tag { - ResolutionTag::Npm => 'brk: { - let mut version_buf = [0u8; 64]; - let mut cursor = &mut version_buf[..]; - let npm_version = pkg_resolution.npm().version; - let _ = write!( - &mut cursor, - "{}", - npm_version.fmt(this.buffers.string_bytes.as_slice()) - ); - let written = 64 - cursor.len(); - break 'brk version_buf[..written].to_vec(); - } - _ => b"unknown".to_vec(), - }; - let mut name = Vec::new(); - write!( - &mut name, - "{}@{}", - bstr::BStr::new(base_name), - bstr::BStr::new(&version_str) - ) - .expect("unreachable"); - scoped_name = Some(name); - } - - if let Some(final_scoped_name) = scoped_name { - let name_hash = string_hash(&final_scoped_name); - this.get_or_put_id(package_id, name_hash)?; - scoped_names.put(package_id, final_scoped_name)?; - scoped_count += 1; - } - } - let _ = scoped_count; - - for (yarn_idx, entry) in yarn_lock.entries.iter().enumerate() { - let package_id = yarn_entry_to_package_id[yarn_idx]; - if package_id == install::INVALID_PACKAGE_ID { - continue; - } - - if let Some(resolved) = entry.resolved.as_deref() { - if let Some(real_name) = Entry::get_package_name_from_resolved_url(resolved) { - for spec in entry.specs.iter() { - let alias_name = Entry::get_name_from_spec(spec); - - if alias_name != real_name { - let alias_hash = string_hash(alias_name); - this.get_or_put_id(package_id, alias_hash)?; - } - } - } - } + for id in 0..this.packages.len() { + let name_hash = this.packages.items_name_hash()[id]; + this.get_or_put_id(id as PackageID, name_hash)?; } this.buffers.trees[0].dependencies = lockfile::DependencyIDSlice::new(0, 0); diff --git a/test/cli/install/hoist.test.ts b/test/cli/install/hoist.test.ts index 12919c36b139..471acd7dde14 100644 --- a/test/cli/install/hoist.test.ts +++ b/test/cli/install/hoist.test.ts @@ -1,5 +1,8 @@ -import { afterAll, beforeAll, test } from "bun:test"; +import { file, write } from "bun"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { exists, rm } from "fs/promises"; import { VerdaccioRegistry, bunEnv, runBunInstall } from "harness"; +import { join } from "path"; const registry = new VerdaccioRegistry(); @@ -28,3 +31,66 @@ test("should handle resolving optional peer from multiple instances of same pack // this shouldn't hit an assertion await runBunInstall(bunEnv, packageDir); }); + +test("tree written after a ranged peer gains a higher candidate is the tree the next install lays out", async () => { + // `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. As a devDependency it is + // hoisted before the root's `dependencies`, so whatever its peer edge is bound + // to is the `no-deps` that lands at the root of node_modules. Loading bun.lock + // binds such an edge to the highest satisfying version in the lockfile, so + // the install that adds `one-dep` (no-deps@1.0.1, next to one-fixed-dep's + // 1.0.0) has to bind it the same way before hoisting. Otherwise it writes a + // lockfile keyed with 1.0.0 at the root and the very next `bun install` + // relinks node_modules with 1.0.1 at the root, without touching bun.lock. + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); + const noDepsVersion = async (...segments: string[]) => { + const pkg = join(packageDir, "node_modules", ...segments, "no-deps", "package.json"); + return (await exists(pkg)) ? ((await file(pkg).json()) as { version: string }).version : null; + }; + const layout = async () => ({ + root: await noDepsVersion(), + "one-dep": await noDepsVersion("one-dep", "node_modules"), + "one-fixed-dep": await noDepsVersion("one-fixed-dep", "node_modules"), + }); + + await write( + packageJson, + JSON.stringify({ + name: "ranged-peer-roundtrip", + dependencies: { "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + expect(await layout()).toEqual({ root: "1.0.0", "one-dep": null, "one-fixed-dep": null }); + + await write( + packageJson, + JSON.stringify({ + name: "ranged-peer-roundtrip", + dependencies: { "one-dep": "1.0.0", "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const written = await layout(); + const lockfile = await file(join(packageDir, "bun.lock")).text(); + + // the tree on disk is the tree the lockfile describes, so reinstalling from it is a no-op + const { out, err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + expect(out).toContain("(no changes)"); + expect(err).not.toContain("Saved lockfile"); + expect(await layout()).toEqual(written); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); + + // the peer is bound to the highest satisfying version, and peer-deps-fixed hoists it first + expect(written).toEqual({ root: "1.0.1", "one-dep": null, "one-fixed-dep": "1.0.0" }); + expect(lockfile).toContain('"no-deps": ["no-deps@1.0.1"'); + expect(lockfile).toContain('"one-fixed-dep/no-deps": ["no-deps@1.0.0"'); + + // a fresh resolve of the same package.json binds the peer the same way + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await rm(join(packageDir, "bun.lock")); + await runBunInstall(bunEnv, packageDir); + expect(await layout()).toEqual(written); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); +}); diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 59ddce9484b1..1e9a6aa5df67 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -1220,6 +1220,59 @@ test("ranged peer dependency resolution is stable across installs from bun.lock" }); }); +test("ranged peer rebinds in the install that adds a higher satisfying version, not the one after", async () => { + // The first install binds peer-deps-fixed's `no-deps@^1.0.0` to 1.0.0, the + // only candidate. Adding `one-dep` brings in no-deps@1.0.1; loading the + // lockfile that install writes binds the edge to 1.0.1 (highest satisfying), + // so that install has to bind it the same way itself. Otherwise it links the + // 1.0.0 peer variant and the next `bun install`, with nothing changed, + // re-keys the store entry. + const { packageJson, packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "isolated" }, + }); + const bunDir = join(packageDir, "node_modules", ".bun"); + const peerEntries = async () => (await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@")); + const peerNoDepsVersion = async (entry: string) => + ((await file(join(bunDir, entry, "node_modules", "no-deps", "package.json")).json()) as { version: string }) + .version; + + await write( + packageJson, + JSON.stringify({ + name: "rebind-ranged-peer", + dependencies: { "peer-deps-fixed": "1.0.0", "one-fixed-dep": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const [initialEntry] = await peerEntries(); + expect(await peerNoDepsVersion(initialEntry)).toBe("1.0.0"); + + await write( + packageJson, + JSON.stringify({ + name: "rebind-ranged-peer", + dependencies: { "peer-deps-fixed": "1.0.0", "one-fixed-dep": "1.0.0", "one-dep": "1.0.0" }, + }), + ); + await runBunInstall(bunEnv, packageDir); + const entries = await peerEntries(); + const lockfile = await file(join(packageDir, "bun.lock")).text(); + + const { out, err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + expect(out).toContain("(no changes)"); + expect(err).not.toContain("Saved lockfile"); + expect(await peerEntries()).toEqual(entries); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); + + // The superseded variant stays in the store until `bun prune`, like after any peer bump. + const [rebound, ...rest] = entries.filter(entry => entry !== initialEntry); + expect(rest).toEqual([]); + expect(await peerNoDepsVersion(rebound)).toBe("1.0.1"); + expect(readlinkSync(join(packageDir, "node_modules", "peer-deps-fixed"))).toBe( + join(".bun", rebound, "node_modules", "peer-deps-fixed"), + ); +}); + test("aliased peer dependency binds to its real package across installs from bun.lock", async () => { // The peer alias `no-deps` points at `npm:a-dep@^1.0.2` while the real // no-deps package (in two versions) is also in the graph. Loading bun.lock diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 2429afe854aa..8bb11fffa225 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -1366,6 +1366,61 @@ describe("package-lock.json migration fixes", () => { }, ); + test.concurrent( + "a ranged peer npm satisfied with a lower version migrates to the tree a fresh resolve writes", + async () => { + // npm satisfied peer-deps-fixed's `no-deps@^1.0.0` with the 1.0.0 it hoisted. bun binds such a + // peer to the highest satisfying version in the lockfile (1.0.1) whenever it loads bun.lock, and + // peer-deps-fixed, a devDependency, hoists before the root's dependencies: a migrated tree + // built from npm's binding would key 1.0.0 at the root and be rebuilt with 1.0.1 there by the + // first install that loads it. + using registry = localRegistry(); + const entry = (name: string, version: string, info: Record = {}) => ({ + version, + resolved: registry.tarball(name, version), + integrity: registry.integrity(name, version), + ...info, + }); + const root = { + name: "ranged-peer", + dependencies: { "one-dep": "1.0.0", "one-fixed-dep": "1.0.0" }, + devDependencies: { "peer-deps-fixed": "1.0.0" }, + }; + using dir = synthetic( + "npm-migrate-ranged-peer", + { + "package.json": JSON.stringify(root), + "package-lock.json": npmLock("ranged-peer", { + "": root, + "node_modules/no-deps": entry("no-deps", "1.0.0"), + "node_modules/one-dep": entry("one-dep", "1.0.0", { dependencies: { "no-deps": "1.0.1" } }), + "node_modules/one-dep/node_modules/no-deps": entry("no-deps", "1.0.1"), + "node_modules/one-fixed-dep": entry("one-fixed-dep", "1.0.0", { dependencies: { "no-deps": "1.0.0" } }), + "node_modules/peer-deps-fixed": entry("peer-deps-fixed", "1.0.0", { + dev: true, + peerDependencies: { "no-deps": "^1.0.0" }, + }), + }), + }, + registry.url, + ); + const { lock } = await migrate(dir); + expect(lock.packages["no-deps"][0]).toBe("no-deps@1.0.1"); + expect(lock.packages["one-fixed-dep/no-deps"][0]).toBe("no-deps@1.0.0"); + await frozen(dir); + + using freshDir = synthetic( + "npm-migrate-ranged-peer-fresh", + { "package.json": JSON.stringify(root) }, + registry.url, + ); + const fresh = await run(freshDir, "install", "--lockfile-only"); + expect(fresh.exitCode).toBe(0); + const { lock: freshLock } = await readLock(freshDir); + expect(lock.packages).toStrictEqual(freshLock.packages); + }, + ); + test.concurrent("workspace listed in the lockfile but deleted from disk is skipped", async () => { const src = join(ARBORIST, "workspaces-simple-virtual"); const packageLock = JSON.parse(fs.readFileSync(join(src, "package-lock.json"), "utf8")); diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index de69143e155b..c83cec240d83 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -1639,4 +1639,76 @@ fsevents@^2.3.2: expect(bunLockContent).toContain("@esbuild/linux-arm64"); expect(bunLockContent).toContain("@esbuild/darwin-arm64"); }); + + test("a peer binds to the highest satisfying version, not the copy yarn.lock resolved its range to", async () => { + // yarn.lock resolved bar's `foo@^1.0.0` peer spec to the foo@1.0.0 it lists first, while + // foo@1.5.0 is in the graph too. Hoisting binds the peer the way every other tree build + // does, and bar hoists before uses-foo1, so the copy written at the root is 1.5.0, the + // same tree a reload of the migrated file builds. + const sha = Buffer.alloc(40, "0").toString(); + await using tmpDir = tempDir("yarn-migration-peer-binding", { + "package.json": JSON.stringify({ + name: "peer-binding", + dependencies: { "bar": "1.0.0", "uses-foo1": "1.0.0", "uses-foo15": "1.0.0" }, + }), + // port 1 refuses connections, so the manifest fetch after the migration fails fast + "bunfig.toml": `[install]\nregistry = "http://localhost:1/"\n`, + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +foo@1.0.0, foo@^1.0.0: + version "1.0.0" + resolved "http://localhost:1/foo/-/foo-1.0.0.tgz#${sha}" + +foo@1.5.0: + version "1.5.0" + resolved "http://localhost:1/foo/-/foo-1.5.0.tgz#${sha}" + +bar@1.0.0: + version "1.0.0" + resolved "http://localhost:1/bar/-/bar-1.0.0.tgz#${sha}" + peerDependencies: + foo "^1.0.0" + +uses-foo1@1.0.0: + version "1.0.0" + resolved "http://localhost:1/uses-foo1/-/uses-foo1-1.0.0.tgz#${sha}" + dependencies: + foo "1.0.0" + +uses-foo15@1.0.0: + version "1.0.0" + resolved "http://localhost:1/uses-foo15/-/uses-foo15-1.0.0.tgz#${sha}" + dependencies: + foo "1.5.0" +`, + }); + + await using migrateResult = Bun.spawn({ + cmd: [bunExe(), "pm", "migrate", "-f"], + cwd: tmpDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + migrateResult.stdout.text(), + migrateResult.stderr.text(), + migrateResult.exited, + ]); + expect(stdout).toBe(""); + expect(stderr).toContain("migrated lockfile from yarn.lock"); + expect(exitCode).toBe(0); + + const lock = Bun.JSONC.parse(fs.readFileSync(join(tmpDir, "bun.lock"), "utf8")) as any; + expect(Object.fromEntries(Object.entries(lock.packages).map(([key, value]: any) => [key, value[0]]))).toEqual({ + "bar": "bar@1.0.0", + "foo": "foo@1.5.0", + "uses-foo1": "uses-foo1@1.0.0", + "uses-foo1/foo": "foo@1.0.0", + "uses-foo15": "uses-foo15@1.0.0", + }); + }); }); From 96fbc40bf638a21c8cc7891d351f5cca56d869b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:03:48 +0000 Subject: [PATCH 002/258] install: keep transitive rows on the copy a direct dependency resolves to (#38770) --- docs/pm/cli/update.mdx | 1 + .../PackageManager/PackageManagerEnqueue.rs | 62 +++++- .../PackageManager/install_with_manager.rs | 44 +++- src/install/lockfile.rs | 26 +++ src/install/update_transitive.rs | 150 +++++++++++-- test/cli/install/bun-install-registry.test.ts | 43 ++++ .../cli/install/bun-update-transitive.test.ts | 203 +++++++++++++++++- test/cli/install/bun-update.test.ts | 46 +++- 8 files changed, 518 insertions(+), 57 deletions(-) diff --git a/docs/pm/cli/update.mdx b/docs/pm/cli/update.mdx index 169b8b4e8d6d..580dd0eab3a7 100644 --- a/docs/pm/cli/update.mdx +++ b/docs/pm/cli/update.mdx @@ -36,6 +36,7 @@ Updated packages appear in the install summary as `↑ name old → new`, with ` ### What is held back - Bun never widens ranges. A package that depends on `foo@^1.0.0` never gets `foo@2.x`. +- A transitive dependency that shares the copy your own `package.json` entry resolves to (in the root or in a workspace) stays on that copy and moves with it. With `@types/node: ~20` in your `package.json`, the `@types/node: *` that `@types/ws` declares keeps using your `~20` copy instead of nesting the newest major under `@types/ws`. Bun updates a transitive range on its own when it rejects the version your entry moves to, or when you removed the entry. - Versions in `patchedDependencies` stay put as long as their range allows. Bun reports them as `kept name@version (patched, v1.2.3 available)`. `--latest` and [`bun audit fix`](/pm/cli/audit#bun-audit-fix) do move them; re-create the patch with [`bun patch`](/pm/cli/patch) afterwards. - If a registry request for a transitive package fails, that package keeps its locked version and Bun prints a warning. A failed request for a direct dependency is an error. diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index db8f7c3058d3..a323dc824b42 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -2098,6 +2098,22 @@ fn get_or_put_resolved_package_with_find_result( } } + // `bun update ` re-resolves every row of the name. A row owned by a + // regular package stays on the copy a root/workspace dependency resolves to + // whenever its range allows, as a fresh resolution would dedupe it there; + // the direct rows themselves (enqueued first) move within their own ranges. + if should_update && !behavior.is_peer() && !this.lockfile.is_workspace_dependency(dependency_id) + { + if let Some(id) = direct_dependency_package_satisfying(this, name_hash, version) { + success_fn(this, dependency_id, id); + return Ok(Some(ResolvedPackageResult { + package: *this.lockfile.packages.get(id as usize), + is_first_time: false, + task: None, + })); + } + } + // Was this package already allocated? Let's reuse the existing one. // // Determinism: passing `version` here unconditionally lets a @@ -2915,15 +2931,13 @@ fn resolution_satisfies_dependency( resolution.satisfies_dependency_version(dependency, buf, buf) } -fn patched_package_satisfying( - this: &PackageManager, +/// The first npm package of this name that `version` allows and `accept` takes. +fn npm_package_satisfying( + lockfile: &Lockfile::Lockfile, name_hash: PackageNameHash, version: &dependency::Version, + accept: impl Fn(PackageID) -> bool, ) -> Option { - let lockfile: &Lockfile::Lockfile = &this.lockfile; - if lockfile.patched_dependencies.count() == 0 { - return None; - } let candidates = lockfile.package_index.get(&name_hash)?.as_slice(); let pkg_res = lockfile.packages.items_resolution(); let buf = lockfile.buffers.string_bytes.as_slice(); @@ -2931,11 +2945,37 @@ fn patched_package_satisfying( let res = &pkg_res[id as usize]; res.tag == ResolutionTag::Npm && res.satisfies_dependency_version(version, buf, buf) - && lockfile - .patched_dependencies - .contains(&Semver::string::Builder::string_hash( - &crate::dedupe::label(lockfile, id), - )) + && accept(id) + }) +} + +fn patched_package_satisfying( + this: &PackageManager, + name_hash: PackageNameHash, + version: &dependency::Version, +) -> Option { + let lockfile: &Lockfile::Lockfile = &this.lockfile; + if lockfile.patched_dependencies.count() == 0 { + return None; + } + npm_package_satisfying(lockfile, name_hash, version, |id| { + lockfile + .patched_dependencies + .contains(&Semver::string::Builder::string_hash( + &crate::dedupe::label(lockfile, id), + )) + }) +} + +/// The package of this name that a root or workspace dependency resolves to, if `version` allows it. +fn direct_dependency_package_satisfying( + this: &PackageManager, + name_hash: PackageNameHash, + version: &dependency::Version, +) -> Option { + let lockfile: &Lockfile::Lockfile = &this.lockfile; + npm_package_satisfying(lockfile, name_hash, version, |id| { + lockfile.is_direct_dependency_resolution(id) }) } diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index ddeecd488e28..a2aaffe39e33 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -236,7 +236,7 @@ pub fn install_with_manager( if manager.subcommand == Subcommand::Dedupe { crate::dedupe::dedupe_after_differ(manager); } - if manager.summary.changes_resolutions() { + if manager.summary.changes_resolutions() || bare_update { direct_deps_before = DirectDependencies::snapshot(&manager.lockfile); } @@ -593,6 +593,9 @@ pub fn install_with_manager( let mut named = NamedUpdates::default(); if !needs_new_lockfile { if named_update { + // The rows of a workspace the differ re-parsed are still queued; they are + // direct entries, so they go out ahead of the rows the named pass enqueues. + manager.drain_dependency_list(); named = enqueue_named_updates( manager, &direct_deps_before, @@ -634,8 +637,16 @@ pub fn install_with_manager( if manager.pending_task_count() > 0 || manager.peer_dependencies.readable_length() > 0 || !named.latest_rows.is_empty() + || transitive.has_deferred() { - resolve_pending_tasks(manager, &root, log_level, &mut named)?; + resolve_pending_tasks( + manager, + &root, + log_level, + &mut named, + &mut transitive, + &direct_deps_before, + )?; } direct_deps_before.redirect_dependents(&mut manager.lockfile); @@ -1561,11 +1572,12 @@ fn enqueue_named_updates( let mut matched_elsewhere = DynamicBitSet::init_empty(requests).unwrap_or_oom(); let mut named = NamedUpdates::default(); let mut peer_rows: Vec = Vec::new(); + let mut rows: Vec<(DependencyID, PackageID)> = Vec::new(); let dependencies_len = manager.lockfile.buffers.dependencies.len(); for dependency_i in 0..dependencies_len { - let dependency = manager.lockfile.buffers.dependencies[dependency_i].clone(); + let dependency = &manager.lockfile.buffers.dependencies[dependency_i]; let package_id = manager.lockfile.buffers.resolutions[dependency_i]; - let Some(request) = index_of_named_update(manager, &dependency, package_id) else { + let Some(request) = index_of_named_update(manager, dependency, package_id) else { continue; }; if !walkable.is_set(dependency_i) { @@ -1585,11 +1597,23 @@ fn enqueue_named_updates( } continue; } - manager.lockfile.buffers.resolutions[dependency_i] = invalid_package_id; - named.moved.push((dependency_i as DependencyID, package_id)); + rows.push((dependency_i as DependencyID, package_id)); + } + + // Rows declared by the root or a workspace resolve first, so the rows regular + // packages own can land on the copy they move to (see + // `get_or_put_resolved_package_with_find_result`), and edges following a + // vacated package through `redirect_moved_edges` follow the direct entry. + let (direct_rows, transitive_rows): (Vec<_>, Vec<_>) = rows + .into_iter() + .partition(|&(dependency_i, _)| manager.lockfile.is_workspace_dependency(dependency_i)); + for (dependency_i, package_id) in direct_rows.into_iter().chain(transitive_rows) { + let dependency = manager.lockfile.buffers.dependencies[dependency_i as usize].clone(); + manager.lockfile.buffers.resolutions[dependency_i as usize] = invalid_package_id; + named.moved.push((dependency_i, package_id)); if let Err(err) = enqueue_dependency_with_main( manager, - dependency_i as DependencyID, + dependency_i, &dependency, invalid_package_id, false, @@ -1959,6 +1983,8 @@ fn resolve_pending_tasks( root: &lockfile::Package, log_level: Options::LogLevel, named: &mut NamedUpdates, + transitive: &mut TransitiveUpdate, + direct_deps_before: &DirectDependencies, ) -> crate::Result<()> { if root.dependencies.len > 0 { let _ = manager.get_cache_directory(); @@ -1978,6 +2004,10 @@ fn resolve_pending_tasks( wait_for_resolution(manager)?; + if transitive.plan_unanchored(manager, direct_deps_before)? { + wait_for_resolution(manager)?; + } + if !named.latest_rows.is_empty() { let child_moves = refresh_children_of_named(manager, &named.latest_rows)?; named.moved.extend(child_moves); diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 755ec191a69a..f80c2ed6b1ee 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -782,6 +782,23 @@ impl Lockfile { self.get_workspace_pkg_if_workspace_dep(id) != invalid_package_id } + /// Does a dependency declared by the root package or a workspace currently + /// resolve to package `id`? + pub(crate) fn is_direct_dependency_resolution(&self, id: PackageID) -> bool { + let packages = self.packages.slice(); + let resolutions_buf = self.buffers.resolutions.as_slice(); + packages + .items_resolution() + .iter() + .zip(packages.items_resolutions()) + .any(|(resolution, resolution_list)| { + matches!( + resolution.tag, + ResolutionTag::Root | ResolutionTag::Workspace + ) && resolution_list.get(resolutions_buf).contains(&id) + }) + } + pub(crate) fn get_workspace_pkg_if_workspace_dep(&self, id: DependencyID) -> PackageID { let packages = self.packages.slice(); let resolutions = packages.items_resolution(); @@ -2076,6 +2093,14 @@ impl Lockfile { // (an exact pin anywhere in the tree is a deliberate choice, // not a network-order artefact — `dragon test 2` / // "dependency from root satisfies range from dependency"), + // - no dependency declared by the root or a workspace resolves + // to the entry. Those rows are enqueued before any package's + // dependency list is drained, so whatever they resolved to is + // already in place by the time a transitive row on the same + // name gets here, whichever order the manifests landed in. + // This is what keeps a project's `@types/node: ~20` as the + // single copy every `@types/*` package's `@types/node: *` + // collapses onto, instead of each nesting the latest major. // - the manifest's best-match is a *different major* (within a // major, deduping to an older patch is the long-standing // behaviour and the worst case is still ^-compatible). @@ -2088,6 +2113,7 @@ impl Lockfile { if let Some(floor) = resolved_npm_floor { if existing_ver.order(floor, buf, buf) == Ordering::Less && existing_ver.major != floor.major + && !self.is_direct_dependency_resolution(id) { return false; } diff --git a/src/install/update_transitive.rs b/src/install/update_transitive.rs index d53d2adbd821..9492b81a4401 100644 --- a/src/install/update_transitive.rs +++ b/src/install/update_transitive.rs @@ -23,11 +23,17 @@ use crate::{ PackageManager, PackageNameHash, ResolutionTag, invalid_package_id, }; +struct DirectRow { + name_hash: PackageNameHash, + behavior: Behavior, + resolved: PackageID, +} + /// Root/workspace dependency rows as loaded from bun.lock, taken before the differ re-enqueues them. #[derive(Default)] pub struct DirectDependencies { owners: Vec<(PackageID, u32, u32)>, - rows: Vec<(PackageNameHash, Behavior, PackageID)>, + rows: Vec, } impl DirectDependencies { @@ -52,7 +58,11 @@ impl DirectDependencies { .get(deps) .iter() .zip(res_slices[owner].get(resolutions)) - .map(|(dep, &resolved)| (dep.name_hash, dep.behavior, resolved)), + .map(|(dep, &resolved)| DirectRow { + name_hash: dep.name_hash, + behavior: dep.behavior, + resolved, + }), ); out.owners.push(( owner as PackageID, @@ -63,6 +73,17 @@ impl DirectDependencies { out } + /// One bit per package: the packages the direct rows resolve to. + fn resolved_packages(&self, packages_len: usize) -> DynamicBitSet { + let mut set = DynamicBitSet::init_empty(packages_len).unwrap_or_oom(); + for row in &self.rows { + if (row.resolved as usize) < packages_len { + set.set(row.resolved as usize); + } + } + set + } + /// Edges still resolving to the previous package of a direct dependency that moved follow it when their range allows. pub fn redirect_dependents(&self, lockfile: &mut Lockfile) { if self.owners.is_empty() || lockfile.loaded_package_count == 0 { @@ -93,8 +114,8 @@ impl DirectDependencies { .iter() .zip(res_slices[owner].get(resolutions)); for (i, (dep, &new)) in current.enumerate() { - let same = |row: &(PackageNameHash, Behavior, PackageID)| { - row.0 == dep.name_hash && row.1 == dep.behavior + let same = |row: &DirectRow| { + row.name_hash == dep.name_hash && row.behavior == dep.behavior }; let index = if claimed.is_none() && rows.get(i).is_some_and(same) { i @@ -122,7 +143,7 @@ impl DirectDependencies { taken.set(k); k }; - let old = rows[index].2; + let old = rows[index].resolved; if old == new || (old as usize) >= packages_len || (new as usize) >= packages_len @@ -259,12 +280,14 @@ struct Pin { to: Option, } -/// The transitive half of a bare `bun update`: every edge owned by a non-workspace package the selected workspaces reach (all of them from the root or with -r) moves to the newest release its own range allows, or to wherever its dist-tag points now. +/// The transitive half of a bare `bun update`: every edge owned by a non-workspace package the selected workspaces reach (all of them from the root or with -r) moves to the newest release its own range allows, or to wherever its dist-tag points now. A range edge sharing the package a root/workspace entry resolves to follows that entry instead (`deferred`). #[derive(Default)] pub struct TransitiveUpdate { pins: Vec, /// Kept for `print_plan` when no install summary will print the rows (`--dry-run`, `--lockfile-only`). report: Option, + /// Range rows left to follow the direct entry whose package they share; `plan_unanchored` plans the ones that entry did not take along. + deferred: Vec, } impl TransitiveUpdate { @@ -286,15 +309,67 @@ impl TransitiveUpdate { } edges }; - let (pins, report) = plan_edges(manager, &edges, direct)?; - register_moved(manager, &report.moved)?; + let planned = plan_edges(manager, &edges, direct)?; + register_moved(manager, &planned.report.moved)?; let printed_here = manager.options.dry_run || manager.options.lockfile_only; Ok(TransitiveUpdate { - pins, - report: printed_here.then_some(report), + pins: planned.pins, + report: printed_here.then_some(planned.report), + deferred: planned.deferred, }) } + pub fn has_deferred(&self) -> bool { + !self.deferred.is_empty() + } + + /// Once the direct entries have resolved: a deferred row whose entry moved to a version the row's range accepts has followed it; one whose package no direct entry resolves to any more (the entry moved where the range does not reach, or was removed from package.json in the meantime) is planned on its own range here. Returns whether anything was enqueued, in which case the caller resolves again. + pub fn plan_unanchored( + &mut self, + manager: &mut PackageManager, + direct: &DirectDependencies, + ) -> crate::Result { + if self.deferred.is_empty() { + return Ok(false); + } + direct.redirect_dependents(&mut manager.lockfile); + let current = DirectDependencies::snapshot(&manager.lockfile); + let edges = { + let lockfile = &*manager.lockfile; + let packages_len = lockfile.packages.len(); + let anchored = current.resolved_packages(packages_len); + let resolutions = lockfile.buffers.resolutions.as_slice(); + let mut edges = DynamicBitSet::init_empty(resolutions.len())?; + for dep_id in self.deferred.drain(..) { + let target = resolutions[dep_id as usize] as usize; + if target < packages_len && !anchored.is_set(target) { + edges.set(dep_id as usize); + } + } + edges + }; + if edges.count() == 0 { + return Ok(false); + } + let planned = plan_edges(manager, &edges, ¤t)?; + register_moved(manager, &planned.report.moved)?; + if let Some(report) = &mut self.report { + report.rows.extend(planned.report.rows); + report.moved.extend(planned.report.moved); + } + if planned.pins.is_empty() { + return Ok(false); + } + let round = TransitiveUpdate { + pins: planned.pins, + ..Default::default() + }; + round.enqueue(manager)?; + manager.drain_dependency_list(); + self.pins.extend(round.pins); + Ok(true) + } + /// Runs after the differ's own enqueues (including its override/catalog invalidation loops) so the pins win; edges the differ moved off their package are left to it. pub fn enqueue(&self, manager: &mut PackageManager) -> crate::Result<()> { self.enqueue_inner(manager, None) @@ -417,9 +492,14 @@ pub(crate) fn refresh_children_of( } edges }; - let (pins, report) = plan_edges(manager, &edges, &DirectDependencies::default())?; - register_moved(manager, &report.moved)?; - let update = TransitiveUpdate { pins, report: None }; + // The direct rows are resolved by now, so the rows `plan_edges` defers are sharing a package those rows settled on and simply stay there. + let direct = DirectDependencies::snapshot(&manager.lockfile); + let planned = plan_edges(manager, &edges, &direct)?; + register_moved(manager, &planned.report.moved)?; + let update = TransitiveUpdate { + pins: planned.pins, + ..Default::default() + }; update.enqueue(manager)?; Ok(update .pins @@ -977,9 +1057,9 @@ pub(crate) fn plannable_peer_rows( let resolutions = lockfile.buffers.resolutions.as_slice(); let mut providers = DynamicBitSet::init_empty(packages_len).unwrap_or_oom(); - for &(_, behavior, resolved) in &direct.rows { - if !behavior.is_peer() && (resolved as usize) < packages_len { - providers.set(resolved as usize); + for row in &direct.rows { + if !row.behavior.is_peer() && (row.resolved as usize) < packages_len { + providers.set(row.resolved as usize); } } for owner in 0..packages_len { @@ -1020,12 +1100,20 @@ pub(crate) fn plannable_peer_rows( rows } -/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag. +#[derive(Default)] +struct Plan { + pins: Vec, + report: Report, + /// Range rows sharing a package a row of `direct` resolves to; see `TransitiveUpdate::deferred`. + deferred: Vec, +} + +/// `edges` selects the rows to plan; each moves to the newest release its (post-override/catalog) range allows, or follows its dist-tag. A range row on a package that a row of `direct` resolves to is deferred instead, since it belongs with that entry; a dist-tag row keeps following its tag. fn plan_edges( manager: &mut PackageManager, edges: &DynamicBitSet, direct: &DirectDependencies, -) -> crate::Result<(Vec, Report)> { +) -> crate::Result { let mut instances: Vec = Vec::new(); let mut kept: Vec = Vec::new(); { @@ -1124,7 +1212,26 @@ fn plan_edges( } instances.retain(|inst| !inst.wants.is_empty()); if instances.is_empty() { - return Ok((Vec::new(), Report::default())); + return Ok(Plan::default()); + } + + let mut plan = Plan::default(); + let shared_with_direct = direct.resolved_packages(manager.lockfile.packages.len()); + instances.retain_mut(|inst| { + if inst.held || !shared_with_direct.is_set(inst.pkg_id as usize) { + return true; + } + inst.wants.retain_mut(|want| { + if want.version.tag != DependencyVersionTag::Npm { + return true; + } + plan.deferred.append(&mut want.dep_ids); + false + }); + !inst.wants.is_empty() + }); + if instances.is_empty() { + return Ok(plan); } let ids: Vec = instances.iter().map(|inst| inst.pkg_id).collect(); @@ -1137,8 +1244,7 @@ fn plan_edges( let buf = manager.lockfile.buffers.string_bytes.as_slice(); let pkg_names = manager.lockfile.packages.items_name(); - let mut pins: Vec = Vec::new(); - let mut report = Report::default(); + let Plan { pins, report, .. } = &mut plan; let mut unchecked: Vec<(Box<[u8]>, Box<[u8]>)> = Vec::new(); // Non-inline prerelease strings of planned versions live in the manifest buffer; copied into the lockfile's below. let mut pre_strings: Vec<(core::ops::Range, u64, Box<[u8]>)> = Vec::new(); @@ -1234,7 +1340,7 @@ fn plan_edges( } sort_dedup_rows(&mut report.rows); - Ok((pins, report)) + Ok(plan) } /// The `latest` dist-tag when it is newer than the release `v` an in-range move stops at, like the `+` rows' `(vX available)`. diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 0e6f5e17b6ed..4d2d41384e27 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -4634,6 +4634,49 @@ describe("hoisting", async () => { lockfile, ); }); + + // hoist-lockfile-1@1.0.0 depends on `hoist-lockfile-shared: *`; the registry has 1.0.1, 1.0.2, 2.0.1 and 2.0.2. + // The project's own range decides which copy that `*` shares, the same way an exact pin would. + async function sharedResolutions() { + await runBunInstall(env, packageDir, { saveTextLockfile: true }); + const { packages } = Bun.JSONC.parse(await file(join(packageDir, "bun.lock")).text()) as { + packages: Record; + }; + return Object.fromEntries( + Object.entries(packages) + .filter(([, [resolution]]) => resolution.startsWith("hoist-lockfile-shared@")) + .map(([key, [resolution]]) => [key, resolution]), + ); + } + + test("a dependency's `*` shares the version the root's own range resolved to", async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-shared": "^1.0.1" }, + }), + ); + + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + }); + + test("a dependency's `*` shares the version a workspace's own range resolved to", async () => { + await Promise.all([ + write( + packageJson, + JSON.stringify({ name: "foo", workspaces: ["packages/*"], dependencies: { "hoist-lockfile-1": "1.0.0" } }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", dependencies: { "hoist-lockfile-shared": "^1.0.1" } }), + ), + ]); + + expect(await sharedResolutions()).toStrictEqual({ "hoist-lockfile-shared": "hoist-lockfile-shared@1.0.2" }); + expect(await exists(join(packageDir, "node_modules", "hoist-lockfile-1", "node_modules"))).toBeFalse(); + }); }); describe("transitive file dependencies", () => { diff --git a/test/cli/install/bun-update-transitive.test.ts b/test/cli/install/bun-update-transitive.test.ts index 43d7ae599a80..56857712354e 100644 --- a/test/cli/install/bun-update-transitive.test.ts +++ b/test/cli/install/bun-update-transitive.test.ts @@ -1119,6 +1119,133 @@ test.concurrent("the plan counts packages, not the edges that move onto them", a expect(exitCode).toBe(0); }); +// The root declares hoist-lockfile-shared itself, so the dependent's row shares the root's copy; `rootRange` is +// widened after the install (the locked 1.0.1 still satisfies it, so nothing re-resolves until an update). +async function sharedWithRoot(dependent: string, rootRange = "1.0.1") { + const dir = await setup({ "package.json": pkgJson({ [dependent]: "1.0.0", "hoist-lockfile-shared": "1.0.1" }) }); + const packageJson = pkgJson({ [dependent]: "1.0.0", "hoist-lockfile-shared": rootRange }); + if (rootRange !== "1.0.1") await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + return { dir, packageJson, nested: join(dir, "node_modules", dependent, "node_modules", "hoist-lockfile-shared") }; +} + +// hoist-lockfile-1's `*` row follows the root's own hoist-lockfile-shared entry instead of forking off to 2.0.2. +test.concurrent.each([ + ["bare", []], + ["named", ["hoist-lockfile-shared"]], +])( + "a row sharing the root's copy follows the root's range instead of re-resolving on its own (%s)", + async (_, args) => { + const { dir, nested } = await sharedWithRoot("hoist-lockfile-1", "^1.0.1"); + const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expectSummary(stdout, movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2", "2.0.2"), "", installed(1)); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual( + pkgJson({ "hoist-lockfile-1": "1.0.0", "hoist-lockfile-shared": "^1.0.2" }), + ); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("1.0.2"); + expect(await exists(nested)).toBeFalse(); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each([ + ["bare", []], + ["named", ["hoist-lockfile-shared"]], + ["the dependent with --latest", ["hoist-lockfile-1", "--latest"]], +])("a row sharing the root's exact pin stays on it (%s)", async (_, args) => { + const { dir, nested } = await sharedWithRoot("hoist-lockfile-1"); + await expectNoop(dir, ...args); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + expect(await exists(nested)).toBeFalse(); +}); + +// hoist-lockfile-2's `^1.0.1` rejects the 2.0.2 the root's `>=1.0.1` moves to, so that row is planned on its own. +test.concurrent("a row whose range rejects where the root's copy is going moves within its own range", async () => { + const { dir, packageJson } = await sharedWithRoot("hoist-lockfile-2", ">=1.0.1"); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectMoved(stdout, "hoist-lockfile-shared", "1.0.1", "2.0.2"); + expect(normalize(stdout)).toEndWith(`\n${installed(2)}\n`); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + expect(await installedVersion(dir, "hoist-lockfile-2", "node_modules", "hoist-lockfile-shared")).toBe("1.0.2"); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// The root's entry is edited and `bun update` runs right away, so the update sees the old entry in bun.lock and the new one in package.json. +test.concurrent( + "a row is planned on its own when the root's entry is edited out of its range before the update", + async () => { + const { dir } = await sharedWithRoot("hoist-lockfile-2"); + await write( + join(dir, "package.json"), + stringify(pkgJson({ "hoist-lockfile-2": "1.0.0", "hoist-lockfile-shared": "^2.0.1" })), + ); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectMoved(stdout, "hoist-lockfile-shared", "1.0.1", "2.0.2"); + expect(normalize(stdout)).toEndWith(`\n${installed(2)}\n`); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual( + pkgJson({ "hoist-lockfile-2": "1.0.0", "hoist-lockfile-shared": "^2.0.2" }), + ); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + expect(await installedVersion(dir, "hoist-lockfile-2", "node_modules", "hoist-lockfile-shared")).toBe("1.0.2"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +test.concurrent.each([ + ["bare", []], + ["--dry-run", ["--dry-run"]], +])("a row is planned on its own when the root's entry is removed before the update (%s)", async (_, args) => { + const { dir } = await sharedWithRoot("hoist-lockfile-1"); + const packageJson = pkgJson({ "hoist-lockfile-1": "1.0.0" }); + await write(join(dir, "package.json"), stringify(packageJson)); + const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expect(movedRows(stdout)).toStrictEqual([movedRow("hoist-lockfile-shared", "1.0.1", "2.0.2")]); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual(packageJson); + if (args.length) { + expect(normalize(stdout)).toEndWith(`\n${wouldUpdate(1)}\n`); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + } else { + expect(normalize(stdout)).toContain(`\n${installed(1)}\n`); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["2.0.2"]); + expect(await installedVersion(dir, "hoist-lockfile-shared")).toBe("2.0.2"); + await frozen(dir); + } + expect(exitCode).toBe(0); +}); + +// pkg2's no-deps entry, which one-range-dep's `^1.0.0` row shares, is edited to `^2.0.0` right before the update; pkg2's +// new package.json is only read while the update resolves, so the row can only be planned once pkg2 has moved. +test.concurrent( + "a row is planned on its own when a member's entry is edited out of its range before the update", + async () => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": "^2.0.0" }))); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expect(movedRows(stdout)).toStrictEqual([ + movedRow("no-deps", "1.0.0", "2.0.0"), + movedRow("no-deps", "1.0.0", "1.1.0"), + ]); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "^2.0.0" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]); + expect(await installedVersion(dir, "one-range-dep", "node_modules", "no-deps")).toBe("1.1.0"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + // peer-deps-fixed@1.0.0 declares peer `no-deps: ^1.0.0`; the root's exact no-deps@1.0.0 is its only provider. test.concurrent.each([ ["bare", []], @@ -1436,13 +1563,38 @@ test.concurrent( }, ); -// The request binds to the root's exact leaf@1.0.0, which stays put; the copy that moves is parent's nested one. +// parent's `^1.0.0` is satisfied by the root's exact leaf@1.0.0, so it shares that copy and the request has nothing to move. +test.concurrent("`bun update ` keeps a dependent's row on the root's exact pin", async () => { + using server = await serveRegistry(TAGGED_FREE); + const dir = await installServed(server, "update-named-shared-pin-", pkgJson({ parent: "^1.0.0", leaf: "1.0.0" })); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + const before = await withLeafScanner(server, dir); + const { stdout, stderr, exitCode } = await run(dir, "update", "leaf"); + expectNoChangesLine(stdout); + expect(stdout).not.toContain("FATAL:"); + expectCleanStderr(stderr); + expect(await lockText(dir)).toBe(before); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await exists(join(dir, "node_modules", "parent", "node_modules", "leaf"))).toBeFalse(); + expect(exitCode).toBe(0); +}); + +// The request binds to the root's exact leaf@1.0.0, which stays put; parent's `^1.0.1` rejects that copy, so its own +// one (left on 1.0.1 when the root moved from 1.0.1 down to 1.0.0) is the copy that moves. test.concurrent( "`bun update ` also scans the nested copy it re-resolved when the root's own row stays put", async () => { - using server = await serveRegistry(TAGGED_FREE); - const dir = await installServed(server, "update-named-scan-nested-", pkgJson({ parent: "^1.0.0", leaf: "1.0.0" })); - expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + using server = await serveRegistry({ + parent: { "1.0.0": { dependencies: { leaf: "^1.0.1" } } }, + leaf: { "1.0.0": {}, "1.0.1": {}, "1.1.0": {} }, + }); + const dir = await setupServed( + server, + "update-named-scan-nested-", + pkgJson({ parent: "^1.0.0", leaf: "1.0.1" }), + pkgJson({ parent: "^1.0.0", leaf: "1.0.0" }), + ); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "1.0.1"]); const before = await withLeafScanner(server, dir); const { stdout, exitCode } = await run(dir, "update", "leaf"); const scanned = stdout.match(/^scanned: .*$/m)?.[0] ?? ""; @@ -1450,8 +1602,9 @@ test.concurrent( expect(scanned).toContain("leaf@1.1.0"); expect(stdout).toContain("FATAL: leaf"); expect(await lockText(dir)).toBe(before); - expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0", "1.0.1"]); expect(await installedVersion(dir, "leaf")).toBe("1.0.0"); + expect(await installedVersion(dir, "parent", "node_modules", "leaf")).toBe("1.0.1"); expect(exitCode).toBe(1); }, ); @@ -1875,11 +2028,10 @@ test.concurrent("`bun update ` from a member leaves a sibling's own entry test.concurrent( "`bun update ` from a member: a sibling whose range rejects the picked version stays put", async () => { - const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); - const pkg2Text = await packageJsonText(dir, "packages/pkg2"); + const { dir, pkg2Text } = await staleMembers("^1.0.0", "~1.0.0"); const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); expect(stderr).not.toContain("error:"); - expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.1.0" })); expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]); await frozen(dir); @@ -1887,6 +2039,35 @@ test.concurrent( }, ); +// one-range-dep's `^1.0.0` row shares the no-deps@1.0.0 that pkg2's own `~1.0.0` entry holds; neither request re-resolves pkg2's entry, so the row stays with it rather than forking off to 1.1.0. +test.concurrent.each([ + ["bare", []], + ["named", ["no-deps"]], +])("from a member, a row sharing a sibling's copy stays with that copy (%s)", async (_, args) => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + const pkg2Text = await packageJsonText(dir, "packages/pkg2"); + const { stdout, stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", ...args); + expectNoMoves(stdout); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// With pkg2's entry in the request's scope, it moves within `~1.0.0` and one-range-dep's row follows it. +test.concurrent("`bun update -r` moves a sibling's entry and the rows sharing its copy follow", async () => { + const { dir, pkg1 } = await staleMemberTransitive("~1.0.0"); + const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "-r", "no-deps"); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); + expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "~1.0.1" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); + test.concurrent("`bun update ` from the root does not re-resolve a member's own entry", async () => { const root = { ...ROOT, dependencies: { "no-deps": "^2.0.0" } }; const dir = await setup({ @@ -2002,6 +2183,7 @@ test.concurrent.each([ }, ); +// one-range-dep's `^1.0.0` row shares the copy the aliased entry holds, so it moves with that entry to 1.0.1 rather than to 1.1.0 on its own. test.concurrent("several names in one command are matched independently, aliases through their real name", async () => { const dir = await setup({ "package.json": pkgJson({ "a-dep": "1.0.1", aliased: "npm:no-deps@1.0.0", "one-range-dep": "1.0.0" }), @@ -2011,14 +2193,13 @@ test.concurrent("several names in one command are matched independently, aliases expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); const { stdout, stderr, exitCode } = await run(dir, "update", "a-dep", "no-deps"); - expect(movedRows(stdout)).toContain(A_DEP_ROW); - expect(movedRows(stdout)).toContain(NO_DEPS_ROW); + expect(movedRows(stdout)).toStrictEqual([A_DEP_ROW, movedRow("aliased", "1.0.0", "1.0.1")]); expect(stdout).not.toMatch(/^installed /m); expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual( pkgJson({ "a-dep": "^1.0.10", aliased: "npm:no-deps@~1.0.1", "one-range-dep": "1.0.0" }), ); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1", "1.1.0"]); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); await frozen(dir); expect(exitCode).toBe(0); diff --git a/test/cli/install/bun-update.test.ts b/test/cli/install/bun-update.test.ts index e5dc789cc108..b39b59d8c5f4 100644 --- a/test/cli/install/bun-update.test.ts +++ b/test/cli/install/bun-update.test.ts @@ -1506,21 +1506,31 @@ it("bun update from the root leaves a member's own entry alone; running i it("should update transitive resolutions of a named package", async () => { setHandler( await perNameRegistry(join(package_dir, ".tarballs"), { - shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" }, - "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" }, + shared: { versions: { "1.0.0": {}, "1.0.1": {}, "1.1.0": {} }, latest: "1.1.0" }, + "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.1" } } }, latest: "1.0.0" }, }), ); await writeTextLockfileBunfig(); - // dep-x@1.0.0 depends on shared@^1.0.0, which dedupes onto the root's - // exact shared@1.0.0 at install time. + // dep-x@1.0.0 depends on shared@^1.0.1, which dedupes onto the root's + // exact shared@1.0.1 at install time. + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "root", dependencies: { shared: "1.0.1", "dep-x": "^1.0.0" } }), + ); + await runInPackageDir("install"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.1"']); + + // Moving the root down to 1.0.0 leaves dep-x's row where it was: a plain + // install never re-resolves a row whose dependent did not change. await writeFile( join(package_dir, "package.json"), JSON.stringify({ name: "root", dependencies: { shared: "1.0.0", "dep-x": "^1.0.0" } }), ); await runInPackageDir("install"); - expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"', '"shared@1.0.1"']); - // The root's exact `1.0.0` cannot move; dep-x's `^1.0.0` must move to 1.1.0. + // The root's exact `1.0.0` cannot move and does not satisfy dep-x's `^1.0.1`, + // so dep-x's row must move to 1.1.0 on its own. await runInPackageDir("update", "shared"); expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"', '"shared@1.1.0"']); expect( @@ -1528,6 +1538,30 @@ it("should update transitive resolutions of a named package", async () => { ).toMatchObject({ version: "1.1.0" }); }); +// The row re-enters the queue here too, but the root's copy still satisfies it, so it +// lands back on that copy instead of nesting 1.1.0 next to the root's 1.0.0. +it("bun update keeps a transitive row on the root's entry while its range allows it", async () => { + setHandler( + await perNameRegistry(join(package_dir, ".tarballs"), { + shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" }, + "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" }, + }), + ); + await writeTextLockfileBunfig(); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "root", dependencies: { shared: "1.0.0", "dep-x": "^1.0.0" } }), + ); + await runInPackageDir("install"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + const before = await file(join(package_dir, "bun.lock")).text(); + + await runInPackageDir("update", "shared"); + expect(await lockedSharedResolutions()).toEqual(['"shared@1.0.0"']); + expect(await file(join(package_dir, "bun.lock")).text()).toBe(before); + expect(await exists(join(package_dir, "node_modules", "dep-x", "node_modules"))).toBeFalse(); +}); + it("bun update --latest holds back only the root's entry; a transitive edge declared as a dist-tag keeps following it", async () => { setHandler( await perNameRegistry(join(package_dir, ".tarballs"), { From 51177d0387e27e7cff696ea1439199771e434e95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:03:53 +0000 Subject: [PATCH 003/258] install: load root and workspace dependencies from bun.lock the way package.json parses them (#38869) --- src/install/lockfile/bun.lock.rs | 149 ++++++-- .../__snapshots__/bun-workspaces.test.ts.snap | 30 +- test/cli/install/bun-workspaces.test.ts | 344 ++++++++++++++++++ 3 files changed, 485 insertions(+), 38 deletions(-) diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 148c058002c3..602670fc2770 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -3051,10 +3051,6 @@ pub(crate) fn parse_into_binary_lockfile( .resize(lockfile.buffers.dependencies.len(), invalid_package_id); lockfile.buffers.resolutions.fill(invalid_package_id); - // a package can list the same dependency in each dependnecy group, but only the first - // is chosen (dev -> optional -> prod -> peer) - let mut seen_deps: bun_collections::StringArrayHashMap<()> = Default::default(); - // The two `[0]` writes are done first via // sequential `&mut` accessors so the loops can take all column views // immutably without overlapping exclusive borrows or `unsafe`. @@ -3065,6 +3061,7 @@ pub(crate) fn parse_into_binary_lockfile( let pkgs = lockfile.packages.slice(); let pkg_deps = pkgs.items_dependencies(); let pkg_names = pkgs.items_name(); + let pkg_name_hashes: &[PackageNameHash] = pkgs.items_name_hash(); let pkg_resolutions: &[Resolution] = pkgs.items_resolution(); // Populated by `append_package_dedupe` while the packages object was @@ -3073,6 +3070,10 @@ pub(crate) fn parse_into_binary_lockfile( let package_index = &lockfile.package_index; let overrides = &lockfile.overrides; let catalogs: &CatalogMap = &lockfile.catalogs; + let workspace_versions: &VersionHashMap = &lockfile.workspace_versions; + let link_workspace_packages = manager + .as_deref() + .is_none_or(|manager| manager.options.link_workspace_packages); // Disjoint-field split of `lockfile.buffers` so each loop body can hold // `&mut dependencies[i]` and `&mut resolutions[i]` together with a shared @@ -3082,6 +3083,16 @@ pub(crate) fn parse_into_binary_lockfile( let dependencies: &mut [Dependency] = buffers.dependencies.as_mut_slice(); let resolutions: &mut [PackageID] = buffers.resolutions.as_mut_slice(); + let workspace_links = WorkspaceLinks { + pkg_names, + pkg_name_hashes, + workspace_versions, + catalogs, + overrides, + string_buf, + link_workspace_packages, + }; + { // first the root dependencies are resolved for _dep_id in pkg_deps[0].begin()..pkg_deps[0].end() { @@ -3113,22 +3124,14 @@ pub(crate) fn parse_into_binary_lockfile( return Err(ParseError::InvalidPackageInfo); }; - if !dep.behavior.is_workspace() - && seen_deps - .get_or_put(dep.name.slice(string_buf))? - .found_existing - { - resolutions[dep_id as usize] = res_id; - continue; - } - - map_dep_to_pkg( + map_manifest_dep_to_pkg( dep, dep_id, res_id, resolutions, lockfile_version, pkg_resolutions, + &workspace_links, ); } } @@ -3141,8 +3144,6 @@ pub(crate) fn parse_into_binary_lockfile( let pkg_id: PackageID = _pkg_id; let workspace_name = pkg_names[pkg_id as usize].slice(string_buf); - seen_deps.clear_retaining_capacity(); - let deps = pkg_deps[pkg_id as usize]; for _dep_id in deps.begin()..deps.end() { let dep_id: DependencyID = _dep_id; @@ -3198,18 +3199,14 @@ pub(crate) fn parse_into_binary_lockfile( return Err(ParseError::InvalidPackageInfo); }; - if seen_deps.get_or_put(dep_name)?.found_existing { - resolutions[dep_id as usize] = res_id; - continue; - } - - map_dep_to_pkg( + map_manifest_dep_to_pkg( dep, dep_id, res_id, resolutions, lockfile_version, pkg_resolutions, + &workspace_links, ); } } @@ -3453,16 +3450,7 @@ fn map_dep_to_pkg( if text_lockfile_version != Version::V0 { let res = &pkg_resolutions[pkg_id as usize]; if res.tag == ResolutionTag::Workspace { - // Whole-struct assign so `DependencyVersion::Drop` frees any prior - // npm chain. SAFETY: `res.tag == Workspace` checked above. - let literal = dep.version.literal; - dep.version = DependencyVersion { - tag: DependencyVersionTag::Workspace, - literal, - value: DependencyVersionValue { - workspace: *res.workspace(), - }, - }; + link_dep_to_workspace(dep, res); } } } @@ -3472,6 +3460,103 @@ fn may_stay_unresolved(dep: &Dependency) -> bool { dep.behavior.intersects(Behavior::OPTIONAL | Behavior::PEER) } +fn map_manifest_dep_to_pkg( + dep: &mut Dependency, + dep_id: DependencyID, + pkg_id: PackageID, + resolutions: &mut [PackageID], + text_lockfile_version: Version, + pkg_resolutions: &[Resolution], + workspace_links: &WorkspaceLinks<'_>, +) { + resolutions[dep_id as usize] = pkg_id; + + if text_lockfile_version == Version::V0 { + return; + } + + let res = &pkg_resolutions[pkg_id as usize]; + if res.tag == ResolutionTag::Workspace && workspace_links.links(dep, pkg_id) { + link_dep_to_workspace(dep, res); + } +} + +struct WorkspaceLinks<'a> { + pkg_names: &'a [String], + pkg_name_hashes: &'a [PackageNameHash], + workspace_versions: &'a VersionHashMap, + catalogs: &'a CatalogMap, + overrides: &'a OverrideMap, + string_buf: &'a [u8], + link_workspace_packages: bool, +} + +impl WorkspaceLinks<'_> { + /// Whether `Package::parse_dependency` links `dep` to the workspace `bun.lock` + /// bound it to, so that `Diff::generate` sees the loaded and the parsed + /// dependency as equal. With `linkWorkspacePackages` off it links no range, and + /// a range still bound to a workspace is loaded as linked on purpose: the diff + /// re-resolves it. Peers, overridden entries and dist-tags bind to a workspace + /// either way. + fn links(&self, dep: &Dependency, workspace_pkg_id: PackageID) -> bool { + if dep.version.tag == DependencyVersionTag::Workspace { + return true; + } + + let range = if self.link_workspace_packages { + &dep.version + } else { + if dep.behavior.is_peer() || self.overridden(dep) { + return false; + } + self.catalogs.resolve_range(self.string_buf, dep) + }; + if range.tag != DependencyVersionTag::Npm { + return false; + } + + let npm = range.npm(); + let workspace_pkg = workspace_pkg_id as usize; + // `workspace_versions` and a peer's binding are keyed by name hash; the + // names themselves have to match as well. + if !npm.name.eql( + self.pkg_names[workspace_pkg], + self.string_buf, + self.string_buf, + ) { + return false; + } + + !self.link_workspace_packages + || self + .workspace_versions + .get(&self.pkg_name_hashes[workspace_pkg]) + .is_some_and(|workspace_version| { + npm.version + .satisfies(*workspace_version, self.string_buf, self.string_buf) + }) + } + + /// Same exemption as `enqueue_dependency_with_main_and_success_fn`: an `npm:` + /// alias is never overridden. + fn overridden(&self, dep: &Dependency) -> bool { + let is_alias = dep.version.tag == DependencyVersionTag::Npm && dep.version.npm().is_alias; + !is_alias && self.overrides.has_rule_for_name(dep.name_hash) + } +} + +fn link_dep_to_workspace(dep: &mut Dependency, res: &Resolution) { + // Whole-struct assign so `DependencyVersion::Drop` frees any prior npm chain. + let literal = dep.version.literal; + dep.version = DependencyVersion { + tag: DependencyVersionTag::Workspace, + literal, + value: DependencyVersionValue { + workspace: *res.workspace(), + }, + }; +} + fn dependency_resolution_failure( dep: &Dependency, pkg_path: Option<&[u8]>, diff --git a/test/cli/install/__snapshots__/bun-workspaces.test.ts.snap b/test/cli/install/__snapshots__/bun-workspaces.test.ts.snap index ac3d396feaab..1122399f42be 100644 --- a/test/cli/install/__snapshots__/bun-workspaces.test.ts.snap +++ b/test/cli/install/__snapshots__/bun-workspaces.test.ts.snap @@ -50,7 +50,10 @@ exports[`dependency on workspace without version in package.json: version: * 1`] { "name": "no-deps", "literal": "*", - "workspace": "packages/mono", + "npm": { + "name": "no-deps", + "version": ">=0.0.0" + }, "package_id": 2, "behavior": { "prod": true @@ -173,7 +176,10 @@ exports[`dependency on workspace without version in package.json: version: *.*.* { "name": "no-deps", "literal": "*.*.*", - "workspace": "packages/mono", + "npm": { + "name": "no-deps", + "version": ">=0.0.0" + }, "package_id": 2, "behavior": { "prod": true @@ -296,7 +302,10 @@ exports[`dependency on workspace without version in package.json: version: =* 1` { "name": "no-deps", "literal": "=*", - "workspace": "packages/mono", + "npm": { + "name": "no-deps", + "version": ">=0.0.0" + }, "package_id": 2, "behavior": { "prod": true @@ -419,7 +428,10 @@ exports[`dependency on workspace without version in package.json: version: kjwoe { "name": "no-deps", "literal": "kjwoehcojrgjoj", - "workspace": "packages/mono", + "dist_tag": { + "name": "no-deps", + "tag": "no-deps" + }, "package_id": 2, "behavior": { "prod": true @@ -542,7 +554,10 @@ exports[`dependency on workspace without version in package.json: version: *.1.* { "name": "no-deps", "literal": "*.1.*", - "workspace": "packages/mono", + "npm": { + "name": "no-deps", + "version": ">=0.0.0" + }, "package_id": 2, "behavior": { "prod": true @@ -665,7 +680,10 @@ exports[`dependency on workspace without version in package.json: version: *-pre { "name": "no-deps", "literal": "*-pre", - "workspace": "packages/mono", + "npm": { + "name": "no-deps", + "version": ">=0.0.0" + }, "package_id": 2, "behavior": { "prod": true diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 48903d67ec9f..562e1ded1f3e 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -2512,6 +2512,350 @@ test("matching workspace devDependency and npm peerDependency", async () => { expect(out).toContain("no changes"); }); +// bun.lock stores a workspace's dependencies as the specifiers written in its package.json, so +// loading it has to rebuild exactly the dependencies parsing that package.json produces: on the +// next install the two are diffed, and any dependency that differs makes the install report the +// workspace as updated and re-resolve it, on every install, with nothing changed on disk. +// +// Parsing makes a dependency a workspace dependency (version = the workspace's path) only when its +// specifier links the workspace: `workspace:` or an npm range the workspace's version satisfies. +// Anything else that ends up resolved to the workspace keeps its parsed version. Every entry is +// classified on its own, including a name listed in more than one dependency group. +// +// With linkWorkspacePackages off, parsing links no range, so a range (written out or through a +// catalog) that bun.lock still binds to a workspace was linked while the setting was on: it keeps +// loading as linked and the difference gets it re-resolved. Peers, overridden entries, catalog +// entries holding `workspace:` and dist-tags the registry does not have are bound to the workspace +// either way and load as parsed. +describe("dependencies on a workspace load from bun.lock the way package.json parses them", () => { + const linked = "workspace packages/pkg2"; + + // setupTest's bunfig.toml, plus linkWorkspacePackages = false + async function disableLinking(packageDir: string) { + await write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: join(packageDir, ".bun-cache"), + registry: verdaccio.registryUrl(), + linker: "hoisted", + linkWorkspacePackages: false, + }, + }), + ); + } + + // One line per dependency on `depName` declared by `pkgName`, in lockfile order (dev, optional, + // prod, peer): the groups it is declared in, the specifier, and what it was loaded as. Every one + // of them must also resolve to the workspace package. + function loadedDependencies(packageDir: string, pkgName: string, depName: string): string[] { + const lockfile = parseLockfile(packageDir); + const pkg = lockfile.packages.find((p: any) => p.name === pkgName); + const workspacePkg = lockfile.packages.find((p: any) => p.resolution.value === "workspace:packages/pkg2"); + return pkg.dependencies + .map((id: number) => lockfile.dependencies[id]) + .filter((dep: any) => dep.name === depName) + .map((dep: any) => { + expect(dep.package_id).toBe(workspacePkg.id); + const loadedAs = + "workspace" in dep + ? `workspace ${dep.workspace}` + : "npm" in dep + ? "npm" + : "catalog" in dep + ? "catalog" + : "dist_tag" in dep + ? "dist-tag" + : JSON.stringify(dep); + return `${Object.keys(dep.behavior).join("+")} ${JSON.stringify(dep.literal)} -> ${loadedAs}`; + }); + } + + const cases: { + label: string; + /** dependency groups of packages/pkg1 */ + pkg1: Record>; + /** packages/pkg2 is `no-deps@1.0.0` unless given */ + pkg2?: Record; + /** root `workspaces.catalog` */ + catalog?: Record; + /** root `overrides` */ + overrides?: Record; + linkWorkspacePackages?: false; + /** the name pkg1 declares the dependency under, when not `no-deps` itself */ + declaredAs?: string; + loaded: string[]; + }[] = [ + { + label: "workspace:* in dependencies and devDependencies", + pkg1: { dependencies: { "no-deps": "workspace:*" }, devDependencies: { "no-deps": "workspace:*" } }, + loaded: [`dev "workspace:*" -> ${linked}`, `prod "workspace:*" -> ${linked}`], + }, + { + label: "workspace:* in dependencies and peerDependencies", + pkg1: { dependencies: { "no-deps": "workspace:*" }, peerDependencies: { "no-deps": "workspace:*" } }, + loaded: [`prod "workspace:*" -> ${linked}`, `peer "workspace:*" -> ${linked}`], + }, + { + label: "workspace:* in dependencies and optionalDependencies", + pkg1: { dependencies: { "no-deps": "workspace:*" }, optionalDependencies: { "no-deps": "workspace:*" } }, + loaded: [`optional "workspace:*" -> ${linked}`, `prod "workspace:*" -> ${linked}`], + }, + { + label: "workspace:* in dependencies, devDependencies and peerDependencies", + pkg1: { + dependencies: { "no-deps": "workspace:*" }, + devDependencies: { "no-deps": "workspace:*" }, + peerDependencies: { "no-deps": "workspace:*" }, + }, + loaded: [`dev "workspace:*" -> ${linked}`, `prod "workspace:*" -> ${linked}`, `peer "workspace:*" -> ${linked}`], + }, + { + label: "a satisfied range in dependencies and devDependencies", + pkg1: { dependencies: { "no-deps": "^1.0.0" }, devDependencies: { "no-deps": "^1.0.0" } }, + loaded: [`dev "^1.0.0" -> ${linked}`, `prod "^1.0.0" -> ${linked}`], + }, + { + // the range names the workspace, the entry does not + label: "a satisfied range aliased in dependencies and devDependencies", + pkg1: { + dependencies: { "deps-alias": "npm:no-deps@^1.0.0" }, + devDependencies: { "deps-alias": "npm:no-deps@^1.0.0" }, + }, + declaredAs: "deps-alias", + loaded: [`dev "npm:no-deps@^1.0.0" -> ${linked}`, `prod "npm:no-deps@^1.0.0" -> ${linked}`], + }, + { + label: "workspace:* in devDependencies and a satisfied range in peerDependencies", + pkg1: { devDependencies: { "no-deps": "workspace:*" }, peerDependencies: { "no-deps": "^1.0.0" } }, + loaded: [`dev "workspace:*" -> ${linked}`, `peer "^1.0.0" -> ${linked}`], + }, + { + label: "workspace:* in devDependencies and an unsatisfied range in peerDependencies", + pkg1: { devDependencies: { "no-deps": "workspace:*" }, peerDependencies: { "no-deps": "2.0.0" } }, + loaded: [`dev "workspace:*" -> ${linked}`, `peer "2.0.0" -> npm`], + }, + { + label: "an unsatisfied range in peerDependencies", + pkg1: { peerDependencies: { "no-deps": "2.0.0" } }, + loaded: [`peer "2.0.0" -> npm`], + }, + { + label: "a range on a workspace without a version", + pkg1: { dependencies: { "no-deps": "*" } }, + pkg2: { name: "no-deps" }, + loaded: [`prod "*" -> npm`], + }, + { + label: "a catalog entry pointing at the workspace", + pkg1: { dependencies: { "no-deps": "catalog:" } }, + catalog: { "no-deps": "workspace:*" }, + loaded: [`prod "catalog:" -> catalog`], + }, + { + // the registry has no such tag, so the resolver links the workspace + label: "a dist-tag the registry does not have", + pkg1: { dependencies: { "no-deps": "no-such-tag" } }, + loaded: [`prod "no-such-tag" -> dist-tag`], + }, + { + label: "an override sends an unsatisfied range to the workspace", + pkg1: { dependencies: { "no-deps": "^5.0.0" } }, + overrides: { "no-deps": "workspace:*" }, + loaded: [`prod "^5.0.0" -> npm`], + }, + { + label: "linkWorkspacePackages off: workspace:* in dependencies and devDependencies", + pkg1: { dependencies: { "no-deps": "workspace:*" }, devDependencies: { "no-deps": "workspace:*" } }, + linkWorkspacePackages: false, + loaded: [`dev "workspace:*" -> ${linked}`, `prod "workspace:*" -> ${linked}`], + }, + { + label: "linkWorkspacePackages off: an unsatisfied range in peerDependencies", + pkg1: { peerDependencies: { "no-deps": "2.0.0" } }, + linkWorkspacePackages: false, + loaded: [`peer "2.0.0" -> npm`], + }, + { + label: "linkWorkspacePackages off: workspace:* in devDependencies and an unsatisfied range in peerDependencies", + pkg1: { devDependencies: { "no-deps": "workspace:*" }, peerDependencies: { "no-deps": "2.0.0" } }, + linkWorkspacePackages: false, + loaded: [`dev "workspace:*" -> ${linked}`, `peer "2.0.0" -> npm`], + }, + { + label: "linkWorkspacePackages off: a catalog entry pointing at the workspace", + pkg1: { dependencies: { "no-deps": "catalog:" } }, + catalog: { "no-deps": "workspace:*" }, + linkWorkspacePackages: false, + loaded: [`prod "catalog:" -> catalog`], + }, + { + label: "linkWorkspacePackages off: a dist-tag the registry does not have", + pkg1: { dependencies: { "no-deps": "no-such-tag" } }, + linkWorkspacePackages: false, + loaded: [`prod "no-such-tag" -> dist-tag`], + }, + { + label: "linkWorkspacePackages off: an override sends an unsatisfied range to the workspace", + pkg1: { dependencies: { "no-deps": "^5.0.0" } }, + overrides: { "no-deps": "workspace:*" }, + linkWorkspacePackages: false, + loaded: [`prod "^5.0.0" -> npm`], + }, + ]; + + for (const { + label, + pkg1, + pkg2 = { name: "no-deps", version: "1.0.0" }, + catalog, + overrides, + linkWorkspacePackages, + declaredAs = "no-deps", + loaded, + } of cases) { + test.concurrent(label, async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + if (linkWorkspacePackages === false) await disableLinking(packageDir); + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: catalog ? { packages: ["packages/*"], catalog } : ["packages/*"], + overrides, + }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", version: "1.0.0", ...pkg1 }), + ), + write(join(packageDir, "packages", "pkg2", "package.json"), JSON.stringify(pkg2)), + ]); + + await runBunInstall(env, packageDir); + // parseLockfile loads with this test process's settings (linking on); for the linking-off rows the + // install below, which reads the fixture's bunfig.toml, is what exercises that setting. + expect(loadedDependencies(packageDir, "pkg1", declaredAs)).toEqual(loaded); + + // nothing changed, so the second install must not report pkg1 as changed + // ("Workspace package "packages/pkg1" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies") + const { err } = await runBunInstall(env, packageDir, { savesLockfile: false, verbose: true }); + expect(err).not.toContain('Workspace package "packages/pkg1"'); + }); + } + + test.concurrent("the root lists a workspace in dependencies and devDependencies", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: ["packages/*"], + dependencies: { "no-deps": "workspace:*" }, + devDependencies: { "no-deps": "workspace:*" }, + }), + ), + write( + join(packageDir, "packages", "pkg2", "package.json"), + JSON.stringify({ name: "no-deps", version: "1.0.0" }), + ), + ]); + + // the root warns "Duplicate dependency" and keeps both entries, next to the entry every + // workspace gets + const { err } = await runBunInstall(env, packageDir, { allowWarnings: true }); + expect(err).toContain('Duplicate dependency: "no-deps"'); + expect(loadedDependencies(packageDir, "foo", "no-deps")).toEqual([ + `workspace "" -> ${linked}`, + `dev "workspace:*" -> ${linked}`, + `prod "workspace:*" -> ${linked}`, + ]); + + // (the root has no per-package "has added ..." message; what it loads as, above, is what gets diffed) + await runBunInstall(env, packageDir, { allowWarnings: true, savesLockfile: false }); + }); + + // The range is checked against the version bun.lock recorded, so a workspace that moved out of + // range is still noticed and the dependency re-resolved. + test.concurrent("a workspace bumped out of a range is re-resolved", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + const pkg2Json = join(packageDir, "packages", "pkg2", "package.json"); + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", workspaces: ["packages/*"] })), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", version: "1.0.0", dependencies: { "no-deps": "^1.0.0" } }), + ), + write(pkg2Json, JSON.stringify({ name: "no-deps", version: "1.0.0" })), + ]); + + await runBunInstall(env, packageDir); + expect(loadedDependencies(packageDir, "pkg1", "no-deps")).toEqual([`prod "^1.0.0" -> ${linked}`]); + + await write(pkg2Json, JSON.stringify({ name: "no-deps", version: "3.0.0" })); + const { err } = await runBunInstall(env, packageDir, { verbose: true }); + expect(err).toContain( + 'Workspace package "packages/pkg1" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies', + ); + + // ^1.0.0 now comes from the registry + expect(pkg1Resolution(packageDir)).toMatchObject({ tag: "npm", value: "1.1.0" }); + }); + + // A range the lockfile linked keeps loading as linked after linkWorkspacePackages is turned off; + // package.json no longer parses it as linked, and that difference is what gets it re-resolved + // against the registry. + for (const [spec, root, loadedWhileLinking] of [ + ["^1.0.0", {}, `prod "^1.0.0" -> ${linked}`], + // a catalog entry stays a catalog entry while linking; the range behind it is what is stale + ["catalog:", { catalog: { "no-deps": "^1.0.0" } }, `prod "catalog:" -> catalog`], + ] as const) { + test.concurrent(`a linked range (${spec}) is re-resolved after linkWorkspacePackages is turned off`, async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", workspaces: { packages: ["packages/*"], ...root } })), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", version: "1.0.0", dependencies: { "no-deps": spec } }), + ), + write( + join(packageDir, "packages", "pkg2", "package.json"), + JSON.stringify({ name: "no-deps", version: "1.0.0" }), + ), + ]); + + await runBunInstall(env, packageDir); + expect(loadedDependencies(packageDir, "pkg1", "no-deps")).toEqual([loadedWhileLinking]); + + await disableLinking(packageDir); + const { err } = await runBunInstall(env, packageDir, { verbose: true }); + expect(err).toContain( + 'Workspace package "packages/pkg1" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies', + ); + expect(pkg1Resolution(packageDir)).toMatchObject({ tag: "npm", value: "1.1.0" }); + + // and only once + const again = await runBunInstall(env, packageDir, { savesLockfile: false, verbose: true }); + expect(again.err).not.toContain('Workspace package "packages/pkg1"'); + }); + } + + /** what pkg1's only dependency resolves to */ + function pkg1Resolution(packageDir: string) { + const lockfile = parseLockfile(packageDir); + const pkg1 = lockfile.packages.find((p: any) => p.name === "pkg1"); + expect(pkg1.dependencies).toHaveLength(1); + const dep = lockfile.dependencies[pkg1.dependencies[0]]; + return lockfile.packages.find((p: any) => p.id === dep.package_id).resolution; + } +}); + // While linking, the hoisted installer formats each package's version label (its // version, or for tarball/folder/git packages the spec it was resolved from) into a // 512 byte stack buffer. Labels longer than that used to abort the whole install. From 1bd4db67a4bdaf0019f65c669a8b888f81352bbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:03:57 +0000 Subject: [PATCH 004/258] install: keep a peer nothing in bun.lock satisfies where the file records it (#38892) --- src/install/lockfile/bun.lock.rs | 37 +-- test/cli/install/bun-lock.test.ts | 238 ++++++++++++++++++ .../install/migration/pnpm-lock-v9.test.ts | 185 ++++++++++++++ 3 files changed, 431 insertions(+), 29 deletions(-) diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 602670fc2770..086bf9b53d2f 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -3333,13 +3333,9 @@ fn deferred_peer_range<'a>( /// `install_peer`): scan the package ids recorded for the dependency's /// name — `package_index` lists are kept ordered by descending /// `Resolution::order` — and take the first whose resolution satisfies -/// the range. When nothing satisfies, fall back to the highest-ordered -/// candidate, and only when it is the same kind as the dependency (the -/// "incorrect peer dependency" case; the fresh resolver inspects only -/// `list[0]` there, and reproducing its choice exactly is the point of -/// this helper). Returns `None` when no package with the name exists -/// or the fallback is a different kind; the caller then falls back to -/// the path walk. Edges `deferred_peer_range` rejects also return `None`. +/// the range. Returns `None` when no candidate satisfies it (the tree the +/// caller falls back to is the only record of the resolver's "incorrect +/// peer dependency" pick) and for the edges `deferred_peer_range` rejects. /// /// Peer edges cannot be resolved from the printed tree the way regular /// edges are: a peer never materializes its own `node_modules` path when @@ -3408,28 +3404,11 @@ pub(crate) fn resolve_peer_dep_version_based( } let candidates = package_index.get(&name_hash)?.as_slice(); - for &id in candidates { - if (id as usize) < pkg_resolutions.len() - && pkg_resolutions[id as usize] - .satisfies_dependency_version(range, string_buf, string_buf) - { - return Some(id); - } - } - - let &first = candidates.first()?; - if (first as usize) < pkg_resolutions.len() { - let res_tag = pkg_resolutions[first as usize].tag; - let ver_tag = range.tag; - if (res_tag == ResolutionTag::Npm && ver_tag == DependencyVersionTag::Npm) - || (res_tag == ResolutionTag::Git && ver_tag == DependencyVersionTag::Git) - || (res_tag == ResolutionTag::Github && ver_tag == DependencyVersionTag::Github) - { - return Some(first); - } - } - - None + candidates.iter().copied().find(|&id| { + pkg_resolutions + .get(id as usize) + .is_some_and(|res| res.satisfies_dependency_version(range, string_buf, string_buf)) + }) } // Taking `&mut BinaryLockfile` plus a `&mut Dependency` that diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index c8ba27d17e5f..f675ac2c5710 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1573,6 +1573,244 @@ it.each([ await run(["install", "--frozen-lockfile"]); }); +// When no version in the lockfile satisfies a required peer's range, the resolver binds the +// edge to whichever version it saw first and later installs keep that binding, so the only +// record of it is the tree: the version is printed next to the dependent when it conflicts +// with the version hoisted above, and otherwise the edge was deduped onto the hoisted one. +// Loading has to read that record. Picking the highest version in the file instead moved +// the edge whenever the file held another out-of-range version, and the re-save then +// printed a different tree: the recorded copy dropped, or a new nested copy added. These +// shapes are what a lockfile looks like once the package that provided the version the +// peer was first bound to has left the project. The record only holds while nothing in the +// file satisfies the range: once a satisfying version enters the file, loading binds the peer +// to it by version (as a fresh install would) and the next save drops the recorded copy. +describe("loading bun.lock keeps a peer nothing in the file satisfies where the file records it", () => { + const pkg = (nameAndVersion: string, info: object = {}) => [nameAndVersion, "", info, ""]; + const oneDep = pkg("one-dep@1.0.0", { dependencies: { "no-deps": "1.0.1" } }); + const strictPeerDep = pkg("strict-peer-dep@1.0.0", { peerDependencies: { "no-deps": "^2.0.0" } }); + + type Shape = { + root: Record; + workspaces?: Record>; + packages: Record; + saved: string[]; + absent?: string[]; + /** Set when the shape names packages the test registry does not have, so only `--lockfile-only` can run. */ + unpublished?: true; + }; + + const shapes: [string, Shape][] = [ + [ + "a package's peer on the copy printed next to it", + { + root: { dependencies: { "one-dep": "1.0.0", "strict-peer-dep": "1.0.0" } }, + packages: { + "no-deps": pkg("no-deps@1.0.1"), + "one-dep": oneDep, + "strict-peer-dep": strictPeerDep, + "strict-peer-dep/no-deps": pkg("no-deps@1.0.0"), + }, + saved: ['"no-deps": ["no-deps@1.0.1"', '"strict-peer-dep/no-deps": ["no-deps@1.0.0"'], + }, + ], + [ + "a package's peer on the copy hoisted above it", + { + // one-dep is a devDependency so that its no-deps@1.0.1 is hoisted first and holds the + // root slot; the higher 1.1.0 is nested, and the peer was deduped onto the root copy. + root: { + devDependencies: { "one-dep": "1.0.0" }, + dependencies: { "normal-dep-and-dev-dep": "1.0.1", "strict-peer-dep": "1.0.0" }, + }, + packages: { + "no-deps": pkg("no-deps@1.0.1"), + "normal-dep-and-dev-dep": pkg("normal-dep-and-dev-dep@1.0.1", { dependencies: { "no-deps": "1.1.0" } }), + "normal-dep-and-dev-dep/no-deps": pkg("no-deps@1.1.0"), + "one-dep": oneDep, + "strict-peer-dep": strictPeerDep, + }, + saved: ['"no-deps": ["no-deps@1.0.1"', '"normal-dep-and-dev-dep/no-deps": ["no-deps@1.1.0"'], + absent: ['"strict-peer-dep/no-deps"'], + }, + ], + [ + "a workspace's peer on the copy printed next to it", + { + // workspace `a` is hoisted before `w`, so its no-deps holds the root slot + root: { workspaces: ["packages/*"] }, + workspaces: { + "packages/a": { name: "a", version: "1.0.0", dependencies: { "no-deps": "1.0.1" } }, + "packages/w": { name: "w", version: "1.0.0", peerDependencies: { "no-deps": "^2.0.0" } }, + }, + packages: { + "a": ["a@workspace:packages/a"], + "w": ["w@workspace:packages/w"], + "no-deps": pkg("no-deps@1.0.1"), + "w/no-deps": pkg("no-deps@1.0.0"), + }, + saved: ['"no-deps": ["no-deps@1.0.1"', '"w/no-deps": ["no-deps@1.0.0"'], + }, + ], + [ + "the root's own peer on the copy at the root", + { + root: { dependencies: { "one-dep": "1.0.0" }, peerDependencies: { "no-deps": "^2.0.0" } }, + packages: { + "no-deps": pkg("no-deps@1.0.0"), + "one-dep": oneDep, + "one-dep/no-deps": pkg("no-deps@1.0.1"), + }, + saved: ['"no-deps": ["no-deps@1.0.0"', '"one-dep/no-deps": ["no-deps@1.0.1"'], + }, + ], + [ + "a package printed at two paths on the copy printed next to its last path", + { + // dup@1.0.0 is printed under both parents because the root holds dup@2.0.0. Its peer's + // copy is printed under z-parent/dup only: under a-parent/dup it was deduped onto the + // root's own no-deps, which root dependencies do regardless of range. The loader binds + // the package's edges once per printed path and the last path wins, so the record is + // read back here because z-parent sorts after a-parent; were the parents named the + // other way round, the root's copy would win and the next save would rewrite the entry to it. + root: { + dependencies: { "a-parent": "1.0.0", "dup": "2.0.0", "no-deps": "1.0.1", "z-parent": "1.0.0" }, + }, + packages: { + "a-parent": pkg("a-parent@1.0.0", { dependencies: { dup: "1.0.0" } }), + "dup": pkg("dup@2.0.0"), + "no-deps": pkg("no-deps@1.0.1"), + "z-parent": pkg("z-parent@1.0.0", { dependencies: { "dup": "1.0.0", "no-deps": "1.1.0" } }), + "a-parent/dup": pkg("dup@1.0.0", { peerDependencies: { "no-deps": "^2.0.0" } }), + "z-parent/dup": pkg("dup@1.0.0", { peerDependencies: { "no-deps": "^2.0.0" } }), + "z-parent/no-deps": pkg("no-deps@1.1.0"), + "z-parent/dup/no-deps": pkg("no-deps@1.0.0"), + }, + saved: ['"z-parent/dup/no-deps": ["no-deps@1.0.0"'], + absent: ['"a-parent/dup/no-deps"'], + unpublished: true, + }, + ], + ]; + + async function writeProject(packageDir: string, shape: Pick) { + await write(join(packageDir, "package.json"), JSON.stringify({ name: "foo", ...shape.root })); + for (const [path, manifest] of Object.entries(shape.workspaces ?? {})) { + await write(join(packageDir, path, "package.json"), JSON.stringify(manifest)); + } + await write( + join(packageDir, "bun.lock"), + JSON.stringify({ + lockfileVersion: 1, + configVersion: 0, + workspaces: { "": { name: "foo", ...shape.root, workspaces: undefined }, ...shape.workspaces }, + packages: shape.packages, + }), + ); + } + + // Re-saves the shape once, checks the entries it must and must not print, and checks that + // the result is a fixed point: a further re-save leaves it alone and a frozen install accepts it. + async function resave(shape: Shape) { + const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } }); + const run = makeInstallRunner(packageDir); + await writeProject(packageDir, shape); + + await run(["install", "--lockfile-only"]); + const saved = await file(join(packageDir, "bun.lock")).text(); + for (const entry of shape.saved) { + expect(saved).toContain(entry); + } + for (const entry of shape.absent ?? []) { + expect(saved).not.toContain(entry); + } + + await run(["install", "--lockfile-only"]); + if (!shape.unpublished) await run(["install", "--frozen-lockfile"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(saved); + } + + it.each(shapes)("re-saving keeps %s", (_, shape) => resave(shape)); + + const [, nestedCopy] = shapes[0]; + + it("re-saving rebinds the peer once a version satisfying its range enters the file", () => + resave({ + // The first shape after `bun add one-fixed-dep@2.0.0` brought in no-deps@2.0.0: the + // recorded 1.0.0 is no longer the binding, so the re-save replaces it instead of keeping it. + root: { dependencies: { ...(nestedCopy.root.dependencies as object), "one-fixed-dep": "2.0.0" } }, + packages: { + ...nestedCopy.packages, + "one-fixed-dep": pkg("one-fixed-dep@2.0.0", { dependencies: { "no-deps": "2.0.0" } }), + "one-fixed-dep/no-deps": pkg("no-deps@2.0.0"), + }, + saved: [ + '"no-deps": ["no-deps@1.0.1"', + '"one-fixed-dep/no-deps": ["no-deps@2.0.0"', + '"strict-peer-dep/no-deps": ["no-deps@2.0.0"', + ], + absent: ["no-deps@1.0.0"], + })); + + it("a package's peer on the root's own out-of-range copy binds to it, not to the higher version nested elsewhere", async () => { + // Nothing is printed for this edge: either binding dedupes onto the root's copy when the + // tree is built, so the file is the same both ways and both linkers install the root's copy. + // The binding itself is what `pm why` reports (and what a tree built without the root's + // copy, such as `--production` when it is a devDependency, installs next to the dependent). + const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } }); + await writeProject(packageDir, { + root: { dependencies: { "no-deps": "1.0.0", "one-dep": "1.0.0", "strict-peer-dep": "1.0.0" } }, + packages: { + "no-deps": pkg("no-deps@1.0.0"), + "one-dep": oneDep, + "one-dep/no-deps": pkg("no-deps@1.0.1"), + "strict-peer-dep": strictPeerDep, + }, + }); + + const { out } = await makeInstallRunner(packageDir)(["pm", "why", "no-deps"]); + expect(out).toMatchInlineSnapshot(` + "no-deps@1.0.0 + ├─ foo (requires 1.0.0) + └─ peer strict-peer-dep@1.0.0 (requires ^2.0.0) + └─ foo (requires 1.0.0) + + no-deps@1.0.1 + └─ one-dep@1.0.0 (requires 1.0.1) + └─ foo (requires 1.0.0) + + " + `); + }); + + it("the hoisted linker installs the recorded copy next to the dependent", async () => { + const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true, linker: "hoisted" } }); + const run = makeInstallRunner(packageDir); + await writeProject(packageDir, nestedCopy); + + await run(["install", "--frozen-lockfile"]); + expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ + version: "1.0.1", + }); + expect( + await file(join(packageDir, "node_modules", "strict-peer-dep", "node_modules", "no-deps", "package.json")).json(), + ).toMatchObject({ version: "1.0.0" }); + }); + + it("the isolated linker links the dependent against the recorded copy", async () => { + const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true, linker: "isolated" } }); + const run = makeInstallRunner(packageDir); + await writeProject(packageDir, nestedCopy); + + await run(["install", "--frozen-lockfile"]); + const bunDir = join(packageDir, "node_modules", ".bun"); + const entries = (await readdirSorted(bunDir)).filter(entry => entry.startsWith("strict-peer-dep@")); + expect(entries).toHaveLength(1); + expect(await file(join(bunDir, entries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({ + version: "1.0.0", + }); + }); +}); + it("adding a dependency keeps an optional peer on the package bun.lock bound it to while that package stays next to it", async () => { const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } }); const run = makeInstallRunner(packageDir); diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index b943197ac326..549ba0890922 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -79,6 +79,12 @@ const PEER_DEPS_TOO_1_0_0_INTEGRITY = "sha512-sBx0TKrsB8FkRN2lzkDjMuctPGEKn1TmNUBv3dJOtnZM8nd255o5ZAPRpAI2XFLHZAavBlK/e73cZNwnUxlRog=="; const ONE_OPTIONAL_PEER_DEP_1_0_2_INTEGRITY = "sha512-S25U8/QXGIKfn/AWtsce1aVMnDjDL+ykFtAufpsuKGad32NlsCpi9TDuXvzoTQ+MdaZpGV3c4xghUZUsNeMp4A=="; +const STRICT_PEER_DEP_1_0_0_INTEGRITY = + "sha512-bz2RC/Fp4Nvc9aIiHB6Szko9m6sxNy/clIHnTAGeD9VSpQJTvlPAJqJ09lWo7N3q4JNLEqDTf3Mn+zNUsYOKWQ=="; +const NO_DEPS_1_1_0_INTEGRITY = + "sha512-ebG2pipYAKINcNI3YxdsiAgFvNGp2gdRwxAKN2LYBm9+YxuH/lHH2sl+GKQTuGiNfCfNZRMHUyyLPEJD6HWm7w=="; +const NORMAL_DEP_AND_DEV_DEP_1_0_1_INTEGRITY = + "sha512-MzZS9lLNBdqXf/lI+TKlXGeWrcDkOjzdSPJtvkRUN1FUjXg2DcGVldEqx9D8kNwF87Hxf2cRLvLv4a8GqZ6zPg=="; const LOCAL_TARBALL_INTEGRITY = "sha512-HP/5Rgt3pVFLzjmN9qJJ6vZMgCwoCIl/m2bPndYT283CUqnmFiMx0GeeIJ7SyK6TYoJM78SEvFEOQie++caHqw=="; @@ -1471,6 +1477,185 @@ snapshots: `); }); + test("a peer nothing in the lockfile satisfies keeps the version pnpm resolved it to", async () => { + // strict-peer-dep wants no-deps@^2.0.0; pnpm resolved it to 1.0.0 (the snapshot suffix) while + // one-dep brings in 1.0.1. Neither satisfies the range, so there is nothing to rebind the + // peer by; binding it to the highest version present would drop pnpm's 1.0.0 from the tree. + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "unsatisfied-peer", + dependencies: { "one-dep": "1.0.0", "strict-peer-dep": "1.0.0" }, + }), + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + +importers: + + .: + dependencies: + one-dep: + specifier: 1.0.0 + version: 1.0.0 + strict-peer-dep: + specifier: 1.0.0 + version: 1.0.0(no-deps@1.0.0) + +packages: + + no-deps@1.0.0: + resolution: {integrity: ${NO_DEPS_1_0_0_INTEGRITY}} + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + + one-dep@1.0.0: + resolution: {integrity: ${ONE_DEP_1_0_0_INTEGRITY}} + + strict-peer-dep@1.0.0: + resolution: {integrity: ${STRICT_PEER_DEP_1_0_0_INTEGRITY}} + peerDependencies: + no-deps: ^2.0.0 + +snapshots: + + no-deps@1.0.0: {} + + no-deps@1.0.1: {} + + one-dep@1.0.0: + dependencies: + no-deps: 1.0.1 + + strict-peer-dep@1.0.0(no-deps@1.0.0): + dependencies: + no-deps: 1.0.0 +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const bunLock = await bunLockOf(packageDir); + expect(bunLock).toContain(`"no-deps": ["no-deps@1.0.1"`); + expect(bunLock).toContain(`"strict-peer-dep/no-deps": ["no-deps@1.0.0"`); + + const install = await run(packageDir, "install", "--frozen-lockfile"); + + expect(install.stderr).not.toContain("error:"); + expect(install.exitCode).toBe(0); + expect(nodeModulesPackages(packageDir)).toMatchInlineSnapshot(` + "node_modules/no-deps/no-deps@1.0.1 + node_modules/one-dep/one-dep@1.0.0 + node_modules/strict-peer-dep/node_modules/no-deps/no-deps@1.0.0 + node_modules/strict-peer-dep/strict-peer-dep@1.0.0" + `); + }); + + test("a peer nothing in the lockfile satisfies and pnpm left unmet is not given a copy of the highest version", async () => { + // pnpm recorded no resolution for strict-peer-dep's no-deps@^2.0.0 (no snapshot suffix), + // so in pnpm's layout the package sees whatever no-deps sits at the root: 1.0.1, hoisted + // there from one-dep. Binding the peer to the highest version present instead (1.1.0, + // nested under normal-dep-and-dev-dep) would add a strict-peer-dep/no-deps copy to the + // migrated tree that pnpm's tree does not have. Left unbound, the peer is bound to the + // root copy when the migrated bun.lock is loaded, like any other peer the file prints + // nothing for. + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "unmet-peer", + // one-dep is a devDependency so that its no-deps@1.0.1 takes the root slot. + dependencies: { "normal-dep-and-dev-dep": "1.0.1", "strict-peer-dep": "1.0.0" }, + devDependencies: { "one-dep": "1.0.0" }, + }), + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + +importers: + + .: + dependencies: + normal-dep-and-dev-dep: + specifier: 1.0.1 + version: 1.0.1 + strict-peer-dep: + specifier: 1.0.0 + version: 1.0.0 + devDependencies: + one-dep: + specifier: 1.0.0 + version: 1.0.0 + +packages: + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + + no-deps@1.1.0: + resolution: {integrity: ${NO_DEPS_1_1_0_INTEGRITY}} + + normal-dep-and-dev-dep@1.0.1: + resolution: {integrity: ${NORMAL_DEP_AND_DEV_DEP_1_0_1_INTEGRITY}} + + one-dep@1.0.0: + resolution: {integrity: ${ONE_DEP_1_0_0_INTEGRITY}} + + strict-peer-dep@1.0.0: + resolution: {integrity: ${STRICT_PEER_DEP_1_0_0_INTEGRITY}} + peerDependencies: + no-deps: ^2.0.0 + +snapshots: + + no-deps@1.0.1: {} + + no-deps@1.1.0: {} + + normal-dep-and-dev-dep@1.0.1: + dependencies: + no-deps: 1.1.0 + + one-dep@1.0.0: + dependencies: + no-deps: 1.0.1 + + strict-peer-dep@1.0.0: {} +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const bunLock = await bunLockOf(packageDir); + expect(bunLock).toContain(`"no-deps": ["no-deps@1.0.1"`); + expect(bunLock).toContain(`"normal-dep-and-dev-dep/no-deps": ["no-deps@1.1.0"`); + expect(bunLock).not.toContain(`"strict-peer-dep/no-deps"`); + + const install = await run(packageDir, "install", "--frozen-lockfile"); + + expect(install.stderr).not.toContain("error:"); + expect(install.exitCode).toBe(0); + expect(nodeModulesPackages(packageDir)).toMatchInlineSnapshot(` + "node_modules/no-deps/no-deps@1.0.1 + node_modules/normal-dep-and-dev-dep/node_modules/no-deps/no-deps@1.1.0 + node_modules/normal-dep-and-dev-dep/normal-dep-and-dev-dep@1.0.1 + node_modules/one-dep/one-dep@1.0.0 + node_modules/strict-peer-dep/strict-peer-dep@1.0.0" + `); + }); + // pnpm11/lockfile/fs convertToLockfileObject: every variant joins packages[removeSuffix(key)] const peerVariantPackageJsons = { "package.json": JSON.stringify({ name: "v9-peer-variants", workspaces: ["apps/*"] }), From aecc608bdcf353f9a3053dea3245f19aa402b339 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:00 +0000 Subject: [PATCH 005/258] install: re-resolve a peer row instead of binding it to a stale bun.lock leftover (#38901) --- .../PackageManager/PackageManagerEnqueue.rs | 238 ++++++++-------- .../cli/install/bun-update-transitive.test.ts | 264 +++++++++++++++++- 2 files changed, 389 insertions(+), 113 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index a323dc824b42..f38b7acb3cde 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -2298,107 +2298,16 @@ fn get_or_put_resolved_package( success_fn: SuccessFn, ) -> crate::Result> { if install_peer && behavior.is_peer() { - if let Some(index) = this.lockfile.package_index.get(&name_hash) { - let resolutions = this.lockfile.packages.items_resolution(); - match index { - PackageIndexEntry::Id(existing_id) => { - let existing_id = *existing_id; - if (existing_id as usize) < resolutions.len() { - let existing_resolution = resolutions[existing_id as usize]; - if resolution_satisfies_dependency(this, &existing_resolution, version) { - success_fn(this, dependency_id, existing_id); - return Ok(Some(ResolvedPackageResult { - // we must fetch it from the packages array again, incase the package array mutates the value in the `successFn` - package: *this.lockfile.packages.get(existing_id as usize), - ..Default::default() - })); - } - - let res_tag = resolutions[existing_id as usize].tag; - let ver_tag = version.tag; - if (res_tag == ResolutionTag::Npm - && ver_tag == dependency::version::Tag::Npm) - || (res_tag == ResolutionTag::Git - && ver_tag == dependency::version::Tag::Git) - || (res_tag == ResolutionTag::Github - && ver_tag == dependency::version::Tag::Github) - { - let existing_package = this.lockfile.packages.get(existing_id as usize); - this.log_mut().add_warning_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "incorrect peer dependency \"{}@{}\"", - existing_package - .name - .fmt(this.lockfile.buffers.string_bytes.as_slice()), - existing_package.resolution.fmt( - this.lockfile.buffers.string_bytes.as_slice(), - bun_fmt::PathSep::Auto - ), - ), - ); - success_fn(this, dependency_id, existing_id); - return Ok(Some(ResolvedPackageResult { - // we must fetch it from the packages array again, incase the package array mutates the value in the `successFn` - package: *this.lockfile.packages.get(existing_id as usize), - ..Default::default() - })); - } - } - } - PackageIndexEntry::Ids(list) => { - for &existing_id in list.iter() { - if (existing_id as usize) < resolutions.len() { - let existing_resolution = resolutions[existing_id as usize]; - if resolution_satisfies_dependency(this, &existing_resolution, version) - { - success_fn(this, dependency_id, existing_id); - return Ok(Some(ResolvedPackageResult { - package: *this.lockfile.packages.get(existing_id as usize), - ..Default::default() - })); - } - } - } - - if (list[0] as usize) < resolutions.len() { - let res_tag = resolutions[list[0] as usize].tag; - let ver_tag = version.tag; - if (res_tag == ResolutionTag::Npm - && ver_tag == dependency::version::Tag::Npm) - || (res_tag == ResolutionTag::Git - && ver_tag == dependency::version::Tag::Git) - || (res_tag == ResolutionTag::Github - && ver_tag == dependency::version::Tag::Github) - { - let existing_package_id = list[0]; - let existing_package = - this.lockfile.packages.get(existing_package_id as usize); - this.log_mut().add_warning_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "incorrect peer dependency \"{}@{}\"", - existing_package - .name - .fmt(this.lockfile.buffers.string_bytes.as_slice()), - existing_package.resolution.fmt( - this.lockfile.buffers.string_bytes.as_slice(), - bun_fmt::PathSep::Auto - ), - ), - ); - success_fn(this, dependency_id, list[0]); - return Ok(Some(ResolvedPackageResult { - // we must fetch it from the packages array again, incase the package array mutates the value in the `successFn` - package: *this.lockfile.packages.get(existing_package_id as usize), - ..Default::default() - })); - } - } - } - } + if let Some((existing_id, satisfied)) = + existing_peer_target(this, name_hash, version, dependency_id) + { + return Ok(Some(bind_existing_peer( + this, + dependency_id, + existing_id, + satisfied, + success_fn, + ))); } } @@ -2565,13 +2474,28 @@ fn get_or_put_resolved_package( break 'blk Some(result); } - Npm::FindVersionResult::Err(err_type) => match err_type { - Npm::FindVersionError::TooRecent - | Npm::FindVersionError::AllVersionsTooRecent => { - return Err(crate::Error::TooRecentVersion); + Npm::FindVersionResult::Err(err_type) => { + // The leftover `existing_peer_target` passed over is all there is. + if install_peer && behavior.is_peer() { + if let Some(id) = highest_peer_candidate(&this.lockfile, name_hash, version) + { + return Ok(Some(bind_existing_peer( + this, + dependency_id, + id, + false, + success_fn, + ))); + } } - Npm::FindVersionError::NotFound => None, // Handle below with existing logic - }, + match err_type { + Npm::FindVersionError::TooRecent + | Npm::FindVersionError::AllVersionsTooRecent => { + return Err(crate::Error::TooRecentVersion); + } + Npm::FindVersionError::NotFound => None, // Handle below with existing logic + } + } }; let find_result = match find_result_opt { @@ -2922,13 +2846,103 @@ fn locked_version_in_lockfile<'a>( .map(|locked| (locked, buf)) } -fn resolution_satisfies_dependency( +/// The package to bind a deferred peer row to and whether it satisfies the row; the highest-or-nothing fallback is what `resolve_peer_dep_version_based` rebinds to on load. +fn existing_peer_target( this: &PackageManager, - resolution: &Resolution, - dependency: &dependency::Version, + name_hash: PackageNameHash, + version: &dependency::Version, + row: DependencyID, +) -> Option<(PackageID, bool)> { + let lockfile: &Lockfile::Lockfile = &this.lockfile; + let candidates = lockfile.package_index.get(&name_hash)?.as_slice(); + let pkg_res = lockfile.packages.items_resolution(); + let buf = lockfile.buffers.string_bytes.as_slice(); + if let Some(&id) = candidates.iter().find(|&&id| { + (id as usize) < pkg_res.len() + && pkg_res[id as usize].satisfies_dependency_version(version, buf, buf) + }) { + return Some((id, true)); + } + let highest = highest_peer_candidate(lockfile, name_hash, version)?; + (!would_revive_leftover(lockfile, row, highest)).then_some((highest, false)) +} + +/// `package_index` lists the highest version first. +fn highest_peer_candidate( + lockfile: &Lockfile::Lockfile, + name_hash: PackageNameHash, + version: &dependency::Version, +) -> Option { + let &highest = lockfile.package_index.get(&name_hash)?.as_slice().first()?; + let resolution = lockfile.packages.items_resolution().get(highest as usize)?; + let same_kind = matches!( + (resolution.tag, version.tag), + (ResolutionTag::Npm, dependency::version::Tag::Npm) + | (ResolutionTag::Git, dependency::version::Tag::Git) + | (ResolutionTag::Github, dependency::version::Tag::Github) + ); + same_kind.then_some(highest) +} + +fn bind_existing_peer( + this: &mut PackageManager, + dependency_id: DependencyID, + existing_id: PackageID, + satisfied: bool, + success_fn: SuccessFn, +) -> ResolvedPackageResult { + if !satisfied { + let existing_package = this.lockfile.packages.get(existing_id as usize); + this.log_mut().add_warning_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "incorrect peer dependency \"{}@{}\"", + existing_package + .name + .fmt(this.lockfile.buffers.string_bytes.as_slice()), + existing_package.resolution.fmt( + this.lockfile.buffers.string_bytes.as_slice(), + bun_fmt::PathSep::Auto + ), + ), + ); + } + success_fn(this, dependency_id, existing_id); + ResolvedPackageResult { + // we must fetch it from the packages array again, incase the package array mutates the value in the `successFn` + package: *this.lockfile.packages.get(existing_id as usize), + ..Default::default() + } +} + +/// `row` is a root or workspace `peerDependencies` entry and `package_id` is held only by non-root peer rows (usually this entry's own earlier install): nothing provides it, and it is not a root row's copy, which `Tree::hoist_dependency` dedupes every other peer onto regardless of range. +fn would_revive_leftover( + lockfile: &Lockfile::Lockfile, + row: DependencyID, + package_id: PackageID, ) -> bool { - let buf = this.lockfile.buffers.string_bytes.as_slice(); - resolution.satisfies_dependency_version(dependency, buf, buf) + if package_id >= lockfile.loaded_package_count || !lockfile.is_workspace_dependency(row) { + return false; + } + let deps = lockfile.buffers.dependencies.as_slice(); + let resolutions = lockfile.buffers.resolutions.as_slice(); + let mut owned_rows = lockfile + .packages + .items_dependencies() + .iter() + .zip(lockfile.packages.items_resolutions()) + .enumerate() + .flat_map(|(owner, (dep_slice, res_slice))| { + dep_slice + .get(deps) + .iter() + .zip(res_slice.get(resolutions)) + .map(move |(dep, &resolved)| (owner, dep, resolved)) + }); + !owned_rows.any(|(owner, dep, resolved)| { + resolved == package_id && (owner == 0 || !dep.behavior.is_peer()) + }) } /// The first npm package of this name that `version` allows and `accept` takes. diff --git a/test/cli/install/bun-update-transitive.test.ts b/test/cli/install/bun-update-transitive.test.ts index 56857712354e..8f2dd2194caf 100644 --- a/test/cli/install/bun-update-transitive.test.ts +++ b/test/cli/install/bun-update-transitive.test.ts @@ -2345,7 +2345,7 @@ async function stalePeerEntry() { return dir; } -// A root peerDependencies entry is never re-resolved by `bun update`; a pattern still counts it as a match, a group selector does not. +// A root peerDependencies entry whose locked version still satisfies it is never re-resolved by `bun update`; a pattern still counts it as a match, a group selector does not. test.concurrent("a pattern matches a peerDependencies entry", async () => { const dir = await stalePeerEntry(); const packageJsonBefore = await packageJsonText(dir); @@ -2381,6 +2381,225 @@ test.concurrent("`bun update --dev` with only a stale peerDependencies entry has expect(await packageJsonText(dir)).toBe(packageJsonBefore); }); +// Once its range stops accepting the locked version, a peer entry nothing else depends on resolves afresh. The locked +// package used to be bound anyway (it has the entry's name) with an "incorrect peer dependency" warning, and the named +// path then wrote the range back down to it. +test.concurrent("`bun update @` moves a peerDependencies entry nothing else depends on", async () => { + const dir = await stalePeerEntry(); + const { stdout, stderr, exitCode } = await run(dir, "update", "no-deps@2.0.0"); + expectMoved(stdout, "no-deps", "1.0.0", "2.0.0"); + expectCleanStderr(stderr); + expect(await packageJsonOf(dir)).toStrictEqual(withPeer("^1.0.1", "^2.0.0")); + expect((await lock(dir)).workspaces[""].peerDependencies).toStrictEqual({ "no-deps": "^2.0.0" }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); + expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +test.concurrent( + "`bun install` moves a peerDependencies entry nothing else depends on once its range is edited", + async () => { + const dir = await stalePeerEntry(); + await write(join(dir, "package.json"), stringify(withPeer("^1.0.1", "^1.1.0"))); + const stderr = await install(dir); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); + }, +); + +// devDependencies provides no-deps@1.1.0, so the peer entry keeps pointing at it and the warning is the right answer. +test.concurrent("a peerDependencies entry another group provides keeps following that group", async () => { + const provided = (peerRange: string) => ({ + name: "foo", + devDependencies: { "no-deps": "^1.0.0" }, + peerDependencies: { "no-deps": peerRange }, + }); + const dir = await setup({ "package.json": provided("^1.0.0") }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + await write(join(dir, "package.json"), stringify(provided("^2.0.0"))); + const stderr = await install(dir); + expect(stderr).toContain('warn: incorrect peer dependency "no-deps@1.1.0"'); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); +}); + +const PEER_ONLY = (range: string) => ({ name: "foo", peerDependencies: { "no-deps": range } }); + +// no-deps@1.5.0 was never published (a `bun init` project hits this with `bun add typescript@5.0.0`): with nothing to install +// instead, the entry keeps the copy it has and warns as it always did, instead of spinning on or dropping the unresolvable row. +test.concurrent( + "`bun add @` on a peerDependencies entry keeps the installed copy", + async () => { + const dir = await setup({ "package.json": PEER_ONLY("^1.0.0") }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + const { stderr, exitCode } = await run(dir, "add", "no-deps@1.5.0"); + expect(stderr).toContain('warn: incorrect peer dependency "no-deps@1.1.0"'); + expect(stderr).not.toContain("error:"); + expect(await packageJsonOf(dir)).toStrictEqual(PEER_ONLY("1.5.0")); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +// Same when the only release the new range accepts is still inside --minimum-release-age. +test.concurrent( + "a peerDependencies entry whose new range only matches a too recent release keeps the installed copy", + async () => { + using server = await serveRegistry( + { leaf: { "1.0.0": {}, "2.0.0": {} } }, + {}, + { times: { leaf: { "1.0.0": daysAgo(30), "2.0.0": daysAgo(1) } } }, + ); + const entry = (range: string) => ({ name: "foo", peerDependencies: { leaf: range } }); + const dir = await installServed(server, "peer-entry-min-age-", entry("^1.0.0")); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + await write(join(dir, "package.json"), stringify(entry("^2.0.0"))); + const { stderr, exitCode } = await run(dir, "install", "--minimum-release-age", THREE_DAYS_SECONDS); + expect(stderr).toContain('warn: incorrect peer dependency "leaf@1.0.0"'); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); + expect(await installedVersion(dir, "leaf")).toBe("1.0.0"); + await frozen(dir); + expect(exitCode).toBe(0); + }, +); + +// The root's peer entry alone holds no-deps@1.1.0 at the top of node_modules; one-fixed-dep@1.0.0 then nests the no-deps@1.0.0 +// it depends on, so bun.lock carries two copies, the higher of which nothing depends on outright. +const rootPeer = (range: string, dependencies: Json) => + pkgJson(dependencies, { peerDependencies: { "no-deps": range } }); + +async function twoCopies() { + const dir = await setup({ "package.json": rootPeer("^1.0.0", {}) }); + await reinstall(dir, rootPeer("^1.0.0", { "one-fixed-dep": "1.0.0" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + expect(await installedVersion(dir, "one-fixed-dep", "node_modules", "no-deps")).toBe("1.0.0"); + return dir; +} + +// Rewritten past both copies, the entry takes neither: the one that would be bound, the highest, is the one it installed +// itself, and the provided 1.0.0 is a binding loading bun.lock (which binds the highest copy too) would not reproduce. +test.concurrent( + "a rewritten peerDependencies entry replaces the copy it installed rather than binding a lower one", + async () => { + const dir = await twoCopies(); + await write(join(dir, "package.json"), stringify(rootPeer("^2.0.0", { "one-fixed-dep": "1.0.0" }))); + const stderr = await install(dir); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "2.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + expect(await installedVersion(dir, "one-fixed-dep", "node_modules", "no-deps")).toBe("1.0.0"); + await frozen(dir); + }, +); + +// strict-peer-dep@1.0.0's own peer `no-deps: ^2.0.0` accepts neither copy either, but a package's peer rows take whatever +// copy the tree has (a copy resolved for them alone would never be placed), so it binds the highest one and warns as before. +test.concurrent("a package's own peer entry still binds to the highest copy in bun.lock and warns", async () => { + const dir = await twoCopies(); + await write( + join(dir, "package.json"), + stringify(rootPeer("^1.0.0", { "one-fixed-dep": "1.0.0", "strict-peer-dep": "1.0.0" })), + ); + const stderr = await install(dir); + expect(stderr).toContain('warn: incorrect peer dependency "no-deps@1.1.0"'); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); +}); + +// Workspaces declaring the same peer share the one no-deps@1.1.0 the install put at the top of node_modules. +const peerRoot = (range?: string) => (range ? { ...ROOT, peerDependencies: { "no-deps": range } } : ROOT); +const peerMember = (name: string, range: string) => ({ + name, + version: "1.0.0", + peerDependencies: { "no-deps": range }, +}); + +async function sharedPeer(root: Json, ...members: [string, string][]) { + const files: Record = { "package.json": root }; + for (const [name, range] of members) files[`packages/${name}/package.json`] = peerMember(name, range); + const dir = await setup(files); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + return dir; +} + +// The tree dedupes every other peer onto the root's copy whatever their range says, so a member's copy of its own would +// never be installed; the member's entry keeps the root's copy and the warning. +test.concurrent("a member's rewritten entry keeps binding to the copy the root's own entry holds", async () => { + const dir = await sharedPeer(peerRoot("^1.0.0"), ["pkg1", "^1.0.0"]); + await write(join(dir, "packages/pkg1/package.json"), stringify(peerMember("pkg1", "^2.0.0"))); + const stderr = await install(dir); + expect(stderr).toContain('warn: incorrect peer dependency "no-deps@1.1.0"'); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); +}); + +test.concurrent("rewriting the root's and a member's entries together moves both onto the new copy", async () => { + const dir = await sharedPeer(peerRoot("^1.0.0"), ["pkg1", "^1.0.0"]); + await write(join(dir, "package.json"), stringify(peerRoot("^2.0.0"))); + await write(join(dir, "packages/pkg1/package.json"), stringify(peerMember("pkg1", "^2.0.0"))); + const stderr = await install(dir); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + await frozen(dir); +}); + +// Rewriting only the root's entry installs what it asks for; the member's unchanged `^1.0.0` is deduped onto the root's copy, +// the same as when a root dependency moves past it (the tree warns about neither yet, so stderr is not asserted). +test.concurrent("rewriting only the root's entry installs its copy for the unchanged member too", async () => { + const dir = await sharedPeer(peerRoot("^1.0.0"), ["pkg1", "^1.0.0"]); + await write(join(dir, "package.json"), stringify(peerRoot("^2.0.0"))); + const stderr = await install(dir); + expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); + expect((await lock(dir)).workspaces["packages/pkg1"].peerDependencies).toStrictEqual({ "no-deps": "^1.0.0" }); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + await frozen(dir); +}); + +// With no root entry, pkg2's copy is not forced on pkg1: each member ends up with the copy its own entry asks for. +test.concurrent( + "a member's rewritten entry gets its own copy next to a sibling's when the root declares nothing", + async () => { + const dir = await sharedPeer(peerRoot(), ["pkg1", "^1.0.0"], ["pkg2", "^1.0.0"]); + await write(join(dir, "packages/pkg1/package.json"), stringify(peerMember("pkg1", "^2.0.0"))); + const stderr = await install(dir); + expectCleanStderr(stderr); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]); + const { workspaces } = await lock(dir); + expect(workspaces["packages/pkg1"].peerDependencies).toStrictEqual({ "no-deps": "^2.0.0" }); + expect(workspaces["packages/pkg2"].peerDependencies).toStrictEqual({ "no-deps": "^1.0.0" }); + await frozen(dir); + }, +); + +// Without a lockfile the no-deps the first peer row installs is all the second one can bind to; that still dedupes onto it +// and warns. Which row goes first follows the order the two manifests arrive in, so only the single copy is pinned down. +test.concurrent("on a fresh install two peer entries nothing provides still share one no-deps", async () => { + const { packageDir: dir } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + files: { "package.json": stringify(pkgJson({ "peer-deps-fixed": "1.0.0", "strict-peer-dep": "1.0.0" })) }, + }); + const stderr = await install(dir, ...linkerArgs({})); + const [version] = await lockedVersions(dir, "no-deps"); + expect(["1.1.0", "2.0.0"]).toContain(version); + expect(stderr).toContain(`warn: incorrect peer dependency "no-deps@${version}"`); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual([version]); + expect(await installedVersion(dir, "no-deps")).toBe(version); + await frozen(dir); +}); + // pkg1 has a stale entry in every group; pkg2 only in dependencies; the root has none. const PKG1_GROUPS = (noDeps: string, types: string, aDep: string) => ({ name: "pkg1", @@ -2646,3 +2865,46 @@ test.concurrent("`bun update -i --latest` honours an entry toggled back to its i await frozen(dir); expect(exitCode).toBe(0); }); + +// Offered as `a-dep` (dependencies), then the `no-deps` peer row showing Current 1.0.0, Target 1.1.0, Latest 2.0.0. +test.concurrent("`bun update -i` installs the Target version a peerDependencies row shows", async () => { + const dir = await stalePeerEntry(); + const { stderr, exitCode } = await runInteractive(dir, "j \r"); + expect(stderr).not.toContain("error:"); + expect(stderr).not.toContain("warn:"); + expect(await packageJsonOf(dir)).toStrictEqual(withPeer("^1.0.1", "^1.1.0")); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +// With 1.1.0 locked, the row's Target equals Current, so selecting it takes the Latest column. +test.concurrent("`bun update -i` installs the Latest version a peerDependencies row shows", async () => { + const dir = await setup({ "package.json": PEER_ONLY("^1.0.0") }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + const { stderr, exitCode } = await runInteractive(dir, " \r"); + expect(stderr).not.toContain("error:"); + expect(stderr).not.toContain("warn:"); + expect(await packageJsonOf(dir)).toStrictEqual(PEER_ONLY("^2.0.0")); + expect((await lock(dir)).workspaces[""].peerDependencies).toStrictEqual({ "no-deps": "^2.0.0" }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + await frozen(dir); + expect(exitCode).toBe(0); +}); + +test.concurrent("`bun update -i -r` installs the version a workspace member's peerDependencies row shows", async () => { + const pkg1 = (range: string) => ({ name: "pkg1", version: "1.0.0", peerDependencies: { "no-deps": range } }); + const dir = await setup({ "package.json": ROOT, "packages/pkg1/package.json": pkg1("^1.0.0") }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + const { stderr, exitCode } = await runInteractive(dir, " \r", "-r"); + expect(stderr).not.toContain("error:"); + expect(stderr).not.toContain("warn:"); + expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1("^2.0.0")); + expect((await lock(dir)).workspaces["packages/pkg1"].peerDependencies).toStrictEqual({ "no-deps": "^2.0.0" }); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); + await frozen(dir); + expect(exitCode).toBe(0); +}); From b618d99c05db52cfa9fabe21590012dc5bd3e9dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:04 +0000 Subject: [PATCH 006/258] install: keep the packages bun.lock holds only through optional peers when an install re-resolves (#39002) --- .../PackageManager/install_with_manager.rs | 5 +- src/install/dedupe.rs | 3 +- src/install/lockfile.rs | 46 ++++++++++++++----- src/install/lockfile/Package.rs | 10 +++- test/cli/install/bun-lock.test.ts | 45 ++++++++++++++++++ 5 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index a2aaffe39e33..06c22d2f9a4c 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -91,8 +91,9 @@ pub fn install_with_manager( // Snapshot the loaded-from-lockfile package count so // `Lockfile::get_package_id` can tell loaded pins apart from packages - // appended by manifest fetches in this resolve session. - manager.lockfile.mark_loaded_packages(); + // appended by manifest fetches in this resolve session, and which loaded + // packages a non-peer dependency holds, for `clean_with_logger`. + manager.lockfile.mark_loaded_packages()?; let (config_version, changed_config_version) = load_result.choose_config_version(); manager.options.config_version = Some(config_version); diff --git a/src/install/dedupe.rs b/src/install/dedupe.rs index 32ed09e28e02..e524e2b6e54a 100644 --- a/src/install/dedupe.rs +++ b/src/install/dedupe.rs @@ -605,7 +605,8 @@ pub(crate) fn effective_version( Some(version) } -// Optional-peer edges are followed too: with an in-sync package.json `clean` runs with `keep_optional_peer_targets`. +// Optional-peer edges are followed too: `clean` keeps a target the loaded lockfile held through them alone +// (`Lockfile::held_at_load`), and a version that survives here keeps every edge it had, so anything else they reach stays held. fn reachable(lockfile: &Lockfile, resolutions: &[PackageID]) -> DynamicBitSet { crate::lockfile::reachable::packages( lockfile, diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index f80c2ed6b1ee..938634608a26 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -203,6 +203,19 @@ pub struct Lockfile { /// Runtime-only — never serialised. pub(crate) loaded_package_count: PackageID, + /// `bit[id] == true` ⇔ a dependency other than an optional peer resolved + /// to package `id` in the lockfile as loaded. An optional peer slot never + /// keeps such a package alive through `clean_with_logger` (it is bound in + /// `Cloner::flush` if something else still reaches it), so a package whose + /// last real dependent leaves package.json is dropped. A loaded package + /// with the bit unset was held by optional peers alone; 1.3.x wrote such + /// entries and they are kept, otherwise every resolve would prune them and + /// rewrite the file on any unrelated change. Sized to + /// `loaded_package_count`, so packages appended during this resolve read + /// as held (a fresh resolve never creates peer-only entries). Set by + /// `mark_loaded_packages`; runtime-only — never serialised. + pub(crate) held_at_load: DynamicBitSet, + /// `bit[id] == true` ⇔ package `id` was appended for a dependency whose /// version range was an exact `=X.Y.Z` (i.e. the user — root or workspace /// — pinned this exact version somewhere in the tree). `get_package_id`'s @@ -986,9 +999,6 @@ impl Lockfile { let mut package_id_mapping = vec![invalid_package_id; old.packages.len()]; let clone_queue_ = PendingResolutions::new(); - // A frozen install never saves, so dropping peer-held targets could only fail its check. - let keep_optional_peer_targets = - manager.options.enable.frozen_lockfile() || !manager.summary.changes_resolutions(); // Explicit `&mut *` reborrows so `old`/`manager`/`new` are // released back to this scope once `cloner` is dropped. let mut cloner = Cloner { @@ -997,7 +1007,6 @@ impl Lockfile { mapping: &mut package_id_mapping, clone_queue: clone_queue_, optional_peers: PendingResolutions::new(), - keep_optional_peer_targets, log, old_preinstall_state, manager: &mut *manager, @@ -1224,7 +1233,6 @@ pub struct Cloner<'a> { pub(crate) clone_queue: PendingResolutions, /// Bound in `flush`, once `clone_queue` has decided which targets survive. pub(crate) optional_peers: PendingResolutions, - pub(crate) keep_optional_peer_targets: bool, pub lockfile: &'a mut Lockfile, pub(crate) old: &'a mut Lockfile, pub(crate) mapping: &'a mut [PackageID], @@ -2013,16 +2021,32 @@ impl Lockfile { // session-appended, so the order-independence guard in // `get_package_id` applies from id 0. loaded_package_count: 0, + held_at_load: DynamicBitSet::default(), exact_pinned: DynamicBitSet::default(), } } - /// Snapshot `packages.len()` as the "loaded from lockfile" watermark. - /// Call exactly once after `load_from_cwd` (including npm/pnpm/yarn - /// migration) before any manifest-driven `append_package`. - #[inline] - pub(crate) fn mark_loaded_packages(&mut self) { - self.loaded_package_count = self.packages.len() as PackageID; + /// Snapshot `packages.len()` as the "loaded from lockfile" watermark and + /// which of those packages a non-peer dependency resolves to + /// (`held_at_load`). Call exactly once after `load_from_cwd` (including + /// npm/pnpm/yarn migration), before the differ, an update or a dedupe + /// re-points any resolution and before any manifest-driven + /// `append_package`. + pub(crate) fn mark_loaded_packages(&mut self) -> Result<(), AllocError> { + let packages_len = self.packages.len(); + self.loaded_package_count = packages_len as PackageID; + let mut held = DynamicBitSet::init_empty(packages_len)?; + // A load that failed partway leaves `resolutions` shorter than `dependencies`; + // that lockfile is replaced by `init_empty` before anything reads the set. + let dependencies = self.buffers.dependencies.as_slice(); + let resolutions = self.buffers.resolutions.as_slice(); + for (dependency, &package_id) in dependencies.iter().zip(resolutions) { + if !dependency.behavior.is_optional_peer() && (package_id as usize) < packages_len { + held.set(package_id as usize); + } + } + self.held_at_load = held; + Ok(()) } /// Record that package `id` was appended via an exact-version dependency diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index fcd5b545bd4d..767c6aa80c1f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -616,8 +616,14 @@ impl Package { resolve_id: new_package.resolutions.off + PackageID::try_from(i).expect("int cast"), }; - // Peer slots must not keep their target alive; bound in `Cloner::flush`. - if old_dependencies[i].behavior.is_optional_peer() && !cloner.keep_optional_peer_targets + // A peer slot must not keep a target alive that something else held + // when the lockfile was loaded; it is bound in `Cloner::flush` if that + // holder survived. A target the loaded lockfile held through optional + // peers alone is cloned like a dependency (see `Lockfile::held_at_load`). + if old_dependencies[i].behavior.is_optional_peer() + && old + .held_at_load + .is_set_allow_out_of_bound(*old_resolution as usize, true) { cloner.optional_peers.push(pending); continue; diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index f675ac2c5710..be0f1d31ad2b 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1450,6 +1450,51 @@ it("--frozen-lockfile keeps a package that an older lockfile lists only as an op expect(await exists(join(packageDir, "node_modules", "no-deps", "package.json"))).toBeTrue(); }); +// Same entries, but the install that re-resolves and saves: a package.json change +// unrelated to them (here a dependency bun.lock already has) must not prune them +// either. Only a package whose real dependent leaves is dropped (the tests above). +it("a re-resolving install keeps the packages an older lockfile holds through optional peers alone", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + }); + const run = makeInstallRunner(packageDir); + const noDepsEntry = '"no-deps": ["no-deps@1.0.0"'; + const locked = { "optional-peer-deps": "1.0.0", "uses-a-dep-1": "1.0.0" }; + await Promise.all([ + // a-dep is already in bun.lock through uses-a-dep-1, so nothing resolves differently. + write(packageJson, JSON.stringify({ name: "foo", dependencies: { ...locked, "a-dep": "1.0.1" } })), + write( + join(packageDir, "bun.lock"), + JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "foo", dependencies: locked } }, + packages: { + "a-dep": ["a-dep@1.0.1", "", {}, ""], + // only optional-peer-deps's optional peer refers to this entry + "no-deps": ["no-deps@1.0.0", "", {}, ""], + "optional-peer-deps": [ + "optional-peer-deps@1.0.0", + "", + { peerDependencies: { "no-deps": "*" }, optionalPeers: ["no-deps"] }, + "", + ], + "uses-a-dep-1": ["uses-a-dep-1@1.0.0", "", { dependencies: { "a-dep": "1.0.1" } }, ""], + }, + }), + ), + ]); + + await run(["install"]); + const saved = await file(join(packageDir, "bun.lock")).text(); + expect(saved).toContain('"a-dep": "1.0.1"'); + expect(saved).toContain(noDepsEntry); + expect(await exists(join(packageDir, "node_modules", "no-deps", "package.json"))).toBeTrue(); + + await run(["install", "--frozen-lockfile"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(saved); +}); + // The optional-peer-hoist-* fixtures are described in // registry/packages/create-optional-peer-hoist-packages.ts. In short: consumer // has an optional peer on target, and deep -> deep-child reaches target@1.0.0 From ca659933dc40d7459861c77871a6877327c374ed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:08 +0000 Subject: [PATCH 007/258] install: bind unbound required peers while hoisting, like optional peers (#39343) --- src/install/lockfile.rs | 12 +- src/install/lockfile/Tree.rs | 51 +++-- .../install/migration/pnpm-lock-v9.test.ts | 199 ++++++++++++++++++ .../migration/yarn-lock-migration.test.ts | 101 ++++++++- 4 files changed, 328 insertions(+), 35 deletions(-) diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 938634608a26..f97520b42842 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -1294,7 +1294,7 @@ impl<'a> Cloner<'a> { // ──────────────────────────────────────────────────────────────────────────── impl Lockfile { - /// Re-hoists while a pass bound an optional peer late; a reload has that binding up front. + /// Re-hoists while a pass bound a peer late; a reload has that binding up front. pub(crate) fn resolve(&mut self, log: &mut bun_ast::Log) -> Result<(), tree::SubtreeError> { while self.hoist::<{ tree::BuilderMethod::Resolvable }>(log, None, true, &[], None)? {} Ok(()) @@ -1318,7 +1318,7 @@ impl Lockfile { Ok(()) } - /// Sets `buffers.trees`/`hoisted_dependencies`; returns `Builder::late_bound_optional_peer`. + /// Sets `buffers.trees`/`hoisted_dependencies`; returns `Builder::late_bound_peer`. pub(crate) fn hoist( &mut self, log: &mut bun_ast::Log, @@ -1349,8 +1349,8 @@ impl Lockfile { install_root_dependencies, workspace_filters, packages_to_install, - pending_optional_peers: Default::default(), - late_bound_optional_peer: false, + pending_peers: Default::default(), + late_bound_peer: false, list: Default::default(), sort_buf: Default::default(), }; @@ -1369,10 +1369,10 @@ impl Lockfile { } let cleaned = builder.clean()?; - let late_bound_optional_peer = builder.late_bound_optional_peer; + let late_bound_peer = builder.late_bound_peer; self.buffers.trees = cleaned.trees; self.buffers.hoisted_dependencies = cleaned.dep_ids; - Ok(late_bound_optional_peer) + Ok(late_bound_peer) } } diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index b34e2f19b0b7..1a959cda7efa 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -436,14 +436,13 @@ pub struct Builder<'a, const METHOD: BuilderMethod> { /// overlap; reads go through [`Builder::lockfile()`] which never touches /// `buffers.resolutions`. pub lockfile: bun_ptr::ParentRef, - // Unresolved optional peers that might resolve later. if they do we will want to assign + // Unresolved peers that might resolve later. if they do we will want to assign // builder.resolutions[peer.dep_id] to the resolved pkg_id. A dependency ID set is used because there // can be multiple instances of the same package in the tree, so the same unresolved dependency ID // could be visited multiple times before it's resolved. - pub(crate) pending_optional_peers: - ArrayHashMap>, - /// An optional peer got bound after its dependent was placed; see `Lockfile::resolve`. - pub(crate) late_bound_optional_peer: bool, + pub(crate) pending_peers: ArrayHashMap>, + /// A peer got bound after its dependent was placed; see `Lockfile::resolve`. + pub(crate) late_bound_peer: bool, pub(crate) manager: Option<&'a PackageManager>, pub(crate) sort_buf: Vec, pub(crate) workspace_filters: &'a [WorkspaceFilter], @@ -539,7 +538,7 @@ impl<'a, const METHOD: BuilderMethod> Builder<'a, METHOD> { for &dep_id in child.iter() { let pkg_id = self.resolutions[dep_id as usize]; if pkg_id == invalid_package_id { - // optional peers that never resolved + // peers that never resolved continue; } @@ -551,7 +550,7 @@ impl<'a, const METHOD: BuilderMethod> Builder<'a, METHOD> { tree.dependencies.len = len; } - // queue / sort_buf / pending_optional_peers freed by Drop; explicit deinit removed. + // queue / sort_buf / pending_peers freed by Drop; explicit deinit removed. // The sole caller (`Lockfile::hoist`) drops the Builder immediately after clean(). slice.deinit_owned(); @@ -790,7 +789,11 @@ impl Tree { } if pkg_id == invalid_package_id { - if dependency.behavior.is_optional_peer() { + // An unbound peer (optional, or one a migration had nothing recorded for) + // binds to the copy next to or above its dependent, which is where loading + // the saved bun.lock binds it (`PkgMap::find_resolution`); the isolated + // store keys the dependent by that binding. + if dependency.behavior.is_peer() { break 'hoisted Tree::hoist_dependency::( next_id, hoist_root_id, @@ -843,14 +846,10 @@ impl Tree { debug_assert!(pkg_id == invalid_package_id); debug_assert!(res_id != invalid_package_id); builder.resolutions[dep_id as usize] = res_id; - debug_assert!( - !builder - .pending_optional_peers - .contains_key(&dependency.name_hash) - ); + debug_assert!(!builder.pending_peers.contains_key(&dependency.name_hash)); if let Some(entry) = builder - .pending_optional_peers + .pending_peers .fetch_swap_remove(&dependency.name_hash) { let peers = entry.1; @@ -868,10 +867,10 @@ impl Tree { } HoistDependencyResult::ResolveReplace(replace) => { debug_assert!(pkg_id != invalid_package_id); - builder.late_bound_optional_peer = true; + builder.late_bound_peer = true; builder.resolutions[replace.dep_id as usize] = pkg_id; if let Some(entry) = builder - .pending_optional_peers + .pending_peers .fetch_swap_remove(&dependency.name_hash) { let peers = entry.1; @@ -909,12 +908,10 @@ impl Tree { builder.resolutions[dep_id as usize] = res_id; } HoistDependencyResult::ResolveLater => { - // `dep_id` is an unresolved optional peer. while hoisting it deduplicated - // with another unresolved optional peer. save it so we remember resolve it + // `dep_id` is an unresolved peer. while hoisting it deduplicated + // with another unresolved peer. save it so we remember resolve it // later if it's possible to resolve it. - let entry = builder - .pending_optional_peers - .get_or_put(dependency.name_hash)?; + let entry = builder.pending_peers.get_or_put(dependency.name_hash)?; if !entry.found_existing { *entry.value_ptr = ArrayHashMap::default(); } @@ -996,15 +993,15 @@ impl Tree { let res_id = builder.resolutions[dep_id as usize]; if res_id == invalid_package_id && package_id == invalid_package_id { - debug_assert!(dep.behavior.is_optional_peer()); - debug_assert!(dependency.behavior.is_optional_peer()); - // both optional peers will need to be resolved if they can resolve later. + debug_assert!(dep.behavior.is_peer()); + debug_assert!(dependency.behavior.is_peer()); + // both peers will need to be resolved if they can resolve later. // remember input package_id and dependency for later return HoistDependencyResult::ResolveLater; } if res_id == invalid_package_id { - debug_assert!(dep.behavior.is_optional_peer()); + debug_assert!(dep.behavior.is_peer()); return HoistDependencyResult::ResolveReplace(ResolveReplace { id: this.id, dep_id, @@ -1012,9 +1009,9 @@ impl Tree { } if package_id == invalid_package_id { - debug_assert!(dependency.behavior.is_optional_peer()); + debug_assert!(dependency.behavior.is_peer()); debug_assert!(res_id != invalid_package_id); - // resolve optional peer to `builder.resolutions[dep_id]` + // resolve peer to `builder.resolutions[dep_id]` return HoistDependencyResult::Resolve(res_id); // 1 } diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index 549ba0890922..972eaf3aebd1 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -1848,6 +1848,205 @@ ${variants}`; ).toBeTrue(); }); + describe("a peer pnpm left unmet is bound by the install that migrates", () => { + // pnpm records a met peer as a suffix on the dependent's snapshot key; an unmet one (autoInstallPeers + // off) has no suffix, so the migration has nothing to bind peer-deps-too's `no-deps` edge to. Loading + // the migrated bun.lock binds it to the no-deps hoisted to the root, and the isolated store keys + // peer-deps-too by that binding, so the migrating install has to bind it the same way or the next + // install re-keys the entry (bun.lock itself is identical either way). + const unmetPeerProject = (importer: string, packageJson: Record) => ({ + "package.json": JSON.stringify({ name: "unmet-peer", ...packageJson }), + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + +importers: + + .: +${importer} + +packages: + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + + one-dep@1.0.0: + resolution: {integrity: ${ONE_DEP_1_0_0_INTEGRITY}} + + peer-deps-too@1.0.0: + resolution: {integrity: ${PEER_DEPS_TOO_1_0_0_INTEGRITY}} + peerDependencies: + no-deps: '*' + +snapshots: + + no-deps@1.0.1: {} + + one-dep@1.0.0: + dependencies: + no-deps: 1.0.1 + + peer-deps-too@1.0.0: {} +`, + }); + + const storeEntries = (dir: string) => + readdirSync(join(dir, "node_modules", ".bun")) + .filter(name => name !== "node_modules") + .sort(); + + // Hoisting places a package's dependencies breadth-first in dependency-group order. With both in + // `dependencies`, one-dep's no-deps reaches the root before peer-deps-too's peer is looked at; as a + // devDependency peer-deps-too is processed first, and the peer is bound when no-deps arrives later. + test.concurrent.each([ + { + group: "dependencies", + packageJson: { dependencies: { "one-dep": "1.0.0", "peer-deps-too": "1.0.0" } }, + importer: ` dependencies: + one-dep: + specifier: 1.0.0 + version: 1.0.0 + peer-deps-too: + specifier: 1.0.0 + version: 1.0.0`, + }, + { + group: "devDependencies", + packageJson: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "peer-deps-too": "1.0.0" } }, + importer: ` dependencies: + one-dep: + specifier: 1.0.0 + version: 1.0.0 + devDependencies: + peer-deps-too: + specifier: 1.0.0 + version: 1.0.0`, + }, + ])( + "peer-deps-too in $group keys its isolated store entry like a reinstall and like a fresh install", + async ({ packageJson, importer }) => { + const files = unmetPeerProject(importer, packageJson); + const { packageDir } = await verdaccio.createTestDir({ bunfigOpts: { linker: "isolated" }, files }); + + const install = await run(packageDir, "install"); + + expect(install.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(install.exitCode).toBe(0); + const migrated = await bunLockOf(packageDir); + expect(migrated).toContain(`{ "peerDependencies": { "no-deps": "*" } }`); + const store = storeEntries(packageDir); + expect(store).toEqual([ + "no-deps@1.0.1", + "one-dep@1.0.0", + expect.stringMatching(/^peer-deps-too@1\.0\.0\+[0-9a-f]{16}$/), + ]); + + const reinstall = await run(packageDir, "install"); + + expect(reinstall.stdout).toContain("(no changes)"); + expect(reinstall.exitCode).toBe(0); + expect(await bunLockOf(packageDir)).toBe(migrated); + expect(storeEntries(packageDir)).toEqual(store); + + const { packageDir: fresh } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "isolated" }, + files: { "package.json": files["package.json"] }, + }); + + const freshInstall = await run(fresh, "install"); + + expect(freshInstall.stderr).toContain("Saved lockfile"); + expect(freshInstall.exitCode).toBe(0); + expect(storeEntries(fresh)).toEqual(store); + }, + ); + + // The root's and a workspace's own peers come from package.json and are unbound the same way; the + // isolated linker links a bound peer into the importer's node_modules. + test.concurrent("the root's and a workspace's own peers are linked by the migrating install", async () => { + const manifests = { + "package.json": JSON.stringify({ + name: "importer-peers", + workspaces: ["apps/*"], + dependencies: { "one-dep": "1.0.0" }, + peerDependencies: { "no-deps": "*" }, + }), + "apps/a/package.json": JSON.stringify({ name: "a", peerDependencies: { "no-deps": "*" } }), + }; + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "isolated" }, + files: { + ...manifests, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + +importers: + + .: + dependencies: + one-dep: + specifier: 1.0.0 + version: 1.0.0 + + apps/a: {} + +packages: + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + + one-dep@1.0.0: + resolution: {integrity: ${ONE_DEP_1_0_0_INTEGRITY}} + +snapshots: + + no-deps@1.0.1: {} + + one-dep@1.0.0: + dependencies: + no-deps: 1.0.1 +`, + }, + }); + const linked = (dir: string) => ({ + root: readdirSync(join(dir, "node_modules")) + .filter(name => name !== ".bun") + .sort(), + a: existsSync(join(dir, "apps/a/node_modules")) ? readdirSync(join(dir, "apps/a/node_modules")).sort() : [], + }); + + const install = await run(packageDir, "install"); + + expect(install.stderr).toContain('skipped peer "no-deps" of the root package'); + expect(install.stderr).toContain('skipped peer "no-deps" of workspace "apps/a"'); + expect(install.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(install.exitCode).toBe(0); + const migrated = await bunLockOf(packageDir); + expect(linked(packageDir)).toEqual({ root: ["no-deps", "one-dep"], a: ["no-deps"] }); + + const reinstall = await run(packageDir, "install"); + + expect(reinstall.stdout).toContain("(no changes)"); + expect(reinstall.exitCode).toBe(0); + expect(await bunLockOf(packageDir)).toBe(migrated); + expect(linked(packageDir)).toEqual({ root: ["no-deps", "one-dep"], a: ["no-deps"] }); + + const { packageDir: fresh } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "isolated" }, + files: manifests, + }); + + const freshInstall = await run(fresh, "install"); + + expect(freshInstall.stderr).toContain("Saved lockfile"); + expect(freshInstall.exitCode).toBe(0); + expect(linked(fresh)).toEqual({ root: ["no-deps", "one-dep"], a: ["no-deps"] }); + }); + }); + const linkedPeerFiles = { "package.json": JSON.stringify({ name: "v9-linked-peer", diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index c83cec240d83..bcaa8f63f27d 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import fs from "fs"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, tempDir, VerdaccioRegistry } from "harness"; import { join } from "path"; describe("yarn.lock migration basic", () => { @@ -1712,3 +1712,100 @@ uses-foo15@1.0.0: }); }); }); + +describe("installing from a migrated yarn.lock", () => { + const verdaccio = new VerdaccioRegistry(); + const REGISTRY_PACKAGES = join(import.meta.dir, "..", "registry", "packages"); + + beforeAll(async () => { + await verdaccio.start(); + }); + + afterAll(() => { + verdaccio.stop(); + }); + + // Keyed by the exact spec that requested it, as yarn writes pinned dependencies. + function yarnEntry(name: string, version: string, body = "") { + const { shasum, integrity } = JSON.parse(fs.readFileSync(join(REGISTRY_PACKAGES, name, "package.json"), "utf8")) + .versions[version].dist; + return `${name}@${version}: + version "${version}" + resolved "${verdaccio.registryUrl()}${name}/-/${name}-${version}.tgz#${shasum}" + integrity ${integrity} +${body}`; + } + + async function install(cwd: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const storeEntries = (dir: string) => + fs + .readdirSync(join(dir, "node_modules", ".bun")) + .filter(name => name !== "node_modules") + .sort(); + + test("a peer yarn.lock has no entry for is bound by the install that migrates", async () => { + // yarn.lock keys entries by the specs that requested them; nothing requested `no-deps@*`, so the + // migration cannot bind peer-deps-too's peer from the file. Loading the migrated bun.lock binds it to + // the no-deps at the root and the isolated store keys peer-deps-too by that binding, so the migrating + // install has to bind it the same way or the next install re-keys the entry. + const packageJson = JSON.stringify({ + name: "unmet-peer", + dependencies: { "one-dep": "1.0.0", "peer-deps-too": "1.0.0" }, + }); + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "isolated" }, + files: { + "package.json": packageJson, + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +${yarnEntry("no-deps", "1.0.1")} +${yarnEntry("one-dep", "1.0.0", ` dependencies:\n no-deps "1.0.1"\n`)} +${yarnEntry("peer-deps-too", "1.0.0", ` peerDependencies:\n no-deps "*"\n`)}`, + }, + }); + + const migrating = await install(packageDir); + + expect(migrating.stderr).toContain("migrated lockfile from yarn.lock"); + expect(migrating.exitCode).toBe(0); + const migrated = fs.readFileSync(join(packageDir, "bun.lock"), "utf8"); + expect(migrated).toContain(`"peerDependencies": { "no-deps": "*" }`); + const store = storeEntries(packageDir); + expect(store).toEqual([ + "no-deps@1.0.1", + "one-dep@1.0.0", + expect.stringMatching(/^peer-deps-too@1\.0\.0\+[0-9a-f]{16}$/), + ]); + + const reinstall = await install(packageDir); + + expect(reinstall.stdout).toContain("(no changes)"); + expect(reinstall.exitCode).toBe(0); + expect(fs.readFileSync(join(packageDir, "bun.lock"), "utf8")).toBe(migrated); + expect(storeEntries(packageDir)).toEqual(store); + + const { packageDir: fresh } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "isolated" }, + files: { "package.json": packageJson }, + }); + + const freshInstall = await install(fresh); + + expect(freshInstall.stderr).toContain("Saved lockfile"); + expect(freshInstall.exitCode).toBe(0); + expect(storeEntries(fresh)).toEqual(store); + }); +}); From 86f127bc75f9ccd2e4dfa3207464deac013bf64e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:12 +0000 Subject: [PATCH 008/258] install: bind a multi-path package's edges from the bun.lock row that prints the binding (#39347) --- src/install/lockfile/bun.lock.rs | 76 ++++++- test/cli/install/bun-lock.test.ts | 328 +++++++++++++++++++++++------- 2 files changed, 329 insertions(+), 75 deletions(-) diff --git a/src/install/lockfile/bun.lock.rs b/src/install/lockfile/bun.lock.rs index 086bf9b53d2f..412151d0196b 100644 --- a/src/install/lockfile/bun.lock.rs +++ b/src/install/lockfile/bun.lock.rs @@ -1674,6 +1674,51 @@ pub(crate) enum ResolveError { Unresolvable, } +/// Where `PkgMap::find_resolution` found the entry it returned. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum FoundAt { + /// `/`. + OwnPath, + /// `/`. + EnclosingPath, + /// The top-level `` entry. + Root, +} + +/// Which row binds an edge of a package printed at more than one path. `append_package_dedupe` +/// gives every such row the same package, so each row binds the same edges again, and the +/// rows can walk into different copies of a peer. A row overwrites an earlier row's binding +/// only when its find says at least as much about the binding; rows whose finds say the same +/// keep the last one, and a package printed once binds each edge exactly once, as before. +/// +/// What a find says follows from where `Tree::hoist_dependency` puts the package an edge is +/// bound to: nested in the dependent's own path when a copy above blocks it, or at the root +/// when nothing above holds the name. It never ends up at an enclosing path in between; a copy +/// found there was placed for another package's edge, and this edge deduped onto it (a peer +/// the copy satisfies), which says nothing about what the edge is bound to. +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] +enum RowEvidence { + Unbound, + /// A copy found by walking up. Every walked find of an optional peer ranks here: the + /// hoister rebinds those to whatever each placement dedupes onto + /// (`HoistDependencyResult::Rebind`), so which copy a row walked into decides nothing. + Walked, + /// The root's copy, which may be this edge's own binding, placed there from this row. + WalkedToRoot, + /// The copy printed in the package's own path, which is only there for the package's own edge. + OwnEntry, +} + +impl RowEvidence { + fn of(found_at: FoundAt, dep: &Dependency) -> RowEvidence { + match found_at { + FoundAt::OwnPath => RowEvidence::OwnEntry, + FoundAt::Root if !dep.behavior.is_optional_peer() => RowEvidence::WalkedToRoot, + FoundAt::Root | FoundAt::EnclosingPath => RowEvidence::Walked, + } + } +} + impl PkgMap { // No `Entry` alias — inherent associated types are // unstable; callers name `T` directly. @@ -1714,7 +1759,7 @@ impl PkgMap { dep: &Dependency, string_buf: &[u8], path_buf: &mut [u8], - ) -> Result<&T, ResolveError> { + ) -> Result<(&T, FoundAt), ResolveError> { self.find_resolution_impl(pkg_path, dep, string_buf, path_buf, None) } @@ -1735,7 +1780,7 @@ impl PkgMap { string_buf: &[u8], path_buf: &mut [u8], bundled_pkgs: &PkgPathSet, - ) -> Result<&T, ResolveError> { + ) -> Result<(&T, FoundAt), ResolveError> { self.find_resolution_impl(pkg_path, dep, string_buf, path_buf, Some(bundled_pkgs)) } @@ -1746,7 +1791,7 @@ impl PkgMap { string_buf: &[u8], path_buf: &mut [u8], bundled_pkgs: Option<&PkgPathSet>, - ) -> Result<&T, ResolveError> { + ) -> Result<(&T, FoundAt), ResolveError> { let dep_name = dep.name.slice(string_buf); if pkg_path.len() + 1 + dep_name.len() > path_buf.len() { @@ -1757,6 +1802,7 @@ impl PkgMap { path_buf[pkg_path.len()] = b'/'; let mut offset = pkg_path.len() + 1; + let mut own_path = true; let mut at_bundle_root = false; let mut valid = true; while valid { @@ -1764,7 +1810,14 @@ impl PkgMap { let res_path = &path_buf[0..offset + dep_name.len()]; if let Some(entry) = self.map.get(res_path) { - return Ok(entry); + let found_at = if own_path { + FoundAt::OwnPath + } else if offset == 0 { + FoundAt::Root + } else { + FoundAt::EnclosingPath + }; + return Ok((entry, found_at)); } if offset == 0 || at_bundle_root { @@ -1774,6 +1827,7 @@ impl PkgMap { if let Some(bundled_pkgs) = bundled_pkgs { at_bundle_root = bundled_pkgs.contains(&path_buf[0..offset - 1]); } + own_path = false; let Some(slash) = strings::last_index_of_char(&path_buf[0..offset - 1], b'/') else { offset = 0; @@ -3213,6 +3267,11 @@ pub(crate) fn parse_into_binary_lockfile( } // then each package dependency + // + // A package printed at several paths comes through here once per path; see + // `RowEvidence` for which path's find an edge ends up bound to. Edges bound by + // version below do not depend on the path, so every row writes them. + let mut bound_by: Vec = vec![RowEvidence::Unbound; dependencies.len()]; for row in pkg_rows { let pkg_path = row.key.slice(); @@ -3264,7 +3323,14 @@ pub(crate) fn parse_into_binary_lockfile( pkg_map.find_resolution(pkg_path, dep, string_buf, &mut path_buf[..]) }; match found { - Ok(&id) => id, + Ok((&id, found_at)) => { + let evidence = RowEvidence::of(found_at, dep); + if bound_by[dep_id as usize] > evidence { + continue 'deps; + } + bound_by[dep_id as usize] = evidence; + id + } Err(ResolveError::InvalidPackageKey) => { log.add_error(Some(source), row.key_loc, b"Invalid package path"); return Err(ParseError::InvalidPackageKey); diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index be0f1d31ad2b..26e808b193d6 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1932,13 +1932,79 @@ it("an optional peer is rebound when another version of its package takes the sl expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile); }); +/** `name -> version -> extra package.json fields` for `serveRegistry`. */ +type Manifests = Record>>; + +// A registry serving exactly `manifests`, each version as a tarball holding just its package.json. +async function serveRegistry(manifests: Manifests) { + const tarballs = new Map(); + for (const [name, versions] of Object.entries(manifests)) { + for (const [version, extra] of Object.entries(versions)) { + const archive = new Bun.Archive( + { "package/package.json": JSON.stringify({ name, version, ...extra }) }, + { compress: "gzip" }, + ); + tarballs.set(`/${name}-${version}.tgz`, await archive.bytes()); + } + } + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const { origin, pathname } = new URL(request.url); + requests.push(pathname); + const tarball = tarballs.get(pathname); + if (tarball) return new Response(tarball); + const name = pathname.slice(1); + const entry = manifests[name]; + if (!entry) return new Response("not found", { status: 404 }); + const versions: Record = {}; + for (const [version, extra] of Object.entries(entry)) { + versions[version] = { name, version, dist: { tarball: `${origin}/${name}-${version}.tgz` }, ...extra }; + } + return Response.json( + { name, versions, "dist-tags": { latest: Object.keys(entry).at(-1) } }, + // Like registry.npmjs.org. Within this window bun resolves from the + // manifest cache without going back to the registry. + { headers: { "cache-control": "public, max-age=300" } }, + ); + }, + }); + return { + url: server.url.href, + origin: server.url.origin, + requests, + [Symbol.dispose]() { + server.stop(true); + }, + }; +} + +async function installWithOwnCache(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd, + // Request assertions need a cache of their own per project: the environment's + // cache dir takes precedence over bunfig, and a package extracted there by one + // of the concurrent tests is not downloaded again. + env: { ...env, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") }, + stdout: "pipe", + stderr: "pipe", + // Only matters if an install never returns. + timeout: 30_000, + }); + const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ args, err, code }).toMatchObject({ args, err: expect.not.stringContaining("error:"), code: 0 }); + return { out, err }; +} + // https://github.com/oven-sh/bun/issues/26046 // A required peer that nothing in the tree provides and that no published // version satisfies stays unresolved. The bun.lock written afterwards has to // load back, and resolving it again with every manifest already in the cache // has to finish (it used to retry the cached manifest forever). describe.each(["hoisted", "isolated"] as const)("peer no published version satisfies (%s linker)", linker => { - const manifests: Record>> = { + const manifests: Manifests = { "has-unmet-peer": { "1.0.0": { peerDependencies: { "peer-target": "^1.0.1" } } }, "peer-target": { "2.0.1": {} }, }; @@ -1946,50 +2012,6 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis const unmetPeerWarning = 'warn: No version matching "^1.0.1" found for peer dependency "peer-target" (but package exists)'; - async function serveRegistry() { - const tarballs = new Map(); - for (const [name, versions] of Object.entries(manifests)) { - for (const [version, extra] of Object.entries(versions)) { - const archive = new Bun.Archive( - { "package/package.json": JSON.stringify({ name, version, ...extra }) }, - { compress: "gzip" }, - ); - tarballs.set(`/${name}-${version}.tgz`, await archive.bytes()); - } - } - const requests: string[] = []; - const server = Bun.serve({ - port: 0, - fetch(request) { - const { origin, pathname } = new URL(request.url); - requests.push(pathname); - const tarball = tarballs.get(pathname); - if (tarball) return new Response(tarball); - const name = pathname.slice(1); - const entry = manifests[name]; - if (!entry) return new Response("not found", { status: 404 }); - const versions: Record = {}; - for (const [version, extra] of Object.entries(entry)) { - versions[version] = { name, version, dist: { tarball: `${origin}/${name}-${version}.tgz` }, ...extra }; - } - return Response.json( - { name, versions, "dist-tags": { latest: Object.keys(entry).at(-1) } }, - // Like registry.npmjs.org. Within this window bun resolves from the - // manifest cache without going back to the registry. - { headers: { "cache-control": "public, max-age=300" } }, - ); - }, - }); - return { - url: server.url.href, - origin: server.url.origin, - requests, - [Symbol.dispose]() { - server.stop(true); - }, - }; - } - function createProject(registryUrl: string, files: Record) { return tempDir("unmet-peer-", { ...files, @@ -1997,32 +2019,14 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis }); } - async function install(cwd: string, ...args: string[]) { - await using proc = spawn({ - cmd: [bunExe(), "install", ...args], - cwd, - // The request assertions below need a cache of their own per project: the - // environment's cache dir takes precedence over bunfig, and a package - // extracted there by one of the concurrent tests is not downloaded again. - env: { ...env, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") }, - stdout: "pipe", - stderr: "pipe", - // Only matters if an install never returns. - timeout: 30_000, - }); - const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ args, err, code }).toMatchObject({ args, err: expect.not.stringContaining("error:"), code: 0 }); - return { out, err }; - } - it.concurrent("declared by a registry package", async () => { - using registry = await serveRegistry(); + using registry = await serveRegistry(manifests); using dir = createProject(registry.url, { "package.json": JSON.stringify({ name: "app", dependencies: { "has-unmet-peer": "1.0.0" } }), }); const lockfilePath = join(String(dir), "bun.lock"); - let { err } = await install(String(dir)); + let { err } = await installWithOwnCache(String(dir)); expect(err).toContain(unmetPeerWarning); expect(err).toContain("Saved lockfile"); expect(registry.requests.toSorted()).toEqual(["/has-unmet-peer", "/has-unmet-peer-1.0.0.tgz", "/peer-target"]); @@ -2047,7 +2051,7 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis `); expect(await exists(join(String(dir), "node_modules", "peer-target"))).toBeFalse(); - ({ err } = await install(String(dir), "--frozen-lockfile")); + ({ err } = await installWithOwnCache(String(dir), "--frozen-lockfile")); expect(err).not.toContain("Ignoring lockfile"); expect(await file(lockfilePath).text()).toBe(lockfile); @@ -2056,14 +2060,14 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis await rm(lockfilePath); await rm(join(String(dir), "node_modules"), { recursive: true }); registry.requests.length = 0; - ({ err } = await install(String(dir))); + ({ err } = await installWithOwnCache(String(dir))); expect(err).toContain(unmetPeerWarning); expect(registry.requests).toEqual([]); expect(await file(lockfilePath).text()).toBe(lockfile); }); it.concurrent("declared by the root package and a workspace", async () => { - using registry = await serveRegistry(); + using registry = await serveRegistry(manifests); using dir = createProject(registry.url, { "package.json": JSON.stringify({ name: "app", @@ -2074,7 +2078,7 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis }); const lockfilePath = join(String(dir), "bun.lock"); - let { err } = await install(String(dir)); + let { err } = await installWithOwnCache(String(dir)); expect(err).toContain(unmetPeerWarning); expect(err).toContain("Saved lockfile"); expect(registry.requests).toEqual(["/peer-target"]); @@ -2104,8 +2108,192 @@ describe.each(["hoisted", "isolated"] as const)("peer no published version satis " `); - ({ err } = await install(String(dir), "--frozen-lockfile")); + ({ err } = await installWithOwnCache(String(dir), "--frozen-lockfile")); expect(err).not.toContain("Ignoring lockfile"); expect(await file(lockfilePath).text()).toBe(lockfile); }); }); + +// A package is printed at more than one path when another version of it holds the slot above +// one of its dependents (here the root holds one version, a parent holds the other, and the +// dependents under that parent get the root's version printed again next to them). Its edges +// are in the file once, so loading binds them once per printed path, and the paths can find +// different copies of a peer; the path printed last used to win, so what an edge loaded as +// depended on how its dependents' parents happened to sort. +describe("loading bun.lock binds the edges of a package printed at several paths", () => { + /** `path -> "name@version"` for every row of the packages object. */ + const printedPackages = (lockfile: string) => + Object.fromEntries(Array.from(lockfile.matchAll(/^ {4}"([^"]+)": \["([^"]+)"/gm), m => [m[1], m[2]])); + + const manifests: Manifests = { + // star-peer@1.0.0's peer edge is the one being loaded; 2.0.0 exists so mid's copy of 1.0.0 nests. + "star-peer": { "1.0.0": { peerDependencies: { "peer-target": "*" } }, "2.0.0": {} }, + "peer-target": { "1.0.0": {}, "1.1.0": {} }, + // mid@1.0.0 is the second dependent of star-peer@1.0.0 and holds the other peer-target next to + // it; mid@2.0.0 at the root is what keeps mid@1.0.0 nested under parent. + "mid": { "1.0.0": { dependencies: { "star-peer": "1.0.0", "peer-target": "1.1.0" } }, "2.0.0": {} }, + "parent": { "1.0.0": { dependencies: { "star-peer": "2.0.0", "mid": "1.0.0" } } }, + }; + + it.concurrent("a `*` peer is read back from the copy its root-level printing placed at the root", async () => { + using registryServer = await serveRegistry(manifests); + const packageJson = (dependencies: Record) => JSON.stringify({ name: "app", dependencies }); + using dir = tempDir("bun-lock-several-paths-", { + "bunfig.toml": Bun.TOML.stringify({ install: { registry: registryServer.url } }), + "package.json": packageJson({ "star-peer": "1.0.0", "peer-target": "1.0.0", "mid": "2.0.0" }), + }); + const cwd = String(dir); + const lockfilePath = join(cwd, "bun.lock"); + + await installWithOwnCache(cwd); + expect(printedPackages(await file(lockfilePath).text())).toEqual({ + "mid": "mid@2.0.0", + "peer-target": "peer-target@1.0.0", + "star-peer": "star-peer@1.0.0", + }); + + // peer-target leaves package.json as parent enters it. star-peer's peer edge is still bound + // to peer-target@1.0.0, which keeps it in the file: the edge's own copy is placed at the root + // and mid's 1.1.0 is printed next to mid, which is where star-peer's second printing finds + // the name first. + await write(join(cwd, "package.json"), packageJson({ "star-peer": "1.0.0", "mid": "2.0.0", "parent": "1.0.0" })); + await installWithOwnCache(cwd); + expect(printedPackages(await file(lockfilePath).text())).toEqual({ + "mid": "mid@2.0.0", + "parent": "parent@1.0.0", + "peer-target": "peer-target@1.0.0", + "star-peer": "star-peer@1.0.0", + "parent/mid": "mid@1.0.0", + "parent/star-peer": "star-peer@2.0.0", + "parent/mid/peer-target": "peer-target@1.1.0", + "parent/mid/star-peer": "star-peer@1.0.0", + }); + + // Loading used to bind the edge from parent/mid/star-peer, the printing that comes last, to + // the 1.1.0 next to it. Nothing held peer-target@1.0.0 any more, so the tree built from the + // file differed from the file: --frozen-lockfile rejected it and an install rewrote it. + await rm(join(cwd, "node_modules"), { recursive: true }); + await installWithOwnCache(cwd, "--frozen-lockfile"); + expect(await file(join(cwd, "node_modules", "peer-target", "package.json")).json()).toMatchObject({ + version: "1.0.0", + }); + expect( + await file( + join(cwd, "node_modules", "parent", "node_modules", "mid", "node_modules", "peer-target", "package.json"), + ).json(), + ).toMatchObject({ version: "1.1.0" }); + }); + + // The shapes below are written by hand so nothing needs to be fetched: every row has an empty + // registry URL and integrity, which --lockfile-only and --frozen-lockfile --dry-run never read. + const pkg = (nameAndVersion: string, info: object = {}) => [nameAndVersion, "", info, ""]; + const printedDepth = (path: string) => path.split("/").filter(segment => !segment.startsWith("@")).length; + + async function writeProject(root: Record, packages: Record) { + const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } }); + // Rows in the order bun prints them (depth, then path), since loading reads them in file order. + const rows = Object.entries(packages).sort(([a], [b]) => printedDepth(a) - printedDepth(b) || a.localeCompare(b)); + await Promise.all([ + write(join(packageDir, "package.json"), JSON.stringify({ name: "foo", ...root })), + write( + join(packageDir, "bun.lock"), + JSON.stringify({ + lockfileVersion: 1, + configVersion: 0, + workspaces: { "": { name: "foo", ...root } }, + packages: Object.fromEntries(rows), + }), + ), + ]); + return { packageDir, run: makeInstallRunner(packageDir) }; + } + + // dup@1.0.0 is printed under a-parent and under z-parent (the root holds dup@2.0.0). Its + // optional peer wants no-deps@1.0.0 exactly; the root's no-deps is one-dep's 1.0.1, which the + // edge cannot be deduped onto (out of range, not a root dependency), so the copy the edge is + // bound to gets printed next to dup. The file records one such copy, under `holder`; the + // printing under the other parent walks up to the root's 1.0.1. An entry in a package's own + // path is only ever there for that package's own edge, so the edge has to load as 1.0.0 + // whichever parent sorts last, and the tree then holds 1.0.0 next to both printings. With the + // record under a-parent, binding from the last printing read the root's 1.0.1 instead and the + // re-save dropped the record. (uses-old keeps no-deps@1.0.0 in the file: a version held only + // by optional peer edges is dropped on save.) + it.concurrent.each(["a-parent", "z-parent"])( + "an optional peer's copy printed next to its %s printing is read from there", + async holder => { + const dup = pkg("dup@1.0.0", { peerDependencies: { "no-deps": "1.0.0" }, optionalPeers: ["no-deps"] }); + const packages: Record = { + "a-parent": pkg("a-parent@1.0.0", { dependencies: { "dup": "1.0.0" } }), + "dup": pkg("dup@2.0.0"), + "no-deps": pkg("no-deps@1.0.1"), + "one-dep": pkg("one-dep@1.0.0", { dependencies: { "no-deps": "1.0.1" } }), + "uses-old": pkg("uses-old@1.0.0", { dependencies: { "no-deps": "1.0.0" } }), + "z-parent": pkg("z-parent@1.0.0", { dependencies: { "dup": "1.0.0" } }), + "a-parent/dup": dup, + "uses-old/no-deps": pkg("no-deps@1.0.0"), + "z-parent/dup": dup, + [`${holder}/dup/no-deps`]: pkg("no-deps@1.0.0"), + }; + const { packageDir, run } = await writeProject( + { + dependencies: { + "a-parent": "1.0.0", + "dup": "2.0.0", + "one-dep": "1.0.0", + "uses-old": "1.0.0", + "z-parent": "1.0.0", + }, + }, + packages, + ); + + await run(["install", "--lockfile-only"]); + const saved = await file(join(packageDir, "bun.lock")).text(); + expect(printedPackages(saved)).toEqual({ + "a-parent": "a-parent@1.0.0", + "dup": "dup@2.0.0", + "no-deps": "no-deps@1.0.1", + "one-dep": "one-dep@1.0.0", + "uses-old": "uses-old@1.0.0", + "z-parent": "z-parent@1.0.0", + "a-parent/dup": "dup@1.0.0", + "uses-old/no-deps": "no-deps@1.0.0", + "z-parent/dup": "dup@1.0.0", + "a-parent/dup/no-deps": "no-deps@1.0.0", + "z-parent/dup/no-deps": "no-deps@1.0.0", + }); + + await run(["install", "--frozen-lockfile", "--dry-run"]); + await run(["install", "--lockfile-only"]); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(saved); + }, + ); + + // Same two printings of dup@1.0.0, but nothing is printed for its optional peer: the printing + // under a-q walks up to the root's no-deps@1.0.0 (b-old's, out of range), the one under z-p + // finds z-p's own 1.1.0, which the range accepts. This is the file a fresh install writes, and + // it has to keep loading from the printing that comes last (z-p's). The hoister rebinds an + // optional peer to whatever copy a printing dedupes onto, so loading it as the root's copy, + // the way the `*` peer in the first test is, builds a tree in which a-q's printing dedupes + // onto the root copy and z-p's then rebinds the edge to 1.1.0; the tree the save rebuilds + // from that binding nests 1.1.0 under a-q/dup, and --frozen-lockfile reports the difference + // as a changed lockfile. + it.concurrent("an optional peer printed nowhere keeps loading from the last printing", async () => { + const dup = pkg("dup@1.0.0", { peerDependencies: { "no-deps": "^1.1.0" }, optionalPeers: ["no-deps"] }); + const { run } = await writeProject( + { dependencies: { "a-q": "1.0.0", "b-old": "1.0.0", "dup": "2.0.0", "z-p": "1.0.0" } }, + { + "a-q": pkg("a-q@1.0.0", { dependencies: { "dup": "1.0.0" } }), + "b-old": pkg("b-old@1.0.0", { dependencies: { "no-deps": "1.0.0" } }), + "dup": pkg("dup@2.0.0"), + "no-deps": pkg("no-deps@1.0.0"), + "z-p": pkg("z-p@1.0.0", { dependencies: { "dup": "1.0.0", "no-deps": "1.1.0" } }), + "a-q/dup": dup, + "z-p/dup": dup, + "z-p/no-deps": pkg("no-deps@1.1.0"), + }, + ); + + await run(["install", "--frozen-lockfile", "--dry-run"]); + }); +}); From f1f8499b8cccb302ea447bbac602fe2ea239bc63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:15 +0000 Subject: [PATCH 009/258] install: only re-resolve rows a package still owns when overrides or catalogs change (#38849) --- .../PackageManager/install_with_manager.rs | 118 ++++++++--------- test/cli/install/catalogs.test.ts | 107 ++++++++++++++++ test/cli/install/nested-overrides.test.ts | 121 +++++++++++++++++- 3 files changed, 282 insertions(+), 64 deletions(-) diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 06c22d2f9a4c..71c9c997d5a6 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -485,70 +485,26 @@ pub fn install_with_manager( pinned_rows = enqueue_transitive(manager, &transitive, invalidates_rows)?; } - // `enqueueDependencyWithMain` can reach `Lockfile.Package.fromNPM`, - // which grows `buffers.dependencies` and may reallocate it. - // Iterate by index against a snapshot of the original length and - // copy each entry to the stack so neither the loop nor the callee - // ever reads through a pointer into the old backing storage. - if manager.summary.overrides_changed && !all_name_hashes.is_empty() { - let dependencies_len = manager.lockfile.buffers.dependencies.len(); - for dependency_i in 0..dependencies_len { - if pinned_rows.is_set_allow_out_of_bound(dependency_i, false) { - continue; - } - let dependency = - manager.lockfile.buffers.dependencies[dependency_i].clone(); - if all_name_hashes.binary_search(&dependency.name_hash).is_ok() { - manager.lockfile.buffers.resolutions[dependency_i] = - invalid_package_id; - if let Err(err) = enqueue_dependency_with_main( - manager, - dependency_i as u32, - &dependency, - invalid_package_id, - false, - ) { - add_dependency_error(manager, &dependency, err); - } - } - } - } - - if manager.summary.catalogs_changed { + if invalidates_rows { + let catalogs_changed = manager.summary.catalogs_changed; let mut catalog_overridden: Vec = Vec::new(); - manager - .lockfile - .overrides - .append_catalog_valued_name_hashes(&mut catalog_overridden); - catalog_overridden.sort_unstable(); - catalog_overridden.dedup(); - let dependencies_len = manager.lockfile.buffers.dependencies.len(); - for _dep_id in 0..dependencies_len { - let dep_id: DependencyID = u32::try_from(_dep_id).expect("int cast"); - if pinned_rows.is_set_allow_out_of_bound(_dep_id, false) { - continue; - } - let dep = - manager.lockfile.buffers.dependencies[dep_id as usize].clone(); - if dep.version.tag != DependencyVersionTag::Catalog - && (catalog_overridden.is_empty() - || catalog_overridden.binary_search(&dep.name_hash).is_err()) - { - continue; - } - - manager.lockfile.buffers.resolutions[dep_id as usize] = - invalid_package_id; - if let Err(err) = enqueue_dependency_with_main( - manager, - dep_id, - &dep, - invalid_package_id, - false, - ) { - add_dependency_error(manager, &dep, err); - } + if catalogs_changed { + manager + .lockfile + .overrides + .append_catalog_valued_name_hashes(&mut catalog_overridden); + catalog_overridden.sort_unstable(); + catalog_overridden.dedup(); } + // `all_name_hashes` is empty unless the overrides changed. + reresolve_owned_rows(manager, &pinned_rows, |dependency| { + all_name_hashes.binary_search(&dependency.name_hash).is_ok() + || (catalogs_changed + && dependency.version.tag == DependencyVersionTag::Catalog) + || catalog_overridden + .binary_search(&dependency.name_hash) + .is_ok() + }); } // Split this into two passes because the below may allocate memory or invalidate pointers @@ -1536,7 +1492,7 @@ fn report_lockfile_load_error( Ok(()) } -/// Returns the rows the plan re-resolved so the overrides/catalogs invalidation loops that follow leave them pinned; only tracked when those loops will run. +/// Returns the rows the plan re-resolved so the overrides/catalogs invalidation pass that follows leaves them pinned; only tracked when that pass will run. fn enqueue_transitive( manager: &mut PackageManager, transitive: &TransitiveUpdate, @@ -1549,6 +1505,42 @@ fn enqueue_transitive( transitive.enqueue_tracked(manager) } +/// Re-resolves the rows `selects`, walking each package's current dependency list in package order. The root's +/// list (just rebuilt by the differ) goes first because a later row dedupes onto what an earlier one appended +/// (`Lockfile::get_package_id`); the root's loaded rows, now in no list, would resolve as nobody's and are not +/// walked, nor are `pinned_rows`, which the update plan just resolved. A workspace the add/update pass is about +/// to re-read still holds its loaded list here and is walked like any other package. +fn reresolve_owned_rows( + manager: &mut PackageManager, + pinned_rows: &DynamicBitSet, + selects: impl Fn(&Dependency) -> bool, +) { + // Resolving appends packages and rows; only the lists present now are walked. + let lists: Vec = + manager.lockfile.packages.items_dependencies().to_vec(); + for list in lists { + for dep_id in list.begin()..list.end() { + if pinned_rows.is_set_allow_out_of_bound(dep_id as usize, false) { + continue; + } + let dependency = manager.lockfile.buffers.dependencies[dep_id as usize].clone(); + if !selects(&dependency) { + continue; + } + manager.lockfile.buffers.resolutions[dep_id as usize] = invalid_package_id; + if let Err(err) = enqueue_dependency_with_main( + manager, + dep_id, + &dependency, + invalid_package_id, + false, + ) { + add_dependency_error(manager, &dependency, err); + } + } + } +} + #[derive(Default)] struct NamedUpdates { /// Invalidated rows paired with the package they resolved to, for redirect_moved_edges. diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index 159c6aabe4aa..88fb656a382f 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -259,6 +259,54 @@ describe("basic", () => { await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); }); + // A file: path outside the project is only accepted on a row the root (or a workspace) owns. A catalog change + // re-resolves every catalog: row; the rows the root was loaded with have been replaced by then and belong to + // nobody, so re-resolving them fails the way an escaping transitive file: dependency does. + test.concurrent("changing the entry of a catalog: dependency pointing outside the project", async () => { + const packageJson = (xPath: string) => + JSON.stringify({ + name: "catalog-file-dep", + workspaces: { packages: [], catalog: { x: xPath } }, + dependencies: { x: "catalog:" }, + }); + using dir = tempDir("catalog-file-dep", { + "x/package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "x2/package.json": JSON.stringify({ name: "x", version: "2.0.0" }), + "project/package.json": packageJson("file:../x"), + }); + const packageDir = join(String(dir), "project"); + const installedVersion = async () => + (await file(join(packageDir, "node_modules", "x", "package.json")).json()).version; + + await runBunInstall(bunEnv, packageDir); + expect(await installedVersion()).toBe("1.0.0"); + + await write(join(packageDir, "package.json"), packageJson("file:../x2")); + await runBunInstall(bunEnv, packageDir); + expect(await installedVersion()).toBe("2.0.0"); + expect(normalizeBunSnapshot(await file(join(packageDir, "bun.lock")).text(), packageDir)).toMatchInlineSnapshot(` + "{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "catalog-file-dep", + "dependencies": { + "x": "catalog:", + }, + }, + }, + "catalog": { + "x": "file:../x2", + }, + "packages": { + "x": ["x@file:../x2", {}], + } + }" + `); + await runBunInstall(bunEnv, packageDir, { frozenLockfile: true }); + }); + test.concurrent("catalog and catalogs.default may split different packages between them", async () => { const { packageDir } = await registry.createTestDir({ files: { @@ -1274,6 +1322,65 @@ describe("peer dependencies", () => { expect(await packageKeys(dir)).toStrictEqual(dedupedKeys); }); + // When the catalog changes, every row declared through it is re-resolved. The root's rows from bun.lock have + // been replaced by then and belong to no package; re-resolving them too bound the peer a second time and + // repeated its warning. + describe("a root peer whose catalog range stops matching the installed version is checked once", () => { + const peerWarning = 'warn: incorrect peer dependency "no-deps@1.0.0"'; + const peerWarnings = (err: string) => err.split(peerWarning).length - 1; + + function rootWithPeer(peerSpec: string, fields: Record = {}) { + return JSON.stringify({ name: "root", peerDependencies: { "no-deps": peerSpec }, ...fields }); + } + + async function installedAlone(packageJson: string) { + const { packageDir } = await registry.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { "package.json": packageJson }, + }); + const { err } = await install(packageDir, "hoisted"); + expect(peerWarnings(err)).toBe(0); + expect((await Bun.file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe( + "1.0.0", + ); + return packageDir; + } + + async function reinstall(dir: string, packageJson: string) { + await Bun.write(join(dir, "package.json"), packageJson); + const { err } = await install(dir, "hoisted"); + expect(err).toContain("Saved lockfile"); + return peerWarnings(err); + } + + // The inline row is the baseline: it changes itself and is only re-enqueued by the add/update pass. + test.concurrent.each([ + [ + "through the catalog", + (range: string) => rootWithPeer("catalog:", { workspaces: { catalog: { "no-deps": range } } }), + ], + ["inline", (range: string) => rootWithPeer(range)], + ])("declared %s", async (_, packageJson) => { + const dir = await installedAlone(packageJson("1.0.0")); + expect(await reinstall(dir, packageJson("^1.0.1"))).toBe(1); + }); + + test.concurrent("overridden to catalog:", async () => { + const packageJson = (range: string) => + rootWithPeer("1.0.0", { overrides: { "no-deps": "catalog:" }, workspaces: { catalog: { "no-deps": range } } }); + const dir = await installedAlone(packageJson("1.0.0")); + expect(await reinstall(dir, packageJson("^1.0.1"))).toBe(1); + }); + + // Selected both as an overridden name and as a catalog: row; one pass handles both. + test.concurrent("declared through the catalog while an override of the same name changes too", async () => { + const packageJson = (range: string) => + rootWithPeer("catalog:", { overrides: { "no-deps": range }, workspaces: { catalog: { "no-deps": range } } }); + const dir = await installedAlone(packageJson("1.0.0")); + expect(await reinstall(dir, packageJson("^1.0.1"))).toBe(1); + }); + }); + // pnpm: deps-installer/test/catalogs.ts "frozen lockfile error is thrown if catalog config changes" test.concurrent("--frozen-lockfile fails when only a peer's catalog range changed", async () => { const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", linker: "hoisted" }); diff --git a/test/cli/install/nested-overrides.test.ts b/test/cli/install/nested-overrides.test.ts index 9308eb302438..43526a00c5d0 100644 --- a/test/cli/install/nested-overrides.test.ts +++ b/test/cli/install/nested-overrides.test.ts @@ -2,7 +2,7 @@ import { file, write } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, realpathSync } from "fs"; import { rm } from "fs/promises"; -import { VerdaccioRegistry, bunEnv, bunExe } from "harness"; +import { VerdaccioRegistry, bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; import { join } from "path"; const registry = new VerdaccioRegistry(); @@ -1290,6 +1290,125 @@ describe.concurrent("lockfile", () => { await installOk(dir, "--frozen-lockfile"); }); + // An overrides change re-resolves every row naming a previously or newly overridden package. By then the root's + // rows from bun.lock have been replaced by freshly parsed ones and belong to no package; they must not be + // re-resolved as well: a range that is no longer in package.json would add a package the current row then dedupes + // onto, and a file: path outside the project is only accepted on a row the root (or a workspace) owns. The + // root's current rows also have to be resolved before everybody else's, as on a fresh install, since a row + // resolved later dedupes onto a same-major package an earlier one added. + describe.concurrent("removing a flat rule re-resolves only the root's current rows", () => { + test("the root's row resolves before a dependency's row of the same name", async () => { + const deps = { "no-deps": "^1.0.0", "one-dep": "1.0.0" }; // one-dep@1.0.0 depends on no-deps@1.0.1 + const [dir, fresh] = await Promise.all([ + project({ dependencies: deps, overrides: { "no-deps": "2.0.0" } }), + project({ dependencies: deps }), + ]); + await Promise.all([installOk(dir), installOk(fresh)]); + expect(await lock(dir)).toContain("no-deps@2.0.0"); + + await write(join(dir, "package.json"), JSON.stringify({ name: "nested-overrides", dependencies: deps })); + const { err } = await installOk(dir); + expect(err).toContain("Saved lockfile"); + expect(await versionSeenBy(dir, undefined, "no-deps")).toBe("1.1.0"); + expect(await versionSeenBy(dir, "one-dep", "no-deps")).toBe("1.0.1"); + // Same packages as installing this package.json from scratch. + expect(await lock(dir)).toBe(await lock(fresh)); + }); + + test("a range changed in the same edit resolves on its own", async () => { + const dir = await project({ dependencies: { "no-deps": "~1.0.0" }, overrides: { "no-deps": "2.0.0" } }); + await installOk(dir); + expect(await versionSeenBy(dir, undefined, "no-deps")).toBe("2.0.0"); + + await write( + join(dir, "package.json"), + JSON.stringify({ name: "nested-overrides", dependencies: { "no-deps": "^1.0.0" } }), + ); + const { err } = await installOk(dir); + expect(err).toContain("Saved lockfile"); + // 1.0.1 is what the dropped ~1.0.0 range would pick. + expect(await versionSeenBy(dir, undefined, "no-deps")).toBe("1.1.0"); + const after = await lock(dir); + expect(after).not.toContain('"overrides"'); + expect(after).not.toContain("no-deps@1.0.1"); + expect(after).not.toContain("no-deps@2.0.0"); + await installOk(dir, "--frozen-lockfile"); + }); + + const outside = { + "x/package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "x2/package.json": JSON.stringify({ name: "x", version: "2.0.0" }), + "project/y/package.json": JSON.stringify({ name: "y", version: "1.0.0" }), + }; + const rootPackageJson = (pkg: Record) => JSON.stringify({ name: "nested-overrides", ...pkg }); + const before = rootPackageJson({ + dependencies: { x: "file:../x", y: "file:./y" }, + overrides: { x: "file:../x2" }, + }); + + test("a file: dependency outside the project re-resolves to its own path", async () => { + using root = tempDir("override-removed-file-dep", { ...outside, "project/package.json": before }); + const dir = join(String(root), "project"); + await installOk(dir); + expect(await versionSeenBy(dir, undefined, "x")).toBe("2.0.0"); + + await write(join(dir, "package.json"), rootPackageJson({ dependencies: { x: "file:../x", y: "file:./y" } })); + const { err } = await installOk(dir); + expect(err).toContain("Saved lockfile"); + expect(await versionSeenBy(dir, undefined, "x")).toBe("1.0.0"); + expect(normalizeBunSnapshot(await lock(dir), dir)).toMatchInlineSnapshot(` + "{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "nested-overrides", + "dependencies": { + "x": "file:../x", + "y": "file:./y", + }, + }, + }, + "packages": { + "x": ["x@file:../x", {}], + + "y": ["y@file:y", {}], + } + }" + `); + await installOk(dir, "--frozen-lockfile"); + }); + + test("a file: dependency outside the project removed together with its rule", async () => { + using root = tempDir("override-and-file-dep-removed", { ...outside, "project/package.json": before }); + const dir = join(String(root), "project"); + await installOk(dir); + expect(await versionSeenBy(dir, undefined, "x")).toBe("2.0.0"); + + await write(join(dir, "package.json"), rootPackageJson({ dependencies: { y: "file:./y" } })); + const { err } = await installOk(dir); + expect(err).toContain("Saved lockfile"); + expect(normalizeBunSnapshot(await lock(dir), dir)).toMatchInlineSnapshot(` + "{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "nested-overrides", + "dependencies": { + "y": "file:./y", + }, + }, + }, + "packages": { + "y": ["y@file:y", {}], + } + }" + `); + await installOk(dir, "--frozen-lockfile"); + }); + }); + test("changing only the parent's range text is a frozen-lockfile change", async () => { const dir = await project({ dependencies: twoParents, overrides: { "one-fixed-dep@1": { "no-deps": "1.1.0" } } }); await installOk(dir); From 5ff49e05e4aafb2eadd2f19b21ebd7d6fe7dddaa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:20 +0000 Subject: [PATCH 010/258] install: print the update row for catalog entries moved by bun update (#38763) --- src/install/PackageManager.rs | 13 +++ .../PackageManager/PackageJSONEditor.rs | 64 +++++++++++++-- .../PackageManager/install_with_manager.rs | 23 ++---- src/install/lockfile/printer/tree_printer.rs | 69 +++++++++++++++- src/install/update_transitive.rs | 12 +-- test/cli/install/catalogs.test.ts | 82 ++++++++++++++++++- 6 files changed, 225 insertions(+), 38 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e1524675ffb9..9594ec991990 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -584,10 +584,23 @@ pub struct PackageUpdateInfo { pub(crate) original_version_literal: Box<[u8]>, // set by the post-install write-back; the install summary still needs the entry pub(crate) written_back: bool, + /// Registered by `package_json_editor::record_catalog_originals`: the name's `catalog:` rows carry the move, so the install summary reports it through them. + pub(crate) catalog_entry: bool, pub(crate) original_version_string_buf: Box<[u8]>, pub(crate) original_version: Option, } +impl PackageUpdateInfo { + /// `version`'s tag strings live in `buf` (a lockfile string buffer that cleaning rebuilds), so the original keeps its own copy of them. + pub(crate) fn set_original_version(&mut self, version: Semver::Version, buf: &[u8]) { + let mut tag_buf = + vec![0u8; version.tag.pre.len() + version.tag.build.len()].into_boxed_slice(); + let mut cursor: &mut [u8] = &mut tag_buf; + self.original_version = Some(version.clone_into(buf, &mut cursor)); + self.original_version_string_buf = tag_buf; + } +} + pub struct CatalogUpdateInfo { /// Catalog group name; empty for the default catalog. pub catalog_name: Box<[u8]>, diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 00a19a9c55f3..704181b224a5 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -507,9 +507,7 @@ fn edit_update_entries( *entry.value_ptr = PackageUpdateInfo { original_version_literal: version_literal_owned, - written_back: false, - original_version_string_buf: Box::default(), - original_version: None, + ..Default::default() }; if update_to_latest { @@ -751,6 +749,62 @@ pub(crate) fn edit_catalogs_before_update( Ok(!manager.updating_catalogs.is_empty()) } +/// Runs on the loaded lockfile, before the differ: every `catalog:` row of an entry recorded by `edit_catalogs_before_update` registers its name in `updating_packages` with the row's locked version as the original, the way the cwd's own dependency lists register theirs, so the install summary prints the entry's move as an update row; a name those lists already registered keeps their original. +pub(crate) fn record_catalog_originals( + manager: &mut PackageManager, +) -> Result<(), bun_alloc::AllocError> { + let infos: &[CatalogUpdateInfo] = &manager.updating_catalogs; + if infos.is_empty() { + return Ok(()); + } + let by_name = CatalogInfoIndex::init(infos)?; + let lockfile: &Lockfile = &manager.lockfile; + let updating_packages = &mut manager.updating_packages; + let string_buf = lockfile.buffers.string_bytes.as_slice(); + let package_resolutions = lockfile.packages.items_resolution(); + + for (dep, &package_id) in lockfile + .buffers + .dependencies + .iter() + .zip(lockfile.buffers.resolutions.iter()) + { + if dep.version.tag != dependency::Tag::Catalog + || (package_id as usize) >= package_resolutions.len() + { + continue; + } + let resolution = &package_resolutions[package_id as usize]; + if resolution.tag != resolution::Tag::Npm { + continue; + } + let dep_name = dep.name.slice(string_buf); + let catalog_name = dep.version.catalog().slice(string_buf); + let Some(info) = by_name + .candidates(dep_name) + .and_then(|candidates| CatalogInfoIndex::pick(candidates, infos, catalog_name)) + .map(|i| &infos[i]) + else { + continue; + }; + let entry = updating_packages.get_or_put(dep_name)?; + if entry.found_existing { + continue; + } + *entry.value_ptr = PackageUpdateInfo { + original_version_literal: info.original_version_literal.clone(), + // The entry is written by `edit_catalogs_after_update`; `edit_update_entries` has nothing of it to write into the cwd's dependency lists. + written_back: true, + catalog_entry: true, + ..Default::default() + }; + entry + .value_ptr + .set_original_version(resolution.npm().version, string_buf); + } + Ok(()) +} + /// Writes each recorded catalog entry's resolved literal (unresolved ones are restored) into the root AST; returns `changed`. pub(crate) fn edit_catalogs_after_update( manager: &mut PackageManager, @@ -1041,9 +1095,7 @@ pub(crate) fn edit( *entry.value_ptr = PackageUpdateInfo { original_version_literal: version_literal_owned, - written_back: false, - original_version_string_buf: Box::default(), - original_version: None, + ..Default::default() }; } } diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 71c9c997d5a6..851724b25158 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -24,7 +24,7 @@ use crate::{ }; // Bring the typed `items_()` column accessors into scope for // `MultiArrayList` / `Slice`. -use super::Command; +use super::{Command, PackageJSONEditor}; use crate::PackageManager; use crate::config_version::ConfigVersion; use crate::hoisted_install::install_hoisted_packages; @@ -1844,24 +1844,13 @@ fn record_updating_package_versions(manager: &mut PackageManager) { if original_resolution.tag != ResolutionTag::Npm { continue; } - - let mut original = original_resolution.npm().version; - let tag_total = original.tag.pre.len() + original.tag.build.len(); - if tag_total > 0 { - let mut tag_buf = vec![0u8; tag_total].into_boxed_slice(); - let mut ptr = &mut tag_buf[..]; - original.tag = original_resolution - .npm() - .version - .tag - .clone_into(&lockfile.buffers.string_bytes, &mut ptr); - - entry_ptr.original_version_string_buf = tag_buf; - } - - entry_ptr.original_version = Some(original); + entry_ptr.set_original_version( + original_resolution.npm().version, + &lockfile.buffers.string_bytes, + ); } } + PackageJSONEditor::record_catalog_originals(manager).unwrap_or_oom(); } fn root_package_json_source( diff --git a/src/install/lockfile/printer/tree_printer.rs b/src/install/lockfile/printer/tree_printer.rs index 4c7385b7748c..3bf736768cb6 100644 --- a/src/install/lockfile/printer/tree_printer.rs +++ b/src/install/lockfile/printer/tree_printer.rs @@ -7,8 +7,8 @@ use crate::package_manager_real::TrackInstalledBin; use bun_core::fmt::PathSep; use bun_install::lockfile::{Printer, package::Meta as PackageMeta}; use bun_install::{ - self as install, Bin, Dependency, DependencyID, INVALID_PACKAGE_ID, PackageID, PackageManager, - PackageNameHash, Resolution, Subcommand, bin, resolution, + self as install, Bin, Dependency, DependencyID, DependencyVersionTag, INVALID_PACKAGE_ID, + PackageID, PackageManager, PackageNameHash, Resolution, Subcommand, bin, resolution, }; use bun_sys::Fd; @@ -27,6 +27,8 @@ fn print_installed_workspace_section< printed_new_install: &mut bool, id_map: Option<&mut [DependencyID]>, update_owners: &[PackageID], + // The summary has no other sections, so `print_catalog_entry_updates` runs here. + sole_section: bool, ) -> Result<(), crate::Error> where W: Write, @@ -114,6 +116,19 @@ where } if !PRINT_SECTION_HEADER { + if sole_section + && print_catalog_entry_updates::( + this, + manager, + installed, + pkg_metas, + &mut update_dedupe, + writer, + )? + { + *printed_new_install = true; + printed_update = true; + } if print_transitive_updates::( this, manager, @@ -375,6 +390,53 @@ where Ok(()) } +/// A bare `bun update` moves the root's catalog entries for every importer at once, but the summary's one section only walks the rows of `update_owners` (above, hence the shared `update_dedupe`), so the entries consumed elsewhere are reported through whichever `catalog:` row names them. The verbose summary prints a section per importer, each reporting its own `catalog:` rows, and skips this. Like a direct dependency's row, a row prints whether or not its new version had to be installed. +fn print_catalog_entry_updates( + this: &Printer, + manager: &mut PackageManager, + installed: &Bitset, + pkg_metas: &[PackageMeta], + update_dedupe: &mut HashMap, + writer: &mut W, +) -> Result +where + W: Write, +{ + if !manager + .updating_packages + .values() + .iter() + .any(|info| info.catalog_entry) + { + return Ok(false); + } + let string_buf = this.lockfile.buffers.string_bytes.as_slice(); + let dependencies = this.lockfile.buffers.dependencies.as_slice(); + let mut printed = false; + for (dep_id, dep) in dependencies.iter().enumerate() { + if dep.version.tag != DependencyVersionTag::Catalog + || !manager + .updating_packages + .get(dep.name.slice(string_buf)) + .is_some_and(|info| info.catalog_entry) + { + continue; + } + let dep_id = DependencyID::try_from(dep_id).expect("int cast"); + let ShouldPrintPackageInstallResult::Update(update_info) = + should_print_package_install(this, manager, dep_id, installed, None, pkg_metas) + else { + continue; + }; + if update_dedupe.get_or_put(dep.name_hash)?.found_existing { + continue; + } + print_updated_package::(this, manager, &update_info, writer)?; + printed = true; + } + Ok(printed) +} + /// Packages registered by the transitive half of `bun update` are not rows of the walked workspaces, so the walk above never reaches them; the walked workspaces' own targets stay with them. fn print_transitive_updates( this: &Printer, @@ -629,6 +691,7 @@ where &mut had_printed_new_install, None, &[0], + false, )?; for &workspace_dep_id in &workspaces_to_print { @@ -642,6 +705,7 @@ where &mut had_printed_new_install, None, &[workspace_package_id], + false, )?; } } else { @@ -693,6 +757,7 @@ where &mut had_printed_new_install, Some(&mut id_map), &update_owners, + true, )?; } } else { diff --git a/src/install/update_transitive.rs b/src/install/update_transitive.rs index 9492b81a4401..b5358524a658 100644 --- a/src/install/update_transitive.rs +++ b/src/install/update_transitive.rs @@ -654,16 +654,8 @@ pub(crate) fn register_moved( continue; } } - let mut tag_buf = - vec![0u8; current.tag.pre.len() + current.tag.build.len()].into_boxed_slice(); - let mut cursor: &mut [u8] = &mut tag_buf; - let original = current.clone_into(buf, &mut cursor); - *entry.value_ptr = PackageUpdateInfo { - original_version_literal: Box::default(), - written_back: false, - original_version_string_buf: tag_buf, - original_version: Some(original), - }; + *entry.value_ptr = PackageUpdateInfo::default(); + entry.value_ptr.set_original_version(current, buf); } Ok(()) } diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index 88fb656a382f..c06304ed2474 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -400,9 +400,14 @@ describe("update", () => { await createUpdateMonorepo(packageDir, `catalog-update-latest-${label.replace(/\W+/g, "-")}`, isTopLevel); await runBunInstall(bunEnv, packageDir); - const { err, exitCode } = await runUpdate(packageDir, ...flags); + const { out, err, exitCode } = await runUpdate(packageDir, ...flags); expect(err).not.toContain("error:"); + // The moved entry is reported like a direct dependency of the root, even though only pkg1 depends on it. + // a-dep still resolves to 1.0.10 (only its literal changes), so it gets no row. + expect(out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + expect(out.match(/^.*a-dep.*$/gm)).toBeNull(); + // catalog entries are updated, preserving the pinning style const root = await file(join(packageDir, "package.json")).json(); const { catalog, catalogs } = isTopLevel ? root : root.workspaces; @@ -453,9 +458,12 @@ describe("update", () => { await createUpdateMonorepo(packageDir, "catalog-update-in-workspace"); await runBunInstall(bunEnv, packageDir); - const { err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"), "--latest"); + const { out, err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"), "--latest"); expect(err).not.toContain("error:"); + // pkg1's own `catalog:` row is an update row, not a `+ no-deps@2.0.0` install row. + expect(out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + const root = await file(join(packageDir, "package.json")).json(); expect(root.workspaces.catalog).toEqual({ "no-deps": "^2.0.0" }); expect(root.workspaces.catalogs).toEqual({ a: { "a-dep": "~1.0.10" } }); @@ -467,6 +475,73 @@ describe("update", () => { expect(exitCode).toBe(0); }); + test("--latest reports a catalog entry the root itself depends on once", async () => { + const { packageDir } = await registry.createTestDir(); + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "catalog-update-root-consumer", + workspaces: { packages: ["packages/*"], catalog: { "no-deps": "^1.0.0" } }, + dependencies: { "no-deps": "catalog:" }, + }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ name: "pkg1", dependencies: { "no-deps": "catalog:" } }), + ), + ]); + await runBunInstall(bunEnv, packageDir); + + const { out, err, exitCode } = await runUpdate(packageDir, "--latest"); + expect(err).not.toContain("error:"); + expect(out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + + const root = await file(join(packageDir, "package.json")).json(); + expect(root.workspaces.catalog).toEqual({ "no-deps": "^2.0.0" }); + expect(root.dependencies).toEqual({ "no-deps": "catalog:" }); + expect(exitCode).toBe(0); + }); + + test("--latest --verbose reports a catalog entry once, under the workspace that depends on it", async () => { + const { packageDir } = await registry.createTestDir(); + await createUpdateMonorepo(packageDir, "catalog-update-verbose"); + await runBunInstall(bunEnv, packageDir); + + const { out, err, exitCode } = await runUpdate(packageDir, "--latest", "--verbose"); + expect(err).not.toContain("error:"); + const lines = out.split(/\r?\n/); + expect(lines.filter(line => line.includes("no-deps"))).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + expect(lines[lines.indexOf("pkg1:") + 1]).toBe("^ no-deps 1.1.0 -> 2.0.0"); + expect(exitCode).toBe(0); + }); + + test("--latest reports a catalog entry whose new version needs no install (isolated store already has it)", async () => { + const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + await createUpdateMonorepo(packageDir, "catalog-update-isolated-rerun"); + await runBunInstall(bunEnv, packageDir); + const rootBefore = await file(join(packageDir, "package.json")).text(); + const lockBefore = await file(join(packageDir, "bun.lock")).text(); + + const first = await runUpdate(packageDir, "--latest"); + expect(first.err).not.toContain("error:"); + expect(first.out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + expect(first.exitCode).toBe(0); + + // Like `git checkout .` followed by `bun install`: the project is back on 1.1.0 while node_modules/.bun keeps no-deps@2.0.0. + await Promise.all([ + write(join(packageDir, "package.json"), rootBefore), + write(join(packageDir, "bun.lock"), lockBefore), + ]); + await runBunInstall(bunEnv, packageDir, { savesLockfile: false }); + + const second = await runUpdate(packageDir, "--latest"); + expect(second.err).not.toContain("error:"); + expect(second.out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.1.0 -> 2.0.0"]); + expect((await file(join(packageDir, "package.json")).json()).workspaces.catalog).toEqual({ "no-deps": "^2.0.0" }); + expect(second.exitCode).toBe(0); + }); + test("--latest updates the same package independently per catalog", async () => { const { packageDir } = await registry.createTestDir(); await Promise.all([ @@ -590,8 +665,9 @@ describe("update", () => { ), ]); - const { err, exitCode } = await runUpdate(packageDir, ...args); + const { out, err, exitCode } = await runUpdate(packageDir, ...args); expect(err).not.toContain("error:"); + expect(out.match(/^.*no-deps.*$/gm)).toStrictEqual(["^ no-deps 1.0.0 -> 1.1.0 (v2.0.0 available)"]); const root = await file(join(packageDir, "package.json")).json(); expect(root.workspaces.catalog).toEqual({ "no-deps": "^1.1.0" }); From 28bd7ed4a729a516ba4684507bb7cc42df7648ca Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:23 +0000 Subject: [PATCH 011/258] install: keep workspace and other non-registry entries as written on bun update (#38847) --- .../PackageManager/PackageJSONEditor.rs | 6 +- src/install/lockfile.rs | 4 ++ .../install/bun-update-lockfile-sync.test.ts | 65 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 704181b224a5..4c3250ccfafe 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1384,9 +1384,9 @@ pub(crate) fn edit( // derived from a `StoreRef` to the same `E::EString` is live inside this loop body, // so this is the sole mutable borrow. let e_string = unsafe { &mut *e_string }; - // `bun update ` keeps a `catalog:` reference; `bun add` still replaces it. + // `bun update ` only moves registry entries, like `edit_update_entries`; `bun add` still replaces any entry. if manager.subcommand == Subcommand::Update - && dependency::Tag::infer(e_string.data.slice()) == dependency::Tag::Catalog + && !dependency::Tag::infer(e_string.data.slice()).is_npm() { continue; } @@ -1499,6 +1499,8 @@ pub(crate) fn edit( arena_dup(arena, installed) } + // A range that linked a workspace member has nothing to move to; `workspace:*` is what `bun add` writes. + resolution::Tag::Workspace if manager.subcommand == Subcommand::Update => continue, resolution::Tag::Workspace => b"workspace:*", _ => arena_dup(arena, request.version.literal.slice(request.version_buf())), }; diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index f97520b42842..ff3f5bcc5ff5 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -910,6 +910,10 @@ impl Lockfile { let resolved_ids: &[PackageID] = res_list.get(self.buffers.resolutions.as_slice()); debug_assert_eq!(resolved_ids.len(), workspace_deps.len()); for (&package_id, dep) in resolved_ids.iter().zip(workspace_deps.iter()) { + // The root's implicit `workspaces` rows are not package.json entries; bind to the entry naming the package. + if dep.behavior.is_workspace() { + continue; + } if update.matches(dep, string_buf) { if package_id as usize > self.packages.len() { continue; diff --git a/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index d83f6eb72cf7..1064a9671139 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -280,6 +280,71 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await expectInSync(dir); }); + // Naming a workspace member used to rewrite the entry linking it to `workspace:*` (or, with --latest / an explicit + // range, to send the name to the registry), and to add an entry to a root that did not declare the member. + test.each([ + ["workspace:^", ["pkg1"]], + ["workspace:~", ["pkg1"]], + ["workspace:1.0.0", ["pkg1"]], + ["^1.0.0", ["pkg1"]], + ["workspace:^", ["pkg1", "--latest"]], + ["workspace:^", ["pkg1@^1.0.0"]], + ["workspace:^", ["pkg1@latest"]], + ])("a %s entry linking a workspace member is kept as written by bun update %j", async (literal, args) => { + const dir = await setup(MONOREPO({}, { dependencies: { pkg1: literal } })); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", ...args); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + + // Same rule for the other non-registry kinds: the registry has a no-deps, so naming this entry with --latest or an + // explicit spec used to replace the folder with the registry package (exit 0). + test.each([[["no-deps"]], [["no-deps", "--latest"]], [["no-deps@^1.0.0"]], [["no-deps@latest"]]])( + "a file: entry is kept as written by bun update %j", + async args => { + const dir = await setup({ + "package.json": root({ dependencies: { "no-deps": "file:./local-no-deps" } }), + "local-no-deps/package.json": { name: "no-deps", version: "1.0.0" }, + }); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", ...args); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" }); + }, + ); + + test("bun update from a member declaring it keeps the entry as written", async () => { + const dir = await setup(WORKSPACES({}, { pkg1: {}, pkg2: { dependencies: { pkg1: "workspace:~" } } })); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir, PKG2), lockText(dir)]); + await runIn(dir, PKG2, "update", "pkg1"); + expect(await pkgText(dir, PKG2)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + + test("bun update -r keeps every workspace's entry as written", async () => { + const dir = await setup( + WORKSPACES( + { dependencies: { pkg1: "workspace:^" } }, + { pkg1: {}, pkg2: { dependencies: { pkg1: "workspace:1.0.0" } } }, + ), + ); + const [rootBefore, pkg2Before, lockBefore] = await Promise.all([pkgText(dir), pkgText(dir, PKG2), lockText(dir)]); + await run(dir, "update", "pkg1", "-r"); + expect(await pkgText(dir)).toBe(rootBefore); + expect(await pkgText(dir, PKG2)).toBe(pkg2Before); + expect(await lockText(dir)).toBe(lockBefore); + }); + + test("bun update does not add it to a root that does not declare it", async () => { + const dir = await setup(MONOREPO()); + const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]); + await run(dir, "update", "pkg1"); + expect(await pkgText(dir)).toBe(pkgBefore); + expect(await lockText(dir)).toBe(lockBefore); + }); + test.each([[[]], [["--latest"]]])("bun update %j leaves folder, tarball and workspace literals alone", async args => { const dependencies = { "no-deps": "^1.0.0", From d551e67dc2a6ea9434eb08e0a7c30ca89fcf1357 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:27 +0000 Subject: [PATCH 012/258] Report package.json sync in the bun update summary instead of (no changes) (#38918) --- src/install/PackageManager.rs | 5 ++++ .../PackageManager/install_with_manager.rs | 12 ++++++-- .../PackageManager/package_json_write_back.rs | 6 +++- test/cli/install/bun-install-registry.test.ts | 11 +++---- test/cli/install/bun-update.test.ts | 30 +++++++++++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 9594ec991990..39599591b2ec 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -447,6 +447,9 @@ pub struct PackageManager { // package.json cache entries that differ from disk; written by package_json_write_back::flush. pub(crate) edited_package_jsons: Vec, + // Set by package_json_write_back::flush when it rewrites a file; read by the install summary. + pub(crate) wrote_package_json: bool, + // bun add: catalog references decided per target and the root entries they need; see add_catalog.rs pub(crate) catalog_add: add_catalog::State, @@ -2133,6 +2136,7 @@ pub fn init( wr!(filtered_link_targets, None); wr!(pending_filtered_write, None); wr!(edited_package_jsons, Vec::new()); + wr!(wrote_package_json, false); wr!(catalog_add, add_catalog::State::default()); wr!(patched_dependencies_to_remove, ArrayHashMap::default()); wr!(last_reported_slow_lifecycle_script_at, 0); @@ -2582,6 +2586,7 @@ fn init_with_runtime_once( wr!(filtered_link_targets, None); wr!(pending_filtered_write, None); wr!(edited_package_jsons, Vec::new()); + wr!(wrote_package_json, false); wr!(catalog_add, add_catalog::State::default()); wr!(patched_dependencies_to_remove, ArrayHashMap::default()); wr!(last_reported_slow_lifecycle_script_at, 0); diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 851724b25158..ecb78304ec88 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -1134,9 +1134,15 @@ fn print_install_summary( { // Hot no-op path (install/fastify bench): kept inline. let count = this.lockfile.packages.len() as PackageID; + // "(no changes)" would misreport an update that rewrote package.json. + let note = if this.subcommand == Subcommand::Update && this.wrote_package_json { + "up to date, package.json synced" + } else { + "no changes" + }; if count != install_summary.skipped { bun_core::pretty!( - "Checked {} install{} across {} package{} (no changes) ", + "Checked {} install{} across {} package{} ({}) ", install_summary.skipped, if install_summary.skipped == 1 { "" @@ -1145,19 +1151,21 @@ fn print_install_summary( }, count, if count == 1 { "" } else { "s" }, + note, ); Output::print_start_end_stdout(ctx.start_time, nano_timestamp()); printed_timestamp = true; print_blocked_packages_info(install_summary, this.options.global); } else { bun_core::pretty!( - "Done! Checked {} package{} (no changes) ", + "Done! Checked {} package{} ({}) ", install_summary.skipped, if install_summary.skipped == 1 { "" } else { "s" }, + note, ); Output::print_start_end_stdout(ctx.start_time, nano_timestamp()); printed_timestamp = true; diff --git a/src/install/PackageManager/package_json_write_back.rs b/src/install/PackageManager/package_json_write_back.rs index becf4d246f84..7b24882fea36 100644 --- a/src/install/PackageManager/package_json_write_back.rs +++ b/src/install/PackageManager/package_json_write_back.rs @@ -406,7 +406,11 @@ pub(crate) fn flush(manager: &mut PackageManager) -> Result<(), crate::Error> { if unchanged_on_disk(manager, &e.target) { continue; } - any_failed |= !write_target(manager, &e.target); + if write_target(manager, &e.target) { + manager.wrote_package_json = true; + } else { + any_failed = true; + } } if any_failed { Global::exit(1); diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 4d2d41384e27..dcf5c47fb38c 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -5473,7 +5473,7 @@ describe("update", () => { expect(out).toEqual([ expect.stringContaining("bun update v1."), "", - "Checked 1 install across 2 packages (no changes)", + "Checked 1 install across 2 packages (up to date, package.json synced)", ]); expect(await file(packageJson).json()).toEqual({ name: "foo", @@ -5632,7 +5632,7 @@ describe("update", () => { expect(out).toStrictEqual([ expect.stringContaining("bun update v1."), "", - "Checked 2 installs across 3 packages (no changes)", + "Checked 2 installs across 3 packages (up to date, package.json synced)", ]); expect(await file(packageJson).json()).toStrictEqual({ name: "foo", @@ -5819,7 +5819,7 @@ describe("update", () => { expect(out).toEqual([ expect.stringContaining("bun update v1."), "", - "Checked 1 install across 2 packages (no changes)", + "Checked 1 install across 2 packages (up to date, package.json synced)", ]); expect(await file(packageJson).json()).toEqual({ name: "foo", @@ -6139,14 +6139,15 @@ describe("update", () => { version: "1.0.0", }); - // update no-deps, no range, no change + // update no-deps, no range, no change to the resolved version (the re-printed + // package.json is still written back, which the summary reports) let { out } = await runBunUpdate(env, packageDir, ["no-deps"]); assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl()); expect(out).toStrictEqual([ expect.stringContaining("bun update v1."), "", - "Checked 5 installs across 6 packages (no changes)", + "Checked 5 installs across 6 packages (up to date, package.json synced)", ]); expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.0", diff --git a/test/cli/install/bun-update.test.ts b/test/cli/install/bun-update.test.ts index b39b59d8c5f4..21a0a9e23553 100644 --- a/test/cli/install/bun-update.test.ts +++ b/test/cli/install/bun-update.test.ts @@ -1814,6 +1814,36 @@ describe("bun update semantics", () => { }); } + // https://github.com/oven-sh/bun/issues/38908 + it.concurrent("bun update that only rewrites package.json reports the sync instead of (no changes)", async () => { + const dir = await setup({ "a-dep": "^1.0.1" }); + // install already resolved the newest match, so update only moves the declared range + expect(await installedVersion(dir, "a-dep")).toBe("1.0.10"); + const first = await update(dir); + expect(first.stdout).toContain("(up to date, package.json synced)"); + expect(first.stdout).not.toContain("(no changes)"); + await expectInSync(dir, { "a-dep": "^1.0.10" }); + const second = await update(dir); + expect(second.stdout).toContain("(no changes)"); + expect(second.stdout).not.toContain("package.json synced"); + }); + + // https://github.com/oven-sh/bun/issues/38908 + it.concurrent("bun update syncing a catalog range reports the package.json write", async () => { + const dir = await createDir({ + "package.json": { name: "mono", private: true, workspaces: ["packages/*"], catalog: { "a-dep": "^1.0.1" } }, + "packages/web/package.json": { name: "web", version: "1.0.0", dependencies: { "a-dep": "catalog:" } }, + }); + await install(dir); + const first = await update(dir); + expect(first.stdout).toContain("(up to date, package.json synced)"); + expect(first.stdout).not.toContain("(no changes)"); + expect((await packageJsonOf(dir)).catalog).toEqual({ "a-dep": "^1.0.10" }); + const second = await update(dir); + expect(second.stdout).toContain("(no changes)"); + expect(second.stdout).not.toContain("package.json synced"); + }); + it.concurrent("bun update keeps a dist-tag literal as written", async () => { const dir = await setup({ "dep-with-tags": "pre-2" }); expect(await installedVersion(dir, "dep-with-tags")).toBe("2.0.1"); From 027faccb94ab0df1aca740198b68756469a7e181 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:32 +0000 Subject: [PATCH 013/258] install: ignore leading whitespace in dist-tag, bare folder and local tarball versions (#38331) --- src/install/dependency.rs | 20 ++++-- test/cli/install/bun-install.test.ts | 96 ++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 2f5b38f9b895..954735c4c116 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -1154,6 +1154,12 @@ pub(crate) fn is_windows_abs_path_with_leading_slashes(dep: &[u8]) -> Option<&[u None } +/// Literals may carry leading whitespace; classify and parse these bytes, not the raw literal. +#[inline] +pub fn trim_literal(literal: &[u8]) -> &[u8] { + strings::trim_left(literal, b" \t\n\r") +} + #[inline] pub fn parse<'a, 'b>( alias: String, @@ -1163,7 +1169,7 @@ pub fn parse<'a, 'b>( log: impl Into>, manager: impl Into>, ) -> Option { - let dep = strings::trim_left(dependency, b" \t\n\r"); + let dep = trim_literal(dependency); parse_with_tag( alias, alias_hash.into(), @@ -1184,7 +1190,7 @@ pub(crate) fn parse_with_optional_tag<'a, 'b>( log: impl Into>, package_manager: impl Into>, ) -> Option { - let dep = strings::trim_left(dependency, b" \t\n\r"); + let dep = trim_literal(dependency); parse_with_tag( alias, alias_hash.into(), @@ -1207,6 +1213,8 @@ pub(crate) fn parse_with_tag( log_: Option<&mut bun_ast::Log>, package_manager: Option<&mut dyn NpmAliasRegistry>, ) -> Option { + // `to_version` (bun.lockb) and `clone_with_different_buffers` pass the stored literal untrimmed. + let dependency = trim_literal(dependency); match tag { Tag::Npm => { let mut input = dependency; @@ -1272,7 +1280,7 @@ pub(crate) fn parse_with_tag( Some(result) } Tag::DistTag => { - let mut tag_to_use = sliced.value(); + let mut tag_to_use = sliced.sub(dependency).value(); let actual = if dependency.starts_with(b"npm:") && dependency.len() > b"npm:".len() { // npm:@foo/bar@latest @@ -1457,7 +1465,7 @@ pub(crate) fn parse_with_tag( literal: sliced.value(), value: Value { tarball: TarballInfo { - uri: URI::Local(sliced.value()), + uri: URI::Local(sliced.sub(dependency).value()), package_name: String::default(), }, }, @@ -1574,7 +1582,7 @@ pub(crate) fn parse_with_tag( Some(Version { value: Value { - folder: sliced.value(), + folder: sliced.sub(dependency).value(), }, tag: Tag::Folder, literal: sliced.value(), @@ -1598,7 +1606,7 @@ pub(crate) fn parse_with_tag( Some(Version { value: Value { - symlink: sliced.value(), + symlink: sliced.sub(dependency).value(), }, tag: Tag::Symlink, literal: sliced.value(), diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 583fceefe6c7..737ddfce3513 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -6347,6 +6347,102 @@ describe.concurrent("bun-install", () => { }); }); + describe("leading whitespace in the version literal", () => { + // Leading whitespace is ignored when a version literal is classified, so each of these has to + // install what the literal without the whitespace installs. The second install re-parses the + // literal stored in the lockfile (bun.lockb unless told otherwise) and has to agree with the first. + async function installTwice( + ctx: TestContext, + installed: { name: string; version: string }, + lockfile: "bun.lockb" | "bun.lock" = "bun.lockb", + ) { + const firstInstall = lockfile === "bun.lock" ? ["--save-text-lockfile"] : []; + for (const args of [firstInstall, ["--frozen-lockfile"]]) { + await rm(join(ctx.package_dir, "node_modules"), { recursive: true, force: true }); + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd: ctx.package_dir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(out).toContain("1 package installed"); + expect(exitCode).toBe(0); + expect(await file(join(ctx.package_dir, "node_modules", installed.name, "package.json")).json()).toMatchObject( + installed, + ); + } + await access(join(ctx.package_dir, lockfile)); + } + + for (const version of [" latest", "\tlatest"]) { + it(`installs the dist-tag ${JSON.stringify(version)}`, async () => { + await withContext(defaultOpts, async ctx => { + const urls: string[] = []; + setContextHandler(ctx, dummyRegistryForContext(ctx, urls)); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: version } }), + ); + + await installTwice(ctx, { name: "bar", version: "0.0.2" }); + expect(urls.slice(0, 2)).toEqual([`${ctx.registry_url}bar`, `${ctx.registry_url}bar-0.0.2.tgz`]); + }); + }); + } + + it("keeps the literal as written in bun.lock", async () => { + await withContext(defaultOpts, async ctx => { + setContextHandler(ctx, dummyRegistryForContext(ctx, [])); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: " latest" } }), + ); + + await installTwice(ctx, { name: "bar", version: "0.0.2" }, "bun.lock"); + expect(await file(join(ctx.package_dir, "bun.lock")).text()).toContain('"bar": " latest"'); + }); + }); + + it('installs the folder " ./pkg"', async () => { + await withContext(defaultOpts, async ctx => { + await mkdir(join(ctx.package_dir, "pkg")); + await Promise.all([ + writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { pkg: " ./pkg" } }), + ), + writeFile(join(ctx.package_dir, "pkg", "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" })), + ]); + + await installTwice(ctx, { name: "pkg", version: "1.0.0" }); + expect(ctx.requested).toBe(0); + }); + }); + + // " file:./x.tgz" resolves on the first install either way; it is here for the second one, which + // re-parses the literal stored in bun.lockb with the tag the first install gave it. + for (const version of [" ./baz-0.0.3.tgz", " file:./baz-0.0.3.tgz"]) { + it(`installs the local tarball ${JSON.stringify(version)}`, async () => { + await withContext(defaultOpts, async ctx => { + await Promise.all([ + writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { baz: version } }), + ), + cp(join(import.meta.dir, "baz-0.0.3.tgz"), join(ctx.package_dir, "baz-0.0.3.tgz")), + ]); + + await installTwice(ctx, { name: "baz", version: "0.0.3" }); + expect(ctx.requested).toBe(0); + }); + }); + } + }); + it("should de-duplicate dependencies alongside tarball URL", async () => { await withContext(defaultOpts, async ctx => { const urls: string[] = []; From ca6507875faf91ebfc862f0f9d4de46850a53e74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:35 +0000 Subject: [PATCH 014/258] install: apply both "overrides" and "resolutions" when package.json has both (#38811) --- docs/pm/overrides.mdx | 2 +- .../PackageManager/install_with_manager.rs | 11 +- src/install/lockfile/OverrideMap.rs | 153 +++++++++++----- test/cli/install/nested-overrides.test.ts | 163 ++++++++++++++++++ 4 files changed, 277 insertions(+), 52 deletions(-) diff --git a/docs/pm/overrides.mdx b/docs/pm/overrides.mdx index ee4357e3ac24..40f5b7b2a335 100644 --- a/docs/pm/overrides.mdx +++ b/docs/pm/overrides.mdx @@ -62,7 +62,7 @@ Bun only reads overrides from the root `package.json`, not from workspace packag ## `"resolutions"` -`"resolutions"` is Yarn's alternative to `"overrides"`, with similar syntax. Bun supports it to help projects migrate from Yarn. +`"resolutions"` is Yarn's alternative to `"overrides"`, with similar syntax. Bun supports it to help projects migrate from Yarn. When a `package.json` has both fields, Bun applies the rules from both. If the same package (or the same nested selector) appears in both, the `"overrides"` rule wins. {/* prettier-ignore */} ```json package.json icon="file-json" diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index ecb78304ec88..280fcddd91cb 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -1401,10 +1401,13 @@ fn overrides_field_name( return "overrides"; }; let root = entry.root; - if root.as_property(b"overrides").is_none() && root.as_property(b"resolutions").is_some() { - "resolutions" - } else { - "overrides" + match ( + root.as_property(b"overrides").is_some(), + root.as_property(b"resolutions").is_some(), + ) { + (false, true) => "resolutions", + (true, true) => "overrides or resolutions", + _ => "overrides", } } diff --git a/src/install/lockfile/OverrideMap.rs b/src/install/lockfile/OverrideMap.rs index 4ac9fffab6a3..58188dbece3b 100644 --- a/src/install/lockfile/OverrideMap.rs +++ b/src/install/lockfile/OverrideMap.rs @@ -128,8 +128,16 @@ fn is_comment_key(key: &[u8]) -> bool { key.starts_with(b"//") } +/// Parsing only appends or replaces in place, so the rules `"overrides"` produced are the first `flat` entries of `map` and the first `scoped` entries of `scoped`. +#[derive(Clone, Copy, Default)] +struct RuleCount { + flat: usize, + scoped: usize, +} + struct ParseContext<'a, 'b> { field: Field, + from_overrides: RuleCount, pm: &'a mut PackageManager, lockfile_dependencies: &'a [Dependency], root_package: &'a Package, @@ -488,29 +496,40 @@ impl OverrideMap { /// Replaces the rule with the same (parent, parent range, target, target range); `buf` is the buffer `rule` was appended into. pub(crate) fn push_scoped(&mut self, rule: ScopedOverride, buf: &[u8]) { - if self.scoped_names.contains(&rule.dep.name_hash) { - if let Some(existing) = self.scoped.iter_mut().find(|existing| { - existing.dep.name_hash == rule.dep.name_hash - && match (&existing.parent, &rule.parent) { - (None, None) => true, - (Some(a), Some(b)) => { - a.name_hash == b.name_hash - && a.version.literal.eql(b.version.literal, buf, buf) - } - _ => false, - } - && existing - .target_range - .literal - .eql(rule.target_range.literal, buf, buf) - }) { - *existing = rule; - return; + self.put_scoped(rule, buf, 0); + } + + /// Like `push_scoped`, except that a matching rule at a position below `keep` is kept and `rule` is dropped. + fn put_scoped(&mut self, rule: ScopedOverride, buf: &[u8], keep: usize) { + match self.scoped_position(&rule, buf) { + Some(index) if index < keep => {} + Some(index) => self.scoped[index] = rule, + None => { + self.scoped_names.insert(rule.dep.name_hash, ()); + self.scoped.push(rule); } - } else { - self.scoped_names.insert(rule.dep.name_hash, ()); } - self.scoped.push(rule); + } + + fn scoped_position(&self, rule: &ScopedOverride, buf: &[u8]) -> Option { + if !self.scoped_names.contains(&rule.dep.name_hash) { + return None; + } + self.scoped.iter().position(|existing| { + existing.dep.name_hash == rule.dep.name_hash + && match (&existing.parent, &rule.parent) { + (None, None) => true, + (Some(a), Some(b)) => { + a.name_hash == b.name_hash + && a.version.literal.eql(b.version.literal, buf, buf) + } + _ => false, + } + && existing + .target_range + .literal + .eql(rule.target_range.literal, buf, buf) + }) } /// Row constructor for bun.lock / pnpm-lock.yaml; `Ok(false)` when `value`, the parent range or the target range does not parse. @@ -589,14 +608,32 @@ impl OverrideMap { expr: Expr, builder: &mut StringBuilder, ) { - let (field, field_expr) = if let Some(overrides) = expr.as_property(b"overrides") { - (Field::Overrides, overrides.expr) - } else if let Some(resolutions) = expr.as_property(b"resolutions") { - (Field::Resolutions, resolutions.expr) - } else { - return; - }; + for field in [Field::Overrides, Field::Resolutions] { + if let Some(property) = expr.as_property(field.json_name().as_bytes()) { + Self::count_field( + pm, + log, + json_source, + workspace_names, + &expr, + field, + property.expr, + builder, + ); + } + } + } + fn count_field( + pm: &mut PackageManager, + log: &mut bun_ast::Log, + json_source: &bun_ast::Source, + workspace_names: &WorkspaceMap, + root_json: &Expr, + field: Field, + field_expr: Expr, + builder: &mut StringBuilder, + ) { field_expr.for_each_property(|key, _key_loc, value| { if is_comment_key(key) { return; @@ -612,7 +649,15 @@ impl OverrideMap { } } builder.count(value); - count_ref_value(pm, log, json_source, workspace_names, &expr, value, builder); + count_ref_value( + pm, + log, + json_source, + workspace_names, + root_json, + value, + builder, + ); return; } if field != Field::Overrides || !value.is_object() { @@ -637,7 +682,7 @@ impl OverrideMap { log, json_source, workspace_names, - &expr, + root_json, child_value, builder, ); @@ -646,8 +691,8 @@ impl OverrideMap { }); } - /// Given a package json expression, detect and parse override configuration into the given override map. - /// It is assumed the input map is uninitialized (zero entries) + /// Parses the root package.json's `"overrides"` and `"resolutions"` into this (empty) map. + /// Both fields apply; where they define the same selector the `"overrides"` rule wins. pub(crate) fn parse_append( &mut self, pm: &mut PackageManager, @@ -660,15 +705,9 @@ impl OverrideMap { builder: &mut StringBuilder, ) -> Result<(), Error> { debug_assert!(self.map.count() == 0 && self.scoped.is_empty()); // only call parse once - let (field, field_expr) = if let Some(overrides) = expr.as_property(b"overrides") { - (Field::Overrides, overrides.expr) - } else if let Some(resolutions) = expr.as_property(b"resolutions") { - (Field::Resolutions, resolutions.expr) - } else { - return Ok(()); - }; let mut ctx = ParseContext { - field, + field: Field::Overrides, + from_overrides: RuleCount::default(), pm, lockfile_dependencies, root_package, @@ -677,9 +716,16 @@ impl OverrideMap { workspace_names, builder, }; - match field { - Field::Overrides => self.parse_from_overrides(&mut ctx, field_expr)?, - Field::Resolutions => self.parse_from_resolutions(&mut ctx, field_expr)?, + if let Some(overrides) = expr.as_property(b"overrides") { + self.parse_from_overrides(&mut ctx, overrides.expr)?; + ctx.from_overrides = RuleCount { + flat: self.map.count(), + scoped: self.scoped.len(), + }; + } + if let Some(resolutions) = expr.as_property(b"resolutions") { + ctx.field = Field::Resolutions; + self.parse_from_resolutions(&mut ctx, resolutions.expr)?; } scoped_log!( OverrideMap, @@ -898,24 +944,37 @@ impl OverrideMap { return Ok(()); } + let name_hash = SemverBuilder::string_hash(target.name); let is_flat = parent.is_none() && target.range.is_empty(); - let Some(dep) = parse_override_value(ctx, value_loc, target.name, value, is_flat)? else { + // Checked before the value is parsed: a flat `npm:` value also registers an alias, which a skipped rule must not do. + if is_flat + && self + .map + .get_index(&name_hash) + .is_some_and(|index| index < ctx.from_overrides.flat) + { + return Ok(()); + } + let Some(dep) = + parse_override_value(ctx, value_loc, target.name, name_hash, value, is_flat)? + else { return Ok(()); }; let Some(target_range) = parse_range(ctx, key_loc, dep.name, dep.name_hash, target.range) else { return Ok(()); }; - if parent.is_none() && target_range.tag != VersionTag::Npm { + if is_flat { self.map.put_assume_capacity(dep.name_hash, dep); } else { - self.push_scoped( + self.put_scoped( ScopedOverride { parent: parent.cloned(), target_range, dep, }, ctx.builder.string_bytes.as_slice(), + ctx.from_overrides.scoped, ); } Ok(()) @@ -1063,6 +1122,7 @@ fn parse_override_value( ctx: &mut ParseContext<'_, '_>, loc: bun_ast::Loc, key: &[u8], + name_hash: PackageNameHash, value: &[u8], register_aliases: bool, ) -> Result, Error> { @@ -1087,7 +1147,6 @@ fn parse_override_value( return Ok(None); } - let name_hash = SemverBuilder::string_hash(key); let name = ctx.builder.append_with_hash::(key, name_hash); // https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides diff --git a/test/cli/install/nested-overrides.test.ts b/test/cli/install/nested-overrides.test.ts index 43526a00c5d0..1cba002e7b9d 100644 --- a/test/cli/install/nested-overrides.test.ts +++ b/test/cli/install/nested-overrides.test.ts @@ -557,6 +557,128 @@ describe.concurrent("syntax", () => { }); }); +// two-range-deps declares no-deps ^1.0.0 (1.1.0 without a rule) and @types/is-number >=1.0.0 (2.0.0 without a rule). +describe.concurrent("overrides and resolutions in the same package.json", () => { + test("rules from both fields apply", async () => { + const dir = await project({ + dependencies: { "two-range-deps": "1.0.0" }, + overrides: { "no-deps": "1.0.0" }, + resolutions: { "@types/is-number": "1.0.0" }, + }); + const { err } = await installOk(dir); + expect(err).not.toContain("warn:"); + expect(await versionSeenBy(dir, "two-range-deps", "no-deps")).toBe("1.0.0"); + expect(await versionSeenBy(dir, "two-range-deps", "@types/is-number")).toBe("1.0.0"); + const first = await lock(dir); + expect(overridesSection(first)).toMatchInlineSnapshot(` + ""overrides": { + "@types/is-number": "1.0.0", + "no-deps": "1.0.0", + }," + `); + await installOk(dir, "--frozen-lockfile"); + const again = await installOk(dir); + expect(again.err).not.toContain("Saved lockfile"); + expect(await lock(dir)).toBe(first); + }); + + test("a scoped rule in resolutions applies next to a flat rule in overrides", async () => { + const dir = await project({ + dependencies: { "two-range-deps": "1.0.0", "one-range-dep": "1.0.0" }, + overrides: { "@types/is-number": "1.0.0" }, + resolutions: { "one-range-dep/no-deps": "2.0.0" }, + }); + const { err } = await installOk(dir); + expect(err).not.toContain("warn:"); + expect(await versionSeenBy(dir, "two-range-deps", "@types/is-number")).toBe("1.0.0"); + expect(await versionSeenBy(dir, "one-range-dep", "no-deps")).toBe("2.0.0"); + expect(await versionSeenBy(dir, "two-range-deps", "no-deps")).toBe("1.1.0"); + expect(overridesSection(await lock(dir))).toMatchInlineSnapshot(` + ""overrides": { + "@types/is-number": "1.0.0", + "one-range-dep": { + "no-deps": "2.0.0", + }, + }," + `); + await installOk(dir, "--frozen-lockfile"); + }); + + test("the same name in both fields: overrides wins", async () => { + const dir = await project({ + dependencies: { "one-range-dep": "1.0.0" }, + overrides: { "no-deps": "1.0.0" }, + resolutions: { "no-deps": "1.0.1" }, + }); + await installOk(dir); + expect(await versionSeenBy(dir, "one-range-dep", "no-deps")).toBe("1.0.0"); + expect(overridesSection(await lock(dir))).toMatchInlineSnapshot(` + ""overrides": { + "no-deps": "1.0.0", + }," + `); + }); + + // A flat `npm:` rule also registers an alias that redirects matching edges before overrides are consulted, + // so a resolutions rule that loses to overrides must not be parsed at all. + test("a losing resolutions rule with an npm: value does not redirect the edge", async () => { + const dir = await project({ + dependencies: { "one-range-dep": "1.0.0" }, + overrides: { "no-deps": "1.0.0" }, + resolutions: { "no-deps": "npm:a-dep@1.0.1" }, + }); + await installOk(dir); + expect(await packageSeenBy(dir, "one-range-dep", "no-deps")).toBe("no-deps@1.0.0"); + expect(await lock(dir)).not.toContain("a-dep"); + }); + + test("the same scoped selector in both fields: overrides wins", async () => { + const dir = await project({ + dependencies: { "one-range-dep": "1.0.0" }, + overrides: { "one-range-dep": { "no-deps": "1.0.0" } }, + resolutions: { "one-range-dep/no-deps": "1.0.1" }, + }); + await installOk(dir); + expect(await versionSeenBy(dir, "one-range-dep", "no-deps")).toBe("1.0.0"); + expect(overridesSection(await lock(dir))).toMatchInlineSnapshot(` + ""overrides": { + "one-range-dep": { + "no-deps": "1.0.0", + }, + }," + `); + }); + + test("within resolutions the last spelling of a rule still wins", async () => { + const dir = await project({ + dependencies: { "two-range-deps": "1.0.0" }, + overrides: { "@types/is-number": "1.0.0" }, + resolutions: { "**/no-deps": "1.0.0", "no-deps": "1.0.1" }, + }); + await installOk(dir); + expect(await versionSeenBy(dir, "two-range-deps", "no-deps")).toBe("1.0.1"); + expect(await versionSeenBy(dir, "two-range-deps", "@types/is-number")).toBe("1.0.0"); + }); + + test("editing resolutions is a frozen-lockfile change that names both fields", async () => { + const pkg = { + dependencies: { "two-range-deps": "1.0.0" }, + overrides: { "no-deps": "1.0.0" }, + resolutions: { "@types/is-number": "1.0.0" }, + }; + const dir = await project(pkg); + await installOk(dir); + pkg.resolutions["@types/is-number"] = "2.0.0"; + await write(join(dir, "package.json"), JSON.stringify({ name: "nested-overrides", ...pkg })); + const frozen = await install(dir, "--frozen-lockfile"); + expect(frozen.err).toContain("error: lockfile had changes, but lockfile is frozen"); + expect(frozen.err).toContain("note: overrides or resolutions in package.json changed since bun.lock was saved"); + expect(frozen.exitCode).toBe(1); + await installOk(dir); + expect(await versionSeenBy(dir, "two-range-deps", "@types/is-number")).toBe("2.0.0"); + }); +}); + // The selector range is matched against the range the dependent declares; a rule applies when the two intersect. describe.concurrent("version-scoped targets", () => { test("a flat name@range rule applies to edges whose declared range intersects it", async () => { @@ -1632,6 +1754,47 @@ one-dep@1.0.0: expect(await versionSeenBy(dir, "one-dep", "no-deps")).toBe("2.0.0"); }); + test("yarn.lock migration carries both overrides and resolutions", async () => { + const url = registry.registryUrl(); + const [aDep, noDeps] = await Promise.all([integrityOf("a-dep", "1.0.2"), integrityOf("no-deps", "1.0.0")]); + const dir = await project( + { + dependencies: { "a-dep": "^1.0.1", "no-deps": "^1.0.0" }, + overrides: { "no-deps": "1.0.0" }, + resolutions: { "a-dep": "1.0.2" }, + }, + "hoisted", + { + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +a-dep@^1.0.1: + version "1.0.2" + resolved "${url}a-dep/-/a-dep-1.0.2.tgz" + integrity ${aDep} + +no-deps@^1.0.0: + version "1.0.0" + resolved "${url}no-deps/-/no-deps-1.0.0.tgz" + integrity ${noDeps} +`, + }, + ); + const migrated = await migrate(dir); + expect(migrated.err).not.toContain("error:"); + expect(migrated.exitCode).toBe(0); + expect(overridesSection(await lock(dir))).toMatchInlineSnapshot(` + ""overrides": { + "a-dep": "1.0.2", + "no-deps": "1.0.0", + }," + `); + await installOk(dir, "--frozen-lockfile"); + expect(await versionSeenBy(dir, undefined, "a-dep")).toBe("1.0.2"); + expect(await versionSeenBy(dir, undefined, "no-deps")).toBe("1.0.0"); + }); + // one-dep@1.0.0 declares no-deps@1.0.1; a lockfile snapshot on 2.0.0 is only consistent with an override. async function pnpmLock({ overrides, noDepsVersion = "2.0.0" }: { overrides?: string[]; noDepsVersion?: string }) { const [oneDep, noDeps] = await Promise.all([ From e0ebdb4b2da39f1ec6f2609f50f3d2863ff99787 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:39 +0000 Subject: [PATCH 015/258] Fix package-lock.json migration for scope registries configured without a trailing slash (#38698) --- src/install/migration/npm_lock.rs | 5 +- test/cli/install/migration/migrate.test.ts | 59 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/install/migration/npm_lock.rs b/src/install/migration/npm_lock.rs index 263077dfa258..6acbcf2dcce2 100644 --- a/src/install/migration/npm_lock.rs +++ b/src/install/migration/npm_lock.rs @@ -591,11 +591,13 @@ impl<'a> Migrator<'a> { } else { name }; - let href: &[u8] = self.manager.scope_for_package_name(name).url.href(); + let href: &[u8] = + strings::without_trailing_slash(self.manager.scope_for_package_name(name).url.href()); let url = &mut self.url; url.clear(); url.reserve( href.len() + + 1 + name.len() + b"/-/".len() + unscoped.len() @@ -604,6 +606,7 @@ impl<'a> Migrator<'a> { + b".tgz".len(), ); url.extend_from_slice(href); + url.push(b'/'); url.extend_from_slice(name); url.extend_from_slice(b"/-/"); url.extend_from_slice(unscoped); diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 8bb11fffa225..9cdcf8023fbf 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -109,6 +109,65 @@ test("migrate from npm lockfile that is missing `resolved` properties", async () expect(exitCode).toBe(0); }); +// https://github.com/oven-sh/bun/issues/38668 +test.concurrent("migrate npm lockfile with missing `resolved` when scope registry has no trailing slash", async () => { + using dir = tempDir("migrate-scope-no-slash", { + "package.json": JSON.stringify({ + name: "repro", + version: "1.0.0", + dependencies: { + "@myscope/a": "1.0.0", + "@myscope/b": "2.0.0", + }, + }), + "bunfig.toml": ` +[install.scopes] +"@myscope" = { url = "https://example-registry.invalid" } +`, + "package-lock.json": JSON.stringify({ + name: "repro", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "repro", + version: "1.0.0", + dependencies: { + "@myscope/a": "1.0.0", + "@myscope/b": "2.0.0", + }, + }, + // without `integrity` the malformed URL failed the whole migration + "node_modules/@myscope/a": { version: "1.0.0" }, + // with `integrity` the malformed URL was written into bun.lock + "node_modules/@myscope/b": { + version: "2.0.0", + integrity: "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + }, + }, + }), + }); + + // `bun pm migrate` never touches the network, so the unresolvable registry host is fine + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "migrate"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("InvalidNPMLockfile"); + expect(exitCode).toBe(0); + + const lock = await Bun.file(join(String(dir), "bun.lock")).text(); + expect(lock).toContain("https://example-registry.invalid/@myscope/a/-/a-1.0.0.tgz"); + expect(lock).toContain("https://example-registry.invalid/@myscope/b/-/b-2.0.0.tgz"); + expect(lock).not.toContain("invalid@myscope"); +}); + test("npm lockfile with relative workspaces", async () => { const testDir = tmpdirSync(); fs.cpSync(join(import.meta.dir, "lockfile-with-workspaces"), testDir, { recursive: true }); From 3792fb16e7656c8c2ac82998f491afe29bc9617d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:43 +0000 Subject: [PATCH 016/258] install: record package.json trustedDependencies when migrating a lockfile (#38773) --- src/install/lockfile/Package.rs | 62 ++++-- src/install/migration.rs | 106 ++++++++-- .../bun-install-lifecycle-scripts.test.ts | 199 ++++++++++++++++++ 3 files changed, 324 insertions(+), 43 deletions(-) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 767c6aa80c1f..0b4ed4b1ca4a 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -126,6 +126,38 @@ fn invalid_trusted_dependencies( crate::Error::InvalidPackageJSON } +/// A declared `trustedDependencies` (even `[]`) replaces the default list, so the set becomes `Some` as soon as the field exists. +pub(crate) fn parse_append_trusted_dependencies( + trusted_dependencies: &mut Option, + log: &mut bun_ast::Log, + source: &bun_ast::Source, + json: Expr, + bump: &bun_alloc::Arena, +) -> crate::Result<()> { + let Some(q) = json.as_property(b"trustedDependencies") else { + return Ok(()); + }; + let count = match &q.expr.data { + ExprData::EArray(arr) => arr.items.len_u32() as usize, + ExprData::EArrayJSON(arr) => arr.get().items().len(), + _ => return Err(invalid_trusted_dependencies(log, source, q.loc)), + }; + let trusted = trusted_dependencies.get_or_insert_with(Default::default); + trusted.ensure_unused_capacity(count)?; + if let Some(mut items) = q.expr.as_array() { + while let Some(item) = items.next() { + let Some(name) = item.as_string(bump) else { + return Err(invalid_trusted_dependencies(log, source, q.loc)); + }; + trusted.put_assume_capacity( + semver::string::Builder::string_hash(name) as TruncatedPackageNameHash, + Box::<[u8]>::from(name), + ); + } + } + Ok(()) +} + // `SemverIntType` defaults to `u64`, the only instantiation the lockfile/PM // call sites name unqualified. // @@ -2472,29 +2504,13 @@ impl Package { } if FEATURES.trusted_dependencies { - if let Some(q) = json.as_property(b"trustedDependencies") { - let count = match &q.expr.data { - ExprData::EArray(arr) => arr.items.len_u32() as usize, - ExprData::EArrayJSON(arr) => arr.get().items().len(), - _ => return Err(invalid_trusted_dependencies(log, source, q.loc)), - }; - if lockfile.trusted_dependencies.is_none() { - lockfile.trusted_dependencies = Some(Default::default()); - } - let trusted = lockfile.trusted_dependencies.as_mut().unwrap(); - trusted.ensure_unused_capacity(count)?; - if let Some(mut items) = q.expr.as_array() { - while let Some(item) = items.next() { - let Some(name) = item.as_string(&bump) else { - return Err(invalid_trusted_dependencies(log, source, q.loc)); - }; - trusted.put_assume_capacity( - semver::string::Builder::string_hash(name) as TruncatedPackageNameHash, - Box::<[u8]>::from(name), - ); - } - } - } + parse_append_trusted_dependencies( + &mut lockfile.trusted_dependencies, + log, + source, + json, + &bump, + )?; } if FEATURES.is_main { diff --git a/src/install/migration.rs b/src/install/migration.rs index dd5270fe8424..172b5661560d 100644 --- a/src/install/migration.rs +++ b/src/install/migration.rs @@ -1,8 +1,8 @@ use crate::Error; use bun_ast::{E, ExprData}; use bun_core::strings; -use bun_core::{Output, zstr}; -use bun_paths::PathBuffer; +use bun_core::{Output, ZStr, zstr}; +use bun_paths::{AutoAbsPath, PathBuffer}; use bun_semver::query::token::Wildcard; use bun_semver::{self as Semver, SlicedString}; use bun_sys::{self, Fd, File, O}; @@ -11,8 +11,8 @@ use crate::install::{self as Install, PackageManager, Subcommand}; use crate::lockfile::{ Format as LockfileFormat, LoadResult, LoadResultErr, LoadResultOk, LoadStep, Lockfile, Migrated, }; -use crate::lockfile_real::package::PackageColumns as _; use crate::lockfile_real::package::workspace_map::{MissingWorkspace, NamesArray, WorkspaceMap}; +use crate::lockfile_real::package::{PackageColumns as _, parse_append_trusted_dependencies}; use crate::npm::{self as Npm}; use crate::pnpm; use crate::pnpm::MigratePnpmLockfileError; @@ -64,11 +64,13 @@ pub fn detect_and_load_other_lockfile<'a>( } }; - if matches!(migrate_result, LoadResult::Ok { .. }) { - report_migrated(manager, log, &timer, "package-lock.json"); - } - - return migrate_result; + return finish_migration( + migrate_result, + manager, + log, + &timer, + zstr!("package-lock.json"), + ); } 'yarn: { @@ -88,11 +90,7 @@ pub fn detect_and_load_other_lockfile<'a>( } }; - if matches!(migrate_result, LoadResult::Ok { .. }) { - report_migrated(manager, log, &timer, "yarn.lock"); - } - - return migrate_result; + return finish_migration(migrate_result, manager, log, &timer, zstr!("yarn.lock")); } 'pnpm: { @@ -155,16 +153,81 @@ pub fn detect_and_load_other_lockfile<'a>( } }; - if matches!(migrate_result, LoadResult::Ok { .. }) { - report_migrated(manager, log, &timer, "pnpm-lock.yaml"); - } - - return migrate_result; + return finish_migration( + migrate_result, + manager, + log, + &timer, + zstr!("pnpm-lock.yaml"), + ); } LoadResult::NotFound } +fn finish_migration<'a>( + migrate_result: LoadResult<'a>, + manager: &mut PackageManager, + log: &mut bun_ast::Log, + timer: &std::time::Instant, + lockfile_name: &'static ZStr, +) -> LoadResult<'a> { + let ok = match migrate_result { + LoadResult::Ok(ok) => ok, + other => return other, + }; + if let Err(err) = record_trusted_dependencies(&mut *ok.lockfile, manager, log) { + if !manager.options.log_level.is_silent() && log.has_errors() { + let _ = log.print(std::ptr::from_mut(Output::error_writer())); + Output::flush(); + } + log.reset(); + return LoadResult::Err(LoadResultErr { + step: LoadStep::Migrating, + value: err, + lockfile_path: lockfile_name, + format: LockfileFormat::Text, + }); + } + report_migrated(manager, log, timer, lockfile_name); + LoadResult::Ok(ok) +} + +/// Other lockfiles have no `trustedDependencies`; read the root's and the members' from package.json like `Package::parse_with_json` does, since `bun pm migrate` saves this lockfile as-is. +fn record_trusted_dependencies( + lockfile: &mut Lockfile, + manager: &mut PackageManager, + log: &mut bun_ast::Log, +) -> Result<(), Error> { + let bump = bun_alloc::Arena::new(); + let string_bytes = lockfile.buffers.string_bytes.as_slice(); + let root: &[u8] = b""; + let members = lockfile + .workspace_paths + .values() + .iter() + .map(|path| path.slice(string_bytes)); + for relative_dir in core::iter::once(root).chain(members) { + let mut package_json_path = AutoAbsPath::init_top_level_dir(); + let _ = package_json_path.append(relative_dir); + let _ = package_json_path.append(b"package.json"); + let crate::GetJsonResult::Entry(entry) = manager + .workspace_package_json_cache + .get_with_path(log, package_json_path.slice(), Default::default()) + else { + continue; + }; + parse_append_trusted_dependencies( + &mut lockfile.trusted_dependencies, + log, + &entry.source, + entry.root, + &bump, + )?; + } + Ok(()) +} + /// True when the migrator already printed the version warn/error + upgrade note, so lockfile-load reporters must stay quiet. pub fn reported_unsupported_lockfile_version(err: &LoadResultErr) -> bool { err.step == LoadStep::Migrating && matches!(err.value, Error::UnexpectedLockfileVersion) @@ -229,7 +292,7 @@ fn report_migrated( manager: &PackageManager, log: &mut bun_ast::Log, timer: &std::time::Instant, - lockfile_name: &str, + lockfile_name: &ZStr, ) { if manager.options.log_level.is_silent() { log.reset(); @@ -240,7 +303,10 @@ fn report_migrated( log.reset(); } Output::print_elapsed(timer.elapsed().as_nanos() as f64 / 1_000_000.0); - bun_core::pretty_errorln!(" migrated lockfile from {}", lockfile_name); + bun_core::pretty_errorln!( + " migrated lockfile from {}", + bstr::BStr::new(lockfile_name.as_bytes()) + ); Output::flush(); } diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index aea7f38ebec4..bd4989773c04 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -259,6 +259,205 @@ test.concurrent( }, ); +describe("trustedDependencies survive lockfile migration", () => { + // `electron` is on the default trusted list, `uses-what-bin` is not. Declaring + // `trustedDependencies: ["uses-what-bin"]` therefore has to flip both: the + // migrated lockfile must run uses-what-bin's install script and block electron's. + const integrity = { + "electron": "sha512-GkuwCdn6o8Krsxb3DIIqYP+TAi8Y5jYUadmseZ6nR2op2k5ssdKRYo4JjYDGopa1ACrGAcQuWViz/+vX/WjYnA==", + "uses-what-bin": "sha512-EI+uMDESinRewWTFhsyzibkzFV+j5LmLM7T1jEpb2X82TmzhSQRzCBTBURflt5dGvNEIY7l563P9Su01Tpe++g==", + "what-bin": "sha512-mbvEObM9mSliIzNJ4lJHfx8Zzdcf3v8PCIanJyIbXv6AFuuipK5tirQuM9Oi1yFXgO/YxI1W5zt4AwwLGxmaPA==", + }; + const shasum = { + "electron": "f1b8bc2c23cd7e4f1500669dfaf8757578d2e391", + "uses-what-bin": "78dea365c24435c0faa99dca78ed44c42273b84a", + "what-bin": "934dc0859a1a9ccf90ac341b1d4867f94d8e4d11", + }; + const dependencies = { "electron": "1.0.0", "uses-what-bin": "1.5.0" }; + + function tarball(name: keyof typeof integrity, version: string) { + return `http://localhost:${verdaccio.port}/${name}/-/${name}-${version}.tgz`; + } + + function packageLock() { + return JSON.stringify({ + name: "foo", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "foo", version: "1.0.0", dependencies }, + "node_modules/electron": { + version: "1.0.0", + resolved: tarball("electron", "1.0.0"), + integrity: integrity["electron"], + hasInstallScript: true, + }, + "node_modules/uses-what-bin": { + version: "1.5.0", + resolved: tarball("uses-what-bin", "1.5.0"), + integrity: integrity["uses-what-bin"], + hasInstallScript: true, + dependencies: { "what-bin": "1.5.0" }, + }, + "node_modules/what-bin": { + version: "1.5.0", + resolved: tarball("what-bin", "1.5.0"), + integrity: integrity["what-bin"], + bin: { "what-bin": "what-bin.js" }, + }, + }, + }); + } + + function yarnLock() { + return `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +electron@1.0.0: + version "1.0.0" + resolved "${tarball("electron", "1.0.0")}#${shasum["electron"]}" + integrity ${integrity["electron"]} + +uses-what-bin@1.5.0: + version "1.5.0" + resolved "${tarball("uses-what-bin", "1.5.0")}#${shasum["uses-what-bin"]}" + integrity ${integrity["uses-what-bin"]} + dependencies: + what-bin "1.5.0" + +what-bin@1.5.0: + version "1.5.0" + resolved "${tarball("what-bin", "1.5.0")}#${shasum["what-bin"]}" + integrity ${integrity["what-bin"]} +`; + } + + function pnpmLock(importer: string) { + return `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: +${importer} +packages: + + electron@1.0.0: + resolution: {integrity: ${integrity["electron"]}} + + uses-what-bin@1.5.0: + resolution: {integrity: ${integrity["uses-what-bin"]}} + + what-bin@1.5.0: + resolution: {integrity: ${integrity["what-bin"]}} + hasBin: true + +snapshots: + + electron@1.0.0: {} + + uses-what-bin@1.5.0: + dependencies: + what-bin: 1.5.0 + + what-bin@1.5.0: {} +`; + } + + const importerDependencies = ` + dependencies: + electron: + specifier: 1.0.0 + version: 1.0.0 + uses-what-bin: + specifier: 1.5.0 + version: 1.5.0 +`; + + async function runBunPm(env: Record, packageDir: string, subcommand: string) { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "pm", subcommand], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + return { out, err }; + } + + // The migrated bun.lock is what gets committed and installed from with + // --frozen-lockfile, which never writes it back, so everything package.json + // declares has to be in it when `bun pm migrate` returns. + async function expectMigrationKeepsTrust(env: Record, packageDir: string, lockfileName: string) { + const { err: migrateErr } = await runBunPm(env, packageDir, "migrate"); + expect(migrateErr).toContain(`migrated lockfile from ${lockfileName}`); + expect(await file(join(packageDir, "bun.lock")).text()).toMatch( + /"trustedDependencies": \[\s*"uses-what-bin",\s*\]/, + ); + + await runBunInstall(env, packageDir, { frozenLockfile: true }); + expect( + await Promise.all([ + exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt")), + exists(join(packageDir, "node_modules", "electron", "preinstall.txt")), + ]), + ).toEqual([true, false]); + + const { out } = await runBunPm(env, packageDir, "untrusted"); + expect(out).toContain("./node_modules/electron @1.0.0".replaceAll("/", sep)); + expect(out).not.toContain("uses-what-bin"); + } + + const rootLockfiles: Record string> = { + "package-lock.json": packageLock, + "yarn.lock": yarnLock, + "pnpm-lock.yaml": () => pnpmLock(`\n .:${importerDependencies}`), + }; + + for (const [lockfileName, contents] of Object.entries(rootLockfiles)) { + test.concurrent(`bun pm migrate from ${lockfileName} keeps the root's trustedDependencies`, async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + + await Promise.all([ + write( + packageJson, + JSON.stringify({ name: "foo", version: "1.0.0", dependencies, trustedDependencies: ["uses-what-bin"] }), + ), + write(join(packageDir, lockfileName), contents()), + ]); + + await expectMigrationKeepsTrust(env, packageDir, lockfileName); + }); + } + + test.concurrent("bun pm migrate from pnpm-lock.yaml keeps a workspace member's trustedDependencies", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + + // Like `bun install`, a list declared by a member counts for the whole + // install: it is recorded and it turns the default trusted list off. + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", workspaces: ["packages/*"] })), + write(join(packageDir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n"), + write( + join(packageDir, "packages", "app", "package.json"), + JSON.stringify({ name: "app", version: "1.0.0", dependencies, trustedDependencies: ["uses-what-bin"] }), + ), + write(join(packageDir, "pnpm-lock.yaml"), pnpmLock(`\n .: {}\n\n packages/app:${importerDependencies}`)), + ]); + + await expectMigrationKeepsTrust(env, packageDir, "pnpm-lock.yaml"); + }); +}); + test.concurrent("node-gyp shim directory added to lifecycle script PATH gets a randomized name", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; From d18e84cdf941a0301a517843eb26b523656572df Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:47 +0000 Subject: [PATCH 017/258] install: keep the pnpm block in package.json when migrating from pnpm (#38775) --- docs/pm/cli/install.mdx | 12 +- src/install/pnpm.rs | 291 +++++------------- .../install/migration/pnpm-lock-v9.test.ts | 227 +++++++++++++- test/cli/install/nested-overrides.test.ts | 20 +- 4 files changed, 319 insertions(+), 231 deletions(-) diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index bdf55958b896..08f526a04126 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -583,11 +583,13 @@ Bun preserves dependencies that use pnpm's `catalog:` protocol: ### Configuration Migration -Bun migrates the following pnpm configuration from both `pnpm-lock.yaml` and `pnpm-workspace.yaml`: +Bun copies the following pnpm configuration into the root `package.json`: -- **Overrides**: Moved from `pnpm.overrides` to root-level `overrides` in `package.json` -- **Patched Dependencies**: Moved from `pnpm.patchedDependencies` to root-level `patchedDependencies` in `package.json` -- **Workspace Overrides**: Applied from `pnpm-workspace.yaml` to root `package.json` +- **Overrides**: Copied from `pnpm.overrides` to root-level `overrides` in `package.json` +- **Patched Dependencies**: Copied from `pnpm.patchedDependencies` to root-level `patchedDependencies` in `package.json` +- **Workspace Overrides**: Copied from `overrides` and `patchedDependencies` in `pnpm-workspace.yaml` to the same root-level fields + +Bun leaves the `pnpm` field of `package.json` as it is. pnpm reads its configuration from that field and ignores the root-level fields, so `pnpm install --frozen-lockfile` keeps working for teammates who still use pnpm. Bun reads the root-level fields and ignores the `pnpm` field. ### Requirements and limitations @@ -598,7 +600,7 @@ Bun migrates the following pnpm configuration from both `pnpm-lock.yaml` and `pn - Relative `link:` dependencies and git dependencies with a sub-directory (`resolution.path`) are not supported - If migration fails for any of these reasons, Bun prints why and resolves from scratch instead -After migration, you can safely remove `pnpm-lock.yaml` and `pnpm-workspace.yaml` files. +Once nobody on the repository uses pnpm anymore, you can remove `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and the `pnpm` field of `package.json`. --- diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index e14532accf02..ed086e505dce 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -17,6 +17,7 @@ use crate::external_slice::ExternalSlice; use crate::integrity::Integrity; use crate::lockfile::{self, LoadResult, LoadResultOk, Lockfile}; use crate::npm::{self}; +use crate::package_manager_real::update_package_json_and_install::print_package_json_into_cache_entry; use crate::repository::Repository; use crate::resolution::{self, Resolution, TaggedValue}; use crate::{DependencyID, INVALID_PACKAGE_ID, PackageID, PackageManager}; @@ -2364,140 +2365,28 @@ fn update_package_json_after_migration( return Ok(()); } - let mut needs_update = false; - let mut moved_overrides = false; - let mut moved_patched_deps = false; - let mut moved: Vec<&'static str> = Vec::new(); + let mut copied: Vec<&'static str> = Vec::new(); - if let Some(mut pnpm_prop) = json.as_property(b"pnpm") { + // Copied, not moved: pnpm keeps reading this block, bun only reads the root-level fields. + if let Some(pnpm_prop) = json.as_property(b"pnpm") { if pnpm_prop.expr.is_object() { - let pnpm_obj = e_object_mut(&mut pnpm_prop.expr); - - if let Some(overrides_field) = pnpm_obj.get(b"overrides") { - if is_non_empty_object(&overrides_field) { - if let Some(mut existing_prop) = json.as_property(b"overrides") { - if existing_prop.expr.is_object() { - let existing_overrides = e_object_mut(&mut existing_prop.expr); - for prop in e_object(&overrides_field).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_overrides.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"overrides", overrides_field)?; - } - moved_overrides = true; - needs_update = true; - moved.push("pnpm.overrides to overrides"); - } - } + let pnpm_obj = e_object(&pnpm_prop.expr); - if let Some(mut patched_field) = pnpm_obj.get(b"patchedDependencies") { - if is_non_empty_object(&patched_field) { - rewrite_bare_patch_keys(&mut patched_field, patches)?; - if let Some(mut existing_prop) = json.as_property(b"patchedDependencies") { - if existing_prop.expr.is_object() { - let existing_patches = e_object_mut(&mut existing_prop.expr); - for prop in e_object(&patched_field).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_patches.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put( - &bump, - b"patchedDependencies", - patched_field, - )?; - } - moved_patched_deps = true; - needs_update = true; - moved.push("pnpm.patchedDependencies to patchedDependencies"); + if let Some(overrides) = pnpm_obj.get(b"overrides").filter(is_non_empty_object) { + if copy_into_root(&mut json, &bump, b"overrides", copy_object(&overrides))? { + copied.push("pnpm.overrides to overrides"); } } - if moved_overrides || moved_patched_deps { - let mut remaining_count: usize = 0; - for prop in pnpm_obj.properties.slice() { - let Some(key) = as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - remaining_count += 1; - continue; - }; - if moved_overrides && key == b"overrides" { - continue; - } - if moved_patched_deps && key == b"patchedDependencies" { - continue; - } - remaining_count += 1; - } - - if remaining_count == 0 { - let mut new_root_count: usize = 0; - for prop in e_object(&json).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - new_root_count += 1; - continue; - }; - if key != b"pnpm" { - new_root_count += 1; - } - } - - let mut new_root_props = G::PropertyList::init_capacity(new_root_count); - for prop in e_object(&json).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - VecExt::append(&mut new_root_props, shallow_clone_prop(prop)); - continue; - }; - if key != b"pnpm" { - VecExt::append(&mut new_root_props, shallow_clone_prop(prop)); - } - } - - e_object_mut(&mut json).properties = new_root_props; - } else { - let mut new_pnpm_props = G::PropertyList::init_capacity(remaining_count); - for prop in pnpm_obj.properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - VecExt::append(&mut new_pnpm_props, shallow_clone_prop(prop)); - continue; - }; - if moved_overrides && key == b"overrides" { - continue; - } - if moved_patched_deps && key == b"patchedDependencies" { - continue; - } - VecExt::append(&mut new_pnpm_props, shallow_clone_prop(prop)); - } - - pnpm_obj.properties = new_pnpm_props; + if let Some(patched) = pnpm_obj + .get(b"patchedDependencies") + .filter(is_non_empty_object) + { + let mut patched = copy_object(&patched); + rewrite_bare_patch_keys(&mut patched, patches)?; + if copy_into_root(&mut json, &bump, b"patchedDependencies", patched)? { + copied.push("pnpm.patchedDependencies to patchedDependencies"); } - needs_update = true; } } } @@ -2522,11 +2411,11 @@ fn update_package_json_after_migration( // `Expr::data_store_reset`). let contents: &'static [u8] = js_ast::data_store_dupe_str(&contents); let yaml_source = bun_ast::Source::init_path_string(b"pnpm-workspace.yaml", contents); - let arena = bun_alloc::Arena::new(); + // Quoted scalars are copied into the arena; `bump` lives until the print below. let Ok(ws_root) = bun_parsers::yaml::YAML::parse( &yaml_source, log, - &arena, + &bump, bun_parsers::yaml::CyclicAliases::Reject, ) else { break 'read_pnpm_workspace_yaml; @@ -2655,108 +2544,42 @@ fn update_package_json_after_migration( } } if wrote_workspaces { - needs_update = true; - moved.push("pnpm-workspace.yaml to workspaces"); - } - - // Handle overrides from pnpm-workspace.yaml - if let Some(ws_overrides) = &workspace_overrides_obj { - if ws_overrides.is_object() { - if let Some(mut existing_prop) = json.as_property(b"overrides") { - if existing_prop.expr.is_object() { - let existing_overrides = e_object_mut(&mut existing_prop.expr); - for prop in e_object(ws_overrides).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_overrides.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"overrides", *ws_overrides)?; - } - needs_update = true; - moved.push("pnpm-workspace.yaml overrides to overrides"); - } + copied.push("pnpm-workspace.yaml to workspaces"); } - // Handle patchedDependencies from pnpm-workspace.yaml - if let Some(ws_patched) = &mut workspace_patched_deps_obj { - if ws_patched.is_object() { - rewrite_bare_patch_keys(ws_patched, patches)?; - if let Some(mut existing_prop) = json.as_property(b"patchedDependencies") { - if existing_prop.expr.is_object() { - let existing_patches = e_object_mut(&mut existing_prop.expr); - for prop in e_object(ws_patched).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_patches.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"patchedDependencies", *ws_patched)?; - } - needs_update = true; - moved.push("pnpm-workspace.yaml patchedDependencies to patchedDependencies"); + if let Some(ws_overrides) = workspace_overrides_obj { + if copy_into_root(&mut json, &bump, b"overrides", ws_overrides)? { + copied.push("pnpm-workspace.yaml overrides to overrides"); } } - if needs_update { - let mut buffer_writer = bun_js_printer::BufferWriter::init(); - buffer_writer.append_newline = !root_pkg_json.source.contents().is_empty() - && root_pkg_json.source.contents()[root_pkg_json.source.contents().len() - 1] == b'\n'; - let mut package_json_writer = bun_js_printer::BufferPrinter::init(buffer_writer); - - if bun_js_printer::print_json( - &mut package_json_writer, - json, - &root_pkg_json.source, - bun_js_printer::PrintJsonOptions { - indent: root_pkg_json.indentation, - mangled_props: None, - ..Default::default() - }, - ) - .is_err() - { - return Ok(()); + if let Some(mut ws_patched) = workspace_patched_deps_obj { + rewrite_bare_patch_keys(&mut ws_patched, patches)?; + if copy_into_root(&mut json, &bump, b"patchedDependencies", ws_patched)? { + copied.push("pnpm-workspace.yaml patchedDependencies to patchedDependencies"); } + } - if package_json_writer.flush().is_err() { - return Err(AllocError); + if !copied.is_empty() { + print_package_json_into_cache_entry(root_pkg_json, json); + // The printed tree borrows from the replaced contents; `bun update` edits this entry next. + if let Err(err) = root_pkg_json.reparse_root(log) { + bun_core::pretty_errorln!("package.json failed to parse due to error {}", err.name()); + bun_core::Global::crash(); } - root_pkg_json.source.contents = std::borrow::Cow::Owned( - package_json_writer - .ctx - .written_without_trailing_zero() - .to_vec(), - ); - - // Write the updated package.json if sys::File::write_file( dir, bun_core::zstr!("package.json"), root_pkg_json.source.contents(), ) .is_ok() - && !moved.is_empty() && !silent { - bun_core::pretty_errorln!("moved {} in package.json", moved.join(", ")); + bun_core::pretty_errorln!( + "copied {} in package.json", + copied.join(", ") + ); } } @@ -2767,6 +2590,46 @@ fn is_non_empty_object(expr: &Expr) -> bool { matches!(&expr.data, ExprData::EObject(o) if !o.properties.is_empty()) } +/// The root-level copy gets edited further; an `Expr` from `get` would alias the `pnpm` block. +fn copy_object(src: &Expr) -> Expr { + let src_props = e_object(src).properties.slice(); + let mut properties = G::PropertyList::init_capacity(src_props.len()); + for prop in src_props { + VecExt::append(&mut properties, shallow_clone_prop(prop)); + } + Expr::init( + E::Object { + properties, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ) +} + +/// Merges `src` into the root-level `field` (created when absent); `false` if it is not an object. +fn copy_into_root( + json: &mut Expr, + bump: &bun_alloc::Arena, + field: &[u8], + src: Expr, +) -> Result { + let Some(mut existing) = json.as_property(field) else { + e_object_mut(json).put(bump, field, src)?; + return Ok(true); + }; + if !existing.expr.is_object() { + return Ok(false); + } + let existing_obj = e_object_mut(&mut existing.expr); + for prop in e_object(&src).properties.slice() { + let Some(key) = as_string(prop.key.as_ref().expect("infallible: prop has key")) else { + continue; + }; + existing_obj.put(bump, key, prop.value.expect("infallible: prop has value"))?; + } + Ok(true) +} + fn paths_array(paths: &[&'static [u8]]) -> Expr { let mut items = js_ast::ExprNodeList::init_capacity(paths.len()); for path in paths { diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index 972eaf3aebd1..dc80656b10eb 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -791,16 +791,19 @@ snapshots: const { stderr, exitCode } = await migrate(packageDir); expect(stderr).not.toContain("is not in patchedDependencies"); + expect(stderr).toContain("copied pnpm.patchedDependencies to patchedDependencies in package.json"); expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(exitCode).toBe(0); const bunLock = await bunLockOf(packageDir); expect(bunLock).toContain(`"patchedDependencies": {\n "no-deps@1.0.1": "patches/no-deps.patch",\n }`); + // The versioned key only goes to the root; pnpm keeps reading the bare key from its own block. const packageJson = await Bun.file(join(packageDir, "package.json")).json(); expect(packageJson).toStrictEqual({ name: "patch-path-in-package-json", dependencies: { "no-deps": "^1.0.0" }, + pnpm: { patchedDependencies: { "no-deps": "patches/no-deps.patch" } }, patchedDependencies: { "no-deps@1.0.1": "patches/no-deps.patch" }, }); @@ -813,6 +816,55 @@ snapshots: ); }); + // `bun update` migrates, then edits the cached root package.json that the migration rewrote, so the cached + // copy has to be re-read from the rewritten contents (a stale copy pointed into the freed previous contents). + test("bun update straight from pnpm-lock.yaml edits the package.json the migration rewrote", async () => { + const pnpm = { + patchedDependencies: { "no-deps": "patches/no-deps.patch" }, + overrides: { "a-dep": "1.0.1" }, + }; + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "update-after-migration", + dependencies: { "no-deps": "^1.0.0" }, + pnpm, + }), + "patches/no-deps.patch": NO_DEPS_INDEX_PATCH, + "pnpm-lock.yaml": bareHashNoDepsLockfile("no-deps").replace( + "importers:", + "overrides:\n a-dep: 1.0.1\n\nimporters:", + ), + }, + }); + + const update = await run(packageDir, "update"); + + expect(update.stderr).toContain( + "copied pnpm.overrides to overrides, pnpm.patchedDependencies to patchedDependencies in package.json", + ); + expect(update.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(update.stderr).not.toContain("error:"); + expect(update.stdout).toContain("no-deps@1.0.1"); + expect(update.exitCode).toBe(0); + expect(await Bun.file(join(packageDir, "package.json")).json()).toStrictEqual({ + name: "update-after-migration", + dependencies: { "no-deps": "^1.0.1" }, + pnpm, + overrides: pnpm.overrides, + patchedDependencies: { "no-deps@1.0.1": "patches/no-deps.patch" }, + }); + expect(await Bun.file(join(packageDir, "node_modules/no-deps/index.js")).text()).toStartWith( + "globalThis.patchedByMigration = true;\n", + ); + + const frozen = await run(packageDir, "install", "--frozen-lockfile"); + + expect(frozen.stderr).not.toContain("error:"); + expect(frozen.exitCode).toBe(0); + }); + test("versioned lockfile key falls back to the bare config key", async () => { const { packageDir } = await verdaccio.createTestDir({ bunfigOpts: { linker: "hoisted" }, @@ -2878,6 +2930,77 @@ snapshots: }); }); + // #23694: `bun update -i` migrates once to list the outdated packages, edits the root package.json through the + // cache, then installs, which migrates again because bun.lock is still not on disk. The editor and the second + // migration both used the tree the first migration had edited, which pointed into the contents it had freed. + test("bun update -i in a pnpm workspace migrates twice and keeps package.json intact", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "update-interactive", + dependencies: { "no-deps": "^1.0.0" }, + pnpm: { overrides: { "a-dep": "1.0.1" } }, + }), + "packages/a/package.json": JSON.stringify({ name: "a" }), + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +overrides: + a-dep: 1.0.1 + +importers: + + .: + dependencies: + no-deps: + specifier: ^1.0.0 + version: 1.0.0 + + packages/a: {} + +packages: + + no-deps@1.0.0: + resolution: {integrity: ${NO_DEPS_1_0_0_INTEGRITY}} + +snapshots: + + no-deps@1.0.0: {} +`, + }, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "update", "-i"], + cwd: packageDir, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache") }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write("a\r"); + proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("no-deps"); + expect(exitCode).toBe(0); + expect(await Bun.file(join(packageDir, "package.json")).json()).toStrictEqual({ + name: "update-interactive", + dependencies: { "no-deps": "^1.1.0" }, + pnpm: { overrides: { "a-dep": "1.0.1" } }, + overrides: { "a-dep": "1.0.1" }, + workspaces: ["packages/*"], + }); + expect(existsSync(join(packageDir, "bun.lock"))).toBe(true); + + const frozen = await run(packageDir, "install", "--frozen-lockfile"); + + expect(frozen.stderr).not.toContain("error:"); + expect(frozen.exitCode).toBe(0); + }); + describe("overrides", () => { function overridesLockfile(overrides: string) { return `lockfileVersion: '9.0' @@ -2899,7 +3022,7 @@ importers: return bunLock.slice(start, end + "\n },".length); } - // pnpm/pnpm#5928 (`-` removes the dependency) is warned once, with a location, when bun install reads the moved package.json overrides; pnpm/pnpm#6774 (`name@range` keys) migrates as ranged rules + // pnpm/pnpm#5928 (`-` removes the dependency) is warned once, with a location, when bun install reads the copied package.json overrides; pnpm/pnpm#6774 (`name@range` keys) migrates as ranged rules test.concurrent("removal values are dropped; name@range keys migrate as ranged rules", async () => { const overrides = { "left-pad": "-", @@ -2922,12 +3045,13 @@ importers: const { stderr, exitCode } = await migrate(String(dir)); expect(stderr).not.toContain("warn:"); - expect(stderr).toContain("moved pnpm.overrides to overrides in package.json"); + expect(stderr).toContain("copied pnpm.overrides to overrides in package.json"); expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(exitCode).toBe(0); expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ name: "overrides-unsupported", + pnpm: { overrides }, overrides, }); @@ -2958,6 +3082,103 @@ importers: expect(install.exitCode).toBe(0); }); + // pnpm reads `pnpm.overrides` and `pnpm.patchedDependencies` from package.json and ignores the root-level + // fields, so removing them from the `pnpm` block breaks `pnpm install --frozen-lockfile` for everyone still on + // pnpm (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH). The block has to survive the migration as it was. + test.concurrent("the pnpm block in package.json is left as it was", async () => { + const pnpm = { + overrides: { "no-deps": "1.0.0" }, + patchedDependencies: { "one-dep@1.0.0": "patches/one-dep.patch" }, + onlyBuiltDependencies: ["one-dep"], + }; + using dir = tempDir("pnpm-v9-pnpm-block-kept", { + "package.json": JSON.stringify({ name: "pnpm-block-kept", private: true, pnpm }), + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0"), + }); + using onlyMigratedKeys = tempDir("pnpm-v9-pnpm-block-only-migrated-keys", { + "package.json": JSON.stringify({ name: "pnpm-block-only-migrated-keys", pnpm: { overrides: pnpm.overrides } }), + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm.overrides to overrides, pnpm.patchedDependencies to patchedDependencies in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "pnpm-block-kept", + private: true, + pnpm, + overrides: pnpm.overrides, + patchedDependencies: pnpm.patchedDependencies, + }); + + const onlyMigrated = await migrate(String(onlyMigratedKeys)); + + expect(onlyMigrated.stderr).toContain("copied pnpm.overrides to overrides in package.json"); + expect(onlyMigrated.exitCode).toBe(0); + expect(await Bun.file(join(String(onlyMigratedKeys), "package.json")).json()).toStrictEqual({ + name: "pnpm-block-only-migrated-keys", + pnpm: { overrides: pnpm.overrides }, + overrides: pnpm.overrides, + }); + }); + + // pnpm 10 merges `pnpm.overrides` with the `overrides` of pnpm-workspace.yaml, and the lockfile records the + // merged set. The yaml entries belong in the root-level copy only. + test.concurrent("pnpm-workspace.yaml overrides go into the root copy, not into the pnpm block", async () => { + using dir = tempDir("pnpm-v9-overrides-package-json-and-workspace-yaml", { + "package.json": JSON.stringify({ + name: "overrides-package-json-and-workspace-yaml", + pnpm: { overrides: { "no-deps": "1.0.0" } }, + }), + "pnpm-workspace.yaml": "overrides:\n a-dep: 1.0.1\n", + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0\n a-dep: 1.0.1"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm.overrides to overrides, pnpm-workspace.yaml overrides to overrides in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "overrides-package-json-and-workspace-yaml", + pnpm: { overrides: { "no-deps": "1.0.0" } }, + overrides: { "no-deps": "1.0.0", "a-dep": "1.0.1" }, + }); + }); + + // Unquoted yaml scalars point into the file contents; quoted ones (the usual spelling for scoped names) are + // copied into the arena the yaml was parsed with, which has to stay alive until package.json is printed. + test.concurrent("quoted pnpm-workspace.yaml scalars are written to package.json", async () => { + using dir = tempDir("pnpm-v9-workspace-yaml-quoted", { + "package.json": JSON.stringify({ name: "workspace-yaml-quoted" }), + "pnpm-workspace.yaml": `overrides: + '@scope/pkg': "1.0.0" +catalog: + '@scope/other': '^2.0.0' +`, + "pnpm-lock.yaml": overridesLockfile(" '@scope/pkg': 1.0.0"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm-workspace.yaml to workspaces, pnpm-workspace.yaml overrides to overrides in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "workspace-yaml-quoted", + workspaces: { catalog: { "@scope/other": "^2.0.0" } }, + overrides: { "@scope/pkg": "1.0.0" }, + }); + }); + test.concurrent("parent selectors become nested rules", async () => { using dir = tempDir("pnpm-v9-overrides-nested", { "package.json": JSON.stringify({ name: "overrides-nested" }), @@ -2995,7 +3216,7 @@ importers: const deep = await migrate(String(tooDeep)); expect(deep.stderr).not.toContain("warn:"); - expect(deep.stderr).toContain("moved pnpm.overrides to overrides in package.json"); + expect(deep.stderr).toContain("copied pnpm.overrides to overrides in package.json"); expect(deep.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(deep.exitCode).toBe(0); expect(await bunLockOf(String(tooDeep))).not.toContain("a>b"); diff --git a/test/cli/install/nested-overrides.test.ts b/test/cli/install/nested-overrides.test.ts index 1cba002e7b9d..4a94485c15e5 100644 --- a/test/cli/install/nested-overrides.test.ts +++ b/test/cli/install/nested-overrides.test.ts @@ -1835,7 +1835,7 @@ snapshots: } const migratedLine = /\[[\d.]+m?s\] migrated lockfile from pnpm-lock\.yaml\n/; - const movedOverridesLine = "moved pnpm.overrides to overrides in package.json"; + const copiedOverridesLine = "copied pnpm.overrides to overrides in package.json"; test("pnpm-lock.yaml parent>child overrides become nested rules that package.json agrees with", async () => { const dir = await project( @@ -1847,7 +1847,7 @@ snapshots: expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); expect(migrated.err).toMatch(migratedLine); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"one-dep": {'); @@ -1857,6 +1857,7 @@ snapshots: expect(JSON.parse(packageJson)).toStrictEqual({ name: "nested-overrides", dependencies: { "one-dep": "1.0.0" }, + pnpm: { overrides: { "one-dep>no-deps": "2.0.0" } }, overrides: { "one-dep>no-deps": "2.0.0" }, }); await installOk(dir, "--frozen-lockfile"); @@ -1871,7 +1872,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"one-dep": {'); @@ -1889,7 +1890,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"lockfileVersion": 3'); @@ -1920,7 +1921,7 @@ snapshots: }) .then(({ packageDir }) => packageDir); const migrated = await migrate(dir); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.err).not.toContain("error:"); expect(migrated.exitCode).toBe(0); const packageJson = await packageJsonText(dir); @@ -1931,10 +1932,11 @@ snapshots: name: "nested-overrides", dependencies: { "one-dep": "1.0.0" }, overrides: { "a-dep": "1.0.1", "one-dep>no-deps": "2.0.0" }, + pnpm: { overrides: { "one-dep>no-deps": "2.0.0" } }, }); }); - test("an empty pnpm.overrides is not moved and package.json is not announced as modified", async () => { + test("an empty pnpm.overrides is not copied and package.json is not announced as modified", async () => { const dir = await project({ dependencies: { "one-dep": "1.0.0" }, pnpm: { overrides: {} } }, "hoisted", { "pnpm-lock.yaml": await pnpmLock({ noDepsVersion: "1.0.1" }), }); @@ -1942,7 +1944,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(migrated.err).not.toContain(movedOverridesLine); + expect(migrated.err).not.toContain(copiedOverridesLine); expect(migrated.err).toMatch(migratedLine); expect(migrated.exitCode).toBe(0); expect(await packageJsonText(dir)).toBe(before); @@ -1978,14 +1980,14 @@ snapshots: ), ); - test("bun install warns once per rejected rule that the migration moved into package.json", async () => { + test("bun install warns once per rejected rule that the migration copied into package.json", async () => { const dir = await rejectedRulesProject(); const { err, exitCode } = await install(dir); expect(occurrences(err, 'warn: Removing "left-pad" with "-" is not supported')).toBe(1); expect(occurrences(err, 'warn: Bun currently only supports one level of nested "overrides"')).toBe(1); expect(occurrences(err, "warn:")).toBe(2); expect(err).toMatch(migratedLine); - expect(occurrences(err, movedOverridesLine)).toBe(1); + expect(occurrences(err, copiedOverridesLine)).toBe(1); expect(err).not.toContain("error:"); expect(exitCode).toBe(0); expect((await file(join(dir, "package.json")).json()).overrides).toStrictEqual({ From 50c0b934b80599a81d4cc6d0335b38bf25096a76 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:52 +0000 Subject: [PATCH 018/258] install: fix default-trusted lifecycle scripts being blocked after yarn.lock migration (#38795) --- src/install/dependency.rs | 2 +- src/install/lockfile.rs | 4 +- src/install/yarn.rs | 66 ++++--- .../bun-install-lifecycle-scripts.test.ts | 173 ++++++++++++++++++ .../yarn-lock-migration.test.ts.snap | 4 +- .../migration/yarn-lock-migration.test.ts | 120 ++++++++++++ 6 files changed, 336 insertions(+), 33 deletions(-) diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 954735c4c116..8e3b39faad9f 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -417,7 +417,7 @@ pub(crate) fn is_scp_like_path(dependency: &[u8]) -> bool { /// /// This also checks for a github url that ends with ".tar.gz" #[inline] -fn is_github_tarball_path(dependency: &[u8]) -> bool { +pub(crate) fn is_github_tarball_path(dependency: &[u8]) -> bool { if is_tarball(dependency) { return true; } diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index ff3f5bcc5ff5..917c025518f9 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -3254,7 +3254,9 @@ impl Lockfile { ) else { return false; }; - url == canonical_url.as_slice() + // Older yarn.lock migrations stored yarn's `#` on the URL. A fragment is + // never sent to the registry, so it cannot change which tarball is downloaded. + crate::yarn::Entry::url_without_hash(url) == canonical_url.as_slice() } fn declared_by_root_or_workspace(&self, alias: &[u8], resolution: &Resolution) -> bool { diff --git a/src/install/yarn.rs b/src/install/yarn.rs index 5dfb95a04f08..da1e1c5dab41 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -129,10 +129,13 @@ impl<'a> Entry<'a> { } pub(crate) fn is_git_dependency(version: &[u8]) -> bool { + if let Some(github_path) = version.strip_prefix(b"https://github.com/") { + // An archive download's `#` is yarn's tarball hash, not a commit. + return !dependency::is_github_tarball_path(Entry::url_without_hash(github_path)); + } version.starts_with(b"git+") || version.starts_with(b"git://") || version.starts_with(b"github:") - || version.starts_with(b"https://github.com/") } pub(crate) fn is_npm_alias(version: &[u8]) -> bool { @@ -143,6 +146,14 @@ impl<'a> Entry<'a> { version.starts_with(b"https://") && version.ends_with(b".tgz") } + /// yarn v1 writes tarball `resolved` fields as `#`. + pub(crate) fn url_without_hash(resolved: &[u8]) -> &[u8] { + match strings::index_of_char_usize(resolved, b'#') { + Some(hash_idx) => &resolved[..hash_idx], + None => resolved, + } + } + pub(crate) fn is_workspace_dependency(version: &[u8]) -> bool { version.starts_with(b"workspace:") || version == b"*" } @@ -939,31 +950,26 @@ pub(crate) fn migrate_yarn_lockfile<'a>( package_id_to_yarn_idx[package_id as usize] = yarn_idx; + let resolved_url: Option<&[u8]> = entry.resolved.as_deref().map(Entry::url_without_hash); + let name_to_use: &[u8] = 'blk: { if entry.commit.is_some() && entry.git_repo_name.is_some() { break 'blk entry.git_repo_name.as_deref().unwrap(); - } else if let Some(resolved) = entry.resolved.as_deref() { - if is_direct_url_dep - || Entry::is_remote_tarball(resolved) - || resolved.ends_with(b".tgz") + } else if let (true, Some(resolved)) = (is_direct_url_dep, resolved_url) { + // https://registry.npmjs.org/package/-/package-version.tgz + if strings::index_of(resolved, b"registry.npmjs.org/").is_some() + || strings::index_of(resolved, b"registry.yarnpkg.com/").is_some() { - // https://registry.npmjs.org/package/-/package-version.tgz - if strings::index_of(resolved, b"registry.npmjs.org/").is_some() - || strings::index_of(resolved, b"registry.yarnpkg.com/").is_some() - { - if let Some(separator_idx) = strings::index_of(resolved, b"/-/") { - if let Some(registry_idx) = strings::index_of(resolved, b"registry.") { - let after_registry = &resolved[registry_idx..]; - if let Some(domain_slash) = strings::index_of(after_registry, b"/") - { - let package_start = registry_idx + domain_slash + 1; - let extracted_name = &resolved[package_start..separator_idx]; - break 'blk extracted_name; - } + if let Some(separator_idx) = strings::index_of(resolved, b"/-/") { + if let Some(registry_idx) = strings::index_of(resolved, b"registry.") { + let after_registry = &resolved[registry_idx..]; + if let Some(domain_slash) = strings::index_of(after_registry, b"/") { + let package_start = registry_idx + domain_slash + 1; + let extracted_name = &resolved[package_start..separator_idx]; + break 'blk extracted_name; } } } - break 'blk base_name; } } break 'blk base_name; @@ -1033,29 +1039,31 @@ pub(crate) fn migrate_yarn_lockfile<'a>( } } break 'blk Resolution::default(); - } else if let Some(resolved) = entry.resolved.as_deref() { + } else if let Some(resolved) = resolved_url { if is_direct_url_dep { break 'blk Resolution::init(ResolutionValue::RemoteTarball( sbuf!().append(resolved)?, )); } - // Yarn v1 lockfiles legitimately contain entries without an integrity field - // (workspace deps, file:, codeload tarballs), so migration intentionally - // accepts off-registry tarball URLs without integrity instead of failing. - if Entry::is_remote_tarball(resolved) || resolved.ends_with(b".tgz") { - break 'blk Resolution::init(ResolutionValue::RemoteTarball( - sbuf!().append(resolved)?, - )); - } - let version = sbuf!().append(entry.version)?; let result = Semver::Version::parse(version.sliced(this.buffers.string_bytes.as_slice())); if !result.valid { + // Yarn v1 lockfiles legitimately contain entries without an integrity field + // (workspace deps, file:, codeload tarballs), so migration intentionally + // accepts off-registry tarball URLs without integrity instead of failing. + if Entry::is_remote_tarball(resolved) || resolved.ends_with(b".tgz") { + break 'blk Resolution::init(ResolutionValue::RemoteTarball( + sbuf!().append(resolved)?, + )); + } break 'blk Resolution::default(); } + // `has_trusted_dependency` compares this URL with the canonical registry + // tarball URL, so it must be the bare URL a fresh install records: no + // `#sha1`, and no RemoteTarball just because the URL ends in `.tgz`. let is_default_registry = resolved.starts_with(b"https://registry.yarnpkg.com/") || resolved.starts_with(b"https://registry.npmjs.org/"); diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index bd4989773c04..d641b05600a8 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -617,6 +617,179 @@ test.concurrent("default trusted dependencies require the canonical registry tar expect(await exited).toBe(0); }); +describe("default trusted dependencies after yarn.lock migration", () => { + const electronIntegrity = + "sha512-GkuwCdn6o8Krsxb3DIIqYP+TAi8Y5jYUadmseZ6nR2op2k5ssdKRYo4JjYDGopa1ACrGAcQuWViz/+vX/WjYnA=="; + // `all-lifecycle-scripts@1.0.0`: a different package with preinstall, install + // and postinstall scripts of its own, used to record a tarball that is not + // electron's under the default-trusted name `electron`. + const otherIntegrity = + "sha512-hgU56juWYnFOQ3byQuydEgugxd+iWvaWfaoGvly4k/AxehC3dhM6IhXoDc3K7b/n1mP/II8hGIjI+LmxXFNMlw=="; + + // Installs `electron@1.0.0` (on the default trusted list) from a yarn.lock + // that resolves it to `resolved`, and returns the migrated bun.lock. + async function installFromYarnLock(ctx: TestCtx, resolved: string, integrity: string) { + const { packageDir, packageJson, env } = ctx; + await Promise.all([ + writeFile( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "electron": "1.0.0", + }, + }), + ), + writeFile( + join(packageDir, "yarn.lock"), + `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +electron@1.0.0: + version "1.0.0" + resolved "${resolved}" + integrity ${integrity} +`, + ), + ]); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).toContain("migrated lockfile from yarn.lock"); + expect(err).not.toContain("error:"); + expect(await exists(join(packageDir, "node_modules", "electron", "package.json"))).toBeTrue(); + expect(exitCode).toBe(0); + return { out, lockfile: await file(join(packageDir, "bun.lock")).text() }; + } + + // yarn v1 writes the tarball's sha1 as a URL fragment after registry tarball + // URLs. Other producers of the format (bun's own `--yarn` output, registries + // without a shasum) omit it. Both shapes describe the canonical registry + // tarball for electron@1.0.0, so both must keep the default-trusted grant + // after migration, exactly like an install that never had a yarn.lock. + test.concurrent.each([ + ["with a sha1 fragment", "#f1b8bc2c23cd7e4f1500669dfaf8757578d2e391"], + ["without a fragment", ""], + ])("scripts run for the canonical registry tarball URL %s", async (_, fragment) => { + using ctx = await setupTest(); + const canonicalUrl = `http://localhost:${verdaccio.port}/electron/-/electron-1.0.0.tgz`; + + const { out, lockfile } = await installFromYarnLock(ctx, `${canonicalUrl}${fragment}`, electronIntegrity); + + expect(out).not.toContain("Blocked"); + expect(await exists(join(ctx.packageDir, "node_modules", "electron", "preinstall.txt"))).toBeTrue(); + // The migrated entry is an npm package whose tarball URL is the canonical + // one for the configured registry, with yarn's fragment dropped. + expect(lockfile).toContain(`"electron": ["electron@1.0.0", "${canonicalUrl}", {}, "${electronIntegrity}"]`); + }); + + test.concurrent("scripts stay blocked when the yarn.lock points the name at another tarball", async () => { + using ctx = await setupTest(); + // Same registry, but another package's tarball recorded under the + // default-trusted name. Migration must keep that URL, so the canonical URL + // check still denies the default grant. + const otherUrl = `http://localhost:${verdaccio.port}/all-lifecycle-scripts/-/all-lifecycle-scripts-1.0.0.tgz`; + + const { lockfile } = await installFromYarnLock( + ctx, + `${otherUrl}#91cd0bd6a450b21db0078b9118c54bc0a27fccb7`, + otherIntegrity, + ); + + expect(lockfile).toContain(`"electron": ["electron@1.0.0", "${otherUrl}", {}, "${otherIntegrity}"]`); + const electronDir = join(ctx.packageDir, "node_modules", "electron"); + expect( + await Promise.all([ + exists(join(electronDir, "install.js")), + exists(join(electronDir, "preinstall.txt")), + exists(join(electronDir, "install.txt")), + exists(join(electronDir, "postinstall.txt")), + ]), + ).toEqual([true, false, false, false]); + }); + + // Earlier versions of the migrator wrote yarn's `#sha1` into bun.lock itself. + // Those lockfiles are still out there, so the canonical URL check has to look + // past the fragment (which never reaches the registry) while still rejecting + // a URL whose path is not electron's tarball. + test.concurrent.each([ + [ + "runs scripts for the canonical tarball", + "electron/-/electron-1.0.0.tgz#f1b8bc2c23cd7e4f1500669dfaf8757578d2e391", + electronIntegrity, + true, + ], + [ + "keeps blocking another tarball", + "all-lifecycle-scripts/-/all-lifecycle-scripts-1.0.0.tgz#91cd0bd6a450b21db0078b9118c54bc0a27fccb7", + otherIntegrity, + false, + ], + ])("a bun.lock already migrated with the #sha1 suffix %s", async (_, tarball, integrity, scriptsRun) => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + const url = `http://localhost:${verdaccio.port}/${tarball}`; + + await Promise.all([ + writeFile( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "electron": "1.0.0", + }, + }), + ), + writeFile( + join(packageDir, "bun.lock"), + `{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "foo", + "dependencies": { + "electron": "1.0.0", + }, + }, + }, + "packages": { + "electron": ["electron@1.0.0", "${url}", {}, "${integrity}"], + } +} +`, + ), + ]); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + expect(err).not.toContain("error:"); + const electronDir = join(packageDir, "node_modules", "electron"); + expect( + await Promise.all([exists(join(electronDir, "package.json")), exists(join(electronDir, "preinstall.txt"))]), + ).toEqual([true, scriptsRun]); + expect(exitCode).toBe(0); + }); +}); + test.concurrent("binary lockfile trusted dependency entries require an exact name match", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; diff --git a/test/cli/install/migration/__snapshots__/yarn-lock-migration.test.ts.snap b/test/cli/install/migration/__snapshots__/yarn-lock-migration.test.ts.snap index b52a34874284..c0e389462c75 100644 --- a/test/cli/install/migration/__snapshots__/yarn-lock-migration.test.ts.snap +++ b/test/cli/install/migration/__snapshots__/yarn-lock-migration.test.ts.snap @@ -2935,7 +2935,7 @@ exports[`bun pm migrate for existing yarn.lock yarn-cli-repo: yarn-cli-repo 1`] "class-utils/define-property/is-descriptor/is-data-descriptor/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ="], - "commitizen/inquirer/cli-cursor/restore-cursor/onetime": ["onetime@1.1.0", "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789", {}, "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k="], + "commitizen/inquirer/cli-cursor/restore-cursor/onetime": ["onetime@1.1.0", "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", {}, "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k="], "eslint/inquirer/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="], @@ -3154,7 +3154,7 @@ exports[`bun pm migrate for existing yarn.lock yarn-stuff: yarn-stuff 1`] = ` "reg": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], - "remote": ["abbrev@https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8", {}], + "remote": ["abbrev@https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", {}], "symlink": ["symlink@file:abbrev-link-target", {}], diff --git a/test/cli/install/migration/yarn-lock-migration.test.ts b/test/cli/install/migration/yarn-lock-migration.test.ts index bcaa8f63f27d..83fe9cd28754 100644 --- a/test/cli/install/migration/yarn-lock-migration.test.ts +++ b/test/cli/install/migration/yarn-lock-migration.test.ts @@ -1809,3 +1809,123 @@ ${yarnEntry("peer-deps-too", "1.0.0", ` peerDependencies:\n no-deps "*"\n`)} expect(storeEntries(fresh)).toEqual(store); }); }); + +describe.concurrent("yarn.lock migration of registry tarball URLs", () => { + const integrity = { + parent: "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + leaf: "sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q==", + }; + + async function migrate(dependencies: Record, yarnLockEntries: string) { + // After migrating, bun fetches manifests from the configured registry to + // fill in bin/os/cpu. Point it at a local server that has nothing so the + // test stays off the network; the migrated resolutions don't depend on it. + using registry = Bun.serve({ + port: 0, + fetch: () => new Response("not found", { status: 404 }), + }); + await using dir = tempDir("yarn-migration-registry-urls", { + "package.json": JSON.stringify({ name: "registry-urls", version: "1.0.0", dependencies }), + "yarn.lock": `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +${yarnLockEntries}`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "migrate", "-f"], + cwd: String(dir), + env: { ...bunEnv, BUN_CONFIG_REGISTRY: registry.url.href }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout + stderr).toContain("migrated lockfile from yarn.lock"); + expect(exitCode).toBe(0); + return fs.readFileSync(join(String(dir), "bun.lock"), "utf8"); + } + + test("private registry: yarn's #sha1 suffix is dropped and suffix-less URLs stay npm packages", async () => { + const registry = "https://npm.example.com"; + const bunLock = await migrate( + { parent: "^1.0.0" }, + `parent@^1.0.0: + version "1.2.3" + resolved "${registry}/parent/-/parent-1.2.3.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity ${integrity.parent} + dependencies: + "@scope/leaf" "~2.0.0" + +"@scope/leaf@~2.0.0": + version "2.0.1" + resolved "${registry}/@scope/leaf/-/leaf-2.0.1.tgz" + integrity ${integrity.leaf} +`, + ); + + // Both entries are npm packages whose tarball URL is exactly what the + // registry would hand a fresh install: no `#sha1`, and no remote tarball + // entry (`parent@https://...`) just because yarn omitted the suffix. + expect(bunLock).toContain( + `"parent": ["parent@1.2.3", "${registry}/parent/-/parent-1.2.3.tgz", { "dependencies": { "@scope/leaf": "~2.0.0" } }, "${integrity.parent}"]`, + ); + expect(bunLock).toContain( + `"@scope/leaf": ["@scope/leaf@2.0.1", "${registry}/@scope/leaf/-/leaf-2.0.1.tgz", {}, "${integrity.leaf}"]`, + ); + }); + + test("default registry: a URL without yarn's #sha1 suffix is still the default registry", async () => { + // This is the shape `bun install --yarn` itself writes. + const bunLock = await migrate( + { "@scope/leaf": "~2.0.0", "leaf-alias": "npm:leaf@^1.0.0" }, + `"@scope/leaf@~2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scope/leaf/-/leaf-2.0.1.tgz" + integrity ${integrity.leaf} + +"leaf-alias@npm:leaf@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/leaf/-/leaf-1.0.0.tgz" + integrity ${integrity.parent} +`, + ); + + expect(bunLock).toContain(`"@scope/leaf": ["@scope/leaf@2.0.1", "", {}, "${integrity.leaf}"]`); + expect(bunLock).toContain(`"leaf-alias": ["leaf@1.0.0", "", {}, "${integrity.parent}"]`); + }); + + test("a dependency declared as a tarball URL stays a remote tarball, without yarn's #sha1 suffix", async () => { + const registryUrl = "https://npm.example.com/leaf/-/leaf-2.0.1.tgz"; + // yarn puts the same `#sha1` after a GitHub archive download, where it must + // not be mistaken for the commit of a git dependency. + const githubUrl = "https://github.com/isaacs/abbrev-js/archive/refs/tags/v1.1.1.tar.gz"; + const bunLock = await migrate( + { "leaf": registryUrl, "gh-tar": githubUrl }, + `"leaf@${registryUrl}": + version "2.0.1" + resolved "${registryUrl}#f8f2c887ad10bf67f634f005b6987fed3179aac8" + +"gh-tar@${githubUrl}": + version "1.1.1" + resolved "${githubUrl}#f8f2c887ad10bf67f634f005b6987fed3179aac8" +`, + ); + + expect(bunLock).toContain(`"leaf": ["leaf@${registryUrl}", {}]`); + expect(bunLock).toContain(`"gh-tar": ["gh-tar@${githubUrl}", {}]`); + }); + + test("a git dependency hosted on github.com is still migrated as git", async () => { + const bunLock = await migrate( + { abbrev: "https://github.com/isaacs/abbrev-js.git" }, + `"abbrev@https://github.com/isaacs/abbrev-js.git": + version "1.1.1" + resolved "https://github.com/isaacs/abbrev-js.git#3f9802e56ff878761a338e43ecacbfed39d2181d" +`, + ); + + expect(bunLock).toContain(`"abbrev": ["abbrev-js@github:isaacs/abbrev-js#3f9802e", {}, ""]`); + }); +}); From de8bd474c95155764bfb147eaff46b0f25e49a6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:04:57 +0000 Subject: [PATCH 019/258] install: migrate the whole pnpm-workspace.yaml catalog into bun.lock (#38829) --- docs/pm/cli/install.mdx | 1 + src/install/lockfile/CatalogMap.rs | 140 ++++++-- src/install/pnpm.rs | 10 +- .../pnpm-migration-complete.test.ts.snap | 2 + .../install/migration/pnpm-lock-v9.test.ts | 308 +++++++++++++++++- 5 files changed, 429 insertions(+), 32 deletions(-) diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index 08f526a04126..f698ef57b4ea 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -523,6 +523,7 @@ The migration process handles: - Converts `pnpm-lock.yaml` (lockfile versions 7–9, including pnpm 11's multi-document files) to `bun.lock` - Preserves resolved versions and integrity hashes - Preserves peer dependency ranges and `peerDependenciesMeta`, so the next `bun install` leaves the migrated lockfile unchanged +- Writes every catalog entry declared in `pnpm-workspace.yaml` to `bun.lock`, including entries no workspace uses yet (`pnpm-lock.yaml` lists only the entries in use), so the next `bun install` leaves the catalog unchanged - Migrates git, GitHub, tarball URL, `file:`, and `npm:` alias dependencies, including transitive ones - Resolves pnpm named registries (`name@registry:version`) via `namedRegistries` in `pnpm-workspace.yaml` - Converts injected workspace packages (`dependenciesMeta.*.injected`) to ordinary workspace dependencies diff --git a/src/install/lockfile/CatalogMap.rs b/src/install/lockfile/CatalogMap.rs index d54a7b0d728f..e7e5be2183fb 100644 --- a/src/install/lockfile/CatalogMap.rs +++ b/src/install/lockfile/CatalogMap.rs @@ -10,7 +10,7 @@ use bun_install::dependency::{ Version as DependencyVersion, }; use bun_install::lockfile::{Buffers, StringBuilder}; -use bun_install::{Behavior, Dependency, Lockfile, PackageManager}; +use bun_install::{Behavior, Dependency, Lockfile, PackageManager, PackageNameHash}; use bun_semver::SlicedString; use core::mem::ManuallyDrop; // Layering: every install-side caller (Package.rs / pnpm.rs) parses JSON/YAML @@ -206,14 +206,7 @@ impl CatalogMap { if catalog_name.is_empty() { return Ok(&mut self.default); } - - let entry = self.groups.get_or_put_adapted(&catalog_name, &ctx(buf))?; - if !entry.found_existing { - *entry.key_ptr = catalog_name; - *entry.value_ptr = Map::default(); - } - - Ok(entry.value_ptr) + get_or_put_named_group(&mut self.groups, buf, catalog_name) } // Deliberately takes no `Lockfile` param so `lockfile.catalogs.parse_count` @@ -395,6 +388,31 @@ impl CatalogMap { Ok(()) } + /// `catalog_obj`/`catalogs_obj` are the pnpm-workspace.yaml objects the migration writes into package.json for `parse_append` to read back, so the map takes their shape: pnpm-lock.yaml's section has only the entries in use and spells every default catalog `default`, while entries it does record keep its specifier, which their resolutions were made with. + pub(crate) fn put_missing_from_pnpm_workspace( + catalogs: &mut CatalogMap, + catalog_obj: Option, + catalogs_obj: Option, + string_buf: &mut StringBuf, + ) -> Result<(), AllocError> { + if let Some(declared) = catalog_obj { + put_declared_entries(&mut catalogs.default, None, &declared, string_buf)?; + } + let Some(declared_groups) = catalogs_obj else { + return Ok(()); + }; + declared_groups.try_for_each_property(|group_name_str, _, declared| { + let group_name = string_buf.append(group_name_str)?; + if group_name_str != b"default" { + let group = catalogs.get_or_put_group(string_buf.bytes.as_slice(), group_name)?; + return put_declared_entries(group, None, &declared, string_buf); + } + let CatalogMap { default, groups } = &mut *catalogs; + let group = get_or_put_named_group(groups, string_buf.bytes.as_slice(), group_name)?; + put_declared_entries(group, Some(default), &declared, string_buf) + }) + } + // Takes `buffers: &Buffers` rather than the whole `Lockfile` so the call // site can hold `&mut lockfile.catalogs` while only borrowing // `lockfile.buffers` immutably (disjoint fields), instead of forcing a @@ -541,28 +559,17 @@ fn put_entries_from_pnpm_lockfile( let Some(version_str) = specifier.as_utf8_string_literal() else { return Err(FromPnpmLockfileError::InvalidPnpmLockfile); }; - let version_hash = StringBuilderNs::string_hash(version_str); - let version = string_buf.append_with_hash(version_str, version_hash)?; - let version_sliced = version.sliced(string_buf.bytes.as_slice()); - - let Some(parsed_version) = Dependency::parse( + let Some(dep) = parse_entry( dep_name, dep_name_hash, - version_sliced.slice, - &version_sliced, + version_str, Some(&mut *log), - None, - ) else { + string_buf, + )? + else { return Err(FromPnpmLockfileError::InvalidPnpmLockfile); }; - let dep = Dependency { - name: dep_name, - name_hash: dep_name_hash, - version: parsed_version, - ..Dependency::default() - }; - let buf = string_buf.bytes.as_slice(); let entry = catalog_map.get_or_put_adapted(&dep_name, &ctx(buf))?; @@ -575,3 +582,86 @@ fn put_entries_from_pnpm_lockfile( } Ok(()) } + +/// Entries `group` records stay as recorded, ones `lockfile_default` recorded move into `group`, the rest are added; an entry that does not parse is left out silently because `parse_append_group` leaves it out and reports it when the next install reads package.json. +fn put_declared_entries( + group: &mut Map, + mut lockfile_default: Option<&mut Map>, + declared: &Expr, + string_buf: &mut StringBuf, +) -> Result<(), AllocError> { + declared.try_for_each_property(|dep_name_str, _, specifier| { + let dep_name_hash = StringBuilderNs::string_hash(dep_name_str); + let dep_name = string_buf.append_with_hash(dep_name_str, dep_name_hash)?; + let buf = string_buf.bytes.as_slice(); + if group.contains_adapted(&dep_name, &ctx(buf)) { + return Ok(()); + } + let recorded = lockfile_default + .as_deref_mut() + .and_then(|lockfile_default| { + let i = lockfile_default.get_index_adapted(&dep_name, &ctx(buf))?; + Some(lockfile_default.swap_remove_at(i).1) + }); + let dep = match recorded { + Some(dep) => dep, + None => { + let Some(specifier_str) = specifier.as_utf8_string_literal() else { + return Ok(()); + }; + let Some(dep) = + parse_entry(dep_name, dep_name_hash, specifier_str, None, string_buf)? + else { + return Ok(()); + }; + dep + } + }; + let buf = string_buf.bytes.as_slice(); + let entry = group.get_or_put_adapted(&dep_name, &ctx(buf))?; + *entry.key_ptr = dep_name; + *entry.value_ptr = dep; + Ok(()) + }) +} + +/// `dep_name` is already in `string_buf`; `None` means `specifier` is not a dependency version. +fn parse_entry( + dep_name: String, + dep_name_hash: PackageNameHash, + specifier: &[u8], + log: Option<&mut Log>, + string_buf: &mut StringBuf, +) -> Result, AllocError> { + let version = string_buf.append(specifier)?; + let version_sliced = version.sliced(string_buf.bytes.as_slice()); + let Some(version) = Dependency::parse( + dep_name, + dep_name_hash, + version_sliced.slice, + &version_sliced, + log, + None, + ) else { + return Ok(None); + }; + Ok(Some(Dependency { + name: dep_name, + name_hash: dep_name_hash, + version, + ..Dependency::default() + })) +} + +fn get_or_put_named_group<'a>( + groups: &'a mut ArrayHashMap, + buf: &[u8], + catalog_name: String, +) -> Result<&'a mut Map, AllocError> { + let entry = groups.get_or_put_adapted(&catalog_name, &ctx(buf))?; + if !entry.found_existing { + *entry.key_ptr = catalog_name; + *entry.value_ptr = Map::default(); + } + Ok(entry.value_ptr) +} diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index ed086e505dce..479beef92007 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -1626,7 +1626,7 @@ pub(crate) fn migrate_pnpm_lockfile<'a>( lockfile.fetch_necessary_package_metadata_after_yarn_or_pnpm_migration::(manager)?; - update_package_json_after_migration(manager, log, dir, &found_patches)?; + update_package_json_after_migration(lockfile, manager, log, dir, &found_patches)?; Ok(LoadResult::Ok(LoadResultOk { lockfile, @@ -2333,6 +2333,7 @@ fn rewrite_bare_patch_keys( /// Updates package.json with workspace and catalog information after migration fn update_package_json_after_migration( + lockfile: &mut Lockfile, manager: &mut PackageManager, log: &mut bun_ast::Log, dir: Fd, @@ -2441,6 +2442,13 @@ fn update_package_json_after_migration( catalog_obj = ws_root.get_object(b"catalog").filter(is_non_empty_object); catalogs_obj = ws_root.get_object(b"catalogs").filter(is_non_empty_object); + // The migrated root skips `Package::parse`, so the catalog these declare is recorded in the lockfile here. + crate::lockfile_real::CatalogMap::put_missing_from_pnpm_workspace( + &mut lockfile.catalogs, + catalog_obj, + catalogs_obj, + &mut sbuf!(lockfile), + )?; workspace_overrides_obj = ws_root.get_object(b"overrides").filter(is_non_empty_object); workspace_patched_deps_obj = ws_root .get_object(b"patchedDependencies") diff --git a/test/cli/install/migration/__snapshots__/pnpm-migration-complete.test.ts.snap b/test/cli/install/migration/__snapshots__/pnpm-migration-complete.test.ts.snap index 58de333615b4..51f7da457372 100644 --- a/test/cli/install/migration/__snapshots__/pnpm-migration-complete.test.ts.snap +++ b/test/cli/install/migration/__snapshots__/pnpm-migration-complete.test.ts.snap @@ -255,9 +255,11 @@ exports[`PNPM Migration Complete Test Suite comprehensive PNPM migration with al }, "catalog": { "react": "18.2.0", + "react-dom": "18.2.0", }, "catalogs": { "tools": { + "eslint": "8.56.0", "lodash": "4.17.21", }, }, diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index dc80656b10eb..a08d0dbe23cd 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -39,6 +39,16 @@ async function bunLockOf(dir: string) { return await Bun.file(join(dir, "bun.lock")).text(); } +// A migrated bun.lock is complete when the install after it finds nothing to save. +async function expectInstallToLeaveUnchanged(dir: string, migratedBunLock: string) { + const install = await run(dir, "install"); + + expect(install.stderr).not.toContain("Saved lockfile"); + expect(install.stderr).not.toContain("error:"); + expect(install.exitCode).toBe(0); + expect(await bunLockOf(dir)).toBe(migratedBunLock); +} + function workspacesSection(bunLock: string) { const start = bunLock.indexOf(` "workspaces": {`); const end = bunLock.indexOf(` "packages": {`); @@ -47,6 +57,15 @@ function workspacesSection(bunLock: string) { return bunLock.slice(start, end); } +// bun.lock writes `catalog` and `catalogs` between the workspaces and packages sections. +function catalogSections(bunLock: string) { + const workspacesEnd = bunLock.indexOf("\n },\n", bunLock.indexOf(` "workspaces": {`)); + const packagesStart = bunLock.indexOf(` "packages": {`); + expect(workspacesEnd).not.toBe(-1); + expect(packagesStart).not.toBe(-1); + return bunLock.slice(workspacesEnd + "\n },\n".length, packagesStart); +} + function workspaceBlock(bunLock: string, key: string) { const start = bunLock.indexOf(` "${key}": {\n`); expect(start).not.toBe(-1); @@ -2580,12 +2599,7 @@ snapshots: expect(root).toContain(` "dependencies": {\n "no-deps": "~1.0.0",\n },`); expect(root.split(`"a-dep"`).length - 1).toBe(1); - const install = await run(packageDir, "install"); - - expect(install.stderr).not.toContain("Saved lockfile"); - expect(install.stderr).not.toContain("error:"); - expect(install.exitCode).toBe(0); - expect(await bunLockOf(packageDir)).toBe(migrated); + await expectInstallToLeaveUnchanged(packageDir, migrated); expect(await installedPackageJson(packageDir, "", "a-dep")).toStrictEqual({ name: "a-dep", version: "1.0.1" }); }); @@ -3330,6 +3344,288 @@ catalog: expect(bunLock).toContain(`"no-deps@1.0.1"`); }); + // pnpm-lock.yaml only records the catalog entries some importer uses; the migration copies the whole + // pnpm-workspace.yaml catalog into package.json, so bun.lock has to get the whole catalog too or the + // next `bun install` sees a catalog change and rewrites it. + test("entries no importer uses are migrated into bun.lock", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ name: "unused-catalog", dependencies: { "no-deps": "^1.0.0" } }), + "pnpm-workspace.yaml": `catalog: + unused-default: ^9.0.0 + +catalogs: + tools: + unused-tool: ^8.0.0 +`, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +importers: + + .: + dependencies: + no-deps: + specifier: ^1.0.0 + version: 1.0.1 + +packages: + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + +snapshots: + + no-deps@1.0.1: {} +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const migrated = await bunLockOf(packageDir); + expect(catalogSections(migrated)).toBe( + [ + ` "catalog": {`, + ` "unused-default": "^9.0.0",`, + ` },`, + ` "catalogs": {`, + ` "tools": {`, + ` "unused-tool": "^8.0.0",`, + ` },`, + ` },`, + ``, + ].join("\n"), + ); + expect((await Bun.file(join(packageDir, "package.json")).json()).workspaces).toEqual({ + catalog: { "unused-default": "^9.0.0" }, + catalogs: { tools: { "unused-tool": "^8.0.0" } }, + }); + + await expectInstallToLeaveUnchanged(packageDir, migrated); + }); + + // Peers keep their catalog: reference through the migration, so the install afterwards is a no-op + // exactly when the catalog sections match what package.json declares. + test("declared entries are merged into the groups the lockfile recorded", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "merged-catalog", + peerDependencies: { "a-dep": "catalog:tools", "no-deps": "catalog:" }, + }), + "pnpm-workspace.yaml": `catalog: + no-deps: ^1.0.0 + unused-default: ^9.0.0 + +catalogs: + tools: + a-dep: ^1.0.0 + unused-tool: ^8.0.0 +`, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + +catalogs: + default: + no-deps: + specifier: ^1.0.0 + version: 1.0.1 + tools: + a-dep: + specifier: ^1.0.0 + version: 1.0.1 + +importers: + + .: + dependencies: + a-dep: + specifier: catalog:tools + version: 1.0.1 + no-deps: + specifier: 'catalog:' + version: 1.0.1 + +packages: + + a-dep@1.0.1: + resolution: {integrity: ${A_DEP_1_0_1_INTEGRITY}} + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + +snapshots: + + a-dep@1.0.1: {} + + no-deps@1.0.1: {} +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).not.toContain("missing entry"); + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const migrated = await bunLockOf(packageDir); + expect(catalogSections(migrated)).toBe( + [ + ` "catalog": {`, + ` "no-deps": "^1.0.0",`, + ` "unused-default": "^9.0.0",`, + ` },`, + ` "catalogs": {`, + ` "tools": {`, + ` "a-dep": "^1.0.0",`, + ` "unused-tool": "^8.0.0",`, + ` },`, + ` },`, + ``, + ].join("\n"), + ); + expect(workspaceBlock(migrated, "")).toContain( + ` "peerDependencies": {\n "a-dep": "catalog:tools",\n "no-deps": "catalog:",\n },`, + ); + + await expectInstallToLeaveUnchanged(packageDir, migrated); + expect(await installedPackageJson(packageDir, "", "a-dep")).toStrictEqual({ name: "a-dep", version: "1.0.1" }); + }); + + // pnpm-lock.yaml records the default catalog as `catalogs.default` whether pnpm-workspace.yaml spelled it + // `catalog:` or `catalogs.default:`; bun.lock and package.json keep the two spellings apart. + test("a default catalog declared as catalogs.default stays catalogs.default", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ name: "catalogs-default", peerDependencies: { "no-deps": "catalog:" } }), + "pnpm-workspace.yaml": `catalogs: + default: + no-deps: ^1.0.0 + unused-default: ^9.0.0 +`, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + +catalogs: + default: + no-deps: + specifier: ^1.0.0 + version: 1.0.1 + +importers: + + .: + dependencies: + no-deps: + specifier: 'catalog:' + version: 1.0.1 + +packages: + + no-deps@1.0.1: + resolution: {integrity: ${NO_DEPS_1_0_1_INTEGRITY}} + +snapshots: + + no-deps@1.0.1: {} +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).not.toContain("missing entry"); + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const migrated = await bunLockOf(packageDir); + expect(catalogSections(migrated)).toBe( + [ + ` "catalogs": {`, + ` "default": {`, + ` "no-deps": "^1.0.0",`, + ` "unused-default": "^9.0.0",`, + ` },`, + ` },`, + ``, + ].join("\n"), + ); + expect((await Bun.file(join(packageDir, "package.json")).json()).workspaces).toEqual({ + catalogs: { default: { "no-deps": "^1.0.0", "unused-default": "^9.0.0" } }, + }); + + await expectInstallToLeaveUnchanged(packageDir, migrated); + }); + + // The lockfile's specifier is the one its resolutions were made with: taking the yaml's would hide the + // edit from the next install and leave no-deps@1.0.0 locked under a range it does not satisfy. + test("an entry edited in pnpm-workspace.yaml after the last pnpm install keeps the lockfile's specifier", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ name: "edited-catalog", dependencies: { "no-deps": "catalog:" } }), + "pnpm-workspace.yaml": `catalog: + no-deps: ^1.0.1 +`, + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +catalogs: + default: + no-deps: + specifier: ^1.0.0 + version: 1.0.0 + +importers: + + .: + dependencies: + no-deps: + specifier: 'catalog:' + version: 1.0.0 + +packages: + + no-deps@1.0.0: + resolution: {integrity: ${NO_DEPS_1_0_0_INTEGRITY}} + +snapshots: + + no-deps@1.0.0: {} +`, + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const migrated = await bunLockOf(packageDir); + expect(catalogSections(migrated)).toBe(` "catalog": {\n "no-deps": "^1.0.0",\n },\n`); + expect(migrated).toContain(`"no-deps@1.0.0"`); + + const install = await run(packageDir, "install"); + + expect(install.stderr).toContain("Saved lockfile"); + expect(install.stderr).not.toContain("error:"); + expect(install.exitCode).toBe(0); + const updated = await bunLockOf(packageDir); + expect(catalogSections(updated)).toBe(` "catalog": {\n "no-deps": "^1.0.1",\n },\n`); + expect(updated).toContain(`"no-deps@1.1.0"`); + expect(updated).not.toContain(`"no-deps@1.0.0"`); + }); + // pnpm/pnpm#10456: `pnpm remove` can drop the catalogs: section while importers still say catalog: test.concurrent("importer catalog: reference without a catalogs: section is reported", async () => { using dir = fixture("v9-catalog-default"); From aa8ac2621f113961c624b7f0f5a459dd48dd78b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:02 +0000 Subject: [PATCH 020/258] install: migrate a package's own file: directories from package-lock.json relative to the package (#38990) --- src/install/migration/npm_lock.rs | 66 +++++- test/cli/install/migration/migrate.test.ts | 261 +++++++++++++++++++++ 2 files changed, 317 insertions(+), 10 deletions(-) diff --git a/src/install/migration/npm_lock.rs b/src/install/migration/npm_lock.rs index 6acbcf2dcce2..82836eaa6d02 100644 --- a/src/install/migration/npm_lock.rs +++ b/src/install/migration/npm_lock.rs @@ -99,6 +99,21 @@ fn parent_dir(dir: &[u8]) -> &[u8] { } } +/// The part of package-lock key `path` below key `dir`, if it is strictly inside it. +fn path_inside<'k>(dir: &[u8], path: &'k [u8]) -> Option<&'k [u8]> { + path.strip_prefix(dir)? + .strip_prefix(b"/") + .filter(|rest| !rest.is_empty()) +} + +/// Folder inside a cache-installed dependent; the installer reads its row relative to the package. +#[derive(Clone, Copy)] +struct FolderInDependent<'k> { + path: &'k [u8], + /// Fallback name, as in a fresh resolve; npm writes these entries without one. + alias: &'k [u8], +} + struct Migrator<'a> { this: &'a mut Lockfile, manager: &'a mut PackageManager, @@ -152,7 +167,7 @@ pub(super) fn migrate_packages( migrator.build_index()?; - let root_id = migrator.build_package(0, false, DepTag::Npm)?; + let root_id = migrator.build_package(0, false, DepTag::Npm, None)?; debug_assert_eq!(root_id, 0); let mut cursor = 0; @@ -265,10 +280,16 @@ impl<'a> Migrator<'a> { if existing != INVALID_PACKAGE_ID { return Ok(existing); } - self.build_package(j, via_link, hint) + self.build_package(j, via_link, hint, None) } - fn build_package(&mut self, j: u32, via_link: bool, hint: DepTag) -> Result { + fn build_package( + &mut self, + j: u32, + via_link: bool, + hint: DepTag, + folder_in_dependent: Option>, + ) -> Result { let entries = self.entries; let entry = &entries[j as usize]; let pkg = entry_object(entry); @@ -283,6 +304,8 @@ impl<'a> Migrator<'a> { &ws.name } else if let Some(set_name) = pkg.get(b"name").and_then(|n| n.as_str()) { set_name + } else if let Some(folder) = folder_in_dependent { + folder.alias } else { package_name_from_path(key) }; @@ -385,6 +408,7 @@ impl<'a> Migrator<'a> { workspace_entry.is_some(), via_link, hint, + folder_in_dependent.map(|folder| folder.path), )?; debug!( "{} -> {}", @@ -422,7 +446,9 @@ impl<'a> Migrator<'a> { self.this.packages.items_resolution_mut()[existing as usize] = res; } } - self.entry_package_ids[j as usize] = existing; + if folder_in_dependent.is_none() { + self.entry_package_ids[j as usize] = existing; + } self.shadow(j); return Ok(existing); } @@ -443,8 +469,12 @@ impl<'a> Migrator<'a> { scripts: Default::default(), })?; self.this.get_or_put_id(id, name_hash)?; - self.entry_package_ids[j as usize] = id; self.queue.push((j, id)); + // `build_or_get` must keep handing other dependents the entry-key build. + match folder_in_dependent { + None => self.entry_package_ids[j as usize] = id, + Some(_) => self.shadowed.set(j as usize), + } Ok(id) } @@ -512,6 +542,7 @@ impl<'a> Migrator<'a> { is_workspace: bool, via_link: bool, hint: DepTag, + path_in_dependent: Option<&[u8]>, ) -> Result { if j == 0 { return Ok(Resolution::init(ResTagged::Root)); @@ -558,7 +589,10 @@ impl<'a> Migrator<'a> { } } - let path = self.this.string_buf().append(key)?; + let path = self + .this + .string_buf() + .append(path_in_dependent.unwrap_or(key))?; Ok(Resolution::init(ResTagged::Folder(path))) } @@ -687,6 +721,7 @@ impl<'a> Migrator<'a> { }; let replace_optional_dups = matches!(res_tag, resolution::Tag::Root | resolution::Tag::Npm); let skip_peer_dups = res_tag == resolution::Tag::Npm; + let installed_from_cache = res_tag.can_enqueue_install_task(); if j == 0 { self.link_workspaces()?; @@ -769,15 +804,26 @@ impl<'a> Migrator<'a> { let version_tag = version.tag; let mut found = self.find_target(key, name); + let mut folder_in_dependent: Option> = None; if let Some((t, through_link)) = found && !is_local - && self.is_external_folder(t, through_link) { - self.skip_external(t, name); - found = None; + if through_link && installed_from_cache { + folder_in_dependent = path_inside(key, entries[t as usize].key.slice()) + .map(|path| FolderInDependent { path, alias: name }); + } + if folder_in_dependent.is_none() && self.is_external_folder(t, through_link) { + self.skip_external(t, name); + found = None; + } } let target = match found { - Some((t, through_link)) => self.build_or_get(t, through_link, version_tag)?, + Some((t, through_link)) => match folder_in_dependent { + Some(folder) => { + self.build_package(t, through_link, version_tag, Some(folder))? + } + None => self.build_or_get(t, through_link, version_tag)?, + }, None if behavior.is_peer() || behavior.contains(Behavior::OPTIONAL) => { INVALID_PACKAGE_ID } diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 9cdcf8023fbf..d89f2ce91783 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -1651,6 +1651,267 @@ describe("package-lock.json migration fixes", () => { expect(storeEntries(project.dir)).toStrictEqual(linker === "isolated" ? [project.o.replaceAll(/[/:]/g, "+")] : []); }); + // npm resolves a `file:` directory declared by a registry package against the package itself and keys the + // target entry by its project-relative path (`node_modules//`). bun installs a folder declared by a + // registry package from inside that package, so the migrated row has to be the path inside the package. + describe("file: directory inside a registry package", () => { + function project( + name: string, + registry: ReturnType, + pkg: "file-dep" | "missing-file-dep", + folder: string, + linkKey: string, + ) { + const dependencies = { [pkg]: "1.0.0" }; + return synthetic( + name, + { + "package.json": JSON.stringify({ name, dependencies }), + "package-lock.json": npmLock(name, { + "": { name, dependencies }, + [`node_modules/${pkg}`]: { + version: "1.0.0", + resolved: registry.tarball(pkg, "1.0.0"), + integrity: registry.integrity(pkg, "1.0.0"), + dependencies: { files: `file:./${folder}` }, + }, + [`node_modules/${pkg}/${folder}`]: {}, + [linkKey]: { resolved: `node_modules/${pkg}/${folder}`, link: true }, + }), + }, + registry.url, + ); + } + + test.concurrent.each([ + ["node_modules/files", "hoisted"], + ["node_modules/file-dep/node_modules/files", "nested"], + ])("is recorded relative to the package and installed from it (link at %s)", async (linkKey, slug) => { + using registry = localRegistry(); + using dir = project(`npm-migrate-folder-in-package-${slug}`, registry, "file-dep", "the-files", linkKey); + + const { text, lock } = await migrate(dir); + expect(lock.packages).toStrictEqual({ + "file-dep": [ + "file-dep@1.0.0", + registry.tarball("file-dep", "1.0.0"), + { dependencies: { files: "file:./the-files" } }, + registry.integrity("file-dep", "1.0.0"), + ], + "file-dep/files": ["files@file:the-files", {}], + }); + expect(text).not.toContain("node_modules/file-dep/the-files"); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + expect( + await Bun.file(join(String(dir), "node_modules", "file-dep", "node_modules", "files", "package.json")).json(), + ).toHaveProperty("name", "files"); + const resolve = await run(join(String(dir), "node_modules", "file-dep"), "-e", `require("files")`); + expect(resolve.stdout).toBe("hello files\n"); + expect(resolve.exitCode).toBe(0); + expect(registry.requests).toStrictEqual(["/file-dep/-/file-dep-1.0.0.tgz"]); + }); + + test.concurrent("a directory the package does not ship installs nothing, like a fresh resolve", async () => { + using registry = localRegistry(); + using dir = project( + "npm-migrate-folder-in-package-missing", + registry, + "missing-file-dep", + "missing-folder", + "node_modules/files", + ); + + const { lock } = await migrate(dir); + expect(lock.packages["missing-file-dep/files"]).toStrictEqual(["files@file:missing-folder", {}]); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + expect(fs.existsSync(join(String(dir), "node_modules", "missing-file-dep", "node_modules", "files"))).toBeFalse(); + }); + + // `file:.` links back to the package's own entry, which is not inside itself, so the edge resolves to the package. + test.concurrent("a package depending on itself with file:. keeps resolving to itself", async () => { + using registry = localRegistry(); + const dependencies = { "self-file-dep": "1.0.0" }; + using dir = synthetic( + "npm-migrate-folder-self", + { + "package.json": JSON.stringify({ name: "folder-self", dependencies }), + "package-lock.json": npmLock("folder-self", { + "": { name: "folder-self", dependencies }, + "node_modules/self-file-dep": { + version: "1.0.0", + resolved: registry.tarball("self-file-dep", "1.0.0"), + integrity: registry.integrity("self-file-dep", "1.0.0"), + dependencies: { "self-file-dep": "file:." }, + }, + "node_modules/self-file-dep/node_modules/self-file-dep": { + resolved: "node_modules/self-file-dep", + link: true, + }, + }), + }, + registry.url, + ); + + const { lock } = await migrate(dir); + expect(lock.packages).toStrictEqual({ + "self-file-dep": [ + "self-file-dep@1.0.0", + registry.tarball("self-file-dep", "1.0.0"), + { dependencies: { "self-file-dep": "file:." } }, + registry.integrity("self-file-dep", "1.0.0"), + ], + }); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + const resolve = await run( + join(String(dir), "node_modules", "self-file-dep"), + "-e", + `console.log(require("self-file-dep/package.json").version)`, + ); + expect(resolve.stdout).toBe("1.0.0\n"); + expect(resolve.exitCode).toBe(0); + }); + + test.concurrent("a directory inside a folder the root declares stays relative to the project", async () => { + const dependencies = { a: "file:vendor/a" }; + using dir = synthetic("npm-migrate-folder-in-local-folder", { + "package.json": JSON.stringify({ name: "folder-in-local-folder", dependencies }), + "vendor/a/package.json": JSON.stringify({ name: "a", version: "1.0.0", dependencies: { b: "file:./b" } }), + "vendor/a/b/package.json": JSON.stringify({ name: "b", version: "1.0.0" }), + "package-lock.json": npmLock("folder-in-local-folder", { + "": { name: "folder-in-local-folder", dependencies }, + "node_modules/a": { resolved: "vendor/a", link: true }, + "vendor/a": { version: "1.0.0", dependencies: { b: "file:./b" } }, + "vendor/a/b": { version: "1.0.0" }, + "vendor/a/node_modules/b": { resolved: "vendor/a/b", link: true }, + }), + }); + + const { lock } = await migrate(dir); + expect(lock.packages).toStrictEqual({ + a: ["a@file:vendor/a", { dependencies: { b: "file:./b" } }], + "a/b": ["b@file:vendor/a/b", {}], + }); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + expect( + await Bun.file(join(String(dir), "node_modules", "a", "node_modules", "b", "package.json")).json(), + ).toStrictEqual({ name: "b", version: "1.0.0" }); + }); + + // npm deduplicated a local folder's `files` onto the hoisted link into file-dep. Each dependent needs its own + // form of the row: file-dep's is read relative to file-dep, the local folder's relative to the project. + test.concurrent.each([ + ["file-dep", "local"], + ["local", "file-dep"], + ])("a local folder sharing the directory gets the project-relative row (%s linked first)", async (...order) => { + using registry = localRegistry(); + const specs: Record = { "file-dep": "1.0.0", local: "file:vendor/local" }; + const dependencies = Object.fromEntries(order.map(name => [name, specs[name]])); + const name = `npm-migrate-folder-shared-${order[0]}-first`; + using dir = synthetic( + name, + { + "package.json": JSON.stringify({ name, dependencies }), + "vendor/local/package.json": JSON.stringify({ + name: "local", + version: "1.0.0", + dependencies: { files: "*" }, + }), + "package-lock.json": npmLock(name, { + "": { name, dependencies }, + "node_modules/file-dep": { + version: "1.0.0", + resolved: registry.tarball("file-dep", "1.0.0"), + integrity: registry.integrity("file-dep", "1.0.0"), + dependencies: { files: "file:./the-files" }, + }, + "node_modules/file-dep/the-files": { name: "files", version: "1.1.1" }, + "node_modules/files": { resolved: "node_modules/file-dep/the-files", link: true }, + "node_modules/local": { resolved: "vendor/local", link: true }, + "vendor/local": { version: "1.0.0", dependencies: { files: "*" } }, + }), + }, + registry.url, + ); + + const { lock } = await migrate(dir); + expect(lock.packages["file-dep/files"]).toStrictEqual(["files@file:the-files", {}]); + expect(lock.packages["local/files"]).toStrictEqual(["files@file:node_modules/file-dep/the-files", {}]); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + for (const dependent of ["file-dep", "local"]) { + expect( + await Bun.file(join(String(dir), "node_modules", dependent, "node_modules", "files", "package.json")).json(), + ).toHaveProperty("name", "files"); + } + }); + + test.concurrent( + "a registry package deduplicated onto a folder the root declares keeps the root's row", + async () => { + using registry = localRegistry(); + const dependencies = { "dep-file-dep": "1.0.0", "file-dep": "file:vendor/file-dep" }; + using dir = synthetic( + "npm-migrate-folder-dedupe", + { + "package.json": JSON.stringify({ name: "folder-dedupe", dependencies }), + "vendor/file-dep/package.json": JSON.stringify({ name: "file-dep", version: "1.0.0" }), + "package-lock.json": npmLock("folder-dedupe", { + "": { name: "folder-dedupe", dependencies }, + "node_modules/dep-file-dep": { + version: "1.0.0", + resolved: registry.tarball("dep-file-dep", "1.0.0"), + integrity: registry.integrity("dep-file-dep", "1.0.0"), + dependencies: { "file-dep": "1.0.0" }, + }, + "node_modules/file-dep": { resolved: "vendor/file-dep", link: true }, + "vendor/file-dep": { version: "1.0.0" }, + }), + }, + registry.url, + ); + + const { lock } = await migrate(dir); + expect(lock.packages["file-dep"]).toStrictEqual(["file-dep@file:vendor/file-dep", {}]); + expect(lock.packages["dep-file-dep/file-dep"]).toStrictEqual(["file-dep@file:vendor/file-dep", {}]); + await frozen(dir); + + const install = await run(dir, "install", "--linker", "hoisted"); + expect(install.stderr).not.toContain("error"); + expect(install.exitCode).toBe(0); + // The copy under dep-file-dep has nothing to install from; the root's copy in node_modules serves it. + expect( + fs.existsSync(join(String(dir), "node_modules", "dep-file-dep", "node_modules", "file-dep")), + ).toBeFalse(); + const resolve = await run( + join(String(dir), "node_modules", "dep-file-dep"), + "-e", + `console.log(require("file-dep/package.json").name)`, + ); + expect(resolve.stdout).toBe("file-dep\n"); + expect(resolve.exitCode).toBe(0); + }, + ); + }); + test.concurrent("lockfileVersion 5 is refused, and install falls back to a fresh resolve", async () => { using dir = synthetic("npm-migrate-v5", { "package.json": JSON.stringify({ name: "v5", dependencies: { "dep-1": "file:dep-1" } }), From a0c28d1f59c403226cbb31e06de2175847364ae9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:06 +0000 Subject: [PATCH 021/258] install: fail the isolated install when a link: dependency's target is missing (#38045) --- src/install/isolated_install.rs | 21 ++++++- test/cli/install/isolated-install.test.ts | 76 +++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 26f662acfc4c..71464543f8a2 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2191,7 +2191,26 @@ pub(crate) fn install_isolated_packages( // .monotonic is okay because the task isn't running on another thread. entry_steps[entry_id.get() as usize] .store(installer::Step::Done as u32, Ordering::Relaxed); - installer.on_task_complete(entry_id, installer::CompleteState::Skipped); + + // Same target check as the hoisted linker's `install_from_link`. + let mut link_target: AbsPath = AbsPath::init_top_level_dir(); + installer.append_store_path(&mut link_target, entry_id); + match sys::openat( + Fd::cwd(), + link_target.slice_z(), + sys::O::RDONLY | sys::O::DIRECTORY, + 0, + ) { + Ok(fd) => { + use bun_sys::FdExt as _; + fd.close(); + installer.on_task_complete(entry_id, installer::CompleteState::Skipped); + } + Err(err) => { + installer + .on_task_fail(entry_id, &installer::TaskError::LinkPackage(err)); + } + } continue; } ResolutionTag::Folder => { diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 1e9a6aa5df67..97263adb6893 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -373,6 +373,82 @@ test("can install folder dependencies on root package", async () => { ]); }); +describe("link: dependencies", () => { + async function runBun(args: string[], cwd: string, env: NodeJS.Dict) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // The lockfile records a `link:` dependency by name only, so the `bun link` + // registration behind that name can be gone by the time the lockfile is + // installed: `bun unlink` removes the global link dir entry, deleting the + // package leaves the entry dangling. + test.concurrent.each([ + { + name: "linked-pkg", + gone: "the package was unlinked", + async remove(pkgDir: string, env: NodeJS.Dict) { + const result = await runBun(["unlink"], pkgDir, env); + expect(result.stdout).toContain(`success: unlinked package "linked-pkg"`); + expect(result.exitCode).toBe(0); + }, + }, + { + name: "@scope/linked-pkg", + gone: "the linked directory was deleted", + async remove(pkgDir: string) { + await rm(pkgDir, { recursive: true, force: true }); + }, + }, + ])("installing from the lockfile fails when $gone", async ({ name, remove }) => { + using dir = tempDir("isolated-link-target", { + "pkg/package.json": JSON.stringify({ name, version: "1.0.0" }), + "app/package.json": JSON.stringify({ name: "app", dependencies: { [name]: `link:${name}` } }), + "app/bunfig.toml": `[install]\nlinker = "isolated"\n`, + }); + const pkgDir = join(String(dir), "pkg"); + const appDir = join(String(dir), "app"); + // `bun link` registers into $BUN_INSTALL/install/global; keep it private to this test. + const env = { ...bunEnv, BUN_INSTALL: join(String(dir), "bun-install") }; + + let result = await runBun(["link"], pkgDir, env); + expect(result.stdout).toContain(`Success! Registered "${name}"`); + expect(result.exitCode).toBe(0); + + result = await runBun(["install"], appDir, env); + expect(result.stderr).toContain("Saved lockfile"); + expect(result.exitCode).toBe(0); + expect(await file(join(appDir, "node_modules", name, "package.json")).json()).toEqual({ name, version: "1.0.0" }); + + await remove(pkgDir, env); + + // Both reinstalling over the existing node_modules and installing into a + // fresh one (a clone with the lockfile checked in) must report the missing + // package and exit 1 instead of silently succeeding. As with every other + // failed entry, node_modules/ still points at the registration and + // resolves again once the package is re-linked. + result = await runBun(["install"], appDir, env); + expect(result.stderr).toContain("ENOENT"); + expect(result.stderr).toContain(`failed to link package: ${name}@link:`); + expect(result.stdout).toContain("Failed to install 1 package"); + expect(result.exitCode).toBe(1); + + await rm(join(appDir, "node_modules"), { recursive: true, force: true }); + result = await runBun(["install"], appDir, env); + expect(result.stderr).toContain("ENOENT"); + expect(result.stderr).toContain(`failed to link package: ${name}@link:`); + expect(result.stdout).toContain("Failed to install 1 package"); + expect(result.exitCode).toBe(1); + }); +}); + describe("isolated workspaces", () => { test("basic", async () => { const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); From b1ebbec7a2fcc2cee30b84deae9f03852a73c4ce Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:11 +0000 Subject: [PATCH 022/258] install: read the package.json of a file: dependency declared by a local file: package (#38814) --- .../PackageManager/PackageManagerEnqueue.rs | 37 +-- .../PackageManagerResolution.rs | 9 +- src/install/lockfile.rs | 25 ++ src/install/lockfile/Tree.rs | 9 +- src/install/resolution.rs | 7 + test/cli/install/bun-install-registry.test.ts | 91 +++++++ test/cli/install/bun-install.test.ts | 224 ++++++++++++++++-- 7 files changed, 355 insertions(+), 47 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index f38b7acb3cde..79caa72f7e3a 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -2582,7 +2582,24 @@ fn get_or_put_resolved_package( dependency::version::Tag::Folder => { let folder = *version.folder(); let res: FolderResolutionValue = 'res: { - if this.lockfile.is_workspace_dependency(dependency_id) { + if !this.lockfile.is_workspace_dependency(dependency_id) + && crate::bin::bin_target_escapes_package_dir(this.lockfile.str(&folder)) + { + // overrides/resolutions are only ever parsed from the root + // package.json, so a folder path that reached here via an + // override was written by the user and is trusted the same + // as a direct dependency of the root. + let buf = this.lockfile.buffers.string_bytes.as_slice(); + if !this.lockfile.overrides.contains_name( + dependency.name_hash, + dependency.name.slice(buf), + buf, + ) { + break 'res FolderResolutionValue::Err(crate::Error::MissingPackageJSON); + } + } + + if this.lockfile.is_dependency_of_local_package(dependency_id) { // relative to cwd // reshaped for borrowck — `folder_path` borrows // `string_bytes`; detach the slice lifetime so the @@ -2616,22 +2633,8 @@ fn get_or_put_resolved_package( ); } - // transitive folder dependencies do not have their dependencies resolved - if crate::bin::bin_target_escapes_package_dir(this.lockfile.str(&folder)) { - // overrides/resolutions are only ever parsed from the root - // package.json, so a folder path that reached here via an - // override was written by the user and is trusted the same - // as a direct dependency of the root. - let buf = this.lockfile.buffers.string_bytes.as_slice(); - if !this.lockfile.overrides.contains_name( - dependency.name_hash, - dependency.name.slice(buf), - buf, - ) { - break 'res FolderResolutionValue::Err(crate::Error::MissingPackageJSON); - } - } - + // Declared by a registry package: `Package::from_npm` keeps the path + // relative to that package, which is not on disk until it is installed. let mut package = Package::default(); { diff --git a/src/install/PackageManager/PackageManagerResolution.rs b/src/install/PackageManager/PackageManagerResolution.rs index 1f1b0b65edf0..dede07e3df35 100644 --- a/src/install/PackageManager/PackageManagerResolution.rs +++ b/src/install/PackageManager/PackageManagerResolution.rs @@ -330,11 +330,10 @@ impl PackageManager { continue; } - let features = match pkg_resolutions[parent_id].tag { - ResolutionTag::Root | ResolutionTag::Workspace | ResolutionTag::Folder => { - self.options.local_package_features - } - _ => self.options.remote_package_features, + let features = if pkg_resolutions[parent_id].tag.is_local_package() { + self.options.local_package_features + } else { + self.options.remote_package_features }; // even if optional dependencies are enabled, it's still allowed to fail if failed_dep.behavior.is_optional() || !failed_dep.behavior.is_enabled(features) { diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 917c025518f9..bc95c237a84d 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -832,6 +832,31 @@ impl Lockfile { invalid_package_id } + /// The package whose dependency list contains `id`, or `invalid_package_id` when + /// no package declares it. + pub(crate) fn get_parent_pkg_of_dependency(&self, id: DependencyID) -> PackageID { + self.packages + .items_dependencies() + .iter() + .position(|dependencies| dependencies.contains(id)) + .map_or(invalid_package_id, |pkg_id| { + PackageID::try_from(pkg_id).expect("int cast") + }) + } + + /// Is this a direct dependency of a local package (`resolution::Tag::is_local_package`)? + /// + /// A folder package declared by a registry package gets no dependency list (see the + /// Folder arm of `get_or_put_resolved_package`), so every folder package that + /// declares anything is reached from the root through local packages only. + pub(crate) fn is_dependency_of_local_package(&self, id: DependencyID) -> bool { + let parent_id = self.get_parent_pkg_of_dependency(id); + parent_id != invalid_package_id + && self.packages.items_resolution()[parent_id as usize] + .tag + .is_local_package() + } + /// Does this tree id belong to a workspace (including workspace root)? /// TODO(dylan-conway) fix! pub(crate) fn is_workspace_tree_id(&self, id: tree::Id) -> bool { diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 1a959cda7efa..84307e85cd52 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -620,11 +620,10 @@ pub(crate) fn is_filtered_dependency_or_workspace( return true; } - let dep_features = match parent_res.tag { - crate::resolution::Tag::Root - | crate::resolution::Tag::Workspace - | crate::resolution::Tag::Folder => manager.options.local_package_features, - _ => manager.options.remote_package_features, + let dep_features = if parent_res.tag.is_local_package() { + manager.options.local_package_features + } else { + manager.options.remote_package_features }; if !dep.behavior.is_enabled(dep_features) { diff --git a/src/install/resolution.rs b/src/install/resolution.rs index d1e6fa7424e4..bd665fdd674c 100644 --- a/src/install/resolution.rs +++ b/src/install/resolution.rs @@ -1014,6 +1014,13 @@ impl Tag { self == Tag::Git || self == Tag::Github } + /// The root, a workspace, or a `file:` folder: a package.json of the project's own, + /// so its dependencies get `local_package_features` and `Package::parse` stored its + /// `file:` paths relative to the top-level dir. + pub(crate) fn is_local_package(self) -> bool { + self == Tag::Root || self == Tag::Workspace || self == Tag::Folder + } + pub(crate) fn can_enqueue_install_task(self) -> bool { self == Tag::Npm || self == Tag::LocalTarball diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index dcf5c47fb38c..be638966e2f6 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -5286,6 +5286,97 @@ describe("transitive file dependencies", () => { version: "1.1.1", }); }); + + // Unlike the registry packages above, whose file: targets only exist once the + // package is installed, a local file: package's own file: dependency is on + // disk while resolving, so it is read like one declared by the root. + for (const linker of ["hoisted", "isolated"] as const) { + test(`${linker}: a file: dependency of a local file: package is resolved like a root one`, async () => { + const { packageDir } = await registry.createTestDir({ + bunfigOpts: { linker }, + files: { + "package.json": JSON.stringify({ + name: "foo", + dependencies: { + lib: "file:./vendor/lib", + }, + }), + "vendor/lib/package.json": JSON.stringify({ + name: "lib", + version: "1.0.0", + dependencies: { + tool: "file:../tool", + }, + }), + "vendor/lib/index.js": `module.exports = "lib->" + require("tool");`, + "vendor/tool/package.json": JSON.stringify({ + name: "tool", + version: "1.0.0", + bin: { tool: "cli.js" }, + dependencies: { + "no-deps": "1.0.0", + }, + }), + "vendor/tool/cli.js": `#!/usr/bin/env node\nconsole.log("tool");`, + "vendor/tool/index.js": `module.exports = "tool->no-deps@" + require("no-deps").version;`, + }, + }); + const libNodeModules = + linker === "hoisted" + ? join(packageDir, "node_modules", "lib", "node_modules") + : join(packageDir, "node_modules", ".bun", "lib@file+vendor+lib", "node_modules"); + + let { out } = await runBunInstall(env, packageDir); + expect(out).toContain("3 packages installed"); + + const lock = (await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234"); + expect(normalizeBunSnapshot(lock)).toMatchInlineSnapshot(` + "{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "foo", + "dependencies": { + "lib": "file:./vendor/lib", + }, + }, + }, + "packages": { + "lib": ["lib@file:vendor/lib", { "dependencies": { "tool": "file:../tool" } }], + + "no-deps": ["no-deps@1.0.0", "http://localhost:1234/no-deps/-/no-deps-1.0.0.tgz", {}, "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw=="], + + "lib/tool": ["tool@file:vendor/tool", { "dependencies": { "no-deps": "1.0.0" }, "bin": { "tool": "cli.js" } }], + } + }" + `); + + // Once from the package.json files, once from the lockfile they produced. + for (const frozenLockfile of [false, true]) { + if (frozenLockfile) { + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + ({ out } = await runBunInstall(env, packageDir, { frozenLockfile })); + expect(out).toContain("3 packages installed"); + } + + expect(await readdirSorted(join(libNodeModules, ".bin"))).toHaveBins(["tool"]); + expect(join(libNodeModules, ".bin", "tool")).toBeValidBin(join("..", "tool", "cli.js")); + + await using proc = spawn({ + cmd: [bunExe(), "-e", `console.log(require("lib"))`], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [runOut, runErr, runExit] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(runErr).toBe(""); + expect(runOut).toBe("lib->tool->no-deps@1.0.0\n"); + expect(runExit).toBe(0); + } + }); + } }); test("name from manifest is scoped and url encoded", async () => { diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 737ddfce3513..360a93010395 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -1,7 +1,7 @@ import { file, listen, Socket, spawn, write } from "bun"; import { afterAll, beforeAll, describe, expect, it, jest, setDefaultTimeout, test } from "bun:test"; import { readFileSync, readlinkSync, realpathSync, statSync } from "fs"; -import { access, cp, exists, mkdir, readlink, rm, stat, writeFile } from "fs/promises"; +import { access, chmod, cp, exists, mkdir, readlink, rm, stat, writeFile } from "fs/promises"; import { bunEnv, bunExe, @@ -10492,7 +10492,7 @@ for (const field of ["resolutions", "overrides"]) { "packages": { "pkg-a": ["pkg-a@file:pkg-a", { "dependencies": { "shared": "1.0.0" } }], - "pkg-a/shared": ["shared@file:./vendor/shared", {}], + "pkg-a/shared": ["shared@file:vendor/shared", {}], } }" `); @@ -10559,6 +10559,152 @@ it("installs the transitive file: dependency of a file: dependency", async () => } }); +// Where each linker puts the `node_modules` of the two file: packages that have +// dependencies. Folder packages are never hoisted, so `tool` lives under `lib`. +// (bun-install-registry.test.ts covers requiring a registry dependency of such a +// package at runtime.) +for (const [linker, libNodeModules, toolNodeModules, toolNodeModulesEntries] of [ + [ + "hoisted", + join("node_modules", "lib", "node_modules"), + join("node_modules", "lib", "node_modules", "tool", "node_modules"), + [".bin", "dev-only", "helper"], + ], + [ + "isolated", + join("node_modules", ".bun", "lib@file+vendor+lib", "node_modules"), + join("node_modules", ".bun", "tool@file+vendor+tool", "node_modules"), + // the isolated store also links a package's own bins into its own entry + [".bin", "dev-only", "helper", "tool"], + ], +] as const) { + it.concurrent(`${linker}: nested file: dependencies are installed with their dependencies and bins`, async () => { + using dir = tempDir("nested-file-dep-bins", { + "package.json": JSON.stringify({ + name: "my-app", + version: "1.0.0", + dependencies: { + lib: "file:./vendor/lib", + }, + }), + "vendor/lib/package.json": JSON.stringify({ + name: "lib", + version: "1.0.0", + dependencies: { + tool: "file:../tool", + }, + }), + "vendor/tool/package.json": JSON.stringify({ + name: "tool", + version: "1.0.0", + bin: { tool: "cli.js" }, + dependencies: { + helper: "file:../helper", + }, + devDependencies: { + "dev-only": "file:../dev-only", + }, + }), + "vendor/tool/cli.js": "#!/bin/sh\necho tool\n", + "vendor/helper/package.json": JSON.stringify({ + name: "helper", + version: "1.0.0", + bin: { helper: "cli.js" }, + }), + "vendor/helper/cli.js": "#!/bin/sh\necho helper\n", + "vendor/dev-only/package.json": JSON.stringify({ + name: "dev-only", + version: "1.0.0", + }), + }); + const projectDir = String(dir); + const locks: string[] = []; + + // The hoisted linker installs these packages as per-file symlinks and its bin + // chmod does not reach through them (#38777), so the scripts are made + // executable up front; what is checked below is that each .bin link runs + // the right script. + if (!isWindows) { + await chmod(join(projectDir, "vendor", "tool", "cli.js"), 0o755); + await chmod(join(projectDir, "vendor", "helper", "cli.js"), 0o755); + } + + // The first pass resolves from the package.json files, the second installs + // what the first one recorded in bun.lock. + for (const args of [["install"], ["install", "--frozen-lockfile"]]) { + await rm(join(projectDir, "node_modules"), { recursive: true, force: true }); + + await using proc = spawn({ + cmd: [bunExe(), ...args, `--linker=${linker}`], + cwd: projectDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + + expect(err).not.toContain("error:"); + expect(out).toContain("4 packages installed"); + expect(exitCode).toBe(0); + + locks.push(await file(join(projectDir, "bun.lock")).text()); + + expect(await readdirSorted(join(projectDir, libNodeModules, ".bin"))).toHaveBins(["tool"]); + expect(join(projectDir, libNodeModules, ".bin", "tool")).toBeValidBin(join("..", "tool", "cli.js")); + expect(await readdirSorted(join(projectDir, toolNodeModules))).toEqual(toolNodeModulesEntries); + expect(join(projectDir, toolNodeModules, ".bin", "helper")).toBeValidBin(join("..", "helper", "cli.js")); + + if (!isWindows) { + for (const [bin, expected] of [ + [join(libNodeModules, ".bin", "tool"), "tool\n"], + [join(toolNodeModules, ".bin", "helper"), "helper\n"], + ]) { + await using binProc = spawn({ + cmd: [join(projectDir, bin)], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + }); + const [binOut, binErr, binExit] = await Promise.all([ + binProc.stdout.text(), + binProc.stderr.text(), + binProc.exited, + ]); + expect(binErr).toBe(""); + expect(binOut).toBe(expected); + expect(binExit).toBe(0); + } + } + } + + expect(locks[1]).toBe(locks[0]); + expect(normalizeBunSnapshot(locks[0], projectDir)).toMatchInlineSnapshot(` + "{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "my-app", + "dependencies": { + "lib": "file:./vendor/lib", + }, + }, + }, + "packages": { + "lib": ["lib@file:vendor/lib", { "dependencies": { "tool": "file:../tool" } }], + + "lib/tool": ["tool@file:vendor/tool", { "dependencies": { "helper": "file:../helper" }, "devDependencies": { "dev-only": "file:../dev-only" }, "bin": { "tool": "cli.js" } }], + + "lib/tool/dev-only": ["dev-only@file:vendor/dev-only", {}], + + "lib/tool/helper": ["helper@file:vendor/helper", { "bin": { "helper": "cli.js" } }], + } + }" + `); + }); +} + const fileDepCycleFixture = { "package.json": JSON.stringify({ name: "my-app", @@ -10655,9 +10801,9 @@ it("installs file: dependencies that depend on each other", async () => { "b": ["b@file:packages/b", { "dependencies": { "a": "file:../a" } }], - "a/b": ["b@file:packages/b", {}], + "a/b": ["b@file:packages/b", { "dependencies": { "a": "file:../a" } }], - "b/a": ["a@file:packages/a", {}], + "b/a": ["a@file:packages/a", { "dependencies": { "b": "file:../b" } }], } }" `); @@ -10706,33 +10852,71 @@ it("installs file: dependencies that depend on each other from a lockfile that o `); }); -it("fails when a transitive file: dependency's folder does not exist", async () => { - using dir = tempDir("transitive-file-dep-missing", { - "package.json": JSON.stringify({ - name: "my-app", - version: "1.0.0", - dependencies: { - lib: "file:./vendor/lib", +const missingTransitiveFileDepFixture = { + "package.json": JSON.stringify({ + name: "my-app", + version: "1.0.0", + dependencies: { + lib: "file:./vendor/lib", + }, + }), + "vendor/lib/package.json": JSON.stringify({ + name: "lib", + version: "1.0.0", + dependencies: { + nested: "file:../nested", + }, + }), + "vendor/lib/index.js": `module.exports = require("nested");`, +}; + +it.concurrent("fails to resolve when a transitive file: dependency's folder does not exist", async () => { + using dir = tempDir("transitive-file-dep-missing", missingTransitiveFileDepFixture); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + + // Same failure as a missing file: dependency of the root: the folder is read + // while resolving, so nothing is installed and no lockfile is written. + expect(normalizeBunSnapshot(err, String(dir))).toMatchInlineSnapshot(` + "error: Could not find package.json for "file:vendor/nested" dependency "nested" + error: nested@file:../nested failed to resolve" + `); + expect(out).not.toContain("packages installed"); + expect(await exists(join(String(dir), "bun.lock"))).toBe(false); + expect(await exists(join(String(dir), "node_modules"))).toBe(false); + expect(exitCode).toBe(1); +}); + +it.concurrent("fails to install a lockfile's transitive file: dependency whose folder is missing", async () => { + using dir = tempDir("transitive-file-dep-missing-lock", { + ...missingTransitiveFileDepFixture, + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { + "": { name: "my-app", dependencies: { lib: "file:./vendor/lib" } }, }, - }), - "vendor/lib/package.json": JSON.stringify({ - name: "lib", - version: "1.0.0", - dependencies: { - nested: "file:../nested", + packages: { + "lib": ["lib@file:vendor/lib", { dependencies: { nested: "file:../nested" } }], + "lib/nested": ["nested@file:vendor/nested", {}], }, }), - "vendor/lib/index.js": `module.exports = require("nested");`, }); - const { stdout, stderr, exited } = spawn({ + await using proc = spawn({ cmd: [bunExe(), "install"], cwd: String(dir), stdout: "pipe", stderr: "pipe", env, }); - const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]); + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); // The printed folder path uses the platform separator on Windows. expect(err.replaceAll(sep, "/")).toContain('Could not find folder "file:vendor/nested" for dependency "nested"'); From 66fd8c11bf5e77050ee765a8c26e06d318db4fd6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:14 +0000 Subject: [PATCH 023/258] install: keep file: folder paths declared by git and tarball packages relative to the package (#38816) --- src/install/PackageInstaller.rs | 6 +- src/install/lockfile.rs | 5 +- src/install/lockfile/Package.rs | 5 +- src/install_types/resolver_hooks.rs | 4 + test/cli/install/bun-install-git-deps.test.ts | 177 ++++++++++++++++-- 5 files changed, 179 insertions(+), 18 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 6c96867f593b..994f4ec17c65 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1847,9 +1847,9 @@ impl<'a> PackageInstaller<'a> { ); } - // One declared by an npm manifest (`Package::from_npm`) is verbatim, - // i.e. relative to the declaring package, which installs at - // `dirname(node_modules.path)` because transitive folders never hoist. + // One declared by a cache package (`from_npm`, `Features::NPM`) is + // relative to that package, which installs at `dirname(node_modules.path)` + // because transitive folders never hoist. let dir_name = { let d = dirname::(self.node_modules.path.as_slice()); if d.is_empty() { diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index bc95c237a84d..71018aac06a9 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -866,9 +866,8 @@ impl Lockfile { .is_workspace() } - /// Is the package whose `node_modules` this tree represents resolved from a - /// local `file:` folder? Its `Resolution::Folder` dependencies were normalized - /// relative to the top-level dir (`Package::parse`), unlike npm's (`Package::from_npm`). + /// Is the package whose `node_modules` this tree represents a local `file:` folder? Its + /// folder dependencies are top-level-dir relative; a cache package's are package-relative. pub(crate) fn is_folder_tree_id(&self, id: tree::Id) -> bool { if id == 0 { return false; diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 0b4ed4b1ca4a..e175f2c58f9e 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1881,7 +1881,10 @@ impl Package { } match dependency_version.tag { - dependency::version::Tag::Folder => { + // Cache packages (`Features::NPM`) keep these package-relative, like `from_npm`. + dependency::version::Tag::Folder + if features.is_main || features.is_workspace || features.is_folder => + { let folder = *dependency_version.folder(); let mut folder_buf = PathBuffer::uninit(); let Some(joined) = resolve_path::join_abs_string_buf_checked::( diff --git a/src/install_types/resolver_hooks.rs b/src/install_types/resolver_hooks.rs index 902921588134..2878b87bd28c 100644 --- a/src/install_types/resolver_hooks.rs +++ b/src/install_types/resolver_hooks.rs @@ -1204,6 +1204,7 @@ pub struct Features { pub dev_dependencies: bool, pub is_main: bool, pub is_workspace: bool, + pub is_folder: bool, pub optional_dependencies: bool, pub peer_dependencies: bool, pub trusted_dependencies: bool, @@ -1218,6 +1219,7 @@ impl Default for Features { dev_dependencies: false, is_main: false, is_workspace: false, + is_folder: false, optional_dependencies: false, peer_dependencies: true, trusted_dependencies: false, @@ -1240,6 +1242,7 @@ impl Features { dev_dependencies: false, is_main: false, is_workspace: false, + is_folder: false, optional_dependencies: false, peer_dependencies: true, trusted_dependencies: false, @@ -1262,6 +1265,7 @@ impl Features { pub const FOLDER: Self = Self { dev_dependencies: true, + is_folder: true, optional_dependencies: true, ..Self::base() }; diff --git a/test/cli/install/bun-install-git-deps.test.ts b/test/cli/install/bun-install-git-deps.test.ts index 76997319f218..e68d82e08421 100644 --- a/test/cli/install/bun-install-git-deps.test.ts +++ b/test/cli/install/bun-install-git-deps.test.ts @@ -1,13 +1,15 @@ // Tests for installing git dependencies that live in ONE repository as -// multiple branches (issue #35420), `git+file://` dependencies, and +// multiple branches (issue #35420), `git+file://` dependencies, // tarball-URL / `github:` dependencies that appear both directly and -// transitively (issues #10915, #8501, #11348, #28284). Everything is local: -// a bare repo on disk (served over git's dumb HTTP protocol by Bun.serve -// when an http URL is needed) or static tarballs. +// transitively (issues #10915, #8501, #11348, #28284), and git / tarball +// packages whose own package.json declares `file:` folder dependencies. +// Everything is local: a bare repo on disk (served over git's dumb HTTP +// protocol by Bun.serve when an http URL is needed) or static tarballs. import { expect, test } from "bun:test"; -import { mkdirSync, writeFileSync } from "fs"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { lstat, readdir, readlink } from "fs/promises"; import { bunEnv, bunExe, tempDir } from "harness"; -import { join } from "path"; +import { dirname, join, resolve } from "path"; import { pathToFileURL } from "url"; const gitEnv = { @@ -35,6 +37,8 @@ interface BranchPackage { name: string; branch: string; dependencies?: Record; + /** Files committed next to package.json; an `index.js` entry replaces the default one. */ + files?: Record; } // Creates `/shared-repo.git`, a bare repo with one orphan branch per @@ -47,14 +51,21 @@ async function makeSharedRepo(root: string, packages: BranchPackage[]): Promise< await git(work, "init", "-q"); for (const pkg of packages) { await git(work, "checkout", "-q", "--orphan", pkg.branch); - writeFileSync( - join(work, "package.json"), - JSON.stringify({ name: pkg.name, version: "1.0.0", dependencies: pkg.dependencies }, null, 2), - ); - writeFileSync(join(work, "index.js"), `module.exports = ${JSON.stringify(pkg.branch)};\n`); + const files: Record = { + "package.json": JSON.stringify({ name: pkg.name, version: "1.0.0", dependencies: pkg.dependencies }, null, 2), + "index.js": `module.exports = ${JSON.stringify(pkg.branch)};\n`, + ...pkg.files, + }; + for (const [path, contents] of Object.entries(files)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); + writeFileSync(join(work, path), contents); + } await git(work, "add", "-A"); await git(work, "commit", "-q", "-m", pkg.branch, "--no-gpg-sign"); await git(work, "push", "-q", bare, pkg.branch); + // `checkout --orphan` keeps the working tree, so the next branch would + // otherwise inherit this one's files. + for (const path of Object.keys(files)) rmSync(join(work, path), { force: true }); } // dumb HTTP clients read the static files this generates await git(bare, "update-server-info"); @@ -423,3 +434,147 @@ test.concurrent("installs a git+file:// dependency", async () => { expect(await installedVersionOf(project, "@scope/pkg-b")).toBe("pkg-b"); expect(exitCode).toBe(0); }); + +// `file:` folder dependencies declared by a git or tarball package are relative +// to that package, like a registry package's (registry/packages/file-dep). They +// used to be resolved against the project dir, which pointed them into the cache +// and failed the install with `sub@file:./sub failed to resolve`. +const FILE_DEPS_NAME = "has-file-deps"; + +// `file:` folder dependencies on a subfolder, on the package itself, and on a +// folder that is not part of the package. +const fileDepsManifest = { + name: FILE_DEPS_NAME, + version: "1.0.0", + dependencies: { + "sub": "file:./sub", + [`${FILE_DEPS_NAME}-self`]: "file:.", + "gone": "file:./not-in-package", + }, +}; + +const fileDepsFiles = { + "index.js": `module.exports = require("sub");\n`, + "sub/package.json": JSON.stringify({ name: "sub", version: "1.0.0" }), + "sub/index.js": `module.exports = "sub-ok";\n`, +}; + +// Writes `/work/package/` and packs it into `tarball`, a `.tgz` path. +async function packTarball(root: string, tarball: string, manifest: object, files: Record) { + const pkgDir = join(root, "work", "package"); + for (const [path, contents] of Object.entries({ "package.json": JSON.stringify(manifest), ...files })) { + mkdirSync(dirname(join(pkgDir, path)), { recursive: true }); + writeFileSync(join(pkgDir, path), contents); + } + mkdirSync(dirname(tarball), { recursive: true }); + await run(root, ["tar", "-czf", tarball, "-C", join(root, "work"), "package"], "tar"); +} + +function writeProject(root: string, dependencies: Record): string { + const project = join(root, "project"); + mkdirSync(project, { recursive: true }); + writeFileSync(join(project, "package.json"), JSON.stringify({ name: "project", version: "1.0.0", dependencies })); + return project; +} + +async function linkedJson(link: string) { + expect((await lstat(link)).isSymbolicLink()).toBe(true); + return Bun.file(resolve(dirname(link), await readlink(link))).json(); +} + +async function expectFileDepsInstalled(project: string) { + const name = FILE_DEPS_NAME; + const nested = join(project, "node_modules", name, "node_modules"); + const lockfile = await Bun.file(join(project, "bun.lock")).text(); + expect(lockfile).toContain(`"${name}/sub": ["sub@file:./sub", {}]`); + expect(lockfile).toContain(`"${name}/${name}-self": ["${name}-self@file:.", {}]`); + expect(lockfile).toContain(`"${name}/gone": ["gone@file:./not-in-package", {}]`); + + // Transitive folder dependencies are not hoisted: each one is linked file by + // file into the declaring package's own node_modules, relative to that + // package. The folder missing from the package is skipped, as it is for a + // registry package. + expect((await readdir(nested)).sort()).toEqual([`${name}-self`, "sub"]); + expect(await linkedJson(join(nested, "sub", "package.json"))).toEqual({ name: "sub", version: "1.0.0" }); + expect(await linkedJson(join(nested, `${name}-self`, "package.json"))).toEqual(fileDepsManifest); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `console.log(require(${JSON.stringify(name)}))`], + cwd: project, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "sub-ok\n", stderr: "", exitCode: 0 }); +} + +for (const spec of ["file: path", "http url"] as const) { + test.concurrent(`installs the file: folder dependencies declared by a tarball package (${spec})`, async () => { + using dir = tempDir("tarball-file-deps", {}); + const root = String(dir); + const tarballs = join(root, "tarballs"); + await packTarball(root, join(tarballs, `${FILE_DEPS_NAME}.tgz`), fileDepsManifest, fileDepsFiles); + await using server = serveStatic(tarballs); + const project = writeProject(root, { + [FILE_DEPS_NAME]: + spec === "file: path" + ? `file:../tarballs/${FILE_DEPS_NAME}.tgz` + : `http://localhost:${server.port}/${FILE_DEPS_NAME}.tgz`, + }); + + const { stderr, exitCode } = await runInstall(project, join(root, "cache"), {}); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + await expectFileDepsInstalled(project); + + // The stub rows written to bun.lock install again without re-resolving. + rmSync(join(project, "node_modules"), { recursive: true }); + const frozen = await runInstall(project, join(root, "cache"), {}, "--frozen-lockfile"); + expect(frozen.stderr).not.toContain("error:"); + expect(frozen.exitCode).toBe(0); + await expectFileDepsInstalled(project); + }); +} + +test.concurrent( + "installs the file: folder dependencies declared by a git dependency", + async () => { + using dir = tempDir("git-dep-file-deps", {}); + const root = String(dir); + const bare = await makeSharedRepo(root, [ + { name: FILE_DEPS_NAME, branch: "main", dependencies: fileDepsManifest.dependencies, files: fileDepsFiles }, + ]); + const project = writeProject(root, { [FILE_DEPS_NAME]: `git+${pathToFileURL(bare)}#main` }); + + const { stderr, exitCode } = await runInstall(project, join(root, "cache"), {}); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + await expectFileDepsInstalled(project); + }, + 30_000, +); + +test.concurrent("rejects a file: folder dependency of a tarball package that points outside of it", async () => { + using dir = tempDir("tarball-escaping-file-dep", { + "outside/package.json": JSON.stringify({ name: "outside", version: "1.0.0" }), + }); + const root = String(dir); + const tarball = join(root, "tarballs", "escaping.tgz"); + await packTarball( + root, + tarball, + { name: "escaping", version: "1.0.0", dependencies: { outside: "file:../outside" } }, + {}, + ); + const project = writeProject(root, { escaping: `file:../tarballs/escaping.tgz` }); + + const { stderr, exitCode } = await runInstall(project, join(root, "cache"), {}); + expect(stderr).toContain('error: Could not find package.json for "file:../outside" dependency "outside"'); + expect(stderr).toContain("error: outside@file:../outside failed to resolve"); + expect(await Bun.file(join(project, "node_modules", "outside", "package.json")).exists()).toBe(false); + expect( + await Bun.file(join(project, "node_modules", "escaping", "node_modules", "outside", "package.json")).exists(), + ).toBe(false); + expect(exitCode).toBe(1); +}); From ceec1c9de666f3e27271e5045589d2a29655f29f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:20 +0000 Subject: [PATCH 024/258] install: drop the query string and fragment from a tarball URL's extraction folder name (#39011) --- src/install/extract_tarball.rs | 39 +++++++---- test/cli/install/bun-add.test.ts | 113 +++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index b8d3a8b091b5..e3cec500e2ed 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -198,30 +198,41 @@ impl ExtractTarball { debug_assert!(false); b"unnamed-package" }; - let basename: &[u8] = 'brk: { - let mut tmp = name; - if strings::has_prefix(tmp, b"https://") || strings::has_prefix(tmp, b"http://") { + let basename: &[u8] = + if strings::has_prefix(name, b"https://") || strings::has_prefix(name, b"http://") { + // A URL name is the placeholder `bun add ` uses until package.json is read. + let mut tmp = name; + if let Some(i) = strings::index_of_any(tmp, b"?#") { + tmp = &tmp[0..i]; + } tmp = bun_paths::basename(tmp); if strings::ends_with(tmp, b".tgz") { tmp = &tmp[0..tmp.len() - 4]; } else if strings::ends_with(tmp, b".tar.gz") { tmp = &tmp[0..tmp.len() - 7]; } - } else if tmp[0] == b'@' { - if let Some(i) = strings::index_of_char(tmp, b'/') { - tmp = &tmp[i as usize + 1..]; + if bun_install::dependency::is_safe_install_folder_name(tmp) { + tmp + } else { + b"package" + } + } else { + let mut tmp = name; + if tmp[0] == b'@' { + if let Some(i) = strings::index_of_char(tmp, b'/') { + tmp = &tmp[i as usize + 1..]; + } } - } - #[cfg(windows)] - { - if let Some(i) = strings::last_index_of_char(tmp, b':') { - tmp = &tmp[i + 1..]; + #[cfg(windows)] + { + if let Some(i) = strings::last_index_of_char(tmp, b':') { + tmp = &tmp[i + 1..]; + } } - } - break 'brk tmp; - }; + tmp + }; (name, basename) } diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index 423a7fbb8e4a..a399f6b8f6d5 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -1,8 +1,11 @@ import type { BunLockFile } from "bun"; import { file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout, test } from "bun:test"; +import { randomBytes } from "crypto"; import { access, appendFile, copyFile, mkdir, readlink, rm, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, readdirSorted, tmpdirSync, toBeValidBin, toBeWorkspaceLink, toHaveBins } from "harness"; +import { createServer } from "http"; +import type { AddressInfo } from "net"; import { join, relative, resolve } from "path"; import { check_npm_auth_type, @@ -2665,6 +2668,116 @@ it("should not add duplicate package.json entries when installing the same tarba }); }); +// `bun add ` names the dependency after the URL until the tarball's package.json has been read, +// and the URL's basename labels the directory the tarball is extracted into. The query string and +// fragment used to end up in that label: `?` cannot appear in a directory name on Windows, and a `:` +// within the first 32 bytes of the label (it is truncated to that) fails the install folder name +// check on every platform with "Refusing to install package with invalid name". A URL without a path +// has nothing usable left once the query is gone (its basename is the `host:port`), so the label +// falls back to "package" instead. +describe("should add a tarball URL with a query string or fragment", () => { + const paths = [ + ["query string", "/qs-pkg-1.0.0.tgz?token=abc"], + ["query string containing a colon", "/qs-pkg-1.0.0.tgz?expires=12:00"], + ["fragment containing a colon", "/qs-pkg-1.0.0.tgz#ref:main"], + ["query string on a URL without a path", "/?file=qs-pkg-1.0.0.tgz"], + ] as const; + + let tarball: Uint8Array; + beforeAll(async () => { + tarball = await new Bun.Archive( + { + "package/package.json": JSON.stringify({ name: "qs-pkg", version: "1.0.0" }), + // Incompressible padding so the drip-fed response below arrives in many socket reads, which + // is what commits the install to the streaming extractor. + "package/pad.bin": randomBytes(256 * 1024), + }, + { compress: "gzip" }, + ).bytes(); + }); + + // The tarball is extracted either from the fully buffered response body or, when the body is + // large enough and arrives in several reads, by the streaming extractor; both pick the extraction + // directory name the same way. + async function serveTarball(mode: "buffered" | "streaming") { + if (mode === "buffered") { + const server = Bun.serve({ + port: 0, + fetch: () => new Response(tarball), + }); + return { + origin: server.url.origin, + [Symbol.asyncDispose]: () => server.stop(true), + }; + } + + // node:http so the response carries a Content-Length and can still be written 1 KiB at a time. + const server = createServer((req, res) => { + res.setHeader("Content-Type", "application/gzip"); + res.setHeader("Content-Length", String(tarball.length)); + req.socket.setNoDelay(true); + let offset = 0; + const step = () => { + if (offset >= tarball.length) { + res.end(); + return; + } + res.write(tarball.subarray(offset, offset + 1024)); + offset += 1024; + setImmediate(step); + }; + step(); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + [Symbol.asyncDispose]: () => { + server.closeAllConnections(); + return new Promise(resolve => server.close(() => resolve())); + }, + }; + } + + describe.each(["buffered", "streaming"] as const)("%s extraction", mode => { + test.each(paths)("%s", async (_, path) => { + await using server = await serveTarball(mode); + const url = `${server.origin}${path}`; + await writeFile(join(package_dir, "package.json"), JSON.stringify({ name: "foo", version: "0.0.1" })); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "add", url, "--verbose"], + cwd: package_dir, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: mode === "streaming" ? { ...env, BUN_INSTALL_STREAMING_MIN_SIZE: "1024" } : env, + }); + const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]); + expect(err).not.toContain("error:"); + // Printed by the streaming extractor only, so each mode is known to have taken its own path. + if (mode === "streaming") { + expect(err).toContain("Streamed "); + } else { + expect(err).not.toContain("Streamed "); + } + expect(out).toContain(`+ qs-pkg@${url}`); + expect(exitCode).toBe(0); + expect(await file(join(package_dir, "package.json")).json()).toStrictEqual({ + name: "foo", + version: "0.0.1", + dependencies: { + "qs-pkg": url, + }, + }); + expect(await file(join(package_dir, "node_modules", "qs-pkg", "package.json")).json()).toEqual({ + name: "qs-pkg", + version: "1.0.0", + }); + }); + }); +}); + it("should add multiple dependencies specified on command line", async () => { expect(check_npm_auth_type.check).toBe(true); using server = Bun.serve({ From f5dc5bc7027193991cd9f82004a336531da0fa2a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:24 +0000 Subject: [PATCH 025/258] install: cache file: tarballs under the integrity the lockfile pins (#39016) --- src/install/PackageInstall.rs | 5 + src/install/PackageInstaller.rs | 8 +- src/install/PackageManager.rs | 3 +- .../PackageManagerDirectories.rs | 66 ++++++++- .../PackageManager/PackageManagerLifecycle.rs | 5 +- src/install/PackageManager/patchPackage.rs | 13 +- src/install/PackageManager/runTasks.rs | 14 +- src/install/TarballStream.rs | 16 +-- src/install/extract_tarball.rs | 64 +++++---- src/install/integrity.rs | 23 +++- src/install/isolated_install.rs | 54 ++++---- src/install/isolated_install/Installer.rs | 63 ++++++++- src/install/patch_install.rs | 4 +- .../bun-install-tarball-integrity.test.ts | 126 ++++++++++++++++++ 14 files changed, 374 insertions(+), 90 deletions(-) diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 6adf994d1ad9..0e890e476c6c 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -2248,6 +2248,11 @@ impl<'a> PackageInstall<'a> { package_id: PackageID, resolution_tag: resolution::Tag, ) -> bool { + // No entry can be named yet (a local tarball whose integrity the + // lockfile does not record); extracting it is what names one. + if self.cache_dir_subpath.is_empty() { + return true; + } let state = manager.get_preinstall_state(package_id); match state { crate::PreinstallState::Done => false, diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 994f4ec17c65..d634d960b960 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1117,7 +1117,8 @@ impl<'a> PackageInstaller<'a> { // If a newly computed integrity hash is available (e.g. for a GitHub // tarball) and the lockfile doesn't already have one, persist it so - // the lockfile gets re-saved with the hash. + // the lockfile gets re-saved with the hash. Must happen before the + // callbacks below: a local tarball's cache entry is named after it. if data.integrity.tag.is_supported() { let pkg_metas = self.lockfile_mut().packages.items_meta_mut(); if !pkg_metas[package_id as usize].integrity.tag.is_supported() { @@ -1523,9 +1524,8 @@ impl<'a> PackageInstaller<'a> { } } resolution::Tag::LocalTarball => { - installer.cache_dir_subpath = package_manager::cached_tarball_folder_name( - self.manager_mut(), - *resolution.local_tarball(), + installer.cache_dir_subpath = package_manager::cached_local_tarball_folder_name( + &self.metas[package_id as usize].integrity, patch_contents_hash, ); installer.cache_dir = package_manager::get_cache_directory(self.manager_mut()); diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 39599591b2ec..1379fe74aec3 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -203,7 +203,8 @@ use directories::attempt_to_create_package_json_and_open; pub use directories::{ attempt_to_create_package_json, cached_git_folder_name, cached_git_folder_name_print, cached_git_folder_name_print_auto, cached_github_folder_name, cached_github_folder_name_print, - cached_github_folder_name_print_auto, cached_npm_package_folder_name, + cached_github_folder_name_print_auto, cached_local_tarball_folder_name, + cached_local_tarball_folder_name_print, cached_npm_package_folder_name, cached_npm_package_folder_name_print, cached_npm_package_folder_print_basename, cached_tarball_folder_name, cached_tarball_folder_name_print, compute_cache_dir_and_subpath, fetch_cache_directory_path, get_cache_directory, get_cache_directory_and_abs_path, diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 4861484c4b21..8b4c09be09ca 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -11,7 +11,7 @@ use bun_core::{Global, Output, ZBox, env_var, fmt as bun_fmt}; use bun_dotenv::Loader as DotEnvLoader; use bun_install::lockfile::{Format as LockfileFormat, LoadResult, Lockfile}; use bun_install::resolution::Tag as ResolutionTag; -use bun_install::{PackageID, Resolution}; +use bun_install::{Integrity, PackageID, Resolution}; use bun_paths::{self as path, AbsPath, PathBuffer, SEP}; use bun_semver::{self as Semver, String as SemverString}; #[cfg(windows)] @@ -487,6 +487,14 @@ impl<'a> ByteCursor<'a> { self.put(bun_fmt::u64_hex_var_lower(&mut tmp, n)); } + /// Two lower-hex digits per byte. + #[inline(always)] + fn put_hex_bytes(&mut self, bytes: &[u8]) { + let end = self.at + bytes.len() * 2; + bun_fmt::bytes_to_hex_lower(bytes, &mut self.buf[self.at..end]); + self.at = end; + } + /// `@@@{d}` when set. #[inline(always)] fn put_cache_version(&mut self, v: Option) { @@ -725,6 +733,8 @@ pub fn cached_npm_package_folder_print_basename<'a>( w.finish_z() } +/// `@T@@@@1`, for URL tarballs. `file:` tarballs use +/// `cached_local_tarball_folder_name_print`. pub fn cached_tarball_folder_name_print<'a>( buf: &'a mut [u8], url: &[u8], @@ -750,6 +760,43 @@ pub fn cached_tarball_folder_name( ) } +/// `@T@sha512-@@@1`. +/// +/// A `file:` tarball's resolution is the path as written in package.json +/// (`pkg.tgz`), which names a different tarball in every project sharing the +/// cache, so unlike a URL tarball it is cached under the integrity bun.lock +/// pins for it: the entry is only reused for the bytes it was extracted from. +/// +/// Empty when the integrity is not known yet (lockfile written before tarball +/// integrity was recorded). Callers treat that like a cache miss: extracting the +/// tarball computes the integrity, and the install callbacks record it in the +/// lockfile before installing from the entry named after it. +pub fn cached_local_tarball_folder_name_print<'a>( + buf: &'a mut [u8], + integrity: &Integrity, + patch_hash: Option, +) -> &'a ZStr { + let Some(algorithm) = integrity.tag.name() else { + return ZStr::EMPTY; + }; + let digest = integrity.slice(); + let mut w = ByteCursor::new(buf); + w.put(b"@T@"); + w.put(algorithm.as_bytes()); + w.put_byte(b'-'); + w.put_hex_bytes(&digest[..digest.len().min(16)]); + w.put_cache_version(Some(CacheVersion::CURRENT)); + w.put_patch_hash(patch_hash); + w.finish_z() +} + +pub fn cached_local_tarball_folder_name( + integrity: &Integrity, + patch_hash: Option, +) -> &'static ZStr { + cached_local_tarball_folder_name_print(cached_package_folder_name_buf(), integrity, patch_hash) +} + pub fn is_folder_in_cache(this: &mut PackageManager, folder_path: &ZStr) -> bool { sys::directory_exists_at(get_cache_directory(this), folder_path).unwrap_or(false) } @@ -947,6 +994,7 @@ pub fn compute_cache_dir_and_subpath<'a>( manager: &mut PackageManager, pkg_name: &[u8], resolution: &Resolution, + integrity: &Integrity, folder_path_buf: &'a mut PathBuffer, patch_hash: Option, ) -> CacheDirAndSubpath<'a> { @@ -986,8 +1034,20 @@ pub fn compute_cache_dir_and_subpath<'a>( cache_dir = Fd::cwd(); } ResolutionTag::LocalTarball => { - let tarball = *resolution.local_tarball(); - cache_dir_subpath = cached_tarball_folder_name(manager, tarball, patch_hash); + cache_dir_subpath = cached_local_tarball_folder_name(integrity, patch_hash); + if cache_dir_subpath.is_empty() { + Output::err_generic( + "the lockfile does not record an integrity for {}@{}, run bun install first", + ( + bun_fmt::s(name), + resolution.fmt( + manager.lockfile.buffers.string_bytes.as_slice(), + bun_fmt::PathSep::Posix, + ), + ), + ); + Global::exit(1); + } cache_dir = get_cache_directory(manager); } ResolutionTag::RemoteTarball => { diff --git a/src/install/PackageManager/PackageManagerLifecycle.rs b/src/install/PackageManager/PackageManagerLifecycle.rs index 63730f9876a4..ede34ff115b8 100644 --- a/src/install/PackageManager/PackageManagerLifecycle.rs +++ b/src/install/PackageManager/PackageManagerLifecycle.rs @@ -140,9 +140,8 @@ impl PackageManager { patch_hash, ) } - ResolutionTag::LocalTarball => directories::cached_tarball_folder_name( - self, - *pkg.resolution.local_tarball(), + ResolutionTag::LocalTarball => directories::cached_local_tarball_folder_name( + &pkg.meta.integrity, patch_hash, ), ResolutionTag::RemoteTarball => directories::cached_tarball_folder_name( diff --git a/src/install/PackageManager/patchPackage.rs b/src/install/PackageManager/patchPackage.rs index 49029f7cd401..8709f93b8596 100644 --- a/src/install/PackageManager/patchPackage.rs +++ b/src/install/PackageManager/patchPackage.rs @@ -270,8 +270,14 @@ pub fn do_patch_commit( // `compute_cache_dir_and_subpath` resolves `pkg.resolution`'s strings against `manager.lockfile`. manager.lockfile = lockfile; let name = manager.lockfile.str(&pkg.name).to_vec(); - let cache_result = - compute_cache_dir_and_subpath(manager, &name, &pkg.resolution, &mut folder_path_buf, None); + let cache_result = compute_cache_dir_and_subpath( + manager, + &name, + &pkg.resolution, + &pkg.meta.integrity, + &mut folder_path_buf, + None, + ); let cache_dir: Fd = cache_result.cache_dir; let cache_dir_subpath: &ZStr = cache_result.cache_dir_subpath; let changes_dir: &[u8] = &changes_dir; @@ -858,6 +864,7 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> { manager, &name, &actual_package.resolution, + &actual_package.meta.integrity, &mut folder_path_buf, existing_patchfile_hash, ); @@ -914,10 +921,12 @@ pub fn prepare_patch(manager: &mut PackageManager) -> Result<(), crate::Error> { }; let pkg_resolution = pkg.resolution; + let pkg_integrity = pkg.meta.integrity; let cache_result = compute_cache_dir_and_subpath( manager, &pkg_name, &pkg_resolution, + &pkg_integrity, &mut folder_path_buf, existing_patchfile_hash, ); diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index 05c3c87d1002..dd674fb65f00 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -110,7 +110,11 @@ pub trait RunTasksCallbacks { ) { unreachable!() } - fn on_extract_store_installer(_ctx: &mut Self::Ctx, _task_id: Task::Id) { + fn on_extract_store_installer( + _ctx: &mut Self::Ctx, + _task_id: Task::Id, + _data: &bun_install::ExtractData, + ) { unreachable!() } @@ -1146,7 +1150,7 @@ pub fn run_tasks( log_level, ); } else if C::IS_STORE_INSTALLER { - C::on_extract_store_installer(extract_ctx, task.id); + C::on_extract_store_installer(extract_ctx, task.id, task.data_extract()); } else { unreachable!("unexpected context type"); } @@ -1475,7 +1479,11 @@ pub fn run_tasks( log_level, ); } else if C::IS_STORE_INSTALLER { - C::on_extract_store_installer(extract_ctx, task.id); + C::on_extract_store_installer( + extract_ctx, + task.id, + task.data_git_checkout(), + ); } else { unreachable!("unexpected context type"); } diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 7ae9f698469b..b30138842632 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -1153,12 +1153,14 @@ impl TarballStream { let (name, basename) = tarball.name_and_basename(); + let integrity = tarball.lockfile_integrity(|| self.hasher.final_()); let mut result = match tarball.move_to_cache_directory( &mut (*task).log, self.tmpname.as_zstr(), name, basename, self.resolved_github_dirname, + &integrity, ) { Ok(r) => r, Err(err) => { @@ -1167,19 +1169,7 @@ impl TarballStream { return; } }; - - match tarball.resolution.tag { - ResolutionTag::Github - | ResolutionTag::RemoteTarball - | ResolutionTag::LocalTarball => { - if tarball.integrity.tag.is_supported() { - result.integrity = tarball.integrity; - } else { - result.integrity = self.hasher.final_(); - } - } - _ => {} - } + result.integrity = integrity; if PackageManager::verbose_install() { bun_core::pretty_errorln!( diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index e3cec500e2ed..7f7e49125df5 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -57,28 +57,30 @@ impl ExtractTarball { return Err(crate::Error::IntegrityCheckFailed); } } - let mut result = self.extract(log, bytes)?; + let integrity = self.lockfile_integrity(|| Integrity::for_bytes(bytes)); + let mut result = self.extract(log, bytes, &integrity)?; + result.integrity = integrity; + Ok(result) + } - // Compute and store SHA-512 integrity hash for GitHub / URL / local tarballs - // so the lockfile can pin the exact tarball content. On subsequent installs - // the hash stored in the lockfile is forwarded via this.integrity and verified - // above, preventing a compromised server from silently swapping the tarball. + /// The integrity the lockfile records for a GitHub / URL / local tarball, so + /// later installs verify the same bytes (see `run`). That is the value the + /// lockfile already pins (verified before extraction), or else `compute` from + /// the bytes on the first install. A local tarball's cache entry is named + /// after it (`move_to_cache_directory`), which is why it is settled before + /// extracting. Unknown for npm packages, whose integrity comes from the + /// registry manifest. + pub(crate) fn lockfile_integrity(&self, compute: impl FnOnce() -> Integrity) -> Integrity { match self.resolution.tag { ResolutionTag::Github | ResolutionTag::RemoteTarball | ResolutionTag::LocalTarball => { if self.integrity.tag.is_supported() { - // Re-installing with an existing lockfile: integrity was already - // verified above, propagate the known value to ExtractData so that - // the lockfile keeps it on re-serialisation. - result.integrity = self.integrity; + self.integrity } else { - // First install (no integrity in the lockfile yet): compute it. - result.integrity = Integrity::for_bytes(bytes); + compute() } } - _ => {} + _ => Integrity::default(), } - - Ok(result) } } @@ -236,7 +238,12 @@ impl ExtractTarball { (name, basename) } - fn extract(&self, log: &mut bun_ast::Log, tgz_bytes: &[u8]) -> Result { + fn extract( + &self, + log: &mut bun_ast::Log, + tgz_bytes: &[u8], + integrity: &Integrity, + ) -> Result { let _tracer = bun_core::perf::trace("ExtractTarball.extract"); let tmpdir = Dir::borrow(&self.temp_dir); @@ -450,12 +457,16 @@ impl ExtractTarball { } } - self.move_to_cache_directory(log, tmpname, name, basename, resolved) + self.move_to_cache_directory(log, tmpname, name, basename, resolved, integrity) } /// Rename the freshly-extracted temp directory into the cache, read /// `package.json` if required, and build the `ExtractData` result. Shared /// between the buffered and streaming extraction paths. + /// + /// `resolved` (GitHub) and `integrity` (local tarballs, see + /// `lockfile_integrity`) name the cache entry for the resolutions whose + /// entries are keyed by content rather than by the resolution string. pub(crate) fn move_to_cache_directory( &self, log: &mut bun_ast::Log, @@ -463,6 +474,7 @@ impl ExtractTarball { name: &[u8], basename: &[u8], resolved: &[u8], + integrity: &Integrity, ) -> Result { let package_manager = self.package_manager.get(); @@ -511,14 +523,18 @@ impl ExtractTarball { ) .as_bytes() } - ResolutionTag::LocalTarball | ResolutionTag::RemoteTarball => { - directories::cached_tarball_folder_name_print( - &mut bufs.folder_name_buf, - self.url.slice(), - None, - ) - .as_bytes() - } + ResolutionTag::LocalTarball => directories::cached_local_tarball_folder_name_print( + &mut bufs.folder_name_buf, + integrity, + None, + ) + .as_bytes(), + ResolutionTag::RemoteTarball => directories::cached_tarball_folder_name_print( + &mut bufs.folder_name_buf, + self.url.slice(), + None, + ) + .as_bytes(), _ => unreachable!(), }; if folder_name.is_empty() || (folder_name.len() == 1 && folder_name[0] == b'/') { diff --git a/src/install/integrity.rs b/src/install/integrity.rs index e5ae223d335e..12e459448358 100644 --- a/src/install/integrity.rs +++ b/src/install/integrity.rs @@ -243,13 +243,11 @@ impl Integrity { impl fmt::Display for Integrity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.tag { - Tag::SHA1 => f.write_str("sha1-")?, - Tag::SHA256 => f.write_str("sha256-")?, - Tag::SHA384 => f.write_str("sha384-")?, - Tag::SHA512 => f.write_str("sha512-")?, - _ => return Ok(()), - } + let Some(algorithm) = self.tag.name() else { + return Ok(()); + }; + f.write_str(algorithm)?; + f.write_str("-")?; let mut base64_buf = [0u8; 512]; let bytes = self.slice(); @@ -293,6 +291,17 @@ impl Tag { self.0 >= Tag::SHA1.0 && self.0 <= Tag::SHA512.0 } + /// The algorithm part of the SRI string (`sha512` in `sha512-...`); `None` for `UNKNOWN`. + pub(crate) fn name(self) -> Option<&'static str> { + Some(match self { + Tag::SHA1 => "sha1", + Tag::SHA256 => "sha256", + Tag::SHA384 => "sha384", + Tag::SHA512 => "sha512", + _ => return None, + }) + } + pub(crate) fn parse(buf: &[u8]) -> (Tag, usize) { let Some(i) = strings::index_of_char(&buf[0..buf.len().min(7)], b'-') else { return (Tag::UNKNOWN, 0); diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 71464543f8a2..78dc5b444a01 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -144,8 +144,12 @@ impl<'a> run_tasks::RunTasksCallbacks for StoreRunTasksCallbacks<'a> { const HAS_ON_PACKAGE_DOWNLOAD_ERROR: bool = true; const IS_STORE_INSTALLER: bool = true; - fn on_extract_store_installer(ctx: &mut Self::Ctx, task_id: Task::Id) { - ctx.on_package_extracted(task_id); + fn on_extract_store_installer( + ctx: &mut Self::Ctx, + task_id: Task::Id, + data: &install::ExtractData, + ) { + ctx.on_package_extracted(task_id, data); } fn on_package_download_error_store( @@ -2366,11 +2370,12 @@ pub(crate) fn install_isolated_packages( pkg_res.github(), None, ), - ResolutionTag::LocalTarball => package_manager::cached_tarball_folder_name( - installer.manager(), - *pkg_res.local_tarball(), - None, - ), + ResolutionTag::LocalTarball => { + package_manager::cached_local_tarball_folder_name( + &pkgs.items_meta()[pkg_id as usize].integrity, + None, + ) + } ResolutionTag::RemoteTarball => { package_manager::cached_tarball_folder_name( installer.manager(), @@ -2385,23 +2390,26 @@ pub(crate) fn install_isolated_packages( installer.manager_mut().get_cache_directory_and_abs_path(); let _ = &cache_dir_path; // dropped at scope exit - let missing_from_cache = match installer.manager().get_preinstall_state(pkg_id) - { - install::PreinstallState::Done => false, - _ => { - let exists = package_manager::directories::is_package_in_cache_at( - cache_dir, - cache_subpath_z, - pkg_res_tag, - ); - if exists { - installer - .manager_mut() - .set_preinstall_state(pkg_id, install::PreinstallState::Done); + // An empty name is a local tarball whose integrity the lockfile + // does not record; extracting it is what names its entry. + let missing_from_cache = cache_subpath_z.is_empty() + || match installer.manager().get_preinstall_state(pkg_id) { + install::PreinstallState::Done => false, + _ => { + let exists = package_manager::directories::is_package_in_cache_at( + cache_dir, + cache_subpath_z, + pkg_res_tag, + ); + if exists { + installer.manager_mut().set_preinstall_state( + pkg_id, + install::PreinstallState::Done, + ); + } + !exists } - !exists - } - }; + }; if !missing_from_cache { if let installer::PatchInfo::Patch(patch) = &patch_info { diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index ce346a116b37..12383a8c66a8 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -36,7 +36,7 @@ use super::symlinker::{self, Symlinker}; use crate::bun_fs; use crate::lockfile_real::package::PackageColumns as _; use crate::package_manager_real::directories; -use crate::package_manager_real::package_manager_options::Do; +use crate::package_manager_real::package_manager_options::{Do, Enable}; /// The enum lives at module level in `crate::resolution`. type ResolutionTag = resolution::Tag; @@ -70,7 +70,9 @@ pub struct Installer<'a> { /// pool and each task derefs this field; a `&'a mut` would assert /// exclusivity every concurrent task violates. Mutated only for /// `lockfile.trusted_dependencies` (under `trusted_dependencies_mutex`, - /// narrowed via `addr_of_mut!`). Never null. Read via `lockfile()`. + /// narrowed via `addr_of_mut!`) and, on the main thread, a not-yet-started + /// package's `meta.integrity` (`record_extracted_integrity`, one row via + /// the raw column pointer). Never null. Read via `lockfile()`. pub lockfile: *mut Lockfile, pub(crate) summary: InstallSummary, @@ -181,7 +183,11 @@ impl<'a> Installer<'a> { self.start_task(entry_id); } - pub(crate) fn on_package_extracted(&mut self, task_id: crate::package_manager_task::Id) { + pub(crate) fn on_package_extracted( + &mut self, + task_id: crate::package_manager_task::Id, + data: &install::ExtractData, + ) { if let Some(removed) = self.manager_mut().task_queue.remove(&task_id) { let store = self.store; @@ -205,6 +211,11 @@ impl<'a> Installer<'a> { let node_id = entry_node_ids[entry_id.get() as usize]; let pkg_id = node_pkg_ids[node_id.get() as usize]; + + if data.integrity.tag.is_supported() { + self.record_extracted_integrity(pkg_id, &data.integrity); + } + let pkg_name = pkg_names[pkg_id as usize]; let pkg_name_hash = pkg_name_hashes[pkg_id as usize]; let pkg_res = &pkg_resolutions[pkg_id as usize]; @@ -230,6 +241,42 @@ impl<'a> Installer<'a> { } } + /// Main thread, before the package's tasks start. The counterpart of the + /// integrity write-back in `PackageInstaller::install_enqueued_packages_after_extraction`: + /// a lockfile written before tarball integrity was recorded has none for the + /// package, so the extraction computed it. A local tarball's cache entry is + /// named after it (`cached_local_tarball_folder_name`), so the tasks need it + /// in place, and the lockfile is re-saved with it. + fn record_extracted_integrity(&mut self, pkg_id: PackageID, integrity: &install::Integrity) { + let pkgs = self.lockfile().packages.slice(); + assert!((pkg_id as usize) < pkgs.len()); + // SAFETY: `items_raw` carries the column's root provenance, so this + // field access needs no `&mut Lockfile`. Only a package's own tasks + // read its `integrity` (to name the cache entry), and none of this + // package's tasks have started: every entry of it waited on the + // extraction being reported. Tasks of other packages running on the + // pool hold `&[Meta]` over the column but only touch other bytes. + // `pkg_id` is in bounds per the assert above. + let recorded = unsafe { + core::ptr::addr_of_mut!( + (*pkgs + .items_raw::<"meta", package::Meta>() + .add(pkg_id as usize)) + .integrity + ) + }; + // SAFETY: see above. + if unsafe { (*recorded).tag.is_supported() } { + return; + } + // SAFETY: see above. + unsafe { recorded.write(*integrity) }; + self.manager_mut() + .options + .enable + .set(Enable::FORCE_SAVE_LOCKFILE, true); + } + /// Called from main thread when a tarball download or extraction fails. /// Without this, the upfront pending-task slot for each waiting entry is /// never released and the install loop blocks forever on @@ -1132,9 +1179,13 @@ impl Task { patch_info.contents_hash(), ), ResolutionTag::LocalTarball => { - directories::cached_tarball_folder_name( - manager, - *pkg_res.local_tarball(), + // Recorded by the lockfile or by + // `on_package_extracted` before this task started. + debug_assert!( + pkg_metas[pkg_id as usize].integrity.tag.is_supported() + ); + directories::cached_local_tarball_folder_name( + &pkg_metas[pkg_id as usize].integrity, patch_info.contents_hash(), ) } diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index c5162af3567d..cffe4e4470e0 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -760,16 +760,18 @@ impl PatchTask { let pkg_name_slice = pkg_name .slice(&pkg_manager.lockfile.buffers.string_bytes) .to_vec(); - // `Resolution` is `Copy`; copy out so the lockfile borrow ends + // `Resolution` and `Integrity` are `Copy`; copy out so the lockfile borrow ends // before `compute_cache_dir_and_subpath` reborrows `pkg_manager` mutably. let resolution_clone: Resolution = pkg_manager.lockfile.packages.items_resolution()[pkg_id as usize]; + let integrity = pkg_manager.lockfile.packages.items_meta()[pkg_id as usize].integrity; let mut folder_path_buf = PathBuffer::uninit(); let stuff = package_manager::compute_cache_dir_and_subpath( pkg_manager, &pkg_name_slice, &resolution_clone, + &integrity, &mut folder_path_buf, Some(patch_hash), ); diff --git a/test/cli/install/bun-install-tarball-integrity.test.ts b/test/cli/install/bun-install-tarball-integrity.test.ts index 422352e6a667..35e208458ea2 100644 --- a/test/cli/install/bun-install-tarball-integrity.test.ts +++ b/test/cli/install/bun-install-tarball-integrity.test.ts @@ -493,6 +493,132 @@ describe.concurrent("tarball integrity", () => { }); }); +// A `file:` tarball's resolution is the path written in package.json (`pkg.tgz`), +// which names a different tarball in every project sharing the cache, and an +// install from a lockfile never reads the tarball itself. So the cache entry is +// keyed by the integrity the lockfile pins, not by that path: two projects (or two +// branches of one project) with different tarballs at the same path get separate +// entries, and a lockfile that does not pin an integrity cannot name an entry and +// has the tarball read instead. +describe.concurrent.each(["hoisted", "isolated"] as const)("local tarball cache entries (%s)", linker => { + async function tarball(exported: string) { + const tgz = await new Bun.Archive( + { + "package/package.json": JSON.stringify({ name: "pkg", version: "1.0.0" }), + "package/index.js": `module.exports = ${JSON.stringify(exported)};\n`, + }, + { compress: "gzip" }, + ).bytes(); + const digest = createHash("sha512").update(tgz).digest(); + return { + file: Buffer.from(tgz), + integrity: "sha512-" + digest.toString("base64"), + cacheEntry: `@T@sha512-${digest.subarray(0, 16).toString("hex")}@@@1`, + }; + } + + function project(tgz: Buffer, extraFiles: Record = {}) { + return { + "package.json": JSON.stringify({ name: "app", dependencies: { pkg: "file:pkg.tgz" } }), + "pkg.tgz": tgz, + "bunfig.toml": Bun.TOML.stringify({ install: { linker } }), + ...extraFiles, + }; + } + + // The projects under `root` share `root/cache`. + async function install(root: string, name: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd: join(root, name), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(root, "cache") }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const installedExport = (root: string, name: string) => + file(join(root, name, "node_modules", "pkg", "index.js")).text(); + const lockfile = (root: string, name: string) => file(join(root, name, "bun.lock")).text(); + const reinstall = (root: string, name: string, ...args: string[]) => + rm(join(root, name, "node_modules"), { recursive: true }).then(() => install(root, name, ...args)); + const cacheEntries = async (root: string) => + (await readdirSorted(join(root, "cache"))).filter(entry => entry.startsWith("@T@")); + + it("keeps the tarballs of two projects that both depend on file:pkg.tgz apart", async () => { + const one = await tarball("one"); + const two = await tarball("two"); + using root = tempDir("local-tarball-two-projects", { one: project(one.file), two: project(two.file) }); + + await install(String(root), "one"); + await install(String(root), "two"); + expect(await lockfile(String(root), "one")).toContain(one.integrity); + + await reinstall(String(root), "one"); + expect(await installedExport(String(root), "one")).toBe('module.exports = "one";\n'); + expect(await installedExport(String(root), "two")).toBe('module.exports = "two";\n'); + expect(await cacheEntries(String(root))).toEqual([one.cacheEntry, two.cacheEntry].sort()); + }); + + it("installs the tarball the lockfile pins after another build of it was extracted from the same path", async () => { + const v1 = await tarball("v1"); + const v2 = await tarball("v2"); + using root = tempDir("local-tarball-two-builds", { app: project(v1.file) }); + const app = join(String(root), "app"); + + await install(String(root), "app"); + const v1Lockfile = await lockfile(String(root), "app"); + expect(v1Lockfile).toContain(v1.integrity); + + // Like checking out a branch that ships v2: the rebuilt tarball is resolved and extracted. + await writeFile(join(app, "pkg.tgz"), v2.file); + await rm(join(app, "bun.lock")); + await reinstall(String(root), "app"); + expect(await installedExport(String(root), "app")).toBe('module.exports = "v2";\n'); + expect(await lockfile(String(root), "app")).toContain(v2.integrity); + + // Back on the v1 branch, pkg.tgz and bun.lock are v1's again. + await writeFile(join(app, "pkg.tgz"), v1.file); + await writeFile(join(app, "bun.lock"), v1Lockfile); + await reinstall(String(root), "app"); + expect(await installedExport(String(root), "app")).toBe('module.exports = "v1";\n'); + expect(await cacheEntries(String(root))).toEqual([v1.cacheEntry, v2.cacheEntry].sort()); + }); + + it("reads the tarball when the lockfile does not record its integrity, then records it", async () => { + const other = await tarball("other"); + const mine = await tarball("mine"); + using root = tempDir("local-tarball-unpinned", { + other: project(other.file), + app: project(mine.file, { + // Written before bun recorded the integrity of tarball packages. + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "app", dependencies: { pkg: "file:pkg.tgz" } } }, + packages: { pkg: ["pkg@pkg.tgz", {}] }, + }), + }), + }); + + await install(String(root), "other"); + await install(String(root), "app"); + expect(await installedExport(String(root), "app")).toBe('module.exports = "mine";\n'); + expect(await lockfile(String(root), "app")).toContain(mine.integrity); + expect(await cacheEntries(String(root))).toEqual([mine.cacheEntry, other.cacheEntry].sort()); + + // The recorded integrity names the entry that extraction created: this + // install is served from it without reading pkg.tgz (which would no longer + // match the pin). + await writeFile(join(String(root), "app", "pkg.tgz"), other.file); + await reinstall(String(root), "app"); + expect(await installedExport(String(root), "app")).toBe('module.exports = "mine";\n'); + }); +}); + describe.concurrent.each(["hoisted", "isolated"] as const)("tarball integrity mismatch (%s)", linker => { // Regression test for #29646 — with the isolated linker, a SHA-512 mismatch // during the resolve-phase tarball extract left `task_queue` / From 3050b8067c887d50b30855e4d75ae9350659964a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:28 +0000 Subject: [PATCH 026/258] install: reject tarball, folder and git packages whose package.json name is invalid (#38633) --- .../PackageManager/processDependencyList.rs | 4 +- src/install/lockfile/Package.rs | 10 ++ test/cli/install/bun-install.test.ts | 143 ++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/install/PackageManager/processDependencyList.rs b/src/install/PackageManager/processDependencyList.rs index 7fdd1f654dac..492110ad87af 100644 --- a/src/install/PackageManager/processDependencyList.rs +++ b/src/install/PackageManager/processDependencyList.rs @@ -167,7 +167,7 @@ impl PackageManager { format_args!("{}", resolution.fmt_url(string_buf)), ); } - Global::crash(); + self.crash(); } let has_scripts = pkg.scripts.has_any() || { @@ -264,7 +264,7 @@ impl PackageManager { err.name(), ); } - Global::crash(); + self.crash(); } let has_scripts = package.scripts.has_any() || { diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index e175f2c58f9e..0582474b998b 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -2231,6 +2231,16 @@ impl Package { if let Some(name_q) = json.as_property(b"name") { if let Some(name) = name_q.expr.as_utf8(&bump) { if !name.is_empty() { + // Non-root names become bun.lock `packages` entries, which the + // lockfile parser rejects with the same check (see bun.lock.rs). + if !FEATURES.is_main && !dependency::is_safe_install_folder_name(name) { + log.add_error_fmt( + source, + value_loc_of(source, name_q.loc), + format_args!("Invalid package name {}", bun_core::fmt::quote(name)), + ); + return Err(crate::Error::InvalidPackageJSON); + } string_builder.count(name); break 'name; } diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 360a93010395..c7d98aab03f0 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -10138,6 +10138,149 @@ it("does not install transitive file: dependencies that point outside their pack expect(exitCode).toBe(1); }); +describe.concurrent("a dependency whose own package.json has an invalid name", () => { + // The name inside a tarball, folder or git dependency's package.json is written + // to bun.lock as `name@resolution`, and the bun.lock parser rejects names that + // are not safe install folder names. Saving such a name used to succeed and + // leave behind a lockfile that every later `bun install` ignored, so the + // install has to fail before the lockfile is saved. + async function install(root: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], + cwd: join(root, "project"), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(root, "cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, exitCode }; + } + + async function expectRejected(root: string, quotedName: string) { + const { out, err, exitCode } = await install(root); + expect(err).toContain(`error: Invalid package name ${quotedName}`); + expect(out).not.toContain("1 package installed"); + expect(await exists(join(root, "project", "bun.lock"))).toBe(false); + expect(await exists(join(root, "project", "node_modules", "dep"))).toBe(false); + expect(exitCode).toBe(1); + return err; + } + + it("fails to install a local tarball", async () => { + using dir = tempDir("invalid-name-tarball", { + "project/package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { dep: "file:../dep.tgz" }, + }), + }); + await Bun.Archive.write( + join(String(dir), "dep.tgz"), + { "package/package.json": JSON.stringify({ name: "a:b", version: "1.0.0" }) }, + { compress: "gzip" }, + ); + + await expectRejected(String(dir), '"a:b"'); + }); + + it("fails to install a file: folder and points at the offending package.json", async () => { + using dir = tempDir("invalid-name-folder", { + "project/package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { dep: "file:../lib" }, + }), + "lib/package.json": JSON.stringify({ name: "lib/..", version: "1.0.0" }), + }); + + const err = await expectRejected(String(dir), '"lib/.."'); + expect(err).toMatch(/ at .*lib[\\/]package\.json:1:9\s/); + }); + + it("escapes the rejected name in the error message", async () => { + using dir = tempDir("invalid-name-escaped", { + "project/package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { dep: "file:../lib" }, + }), + "lib/package.json": JSON.stringify({ name: "a\0b", version: "1.0.0" }), + }); + + await expectRejected(String(dir), '"a\\u0000b"'); + }); + + it("fails to install a git dependency", async () => { + using dir = tempDir("invalid-name-git", { + "work/package.json": JSON.stringify({ name: "a\\b", version: "1.0.0" }), + }); + await createDumbHttpGitRepo(String(dir), {}); + using server = serveDirectory(String(dir)); + await write( + join(String(dir), "project", "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { dep: `git+http://localhost:${server.port}/repo.git` }, + }), + ); + + await expectRejected(String(dir), '"a\\b"'); + }); + + it("still installs scoped names and writes a lockfile the next install loads", async () => { + using dir = tempDir("valid-scoped-name-tarball", { + "project/package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { dep: "file:../dep.tgz" }, + }), + }); + await Bun.Archive.write( + join(String(dir), "dep.tgz"), + { "package/package.json": JSON.stringify({ name: "@scope/dep", version: "1.0.0" }) }, + { compress: "gzip" }, + ); + + const first = await install(String(dir)); + expect(first.err).not.toContain("error:"); + expect(first.out).toContain("1 package installed"); + expect(first.exitCode).toBe(0); + expect(await file(join(String(dir), "project", "bun.lock")).text()).toContain('"dep": ["@scope/dep@../dep.tgz"'); + + const second = await install(String(dir), "--frozen-lockfile"); + expect(second.err).not.toContain("Ignoring lockfile"); + expect(second.err).not.toContain("error:"); + expect(second.exitCode).toBe(0); + }); + + it("does not apply to the root package's own name", async () => { + // The root is not a bun.lock `packages` entry; its name only appears in the + // `workspaces` section, which accepts it as-is. + using dir = tempDir("invalid-name-root", { + "project/package.json": JSON.stringify({ + name: "a:b", + version: "0.0.1", + dependencies: { dep: "file:../lib" }, + }), + "lib/package.json": JSON.stringify({ name: "lib", version: "1.0.0" }), + }); + + const first = await install(String(dir)); + expect(first.err).not.toContain("error:"); + expect(first.out).toContain("1 package installed"); + expect(first.exitCode).toBe(0); + const lockfile = await file(join(String(dir), "project", "bun.lock")).text(); + expect(lockfile).toContain('"name": "a:b"'); + expect(lockfile).toContain('"dep": ["lib@file:../lib", {}]'); + + const second = await install(String(dir), "--frozen-lockfile"); + expect(second.err).not.toContain("Ignoring lockfile"); + expect(second.err).not.toContain("error:"); + expect(second.exitCode).toBe(0); + }); +}); + it("does not install transitive file: dependencies with overlong folder targets", async () => { const overlongTarget = "file:./" + Buffer.alloc(120000, "a").toString(); using dir = tempDir("transitive-file-dep-overlong", { From 2e280b9c46ad0453056f8162e34073330e449f59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:33 +0000 Subject: [PATCH 027/258] install: read git+git:// resolutions back from bun.lock (#38798) --- src/install/dependency.rs | 7 +++++- test/cli/install/migration/migrate.test.ts | 25 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 8e3b39faad9f..a5557f315caf 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -871,7 +871,12 @@ impl TagExt for Tag { } } b'+' => { - if url.starts_with(b"+ssh:") || url.starts_with(b"+file:") { + // `git+git:` is how a Git resolution of a `git://` dependency is + // written back to bun.lock (`git+` label + the original URL). + if url.starts_with(b"+ssh:") + || url.starts_with(b"+file:") + || url.starts_with(b"+git:") + { return Tag::Git; } if url.starts_with(b"+http") { diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index d89f2ce91783..9d196aca88bf 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -917,6 +917,31 @@ describe("package-lock.json migration fixes", () => { await frozen(dir); }); + // A git resolution is written to bun.lock as "git+" + the repository URL, so a + // dependency on a plain git:// host comes back as "git+git://...". The lockfile + // parser has to accept that spelling, otherwise every later install throws the + // lockfile away ("Unexpected resolution") and --frozen-lockfile can never pass. + test.concurrent("git:// hosts round-trip through bun.lock", async () => { + const dependencies = { + g: "git://example.com/user/g.git", + h: "git://example.com/user/h.git#v1", + }; + using dir = synthetic("npm-migrate-git-protocol", { + "package.json": JSON.stringify({ name: "git-protocol", dependencies }), + "package-lock.json": npmLock("git-protocol", { + "": { name: "git-protocol", dependencies }, + "node_modules/g": { version: "1.0.0", resolved: `git://example.com/user/g.git#${sha(1)}` }, + "node_modules/h": { version: "1.0.0", resolved: `git://example.com/user/h.git#${sha(2)}` }, + }), + }); + + const { lock } = await migrate(dir); + expect(lock.workspaces[""].dependencies).toStrictEqual(dependencies); + expect(lock.packages.g[0]).toBe(`g@git+git://example.com/user/g.git#${sha(1)}`); + expect(lock.packages.h[0]).toBe(`h@git+git://example.com/user/h.git#${sha(2)}`); + await frozen(dir); + }); + test.concurrent("root bundleDependencies keeps its subtree (B2)", async () => { using dir = fixture("testing-rebuild-bundle--a"); const { text, lock } = await migrate(dir); From c4681f4ce2541d9a86446be20a83cae9380f6edd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:36 +0000 Subject: [PATCH 028/258] install: stop aborting on link: specifiers and patch paths longer than the path buffers (#38359) --- src/install/PackageInstaller.rs | 31 +++- src/install/patch_install.rs | 14 +- src/install/resolvers/folder_resolver.rs | 171 ++++++++++----------- src/resolver/lib.rs | 8 - test/cli/install/bun-install-patch.test.ts | 35 ++++- test/cli/install/bun-install.test.ts | 59 +++++++ test/cli/install/bun-link.test.ts | 88 ++++++++++- test/cli/install/bun-workspaces.test.ts | 1 + 8 files changed, 297 insertions(+), 110 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index d634d960b960..39f28b69a28e 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1568,16 +1568,31 @@ impl<'a> PackageInstaller<'a> { installer.cache_dir = Fd::cwd(); } else { let global_link_dir = package_manager::global_link_dir_path(self.manager_mut()); + let sep_len = (global_link_dir[global_link_dir.len() - 1] != SEP) as usize; + let len = global_link_dir.len() + sep_len + folder.len(); + // `folder` is the `link:` specifier as written in package.json. + if len >= self.folder_path_buf.len() { + if log_level != Options::LogLevel::Silent { + Output::err( + "ENAMETOOLONG", + "link path for package {} is too long", + (bstr::BStr::new(pkg_name.slice(string_buf!())),), + ); + } + self.summary.fail += 1; + self.increment_tree_install_count( + !IS_PENDING_PACKAGE_INSTALL, + self.current_tree_id, + log_level, + ); + return; + } let buf = self.folder_path_buf.as_mut_slice(); - let mut len = 0usize; - buf[len..len + global_link_dir.len()].copy_from_slice(global_link_dir); - len += global_link_dir.len(); - if global_link_dir[global_link_dir.len() - 1] != SEP { - buf[len] = SEP; - len += 1; + buf[..global_link_dir.len()].copy_from_slice(global_link_dir); + if sep_len != 0 { + buf[global_link_dir.len()] = SEP; } - buf[len..len + folder.len()].copy_from_slice(folder); - len += folder.len(); + buf[global_link_dir.len() + sep_len..len].copy_from_slice(folder); buf[len] = 0; // SAFETY: buf[len] == 0 written above installer.cache_dir_subpath = ZStr::from_buf(&self.folder_path_buf, len); diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index cffe4e4470e0..ded1c8904195 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -393,9 +393,11 @@ impl PatchTask { let patchfile_path = &patch.patchfilepath; let mut absolute_patchfile_path_buf = PathBuffer::uninit(); + let mut absolute_patchfile_path_spill = Vec::new(); // 1. Parse the patch file - let absolute_patchfile_path = path::resolve_path::join_z_buf::( + let absolute_patchfile_path = path::resolve_path::join_z_buf_spill::( &mut absolute_patchfile_path_buf.0, + &mut absolute_patchfile_path_spill, &[dir, patchfile_path], ); // TODO: can the patch file be anything other than utf-8? @@ -608,9 +610,11 @@ impl PatchTask { let patchfile_path = &calc_hash.patchfile_path; let mut absolute_patchfile_path_buf = PathBuffer::uninit(); + let mut absolute_patchfile_path_spill = Vec::new(); // parse the patch file - let absolute_patchfile_path = path::resolve_path::join_z_buf::( + let absolute_patchfile_path = path::resolve_path::join_z_buf_spill::( &mut absolute_patchfile_path_buf.0, + &mut absolute_patchfile_path_spill, &[dir, patchfile_path], ); @@ -638,12 +642,10 @@ impl PatchTask { ); return None; } - bun_ast::add_warning_pretty!( - log, + log.add_error_fmt( None, Loc::EMPTY, - "patchfile {} is empty, please restore or delete it.", - BStr::new(absolute_patchfile_path.as_bytes()), + format_args!("failed to read patch file: {}", e), ); return None; } diff --git a/src/install/resolvers/folder_resolver.rs b/src/install/resolvers/folder_resolver.rs index d75d0c9c96df..e81504332ad2 100644 --- a/src/install/resolvers/folder_resolver.rs +++ b/src/install/resolvers/folder_resolver.rs @@ -2,7 +2,7 @@ use core::fmt; use bun_core::fmt::QuotedFormatter; use bun_core::{ZStr, strings}; -use bun_paths::{self, MAX_PATH_BYTES, PathBuffer, SEP, SEP_STR}; +use bun_paths::{self, PathBuffer, SEP, SEP_STR, resolve_path}; use bun_resolver::fs::FileSystem; use bun_semver::{self as semver, String as SemverString}; use bun_sys::{self, Fd, File, O}; @@ -35,7 +35,6 @@ pub(crate) struct PackageWorkspaceSearchPathFormatter<'a> { impl<'a> fmt::Display for PackageWorkspaceSearchPathFormatter<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut joined = [0u8; MAX_PATH_BYTES + 2]; // Caller constructs this formatter only when // `self.version.tag == .workspace`. let workspace = self.version.workspace(); @@ -48,34 +47,38 @@ impl<'a> fmt::Display for PackageWorkspaceSearchPathFormatter<'a> { )) .unwrap_or(workspace); - // SAFETY: joined[2..] is exactly MAX_PATH_BYTES bytes long. - let joined_path: &mut PathBuffer = - unsafe { &mut *joined.as_mut_ptr().add(2).cast::() }; - let mut paths = normalize_package_json_path( - GlobalOrRelative::Relative(dependency::version::Tag::Workspace), - joined_path, - self.manager.lockfile.str(str_to_use), - ); + let search_path = self.manager.lockfile.str(str_to_use); - if !strings::starts_with_char(paths.rel, b'.') && !strings::starts_with_char(paths.rel, SEP) - { - joined[0] = b'.'; - joined[1] = SEP; - // `paths.rel` points into `joined[2..]`; extend the view backward - // by the two bytes just written via safe slicing of `joined`. - let n = paths.rel.len() + 2; - paths.rel = &joined[..n]; - } + let mut joined = PathBuffer::uninit(); + let mut dot_slash_rel = Vec::new(); + let rel: &[u8] = match normalize_package_json_path( + GlobalOrRelative::Relative(dependency::version::Tag::Workspace), + &mut joined, + search_path, + ) { + Some(paths) + if !strings::starts_with_char(paths.rel, b'.') + && !strings::starts_with_char(paths.rel, SEP) => + { + dot_slash_rel.push(b'.'); + dot_slash_rel.push(SEP); + dot_slash_rel.extend_from_slice(paths.rel); + dot_slash_rel.as_slice() + } + Some(paths) => paths.rel, + // Too long to be a path; show it as written. + None => search_path, + }; if self.quoted { - let quoted = QuotedFormatter { text: paths.rel }; + let quoted = QuotedFormatter { text: rel }; fmt::Display::fmt("ed, f) } else { // `fmt::Formatter` only accepts `&str`, so non-UTF-8 path bytes are emitted lossily // (U+FFFD) via `bstr::BStr`'s Display. Both current callers pass // `quoted = true`, so this branch is unreached today; if a future // caller needs byte-exact output it must use an `io::Write` sink. - write!(f, "{}", bstr::BStr::new(paths.rel)) + write!(f, "{}", bstr::BStr::new(rel)) } } } @@ -92,10 +95,6 @@ pub struct Entry { // bun_collections::HashMap currently ignores the context/load-factor // type params (backed by std HashMap); identity hashing is a TODO(perf). -fn normalize(path: &[u8]) -> &[u8] { - FileSystem::instance().normalize(path) -} - pub(crate) fn hash(normalized_path: &[u8]) -> u64 { bun_wyhash::hash(normalized_path) } @@ -177,84 +176,80 @@ struct Paths<'a> { rel: &'a [u8], } +/// Returns `None` when the `package.json` path does not fit `joined`. fn normalize_package_json_path<'a>( global_or_relative: GlobalOrRelative<'_>, joined: &'a mut PathBuffer, non_normalized_path: &[u8], -) -> Paths<'a> { - let abs: &[u8]; - +) -> Option> { + let mut normalize_spill = Vec::new(); // We consider it valid if there is a package.json in the folder - let normalized: &[u8] = if non_normalized_path.len() == 1 && non_normalized_path[0] == b'.' { + let normalized: &[u8] = if non_normalized_path == b"." { non_normalized_path } else if bun_paths::is_absolute(non_normalized_path) { strings::trim_right(non_normalized_path, SEP_STR.as_bytes()) } else { - strings::trim_right(normalize(non_normalized_path), SEP_STR.as_bytes()) + strings::trim_right( + resolve_path::normalize_string_spill::( + &mut normalize_spill, + non_normalized_path, + ), + SEP_STR.as_bytes(), + ) }; const PACKAGE_JSON_LEN: usize = "/package.json".len(); - let rel: &[u8] = if strings::starts_with_char(normalized, b'.') { - let mut tempcat = PathBuffer::uninit(); - - tempcat[..normalized.len()].copy_from_slice(normalized); - tempcat[normalized.len()] = SEP; - tempcat[normalized.len() + 1..normalized.len() + PACKAGE_JSON_LEN] - .copy_from_slice(b"package.json"); - let parts: [&[u8]; 2] = [ - FileSystem::instance().top_level_dir(), - &tempcat[0..normalized.len() + PACKAGE_JSON_LEN], - ]; - abs = FileSystem::instance().abs_buf(&parts, joined); - FileSystem::instance().relative( - FileSystem::instance().top_level_dir(), - &abs[0..abs.len() - PACKAGE_JSON_LEN], - ) + // The last byte of `joined` is reserved for the NUL terminator. + let capacity = joined.len() - 1; + + let abs_len = if strings::starts_with_char(normalized, b'.') { + let parts: [&[u8]; 2] = [normalized, b"package.json"]; + FileSystem::instance() + .abs_buf_checked(&parts, &mut joined[..capacity])? + .len() } else { - let joined_len = joined.len(); - let mut remain: &mut [u8] = &mut joined[..]; - match &global_or_relative { - GlobalOrRelative::Global(path) | GlobalOrRelative::CacheFolder(path) => { - if !path.is_empty() { - let offset = path - .len() - .saturating_sub((path[path.len().saturating_sub(1)] == SEP) as usize); - if offset > 0 { - remain[0..offset].copy_from_slice(&path[0..offset]); - } - remain = &mut remain[offset..]; - if !normalized.is_empty() { - if (path[path.len() - 1] != SEP) && (normalized[0] != SEP) { - remain[0] = SEP; - remain = &mut remain[1..]; - } - } - } + let (prefix, needs_sep): (&[u8], bool) = match global_or_relative { + GlobalOrRelative::Global(path) | GlobalOrRelative::CacheFolder(path) + if !path.is_empty() => + { + let ends_with_sep = path[path.len() - 1] == SEP; + ( + &path[..path.len() - ends_with_sep as usize], + !normalized.is_empty() && !ends_with_sep && normalized[0] != SEP, + ) } - GlobalOrRelative::Relative(_) => {} + _ => (b"", false), + }; + let abs_len = prefix.len() + needs_sep as usize + normalized.len() + PACKAGE_JSON_LEN; + if abs_len > capacity { + return None; } - remain[..normalized.len()].copy_from_slice(normalized); - remain[normalized.len()] = SEP; - remain[normalized.len() + 1..normalized.len() + PACKAGE_JSON_LEN] - .copy_from_slice(b"package.json"); - let remain_after = remain.len() - (normalized.len() + PACKAGE_JSON_LEN); - // Compute abs len from remaining capacity. - let abs_len = joined_len - remain_after; - abs = &joined[0..abs_len]; - // We store the folder name without package.json - FileSystem::instance().relative( - FileSystem::instance().top_level_dir(), - &abs[0..abs.len() - PACKAGE_JSON_LEN], - ) + + let mut len = prefix.len(); + joined[..len].copy_from_slice(prefix); + if needs_sep { + joined[len] = SEP; + len += 1; + } + joined[len..len + normalized.len()].copy_from_slice(normalized); + len += normalized.len(); + joined[len] = SEP; + joined[len + 1..len + PACKAGE_JSON_LEN].copy_from_slice(b"package.json"); + abs_len }; - let abs_len = abs.len(); + + // We store the folder name without package.json + let rel = FileSystem::instance().relative( + FileSystem::instance().top_level_dir(), + &joined[..abs_len - PACKAGE_JSON_LEN], + ); joined[abs_len] = 0; - Paths { + Some(Paths { abs: ZStr::from_buf(joined, abs_len), rel, - } + }) } fn read_package_json_from_disk( @@ -386,7 +381,11 @@ pub(crate) fn get_or_put( let mut joined = PathBuffer::uninit(); #[cfg(windows)] let mut rel_buf = PathBuffer::uninit(); - let paths = normalize_package_json_path(global_or_relative, &mut joined, non_normalized_path); + let Some(paths) = + normalize_package_json_path(global_or_relative, &mut joined, non_normalized_path) + else { + return FolderResolution::Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)); + }; #[cfg(not(windows))] let abs = paths.abs; @@ -435,10 +434,10 @@ pub(crate) fn get_or_put( let result: crate::Result = match global_or_relative { GlobalOrRelative::Global(_) => 'global: { - let mut path = PathBuffer::uninit(); - path[..non_normalized_path.len()].copy_from_slice(non_normalized_path); + // `non_normalized_path` may alias the lockfile string buffer, which grows below. + let folder_path: Box<[u8]> = Box::from(non_normalized_path); let mut resolver: SymlinkResolver = NewResolver { - folder_path: &path[0..non_normalized_path.len()], + folder_path: &folder_path, }; break 'global read_package_json_from_disk( manager, diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 208c1678127e..b9ef626b8bd1 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -427,14 +427,6 @@ pub mod fs { } } - /// Normalizes `str` in the shared scratch space, returning the input - /// unchanged when already normalized. - #[inline] - pub fn normalize<'a>(&self, str: &'a [u8]) -> &'a [u8] { - use bun_paths::resolve_path::{normalize_string, platform}; - normalize_string::(str) - } - /// The process-global directory-name interning store. #[inline] pub fn dirname_store(&self) -> &'static DirnameStore { diff --git a/test/cli/install/bun-install-patch.test.ts b/test/cli/install/bun-install-patch.test.ts index 663a31dd7eb3..b79de709cf38 100644 --- a/test/cli/install/bun-install-patch.test.ts +++ b/test/cli/install/bun-install-patch.test.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; import { describe, expect, it, setDefaultTimeout, test } from "bun:test"; import { rmSync } from "fs"; -import { bunEnv, bunExe, normalizeBunSnapshot as normalizeBunSnapshot_, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot as normalizeBunSnapshot_, tempDir } from "harness"; import { join } from "path"; const normalizeBunSnapshot = (str: string) => { @@ -1119,3 +1119,36 @@ describe("patchedDependencies contents_hash", () => { expect({ hasB: mB.includes("TAIL_BBBB"), hasA: mB.includes("TAIL_AAAA") }).toEqual({ hasB: true, hasA: false }); }); }); + +describe("patchedDependencies path longer than the path buffer", () => { + // Hashing a patch joins its path onto the project directory in a path buffer + // (4096 bytes on Linux, 1024 on macOS, ~96 KiB on Windows). The join used to + // write past the buffer for a path that did not fit, aborting the install. + const longName = Buffer.alloc(100_000, "p").toString(); + + test("install reports the path instead of crashing", async () => { + using dir = tempDir("patch-path-too-long", { + "package.json": JSON.stringify({ + name: "patch-path-too-long", + patchedDependencies: { "is-odd@3.0.1": `patches/${longName}.patch` }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The path is handed to the OS as is. POSIX rejects anything longer than + // PATH_MAX with ENAMETOOLONG; Windows may report it as missing instead. + if (!isWindows) { + expect(stderr).toContain("error: failed to read patch file: ENAMETOOLONG: "); + } + expect(stderr).toContain(`${longName}.patch`); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index c7d98aab03f0..81b92513c577 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -6,6 +6,7 @@ import { bunEnv, bunExe, bunEnv as env, + isLinux, isWindows, joinP, normalizeBunSnapshot, @@ -10321,6 +10322,64 @@ it("does not install transitive file: dependencies with overlong folder targets" expect(exitCode).toBe(1); }); +// Resolving a folder dependency appends "/package.json" and a NUL to its absolute path in a +// path buffer (4096 bytes on Linux, 1024 on macOS). The absolute path itself is checked +// against the buffer when package.json is parsed, but the appended bytes were not, so a +// folder path within 13 bytes of the buffer size aborted the install. Windows' buffer is +// larger than any path the OS accepts, so the boundary cannot be reached there. +describe.skipIf(isWindows)("file: dependency whose package.json path is around the path buffer size", () => { + const PATH_BUFFER_BYTES = isLinux ? 4096 : 1024; + + // A relative path of exactly `bytes` bytes made of one letter directory names, so that a + // path which fits the buffer is rejected by the OS as missing, not as too long. + function pathOfLength(bytes: number) { + const tail = bytes % 2 === 0 ? "dd" : "d"; + return Buffer.alloc(bytes - tail.length, "d/").toString() + tail; + } + + // `packageJsonPathBytes` is the length of `//package.json`. + async function installFolderDependency(projectDir: string, packageJsonPathBytes: number) { + const folder = pathOfLength( + packageJsonPathBytes - Buffer.byteLength(projectDir) - "/".length - "/package.json".length, + ); + await writeFile( + join(projectDir, "package.json"), + JSON.stringify({ name: "my-app", dependencies: { dep: `file:./${folder}` } }), + ); + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { folder, err, exitCode }; + } + + it("is looked up on disk when the path and its NUL terminator fit", async () => { + using dir = tempDir("file-dep-path-buffer-fits", {}); + + const { folder, err, exitCode } = await installFolderDependency(String(dir), PATH_BUFFER_BYTES - 1); + + expect(err).toContain(`error: Could not find package.json for "file:${folder}"`); + expect(exitCode).toBe(1); + }); + + it.each([ + ["exactly the buffer size, leaving no room for the NUL terminator", 0], + // The folder path alone fits; "/package.json" is what does not. + ['longer than the buffer by less than the appended "/package.json"', 8], + ])("fails with ENAMETOOLONG when it is %s", async (_, extraBytes) => { + using dir = tempDir("file-dep-path-buffer-overflow", {}); + + const { err, exitCode } = await installFolderDependency(String(dir), PATH_BUFFER_BYTES + extraBytes); + + expect(err).toContain("error: ENAMETOOLONG"); + expect(exitCode).toBe(1); + }); +}); + for (const field of ["resolutions", "overrides"]) { it(`installs a file: dependency pointing outside the project when it came from root package.json "${field}"`, async () => { // `overrides` / `resolutions` can only be declared in the root package.json, diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..0863bf41d813 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -1,5 +1,5 @@ import { file, spawn } from "bun"; -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { access, mkdir, writeFile } from "fs/promises"; import { bunExe, @@ -471,3 +471,89 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +// A `link:` target is normalized and joined onto the global link directory in +// fixed-size buffers (the normalizer's is 1024 bytes, the others are a path +// buffer). These used to be written without a length check, so a long enough +// specifier aborted `bun install` instead of failing the dependency. +describe("link: specifier longer than the path buffers", () => { + // Longer than every buffer on every platform (the Windows path buffer is ~96 KiB). + const LONG_SPEC_BYTES = 100_000; + + async function run(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, exitCode }; + } + + it("fails to resolve a name that does not fit", async () => { + const target = Buffer.alloc(LONG_SPEC_BYTES, "n").toString(); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: `link:${target}` } }), + ); + + const { err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain("error: ENAMETOOLONG"); + expect(err).toContain("error: bar@link:" + target.slice(0, 64)); + expect(exitCode).toBe(1); + }); + + // `x/../` segments normalize away, so this resolves to the linked package, but + // the specifier itself (which is what gets linked) is still too long for a path. + it("fails to install a linked package whose specifier only fits once normalized", async () => { + const link_name = basename(link_dir).slice("bun-link.".length); + await writeFile(join(link_dir, "package.json"), JSON.stringify({ name: link_name, version: "0.0.1" })); + const registered = await run(link_dir, "link"); + expect(registered.err).toBe(""); + expect(registered.out).toContain(`Success! Registered "${link_name}"`); + expect(registered.exitCode).toBe(0); + + try { + const target = Buffer.alloc(LONG_SPEC_BYTES, "x/../").toString() + link_name; + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { [link_name]: `link:${target}` } }), + ); + + const { out, err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain(`ENAMETOOLONG: link path for package ${link_name} is too long`); + expect(out).toContain("Failed to install 1 package"); + expect(await file(join(package_dir, "node_modules", link_name, "package.json")).exists()).toBe(false); + expect(exitCode).toBe(1); + + // Like the other per-package failures, the error respects --silent. + expect(await run(package_dir, "install", "--silent")).toEqual({ out: "", err: "", exitCode: 1 }); + } finally { + await run(link_dir, "unlink"); + } + }); + + it("still resolves a specifier that normalizes to a linked package", async () => { + const link_name = basename(link_dir).slice("bun-link.".length); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { [link_name]: "link:" + Buffer.alloc(LONG_SPEC_BYTES, "x/../").toString() + link_name }, + }), + ); + + // Nothing is registered under this name: resolution gets as far as looking + // the name up in the link directory, like `link:${link_name}` would. + const { err, exitCode } = await run(package_dir, "install"); + + expect(err).toContain(`error: Package "${link_name}" is not linked`); + expect(err).not.toContain("ENAMETOOLONG"); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 562e1ded1f3e..8b7b3758d672 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -591,6 +591,7 @@ describe("workspace aliases", async () => { const err = await stderr.text(); if (version === "workspace:@org/b") { expect(err).toContain('Workspace dependency "a1" not found'); + expect(err).toMatch(/Searched in "\.[\\/]packages[\\/]pkg1[\\/]@org[\\/]b"/); } else { expect(err).toContain(`No matching version for workspace dependency "a1". Version: "${version}"`); } From aae3b8589f608feb01599bf14ce7871e368a1662 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:40 +0000 Subject: [PATCH 029/258] cli: stop aborting on --cwd and --tsconfig-override values longer than the path join buffer (#38368) --- src/paths/resolve_path.rs | 97 +++++++++++++++++++++++--- src/runtime/cli/Arguments.rs | 26 ++++--- test/cli/install/bun-run.test.ts | 52 +++++++++++++- test/cli/run/tsconfig-override.test.ts | 29 +++++++- 4 files changed, 179 insertions(+), 25 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 6cd439d8eea6..2720213d2014 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -12,11 +12,15 @@ use bun_core::{ZStr, strings}; // SAFETY invariant: each buffer has at most one live mutable borrow per thread; // callers must not re-enter the accessor while a previous borrow is alive. thread_local! { - static PARSER_JOIN_INPUT_BUFFER: UnsafeCell<[u8; 4096]> = const { UnsafeCell::new([0u8; 4096]) }; + static PARSER_JOIN_INPUT_BUFFER: UnsafeCell<[u8; PARSER_JOIN_INPUT_BUFFER_LEN]> = + const { UnsafeCell::new([0u8; PARSER_JOIN_INPUT_BUFFER_LEN]) }; static PARSER_BUFFER: UnsafeCell<[u8; PARSER_BUFFER_LEN]> = const { UnsafeCell::new([0u8; PARSER_BUFFER_LEN]) }; } +/// Output capacity of [`join_abs_string`] / [`join_abs_string_z`]. +const PARSER_JOIN_INPUT_BUFFER_LEN: usize = 4096; + /// Output capacity of [`normalize_string`]. const PARSER_BUFFER_LEN: usize = 1024; @@ -1379,6 +1383,24 @@ pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) } +/// [`join_abs_string`] (thread-local buffer) when the result fits, otherwise +/// into `spill` (grown as needed). `spill` is untouched in the common case. +pub fn join_abs_string_spill<'a, P: PlatformT>( + cwd: &'a [u8], + spill: &'a mut Vec, + parts: &[&[u8]], +) -> &'a [u8] { + debug_assert!(!matches!(P::P, Platform::Nt)); + let needed = join_abs_needed(cwd.len(), parts); + if needed <= PARSER_JOIN_INPUT_BUFFER_LEN { + return join_abs_string::

(cwd, parts); + } + if spill.len() < needed { + spill.resize(needed, 0); + } + join_abs_string_buf::

(cwd, &mut spill[..], parts) +} + /// Convert parts of potentially invalid file paths into a single valid filpeath /// without querying the filesystem /// This is the equivalent of path.resolve @@ -1591,6 +1613,14 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& normalize_string_node_t::(&temp_buf[0..written], buf) } +/// Buffer length that holds `_join_abs_string_buf`'s concatenation of `cwd` and +/// `parts` (one separator each, plus the one a bare Windows root gains) as well +/// as its normalized output. +#[inline] +fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { + parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 +} + /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. /// Draws from the /// thread-local `path_buffer_pool` for the common case and only heap-allocates @@ -1604,10 +1634,7 @@ enum JoinScratch { impl JoinScratch { #[inline] fn init(base: usize, parts: &[&[u8]]) -> Self { - let mut total = base + 2; - for p in parts { - total += p.len() + 1; - } + let total = join_abs_needed(base, parts); if total <= MAX_PATH_BYTES { JoinScratch::Pooled(crate::path_buffer_pool::get()) } else { @@ -1645,10 +1672,7 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>( debug_assert!(!matches!(P::P, Platform::Nt)); // Fast path: size check only — don't allocate a JoinScratch here since the // inner join_abs_string_buf already has its own (avoids doubling stack usage). - let mut total: usize = cwd.len() + 2; - for p in parts { - total += p.len() + 1; - } + let total = join_abs_needed(cwd.len(), parts); if total < buf.len() { return Some(join_abs_string_buf::

(cwd, buf, parts)); } @@ -2542,6 +2566,61 @@ mod tests { ); } + #[test] + fn join_abs_string_spill_leaves_spill_untouched_when_the_result_fits() { + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[b"a/../b.json"]); + assert_eq!(out, b"/work/b.json"); + assert!(spill.is_empty()); + } + + #[test] + fn join_abs_string_spill_spills_a_part_longer_than_the_thread_local_buffer() { + let name = vec![b'a'; PARSER_JOIN_INPUT_BUFFER_LEN + 1]; + let mut expected = b"/work/".to_vec(); + expected.extend_from_slice(&name); + + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[&name]); + assert_eq!(out, &expected[..]); + assert!(!spill.is_empty()); + } + + #[test] + fn join_abs_string_spill_spills_an_absolute_part_and_a_long_cwd_alike() { + let mut abs = b"/".to_vec(); + abs.resize(PARSER_JOIN_INPUT_BUFFER_LEN * 2, b'a'); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(b"/", &mut spill, &[&abs]), + &abs[..] + ); + + let mut cwd = b"/".to_vec(); + cwd.resize(PARSER_JOIN_INPUT_BUFFER_LEN, b'c'); + let mut expected = cwd.clone(); + expected.extend_from_slice(b"/x"); + let mut spill = Vec::new(); + assert_eq!( + join_abs_string_spill::(&cwd, &mut spill, &[b"./x"]), + &expected[..] + ); + } + + #[test] + fn join_abs_string_spill_normalizes_a_long_part_that_collapses() { + // `sub/../` repeated past the buffer size resolves back to the cwd. + let mut part = Vec::new(); + while part.len() <= PARSER_JOIN_INPUT_BUFFER_LEN { + part.extend_from_slice(b"sub/../"); + } + part.extend_from_slice(b"sub"); + + let mut spill = Vec::new(); + let out = join_abs_string_spill::(b"/work", &mut spill, &[&part]); + assert_eq!(out, b"/work/sub"); + } + #[test] fn normalize_string_spill_accounts_for_outputs_that_grow_by_one_byte() { // A bare UNC volume exactly as long as the thread-local buffer diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index ab0fc39e9b37..d95d08a9fe88 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -836,9 +836,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result(base, cwd_arg); - // `chdir` wants a NUL-terminated path; `join_abs` returns a borrowed - // slice into a threadlocal buffer, so dupe-Z once and reuse for both + let mut spill = Vec::new(); + let out = + resolve_path::join_abs_string_spill::(base, &mut spill, &[cwd_arg]); + // `chdir` wants a NUL-terminated path, so dupe-Z once and reuse for both // the `chdir` arg and the stored `absolute_working_dir`. let out_z = bun_core::ZBox::from_bytes(out); if let bun_sys::Result::Err(err) = bun_sys::chdir(&out_z) { @@ -963,17 +964,14 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result( - ctx.args.absolute_working_dir.as_deref().unwrap(), - &[ts], - ) - .into(), - ) - } else { - None - }; + opts.tsconfig_override = args.option(b"--tsconfig-override").map(|ts| { + let mut spill = Vec::new(); + Box::from(resolve_path::join_abs_string_spill::( + ctx.args.absolute_working_dir.as_deref().unwrap(), + &mut spill, + &[ts], + )) + }); opts.main_fields = slice_to_owned(args.options(b"--main-fields")); // we never actually supported inject. diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 2dac7325e97c..9ea1f91edb2c 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -2,7 +2,7 @@ import { $ } from "bun"; import { describe, expect, it } from "bun:test"; import { chmodSync } from "fs"; import { bunEnv as bunEnv_, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; -import { join } from "path"; +import { basename, join } from "path"; const bunEnv = { ...bunEnv_, @@ -329,6 +329,56 @@ describe.concurrent("bun run", () => { expect(exitCode).toBe(0); }); + describe("--cwd longer than the OS path limit", () => { + // Longer than PATH_MAX on every platform (4096 on Linux, 1024 on macOS). + const tooLong = Buffer.alloc(5000, "a").toString(); + + for (const [kind, cwdArg] of [ + ["absolute", "/" + tooLong], + ["relative", tooLong], + ] as const) { + it(`${kind} value is reported as an error instead of crashing`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cwd", cwdArg, "-e", "console.log('ran')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(`Could not change directory to "${cwdArg}"`); + // Windows leaves the verdict on a path this long to SetCurrentDirectoryW. + if (!isWindows) expect(stderr).toContain("ENAMETOOLONG"); + expect(stdout).toBe(""); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); + }); + } + + it("value that only normalizes down to a path that fits is honored", async () => { + using dir = tempDir("bun-run-cwd-normalize", { + "subdir/.keep": "", + }); + // 6006 bytes before normalization, "subdir" after it. + const cwdArg = "subdir" + Buffer.alloc(6000, "/../subdir").toString(); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cwd", cwdArg, "-e", "console.log(process.cwd())"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(basename(stdout.trim())).toBe("subdir"); + expect(exitCode).toBe(0); + }); + }); + it("DCE annotations are respected", async () => { using dir = tempDir("test", { "index.ts": ` diff --git a/test/cli/run/tsconfig-override.test.ts b/test/cli/run/tsconfig-override.test.ts index f5e81aa68fdc..43c7052a8882 100644 --- a/test/cli/run/tsconfig-override.test.ts +++ b/test/cli/run/tsconfig-override.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import path from "node:path"; describe("bun run --tsconfig-override", () => { @@ -302,4 +302,31 @@ describe("bun run --tsconfig-override", () => { } expect(exitCode).toBe(0); }); + + describe.concurrent("path longer than the OS path limit", () => { + // Longer than PATH_MAX on every platform (4096 on Linux, 1024 on macOS). + const tooLong = Buffer.alloc(5000, "a").toString(); + + for (const [kind, tsconfigArg] of [ + ["absolute", "/" + tooLong], + ["relative", tooLong], + ] as const) { + test(`${kind} path is reported as unreadable instead of crashing`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--tsconfig-override", tsconfigArg, "-e", "console.log('ran')"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Windows leaves the verdict on a path this long to the file system. + if (!isWindows) expect(stderr).toContain(`Cannot read file "${path.resolve(tsconfigArg)}": ENAMETOOLONG`); + expect(stdout).toBe("ran\n"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); + }); + } + }); }); From 8ecb2925e7ff62ff8b6fb4823f067c5b9cbcf387 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:45 +0000 Subject: [PATCH 030/258] install: stop panicking when the cache directory setting does not fit the path buffer (#38390) --- .../PackageManagerDirectories.rs | 78 +++++----- src/runtime/cli/package_manager_command.rs | 7 +- test/cli/install/bun-pm.test.ts | 133 +++++++++++++++++- 3 files changed, 171 insertions(+), 47 deletions(-) diff --git a/src/install/PackageManager/PackageManagerDirectories.rs b/src/install/PackageManager/PackageManagerDirectories.rs index 8b4c09be09ca..96f5a5eb4a57 100644 --- a/src/install/PackageManager/PackageManagerDirectories.rs +++ b/src/install/PackageManager/PackageManagerDirectories.rs @@ -332,11 +332,14 @@ unsafe fn ensure_cache_directory(this: *mut PackageManager) -> Dir { // encapsulates the BackRef deref + singleton-liveness invariant. let env = unsafe { &*this }.env_mut(); // SAFETY: shared read of `options`; disjoint from `cache_directory_path`. - let cache_dir = fetch_cache_directory_path(env, Some(unsafe { &(*this).options })); - // SAFETY: see fn safety contract. - unsafe { (*this).cache_directory_path = ZBox::from_bytes(&cache_dir.path) }; + let opened = fetch_cache_directory_path(env, Some(unsafe { &(*this).options })) + .and_then(|cache_dir| { + // SAFETY: see fn safety contract. + unsafe { (*this).cache_directory_path = ZBox::from_bytes(&cache_dir.path) }; + Dir::cwd().make_open_path(&cache_dir.path, Default::default()) + }); - match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { + match opened { Ok(d) => return d, Err(_) => { // SAFETY: narrow `&mut enable` projection; disjoint from @@ -375,46 +378,35 @@ pub struct CacheDir { pub path: Vec, } -pub fn fetch_cache_directory_path(env: &mut DotEnvLoader, options: Option<&Options>) -> CacheDir { - if let Some(dir) = env.get(b"BUN_INSTALL_CACHE_DIR") { - return CacheDir { - path: FileSystem::instance().abs(&[dir]).to_vec(), - }; - } - - if let Some(opts) = options { - if !opts.cache_directory.is_empty() { - return CacheDir { - path: FileSystem::instance().abs(&[opts.cache_directory]).to_vec(), - }; - } - } - - if let Some(dir) = env.get(b"BUN_INSTALL") { - let parts: [&[u8]; 3] = [dir, b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } - - if let Some(dir) = env_var::XDG_CACHE_HOME.get() { - let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } - - if let Some(dir) = env_var::HOME.get() { - let parts: [&[u8]; 4] = [dir, b".bun/", b"install/", b"cache/"]; - return CacheDir { - path: FileSystem::instance().abs(&parts).to_vec(), - }; - } +/// Fails with `ENAMETOOLONG` when the configured directory (user-controlled, so +/// possibly longer than any path the OS accepts) does not fit a `PathBuffer`. +pub fn fetch_cache_directory_path( + env: &mut DotEnvLoader, + options: Option<&Options>, +) -> sys::Maybe { + let parts: &[&[u8]] = if let Some(dir) = env.get(b"BUN_INSTALL_CACHE_DIR") { + &[dir] + } else if let Some(dir) = options + .map(|opts| opts.cache_directory) + .filter(|dir| !dir.is_empty()) + { + &[dir] + } else if let Some(dir) = env.get(b"BUN_INSTALL") { + &[dir, b"install/", b"cache/"] + } else if let Some(dir) = env_var::XDG_CACHE_HOME + .get() + .or_else(|| env_var::HOME.get()) + { + &[dir, b".bun/", b"install/", b"cache/"] + } else { + &[b"node_modules/.bun-cache"] + }; - let fallback_parts: [&[u8]; 1] = [b"node_modules/.bun-cache"]; - CacheDir { - path: FileSystem::instance().abs(&fallback_parts).to_vec(), - } + let mut buf = path::path_buffer_pool::get(); + let Some(abs) = FileSystem::instance().abs_buf_checked(parts, &mut buf[..]) else { + return Err(sys::Error::from_code(sys::E::ENAMETOOLONG, sys::Tag::open).with_path(parts[0])); + }; + Ok(CacheDir { path: abs.to_vec() }) } // ─────────────────────── cached folder name printers ────────────────────────── diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 62b21f210e1b..3518637edec7 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -419,9 +419,12 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let mut process_env = bun_dotenv::Loader::init(); process_env.load_process()?; - let cache_dir = fetch_cache_directory_path(&mut process_env, None); + let opened = + fetch_cache_directory_path(&mut process_env, None).and_then(|cache_dir| { + Dir::cwd().make_open_path(&cache_dir.path, Default::default()) + }); let mut rm_buf = PathBuffer::uninit(); - let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { + let rm_dir = match opened { Ok(d) => d, Err(err) => { bun_core::pretty_errorln!( diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index d0c54586c249..024dfdc020be 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; -import { afterAll, afterEach, beforeAll, beforeEach, expect, it, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test } from "bun:test"; import { exists, mkdir, writeFile } from "fs/promises"; -import { bunEnv, bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunEnv as env, isWindows, readdirSorted, tempDir, tmpdirSync } from "harness"; import { cpSync } from "node:fs"; import { join } from "path"; import { @@ -936,3 +936,132 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +// The cache directory setting (the first one set of $BUN_INSTALL_CACHE_DIR, --cache-dir, +// $BUN_INSTALL/install/cache, $XDG_CACHE_HOME/.bun/install/cache, $HOME/.bun/install/cache) is +// joined into a path buffer of MAX_PATH_BYTES: 4096 bytes on Linux, 1024 on macOS. A value that +// does not fit is unusable in the same way as a directory the OS refuses to create, and bun falls +// back to node_modules/.cache of the project like it does for those. On Windows the buffer holds +// more than an environment variable or argument can carry. +describe.concurrent.skipIf(isWindows)("cache directory setting longer than the path buffer", () => { + // Longer than the buffer on every POSIX platform (the Linux one is the largest). + const tooLong = "/" + Buffer.alloc(2 * 4096, "a").toString(); + + const project = { + "package.json": JSON.stringify({ name: "pm-cache-too-long", version: "1.0.0" }), + // $XDG_CONFIG_HOME stands in for $HOME when bun looks for .npmrc and .bunfig.toml, so the + // oversized $HOME below is only ever used as a cache directory candidate. + "config/.npmrc": "", + }; + + // Every lower precedence setting points at a usable directory: the fallback must be + // node_modules/.cache, not the next candidate. + function cacheEnv(dir: string, setting: Record): NodeJS.Dict { + const spawnEnv: NodeJS.Dict = { + ...env, + HOME: join(dir, "home"), + XDG_CONFIG_HOME: join(dir, "config"), + }; + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + delete spawnEnv.BUN_INSTALL; + delete spawnEnv.XDG_CACHE_HOME; + return { ...spawnEnv, ...setting }; + } + + async function runPm(dir: string, args: string[], setting: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", ...args], + cwd: dir, + env: cacheEnv(dir, setting), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.each(["BUN_INSTALL_CACHE_DIR", "BUN_INSTALL", "XDG_CACHE_HOME", "HOME"])( + "bun pm cache falls back to node_modules/.cache when $%s does not fit", + async name => { + using dir = tempDir("pm-cache-too-long", project); + + const { stdout, stderr, exitCode } = await runPm(String(dir), ["cache"], { [name]: tooLong }); + + expect(stderr).toBe(""); + expect(stdout).toBe(join(String(dir), "node_modules", ".cache")); + expect(exitCode).toBe(0); + }, + ); + + test("bun pm cache falls back to node_modules/.cache when --cache-dir does not fit", async () => { + using dir = tempDir("pm-cache-too-long-flag", project); + + const { stdout, stderr, exitCode } = await runPm(String(dir), ["--cache-dir", tooLong, "cache"], {}); + + expect(stderr).toBe(""); + expect(stdout).toBe(join(String(dir), "node_modules", ".cache")); + expect(exitCode).toBe(0); + }); + + // A directory that fits the buffer but that the OS rejects (one name longer than NAME_MAX) has + // always been handled this way; the oversized values above take the same route. + test("bun pm cache falls back to node_modules/.cache when the OS rejects the directory", async () => { + using dir = tempDir("pm-cache-os-rejects", project); + const tooLongForTheOS = join(String(dir), Buffer.alloc(300, "a").toString()); + + const { stdout, stderr, exitCode } = await runPm(String(dir), ["cache"], { + BUN_INSTALL_CACHE_DIR: tooLongForTheOS, + }); + + expect(stderr).toBe(""); + expect(stdout).toBe(join(String(dir), "node_modules", ".cache")); + expect(exitCode).toBe(0); + }); + + // What has to fit the buffer is the normalized directory, not the value as written. + test("bun pm cache uses a directory that only fits the path buffer once normalized", async () => { + using dir = tempDir("pm-cache-long-normalized", project); + const cacheDir = `${String(dir)}/${Buffer.alloc("x/../".length * 2000, "x/../").toString()}cache`; + + const { stdout, stderr, exitCode } = await runPm(String(dir), ["cache"], { BUN_INSTALL_CACHE_DIR: cacheDir }); + + expect(stderr).toBe(""); + expect(stdout).toBe(join(String(dir), "cache")); + expect(exitCode).toBe(0); + }); + + // `bun pm cache rm` has no fallback: it reports the directory it could not open. + test("bun pm cache rm reports ENAMETOOLONG when $BUN_INSTALL_CACHE_DIR does not fit", async () => { + using dir = tempDir("pm-cache-rm-too-long", project); + + const { stdout, stderr, exitCode } = await runPm(String(dir), ["cache", "rm"], { BUN_INSTALL_CACHE_DIR: tooLong }); + + expect(stderr).toContain("ENAMETOOLONG getting cache directory"); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + expect(await exists(join(String(dir), "node_modules"))).toBeFalse(); + }); + + test("bun install falls back to node_modules/.cache when $BUN_INSTALL_CACHE_DIR does not fit", async () => { + using dir = tempDir("pm-cache-too-long-install", { + ...project, + "package.json": JSON.stringify({ name: "pm-cache-too-long", dependencies: { moo: "./moo" } }), + "moo/package.json": JSON.stringify({ name: "moo", version: "0.1.0" }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: cacheEnv(String(dir), { BUN_INSTALL_CACHE_DIR: tooLong }), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error"); + expect(stdout).toContain("1 package installed"); + expect(exitCode).toBe(0); + expect(await exists(join(String(dir), "node_modules", "moo", "package.json"))).toBeTrue(); + expect(await exists(join(String(dir), "node_modules", ".cache"))).toBeTrue(); + }); +}); From c3c6e24a60ece6d407973fe705e4bc124d112716 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:49 +0000 Subject: [PATCH 031/258] install: stop panicking when a progress bar name is longer than its buffer (#38558) --- src/bun_core/Progress.rs | 5 +- src/bun_core/string/immutable.rs | 28 ++++++ src/install/PackageManager/ProgressStrings.rs | 29 ++++--- test/cli/install/bun-install.test.ts | 85 +++++++++++++++++++ 4 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/bun_core/Progress.rs b/src/bun_core/Progress.rs index a80dbf1ea1b7..44588bf6af5e 100644 --- a/src/bun_core/Progress.rs +++ b/src/bun_core/Progress.rs @@ -194,10 +194,7 @@ pub enum Unit { pub struct Node { pub(crate) context: *mut Progress, pub(crate) parent: *mut Node, - // The non-allocating design means `Node` cannot own the bytes. `'static` - // is the chosen simplification because all current callers (install/, - // cli/) pass string literals; the alternative would be threading a - // lifetime through `Node`/`Progress`. + // Dynamic names (install, create) point at caller-owned scratch buffers that outlive the node. pub name: &'static [u8], pub unit: Unit, /// Must be handled atomically to be thread-safe. diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index c3df7ef4c337..9e71c874881c 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -974,6 +974,18 @@ pub fn is_utf8_char_boundary(c: u8) -> bool { (c as i8) >= -0x40 } +/// Longest prefix of `self_` within `max_len` bytes that does not split a UTF-8 sequence. +pub fn truncate_to_char_boundary(self_: &[u8], max_len: usize) -> &[u8] { + if self_.len() <= max_len { + return self_; + } + let mut end = max_len; + while !is_on_char_boundary(self_, end) { + end -= 1; + } + &self_[..end] +} + pub fn starts_with_case_insensitive_ascii(self_: &[u8], prefix: &[u8]) -> bool { self_.len() >= prefix.len() && eql_case_insensitive_ascii(&self_[0..prefix.len()], prefix, false) @@ -2721,6 +2733,22 @@ mod tests { assert!(!super::eql_case_insensitive_ascii(b"Ab", b"a", true)); } + #[test] + fn truncate_to_char_boundary_never_splits_a_sequence() { + assert_eq!(super::truncate_to_char_boundary(b"abc", 3), b"abc"); + assert_eq!(super::truncate_to_char_boundary(b"abc", 4), b"abc"); + assert_eq!(super::truncate_to_char_boundary(b"abcd", 3), b"abc"); + assert_eq!(super::truncate_to_char_boundary(b"abc", 0), b""); + // "aé" is `61 C3 A9`: a cut at byte 2 would land inside `é`. + assert_eq!(super::truncate_to_char_boundary("aéz".as_bytes(), 2), b"a"); + assert_eq!( + super::truncate_to_char_boundary("aéz".as_bytes(), 3), + "aé".as_bytes() + ); + // A 4-byte sequence that does not fit at all yields the empty prefix. + assert_eq!(super::truncate_to_char_boundary("😀".as_bytes(), 3), b""); + } + #[test] fn convert_utf8_to_utf16_in_buffer_fallback_rejects_malformed_sequences() { let mut buf = [0u16; 16]; diff --git a/src/install/PackageManager/ProgressStrings.rs b/src/install/PackageManager/ProgressStrings.rs index 5ba05d1890fa..d1f5825991ac 100644 --- a/src/install/PackageManager/ProgressStrings.rs +++ b/src/install/PackageManager/ProgressStrings.rs @@ -1,6 +1,6 @@ use core::sync::atomic::Ordering; -use bun_core::Output; +use bun_core::{Output, strings}; use const_format::concatcp; use crate::bun_progress::Node as ProgressNode; @@ -94,24 +94,25 @@ impl PackageManager { name: &[u8], emoji: &[u8], ) { + let emoji_len = if Output::enable_ansi_colors_stderr() { + if IS_FIRST { + self.progress_name_buf[..emoji.len()].copy_from_slice(emoji); + } + emoji.len() + } else { + 0 + }; + // Display-only, so a name longer than the buffer is simply cut short. + let name = + strings::truncate_to_char_boundary(name, self.progress_name_buf.len() - emoji_len); + self.progress_name_buf[emoji_len..][..name.len()].copy_from_slice(name); + let len = emoji_len + name.len(); // SAFETY: `node` is `self.downloads_node` / `self.scripts_node`, both of // which point at storage owned by (or outliving) this `PackageManager` // singleton; `progress_name_buf` is an inline field of that same // singleton, so the buffer outlives every node that references it and // erasing the slice lifetime to `'static` is sound. - unsafe { - let len = if Output::enable_ansi_colors_stderr() { - if IS_FIRST { - self.progress_name_buf[..emoji.len()].copy_from_slice(emoji); - } - self.progress_name_buf[emoji.len()..][..name.len()].copy_from_slice(name); - emoji.len() + name.len() - } else { - self.progress_name_buf[..name.len()].copy_from_slice(name); - name.len() - }; - node.name = bun_ptr::detach_lifetime(&self.progress_name_buf[..len]); - } + node.name = unsafe { bun_ptr::detach_lifetime(&self.progress_name_buf[..len]) }; } pub fn start_progress_bar_if_none(&mut self) { diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 81b92513c577..663dcfbe6f8e 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -11505,3 +11505,88 @@ it.each([ expect(exitCode).not.toBe(0); }); }); + +// The progress bar copies the name it is currently showing into a fixed +// 768-byte buffer (PackageManager.progress_name_buf). Dependency and package +// names have no length limit, so a longer name used to abort the install with +// "panic: range end index 900 out of range for slice of length 768" whenever +// the progress bar was on. BUN_INSTALL_PROGRESS=1 turns it on without a TTY; +// with colors enabled an emoji shares the buffer with the name, so both color +// settings are covered. +describe("progress bar with a name longer than its name buffer", () => { + const longName = Buffer.alloc(900, "a").toString(); + const progressEnvs = [ + ["NO_COLOR", { ...env, BUN_INSTALL_PROGRESS: "1", NO_COLOR: "1" }], + ["FORCE_COLOR", { ...env, BUN_INSTALL_PROGRESS: "1", NO_COLOR: undefined, FORCE_COLOR: "1" }], + ] as const; + + it.each(progressEnvs)("dependency whose manifest is fetched (%s)", async (_colors, progressEnv) => { + await withContext(defaultOpts, async ctx => { + const requests: string[] = []; + setContextHandler(ctx, request => { + requests.push(request.url); + return new Response("Not Found", { status: 404 }); + }); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + [longName]: "1.0.0", + }, + }), + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: ctx.package_dir, + stdout: "pipe", + stderr: "pipe", + env: progressEnv, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(requests).toEqual([`${ctx.registry_url}${longName}`]); + expect(Bun.stripANSI(err)).toContain(`error: GET ${ctx.registry_url}${longName} - 404`); + expect(Bun.stripANSI(err)).toContain(`error: ${longName}@1.0.0 failed to resolve`); + expect(out).not.toContain("1 package installed"); + expect(exitCode).toBe(1); + }); + }); + + it.each(progressEnvs)("workspace package running a lifecycle script (%s)", async (_colors, progressEnv) => { + using dir = tempDir("install-progress-long-name", { + "package.json": JSON.stringify({ + name: "foo", + workspaces: ["packages/*"], + }), + "packages/long/package.json": JSON.stringify({ + name: longName, + version: "1.0.0", + scripts: { + postinstall: "echo ran > postinstall-ran", + }, + }), + }); + + // Nothing depends on the workspace package, so the isolated linker never + // creates a node_modules entry with this name: the progress bar is the + // only thing that has to cope with it. + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--linker", "isolated"], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env: progressEnv, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderr: Bun.stripANSI(err), exitCode }).toEqual({ + stderr: expect.not.stringContaining("error:"), + exitCode: 0, + }); + expect(Bun.stripANSI(out)).toContain("Checked 2 packages"); + expect(await file(join(String(dir), "packages", "long", "postinstall-ran")).text()).toBe("ran\n"); + }); +}); From 2f450e5427153b2a18a37b9145373f662c118edb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:53 +0000 Subject: [PATCH 032/258] install: stop aborting on linked package names that do not fit the symlink buffers (#38571) --- src/install/PackageInstall.rs | 33 +++++++++++++---------- test/cli/install/bun-workspaces.test.ts | 36 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 0e890e476c6c..121b706599bf 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -2094,7 +2094,11 @@ impl<'a> PackageInstall<'a> { } } }; - let dest = bun_paths::basename(dest_path.as_bytes()); + // The entry name is the NUL-terminated tail of `dest_path`. + let dest: &ZStr = ZStr::from_slice_with_nul( + &dest_path.as_bytes_with_nul()[subdir.map_or(0, |dir| dir.len() + 1)..], + ); + debug_assert_eq!(dest.as_bytes(), bun_paths::basename(dest_path.as_bytes())); // When we're linking on Windows, we want to avoid keeping the source directory handle open #[cfg(windows)] { @@ -2145,7 +2149,14 @@ impl<'a> PackageInstall<'a> { dest_buf[offset] = bun_paths::SEP_WINDOWS; offset += 1; } - dest_buf[offset..offset + dest.len()].copy_from_slice(dest); + if offset + dest.len() >= dest_buf.len() { + return InstallResult::fail( + crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG), + Step::LinkingDependency, + None, + ); + } + dest_buf[offset..offset + dest.len()].copy_from_slice(dest.as_bytes()); offset += dest.len(); dest_buf[offset] = 0; @@ -2204,19 +2215,13 @@ impl<'a> PackageInstall<'a> { Err(err) => return InstallResult::fail(err.into(), Step::LinkingDependency, None), }; - let target = path::resolve_path::relative(dest_dir_path, to_path); - // `symlinkat` takes `&ZStr` for both target and dest; build NUL-terminated - // copies in stack buffers. let mut target_buf = PathBuffer::uninit(); - target_buf[..target.len()].copy_from_slice(target); - target_buf[target.len()] = 0; - // SAFETY: NUL written above. - let target_z = ZStr::from_buf(&target_buf, target.len()); - let mut dest_name_buf = [0u8; 512]; - dest_name_buf[..dest.len()].copy_from_slice(dest); - // SAFETY: zero-initialized; NUL at [dest.len()]. - let dest_z = ZStr::from_buf(&dest_name_buf, dest.len()); - if let Err(err) = sys::symlinkat(target_z, dest_dir.fd(), dest_z) { + let target = path::resolve_path::relative_buf_z( + target_buf.as_mut_slice(), + dest_dir_path, + to_path, + ); + if let Err(err) = sys::symlinkat(target, dest_dir.fd(), dest) { return InstallResult::fail(err.into(), Step::LinkingDependency, None); } } diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 8b7b3758d672..a5af15e6fd02 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -7,6 +7,7 @@ import { assertManifestsPopulated, bunEnv as baseEnv, bunExe, + isWindows, readdirSorted, runBunInstall, toMatchNodeModulesAt, @@ -2974,3 +2975,38 @@ describe("packages whose version label is longer than 512 bytes", () => { ); }); }); + +// The hoisted installer links a workspace package into node_modules under its name. A name +// too long for the buffer that symlink is given used to abort the whole install instead of +// failing that one package with ENAMETOOLONG. On POSIX that buffer held 512 bytes. On +// Windows the name is appended to the absolute node_modules path in a 98302 byte buffer +// (bun refuses names of 98302 bytes and up), so the name has to come within a node_modules +// path (`\\?\C:\x\node_modules\` at the very least) of that size to overflow it. +describe("workspace packages whose name is too long to link", () => { + const longName = Buffer.alloc(isWindows ? 98302 - 20 : 600, "a").toString(); + + // A scoped package is linked inside a separately opened `node_modules/@scope` directory. + test.concurrent.each([ + ["unscoped", longName], + ["scoped", `@scope/${longName}`], + ])("%s name fails with ENAMETOOLONG", async (_, name) => { + using ctx = await setupTest(); + const { packageDir, packageJson } = ctx; + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", workspaces: ["pkgs/*"] })), + write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name, version: "1.0.0" })), + ]); + + await using proc = spawn({ + cmd: [bunExe(), "install", "--linker", "hoisted"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: ctx.env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).toContain(`ENAMETOOLONG: failed linking dependency/workspace to node_modules for package ${name}`); + expect(out).toContain("Failed to install 1 package"); + expect(exitCode).toBe(1); + }); +}); From 900279d3f0e1afcf3524b5d14efbb05359c04258 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:05:57 +0000 Subject: [PATCH 033/258] install: fail verification instead of panicking when /package.json does not fit the path buffer (#38575) --- src/install/PackageInstall.rs | 96 ++++++++++++++++------------ test/cli/install/bun-install.test.ts | 52 +++++++++++++++ 2 files changed, 108 insertions(+), 40 deletions(-) diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 121b706599bf..0b6db8483b9c 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -5,7 +5,7 @@ use bun_core::Progress::Progress; use bun_core::{Global, Output}; use bun_core::{MutableString, ZStr}; use bun_paths::strings; -use bun_paths::{self as path, OSPathChar, OSPathSlice, PathBuffer, SEP, SEP_STR}; +use bun_paths::{self as path, OSPathChar, OSPathSlice, PathBuffer, SEP}; use bun_semver::String as SemverString; #[cfg(not(windows))] use bun_sys::OpenDirOptions; @@ -740,6 +740,48 @@ impl UninstallTask { } } +/// `/`, written in place after the alias in +/// `destination_dir_subpath_buf`. Dropping it restores the alias's NUL terminator. +struct DestinationSubpath<'b> { + buf: &'b mut [u8], + alias_len: usize, + len: usize, +} + +impl<'b> DestinationSubpath<'b> { + /// `None` when the path and its NUL terminator do not fit `buf`: the alias is + /// only required to fit the buffer by itself (`alias_is_safe_install_target`). + fn new(buf: &'b mut [u8], alias_len: usize, name: &[u8]) -> Option { + let name_start = alias_len + 1; + let len = name_start + name.len(); + if len >= buf.len() { + return None; + } + buf[alias_len] = SEP; + buf[name_start..len].copy_from_slice(name); + buf[len] = 0; + Some(Self { + buf, + alias_len, + len, + }) + } +} + +impl core::ops::Deref for DestinationSubpath<'_> { + type Target = ZStr; + + fn deref(&self) -> &ZStr { + ZStr::from_buf(self.buf, self.len) + } +} + +impl Drop for DestinationSubpath<'_> { + fn drop(&mut self) { + self.buf[self.alias_len] = 0; + } +} + // ───────────────────────────── impl PackageInstall ───────────────────────────── impl<'a> PackageInstall<'a> { @@ -778,28 +820,17 @@ impl<'a> PackageInstall<'a> { // 1. verify that .bun-tag exists (was it installed from bun?) // 2. check .bun-tag against the resolved version fn verify_git_resolution(&mut self, repo: &Repository, root_node_modules_dir: &Dir) -> bool { - let dest_len = self.destination_dir_subpath.len(); - let suffix: &[u8] = &[SEP, b'.', b'b', b'u', b'n', b'-', b't', b'a', b'g']; - // Reshaped for borrowck — write into buf via raw indices. - self.destination_dir_subpath_buf[dest_len..dest_len + suffix.len()].copy_from_slice(suffix); - self.destination_dir_subpath_buf[dest_len + SEP_STR.len() + b".bun-tag".len()] = 0; - // SAFETY: NUL written above. - let bun_tag_path = unsafe { - ZStr::from_raw_mut( - self.destination_dir_subpath_buf.as_mut_ptr(), - dest_len + SEP_STR.len() + b".bun-tag".len(), - ) + let Some(bun_tag_path) = DestinationSubpath::new( + self.destination_dir_subpath_buf, + self.destination_dir_subpath.len(), + b".bun-tag", + ) else { + return false; }; - let _restore = scopeguard::guard( - self.destination_dir_subpath_buf.as_mut_ptr(), - // SAFETY: p points into destination_dir_subpath_buf which outlives this scope; - // dest_len < buf capacity (was the prior NUL position). - move |p| unsafe { *p.add(dest_len) = 0 }, - ); let Ok(bun_tag_file) = self .node_modules - .read_small_file(root_node_modules_dir, bun_tag_path) + .read_small_file(root_node_modules_dir, &bun_tag_path) else { return false; }; @@ -859,30 +890,15 @@ impl<'a> PackageInstall<'a> { mutable.reset(); mutable.expand_to_capacity(); - let dest_len = self.destination_dir_subpath.len(); - // Write the literal directly into the path buffer; no intermediate Vec. - let suffix: &[u8] = &[ - SEP, b'p', b'a', b'c', b'k', b'a', b'g', b'e', b'.', b'j', b's', b'o', b'n', - ]; - self.destination_dir_subpath_buf[dest_len..dest_len + suffix.len()].copy_from_slice(suffix); - self.destination_dir_subpath_buf[dest_len + SEP_STR.len() + b"package.json".len()] = 0; - // SAFETY: NUL written above. - let package_json_path = unsafe { - ZStr::from_raw_mut( - self.destination_dir_subpath_buf.as_mut_ptr(), - dest_len + SEP_STR.len() + b"package.json".len(), - ) - }; - let _restore = scopeguard::guard( - self.destination_dir_subpath_buf.as_mut_ptr(), - // SAFETY: p points into destination_dir_subpath_buf which outlives this scope; - // dest_len < buf capacity (was the prior NUL position). - move |p| unsafe { *p.add(dest_len) = 0 }, - ); + let package_json_path = DestinationSubpath::new( + self.destination_dir_subpath_buf, + self.destination_dir_subpath.len(), + b"package.json", + )?; let package_json_file = self .node_modules - .open_file(root_node_modules_dir, package_json_path) + .open_file(root_node_modules_dir, &package_json_path) .ok()?; // defer package_json_file.close() diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 663dcfbe6f8e..5c813fe9de3f 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -899,6 +899,58 @@ describe.concurrent("bun-install", () => { expect(exitCode).toBe(1); }); + // When node_modules already exists, the hoisted installer first checks what is installed at + // node_modules/ by appending "/package.json" (or "/.bun-tag" for git dependencies) to + // the alias inside the path buffer holding it. The alias may be up to one byte short of the + // buffer (4096 bytes on Linux, 1024 on macOS), so aliases a few bytes short of it used to crash + // the install right there instead of failing like any other name the file system rejects. + // On Windows the buffer is far larger than any path the OS accepts. + describe.concurrent.skipIf(isWindows)("dependency alias that fills the path buffer", () => { + const alias = Buffer.alloc((isLinux ? 4096 : 1024) - 6, "a").toString(); + + async function installIntoExistingNodeModules(cwd: string) { + await using proc = spawn({ + // hardlink (the Linux default) creates node_modules/ itself on every platform, so + // the failure is reported the same way on macOS, whose default backend is clonefile. + cmd: [bunExe(), "install", "--linker", "hoisted", "--backend", "hardlink"], + cwd, + env: { ...env, BUN_INSTALL_CACHE_DIR: join(cwd, ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("ENAMETOOLONG: failed opening node_modules/package dir for package pkg"); + expect(stdout).toContain("Failed to install 1 package"); + expect(exitCode).toBe(1); + } + + it("file: dependency is verified through /package.json", async () => { + using dir = tempDir("long-alias-file-dep", { + "package.json": JSON.stringify({ name: "app", dependencies: { [alias]: "file:./pkg" } }), + "pkg/package.json": JSON.stringify({ name: "pkg", version: "1.0.0" }), + "node_modules": {}, + }); + + await installIntoExistingNodeModules(String(dir)); + }); + + it("git dependency is verified through /.bun-tag", async () => { + using dir = tempDir("long-alias-git-dep", { + "work/package.json": JSON.stringify({ name: "pkg", version: "1.0.0" }), + "app/node_modules": {}, + }); + await createDumbHttpGitRepo(String(dir), {}); + using server = serveDirectory(String(dir)); + const app = join(String(dir), "app"); + await writeFile( + join(app, "package.json"), + JSON.stringify({ name: "app", dependencies: { [alias]: `git+http://localhost:${server.port}/repo.git` } }), + ); + + await installIntoExistingNodeModules(app); + }); + }); + it("should handle empty string in dependencies", async () => { await withContext(defaultOpts, async ctx => { const urls: string[] = []; From a4d081c43aa86e25feb6ca0b3b7060285dcea14d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:00 +0000 Subject: [PATCH 034/258] pm ls --all: stop panicking on resolutions longer than 512 bytes (#38616) --- src/runtime/cli/package_manager_command.rs | 29 ++---------- test/cli/install/bun-pm.test.ts | 54 ++++++++++++++++++++++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 3518637edec7..05cfe0a302ca 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -801,7 +801,6 @@ fn print_node_modules_folder_structure( } } - let mut resolution_buf = [0u8; 512]; if let Some(id) = directory_package_id { let mut path: &[u8] = directory.relative_path.as_bytes(); @@ -813,25 +812,15 @@ fn print_node_modules_folder_structure( } } } - let directory_version = buf_print( - &mut resolution_buf, - format_args!( - "{}", - resolutions[id as usize].fmt(string_bytes, PathSep::Auto) - ), - ); + let directory_version = resolutions[id as usize].fmt(string_bytes, PathSep::Auto); if let Some(j) = strings::index_of(path, b"node_modules") { bun_core::prettyln!( "{}@{}", bstr::BStr::new(&path[0..j - 1]), - bstr::BStr::new(directory_version), + directory_version, ); } else { - bun_core::prettyln!( - "{}@{}", - bstr::BStr::new(path), - bstr::BStr::new(directory_version), - ); + bun_core::prettyln!("{}@{}", bstr::BStr::new(path), directory_version); } } else { let mut cwd_buf = PathBuffer::uninit(); @@ -938,18 +927,10 @@ fn print_node_modules_folder_structure( bun_core::pretty!("└── "); } - let mut resolution_buf = [0u8; 512]; - let package_version = buf_print( - &mut resolution_buf, - format_args!( - "{}", - resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto) - ), - ); bun_core::prettyln!( "{}@{}", bstr::BStr::new(package_name), - bstr::BStr::new(package_version), + resolutions[package_id as usize].fmt(string_bytes, PathSep::Auto), ); } @@ -1031,5 +1012,3 @@ fn print_trusted_dependencies_flat( } } } - -use bun_core::fmt::buf_print_infallible as buf_print; diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 024dfdc020be..ebf54aad16ea 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -140,6 +140,60 @@ it("should list all dependencies", async () => { expect(requested).toBe(2); }); +// A tarball package's label is the URL it was installed from, which has no length +// limit. `--all` prints a label in two places: the header of a package that has its own +// nested node_modules (moo, whose bar/baz conflict with the root's), and the line of a +// package that has none (bar). +it("should list all dependencies when a resolution is longer than 512 bytes", async () => { + const urls: string[] = []; + setHandler(dummyRegistry(urls, { "0.0.2": {}, "0.0.3": {}, "0.0.5": {}, latest: "0.0.3" })); + const barUrl = `${root_url}/${Buffer.alloc(600, "a").toString()}/bar-0.0.2.tgz`; + const mooUrl = `${root_url}/${Buffer.alloc(600, "b").toString()}/moo-0.1.0.tgz`; + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + bar: barUrl, + baz: "0.0.5", + // moo-0.1.0.tgz depends on bar@0.0.2 and baz@latest + moo: mooUrl, + }, + }), + ); + { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: package_dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(err).toContain("Saved lockfile"); + expect(exitCode).toBe(0); + } + await using proc = spawn({ + cmd: [bunExe(), "pm", "ls", "--all"], + cwd: package_dir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe(`${package_dir} node_modules +├── bar@${barUrl} +├── baz@0.0.5 +└── moo@${mooUrl} + ├── bar@0.0.2 + └── baz@0.0.3 +`); + expect(exitCode).toBe(0); +}); + it("should list top-level aliased dependency", async () => { const urls: string[] = []; setHandler(dummyRegistry(urls)); From f6055e89f738f14977ba7b42f235720554087804 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 17 Aug 2026 01:06:05 +0000 Subject: [PATCH 035/258] pack/publish: stop panicking on package.json bin and files entries longer than the path buffer (#38784) --- src/paths/resolve_path.rs | 150 ++++++++++++++++++++++++++- src/runtime/cli/pack_command.rs | 27 +++-- src/runtime/cli/publish_command.rs | 26 +++-- test/cli/install/bun-pack.test.ts | 83 +++++++++++++++ test/cli/install/bun-publish.test.ts | 47 +++++++++ 5 files changed, 312 insertions(+), 21 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 2720213d2014..e640015524e1 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1306,6 +1306,40 @@ pub fn normalize_buf_z<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a m unsafe { ZStr::from_raw_mut(buf.as_mut_ptr(), len) } } +/// [`normalize_buf`] into `buf` when the result fits, otherwise into `spill` (grown as needed). +pub fn normalize_buf_spill<'a, P: PlatformT>( + buf: &'a mut [u8], + spill: &'a mut Vec, + str: &[u8], +) -> &'a [u8] { + normalize_buf::

(str, normalize_buf_or_spill(buf, spill, str)) +} + +/// [`normalize_buf_z`] into `buf` when the result fits, otherwise into `spill` (grown as needed). +pub fn normalize_buf_z_spill<'a, P: PlatformT>( + buf: &'a mut [u8], + spill: &'a mut Vec, + str: &[u8], +) -> &'a ZStr { + normalize_buf_z::

(str, normalize_buf_or_spill(buf, spill, str)) +} + +fn normalize_buf_or_spill<'a>( + buf: &'a mut [u8], + spill: &'a mut Vec, + str: &[u8], +) -> &'a mut [u8] { + // Normalizing grows a path by at most one byte (`""` -> `.`, `C:` -> `C:.`), plus the NUL. + let needed = str.len() + 2; + if needed <= buf.len() { + return buf; + } + if spill.len() < needed { + spill.resize(needed, 0); + } + &mut spill[..] +} + pub fn normalize_buf_t<'a, T: PathChar, P: PlatformT>(str: &[T], buf: &'a mut [T]) -> &'a mut [T] { if str.is_empty() { buf[0] = T::from_u8(b'.'); @@ -2479,7 +2513,7 @@ pub use crate::PathChar; // Run with `cargo test -p bun_paths` (also the Miri lane, `bun run rust:miri -p bun_paths`). // Normalizing reaches `bun_core::strings`' byte searches, whose highway kernels -// are only linked into the full binary, so the two they reference are satisfied +// are only linked into the full binary, so the ones they reference are satisfied // below with scalar stubs (the simdutf counterparts live in string_paths.rs). // Under Miri the wrappers take their own scalar path and never call these. #[cfg(test)] @@ -2521,6 +2555,45 @@ mod tests { .unwrap_or(text_len) } + /// Returns `haystack_len` when `needle` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_last_index_of_char( + haystack: *const u8, + haystack_len: usize, + needle: u8, + ) -> usize { + // SAFETY: test stub; callers pass a valid (ptr, len) pair. + let haystack = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + haystack + .iter() + .rposition(|&b| b == needle) + .unwrap_or(haystack_len) + } + + /// Returns `usize::MAX` when `needle` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_memrmem16( + haystack: *const u16, + haystack_len: usize, + needle: *const u16, + needle_len: usize, + ) -> usize { + // SAFETY: test stub; callers pass valid (ptr, len) pairs. + let (haystack, needle) = unsafe { + ( + core::slice::from_raw_parts(haystack, haystack_len), + core::slice::from_raw_parts(needle, needle_len), + ) + }; + if needle_len > haystack_len { + return usize::MAX; + } + (0..=haystack_len - needle_len) + .rev() + .find(|&i| haystack[i..i + needle_len] == *needle) + .unwrap_or(usize::MAX) + } + #[test] fn normalize_string_spill_leaves_spill_untouched_when_the_input_fits() { let mut spill = Vec::new(); @@ -2640,4 +2713,79 @@ mod tests { b"C:." ); } + + #[test] + fn normalize_buf_spill_leaves_spill_untouched_when_the_input_fits() { + let mut buf = [0u8; 32]; + let mut spill = Vec::new(); + assert_eq!( + normalize_buf_spill::(&mut buf, &mut spill, b"./bins/../cli/./x.js"), + b"cli/x.js" + ); + assert_eq!( + normalize_buf_z_spill::(&mut buf, &mut spill, b"./bins/").as_bytes(), + b"bins/" + ); + assert!(spill.is_empty()); + } + + #[test] + fn normalize_buf_spill_spills_input_longer_than_buf() { + let mut buf = [0u8; 32]; + let name = vec![b'b'; buf.len() * 3]; + let mut input = b"./".to_vec(); + input.extend_from_slice(&name); + input.extend_from_slice(b"/./x.js"); + let mut expected = name; + expected.extend_from_slice(b"/x.js"); + + let mut spill = Vec::new(); + assert_eq!( + normalize_buf_spill::(&mut buf, &mut spill, &input), + &expected[..] + ); + expected.push(0); + assert_eq!( + normalize_buf_z_spill::(&mut buf, &mut spill, &input) + .as_bytes_with_nul(), + &expected[..] + ); + assert!(!spill.is_empty()); + } + + #[test] + fn normalize_buf_z_spill_spills_input_exactly_as_long_as_buf() { + // The input normalizes to `buf.len()` bytes, leaving no room for the NUL. + let mut buf = [0u8; 32]; + let input = vec![b'a'; buf.len()]; + let mut expected = input.clone(); + expected.push(0); + + let mut spill = Vec::new(); + assert_eq!( + normalize_buf_z_spill::(&mut buf, &mut spill, &input) + .as_bytes_with_nul(), + &expected[..] + ); + assert!(!spill.is_empty()); + } + + #[test] + fn normalize_buf_spill_sizes_the_spill_for_the_empty_input_becoming_a_dot() { + let mut buf = [0u8; 1]; + + let mut spill = Vec::new(); + assert_eq!( + normalize_buf_spill::(&mut buf, &mut spill, b""), + b"." + ); + assert_eq!(spill.len(), 2); + + let mut spill = Vec::new(); + assert_eq!( + normalize_buf_z_spill::(&mut buf, &mut spill, b"").as_bytes_with_nul(), + b".\0" + ); + assert_eq!(spill.len(), 2); + } } diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index aafee8e9db8c..d9a0f0e03bcb 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -28,7 +28,7 @@ type CowString = CowSlice; use crate::cli::run_command::{ConfigureEnvOptions, RunCommand}; use bun_core::ZBox; use bun_core::{ZStr, strings}; -use bun_paths::resolve_path; +use bun_paths::resolve_path::{self, normalize_buf_spill}; use bun_semver as Semver; use bun_sha_hmac::sha; use bun_sys::{ @@ -1461,12 +1461,14 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { let mut bins: Vec = Vec::new(); let mut path_buf = PathBuffer::uninit(); + let mut path_spill: Vec = Vec::new(); if let Some(bin) = json.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, + let normalized = normalize_buf_spill::( &mut path_buf, + &mut path_spill, + bin_str, ); if !bin_path_escapes_root(normalized) { bins.push(BinInfo { @@ -1485,9 +1487,10 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { for bin_prop in bin_obj.properties.slice() { if let Some(bin_prop_value) = &bin_prop.value { if let Some(bin_str) = bin_prop_value.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, + let normalized = normalize_buf_spill::( &mut path_buf, + &mut path_spill, + bin_str, ); if !bin_path_escapes_root(normalized) { bins.push(BinInfo { @@ -1507,9 +1510,10 @@ fn get_package_bins(json: &Expr) -> Result, AllocError> { if let ExprData::EObject(directories_obj) = &directories.expr.data { if let Some(bin) = directories_obj.as_property(b"bin") { if let Some(bin_str) = bin.expr.as_string(pack_bump()) { - let normalized = resolve_path::normalize_buf::( - bin_str, + let normalized = normalize_buf_spill::( &mut path_buf, + &mut path_spill, + bin_str, ); if !bin_path_escapes_root(normalized) { bins.push(BinInfo { @@ -2312,12 +2316,13 @@ pub(crate) fn pack( let mut excludes: Vec = Vec::new(); let mut path_buf = PathBuffer::uninit(); + let mut path_spill: Vec = Vec::new(); while let Some(files_entry) = files_array.next() { if let Some(file_entry_str) = files_entry.as_string(bump) { - let normalized = resolve_path::normalize_buf::< - resolve_path::platform::Posix, - >( - file_entry_str, &mut path_buf + let normalized = normalize_buf_spill::( + &mut path_buf, + &mut path_spill, + file_entry_str, ); let Some(parsed) = Pattern::from_utf8(normalized)? else { continue; diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 60a861015688..b9778ff23a52 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -15,7 +15,7 @@ use bun_install::lockfile::{LoadResult, LoadStep}; use bun_install::{self as install, Lockfile, Npm, PackageManager, Subcommand}; use bun_libarchive::lib::{Archive, ArchiveIterator, IteratorResult as ArchiveIterResult}; use bun_parsers::json as json_mod; -use bun_paths::resolve_path::{join_abs_string_buf_z, normalize_buf, normalize_buf_z}; +use bun_paths::resolve_path::{join_abs_string_buf_z, normalize_buf_spill, normalize_buf_z_spill}; use bun_paths::{self as path, PathBuffer}; use bun_resolver::fs::FileSystem; use bun_sha_hmac as sha; @@ -1608,14 +1608,16 @@ impl PublishCommand { }; } let mut path_buf = PathBuffer::uninit(); + let mut path_spill: Vec = Vec::new(); if let Some(bin_query) = json.as_property(b"bin") { match &bin_query.expr.data { ExprData::EString(bin_str) => { let mut bin_props: Vec = Vec::new(); let normalized = strings::without_prefix_comptime_z( - normalize_buf_z::( - bin_str.string(bump)?, + normalize_buf_z_spill::( &mut *path_buf, + &mut path_spill, + bin_str.string(bump)?, ), b"./", ); @@ -1660,9 +1662,10 @@ impl PublishCommand { if ks.len() != 0 { break 'key Some(Box::<[u8]>::from( strings::without_prefix( - normalize_buf::( - ks.string(bump)?, + normalize_buf_spill::( &mut *path_buf, + &mut path_spill, + ks.string(bump)?, ), b"./", ), @@ -1685,9 +1688,10 @@ impl PublishCommand { break 'value Some(bun_core::ZBox::from_bytes( strings::without_prefix_comptime_z( // replace separators - normalize_buf_z::( - vs.string(bump)?, + normalize_buf_z_spill::( &mut *path_buf, + &mut path_spill, + vs.string(bump)?, ), b"./", ) @@ -1746,7 +1750,11 @@ impl PublishCommand { let mut bin_props: Vec = Vec::new(); let normalized_bin_dir = bun_core::ZBox::from_bytes( strings::without_trailing_slash(strings::without_prefix( - normalize_buf::(bin_dir_str, &mut *path_buf), + normalize_buf_spill::( + &mut *path_buf, + &mut path_spill, + bin_dir_str, + ), b"./", )), ); @@ -1763,7 +1771,7 @@ impl PublishCommand { ) { Ok(fd) => fd, Err(e) => { - if e.get_errno() == bun_sys::E::ENOENT { + if matches!(e.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENAMETOOLONG) { bun_core::warn!( "bin directory '{}' does not exist", bstr::BStr::new(normalized_bin_dir.as_bytes()), diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 34f42ac8a360..8f67964bbbcd 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -1422,6 +1422,34 @@ describe("files", () => { { "pathname": "package/src/index.ts" }, ]); }); + + test("an entry longer than the path buffer is still matched", async () => { + // A brace group of 1000 names that do not exist, then "dist". ~100KB, longer + // than the path buffer on every platform (98302 bytes on Windows). + const unused = Buffer.alloc(100, "x").toString(); + const pattern = `{${Array.from({ length: 1000 }, (_, i) => `${unused}${i}`).join(",")},dist}`; + expect(pattern.length).toBeGreaterThan(100_000); + + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-files-long-entry", + version: "1.0.0", + files: [pattern], + }), + ), + write(join(packageDir, "dist", "index.js"), "console.log('hello ./dist/index.js')"), + write(join(packageDir, "src", "index.js"), "console.log('hello ./src/index.js')"), + ]); + + await pack(packageDir, bunEnv); + const tarball = readTarball(join(packageDir, "pack-files-long-entry-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { "pathname": "package/package.json" }, + { "pathname": "package/dist/index.js" }, + ]); + }); }); describe(".gitignore/.npmignore", () => { @@ -1678,6 +1706,61 @@ describe("bins", () => { }, ]); }); + + describe("longer than the path buffer", () => { + // Longer than the path buffer on every platform (98302 bytes on Windows), so + // nothing on disk can have this name. + const long = Buffer.alloc(100_000, "b").toString(); + + test.each([ + ["bin", { bin: long }], + ["bin object value", { bin: { cli: long } }], + ["directories.bin", { directories: { bin: long } }], + ])("%s is skipped like any other bin that does not exist", async (_, fields) => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-long-bin", + version: "1.0.0", + ...fields, + }), + ), + write(join(packageDir, "index.js"), "console.log('hello ./index.js')"), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-long-bin-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([{ pathname: "package/package.json" }, { pathname: "package/index.js" }]); + }); + + test("the other bins are still packed", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-long-bin", + version: "1.0.0", + files: ["index.js"], + bin: { long, cli: "cli.js" }, + }), + ), + write(join(packageDir, "index.js"), "console.log('hello ./index.js')"), + write(join(packageDir, "cli.js"), `#!/usr/bin/env bun\n`), + ]); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-long-bin-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { pathname: "package/package.json" }, + { pathname: "package/cli.js" }, + { pathname: "package/index.js" }, + ]); + expect(tarball.entries[1].perm & 0o111).toBe(0o111); + }); + }); }); test("unicode", async () => { diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..981a56c307d9 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -654,6 +654,53 @@ for (const info of [ }); } +describe("bin longer than the path buffer", () => { + // Longer than the path buffer on every platform (98302 bytes on Windows), so + // nothing on disk can have this name. + const long = Buffer.alloc(100_000, "b").toString(); + // The registry is never contacted with --dry-run; the userinfo only satisfies the auth check. + const dryRunEnv = { ...env, npm_config_registry: "http://user:pass@127.0.0.1:1/" }; + + test.each([ + ["bin", { bin: long }, "warn: bin '' does not exist"], + ["bin object value", { bin: { cli: long } }, "warn: bin '' does not exist"], + ["bin object key", { bin: { [long]: "cli.js" } }, null], + ["directories.bin", { directories: { bin: long } }, "warn: bin directory '' does not exist"], + ])("tarball with a long %s", async (_, fields, warning) => { + const packageDir = tmpdirSync(); + const packageJson = JSON.stringify({ name: "publish-long-bin", version: "1.0.0", ...fields }); + await Promise.all([ + write(join(packageDir, "package.json"), packageJson), + write(join(packageDir, "cli.js"), ""), + Bun.Archive.write( + join(packageDir, "publish-long-bin-1.0.0.tgz"), + { "package/package.json": packageJson }, + { compress: "gzip" }, + ), + ]); + + const { out, err, exitCode } = await publish(dryRunEnv, packageDir, "./publish-long-bin-1.0.0.tgz", "--dry-run"); + const stderr = err.replaceAll(long, ""); + expect(stderr.split("\n").filter(line => line.startsWith("warn:"))).toEqual(warning === null ? [] : [warning]); + expect(stderr).not.toContain("error:"); + expect(out).toContain("+ publish-long-bin@1.0.0 (dry-run)"); + expect(exitCode).toBe(0); + }); + + test("directory with a long bin", async () => { + const packageDir = tmpdirSync(); + await write( + join(packageDir, "package.json"), + JSON.stringify({ name: "publish-long-bin", version: "1.0.0", bin: long }), + ); + + const { out, err, exitCode } = await publish(dryRunEnv, packageDir, "--dry-run"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ publish-long-bin@1.0.0 (dry-run)"); + expect(exitCode).toBe(0); + }); +}); + test("dependencies are installed", async () => { const { packageDir, packageJson } = await registry.createTestDir(); const publishDir = tmpdirSync(); From 2feba6ed2a407bddc798cc59afdc27d8ce2634eb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:09 +0000 Subject: [PATCH 036/258] install: stop panicking on bin values longer than the path buffer (#38954) --- src/install/bin.rs | 226 ++++++------ .../bun-install-native-binlink.test.ts | 5 + test/cli/install/bun-install-registry.test.ts | 338 +++++++++++++++++- .../create-native-binlink-altpath-packages.ts | 15 +- .../package.json | 21 +- ...st-native-binlink-altpath-target-4.0.0.tgz | Bin 0 -> 342 bytes .../test-native-binlink-altpath/package.json | 23 +- .../test-native-binlink-altpath-4.0.0.tgz | Bin 0 -> 324 bytes 8 files changed, 516 insertions(+), 112 deletions(-) create mode 100644 test/cli/install/registry/packages/test-native-binlink-altpath-target/test-native-binlink-altpath-target-4.0.0.tgz create mode 100644 test/cli/install/registry/packages/test-native-binlink-altpath/test-native-binlink-altpath-4.0.0.tgz diff --git a/src/install/bin.rs b/src/install/bin.rs index 7bb19bf33bbf..763755386e16 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -1042,11 +1042,10 @@ impl<'a> Linker<'a> { // Create temporary file path let mut tmppath_buf = [0u8; MAX_PATH_BYTES]; - let tmppath = resolve_path::join_abs_string_buf_z::( - dir_path, - &mut tmppath_buf, - &[tmpname.as_bytes()], - ); + let Some(tmppath) = Self::join_z_checked(dir_path, tmpname.as_bytes(), &mut tmppath_buf) + else { + return; + }; let mut needs_unlink = true; let unlink_guard = scopeguard::guard(&mut needs_unlink, |needs_unlink| { if *needs_unlink { @@ -1263,8 +1262,16 @@ impl<'a> Linker<'a> { // so each return path calls `Self::chmod_on_ok` explicitly instead. let abs_dest_dir = resolve_path::dirname::(abs_dest.as_bytes()); - let rel_target = - resolve_path::relative_buf_z(self.rel_buf, abs_dest_dir, abs_target.as_bytes()); + // One `..` per component of `abs_dest_dir` can make this longer than `abs_target`. + let rel_bound = abs_target.len() + 3 * (strings::count_char(abs_dest_dir, SEP) + 1) + 2; + let mut rel_spill: Vec; + let rel_buf: &mut [u8] = if rel_bound <= self.rel_buf.len() { + self.rel_buf + } else { + rel_spill = vec![0; rel_bound]; + &mut rel_spill + }; + let rel_target = resolve_path::relative_buf_z(rel_buf, abs_dest_dir, abs_target.as_bytes()); debug_assert!(strings::has_prefix(rel_target.as_bytes(), b"..")); @@ -1464,59 +1471,73 @@ impl<'a> Linker<'a> { /// /// Falls through to (1) when nothing exists so the existing /// `skipped_due_to_missing_bin` retry-without-redirect path still fires. - // `is_native_binlink_redirect()` is hoisted to a parameter so the - // caller can drop its `&self` borrow before mutably calling - // `link_bin_or_create_shim`. Result borrows the threadlocal join buffer - // (lifetime tied to `package_dir` per `join_abs_string_z`'s signature). + /// `None`: the path does not fit `buf`; callers treat that as a missing bin. fn resolve_bin_target<'b>( is_native_binlink_redirect: bool, - package_dir: &'b [u8], + package_dir: &[u8], target: &[u8], bin_name: &[u8], - ) -> &'b ZStr { + buf: &'b mut [u8], + ) -> Option<&'b ZStr> { // A trailing separator would make `lchmod` follow a symlinked target; npm drops it too. let target = strings::without_trailing_slash(target); - let primary = resolve_path::join_abs_string_z::(package_dir, &[target]); if !is_native_binlink_redirect { - return primary; - } - - if sys::exists(primary.as_bytes()) { - return primary; - } - - if !bin_name.is_empty() { - let at_root = resolve_path::join_abs_string_z::(package_dir, &[bin_name]); - if sys::exists(at_root.as_bytes()) { - return at_root; - } + return Self::join_z_checked(package_dir, target, buf); } let target_basename = path::basename(target); - if !target_basename.is_empty() && target_basename.len() != target.len() { - let at_root = - resolve_path::join_abs_string_z::(package_dir, &[target_basename]); - if sys::exists(at_root.as_bytes()) { - return at_root; - } - } + let exe_name: Vec = + if !bin_name.is_empty() && !strings::has_suffix_comptime(bin_name, b".exe") { + [bin_name, b".exe"].concat() + } else { + Vec::new() + }; + let candidates: [&[u8]; 4] = [ + // (1) + target, + // (2) + bin_name, + // (3), only when `target` has a directory component + if target_basename.len() != target.len() { + target_basename + } else { + b"" + }, + // (4) + &exe_name, + ]; - if !bin_name.is_empty() && !strings::has_suffix_comptime(bin_name, b".exe") { - let mut exe_name = Vec::with_capacity(bin_name.len() + b".exe".len()); - exe_name.extend_from_slice(bin_name); - exe_name.extend_from_slice(b".exe"); - let at_root = - resolve_path::join_abs_string_z::(package_dir, &[&exe_name]); - if sys::exists(at_root.as_bytes()) { - return at_root; + for candidate in candidates { + if candidate.is_empty() { + continue; + } + let Some(abs_candidate) = Self::join_z_checked(package_dir, candidate, buf) else { + continue; + }; + if sys::exists_z(abs_candidate) { + let len = abs_candidate.len(); + return Some(ZStr::from_buf(buf, len)); } } - // Nothing found; return the primary so `linkBinOrCreateShim` sets + // Nothing found; return the primary so `link_bin_or_create_shim` sets // `skipped_due_to_missing_bin` and the caller retries without the // redirect. - resolve_path::join_abs_string_z::(package_dir, &[target]) + Self::join_z_checked(package_dir, target, buf) + } + + /// `

/` with a NUL in `buf`, or `None` if it does not fit (`sys` rejects it too). + fn join_z_checked<'b>(dir: &[u8], relative: &[u8], buf: &'b mut [u8]) -> Option<&'b ZStr> { + let without_nul = buf.len() - 1; + let len = resolve_path::join_abs_string_buf_checked::( + dir, + &mut buf[..without_nul], + &[relative], + )? + .len(); + buf[len] = 0; + Some(ZStr::from_buf(buf, len)) } /// uses `self.abs_target_buf` @@ -1588,17 +1609,13 @@ impl<'a> Linker<'a> { debug_assert!(self.bin.tag != Tag::None); - // `link_bin_or_create_shim(&mut self, ..)` - // is called while `abs_target` / `abs_dest` borrow `self.abs_target_buf` - // / `self.abs_dest_buf`. `link_bin_or_create_shim` never reads or writes - // those two buffers (it only touches `rel_buf`, `node_modules_path`, `seen`, `err`, - // `skipped_due_to_missing_bin`). Detach the `abs_dest` borrow via a raw - // pointer so borrowck allows the disjoint access; the SAFETY invariant - // is that `abs_dest_buf` is not aliased mutably for the lifetime of the - // detached slice. `package_dir` (`abs_target_buf[0..package_dir_len]`) - // is re-derived inside each arm so no detached borrow is needed for it. + // SAFETY (`from_raw` below): `link_bin_or_create_shim` never touches `abs_dest_buf`. let abs_dest_buf_ptr: *mut u8 = self.abs_dest_buf.as_mut_ptr(); + // `abs_target_buf` holds `package_dir`, the join input, so the output needs its own buffer. + let mut resolved_target_pool_buf = path::path_buffer_pool::get(); + let resolved_target_buf = resolved_target_pool_buf.as_mut_slice(); + // SAFETY: tag determines the active union field unsafe { match self.bin.tag { @@ -1616,20 +1633,15 @@ impl<'a> Linker<'a> { Dependency::unscoped_package_name(self.package_name.slice()); // for normalizing `target` - let abs_target: &ZStr = { - let package_dir = &self.abs_target_buf[0..package_dir_len]; - let r = Self::resolve_bin_target( - is_redirect, - package_dir, - target, - unscoped_package_name, - ); - // SAFETY: `resolve_bin_target` writes into the thread-local - // `PARSER_JOIN_INPUT_BUFFER` (via `join_abs_string_z`); the - // returned slice does not actually borrow `self` or - // `package_dir`. Detach the lifetime so `self` can be - // re-borrowed mutably below. - ZStr::from_raw(r.as_bytes().as_ptr(), r.len()) + let Some(abs_target) = Self::resolve_bin_target( + is_redirect, + &self.abs_target_buf[0..package_dir_len], + target, + unscoped_package_name, + resolved_target_buf, + ) else { + self.skipped_due_to_missing_bin = true; + return; }; if unscoped_package_name.len() @@ -1672,16 +1684,15 @@ impl<'a> Linker<'a> { } // for normalizing `target` - let abs_target: &ZStr = { - let package_dir = &self.abs_target_buf[0..package_dir_len]; - let r = Self::resolve_bin_target( - is_redirect, - package_dir, - target, - normalized_name, - ); - // SAFETY: thread-local buffer; see Tag::File above. - ZStr::from_raw(r.as_bytes().as_ptr(), r.len()) + let Some(abs_target) = Self::resolve_bin_target( + is_redirect, + &self.abs_target_buf[0..package_dir_len], + target, + normalized_name, + resolved_target_buf, + ) else { + self.skipped_due_to_missing_bin = true; + return; }; self.abs_dest_buf[dest_off..dest_off + normalized_name.len()] @@ -1728,16 +1739,16 @@ impl<'a> Linker<'a> { return; } - let abs_target: &ZStr = { - let package_dir = &self.abs_target_buf[0..package_dir_len]; - let r = Self::resolve_bin_target( - is_redirect, - package_dir, - bin_target, - normalized_bin_dest, - ); - // SAFETY: thread-local buffer; see Tag::File above. - ZStr::from_raw(r.as_bytes().as_ptr(), r.len()) + let Some(abs_target) = Self::resolve_bin_target( + is_redirect, + &self.abs_target_buf[0..package_dir_len], + bin_target, + normalized_bin_dest, + resolved_target_buf, + ) else { + self.skipped_due_to_missing_bin = true; + i += 2; + continue; }; dest_off = abs_dest_dir_end; @@ -1766,15 +1777,13 @@ impl<'a> Linker<'a> { return; } // for normalizing `target` - let abs_target_dir: &ZStr = { - let package_dir = &self.abs_target_buf[0..package_dir_len]; - let r = - resolve_path::join_abs_string_z::(package_dir, &[target]); - // SAFETY: `join_abs_string_z` writes into the thread-local - // `PARSER_JOIN_INPUT_BUFFER`; result does not borrow - // `package_dir`. Detached so `abs_target_buf` can be - // reused inside the loop body (see the SAFETY note below). - ZStr::from_raw(r.as_bytes().as_ptr(), r.len()) + let Some(abs_target_dir) = Self::join_z_checked( + &self.abs_target_buf[0..package_dir_len], + target, + resolved_target_buf, + ) else { + self.err = Some(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)); + return; }; let target_dir = match sys::open_dir_absolute(abs_target_dir.as_bytes()) { @@ -1800,13 +1809,16 @@ impl<'a> Linker<'a> { match entry.kind { sys::EntryKind::SymLink | sys::EntryKind::File => { let entry_name = entry.name.slice_u8(); - // `self.abs_target_buf` is available now because `path::join_abs_string_z` copied everything into `parse_join_input_buffer` + // `package_dir` is no longer needed, so `abs_target_buf` is free. let abs_target: &ZStr = { - let r = resolve_path::join_abs_string_buf_z::( + let Some(r) = Self::join_z_checked( abs_target_dir.as_bytes(), + entry_name, self.abs_target_buf, - &[entry_name], - ); + ) else { + self.skipped_due_to_missing_bin = true; + continue; + }; // SAFETY: result lives in `self.abs_target_buf`, which // `link_bin_or_create_shim` does not write to (only // `rel_buf`/`node_modules_path`/`seen`/`err`/ @@ -1847,11 +1859,6 @@ impl<'a> Linker<'a> { debug_assert!(self.bin.tag != Tag::None); - // see `link()` — detach abs_target_buf borrow via raw ptr. - let abs_target_buf_ptr: *const u8 = self.abs_target_buf.as_ptr(); - // SAFETY: abs_target_buf is not written between here and use. - let package_dir = unsafe { bun_core::ffi::slice(abs_target_buf_ptr, package_dir_len) }; - // SAFETY: tag determines the active union field unsafe { match self.bin.tag { @@ -1936,8 +1943,15 @@ impl<'a> Linker<'a> { return; } - let abs_target_dir = - resolve_path::join_abs_string_z::(package_dir, &[target]); + let mut abs_target_dir_buf = path::path_buffer_pool::get(); + let Some(abs_target_dir) = Self::join_z_checked( + &self.abs_target_buf[0..package_dir_len], + target, + abs_target_dir_buf.as_mut_slice(), + ) else { + self.err = Some(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)); + return; + }; let target_dir = match sys::open_dir_absolute(abs_target_dir.as_bytes()) { Ok(d) => d, diff --git a/test/cli/install/bun-install-native-binlink.test.ts b/test/cli/install/bun-install-native-binlink.test.ts index 71df3ee19c83..1f5e6433d135 100644 --- a/test/cli/install/bun-install-native-binlink.test.ts +++ b/test/cli/install/bun-install-native-binlink.test.ts @@ -460,6 +460,11 @@ describe.concurrent("native binlink altpath", () => { targetFile: "altpath-cmd.exe", description: "/.exe (@esbuild/win32 shape)", }, + { + version: "4.0.0", + targetFile: "altpath-cmd", + description: "/ when the bin value does not fit the path buffer", + }, ] as const; for (const linker of ["hoisted", "isolated"]) { diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index be638966e2f6..c70bc604fa05 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -2,7 +2,7 @@ import { file, spawn, write } from "bun"; import { install_test_helpers, npm_manifest_test_helpers } from "bun:internal-for-testing"; import { afterAll, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { copyFileSync, mkdirSync } from "fs"; -import { cp, exists, lstat, mkdir, readlink, rm, writeFile } from "fs/promises"; +import { cp, exists, lstat, mkdir, readlink, rename, rm, writeFile } from "fs/promises"; import { assertManifestsPopulated, bunExe, @@ -3281,6 +3281,342 @@ describe("binaries", () => { expect(err).not.toContain("error:"); expect(await exited).toBe(0); }); + + describe("bin values longer than the path buffer", () => { + // Longer than the path buffer (MAX_PATH_BYTES: 96 KiB on Windows, 4 KiB on + // Linux, 1 KiB on macOS), so on every platform the linker cannot even build + // the target path. + const longValue = Buffer.alloc(100_000, "b").toString(); + // Also longer than the buffer as written, but normalizes to a short path. + const longNormalizingValue = Buffer.alloc(20_000 * "x/../".length, "x/../").toString() + "normalizing-bin.js"; + const script = (name: string) => `#!/usr/bin/env node\nconsole.log("${name}")`; + const binDir = () => join(packageDir, "node_modules", ".bin"); + const binEntries = (...names: string[]) => + names.flatMap(name => (isWindows ? [`${name}.bunx`, `${name}.exe`] : [name])).sort(); + + async function run(args: string[], { cwd = packageDir, env: runEnv = env } = {}) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: runEnv, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, exitCode }; + } + const install = (linker: "hoisted" | "isolated", cwd = packageDir) => + run(["install", `--linker=${linker}`], { cwd }); + + // For the tests below that need paths close to the limit (POSIX only: the + // Windows buffer is far larger than any path the filesystem accepts). + const maxPathBytes = isMacOS ? 1024 : 4096; + // Directory components (at most 200 bytes each) that bring `base` to exactly `length` bytes. + const componentsUpTo = (base: string, length: number, fill: string) => { + const components: string[] = []; + let remaining = length - Buffer.byteLength(base); + while (remaining > 0) { + let len = Math.min(200, remaining - 1); + if (remaining - 1 - len === 1) len -= 1; + components.push(Buffer.alloc(len, fill).toString()); + remaining -= 1 + len; + } + return components; + }; + // Creates `/bins//` holding `entries`. The absolute paths can be + // too long for the OS to create directly, so descend one component at a time. + async function createBinChain(pkgDir: string, chain: string[], entries: Record) { + const { err, exitCode } = await run( + [ + "-e", + `const fs = require("fs"); + for (const component of process.argv.slice(1)) { fs.mkdirSync(component); process.chdir(component); } + for (const [name, contents] of Object.entries(${JSON.stringify(entries)})) fs.writeFileSync(name, contents);`, + "bins", + ...chain, + ], + { cwd: pkgDir }, + ); + expect(err).toBe(""); + expect(exitCode).toBe(0); + } + // Shortens the paths again so that ordinary tools can delete the directory. + const shortenBinChain = (pkgDir: string, chain: string[]) => + rename(join(pkgDir, "bins", chain[0]), join(pkgDir, "bins", "d")); + + for (const linker of ["hoisted", "isolated"] as const) { + test(`file targets are skipped like missing bins (${linker})`, async () => { + // A target this long cannot exist, and the linker silently skips a bin + // whose target does not exist, so these install without output and the + // other bins are still linked. Covers the string, single-entry and + // multi-entry forms of "bin", which are linked by separate code paths. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "long-file-bin": "./long-file-bin", + "long-named-bin": "./long-named-bin", + "long-map-bin": "./long-map-bin", + "normalizing-bin": "./normalizing-bin", + }, + }), + ), + write( + join(packageDir, "long-file-bin", "package.json"), + JSON.stringify({ name: "long-file-bin", version: "1.0.0", bin: longValue }), + ), + write( + join(packageDir, "long-named-bin", "package.json"), + JSON.stringify({ name: "long-named-bin", version: "1.0.0", bin: { "long-named-bin": longValue } }), + ), + write( + join(packageDir, "long-map-bin", "package.json"), + JSON.stringify({ + name: "long-map-bin", + version: "1.0.0", + bin: { "long-map-bin-1": longValue, "long-map-bin-2": "long-map-bin-2.js" }, + }), + ), + write(join(packageDir, "long-map-bin", "long-map-bin-2.js"), script("long-map-bin-2")), + write( + join(packageDir, "normalizing-bin", "package.json"), + JSON.stringify({ + name: "normalizing-bin", + version: "1.0.0", + bin: { "normalizing-bin": longNormalizingValue }, + }), + ), + write(join(packageDir, "normalizing-bin", "normalizing-bin.js"), script("normalizing-bin")), + ]); + + const expectBins = async () => { + expect(await readdirSorted(binDir())).toEqual(binEntries("long-map-bin-2", "normalizing-bin")); + if (linker === "hoisted") { + expect(join(binDir(), "long-map-bin-2")).toBeValidBin(join("..", "long-map-bin", "long-map-bin-2.js")); + expect(join(binDir(), "normalizing-bin")).toBeValidBin(join("..", "normalizing-bin", "normalizing-bin.js")); + } + }; + + let { out, err, exitCode } = await install(linker); + expect(err).not.toContain("error:"); + expect(err).toContain("Saved lockfile"); + expect(out).toContain("+ long-file-bin@long-file-bin"); + expect(out).toContain("+ long-named-bin@long-named-bin"); + expect(out).toContain("+ long-map-bin@long-map-bin"); + expect(out).toContain("+ normalizing-bin@normalizing-bin"); + expect(exitCode).toBe(0); + await expectBins(); + + // The lockfile stores the values as written, so linking from it hits the same paths. + await rm(binDir(), { recursive: true, force: true }); + ({ err, exitCode } = await install(linker)); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + await expectBins(); + }); + + test(`directories.bin fails with ENAMETOOLONG (${linker})`, async () => { + // The linker reports every failure to open a bin directory other than + // ENOENT, and ENAMETOOLONG is what the OS reports for a directory path it + // cannot address, so a value that does not fit gets the same error. + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "long-dir-bin": "./long-dir-bin", + "ok-bin": "./ok-bin", + }, + }), + ), + write( + join(packageDir, "long-dir-bin", "package.json"), + JSON.stringify({ name: "long-dir-bin", version: "1.0.0", directories: { bin: longValue } }), + ), + write( + join(packageDir, "ok-bin", "package.json"), + JSON.stringify({ name: "ok-bin", version: "1.0.0", bin: { "ok-bin": "ok-bin.js" } }), + ), + write(join(packageDir, "ok-bin", "ok-bin.js"), script("ok-bin")), + ]); + + const { err, exitCode } = await install(linker); + expect(err).toContain( + linker === "hoisted" + ? "error: Failed to link long-dir-bin: ENAMETOOLONG" + : "ENAMETOOLONG: failed to link binaries for package: long-dir-bin@", + ); + expect(exitCode).toBe(1); + + // The rest of the install still happens. + expect(await exists(join(packageDir, "node_modules", "ok-bin", "package.json"))).toBeTrue(); + if (linker === "hoisted") { + expect(await exists(join(packageDir, "node_modules", "long-dir-bin", "package.json"))).toBeTrue(); + expect(await readdirSorted(binDir())).toEqual(binEntries("ok-bin")); + } + }); + } + + // The entries of a bin directory are joined onto the directory's path as + // well, and so is the temporary file used to rewrite a CRLF shebang. + test.skipIf(isWindows)("entries of a directories.bin close to the path limit", async () => { + // Most of the depth goes into the project directory: the `.bin` links are + // relative, so everything below the package ends up in the link targets, + // and XFS rejects link targets of 1 KiB or more. The bin directory is then + // 64 bytes below the limit, so it can be opened. Joined onto it, `short.js` + // fits, `crlfEntry` fits but the temporary file for its shebang (27 bytes + // longer) does not, and `longEntry` does not fit at all. + const project = join(packageDir, ...componentsUpTo(packageDir, maxPathBytes - 768, "p")); + const pkgDir = join(project, "deep-dir-bin"); + const chain = componentsUpTo(join(project, "node_modules", "deep-dir-bin", "bins"), maxPathBytes - 64, "d"); + const crlfEntry = Buffer.alloc(49, "c").toString() + ".js"; + const crlfScript = `#!/usr/bin/env node\r\nconsole.log("crlf")`; + const longEntry = Buffer.alloc(128, "e").toString(); + + await Promise.all([ + write(join(project, "package.json"), JSON.stringify({ name: "foo", workspaces: ["deep-dir-bin"] })), + write( + join(pkgDir, "package.json"), + JSON.stringify({ name: "deep-dir-bin", version: "1.0.0", directories: { bin: join("bins", ...chain) } }), + ), + ]); + await createBinChain(pkgDir, chain, { + "short.js": script("short"), + [crlfEntry]: crlfScript, + [longEntry]: script("long"), + }); + + try { + const { err, exitCode } = await install("hoisted", project); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + const projectBinDir = join(project, "node_modules", ".bin"); + expect(await readdirSorted(projectBinDir)).toEqual([crlfEntry, "short.js"]); + expect(join(projectBinDir, "short.js")).toBeValidBin(join("..", "deep-dir-bin", "bins", ...chain, "short.js")); + expect(join(projectBinDir, crlfEntry)).toBeValidBin(join("..", "deep-dir-bin", "bins", ...chain, crlfEntry)); + } finally { + await shortenBinChain(pkgDir, chain); + } + // Linked, but left as it was since the shebang could not be rewritten in place. + expect(await file(join(pkgDir, "bins", "d", ...chain.slice(1), crlfEntry)).text()).toBe(crlfScript); + }); + + // The link itself is relative to the bin directory. A global bin directory + // on another branch of the tree contributes one `..` per component, so the + // link target can be longer than the target's absolute path. + test.skipIf(isWindows)("bun link with a link target longer than the path buffer", async () => { + // As in the previous test, most of the depth has to sit above everything + // that ends up in a link target that is meant to be created (XFS), so it + // goes into the global directory itself. + const globalBase = join(packageDir, "global", "install"); + const globalDir = join(globalBase, ...componentsUpTo(globalBase, maxPathBytes - 768, "g")); + const pkgDir = join(packageDir, "far-bin"); + // The linker sees the target as /node_modules/far-bin/bins//, + // 20 bytes below the limit. + const chain = componentsUpTo(join(globalDir, "node_modules", "far-bin", "bins"), maxPathBytes - 64, "d"); + const entry = Buffer.alloc(43, "e").toString(); + await Promise.all([ + mkdir(globalDir, { recursive: true }), + write( + join(pkgDir, "package.json"), + JSON.stringify({ name: "far-bin", version: "1.0.0", directories: { bin: join("bins", ...chain) } }), + ), + ]); + await createBinChain(pkgDir, chain, { [entry]: script("far") }); + const link = (binDir: string) => + run(["link"], { + cwd: pkgDir, + env: { + ...env, + BUN_INSTALL: join(packageDir, "global"), + BUN_INSTALL_GLOBAL_DIR: globalDir, + BUN_INSTALL_BIN: binDir, + }, + }); + + try { + // Below the global directory the link target drops that common prefix and + // fits (about 770 bytes), even though the per-component estimate says it + // might not. + const nearBinDir = join(globalDir, "a", "b", "c", "d", "e", "bin"); + let { out, err, exitCode } = await link(nearBinDir); + expect(err).not.toContain("error:"); + expect(out).toContain('Success! Registered "far-bin"'); + expect(exitCode).toBe(0); + expect(await readdirSorted(nearBinDir)).toEqual([entry]); + expect(join(nearBinDir, entry)).toBeValidBin( + join("..", "..", "..", "..", "..", "..", "node_modules", "far-bin", "bins", ...chain, entry), + ); + + // Far enough away for the `..`s to outweigh the prefix they replace: the + // link target no longer fits, and the OS refuses to create the link. + const farBinDir = join( + packageDir, + ...Array(Math.ceil(Buffer.byteLength(packageDir) / 3) + 40).fill("x"), + "bin", + ); + ({ err, exitCode } = await link(farBinDir)); + expect(err).toContain("error: failed to link bin due to error ENAMETOOLONG"); + expect(exitCode).toBe(1); + expect(await readdirSorted(farBinDir)).toEqual([]); + } finally { + await shortenBinChain(pkgDir, chain); + } + }); + + test("bun link and bun unlink", async () => { + // `bun link` links a package's bins into the global bin directory with the + // same linker, and `bun unlink` is the only caller of its unlink side, which + // has to open the same bin directory. + const globalDir = join(packageDir, "global"); + const globalEnv = { + ...env, + BUN_INSTALL: globalDir, + BUN_INSTALL_GLOBAL_DIR: join(globalDir, "install", "global"), + BUN_INSTALL_BIN: join(globalDir, "bin"), + }; + const globalBins = () => readdirSorted(join(globalDir, "bin")); + await Promise.all([ + write( + join(packageDir, "long-map-bin", "package.json"), + JSON.stringify({ + name: "long-map-bin", + version: "1.0.0", + bin: { "long-map-bin-1": longValue, "long-map-bin-2": "long-map-bin-2.js" }, + }), + ), + write(join(packageDir, "long-map-bin", "long-map-bin-2.js"), script("long-map-bin-2")), + write( + join(packageDir, "long-dir-bin", "package.json"), + JSON.stringify({ name: "long-dir-bin", version: "1.0.0", directories: { bin: longValue } }), + ), + ]); + + let { out, err, exitCode } = await run(["link"], { cwd: join(packageDir, "long-map-bin"), env: globalEnv }); + expect(err).not.toContain("error:"); + expect(out).toContain('Success! Registered "long-map-bin"'); + expect(exitCode).toBe(0); + expect(await globalBins()).toEqual(binEntries("long-map-bin-2")); + + ({ out, exitCode } = await run(["unlink"], { cwd: join(packageDir, "long-map-bin"), env: globalEnv })); + expect(out).toContain('success: unlinked package "long-map-bin"'); + expect(exitCode).toBe(0); + expect(await globalBins()).toEqual([]); + + // The package is registered before its bins are linked, so the failed link + // still leaves something for `bun unlink` to remove. + ({ err, exitCode } = await run(["link"], { cwd: join(packageDir, "long-dir-bin"), env: globalEnv })); + expect(err).toContain("error: failed to link bin due to error ENAMETOOLONG"); + expect(exitCode).toBe(1); + + ({ out, exitCode } = await run(["unlink"], { cwd: join(packageDir, "long-dir-bin"), env: globalEnv })); + expect(out).toContain('success: unlinked package "long-dir-bin"'); + expect(exitCode).toBe(0); + }); + }); }); test("--config cli flag works", async () => { diff --git a/test/cli/install/registry/packages/create-native-binlink-altpath-packages.ts b/test/cli/install/registry/packages/create-native-binlink-altpath-packages.ts index fce515ac49ea..7478945306c4 100644 --- a/test/cli/install/registry/packages/create-native-binlink-altpath-packages.ts +++ b/test/cli/install/registry/packages/create-native-binlink-altpath-packages.ts @@ -13,6 +13,9 @@ * probe 4's `.exe` misses; isolates probe 3) * 3.0.0 parent bin `bin/altpath-cmd`, target ships `altpath-cmd.exe` at root * → probe 4, `/.exe` (@esbuild/win32-* shape) + * 4.0.0 parent bin is an 8 KiB value that does not fit the path buffer on + * Linux or macOS (so probe 1 cannot even be built, and no stub is + * packed for it), target ships `altpath-cmd` at root → probe 2 */ import { mkdir, writeFile } from "fs/promises"; @@ -55,10 +58,13 @@ console.log("SUCCESS: Using platform-specific bin at package root"); process.exit(0); `; +const longBinValue = "bin/" + Buffer.alloc(8192, "b").toString(); + const shapes = [ { version: "1.0.0", parentBinValue: "bin/altpath-cmd.exe", targetFile: "altpath-cmd" }, { version: "2.0.0", parentBinValue: "bin/launcher.exe", targetFile: "launcher.exe" }, { version: "3.0.0", parentBinValue: "bin/altpath-cmd", targetFile: "altpath-cmd.exe" }, + { version: "4.0.0", parentBinValue: longBinValue, targetFile: "altpath-cmd" }, ] as const; const parentVersions: Record = {}; @@ -74,9 +80,12 @@ for (const { version, parentBinValue, targetFile } of shapes) { }; parentVersions[version] = { pkgJson: parentJson, - tarball: await packTarball("test-native-binlink-altpath", version, parentJson, { - [parentBinValue]: stub, - }), + tarball: await packTarball( + "test-native-binlink-altpath", + version, + parentJson, + parentBinValue === longBinValue ? {} : { [parentBinValue]: stub }, + ), }; const targetJson = { diff --git a/test/cli/install/registry/packages/test-native-binlink-altpath-target/package.json b/test/cli/install/registry/packages/test-native-binlink-altpath-target/package.json index aac96faf9891..ad04aeeb719d 100644 --- a/test/cli/install/registry/packages/test-native-binlink-altpath-target/package.json +++ b/test/cli/install/registry/packages/test-native-binlink-altpath-target/package.json @@ -2,7 +2,7 @@ "_id": "test-native-binlink-altpath-target", "name": "test-native-binlink-altpath-target", "dist-tags": { - "latest": "3.0.0" + "latest": "4.0.0" }, "versions": { "1.0.0": { @@ -61,6 +61,25 @@ "shasum": "b1b381785a8690834073eef4b9d92ccc1b2a3347", "tarball": "http://localhost:4873/test-native-binlink-altpath-target/-/test-native-binlink-altpath-target-3.0.0.tgz" } + }, + "4.0.0": { + "name": "test-native-binlink-altpath-target", + "version": "4.0.0", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "arm64", + "x64" + ], + "_id": "test-native-binlink-altpath-target@4.0.0", + "dist": { + "integrity": "sha512-nFy+nSrqd+Id9wpIFbHYtYp3vJfO9nvxuMlHcBAHp36csP5Cnqfz/jkCmnjHUQxFNg0t5r+cqllCzAZD4k3icA==", + "shasum": "63a056dbd70ced7435dd653daef17245a9df3e42", + "tarball": "http://localhost:4873/test-native-binlink-altpath-target/-/test-native-binlink-altpath-target-4.0.0.tgz" + } } } } \ No newline at end of file diff --git a/test/cli/install/registry/packages/test-native-binlink-altpath-target/test-native-binlink-altpath-target-4.0.0.tgz b/test/cli/install/registry/packages/test-native-binlink-altpath-target/test-native-binlink-altpath-target-4.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..867345ea7dfc01e6bec4bb90f43b06baaa07d4a0 GIT binary patch literal 342 zcmV-c0jd5UiwFP!00000|LxPuY63A72H;uGQ=Fz7h0e^Gah!rKrB5KPN*8gAHg!yf zBwnEO-HSI0Zn~%nh5y?e63!unir=%ck07%52Ek znTl&`ylqt4*hNQ-C!^8Jcs!tq*JeR&Bg5RfRpMJ!>$$FJstw6Nr;kL=+90~axOKKx o-luA(Lx;P=I066w0000000000000000Dv3#29xO~^Z+OT0HU?2?EnA( literal 0 HcmV?d00001 diff --git a/test/cli/install/registry/packages/test-native-binlink-altpath/package.json b/test/cli/install/registry/packages/test-native-binlink-altpath/package.json index aee53d59c365..58831c434083 100644 --- a/test/cli/install/registry/packages/test-native-binlink-altpath/package.json +++ b/test/cli/install/registry/packages/test-native-binlink-altpath/package.json @@ -2,7 +2,7 @@ "_id": "test-native-binlink-altpath", "name": "test-native-binlink-altpath", "dist-tags": { - "latest": "3.0.0" + "latest": "4.0.0" }, "versions": { "1.0.0": { @@ -67,6 +67,27 @@ "shasum": "a7754b7851528cc3f175c180619bfc66a235a413", "tarball": "http://localhost:4873/test-native-binlink-altpath/-/test-native-binlink-altpath-3.0.0.tgz" } + }, + "4.0.0": { + "name": "test-native-binlink-altpath", + "version": "4.0.0", + "bin": { + "altpath-cmd": "bin/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "scripts": { + "postinstall": "node -e \"require('fs').writeFileSync(require('path').join(__dirname,'postinstall-ran'),'')\"" + }, + "optionalDependencies": { + "test-native-binlink-altpath-target": "4.0.0" + }, + "_id": "test-native-binlink-altpath@4.0.0", + "_hasInstallScript": true, + "hasInstallScript": true, + "dist": { + "integrity": "sha512-csMS6h+MbekxkdVvQ1wBPy9GOFSv4UGdGGVRG5kGMohmeFUlY5zJ6ieLQ8sdhVnQm/1ZqUb14O1OR8mesiiw1g==", + "shasum": "bd3f63bfe6512b904e8c550bdcab1202b31739d7", + "tarball": "http://localhost:4873/test-native-binlink-altpath/-/test-native-binlink-altpath-4.0.0.tgz" + } } } } \ No newline at end of file diff --git a/test/cli/install/registry/packages/test-native-binlink-altpath/test-native-binlink-altpath-4.0.0.tgz b/test/cli/install/registry/packages/test-native-binlink-altpath/test-native-binlink-altpath-4.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..d6d0befd1da2a636746d3a0aa8d2cb8135df6576 GIT binary patch literal 324 zcmV-K0lWSmiwFP!00000|LxkrYQjJe2H>3Y6vJMkVB$t&4mp>;K(B(V$y8V4B;8#t zMSS;ymMR{4X(W>NYiIl5uW5n4^TCP_rfpS4O1g$AWtuC;Z+q)_ki7b2n7un2_}RAn@*(x$yVskr zPKMd0vocsL8tcUDc$C~cnmQxNaFis&xw?+g^}%*x+9&Q=8kXAf%}hU}Aktvmio-Sk W5EGr^Qv?9;cs>Cd=IibNC;$LR$)Fhk literal 0 HcmV?d00001 From 6be388513da4927f5218852e34a14dee3d51b77b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:13 +0000 Subject: [PATCH 037/258] pm view: escape control characters coming from the registry (#38536) --- src/bun_core/fmt.rs | 81 +++++++++++++++++++++++++ src/runtime/cli/pm_view_command.rs | 67 +++++++++++++++------ test/cli/install/bun-info.test.ts | 97 +++++++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 20 deletions(-) diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 381ba04fa6c8..f52f07cbc9d5 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3326,6 +3326,87 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +// ─────────────────────────────────────────────────────────────────────────── +// escapeControlChars +// ─────────────────────────────────────────────────────────────────────────── + +/// Renders the wrapped `Display` with C0 controls, DEL and C1 controls +/// spelled out (`\n`, `\r`, `\t`, `\x1b`, `\x7f`, `\u009b`) instead of +/// written raw. For text somebody else authored (a registry manifest, a +/// dependency's `package.json`): printed raw, an ESC/CR/C1 sequence can erase +/// or repaint the line it is shown on and a newline can forge further lines +/// of our output. Everything else passes through unchanged. +pub struct EscapeControlChars(pub T); + +/// [`EscapeControlChars`] for a value that is legitimately multi-line and is +/// printed on its own (`bun pm view readme`): `\t`, `\n` and `\r\n` +/// pass through. A `\r` not followed by `\n` is still escaped; on a terminal +/// it only overwrites the line printed so far. +pub struct EscapeControlCharsMultiline(pub T); + +/// [`EscapeControlChars`] over raw bytes; invalid UTF-8 renders as U+FFFD. +pub fn escape_control_chars(text: &[u8]) -> EscapeControlChars<&bstr::BStr> { + EscapeControlChars(bstr::BStr::new(text)) +} + +/// [`EscapeControlCharsMultiline`] over raw bytes; invalid UTF-8 renders as U+FFFD. +pub fn escape_control_chars_multiline(text: &[u8]) -> EscapeControlCharsMultiline<&bstr::BStr> { + EscapeControlCharsMultiline(bstr::BStr::new(text)) +} + +impl Display for EscapeControlChars { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut writer = EscapeControlCharsWriter { + f, + keep_line_breaks: false, + }; + write!(writer, "{}", self.0) + } +} + +impl Display for EscapeControlCharsMultiline { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut writer = EscapeControlCharsWriter { + f, + keep_line_breaks: true, + }; + write!(writer, "{}", self.0) + } +} + +struct EscapeControlCharsWriter<'a, 'f> { + f: &'a mut Formatter<'f>, + keep_line_breaks: bool, +} + +impl fmt::Write for EscapeControlCharsWriter<'_, '_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let mut start = 0; + let mut chars = s.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + let pass_through = match c { + '\t' | '\n' => self.keep_line_breaks, + '\r' => self.keep_line_breaks && matches!(chars.peek(), Some((_, '\n'))), + '\0'..='\x1f' | '\x7f' | '\u{80}'..='\u{9f}' => false, + _ => true, + }; + if pass_through { + continue; + } + self.f.write_str(&s[start..i])?; + match c { + '\n' => self.f.write_str("\\n")?, + '\r' => self.f.write_str("\\r")?, + '\t' => self.f.write_str("\\t")?, + c if c.is_ascii() => write!(self.f, "\\x{:02x}", c as u32)?, + c => write!(self.f, "\\u{:04x}", c as u32)?, + } + start = i + c.len_utf8(); + } + self.f.write_str(&s[start..]) + } +} + // js_bindings (fmtString for highlighter.test.ts) lives in src/jsc/fmt_jsc.rs // alongside fmt_jsc.bind.ts; bun_core/ stays JSC-free. diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index ebeca184aac6..205293552cce 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -316,7 +316,10 @@ pub(crate) fn view( bun_fmt::format_json_string_utf8(slice, Default::default()) )); } else { - Output::print(format_args!("{}\n", BStr::new(slice))); + Output::print(format_args!( + "{}\n", + bun_fmt::escape_control_chars_multiline(slice) + )); } Output::flush(); return Ok(()); @@ -416,21 +419,23 @@ pub(crate) fn view( .len_u32() as usize; } + // Every string printed below is registry-controlled: always go through + // `escape_control_chars`. prettyln!( "{}@{} | {} | deps: {} | versions: {}", - BStr::new(pkg_name), - BStr::new(pkg_version), - BStr::new(license), + bun_fmt::escape_control_chars(pkg_name), + bun_fmt::escape_control_chars(pkg_version), + bun_fmt::escape_control_chars(license), dep_count, versions_len, ); // Get description and homepage from the top-level package manifest, not the version-specific one if let Some(desc) = json.get_string_cloned(&bump, b"description").ok().flatten() { - prettyln!("{}", BStr::new(desc)); + prettyln!("{}", bun_fmt::escape_control_chars(desc)); } if let Some(hp) = json.get_string_cloned(&bump, b"homepage").ok().flatten() { - prettyln!("{}", BStr::new(hp)); + prettyln!("{}", bun_fmt::escape_control_chars(hp)); } if let Some(mut iter) = json.get_array(b"keywords") { @@ -447,7 +452,10 @@ pub(crate) fn view( } } if !keywords.list.is_empty() { - prettyln!("keywords: {}", BStr::new(keywords.list.as_slice())); + prettyln!( + "keywords: {}", + bun_fmt::escape_control_chars(keywords.list.as_slice()) + ); } } @@ -481,8 +489,8 @@ pub(crate) fn view( }; prettyln!( "- {}: {}", - BStr::new(dep_name), - BStr::new(dep_version), + bun_fmt::escape_control_chars(dep_name), + bun_fmt::escape_control_chars(dep_version), ); } } @@ -490,13 +498,22 @@ pub(crate) fn view( if let Some(dist) = manifest.get_object(b"dist") { prettyln!("\ndist"); if let Some(t) = dist.get_string_cloned(&bump, b"tarball").ok().flatten() { - prettyln!(" .tarball: {}", BStr::new(t)); + prettyln!( + " .tarball: {}", + bun_fmt::escape_control_chars(t) + ); } if let Some(s) = dist.get_string_cloned(&bump, b"shasum").ok().flatten() { - prettyln!(" .shasum: {}", BStr::new(s)); + prettyln!( + " .shasum: {}", + bun_fmt::escape_control_chars(s) + ); } if let Some(i) = dist.get_string_cloned(&bump, b"integrity").ok().flatten() { - prettyln!(" .integrity: {}", BStr::new(i)); + prettyln!( + " .integrity: {}", + bun_fmt::escape_control_chars(i) + ); } if let Some(u) = dist.get_number(b"unpackedSize") { prettyln!( @@ -522,12 +539,14 @@ pub(crate) fn view( let val_expr = prop.value.as_ref().expect("infallible: prop has value"); if let Some(tag) = tagname_expr.as_string(&bump) { if let Some(val) = val_expr.as_string(&bump) { + let tag_fmt = bun_fmt::escape_control_chars(tag); + let val_fmt = bun_fmt::escape_control_chars(val); if tag == b"latest" { - prettyln!("{}: {}", BStr::new(tag), BStr::new(val)); + prettyln!("{}: {}", tag_fmt, val_fmt); } else if tag == b"beta" { - prettyln!("{}: {}", BStr::new(tag), BStr::new(val)); + prettyln!("{}: {}", tag_fmt, val_fmt); } else { - prettyln!("{}: {}", BStr::new(tag), BStr::new(val)); + prettyln!("{}: {}", tag_fmt, val_fmt); } } } @@ -548,9 +567,13 @@ pub(crate) fn view( .flatten() .unwrap_or(b""); if !em.is_empty() { - prettyln!("- {} \\<{}\\>", BStr::new(nm), BStr::new(em)); + prettyln!( + "- {} \\<{}\\>", + bun_fmt::escape_control_chars(nm), + bun_fmt::escape_control_chars(em) + ); } else if !nm.is_empty() { - prettyln!("- {}", BStr::new(nm)); + prettyln!("- {}", bun_fmt::escape_control_chars(nm)); } } } @@ -563,13 +586,19 @@ pub(crate) fn view( .ok() .flatten() { - prettyln!("\nPublished: {}", BStr::new(published_time)); + prettyln!( + "\nPublished: {}", + bun_fmt::escape_control_chars(published_time) + ); } else if let Some(modified_time) = time_obj .get_string_cloned(&bump, b"modified") .ok() .flatten() { - prettyln!("\nPublished: {}", BStr::new(modified_time)); + prettyln!( + "\nPublished: {}", + bun_fmt::escape_control_chars(modified_time) + ); } } diff --git a/test/cli/install/bun-info.test.ts b/test/cli/install/bun-info.test.ts index 04b1d9fe80b7..17715a244f57 100644 --- a/test/cli/install/bun-info.test.ts +++ b/test/cli/install/bun-info.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isASAN, tempDir, tempDirWithFiles } from "harness"; import { join } from "node:path"; describe.concurrent("bun info", () => { @@ -372,6 +372,101 @@ describe.concurrent("bun info", () => { }); }); +describe.concurrent("bun info with control characters in the packument", () => { + // One of each kind of byte the registry must not be able to write to the + // terminal: ESC (clear screen, then an OSC title change), BEL, a bare CR + // (overwrites the line), CRLF, tab, DEL, and the C1 controls NEL (U+0085) + // and CSI (U+009B). + const hostile = "\x1b[2J\x1b]0;PWNED\x07\rX\r\nY\tZ\x7f\u0085\u009b[31m"; + // How the summary view renders it: every control becomes a visible escape. + const escaped = String.raw`\x1b[2J\x1b]0;PWNED\x07\rX\r\nY\tZ\x7f\u0085\u009b[31m`; + // How `bun pm view ` renders it: CRLF and tab are kept since + // the field itself may be multi-line (readme), everything else is escaped. + const escapedMultiline = String.raw`\x1b[2J\x1b]0;PWNED\x07\rX` + "\r\nY\tZ" + String.raw`\x7f\u0085\u009b[31m`; + + const packument = { + name: "hostile", + "dist-tags": { latest: "1.0.0", ["tag" + hostile]: "1.0.0" }, + description: "D" + hostile, + homepage: "H" + hostile, + keywords: ["K" + hostile, "plain"], + maintainers: [{ name: "M" + hostile, email: "E" + hostile }, { name: "N" + hostile }], + time: { "1.0.0": "P" + hostile }, + versions: { + "1.0.0": { + name: "n" + hostile, + version: "1.0.0", + license: "L" + hostile, + description: "D" + hostile, + dependencies: { ["dep" + hostile]: "^1" + hostile }, + dist: { tarball: "T" + hostile, shasum: "S" + hostile, integrity: "I" + hostile }, + }, + }, + }; + + async function view(...args: string[]) { + await using server = Bun.serve({ + port: 0, + fetch: () => Response.json(packument), + }); + using dir = tempDir("bun-info-control-chars", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0" }), + }); + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: { ...bunEnv, NPM_CONFIG_REGISTRY: server.url.href }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it("escapes every field of the summary view", async () => { + const { stdout, stderr, exitCode } = await view("info", "hostile"); + + // Only the newlines bun itself prints between lines may remain. + expect(stdout).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]/); + expect(stdout.replaceAll(escaped, "")).toMatchInlineSnapshot(` + "n@1.0.0 | L | deps: 1 | versions: 1 + D + H + keywords: K, plain + + dependencies (1): + - dep: ^1 + + dist + .tarball: T + .shasum: S + .integrity: I + + dist-tags: + latest: 1.0.0 + tag: 1.0.0 + + maintainers: + - M > + - N + + Published: P + " + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + it("keeps line structure but escapes the rest when printing a single string field", async () => { + const { stdout, stderr, exitCode } = await view("pm", "view", "hostile", "description"); + + expect(stdout).toBe(`D${escapedMultiline}\n`); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + // LSan's default conservative scan only flags the `send_sync` response-metadata // leak when no idle thread parks with a stale pointer in a callee-saved // register; excluding registers as roots makes the check deterministic. From 5acc84e24c9a516db6714e2e751377d58cfb7e0e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:19 +0000 Subject: [PATCH 038/258] install: redact secrets in the URLs printed for failed manifest and tarball downloads (#38817) --- src/install/PackageManager/runTasks.rs | 8 +- src/install/isolated_install/Installer.rs | 4 +- test/cli/install/redacted-config-logs.test.ts | 131 ++++++++++++++++++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/install/PackageManager/runTasks.rs b/src/install/PackageManager/runTasks.rs index dd674fb65f00..106025bbfaa9 100644 --- a/src/install/PackageManager/runTasks.rs +++ b/src/install/PackageManager/runTasks.rs @@ -488,7 +488,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + bun_core::fmt::redacted_npm_url(metadata.url.slice()), response.status_code, ); } else { @@ -497,7 +497,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + bun_core::fmt::redacted_npm_url(metadata.url.slice()), response.status_code, ); } @@ -838,7 +838,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + bun_core::fmt::redacted_npm_url(metadata.url.slice()), response.status_code, ); } else { @@ -847,7 +847,7 @@ pub fn run_tasks( None, bun_ast::Loc::EMPTY, "GET {} - {}", - bstr::BStr::new(metadata.url.slice()), + bun_core::fmt::redacted_npm_url(metadata.url.slice()), response.status_code, ); } diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index 12383a8c66a8..e95ff866ff21 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -318,7 +318,7 @@ impl<'a> Installer<'a> { bstr::BStr::new(name), resolution.fmt(string_buf, bun_core::fmt::PathSep::Auto), bstr::BStr::new(download_error_reason(err)), - bstr::BStr::new(url), + bun_core::fmt::redacted_npm_url(url), ), ); Output::flush(); @@ -429,7 +429,7 @@ impl<'a> Installer<'a> { bstr::BStr::new(pkg_name.slice(string_buf)), pkg_res.fmt(string_buf, bun_core::fmt::PathSep::Auto), bstr::BStr::new(download_error_reason(dl.err)), - bstr::BStr::new(&dl.url), + bun_core::fmt::redacted_npm_url(&dl.url), ), ); } diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 6c49d43123b4..31b8752e0cbc 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -243,3 +243,134 @@ describe.concurrent("redact", async () => { }); } }); + +// Covers the URL bun reports when a manifest or tarball request fails: the +// manifest URL (after redirects) and the manifest's dist.tarball. Those come +// from the registry, so a password or token in them is not something the user +// wrote down themselves. The registry URL bun is configured with below carries +// no secret; the secrets only enter through the redirect and dist.tarball. +describe.concurrent("bun install masks secrets in the registry-supplied URL it prints after a failed download", () => { + const password = "s3cret"; + const token = "npm_" + "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8"; + + function startRegistry() { + return Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + const secretOrigin = `http://carol:${password}@${url.host}`; + switch (url.pathname) { + case "/redirected-pkg": + return Response.redirect(`${secretOrigin}/private/redirected-pkg?token=${token}`, 302); + case "/tarball-pkg": + return Response.json({ + name: "tarball-pkg", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "tarball-pkg", + version: "1.0.0", + dist: { tarball: `${secretOrigin}/cdn/tarball-pkg-1.0.0.tgz?token=${token}` }, + }, + }, + }); + default: + // Every other request, in particular the redirect target and the tarball, fails. + return new Response("not found", { status: 404 }); + } + }, + }); + } + + type Registry = ReturnType; + + async function install(server: Registry, args: string[], files: Record) { + using dir = tempDir("redacted-install-url", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--registry", `http://${server.hostname}:${server.port}/`, ...args], + cwd: String(dir), + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(out).not.toContain(password); + expect(out).not.toContain(token); + expect(err).not.toContain(password); + expect(err).not.toContain(token); + return { err, exitCode }; + } + + const masked = (server: Registry) => `http://carol:******@${server.hostname}:${server.port}`; + + test("manifest request redirected to a URL with secrets (required dependency)", async () => { + await using server = startRegistry(); + const { err, exitCode } = await install(server, [], { + "package.json": JSON.stringify({ name: "app", dependencies: { "redirected-pkg": "1.0.0" } }), + }); + expect(err).toContain(`error: GET ${masked(server)}/private/redirected-pkg?token=*** - 404`); + expect(exitCode).toBe(1); + }); + + test("manifest request redirected to a URL with secrets (optional dependency)", async () => { + await using server = startRegistry(); + const { err, exitCode } = await install(server, [], { + "package.json": JSON.stringify({ name: "app", optionalDependencies: { "redirected-pkg": "1.0.0" } }), + }); + expect(err).toContain(`warn: GET ${masked(server)}/private/redirected-pkg?token=*** - 404`); + expect(exitCode).toBe(0); + }); + + test("dist.tarball URL with secrets (required dependency)", async () => { + await using server = startRegistry(); + const { err, exitCode } = await install(server, [], { + "package.json": JSON.stringify({ name: "app", dependencies: { "tarball-pkg": "1.0.0" } }), + }); + expect(err).toContain(`error: GET ${masked(server)}/cdn/tarball-pkg-1.0.0.tgz?token=*** - 404`); + expect(exitCode).toBe(1); + }); + + test("dist.tarball URL with secrets (optional dependency)", async () => { + await using server = startRegistry(); + const { err, exitCode } = await install(server, [], { + "package.json": JSON.stringify({ name: "app", optionalDependencies: { "tarball-pkg": "1.0.0" } }), + }); + expect(err).toContain(`warn: GET ${masked(server)}/cdn/tarball-pkg-1.0.0.tgz?token=*** - 404`); + expect(exitCode).toBe(0); + }); + + test("dist.tarball URL recorded in the lockfile, downloaded by the isolated linker", async () => { + await using server = startRegistry(); + // What a previous install writes to bun.lock for an npm package whose + // dist.tarball is not the registry's default tarball location. + const tarball = `http://carol:${password}@${server.hostname}:${server.port}/cdn/tarball-pkg-1.0.0.tgz?token=${token}`; + const { err, exitCode } = await install(server, ["--linker", "isolated"], { + "package.json": JSON.stringify({ name: "app", dependencies: { "tarball-pkg": "1.0.0" } }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "app", dependencies: { "tarball-pkg": "1.0.0" } } }, + packages: { "tarball-pkg": ["tarball-pkg@1.0.0", tarball, {}, ""] }, + }), + }); + expect(err).toContain( + `error: failed to download tarball-pkg@1.0.0: 404 Not Found\n ${masked(server)}/cdn/tarball-pkg-1.0.0.tgz?token=***`, + ); + expect(exitCode).toBe(1); + }); + + // A tarball URL written directly into package.json is printed as the + // dependency's resolution by other lines ("@ failed to resolve", + // and the "failed to download @" prefix of the isolated linker + // message checked above), and those still print it verbatim. + test.todo("tarball URL written in package.json is masked wherever it is echoed", async () => { + await using server = startRegistry(); + const spec = `http://carol:${password}@${server.hostname}:${server.port}/cdn/direct-1.0.0.tgz?token=${token}`; + for (const linker of ["hoisted", "isolated"]) { + const { err, exitCode } = await install(server, ["--linker", linker], { + "package.json": JSON.stringify({ name: "app", dependencies: { direct: spec } }), + }); + expect(err).toContain(`error: GET ${masked(server)}/cdn/direct-1.0.0.tgz?token=*** - 404`); + expect(exitCode).toBe(1); + } + }); +}); From d4308e42e9806ffefe55f09989e1c84bc1d3ac43 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:23 +0000 Subject: [PATCH 039/258] install: redact the registry URL in the manifest/tarball URL errors and keep namedRegistries credentials out of bun.lock (#38997) --- src/install/NetworkTask.rs | 45 +++++--- src/install/pnpm.rs | 6 +- .../install/migration/pnpm-lock-v9.test.ts | 59 ++++++++++ test/cli/install/redacted-config-logs.test.ts | 102 ++++++++++++++++++ 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 615177ac1646..8c2cef98922f 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -4,14 +4,15 @@ use core::sync::atomic::Ordering; use crate::bun_fs::{FileSystem, FilenameStore}; use bun_collections::HashMap; -use bun_core::{self, fmt::quote}; -use bun_core::{MutableString, strings}; +use bun_core::fmt::{quote, redacted_npm_url}; +use bun_core::{self, MutableString, strings}; use bun_http::{ self as http, AsyncHTTP, HTTPClientResult, HTTPClientResultCallback, HTTPVerboseLevel, HeaderBuilder, async_http::Options as AsyncHTTPOptions, }; use bun_threading::thread_pool::Batch; use bun_url::URL; +use std::io::Write as _; use crate::extract_tarball; use crate::npm::{self as npm, PackageManifest}; @@ -435,6 +436,13 @@ impl bun_core::output::ErrName for ForManifestError { } } +/// Redacted before `quote()`: the password scan only recognizes an unquoted URL. +fn redacted_url(url: &[u8]) -> Vec { + let mut out: Vec = Vec::new(); + let _ = write!(out, "{}", redacted_npm_url(url)); + out +} + impl NetworkTask { pub(crate) fn for_manifest( &mut self, @@ -471,13 +479,14 @@ impl NetworkTask { )); if tmp.tag() == bun_core::Tag::Dead { + let redacted_registry = redacted_url(scope.url.href()); if !is_optional { log.add_error_fmt( None, bun_ast::Loc::EMPTY, format_args!( "Failed to join registry {} and package {} URLs", - quote(scope.url.href()), + quote(&redacted_registry), quote(name), ), ); @@ -487,7 +496,7 @@ impl NetworkTask { bun_ast::Loc::EMPTY, format_args!( "Failed to join registry {} and package {} URLs", - quote(scope.url.href()), + quote(&redacted_registry), quote(name), ), ); @@ -495,14 +504,18 @@ impl NetworkTask { return Err(ForManifestError::InvalidURL); } + // This actually duplicates the string! So we defer deref the WTF managed one above. + let url_bytes = tmp.to_owned_slice().into_boxed_slice(); + if !(tmp.has_prefix_comptime(b"https://") || tmp.has_prefix_comptime(b"http://")) { + let redacted_manifest_url = redacted_url(&url_bytes); if !is_optional { log.add_error_fmt( None, bun_ast::Loc::EMPTY, format_args!( - "Registry URL must be http:// or https://\nReceived: \"{}\"", - *tmp + "Registry URL must be http:// or https://\nReceived: {}", + quote(&redacted_manifest_url) ), ); } else { @@ -510,17 +523,14 @@ impl NetworkTask { None, bun_ast::Loc::EMPTY, format_args!( - "Registry URL must be http:// or https://\nReceived: \"{}\"", - *tmp + "Registry URL must be http:// or https://\nReceived: {}", + quote(&redacted_manifest_url) ), ); } return Err(ForManifestError::InvalidURL); } - // This actually duplicates the string! So we defer deref the WTF managed one above. - let url_bytes = tmp.to_owned_slice().into_boxed_slice(); - { let joined = URL::parse(&url_bytes); let registry = scope.url.url(); @@ -532,6 +542,8 @@ impl NetworkTask { || joined.get_port_auto() != registry.get_port_auto() || !joined.pathname.starts_with(registry_dir) { + let redacted_manifest_url = redacted_url(&url_bytes); + let redacted_registry = redacted_url(scope.url.href()); if !is_optional { log.add_error_fmt( None, @@ -539,8 +551,8 @@ impl NetworkTask { format_args!( "Invalid package name {}: manifest URL {} is not on registry {}", quote(name), - quote(&url_bytes), - quote(scope.url.href()), + quote(&redacted_manifest_url), + quote(&redacted_registry), ), ); } else { @@ -550,8 +562,8 @@ impl NetworkTask { format_args!( "Invalid package name {}: manifest URL {} is not on registry {}", quote(name), - quote(&url_bytes), - quote(scope.url.href()), + quote(&redacted_manifest_url), + quote(&redacted_registry), ), ); } @@ -770,6 +782,7 @@ impl NetworkTask { }; if !(self.url_buf.starts_with(b"https://") || self.url_buf.starts_with(b"http://")) { + let redacted_tarball_url = redacted_url(&self.url_buf); // SAFETY: `pm.log` is the long-lived `*mut Log` the package // manager was constructed with. pm.log_mut().add_error_fmt( @@ -777,7 +790,7 @@ impl NetworkTask { bun_ast::Loc::EMPTY, format_args!( "Expected tarball URL to start with https:// or http://, got {} while fetching package {}", - quote(&self.url_buf), + quote(&redacted_tarball_url), quote(tarball.name.slice()), ), ); diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index 479beef92007..17aa9c36d83b 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -243,7 +243,9 @@ fn read_named_registries( let key = prop.key.as_ref().expect("infallible: prop has key"); let value = prop.value.as_ref().expect("infallible: prop has value"); if let (Some(name_str), Some(url_str)) = (as_string(key), as_string(value)) { - registries.put(name_str, Box::from(url_str))?; + // Without its credentials: this URL is recorded in bun.lock as the tarball base. + let url = crate::bun_schema::api::NpmRegistry::from_url(url_str).url; + registries.put(name_str, url)?; } } } @@ -1311,7 +1313,7 @@ pub(crate) fn migrate_pnpm_lockfile<'a>( bun_core::warn!( "fetching pnpm registry \"{}\" packages from {}; add it to bunfig.toml or .npmrc if it needs authentication", bstr::BStr::new(registry_name), - bstr::BStr::new(url) + bun_core::fmt::redacted_npm_url(url) ); } &**url diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index a08d0dbe23cd..d86648e17ccb 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -412,6 +412,65 @@ snapshots: `); }); + test("credentials in a namedRegistries URL stay out of bun.lock and a token in its path is masked in the warning", async () => { + const password = "s3cret"; + const token = "npm_" + "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8"; + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ name: "named-registry-secrets", dependencies: { "no-deps": "^1.0.0" } }), + "pnpm-workspace.yaml": `namedRegistries:\n work: http://carol:${password}@127.0.0.1:1/${token}/\n`, + "pnpm-lock.yaml": registryQualifiedNoDepsLockfile("work"), + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).toContain( + 'warn: fetching pnpm registry "work" packages from http://127.0.0.1:1/***/; add it to bunfig.toml or .npmrc if it needs authentication', + ); + expect(stderr).not.toContain(password); + expect(stderr).not.toContain(token); + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + // The token is part of the tarball location; the credentials are not. + const bunLock = await bunLockOf(packageDir); + expect(bunLock).toContain( + `["no-deps@1.0.1", "http://127.0.0.1:1/${token}/no-deps/-/no-deps-1.0.1.tgz", {}, "${NO_DEPS_1_0_1_INTEGRITY}"]`, + ); + expect(bunLock).not.toContain(password); + }); + + test("namedRegistries entry naming the configured registry with credentials in the URL needs no warning", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ name: "named-registry-same-creds", dependencies: { "no-deps": "^1.0.0" } }), + "pnpm-workspace.yaml": `namedRegistries:\n work: ${verdaccio.registryUrl().replace("http://", "http://carol:s3cret@")}\n`, + "pnpm-lock.yaml": registryQualifiedNoDepsLockfile("work"), + }, + }); + + const { stderr, exitCode } = await migrate(packageDir); + + expect(stderr).not.toContain("pnpm registry"); + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(exitCode).toBe(0); + + const bunLock = await bunLockOf(packageDir); + expect(bunLock).toContain(`"no-deps@1.0.1"`); + expect(bunLock).not.toContain("s3cret"); + expect(bunLock).not.toContain("work:"); + + const install = await run(packageDir, "install", "--frozen-lockfile"); + + expect(install.stderr).not.toContain("error:"); + expect(install.exitCode).toBe(0); + expect(nodeModulesPackages(packageDir)).toMatchInlineSnapshot(`"node_modules/no-deps/no-deps@1.0.1"`); + }); + test("two packages from one unknown registry warn once", async () => { const { packageDir } = await verdaccio.createTestDir({ bunfigOpts: { linker: "hoisted" }, diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 31b8752e0cbc..015ef1bdf4d2 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -141,6 +141,108 @@ test("bunfig password value is masked in config error output", async () => { expect(coloredExit).toBe(1); }); +// The config loaders split user:password@ off a registry URL, but a token +// written as a path segment stays part of it, and these messages echo that +// URL (or the manifest / tarball URL built from it) when no request can be +// made out of it. None of the cases below sends a request. +describe.concurrent("bun install masks the configured registry URL in the messages that echo it", () => { + const token = "npm_" + "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8"; + const packageJson = (deps: Record>) => JSON.stringify({ name: "app", ...deps }); + + const cases: { + title: string; + registry: string; + files: Record; + expected: string; + exitCode: number; + }[] = [ + { + title: "registry URL that is not a valid base URL (dependency)", + registry: `http://ex ample.org/${token}/`, + files: { "package.json": packageJson({ dependencies: { notapackage: "1.0.0" } }) }, + expected: `error: Failed to join registry "http://ex ample.org/***/" and package "notapackage" URLs\n`, + exitCode: 1, + }, + { + title: "registry URL that is not a valid base URL (optional dependency)", + registry: `http://ex ample.org/${token}/`, + files: { "package.json": packageJson({ optionalDependencies: { notapackage: "1.0.0" } }) }, + expected: `warn: Failed to join registry "http://ex ample.org/***/" and package "notapackage" URLs\n`, + exitCode: 0, + }, + // The alias makes ".." the name the manifest is requested for. It joins to + // the registry's parent directory, so the manifest URL no longer contains + // the token segment; the registry URL printed next to it does. + { + title: "manifest URL outside the registry directory (dependency)", + registry: `http://127.0.0.1:1/${token}/`, + files: { "package.json": packageJson({ dependencies: { innocent: "npm:..@1.0.0" } }) }, + expected: `error: Invalid package name "..": manifest URL "http://127.0.0.1:1/" is not on registry "http://127.0.0.1:1/***/"\n`, + exitCode: 1, + }, + { + title: "manifest URL outside the registry directory (optional dependency)", + registry: `http://127.0.0.1:1/${token}/`, + files: { "package.json": packageJson({ optionalDependencies: { innocent: "npm:..@1.0.0" } }) }, + expected: `warn: Invalid package name "..": manifest URL "http://127.0.0.1:1/" is not on registry "http://127.0.0.1:1/***/"\n`, + exitCode: 0, + }, + { + title: "registry URL with a non-http scheme (dependency)", + registry: `htp://127.0.0.1:1/${token}/`, + files: { "package.json": packageJson({ dependencies: { notapackage: "1.0.0" } }) }, + expected: `error: Registry URL must be http:// or https://\nReceived: "htp://127.0.0.1:1/***/notapackage"\n`, + exitCode: 1, + }, + { + title: "registry URL with a non-http scheme (optional dependency)", + registry: `htp://127.0.0.1:1/${token}/`, + files: { "package.json": packageJson({ optionalDependencies: { notapackage: "1.0.0" } }) }, + expected: `warn: Registry URL must be http:// or https://\nReceived: "htp://127.0.0.1:1/***/notapackage"\n`, + exitCode: 0, + }, + { + title: "tarball URL built from a registry URL with a non-http scheme", + registry: `htp://127.0.0.1:1/${token}/`, + files: { + "package.json": packageJson({ dependencies: { pkg: "1.0.0" } }), + // An empty URL in bun.lock stands for the configured registry's + // default tarball location, so no manifest is fetched first. + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "app", dependencies: { pkg: "1.0.0" } } }, + packages: { pkg: ["pkg@1.0.0", "", {}, ""] }, + }), + }, + expected: `error: Expected tarball URL to start with https:// or http://, got "htp://127.0.0.1:1/***/pkg/-/pkg-1.0.0.tgz" while fetching package "pkg"\n`, + exitCode: 1, + }, + ]; + + for (const { title, registry, files, expected, exitCode } of cases) { + test(title, async () => { + using dir = tempDir("redacted-registry-token", { + ...files, + "bunfig.toml": `[install]\nregistry = ${JSON.stringify(registry)}\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") }, + stdout: "pipe", + stderr: "pipe", + }); + + const [out, err, exited] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(err).toContain(expected); + expect(err).not.toContain(token); + expect(out).not.toContain(token); + expect(exited).toBe(exitCode); + }); + } +}); + describe.concurrent("redact", async () => { const tests = [ { From 058682fa1a717491db41afefabd910cd6ce8f3f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:27 +0000 Subject: [PATCH 040/258] install: keep registry credentials across redirects to the same hostname (#38001) --- src/http/lib.rs | 99 ++++++++++++----- src/install/NetworkTask.rs | 4 + test/cli/install/bun-install-retry.test.ts | 25 +++-- test/cli/install/bun-install.test.ts | 122 +++++++++++++++++++++ 4 files changed, 211 insertions(+), 39 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index 57bfb2fccb51..bd6db45186f6 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -106,6 +106,17 @@ pub enum Protocol { Http3, } +/// Which redirects the request's credential headers survive. +#[repr(u8)] +#[derive(Copy, Clone, PartialEq, Eq, Default)] +pub enum RedirectCredentialsPolicy { + /// fetch(): https://fetch.spec.whatwg.org/#concept-http-redirect-fetch + #[default] + SameOrigin, + /// npm's rule, used by `bun install`: port and scheme changes keep them, except https -> http. + SameHostname, +} + pub use bun_http_types::Encoding::Encoding; pub use header_value_iterator::{ HeaderValueIterator, connection_header_keep_alive, upgrade_header_is_not_h2, @@ -214,6 +225,7 @@ pub struct Flags { pub forced_protocol: Option, pub(crate) h3_retried: bool, pub is_node_http_client: bool, + pub redirect_credentials: RedirectCredentialsPolicy, } impl Default for Flags { @@ -235,6 +247,7 @@ impl Default for Flags { forced_protocol: None, h3_retried: false, is_node_http_client: false, + redirect_credentials: RedirectCredentialsPolicy::SameOrigin, } } } @@ -1071,20 +1084,56 @@ bun_core::comptime_string_map! { }; } +#[derive(Copy, Clone)] +enum StrippedOnRedirect { + /// Per `Flags::redirect_credentials`. + Credential, + /// Would suppress the default Host header derived from the new URL. + Host, +} + bun_core::comptime_string_map! { /// Headers deleted from the request on a cross-origin redirect. - /// `host` is included because a user-supplied Host header names the - /// previous origin; keeping it would also suppress the default Host - /// header derived from the new URL. /// Keys are lowercase: looked up via `get_ascii_case_insensitive`. - static CROSS_ORIGIN_STRIPPED_REQUEST_HEADERS: () = { - b"authorization" => (), - b"proxy-authorization" => (), - b"cookie" => (), - b"host" => (), + static CROSS_ORIGIN_STRIPPED_REQUEST_HEADERS: StrippedOnRedirect = { + b"authorization" => StrippedOnRedirect::Credential, + b"proxy-authorization" => StrippedOnRedirect::Credential, + b"cookie" => StrippedOnRedirect::Credential, + b"host" => StrippedOnRedirect::Host, }; } +#[derive(Copy, Clone)] +struct RedirectHop { + same_origin: bool, + same_hostname: bool, + /// https -> http; credentials never follow this hop (cleartext replay). + downgrades_to_http: bool, +} + +impl RedirectHop { + fn between(from: &URL<'_>, to: &URL<'_>) -> Self { + Self { + same_origin: strings::eql_case_insensitive_ascii( + strings::without_trailing_slash(to.origin), + strings::without_trailing_slash(from.origin), + true, + ), + same_hostname: strings::eql_case_insensitive_ascii(to.hostname, from.hostname, true), + downgrades_to_http: from.is_https() && !to.is_https(), + } + } + + fn strips_credentials(self, policy: RedirectCredentialsPolicy) -> bool { + match policy { + RedirectCredentialsPolicy::SameOrigin => !self.same_origin, + RedirectCredentialsPolicy::SameHostname => { + !self.same_hostname || self.downgrades_to_http + } + } + } +} + // ── shared per-thread buffers ─────────────────────────────────────────── // All four are HTTP-thread-only scratch (single uws loop thread); `RacyCell` // is the alias-safe static cell per docs/PORTING.md §Global mutable state. @@ -5023,7 +5072,7 @@ impl<'a> HTTPClient<'a> { { return Err(crate::Error::RequestBodyNotReusable); } - let is_same_origin; + let hop: RedirectHop; { if let Some(i) = strings::index_of(location, b"://") { @@ -5087,11 +5136,7 @@ impl<'a> HTTPClient<'a> { // `self.redirect` below, which lives as long as `self` (≥ `'a`). let new_url: URL<'a> = unsafe { URL::parse(&normalized_url_str).erase_lifetime() }; - is_same_origin = strings::eql_case_insensitive_ascii( - strings::without_trailing_slash(new_url.origin), - strings::without_trailing_slash(self.url.origin), - true, - ); + hop = RedirectHop::between(&self.url, &new_url); self.url = new_url; // connected_url still borrows from the previous hop's buffer // until doRedirect releases the socket, so park it in @@ -5142,11 +5187,7 @@ impl<'a> HTTPClient<'a> { // `self.redirect` below, which lives as long as `self` (≥ `'a`). let new_url: URL<'a> = unsafe { URL::parse(&normalized_url_str).erase_lifetime() }; - is_same_origin = strings::eql_case_insensitive_ascii( - strings::without_trailing_slash(new_url.origin), - strings::without_trailing_slash(self.url.origin), - true, - ); + hop = RedirectHop::between(&self.url, &new_url); self.url = new_url; debug_assert!(self.prev_redirect.is_empty()); self.prev_redirect = @@ -5170,11 +5211,7 @@ impl<'a> HTTPClient<'a> { // SAFETY: self-borrow — `new_url` is moved into `self.redirect` // below, which lives as long as `self` (≥ `'a`). self.url = unsafe { parsed_url.erase_lifetime() }; - is_same_origin = strings::eql_case_insensitive_ascii( - strings::without_trailing_slash(self.url.origin), - strings::without_trailing_slash(original_url.origin), - true, - ); + hop = RedirectHop::between(&original_url, &self.url); debug_assert!(self.prev_redirect.is_empty()); self.prev_redirect = core::mem::replace(&mut self.redirect, new_url); } @@ -5214,7 +5251,7 @@ impl<'a> HTTPClient<'a> { // Cross-origin redirect: re-derive SNI / cert // verification / Host from the redirect target. See // `InternalStateFlags::clear_hostname_on_redirect`. - if !is_same_origin { + if !hop.same_origin { self.state.flags.clear_hostname_on_redirect = true; } @@ -5223,14 +5260,20 @@ impl<'a> HTTPClient<'a> { // locationURL's origin, then for each headerName of CORS // non-wildcard request-header name, delete headerName from // request's header list. - if !is_same_origin && self.header_entries.len() > 0 { + // The credential headers follow `Flags::redirect_credentials`. + if !hop.same_origin && self.header_entries.len() > 0 { + let strip_credentials = hop.strips_credentials(self.flags.redirect_credentials); let mut i = 0; while i < self.header_entries.len() { let name = self.header_str(self.header_entries.items_name()[i]); - if CROSS_ORIGIN_STRIPPED_REQUEST_HEADERS + let strip = match CROSS_ORIGIN_STRIPPED_REQUEST_HEADERS .get_ascii_case_insensitive(name) - .is_some() { + Some(StrippedOnRedirect::Host) => true, + Some(StrippedOnRedirect::Credential) => strip_credentials, + None => false, + }; + if strip { let _ = self.header_entries.ordered_remove(i); } else { i += 1; diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 8c2cef98922f..e1fd9aaa0a9e 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -673,6 +673,8 @@ impl NetworkTask { }, )); self.http_mut().client.flags.reject_unauthorized = pm.tls_reject_unauthorized(); + self.http_mut().client.flags.redirect_credentials = + http::RedirectCredentialsPolicy::SameHostname; if PackageManager::verbose_install() { self.http_mut().client.verbose = HTTPVerboseLevel::Headers; @@ -896,6 +898,8 @@ impl NetworkTask { http_options, )); self.http_mut().client.flags.reject_unauthorized = pm.tls_reject_unauthorized(); + self.http_mut().client.flags.redirect_credentials = + http::RedirectCredentialsPolicy::SameHostname; if PackageManager::verbose_install() { self.http_mut().client.verbose = HTTPVerboseLevel::Headers; } diff --git a/test/cli/install/bun-install-retry.test.ts b/test/cli/install/bun-install-retry.test.ts index d9706157a9be..9c34fa6d5c95 100644 --- a/test/cli/install/bun-install-retry.test.ts +++ b/test/cli/install/bun-install-retry.test.ts @@ -94,16 +94,20 @@ it("retries a manifest whose redirect target 500s once", async () => { }); }); -// A cross-origin redirect strips Authorization from the request (per the fetch -// spec). The install retry restarts from the original registry URL and must -// carry the original headers, including Authorization, again. -it("retries an authorized manifest whose cross-origin redirect target 500s once", async () => { - const token = "test-registry-token"; +// The registry (reached as `localhost`) redirects the manifest to a second +// server standing in for a CDN, and the retry restarts from the registry URL. +// 127.0.0.1 is a different hostname, so that hop strips the token and the +// retry must carry the original headers again; localhost: keeps +// it, and the retry must apply the same rule. Which redirects keep the token +// is covered by "registry token across redirects" in bun-install.test.ts. +const token = "test-registry-token"; +it.each([ + { cdnHost: "localhost", expectedCdnAuth: [`Bearer ${token}`, `Bearer ${token}`] }, + { cdnHost: "127.0.0.1", expectedCdnAuth: [null, null] }, +])("retries an authorized manifest redirected to $cdnHost when it 500s once", async ({ cdnHost, expectedCdnAuth }) => { const registryUrls: string[] = []; const cdnAuth: (string | null)[] = []; let cdnHits = 0; - // A second server on its own port stands in for the CDN the registry - // redirects to; a different port makes the redirect cross-origin. await using cdn = Bun.serve({ port: 0, fetch(request) { @@ -133,7 +137,7 @@ it("retries an authorized manifest whose cross-origin redirect target 500s once" } return new Response(null, { status: 302, - headers: { Location: `http://localhost:${cdn.port}/cdn/BaR` }, + headers: { Location: `http://${cdnHost}:${cdn.port}/cdn/BaR` }, }); } if (pathname === "/BaR-0.0.2.tgz") { @@ -168,10 +172,9 @@ it("retries an authorized manifest whose cross-origin redirect target 500s once" expect(err).toContain("Saved lockfile"); expect(out).toContain("1 package installed"); expect(exitCode).toBe(0); - // Both registry hits carried the token (the handler 401s otherwise); the - // cross-origin CDN hops must NOT have (the spec strips it for that hop). + // Both registry hits carried the token (the handler 401s otherwise). expect(registryUrls).toEqual(["/BaR", "/BaR", "/BaR-0.0.2.tgz"]); - expect(cdnAuth).toEqual([null, null]); + expect(cdnAuth).toEqual(expectedCdnAuth); }); // Sibling retry site (tarball downloads in runTasks): the tarball URL diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 5c813fe9de3f..06de24343ffb 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -14,6 +14,7 @@ import { runBunInstall, tempDir, textLockfile, + tls, toBeValidBin, toBeWorkspaceLink, toHaveBins, @@ -855,6 +856,127 @@ describe.concurrent("bun-install", () => { expect(exitCode).toBe(0); }); + // Registries such as Nexus and Artifactory answer manifest and tarball + // requests with a redirect to the same host on another scheme or port + // (https://github.com/oven-sh/bun/issues/15516). Like npm, bun keeps the + // token across such a redirect; it drops it once the redirect leaves the + // registry's hostname, and (unlike npm) when it downgrades https to http. + describe("registry token across redirects", () => { + const token = "secret-registry-token"; + const tgz = join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"); + const integrity = "sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw=="; + const manifestPath = "/no-deps"; + const tarballPath = "/no-deps/-/no-deps-1.0.0.tgz"; + + // The registry lives on `registry`://127.0.0.1; `target` is the scheme and + // hostname it redirects to (the port always differs). https servers use + // the harness certificate, which the install trusts via --cafile. + it.each([ + { + name: "keeps the token when only the port changes", + registry: "http", + target: "http://127.0.0.1", + authorization: `Bearer ${token}`, + }, + { + name: "keeps the token when only the port changes (https registry)", + registry: "https", + target: "https://127.0.0.1", + authorization: `Bearer ${token}`, + }, + { + name: "keeps the token when the redirect upgrades http to https", + registry: "http", + target: "https://127.0.0.1", + authorization: `Bearer ${token}`, + }, + { + name: "drops the token when the redirect downgrades https to http", + registry: "https", + target: "http://127.0.0.1", + authorization: null, + }, + { + // The same machine, but a different hostname than the registry's. + name: "drops the token when the hostname changes", + registry: "http", + target: "http://localhost", + authorization: null, + }, + ])("$name", async ({ registry: registryScheme, target, authorization }) => { + // `target` serves the manifest and the tarball unconditionally and + // records the Authorization header each hop arrived with. + const received: { pathname: string; authorization: string | null }[] = []; + await using targetServer = Bun.serve({ + port: 0, + ...(target.startsWith("https:") ? tls : {}), + fetch(req) { + const { pathname } = new URL(req.url); + received.push({ pathname, authorization: req.headers.get("authorization") }); + if (pathname === manifestPath) { + // `dist.tarball` points at the registry origin so the tarball + // request carries the token in the first place (see the test + // above); the registry then redirects it as well. + return Response.json({ + name: "no-deps", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "no-deps", + version: "1.0.0", + dist: { integrity, tarball: `${registryScheme}://127.0.0.1:${registry.port}${tarballPath}` }, + }, + }, + }); + } + if (pathname === tarballPath) return new Response(file(tgz)); + return new Response("not found", { status: 404 }); + }, + }); + // The registry requires the token and answers every request with a + // redirect to the same path on `target`. + await using registry = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + ...(registryScheme === "https" ? tls : {}), + fetch(req) { + if (req.headers.get("authorization") !== `Bearer ${token}`) { + return new Response("unauthorized", { status: 401 }); + } + return Response.redirect(`${target}:${targetServer.port}${new URL(req.url).pathname}`, 302); + }, + }); + + using dir = tempDir("token-across-redirects", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), + ".npmrc": [ + `registry=${registryScheme}://127.0.0.1:${registry.port}/`, + `//127.0.0.1:${registry.port}/:_authToken=${token}`, + ``, + ].join("\n"), + "cafile": tls.cert, + }); + await using proc = spawn({ + cmd: [bunExe(), "install", "--cafile", "cafile"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stderr, received }).toEqual({ + stderr: expect.stringContaining("Saved lockfile"), + received: [ + { pathname: manifestPath, authorization }, + { pathname: tarballPath, authorization }, + ], + }); + expect(stdout).toContain("1 package installed"); + expect(exitCode).toBe(0); + }); + }); + it("--silent suppresses verbose output even when RUNNER_DEBUG is set", async () => { using dir = tempDir("install-silent-verbose", { "package.json": JSON.stringify({ name: "app", dependencies: {} }), From d8148a0899076a1baa00dc2ce1c5ba45790d9cf0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:31 +0000 Subject: [PATCH 041/258] install: read NPM_CONFIG_USERCONFIG as the user-level .npmrc path (#38047) --- docs/pm/npmrc.mdx | 15 ++++++ src/install/PackageManager.rs | 20 ++++++-- test/cli/install/npmrc.test.ts | 92 +++++++++++++++++++++++++++++++--- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 6e734c9f32bc..adac6df65c44 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -24,6 +24,21 @@ Values may reference environment variables. Bun replaces `${NAME}` with the vari --- +## Which files are read + +Bun reads up to two `.npmrc` files. Options in the later file override the earlier one: + +1. The user-level file. This is the file `NPM_CONFIG_USERCONFIG` (npm's `userconfig` option) points at when that variable is set; otherwise `$XDG_CONFIG_HOME/.npmrc` if it exists, otherwise `$HOME/.npmrc`. +2. The `.npmrc` next to your project's root `package.json`. + + + `actions/setup-node` with `registry-url` writes an `.npmrc` to `$RUNNER_TEMP` and exports `NPM_CONFIG_USERCONFIG` + pointing at it, so `bun publish` reads the token from `NODE_AUTH_TOKEN` in that workflow the same way `npm publish` + does. + + +--- + ## Supported options ### Set the default registry diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 1379fe74aec3..f27026b6f903 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1927,10 +1927,24 @@ pub fn init( let mut buf = PathBuffer::uninit(); let parts = [b"./.npmrc" as &[u8]]; - // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep - // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. let mut global_len: usize = 0; - if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() { + // npm's `userconfig`: when set, this file is the per-user .npmrc and + // neither $XDG_CONFIG_HOME/.npmrc nor ~/.npmrc is looked at. + // actions/setup-node writes one to $RUNNER_TEMP and exports + // NPM_CONFIG_USERCONFIG pointing at it. + if let Some(userconfig) = [b"NPM_CONFIG_USERCONFIG" as &[u8], b"npm_config_userconfig"] + .into_iter() + .find_map(|key| env.get(key).filter(|path| !path.is_empty())) + { + global_len = resolve_path::join_abs_string_buf_z::( + &original_cwd_clone, + &mut buf, + &[userconfig], + ) + .len(); + } else if let Some(xdg_dir) = bun_core::env_var::XDG_CONFIG_HOME.get_not_empty() { + // npm reads `$HOME/.npmrc` and ignores XDG_CONFIG_HOME; keep + // `$XDG_CONFIG_HOME/.npmrc` only when that file actually exists. let p = resolve_path::join_abs_string_buf_z::(xdg_dir, &mut buf, &parts); if bun_sys::exists_z(p) { diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index ec2e6adfcaee..5f8e8516daab 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -172,16 +172,17 @@ registry = http://localhost:${registry.port}/ const npmrc = (port: number) => `registry=http://localhost:${port}/\n//localhost:${port}/:_authToken=token\n`; const pkg = { "pkg/package.json": JSON.stringify({ name: "npmrc-lookup", version: "0.0.1" }) }; - // `publish --dry-run` never contacts the registry, but it still requires a token - // for it and prints which registry it picked up, so it shows which .npmrc was read. - // bunEnv spreads process.env and CI runners commonly export XDG_CONFIG_HOME, so it - // is removed here and each case passes back exactly the value it is testing. - async function publishDryRun(dir: string, envOverride: Record) { + // bunEnv spreads process.env, and CI runners commonly export XDG_CONFIG_HOME or + // (via actions/setup-node) NPM_CONFIG_USERCONFIG, so both are removed here and each + // case passes back exactly the variables it is testing. + async function publish(dir: string, envOverride: Record, ...args: string[]) { const spawnEnv = { ...env, HOME: join(dir, "home"), USERPROFILE: join(dir, "home") }; delete spawnEnv.XDG_CONFIG_HOME; + delete spawnEnv.NPM_CONFIG_USERCONFIG; + delete spawnEnv.npm_config_userconfig; await using proc = Bun.spawn({ - cmd: [bunExe(), "publish", "--dry-run"], + cmd: [bunExe(), "publish", ...args], cwd: join(dir, "pkg"), env: { ...spawnEnv, ...envOverride }, stdout: "pipe", @@ -191,6 +192,10 @@ registry = http://localhost:${registry.port}/ return { stdout, stderr, exitCode }; } + // `publish --dry-run` never contacts the registry, but it still requires a token + // for it and prints which registry it picked up, so it shows which .npmrc was read. + const publishDryRun = (dir: string, envOverride: Record) => publish(dir, envOverride, "--dry-run"); + const usesRegistry = (port: number) => ({ stdout: expect.stringContaining(`Registry: http://localhost:${port}/\n`), stderr: expect.not.stringContaining("missing authentication"), @@ -222,6 +227,81 @@ registry = http://localhost:${registry.port}/ const result = await publishDryRun(String(dir), { XDG_CONFIG_HOME: "" }); expect(result).toEqual(usesRegistry(1)); }); + + // npm's `userconfig` option: the path of the per-user .npmrc, settable through + // NPM_CONFIG_USERCONFIG like any other npm config option. + it.concurrent("uses $NPM_CONFIG_USERCONFIG instead of $XDG_CONFIG_HOME/.npmrc and $HOME/.npmrc", async () => { + using dir = tempDir("npmrc-userconfig", { + ...pkg, + "home/.npmrc": npmrc(1), + "xdg/.npmrc": npmrc(2), + "elsewhere/ci.npmrc": npmrc(3), + }); + const result = await publishDryRun(String(dir), { + XDG_CONFIG_HOME: join(String(dir), "xdg"), + NPM_CONFIG_USERCONFIG: join(String(dir), "elsewhere", "ci.npmrc"), + }); + expect(result).toEqual(usesRegistry(3)); + }); + + it.concurrent("accepts lowercase $npm_config_userconfig", async () => { + using dir = tempDir("npmrc-userconfig-lowercase", { + ...pkg, + "home/.npmrc": npmrc(1), + "elsewhere/ci.npmrc": npmrc(2), + }); + const result = await publishDryRun(String(dir), { + npm_config_userconfig: join(String(dir), "elsewhere", "ci.npmrc"), + }); + expect(result).toEqual(usesRegistry(2)); + }); + + it.concurrent("uses $HOME/.npmrc when $NPM_CONFIG_USERCONFIG is empty", async () => { + using dir = tempDir("npmrc-userconfig-empty", { ...pkg, "home/.npmrc": npmrc(1) }); + const result = await publishDryRun(String(dir), { NPM_CONFIG_USERCONFIG: "" }); + expect(result).toEqual(usesRegistry(1)); + }); + + it.concurrent("the project .npmrc still overrides $NPM_CONFIG_USERCONFIG", async () => { + using dir = tempDir("npmrc-userconfig-project", { + ...pkg, + "elsewhere/ci.npmrc": npmrc(1), + "pkg/.npmrc": npmrc(2), + }); + const result = await publishDryRun(String(dir), { + NPM_CONFIG_USERCONFIG: join(String(dir), "elsewhere", "ci.npmrc"), + }); + expect(result).toEqual(usesRegistry(2)); + }); + + // https://github.com/oven-sh/bun/issues/14824: actions/setup-node with `registry-url` + // writes $RUNNER_TEMP/.npmrc with the contents below and exports NPM_CONFIG_USERCONFIG + // pointing at it; the token itself is only present as $NODE_AUTH_TOKEN. + it.concurrent("reads the .npmrc written by actions/setup-node, expanding ${NODE_AUTH_TOKEN}", async () => { + const { promise: authorization, resolve } = Promise.withResolvers(); + using mockRegistry = Bun.serve({ + port: 0, + fetch(req) { + resolve(req.headers.get("authorization")); + return new Response("{}"); + }, + }); + const { port } = mockRegistry; + using dir = tempDir("npmrc-setup-node", { + ...pkg, + "runner-temp/.npmrc": `//localhost:${port}/:_authToken=\${NODE_AUTH_TOKEN}\nregistry=http://localhost:${port}/\nalways-auth=true\n`, + }); + const result = await publish(String(dir), { + NPM_CONFIG_USERCONFIG: join(String(dir), "runner-temp", ".npmrc"), + NODE_AUTH_TOKEN: "npm_token-from-the-workflow-env", + }); + expect(result).toEqual({ + stdout: expect.stringContaining(" + npmrc-lookup@0.0.1\n"), + stderr: "", + exitCode: 0, + }); + expect(await authorization).toBe("Bearer npm_token-from-the-workflow-env"); + }); }); it("package config overrides home config", async () => { From 0648a52de965bd5881fed6fe86bb3ed93318bdb7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:35 +0000 Subject: [PATCH 042/258] publish, pm whoami: route registry requests through http_proxy (#38075) --- src/install/npm.rs | 3 +- src/runtime/cli/publish_command.rs | 18 +- test/cli/install/bun-install-proxy.test.ts | 356 ++++++++++++++---- .../source-lints/init-sync-http-proxy.test.ts | 146 +++++++ 4 files changed, 436 insertions(+), 87 deletions(-) create mode 100644 test/internal/source-lints/init-sync-http-proxy.test.ts diff --git a/src/install/npm.rs b/src/install/npm.rs index 1d1482ab76e2..72227e8215e4 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -146,6 +146,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { // returns. `init_sync` borrows the URL/header buffers for the duration of // the synchronous request only. let url = URL::parse(&print_buf); + let http_proxy = manager.http_proxy(&url); // `headers.allocate()` set `content.ptr` to a valid `content.len`-byte // allocation; `headers` outlives `req`. `written_slice()` is the safe @@ -159,7 +160,7 @@ pub fn whoami(manager: &mut PackageManager) -> Result, WhoamiError> { headers.entries, header_buf, b"", - None, + http_proxy, None, http::FetchRedirect::Follow, ); diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index b9778ff23a52..826d12dbd522 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -743,6 +743,7 @@ impl PublishCommand { } fn check_package_version_exists( + manager: &PackageManager, package_name: &[u8], version: &[u8], registry: &Npm::Registry::Scope, @@ -807,13 +808,14 @@ impl PublishCommand { headers.append(b"authorization", &auth_buf); } + let http_proxy = manager.http_proxy(&package_url); let mut req = http::AsyncHTTP::init_sync( http::Method::GET, package_url, headers.entries, headers.content.written_slice(), b"", - None, + http_proxy, None, http::FetchRedirect::Follow, ); @@ -860,6 +862,7 @@ impl PublishCommand { if tolerate_republish { let version_without_build_tag = dependency::without_build_tag(&ctx.package_version); let package_exists = Self::check_package_version_exists( + ctx.manager, &ctx.package_name, version_without_build_tag, registry, @@ -932,6 +935,7 @@ impl PublishCommand { // arena so the URL outlives `print_buf.clear()` below. let publish_url = URL::parse(crate::cli::cli_dupe(&print_buf)); print_buf.clear(); + let http_proxy = ctx.manager.http_proxy(&publish_url); let mut req = http::AsyncHTTP::init_sync( http::Method::PUT, @@ -939,7 +943,7 @@ impl PublishCommand { publish_headers.entries, publish_headers.content.written_slice(), publish_req_body, - None, + http_proxy.clone(), None, http::FetchRedirect::Follow, ); @@ -1033,7 +1037,7 @@ impl PublishCommand { otp_headers.entries, otp_headers.content.written_slice(), publish_req_body, - None, + http_proxy, None, http::FetchRedirect::Follow, ); @@ -1252,18 +1256,20 @@ impl PublishCommand { ctx.manager.options.publish_config.auth_type, )?; + let http_proxy = ctx.manager.http_proxy(&done_url); + loop { response_buf.reset(); - // Note: `done_url`/`auth_headers.entries` move into - // `init_sync`, so re-clone per iteration. + // Note: `done_url`/`auth_headers.entries`/`http_proxy` move + // into `init_sync`, so re-clone per iteration. let mut req = http::AsyncHTTP::init_sync( http::Method::GET, done_url.clone(), auth_headers.entries.clone()?, auth_headers.content.written_slice(), b"", - None, + http_proxy.clone(), None, http::FetchRedirect::Follow, ); diff --git a/test/cli/install/bun-install-proxy.test.ts b/test/cli/install/bun-install-proxy.test.ts index 40672217c0ab..960f80e3c0fb 100644 --- a/test/cli/install/bun-install-proxy.test.ts +++ b/test/cli/install/bun-install-proxy.test.ts @@ -1,97 +1,293 @@ -import { beforeAll, it } from "bun:test"; +import { beforeAll, describe, expect, it } from "bun:test"; import { exec } from "child_process"; import { rm } from "fs/promises"; -import { bunEnv, bunExe, dockerExe, isDockerEnabled, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, dockerExe, isDockerEnabled, tempDir, tempDirWithFiles } from "harness"; import { join } from "path"; import { promisify } from "util"; const execAsync = promisify(exec); const dockerCLI = dockerExe() as string; const SQUID_URL = "http://127.0.0.1:3128"; if (isDockerEnabled()) { - beforeAll(async () => { - async function isSquidRunning() { - const text = await fetch(SQUID_URL) - .then(res => res.text()) - .catch(() => {}); - return text?.includes("squid") ?? false; - } - if (!(await isSquidRunning())) { - // try to create or error if is already created - await execAsync( - `${dockerCLI} run -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta`, - ).catch(() => {}); - - async function waitForSquid(max_wait = 60_000) { - const start = Date.now(); - while (true) { - if (await isSquidRunning()) { - return; - } - if (Date.now() - start > max_wait) { - throw new Error("Squid did not start in time"); - } + describe("squid", () => { + beforeAll(async () => { + async function isSquidRunning() { + const text = await fetch(SQUID_URL) + .then(res => res.text()) + .catch(() => {}); + return text?.includes("squid") ?? false; + } + if (!(await isSquidRunning())) { + // try to create or error if is already created + await execAsync( + `${dockerCLI} run -d --name squid-container -e TZ=UTC -p 3128:3128 ubuntu/squid:5.2-22.04_beta`, + ).catch(() => {}); - await Bun.sleep(1000); + async function waitForSquid(max_wait = 60_000) { + const start = Date.now(); + while (true) { + if (await isSquidRunning()) { + return; + } + if (Date.now() - start > max_wait) { + throw new Error("Squid did not start in time"); + } + + await Bun.sleep(1000); + } } + // wait for squid to start + await waitForSquid(); } - // wait for squid to start - await waitForSquid(); - } + }); + + it("bun install with proxy with big packages", async () => { + const files = { + "package.json": JSON.stringify({ + "name": "test-install", + "module": "index.ts", + "type": "module", + "private": true, + "dependencies": { + "gastby": "1.0.1", + "mitata": "1.0.34", + "next.js": "1.0.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "@types/react": "18.3.3", + "esbuild": "0.21.4", + "peechy": "0.4.34", + "prettier": "3.5.3", + "prettier-plugin-organize-imports": "4.0.0", + "source-map-js": "1.2.0", + "typescript": "5.7.2", + }, + }), + }; + const promises = new Array(5); + // this repro a hang when using a proxy, we run multiple times to make sure it's not a flaky test + for (let i = 0; i < 5; i++) { + const package_dir = tempDirWithFiles("codex-" + i, files); + + const { exited } = Bun.spawn([bunExe(), "install", "--ignore-scripts"], { + cwd: package_dir, + // @ts-ignore + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: join(package_dir, ".bun-install-cache"), + TMPDIR: join(package_dir, ".tmp"), + BUN_TMPDIR: join(package_dir, ".tmp"), + HTTPS_PROXY: SQUID_URL, + HTTP_PROXY: SQUID_URL, + }, + stdio: ["inherit", "inherit", "inherit"], + timeout: 20_000, + }); + promises[i] = exited + .then(r => { + if (r !== 0) { + throw new Error("failed to install with exit code " + r); + } + }) + .finally(() => { + return rm(package_dir, { recursive: true, force: true }); + }); + } + + await Promise.all(promises); + }, 60_000); }); +} - it("bun install with proxy with big packages", async () => { - const files = { - "package.json": JSON.stringify({ - "name": "test-install", - "module": "index.ts", - "type": "module", - "private": true, - "dependencies": { - "gastby": "1.0.1", - "mitata": "1.0.34", - "next.js": "1.0.3", - "react": "19.1.0", - "react-dom": "19.1.0", - "@types/react": "18.3.3", - "esbuild": "0.21.4", - "peechy": "0.4.34", - "prettier": "3.5.3", - "prettier-plugin-organize-imports": "4.0.0", - "source-map-js": "1.2.0", - "typescript": "5.7.2", - }, - }), - }; - const promises = new Array(5); - // this repro a hang when using a proxy, we run multiple times to make sure it's not a flaky test - for (let i = 0; i < 5; i++) { - const package_dir = tempDirWithFiles("codex-" + i, files); - - const { exited } = Bun.spawn([bunExe(), "install", "--ignore-scripts"], { - cwd: package_dir, - // @ts-ignore - env: { - ...bunEnv, - BUN_INSTALL_CACHE_DIR: join(package_dir, ".bun-install-cache"), - TMPDIR: join(package_dir, ".tmp"), - BUN_TMPDIR: join(package_dir, ".tmp"), - HTTPS_PROXY: SQUID_URL, - HTTP_PROXY: SQUID_URL, +// Each command builds its registry requests separately, and `bun audit` +// (#20295), `bun publish` and `bun pm whoami` have each shipped without the +// proxy, so every command that talks to the registry gets a case here. +describe("registry commands honor http_proxy", () => { + type RegistryRequest = { via: "proxy" | "registry"; method: string; url: string; otp?: string }; + + // The registry from bunfig.toml and the proxy from http_proxy both answer + // with `respond`, and every request records which of the two received it. + // A request sent through an HTTP proxy names the full registry URL as its + // target, so `req.url` is the registry URL at either server. + function registryBehindProxy(respond: (req: RegistryRequest) => Response) { + const requests: RegistryRequest[] = []; + const serve = (via: RegistryRequest["via"]) => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + await req.arrayBuffer(); + const request: RegistryRequest = { via, method: req.method, url: req.url }; + const otp = req.headers.get("npm-otp"); + if (otp !== null) request.otp = otp; + requests.push(request); + return respond(request); }, - stdio: ["inherit", "inherit", "inherit"], - timeout: 20_000, }); - promises[i] = exited - .then(r => { - if (r !== 0) { - throw new Error("failed to install with exit code " + r); - } - }) - .finally(() => { - return rm(package_dir, { recursive: true, force: true }); + const registry = serve("registry"); + const proxy = serve("proxy"); + const registryUrl = `http://127.0.0.1:${registry.port}/`; + return { + registryUrl, + requests, + async run(args: string[], opts: { files?: Record; env?: Record } = {}) { + using dir = tempDir("registry-behind-proxy", { + "package.json": JSON.stringify({ name: "app", version: "1.0.0" }), + ...opts.files, + "bunfig.toml": Bun.TOML.stringify({ + install: { cache: false, registry: { url: registryUrl, token: "unused" } }, + }), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache"), + NO_PROXY: undefined, + no_proxy: undefined, + HTTPS_PROXY: undefined, + https_proxy: undefined, + HTTP_PROXY: undefined, + http_proxy: `http://127.0.0.1:${proxy.port}`, + ...opts.env, + }, + stdout: "pipe", + stderr: "pipe", }); - } + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }, + [Symbol.dispose]() { + registry.stop(true); + proxy.stop(true); + }, + }; + } - await Promise.all(promises); - }, 60_000); -} + const manifest = (name: string) => + Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name, version: "1.0.0", dist: { tarball: `http://127.0.0.1:1/${name}-1.0.0.tgz` } } }, + }); + + it.concurrent("bun pm whoami", async () => { + using setup = registryBehindProxy(() => Response.json({ username: "user-behind-the-proxy" })); + + const { stdout, stderr, exitCode } = await setup.run(["pm", "whoami"]); + expect({ requests: setup.requests, stdout, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "GET", url: `${setup.registryUrl}-/whoami` }], + stdout: "user-behind-the-proxy\n", + stderr: "", + exitCode: 0, + }); + }); + + it.concurrent("bun publish", async () => { + using setup = registryBehindProxy(() => Response.json({ ok: true })); + + const { stdout, stderr, exitCode } = await setup.run(["publish"]); + expect({ requests: setup.requests, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "PUT", url: `${setup.registryUrl}app` }], + stderr: "", + exitCode: 0, + }); + expect(stdout).toContain(" + app@1.0.0"); + }); + + it.concurrent("bun publish --tolerate-republish version lookup", async () => { + using setup = registryBehindProxy(({ method }) => + method === "GET" ? manifest("app") : new Response(null, { status: 500 }), + ); + + const { stderr, exitCode } = await setup.run(["publish", "--tolerate-republish"]); + expect({ requests: setup.requests, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "GET", url: `${setup.registryUrl}app` }], + stderr: "warn: Registry already knows about version 1.0.0; skipping.\n", + exitCode: 0, + }); + }); + + it.concurrent("bun publish web login poll and OTP retry", async () => { + using setup = registryBehindProxy(({ url, otp }) => { + if (url.endsWith("/-/done")) return Response.json({ token: "otp-from-web-login" }); + if (otp === "otp-from-web-login") return Response.json({ ok: true }); + return Response.json( + { authUrl: new URL("/-/auth", url).href, doneUrl: new URL("/-/done", url).href }, + { status: 401, headers: { "www-authenticate": "OTP" } }, + ); + }); + + const { stdout, stderr, exitCode } = await setup.run(["publish"]); + expect({ requests: setup.requests, stderr, exitCode }).toEqual({ + requests: [ + { via: "proxy", method: "PUT", url: `${setup.registryUrl}app` }, + { via: "proxy", method: "GET", url: `${setup.registryUrl}-/done` }, + { via: "proxy", method: "PUT", url: `${setup.registryUrl}app`, otp: "otp-from-web-login" }, + ], + stderr: "", + exitCode: 0, + }); + expect(stdout).toContain(" + app@1.0.0"); + }); + + it.concurrent("bun publish to a registry listed in no_proxy goes direct", async () => { + using setup = registryBehindProxy(() => Response.json({ ok: true })); + + const { stdout, stderr, exitCode } = await setup.run(["publish"], { env: { no_proxy: "127.0.0.1" } }); + expect({ requests: setup.requests, stderr, exitCode }).toEqual({ + requests: [{ via: "registry", method: "PUT", url: `${setup.registryUrl}app` }], + stderr: "", + exitCode: 0, + }); + expect(stdout).toContain(" + app@1.0.0"); + }); + + it.concurrent("bun info", async () => { + using setup = registryBehindProxy(() => manifest("some-dep")); + + const { stdout, stderr, exitCode } = await setup.run(["info", "some-dep", "name"]); + expect({ requests: setup.requests, stdout, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "GET", url: `${setup.registryUrl}some-dep` }], + stdout: "some-dep\n", + stderr: "", + exitCode: 0, + }); + }); + + it.concurrent("bun audit", async () => { + using setup = registryBehindProxy(() => Response.json({})); + + const { stdout, stderr, exitCode } = await setup.run(["audit"], { + files: { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "some-dep": "1.0.0" } }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "app", dependencies: { "some-dep": "1.0.0" } } }, + packages: { "some-dep": ["some-dep@1.0.0", "", {}, "sha512-AAAA"] }, + }), + }, + }); + expect({ requests: setup.requests, stdout, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "POST", url: `${setup.registryUrl}-/npm/v1/security/advisories/bulk` }], + stdout: "No vulnerabilities found\n", + stderr: expect.stringMatching(/^bun audit v.*\n$/), + exitCode: 0, + }); + }); + + it.concurrent("bun install", async () => { + using setup = registryBehindProxy(() => new Response(null, { status: 404 })); + + const { stderr, exitCode } = await setup.run(["install"], { + files: { + "package.json": JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "some-dep": "1.0.0" } }), + }, + }); + expect({ requests: setup.requests, stderr, exitCode }).toEqual({ + requests: [{ via: "proxy", method: "GET", url: `${setup.registryUrl}some-dep` }], + stderr: expect.stringContaining(`error: GET ${setup.registryUrl}some-dep - 404`), + exitCode: 1, + }); + }); +}); diff --git a/test/internal/source-lints/init-sync-http-proxy.test.ts b/test/internal/source-lints/init-sync-http-proxy.test.ts new file mode 100644 index 000000000000..6c5cc125ce90 --- /dev/null +++ b/test/internal/source-lints/init-sync-http-proxy.test.ts @@ -0,0 +1,146 @@ +import { file } from "bun"; +import { expect, test } from "bun:test"; +import { realpathSync } from "fs"; +import path from "path"; +import { globAllSources } from "../../../scripts/glob-sources.ts"; + +// `AsyncHTTP::init_sync` is how CLI commands (install, publish, audit, pm view, +// upgrade, create, ...) make a blocking request, and its `http_proxy` argument +// is the only way the request learns about http_proxy / https_proxy / +// NO_PROXY. A literal `None` there is a command that silently ignores the +// proxy environment every other command honours: `bun audit` shipped that way +// (#20295), and so did `bun publish` and `bun pm whoami`. Resolve it from the +// environment instead, for the URL being requested: +// +// let http_proxy = pm.http_proxy(&url); // with a PackageManager +// let http_proxy = env_loader.get_http_proxy_for(&url); // with a dotenv Loader +// +// Both return `None` when no proxy applies, so there is never a reason to +// write the literal. The argument is found by position, so the lint also +// fails if the signature changes shape; update INIT_SYNC_ARGS along with it. + +const INIT_SYNC_ARGS = [ + "method", + "url", + "headers", + "headers_buf", + "request_body", + "http_proxy", + "hostname", + "redirect_type", +]; +const HTTP_PROXY_ARG = INIT_SYNC_ARGS.indexOf("http_proxy"); +const CALL = "AsyncHTTP::init_sync("; + +const root = path.resolve(import.meta.dir, "..", "..", ".."); +const rustSources = globAllSources().rust.filter(p => p.endsWith(".rs")); + +// Only scan files tracked in HEAD (a `git stash` round-trip can leave stray +// `.rs` files in the working tree; CI runs on a clean checkout). Same guard as +// dead-code-escapes.test.ts. +const tracked: Set | null = (() => { + const r = Bun.spawnSync({ + cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], + stdout: "pipe", + stderr: "ignore", + }); + if (!r.success) return null; + return new Set(r.stdout.toString().split("\0").filter(Boolean)); +})(); + +// A string or char literal (`"..."`, `b"..."`, `'x'`, `b'\n'`) starting at +// lastIndex; a lifetime (`'a`) has no closing quote after its first character +// and does not match. +const LITERAL = /b?"(?:\\[\s\S]|[^\\"])*"|b?'(?:\\[\s\S]|[^\\'])'/y; +const COMMENT = /\/\/[^\n]*|\/\*[\s\S]*?\*\//y; + +function matchAt(re: RegExp, source: string, at: number): string | null { + re.lastIndex = at; + return re.exec(source)?.[0] ?? null; +} + +/** Replaces comments with spaces (line count preserved); literals are kept as-is. */ +function stripComments(source: string): string { + let out = ""; + for (let i = 0; i < source.length; ) { + const literal = matchAt(LITERAL, source, i); + if (literal !== null) { + out += literal; + i += literal.length; + continue; + } + const comment = matchAt(COMMENT, source, i); + if (comment !== null) { + out += comment.replace(/[^\n]/g, " "); + i += comment.length; + continue; + } + out += source[i++]; + } + return out; +} + +/** + * The arguments of the call whose `(` is at `source[open]`, split on the + * commas at nesting depth 0, or `null` if the call is never closed. + */ +function callArguments(source: string, open: number): string[] | null { + const args: string[] = []; + let current = ""; + let depth = 0; + for (let i = open + 1; i < source.length; ) { + const literal = matchAt(LITERAL, source, i); + if (literal !== null) { + current += literal; + i += literal.length; + continue; + } + const c = source[i++]!; + if (c === "(" || c === "[" || c === "{") depth++; + if (c === ")" || c === "]" || c === "}") { + if (depth === 0) { + if (current.trim() !== "") args.push(current.trim()); + return args; + } + depth--; + } + if (c === "," && depth === 0) { + args.push(current.trim()); + current = ""; + continue; + } + current += c; + } + return null; +} + +const offenders: string[] = []; +let calls = 0; +for (const abs of rustSources) { + const source = path.relative(root, abs).replaceAll(path.sep, "/"); + if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; + if (tracked !== null && !tracked.has(source)) continue; + const content = await file(abs).text(); + if (!content.includes(CALL)) continue; + const code = stripComments(content); + for (let at = code.indexOf(CALL); at !== -1; at = code.indexOf(CALL, at + CALL.length)) { + calls++; + const where = `${source}:${code.slice(0, at).split("\n").length}`; + const args = callArguments(code, at + CALL.length - 1); + if (args === null || args.length !== INIT_SYNC_ARGS.length) { + offenders.push( + `${where}: expected the ${INIT_SYNC_ARGS.length} arguments (${INIT_SYNC_ARGS.join(", ")}), found ${args?.length ?? "an unterminated call"}; update this lint if init_sync changed`, + ); + } else if (args[HTTP_PROXY_ARG] === "None") { + offenders.push(`${where}: AsyncHTTP::init_sync with http_proxy: None; resolve it from the environment`); + } + } +} + +test("scans the AsyncHTTP::init_sync call sites", () => { + expect(calls).toBeGreaterThan(0); +}); + +test("every AsyncHTTP::init_sync call resolves http_proxy from the environment", () => { + expect(offenders).toEqual([]); +}); From 1595a7824bf047eec0e8fbcc9de49ca34ca1cc4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:40 +0000 Subject: [PATCH 043/258] install: make BUN_CONFIG_NO_VERIFY disable integrity verification when set (#38310) --- .../PackageManager/PackageManagerOptions.rs | 2 +- .../bun-install-tarball-integrity.test.ts | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..a8f97684a22d 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -691,7 +691,7 @@ impl Options { } if let Some(check_bool) = env.get(b"BUN_CONFIG_NO_VERIFY") { - self.do_.set(Do::VERIFY_INTEGRITY, check_bool != b"0"); + self.do_.set(Do::VERIFY_INTEGRITY, check_bool == b"0"); } // Update should never read from manifest cache diff --git a/test/cli/install/bun-install-tarball-integrity.test.ts b/test/cli/install/bun-install-tarball-integrity.test.ts index 35e208458ea2..765f13314ccb 100644 --- a/test/cli/install/bun-install-tarball-integrity.test.ts +++ b/test/cli/install/bun-install-tarball-integrity.test.ts @@ -884,6 +884,74 @@ describe.concurrent("tarball integrity metadata forms", () => { expect(stdout).not.toContain("1 package installed"); expect(exitCode).not.toBe(0); }); + + it("--no-verify installs a tarball whose bytes don't match the advertised integrity", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(other.sha512, real.tgz); + using dir = projectDir("integrity-no-verify-flag", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install", "--no-verify"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr + stdout).not.toContain("Integrity check failed"); + expect(stdout).toContain("1 package installed"); + expect(await file(join(String(dir), "node_modules", "pkg", "package.json")).json()).toEqual({ + name: "pkg", + version: "1.0.0", + }); + expect(exitCode).toBe(0); + }); + + it("BUN_CONFIG_NO_VERIFY=1 skips the integrity check like --no-verify", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(other.sha512, real.tgz); + using dir = projectDir("integrity-no-verify-env-1", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache"), BUN_CONFIG_NO_VERIFY: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr + stdout).not.toContain("Integrity check failed"); + expect(stdout).toContain("1 package installed"); + expect(await file(join(String(dir), "node_modules", "pkg", "package.json")).json()).toEqual({ + name: "pkg", + version: "1.0.0", + }); + expect(exitCode).toBe(0); + }); + + it("BUN_CONFIG_NO_VERIFY=0 keeps the integrity check enabled", async () => { + const real = buildTarball(Buffer.from('{"name":"pkg","version":"1.0.0"}\n')); + const other = buildTarball(Buffer.from('{"name":"other","version":"9.9.9"}\n')); + + await using server = serveManifest(other.sha512, real.tgz); + using dir = projectDir("integrity-no-verify-env-0", server.port); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { ...env, BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache"), BUN_CONFIG_NO_VERIFY: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, stdout, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(stderr + stdout).toContain("Integrity check failed"); + expect(stdout).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + }); }); describe.concurrent.each(["hoisted", "isolated"] as const)("tarball download failure (%s)", linker => { From a6f3a869cf8e2d7c256be8dc73e4502ad0394b98 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:44 +0000 Subject: [PATCH 044/258] install: honor BUN_CONFIG_MAX_HTTP_REQUESTS (#38744) --- docs/runtime/environment-variables.mdx | 2 +- src/install/PackageManager.rs | 31 ++++--- test/cli/install/bun-install.test.ts | 118 ++++++++----------------- 3 files changed, 51 insertions(+), 100 deletions(-) diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index aaf6d5beda5f..40b0a3545d67 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -203,7 +203,7 @@ Bun reads these environment variables to configure aspects of its behavior. | `TMPDIR` | Bun occasionally requires a directory to store intermediate assets during bundling or other operations. If unset, defaults to the platform-specific temporary directory: `/tmp` on Linux, `/private/tmp` on macOS. | | `NO_COLOR` | If `NO_COLOR=1`, then ANSI color output is [disabled](https://no-color.org/). | | `FORCE_COLOR` | If `FORCE_COLOR=1`, then ANSI color output is forced on, even if `NO_COLOR` is set. | -| `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by fetch and `bun install`. Defaults to `256`. Lower it if you run into rate limits or connection issues. | +| `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by `fetch` and `bun install`. `fetch` defaults to `256`. `bun install` defaults to `64`, and its `--network-concurrency` flag overrides this variable. Lower it if you run into rate limits or connection issues. | | `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD` | If `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=true`, then `bun --watch` does not clear the console on reload | | `DO_NOT_TRACK` | Disable uploading crash reports to `bun.report` on crash. On macOS & Windows, crash report uploads are enabled by default. Bun sends no other telemetry, though we plan to add some. If `DO_NOT_TRACK=1`, then auto-uploading crash reports and telemetry are both [disabled](https://do-not-track.dev/). | | `BUN_OPTIONS` | Prepends command-line arguments to any Bun execution. For example, `BUN_OPTIONS="--hot"` makes `bun run dev` behave like `bun --hot run dev`. | diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index f27026b6f903..4382e8d4aa5b 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -2247,6 +2247,16 @@ pub fn init( } } + // `options.load` applies BUN_CONFIG_MAX_HTTP_REQUESTS on top of this default. + http::async_http::MAX_SIMULTANEOUS_REQUESTS.store( + if env.has_http_proxy() { + DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL_FOR_PROXIES + } else { + DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL + }, + Ordering::Relaxed, + ); + manager.options.load( // SAFETY: ctx.log is the process-lifetime CLI log set by // create_context_data(); single-threaded init region. @@ -2257,6 +2267,11 @@ pub fn init( subcommand, )?; + if let Some(network_concurrency) = cli_network_concurrency { + http::async_http::MAX_SIMULTANEOUS_REQUESTS + .store(usize::from(network_concurrency.max(1)), Ordering::Relaxed); + } + if let Some(config) = ctx.install.as_deref_mut() { if let Some(p) = config.public_hoist_pattern.take() { manager.options.public_hoist_pattern = Some(p); @@ -2305,22 +2320,6 @@ pub fn init( } } - http::async_http::MAX_SIMULTANEOUS_REQUESTS.store( - 'brk: { - if let Some(network_concurrency) = cli_network_concurrency { - break 'brk network_concurrency.max(1) as usize; - } - - // If any HTTP proxy is set, use a diferent limit - if env.has_http_proxy() { - break 'brk DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL_FOR_PROXIES; - } - - DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL - }, - Ordering::Relaxed, // .monotonic - ); - // `InitOpts.ca: Vec<*const c_void>` (erased `[*:0]const u8`). The HTTP // thread reads these asynchronously after `init` returns, so park the // owning `ZBox`es in `holder::CA` for process lifetime (never freed) diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 06de24343ffb..aeb048ae55ac 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -155,105 +155,57 @@ describe.concurrent("bun-install", () => { }); } - it("bun install --network-concurrency=5 doesnt go over 5 concurrent requests", async () => { + it.each([ + { label: "--network-concurrency=5", cap: 5, args: ["--network-concurrency", "5"], extraEnv: {} }, + { label: "BUN_CONFIG_MAX_HTTP_REQUESTS=5", cap: 5, args: [], extraEnv: { BUN_CONFIG_MAX_HTTP_REQUESTS: "5" } }, + { + label: "--network-concurrency=2 overriding BUN_CONFIG_MAX_HTTP_REQUESTS=50", + cap: 2, + args: ["--network-concurrency", "2"], + extraEnv: { BUN_CONFIG_MAX_HTTP_REQUESTS: "50" }, + }, + ])("bun install with $label doesnt go over $cap concurrent requests", async ({ cap, args, extraEnv }) => { await withContext(defaultOpts, async ctx => { - const urls: string[] = []; let maxConcurrentRequests = 0; - let concurrentRequestCounter = 0; - let totalRequests = 0; - setContextHandler(ctx, async function (request) { - concurrentRequestCounter++; - totalRequests++; + let concurrentRequests = 0; + setContextHandler(ctx, async function () { + concurrentRequests++; + maxConcurrentRequests = Math.max(maxConcurrentRequests, concurrentRequests); try { + // Simulated registry latency: requests have to overlap for the stub to + // observe how many the client keeps in flight at once. await Bun.sleep(10); - maxConcurrentRequests = Math.max(maxConcurrentRequests, concurrentRequestCounter); - - if (concurrentRequestCounter > 20) { - throw new Error("Too many concurrent requests"); - } } finally { - concurrentRequestCounter--; + concurrentRequests--; } - return new Response("404", { status: 404 }); }); + + const dependencies: Record = {}; + for (let i = 1; i <= 51; i++) { + dependencies[`bar${i}`] = "^1"; + } await writeFile( join(ctx.package_dir, "package.json"), - ` - { - "name": "foo", - "version": "0.0.1", - "dependencies": { - "bar1": "^1", - "bar2": "^1", - "bar3": "^1", - "bar4": "^1", - "bar5": "^1", - "bar6": "^1", - "bar7": "^1", - "bar8": "^1", - "bar9": "^1", - "bar10": "^1", - "bar11": "^1", - "bar12": "^1", - "bar13": "^1", - "bar14": "^1", - "bar15": "^1", - "bar16": "^1", - "bar17": "^1", - "bar18": "^1", - "bar19": "^1", - "bar20": "^1", - "bar21": "^1", - "bar22": "^1", - "bar23": "^1", - "bar24": "^1", - "bar25": "^1", - "bar26": "^1", - "bar27": "^1", - "bar28": "^1", - "bar29": "^1", - "bar30": "^1", - "bar31": "^1", - "bar32": "^1", - "bar33": "^1", - "bar34": "^1", - "bar35": "^1", - "bar36": "^1", - "bar37": "^1", - "bar38": "^1", - "bar39": "^1", - "bar40": "^1", - "bar41": "^1", - "bar42": "^1", - "bar43": "^1", - "bar44": "^1", - "bar45": "^1", - "bar46": "^1", - "bar47": "^1", - "bar48": "^1", - "bar49": "^1", - "bar50": "^1", - "bar51": "^1", - } - }`, + JSON.stringify({ name: "foo", version: "0.0.1", dependencies }), ); - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), "install", "--network-concurrency", "5"], + + await using proc = spawn({ + cmd: [bunExe(), "install", ...args], cwd: ctx.package_dir, stdout: "pipe", - stdin: "pipe", stderr: "pipe", - env, + env: { ...env, ...extraEnv }, }); - const err = await stderr.text(); - expect(await exited).toBe(1); - expect(urls).toBeEmpty(); - expect(maxConcurrentRequests).toBeLessThanOrEqual(5); - expect(totalRequests).toBe(51); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(err).toContain("failed to resolve"); - expect(await stdout.text()).toEqual(expect.stringContaining("bun install v1.")); + expect(maxConcurrentRequests).toBeLessThanOrEqual(cap); + expect({ totalRequests: ctx.requested, stdout, stderr, exitCode }).toEqual({ + totalRequests: 51, + stdout: expect.stringContaining("bun install v1."), + stderr: expect.stringContaining("failed to resolve"), + exitCode: 1, + }); }); }); From aa522102fb1c7944441d9c30000f20b5455fe653 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:48 +0000 Subject: [PATCH 045/258] url: emit a single slash in href_without_auth() for root-path registry URLs (#38812) --- src/url/lib.rs | 13 ++--- test/cli/install/npmrc.test.ts | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index 8c02d641839a..dc1dd00fdb7a 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -390,12 +390,7 @@ impl<'a> URL<'a> { } } - /// Formats `:////`. - /// - /// `display_host()` yields a `bun_core::fmt::HostFormatter` (impls - /// `Display`); the other two pieces are raw byte slices, so we assemble - /// into a `Vec` directly rather than going through `format!` and - /// risking lossy UTF-8 round-trips. + /// The URL without its userinfo, ending in exactly one `/`: `http://host/`, `http://host/npm/`. pub fn href_without_auth(&self) -> Box<[u8]> { let proto = self.display_protocol(); let path = strings::trim(self.pathname, b"/"); @@ -407,8 +402,10 @@ impl<'a> URL<'a> { // bun_core::io::Write on Vec is infallible. let _ = buf.print(format_args!("{}", self.display_host())); buf.push(b'/'); - buf.extend_from_slice(path); - buf.push(b'/'); + if !path.is_empty() { + buf.extend_from_slice(path); + buf.push(b'/'); + } buf.into_boxed_slice() } diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 5f8e8516daab..69e24a9b14a6 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -706,6 +706,99 @@ registry=https://somehost.com/org1/npm/registry/ }); }); +describe.concurrent("registry URL with embedded credentials", () => { + // Credentials written into the registry URL are split off and the URL is + // stored without them. The stored URL is what requests are built from and + // what error messages print, so a registry at the root of its host has to + // come back as "http://host/", not "http://host//". + test.each([ + ["http://alice:s3cret@registry.example.com/", "http://registry.example.com/"], + ["http://alice:s3cret@registry.example.com", "http://registry.example.com/"], + ["http://alice:s3cret@registry.example.com:8080", "http://registry.example.com:8080/"], + ["http://alice:s3cret@registry.example.com/npm/", "http://registry.example.com/npm/"], + ["http://alice:s3cret@registry.example.com/npm", "http://registry.example.com/npm/"], + ])("registry=%s is stored as %s", (registry, url) => { + expect(loadNpmrc(`registry=${registry}\n`)).toMatchObject({ + default_registry_url: url, + default_registry_username: "alice", + default_registry_password: "s3cret", + }); + }); + + test.each([ + ["http://:tok@registry.example.com/", "http://registry.example.com/"], + ["http://:tok@registry.example.com:8080", "http://registry.example.com:8080/"], + ["http://:tok@registry.example.com:8080/a/b/", "http://registry.example.com:8080/a/b/"], + ])("registry=%s is stored as %s", (registry, url) => { + expect(loadNpmrc(`registry=${registry}\n`)).toMatchObject({ + default_registry_url: url, + default_registry_token: "tok", + }); + }); + + type Req = { path: string; auth: string | null }; + + function mockRegistry(reqs: Req[], respond: () => Response) { + return Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + reqs.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") }); + return respond(); + }, + }); + } + + async function run(dir: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: dir, + env: { ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".cache") }, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + } + + // The documented bunfig form for a token: the token rides on the end of the + // URL's path. `bun pm whoami` prints the stored URL verbatim when the + // registry does not return a username. + test.each([ + ["/", "/-/whoami"], + ["/npm/", "/npm/-/whoami"], + ])("_authToken= appended to a bunfig.toml registry URL with path %s", async (path, whoami) => { + const reqs: Req[] = []; + await using server = mockRegistry(reqs, () => Response.json({})); + const base = `http://127.0.0.1:${server.port}${path}`; + using dir = tempDir("bunfig-authtoken-suffix", { + "bunfig.toml": `[install.registry]\nurl = "${base}_authToken=tok"\n`, + "package.json": JSON.stringify({ name: "app" }), + }); + + const { stderr, exitCode } = await run(String(dir), "pm", "whoami"); + + expect(stderr).toBe(`error: failed to authenticate with registry '${base}'\n`); + expect(reqs).toEqual([{ path: whoami, auth: "Bearer tok" }]); + expect(exitCode).toBe(1); + }); + + test("username:password in the .npmrc registry URL", async () => { + const reqs: Req[] = []; + await using server = mockRegistry(reqs, () => new Response("unauthorized", { status: 401 })); + using dir = tempDir("npmrc-userinfo", { + ".npmrc": `registry=http://alice:s3cret@127.0.0.1:${server.port}/\n`, + "package.json": JSON.stringify({ name: "app", dependencies: { "needs-creds": "1.0.0" } }), + }); + + const { stderr, exitCode } = await run(String(dir), "install"); + + expect(stderr.split(/\r?\n/)).toContain(`error: GET http://127.0.0.1:${server.port}/needs-creds - 401`); + expect(reqs).toEqual([{ path: "/needs-creds", auth: `Basic ${Buffer.from("alice:s3cret").toString("base64")}` }]); + expect(exitCode).toBe(1); + }); +}); + describe("scoped registry routing", () => { // A request for a @scope package must be sent only to that scope's configured // registry with that scope's token. The registry map was keyed by a bare From dc0e10c0ed111945ab19b21520be5be0123061c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:53 +0000 Subject: [PATCH 046/258] url: parse the host of a protocol-relative URL instead of leaving the authority in the path (#39000) --- src/url/lib.rs | 7 +- test/cli/install/npmrc.test.ts | 100 +++++++++++++++++++++ test/js/bun/util/filesystem_router.test.ts | 36 ++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/url/lib.rs b/src/url/lib.rs index dc1dd00fdb7a..be249e698626 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -589,9 +589,9 @@ impl<'a> URL<'a> { offset += url.parse_host(base).unwrap_or(0); } b'/' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b':' => { - let is_protocol_relative = base.len() > 1 && base[1] == b'/'; + let is_protocol_relative = base.starts_with(b"//"); if is_protocol_relative { - offset += 1; + offset += 2; } else { offset += url.parse_protocol(&base[offset as usize..]).unwrap_or(0); } @@ -697,7 +697,8 @@ impl<'a> URL<'a> { url.pathname = &url.pathname[1..]; } - url.origin = strings::trim(url.origin, b"/ ?#"); + // Only the right side: a protocol-relative origin keeps its leading `//`. + url.origin = strings::trim_right(url.origin, b"/ ?#"); url } diff --git a/test/cli/install/npmrc.test.ts b/test/cli/install/npmrc.test.ts index 69e24a9b14a6..7dca931cd5fd 100644 --- a/test/cli/install/npmrc.test.ts +++ b/test/cli/install/npmrc.test.ts @@ -683,6 +683,65 @@ registry=https://somehost.com/org1/npm/registry/ }); }); + describe("protocol-relative registry value", () => { + // `registry=//host/path/` is kept as written and matched against the credential + // keys by host and pathname. It used to parse with an empty host and `/host/path/` + // as its pathname, so no key could match it. + test.each([ + ["host only", "//registry.example.com/", "//registry.example.com/"], + ["host with a port and a path", "//registry.example.com:8080/npm/", "//registry.example.com:8080/npm/"], + ["key without the trailing slash", "//registry.example.com:8080/npm/", "//registry.example.com:8080/npm"], + ])("_authToken is applied: %s", (_, registryUrl, key) => { + expect(loadNpmrc(`registry=${registryUrl}\n${key}:_authToken=rel-token\n`)).toEqual({ + default_registry_url: registryUrl, + default_registry_token: "rel-token", + default_registry_username: "", + default_registry_password: "", + default_registry_email: "", + }); + }); + + test.each([ + ["a different port", "//registry.example.com:9090/npm/"], + ["a different host", "//other.example.com:8080/npm/"], + ["the host without the path", "//registry.example.com:8080/"], + ])("a key for %s is not applied", (_, key) => { + const result = loadNpmrc(`registry=//registry.example.com:8080/npm/\n${key}:_authToken=rel-token\n`); + expect(result.default_registry_url).toBe("//registry.example.com:8080/npm/"); + expect(result.default_registry_token).toBe(""); + }); + + test.each(["http://", "//"])("credentials embedded in a %sregistry value are split out of the URL", prefix => { + expect(loadNpmrc(`registry=${prefix}:embedded-token@registry.example.com/npm/\n`)).toEqual({ + default_registry_url: "http://registry.example.com/npm/", + default_registry_token: "embedded-token", + default_registry_username: "", + default_registry_password: "", + default_registry_email: "", + }); + + expect(loadNpmrc(`registry=${prefix}embedded-user:embedded-password@registry.example.com/npm/\n`)).toEqual({ + default_registry_url: "http://registry.example.com/npm/", + default_registry_token: "", + default_registry_username: "embedded-user", + default_registry_password: "embedded-password", + default_registry_email: "", + }); + }); + }); + + test.each(["a", "ab"])("a credential key for the host %j is applied to registry=http:///", host => { + // The `//a/` key is stripped to `a/` before it is parsed. A second byte of `/` used to + // be taken as the start of a protocol-relative URL, leaving the key with an empty host. + expect(loadNpmrc(`registry=http://${host}/\n//${host}/:_authToken=${host}-token\n`)).toEqual({ + default_registry_url: `http://${host}/`, + default_registry_token: `${host}-token`, + default_registry_username: "", + default_registry_password: "", + default_registry_email: "", + }); + }); + it("does not print an undecodable _password value", async () => { const secret = "s!ecret!pass"; using dir = tempDir("npmrc-password-decode", { @@ -946,6 +1005,47 @@ describe("--registry override", () => { }); }); +describe("protocol-relative registry", () => { + test("bun install rejects registry=//host:port/path/ instead of requesting http://localhost/host/", async () => { + // The `:port` used to be taken for a `:option=value` suffix of the pathname and the + // value rewritten to http://localhost/127.0.0.1/, so the manifest was requested from + // a different host on port 80. A scheme-less registry is not usable either way; the + // server only exists to show that the host actually named in the value is not hit. + let hits = 0; + await using named = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + hits++; + return new Response("not found", { status: 404 }); + }, + }); + const registryUrl = `//127.0.0.1:${named.port}/npm/`; + + using dir = tempDir("npmrc-protocol-relative-registry", { + ".npmrc": `registry=${registryUrl}\n`, + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { "no-deps": "1.0.0" }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--no-cache"], + cwd: String(dir), + env: { ...env, http_proxy: "", https_proxy: "", HTTP_PROXY: "", HTTPS_PROXY: "" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(`Failed to join registry "${registryUrl}" and package "no-deps" URLs`); + expect(hits).toBe(0); + expect(exitCode).toBe(1); + }); +}); + describe.skipIf(!isIPv6())("registry on a bracketed IPv6 host", () => { test("sends the token keyed to //[::1]:port/ to the default and the scoped registry", async () => { type Req = { path: string; auth: string | null }; diff --git a/test/js/bun/util/filesystem_router.test.ts b/test/js/bun/util/filesystem_router.test.ts index 9c9821457815..9bee32bb2fe1 100644 --- a/test/js/bun/util/filesystem_router.test.ts +++ b/test/js/bun/util/filesystem_router.test.ts @@ -314,6 +314,42 @@ it("assetPrefix, src, and origin", async () => { } }); +it("src keeps a protocol-relative origin and a one-character host", () => { + using dir = tempDir("fsr-origin", { + "pages/index.tsx": "export default 1;", + "pages/posts/[id].tsx": "export default 1;", + }); + + const srcFor = (origin: string) => + new Bun.FileSystemRouter({ + dir: path.join(String(dir), "pages"), + style: "nextjs", + assetPrefix: "/_next/static/", + origin, + }).match("/posts/hello-world")!.src; + + // `//host` used to parse with an empty host, and so did `a/` (any second byte + // of `/` was taken as the start of a protocol-relative URL). Both silently + // dropped the origin and the assetPrefix from `src`. + expect({ + "//nextjs.org": srcFor("//nextjs.org"), + "//nextjs.org:8080": srcFor("//nextjs.org:8080"), + "//nextjs.org/ignored": srcFor("//nextjs.org/ignored"), + "a/": srcFor("a/"), + "https://nextjs.org/ignored": srcFor("https://nextjs.org/ignored"), + "nextjs.org:8080": srcFor("nextjs.org:8080"), + "ab/": srcFor("ab/"), + }).toEqual({ + "//nextjs.org": "//nextjs.org/_next/static/posts/[id].tsx", + "//nextjs.org:8080": "//nextjs.org:8080/_next/static/posts/[id].tsx", + "//nextjs.org/ignored": "//nextjs.org/_next/static/posts/[id].tsx", + "a/": "a/_next/static/posts/[id].tsx", + "https://nextjs.org/ignored": "https://nextjs.org/_next/static/posts/[id].tsx", + "nextjs.org:8080": "nextjs.org:8080/_next/static/posts/[id].tsx", + "ab/": "ab/_next/static/posts/[id].tsx", + }); +}); + it(".query works", () => { // set up the test const { dir } = make(["posts.tsx"]); From efff492f5de11fd54266181c26d2a56ea48c2d09 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:06:57 +0000 Subject: [PATCH 047/258] install: say --network-concurrency defaults to 64 in help and docs (#38759) --- completions/bun-cli.json | 22 ++--- docs/snippets/cli/add.mdx | 2 +- docs/snippets/cli/install.mdx | 2 +- docs/snippets/cli/link.mdx | 2 +- docs/snippets/cli/outdated.mdx | 4 +- docs/snippets/cli/patch.mdx | 4 +- docs/snippets/cli/publish.mdx | 2 +- docs/snippets/cli/remove.mdx | 4 +- docs/snippets/cli/update.mdx | 4 +- .../PackageManager/CommandLineArguments.rs | 13 ++- test/cli/install/bun-install.test.ts | 84 ++++++++++++++++++- 11 files changed, 115 insertions(+), 28 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 5bcf1c989fbd..2a14b1a1ab85 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -427,7 +427,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -748,7 +748,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -1079,7 +1079,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -1351,7 +1351,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -1769,7 +1769,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -2149,7 +2149,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -2432,7 +2432,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -2691,7 +2691,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -2943,7 +2943,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -3248,7 +3248,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, @@ -3677,7 +3677,7 @@ }, { "name": "network-concurrency", - "description": "Maximum number of concurrent network requests (default 48)", + "description": "Maximum number of concurrent network requests (default 64)", "hasValue": true, "valueType": "val", "required": false, diff --git a/docs/snippets/cli/add.mdx b/docs/snippets/cli/add.mdx index 170d397c69e1..5a2e18bc855e 100644 --- a/docs/snippets/cli/add.mdx +++ b/docs/snippets/cli/add.mdx @@ -116,7 +116,7 @@ bun add <@version> variables - + Maximum number of concurrent network requests diff --git a/docs/snippets/cli/install.mdx b/docs/snippets/cli/install.mdx index c8392f9f27ff..f65e5026082e 100644 --- a/docs/snippets/cli/install.mdx +++ b/docs/snippets/cli/install.mdx @@ -157,7 +157,7 @@ bun install @ Maximum number of concurrent jobs for lifecycle scripts (default: 2x CPU cores) - + Maximum number of concurrent network requests diff --git a/docs/snippets/cli/link.mdx b/docs/snippets/cli/link.mdx index 6e6d84d3bacb..87222e86fd75 100644 --- a/docs/snippets/cli/link.mdx +++ b/docs/snippets/cli/link.mdx @@ -93,7 +93,7 @@ bun link variables - + Maximum number of concurrent network requests diff --git a/docs/snippets/cli/outdated.mdx b/docs/snippets/cli/outdated.mdx index 32d5e582ed78..ceb98222f41b 100644 --- a/docs/snippets/cli/outdated.mdx +++ b/docs/snippets/cli/outdated.mdx @@ -102,8 +102,8 @@ bun outdated Use a specific registry by default, overriding .npmrc, bunfig.toml and environment variables - - Maximum number of concurrent network requests (default 48) + + Maximum number of concurrent network requests (default 64) ### Caching diff --git a/docs/snippets/cli/patch.mdx b/docs/snippets/cli/patch.mdx index 01bf9b04d288..71d991157771 100644 --- a/docs/snippets/cli/patch.mdx +++ b/docs/snippets/cli/patch.mdx @@ -105,8 +105,8 @@ bun patch @ variables - - Maximum number of concurrent network requests (default 48) + + Maximum number of concurrent network requests (default 64) ### Performance & Resource diff --git a/docs/snippets/cli/publish.mdx b/docs/snippets/cli/publish.mdx index 3989732fa75a..7d542b1fd8cc 100644 --- a/docs/snippets/cli/publish.mdx +++ b/docs/snippets/cli/publish.mdx @@ -177,7 +177,7 @@ bun publish --cafile ./ca-cert.pem `copyfile` - + Maximum concurrent network requests diff --git a/docs/snippets/cli/remove.mdx b/docs/snippets/cli/remove.mdx index d736f537d175..5dac16332867 100644 --- a/docs/snippets/cli/remove.mdx +++ b/docs/snippets/cli/remove.mdx @@ -146,6 +146,6 @@ bun remove macOS), hardlink (default on Linux and Windows), symlink, copyfile - - Maximum number of concurrent network requests (default 48) + + Maximum number of concurrent network requests (default 64) diff --git a/docs/snippets/cli/update.mdx b/docs/snippets/cli/update.mdx index 913251ada4f8..ae98afaf0b1c 100644 --- a/docs/snippets/cli/update.mdx +++ b/docs/snippets/cli/update.mdx @@ -78,8 +78,8 @@ bun up Use a specific registry by default, overriding .npmrc, bunfig.toml and environment variables - - Maximum number of concurrent network requests (default 48) + + Maximum number of concurrent network requests (default 64) ### Caching diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index be895af25bf1..dfd92eb3c21a 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -50,6 +50,15 @@ const BACKEND_PARAM: ParamType = clap::param!( "--backend Platform-specific optimizations for installing dependencies. Possible values: \"hardlink\" (default), \"symlink\", \"copyfile\"" ); +const NETWORK_CONCURRENCY_PARAM: ParamType = clap::param!( + "--network-concurrency Maximum number of concurrent network requests (default 64)" +); +const _: () = assert!( + super::DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL == 64 + && super::DEFAULT_MAX_SIMULTANEOUS_REQUESTS_FOR_BUN_INSTALL_FOR_PROXIES == 64, + "update the default in the --network-concurrency help text (and docs/snippets/cli/*.mdx)" +); + const SHARED_HEAD_PARAMS: &[ParamType] = &[ clap::param!("-c, --config ? Specify path to config file (bunfig.toml)"), clap::param!("-y, --yarn Write a yarn.lock file (yarn v1)"), @@ -103,9 +112,7 @@ const SHARED_TAIL_PARAMS: &[ParamType] = &[ clap::param!( "--concurrent-scripts Maximum number of concurrent jobs for lifecycle scripts (default: 2x CPU cores)" ), - clap::param!( - "--network-concurrency Maximum number of concurrent network requests (default 48)" - ), + NETWORK_CONCURRENCY_PARAM, clap::param!("--save-text-lockfile Save a text-based lockfile"), clap::param!( "--omit ... Exclude 'dev', 'optional', or 'peer' dependencies from install" diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index aeb048ae55ac..817c06db84c6 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -69,6 +69,66 @@ async function withContext( // Default context options for most tests const defaultOpts = { linker: "hoisted" as const }; +// BUN_CONFIG_MAX_HTTP_REQUESTS is documented as another way to set the request +// limit, so it must not leak in from the outer environment while measuring it. +const networkConcurrencyEnv = { ...env, BUN_CONFIG_MAX_HTTP_REQUESTS: undefined }; + +// Runs `bun install` with one dependency more than `limit`, against a registry +// that holds every manifest request open, and asserts that bun has exactly +// `limit` requests in flight: the extra dependency has to wait for a slot. +async function expectInstallInFlightLimit(ctx: TestContext, limit: number) { + const dependencies: Record = {}; + for (let i = 0; i < limit + 1; i++) dependencies[`dep-${i}`] = "^1"; + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies }), + ); + + let inFlight = 0; + let maxInFlight = 0; + const limitReached = Promise.withResolvers(); + const limitExceeded = Promise.withResolvers(); + const release = Promise.withResolvers(); + setContextHandler(ctx, async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + if (inFlight >= limit) limitReached.resolve(); + if (inFlight > limit) limitExceeded.resolve(); + await release.promise; + inFlight--; + return new Response("404", { status: 404 }); + }); + + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: ctx.package_dir, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + env: networkConcurrencyEnv, + }); + try { + // A real limit below `limit` parks bun (and this wait) with fewer requests + // in flight and no further signal, so the wait is bounded (generously: this + // is a debug build under CI load) and the assertions below report what was + // reached. proc.exited covers bun giving up early. + await Promise.race([limitReached.promise, proc.exited, Bun.sleep(30_000)]); + // bun sends everything its limit allows in one burst, so a request beyond + // the limit arrives right behind the others. That it never arrives can only + // be observed by giving it a moment to show up. + await Promise.race([limitExceeded.promise, Bun.sleep(500)]); + } finally { + release.resolve(); + } + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(maxInFlight).toBe(limit); + expect(ctx.requested).toBe(limit + 1); + expect(stderr).toContain("failed to resolve"); + expect(stdout).toContain("bun install v1."); + expect(exitCode).toBe(1); +} + const gitEnv = { ...bunEnv, GIT_AUTHOR_NAME: "bun-test", @@ -123,6 +183,26 @@ function serveDirectory(root: string) { } describe.concurrent("bun-install", () => { + it("bun install --help states the --network-concurrency default that bun install actually uses", async () => { + await using help = spawn({ + cmd: [bunExe(), "install", "--help"], + stdout: "pipe", + stderr: "pipe", + env, + }); + const [helpStdout, helpStderr, helpExitCode] = await Promise.all([ + help.stdout.text(), + help.stderr.text(), + help.exited, + ]); + const helpLine = (helpStdout + helpStderr).split(/\r?\n/).find(line => line.includes("--network-concurrency")); + const documentedDefault = /Maximum number of concurrent network requests \(default (\d+)\)$/.exec(helpLine ?? ""); + expect(documentedDefault).not.toBeNull(); + expect(helpExitCode).toBe(0); + + await withContext(defaultOpts, ctx => expectInstallInFlightLimit(ctx, Number(documentedDefault![1]))); + }); + for (let input of ["abcdef", "65537", "-1"]) { it(`bun install --network-concurrency=${input} fails`, async () => { await withContext(defaultOpts, async ctx => { @@ -140,7 +220,7 @@ describe.concurrent("bun-install", () => { }`, ); const { stderr, exited } = spawn({ - cmd: [bunExe(), "install", "--network-concurrency", "abcdef"], + cmd: [bunExe(), "install", "--network-concurrency", input], cwd: ctx.package_dir, stdout: "inherit", stdin: "inherit", @@ -148,7 +228,7 @@ describe.concurrent("bun-install", () => { env, }); const err = await stderr.text(); - expect(err).toContain("Expected --network-concurrency to be a number between 0 and 65535"); + expect(err).toContain(`Expected --network-concurrency to be a number between 0 and 65535: ${input}`); expect(await exited).toBe(1); expect(urls).toBeEmpty(); }); From 3d96a1dc52e5543bf3100a4313b6e7e1d5e9699f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:01 +0000 Subject: [PATCH 048/258] http: report a refused HTTP client thread as an error instead of a crash (#38766) --- src/errno/lib.rs | 42 ++++++++++++++ src/http/HTTPThread.rs | 26 ++++++++- .../bun-install-thread-spawn-failure.test.ts | 57 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 test/cli/install/bun-install-thread-spawn-failure.test.ts diff --git a/src/errno/lib.rs b/src/errno/lib.rs index 09e327f8bf72..ff770d74dd52 100644 --- a/src/errno/lib.rs +++ b/src/errno/lib.rs @@ -304,6 +304,22 @@ pub fn from_errno(errno: i32) -> SystemErrno { SystemErrno::init(errno as i64).unwrap_or(SystemErrno::EIO) } +impl SystemErrno { + /// The OS error behind a `std::io::Error`; `None` if it is not an OS error or its code has no `SystemErrno`. + pub fn from_io_error(err: &std::io::Error) -> Option { + let code = err.raw_os_error()?; + #[cfg(windows)] + { + // A Win32 code: the `u32` entry point maps it, `i64` would read it as an errno discriminant. + SystemErrno::init(code as u32) + } + #[cfg(not(windows))] + { + SystemErrno::init(i64::from(code)) + } + } +} + #[cfg(not(windows))] impl SystemErrno { // `i64` covers every concrete call site (errno-range values). @@ -503,6 +519,32 @@ mod errno_name_tests { } } + #[test] + fn io_error_to_errno() { + // Deliberately not EAGAIN, which is what the callers fall back to. + #[cfg(not(windows))] + assert_eq!( + SystemErrno::from_io_error(&std::io::Error::from_raw_os_error(libc::ENOMEM)), + Some(SystemErrno::ENOMEM) + ); + #[cfg(windows)] + { + // Win32 ERROR_NOT_ENOUGH_MEMORY and ERROR_ACCESS_DENIED; read as errno values, 8 and 5 would be ENOEXEC and EIO. + assert_eq!( + SystemErrno::from_io_error(&std::io::Error::from_raw_os_error(8)), + Some(SystemErrno::ENOMEM) + ); + assert_eq!( + SystemErrno::from_io_error(&std::io::Error::from_raw_os_error(5)), + Some(SystemErrno::EPERM) + ); + } + assert_eq!( + SystemErrno::from_io_error(&std::io::Error::other("not from the OS")), + None + ); + } + #[test] fn coreutils_map() { assert_eq!( diff --git a/src/http/HTTPThread.rs b/src/http/HTTPThread.rs index 09e3273ccbf4..2c023c1b387b 100644 --- a/src/http/HTTPThread.rs +++ b/src/http/HTTPThread.rs @@ -5,6 +5,7 @@ use std::time::Instant; use bun_collections::ArrayHashMap; use bun_core::{self, Output}; +use bun_errno::SystemErrno; use bun_threading::{Mutex, UnboundedQueue}; use bun_uws as uws; @@ -1285,10 +1286,33 @@ mod _event_loop_draft { Ok(t) => { let _ = HTTP_THREAD_HANDLE.set(t); } - Err(err) => Output::panic(format_args!("Failed to start HTTP Client thread: {}", err)), + Err(err) => exit_spawn_failed(&err), } } + /// Nothing that needs the HTTP thread can go on without it, but a refused + /// `pthread_create`/`CreateThread` (`RLIMIT_NPROC`, a container pids limit, + /// no memory) is the environment's limit, not a bug: report it like any + /// other fatal CLI error rather than through the crash reporter. + #[cold] + #[inline(never)] + fn exit_spawn_failed(err: &std::io::Error) -> ! { + match SystemErrno::from_io_error(err) { + Some(errno) => { + bun_core::err_generic!("Failed to start HTTP Client thread: {}", errno); + if errno == SystemErrno::EAGAIN { + bun_core::note!( + "The process or thread limit may have been reached (ulimit -u, or the container's pids limit); raise it or reduce concurrency" + ); + } + } + // No errno name for this OS code (most Windows thread-creation + // failures): show the OS's own description instead of guessing one. + None => bun_core::err_generic!("Failed to start HTTP Client thread: {}", err), + } + bun_core::Global::crash() + } + fn on_start(opts: InitOpts) { Output::Source::configure_named_thread(bun_core::zstr!("HTTP Client")); diff --git a/test/cli/install/bun-install-thread-spawn-failure.test.ts b/test/cli/install/bun-install-thread-spawn-failure.test.ts new file mode 100644 index 000000000000..907cfa20ca70 --- /dev/null +++ b/test/cli/install/bun-install-thread-spawn-failure.test.ts @@ -0,0 +1,57 @@ +// `bun install` starts the HTTP client thread unconditionally. When the OS +// refuses the thread (EAGAIN under a tight `ulimit -u` / RLIMIT_NPROC or a +// container pids limit) that is the environment's limit, not a bug in bun, so +// it must be reported as a plain error with exit code 1 instead of going +// through the crash reporter ("oh no: Bun has crashed", bun.report link). +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, compileFixture, isLinux, isMusl, tempDir } from "harness"; +import { join } from "node:path"; + +const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); + +// Models a process sitting at its thread limit: every further pthread_create +// fails with `code`, the way pthread_create reports errors (a returned errno). +const shimC = (code: string) => /* c */ ` +#include +#include + +int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg) { + (void)thread; (void)attr; (void)start; (void)arg; + return ${code}; +} +`; + +// bun-musl is statically linked, so LD_PRELOAD cannot intercept pthread_create. +describe.skipIf(!isLinux || isMusl || !cc)("bun install when the HTTP client thread cannot be started", () => { + test.concurrent.each([ + // [pthread_create result, how it is reported, whether the thread-limit hint applies] + ["EAGAIN", "EAGAIN", true], + ["EPERM", "EPERM", false], + // A code bun has no errno name for is reported with the OS's own description. + ["9999", "Unknown error 9999 (os error 9999)", false], + ])("pthread_create returning %s exits 1 with an error naming %s", async (code, reported, hintApplies) => { + using dir = tempDir(`install-thread-spawn-${code}`, { + "shim.c": shimC(code), + "package.json": JSON.stringify({ name: "thread-spawn-failure", dependencies: {} }), + }); + const shim = compileFixture(join(String(dir), "shim.c")); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), + LD_PRELOAD: [shim, bunEnv.LD_PRELOAD].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, , exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + + expect(stderr).toContain(`Failed to start HTTP Client thread: ${reported}`); + expect(stderr.includes("ulimit -u")).toBe(hintApplies); + expect(stderr).not.toContain("Bun has crashed"); + expect(exitCode).toBe(1); + }); +}); From 878f8c21c8c9b8f9ca1d1519cffdb09c405f2a18 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:05 +0000 Subject: [PATCH 049/258] bun why: print a package's dependents once, mark repeats as *deduped (#37922) --- docs/pm/cli/why.mdx | 18 +++ src/runtime/cli/why_command.rs | 89 +++++++++++-- test/cli/install/bun-pm-why.test.ts | 196 +++++++++++++++++++++++++++- 3 files changed, 288 insertions(+), 15 deletions(-) diff --git a/docs/pm/cli/why.mdx b/docs/pm/cli/why.mdx index 2091605c8c99..6db73b4723f4 100644 --- a/docs/pm/cli/why.mdx +++ b/docs/pm/cli/why.mdx @@ -82,3 +82,21 @@ The output shows: - The version requirement specified in each package's dependencies For nested dependencies, the command shows the complete dependency tree by default, with indentation indicating the relationship hierarchy. + +A package can appear in the tree more than once when several chains go through it. Once its dependents have been listed (and take up more than one line), later occurrences are marked `*deduped` instead of listing them again. The exception is `--depth`: if the listing was cut off by the depth limit and the package appears again closer to the queried package, where more levels fit, it is listed again. A dependency cycle is marked `*circular`. + +```bash terminal icon="terminal" +bun why pkg-x +``` + +```txt +pkg-x@workspace:packages/pkg-x + ├─ monorepo + ├─ pkg-a@workspace (requires workspace:*) + │ └─ pkg-c@workspace (requires workspace:*) + │ └─ pkg-d@workspace (requires workspace:*) + │ └─ pkg-f@workspace (requires workspace:*) + │ + └─ pkg-c@workspace (requires workspace:*) + └─ *deduped +``` diff --git a/src/runtime/cli/why_command.rs b/src/runtime/cli/why_command.rs index 095c8e8db2ce..a4ba088cf79b 100644 --- a/src/runtime/cli/why_command.rs +++ b/src/runtime/cli/why_command.rs @@ -486,7 +486,6 @@ impl WhyCommand { } else if MAX_DEPTH.load(AtomicOrdering::Relaxed) == 0 { bun_core::prettyln!(" └─ (deeper dependencies hidden)"); } else { - let _ctx_data = TreeContext::init(&all_dependents); // Clone the slice so it can be sorted while `ctx_data` // still borrows `all_dependents`. let mut sorted: Vec = dependents.clone(); @@ -515,8 +514,6 @@ impl WhyCommand { ); } } - - ctx_data.clear_path_tracker(); } } else { bun_core::prettyln!(" └─ No dependents found"); @@ -565,7 +562,24 @@ fn print_package_with_type(prefix: &[u8], package: &DependentInfo) { pub(crate) struct TreeContext<'a> { all_dependents: &'a HashMap>, - path_tracker: HashMap, + /// Packages being expanded right now; the value records whether a `*circular` pointed at it. + path_tracker: HashMap, + /// Subtrees already printed; repeats print `*deduped` (there are exponentially many paths). + expanded: HashMap, +} + +#[derive(Clone, Copy)] +struct Expanded { + depth: usize, + cutoff: usize, +} + +/// What `print_dependency_tree` printed below a package line. +#[derive(Clone, Copy)] +struct Printed { + lines: usize, + /// Repeating the package at a depth below this shows more: a branch ran into `MAX_DEPTH`. + cutoff: usize, } impl<'a> TreeContext<'a> { @@ -573,11 +587,14 @@ impl<'a> TreeContext<'a> { TreeContext { all_dependents, path_tracker: HashMap::default(), + expanded: HashMap::default(), } } - fn clear_path_tracker(&mut self) { - self.path_tracker.clear(); + /// The earlier expansion of `pkg_id`, unless repeating it at `depth` would show more. + fn already_expanded(&self, pkg_id: PackageID, depth: usize) -> Option { + let previous = *self.expanded.get(&pkg_id)?; + (depth >= previous.cutoff).then_some(previous) } } @@ -588,16 +605,33 @@ fn print_dependency_tree( depth: usize, printed_break_line: bool, parent_is_workspace: bool, -) { - if ctx.path_tracker.get(¤t_pkg_id).is_some() { +) -> Printed { + if let Some(pointed_at_by_cycle) = ctx.path_tracker.get_mut(¤t_pkg_id) { + *pointed_at_by_cycle = true; bun_core::prettyln!("{}└─ *circular", BStr::new(prefix)); - return; + return Printed { + lines: 1, + cutoff: 0, + }; + } + + if let Some(previous) = ctx.already_expanded(current_pkg_id, depth) { + bun_core::prettyln!("{}└─ *deduped", BStr::new(prefix)); + return Printed { + lines: 1, + cutoff: previous.cutoff, + }; } - ctx.path_tracker.insert(current_pkg_id, depth); + ctx.path_tracker.insert(current_pkg_id, false); // All post-insert exit paths below remove explicitly. Error paths are gone // (alloc failures abort under global mimalloc). + let mut printed = Printed { + lines: 0, + cutoff: 0, + }; + if let Some(dependents) = ctx.all_dependents.get(¤t_pkg_id) { let mut sorted_dependents: Vec = dependents.clone(); sorted_dependents.sort_by(cmp_dependents); @@ -610,8 +644,9 @@ fn print_dependency_tree( if depth >= MAX_DEPTH.load(AtomicOrdering::Relaxed) { bun_core::prettyln!("{}└─ (deeper dependencies hidden)", BStr::new(prefix)); - ctx.path_tracker.remove(¤t_pkg_id); - return; + printed.lines += 1; + printed.cutoff = depth; + break; } let is_dep_last = dep_idx == len - 1; @@ -625,6 +660,7 @@ fn print_dependency_tree( full_prefix.extend_from_slice(prefix); full_prefix.extend_from_slice(prefix_char); print_package_with_type(&full_prefix, dep); + printed.lines += 1; let next_suffix: &[u8] = if is_dep_last { b" " @@ -636,7 +672,7 @@ fn print_dependency_tree( next_prefix.extend_from_slice(next_suffix); let print_break_line = is_dep_last && len > 1 && !printed_break_line; - print_dependency_tree( + let subtree = print_dependency_tree( ctx, dep.pkg_id, &next_prefix, @@ -644,6 +680,9 @@ fn print_dependency_tree( printed_break_line || print_break_line, dep.workspace, ); + printed.lines += subtree.lines; + // A dependent repeats one level deeper than this package does. + printed.cutoff = printed.cutoff.max(subtree.cutoff.saturating_sub(1)); if print_break_line { bun_core::prettyln!("{}", BStr::new(prefix)); @@ -651,5 +690,27 @@ fn print_dependency_tree( } } - ctx.path_tracker.remove(¤t_pkg_id); + let pointed_at_by_cycle = ctx.path_tracker.remove(¤t_pkg_id) == Some(true); + + if pointed_at_by_cycle && printed.cutoff > 0 { + // Their `*circular` stood in for this cut-off subtree; a repeat reaches it one level down. + for record in ctx.expanded.values_mut() { + if record.depth > depth { + record.cutoff = record.cutoff.max(printed.cutoff - 1); + } + } + } + + // A one-line subtree is no longer than the `*deduped` marker, so it is repeated instead. + if printed.lines > 1 { + ctx.expanded.insert( + current_pkg_id, + Expanded { + depth, + cutoff: printed.cutoff, + }, + ); + } + + printed } diff --git a/test/cli/install/bun-pm-why.test.ts b/test/cli/install/bun-pm-why.test.ts index aa147f047daf..ff47bc4ac411 100644 --- a/test/cli/install/bun-pm-why.test.ts +++ b/test/cli/install/bun-pm-why.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; import { existsSync, mkdtempSync, realpathSync } from "node:fs"; import { mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -579,4 +579,198 @@ describe.concurrent.each(["why", "pm why"])("bun %s", cmd => { expect(outputDepth2).toContain("mime-db@"); }); + + describe("packages reachable through more than one path", () => { + // Workspace packages that only depend on each other, so `bun install` never + // contacts a registry. The root sorts before the `pkg-*`/`w*` names. + function workspaceFixture(name: string, dependencies: Record) { + const files: Record = { + "package.json": JSON.stringify({ name: "monorepo", private: true, workspaces: ["p/*"] }), + }; + for (const [pkg, deps] of Object.entries(dependencies)) { + files[`p/${pkg}/package.json`] = JSON.stringify({ + name: pkg, + version: "1.0.0", + dependencies: Object.fromEntries(deps.map(dep => [dep, "workspace:*"])), + }); + } + return tempDir(name, files); + } + + async function installAndWhy(cwd: string, args: string[]) { + await using install = spawn({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, installStderr, installExitCode] = await Promise.all([ + install.stdout.text(), + install.stderr.text(), + install.exited, + ]); + expect(installStderr).toContain("Saved lockfile"); + expect(installExitCode).toBe(0); + + await using why = spawn({ + cmd: [bunExe(), ...cmd.split(" "), ...args], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([why.stdout.text(), why.stderr.text(), why.exited]); + return { + // The tree prints spacer lines that are only a trailing prefix. + stdout: normalizeBunSnapshot(stdout) + .split("\n") + .map(line => line.trimEnd()) + .join("\n"), + stderr, + exitCode, + }; + } + + // pkg-c is listed under pkg-x twice: it depends on pkg-x directly and on + // pkg-a, which depends on pkg-x. + const diamond = { + "pkg-x": [], + "pkg-a": ["pkg-x"], + "pkg-c": ["pkg-x", "pkg-a"], + "pkg-d": ["pkg-c"], + "pkg-f": ["pkg-d"], + }; + + it("prints the dependents of a package once and marks later occurrences as deduped", async () => { + using dir = workspaceFixture("why-deduped", diamond); + + const { stdout, stderr, exitCode } = await installAndWhy(String(dir), ["pkg-x"]); + expect(stdout).toMatchInlineSnapshot(` + "pkg-x@workspace:p/pkg-x + ├─ monorepo + ├─ pkg-a@workspace (requires workspace:*) + │ └─ pkg-c@workspace (requires workspace:*) + │ └─ pkg-d@workspace (requires workspace:*) + │ └─ pkg-f@workspace (requires workspace:*) + │ + └─ pkg-c@workspace (requires workspace:*) + └─ *deduped" + `); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + it("expands a deduped package again when --depth cut it off at a deeper occurrence", async () => { + using dir = workspaceFixture("why-deduped-depth", diamond); + + const { stdout, stderr, exitCode } = await installAndWhy(String(dir), ["pkg-x", "--depth", "3"]); + expect(stdout).toMatchInlineSnapshot(` + "pkg-x@workspace:p/pkg-x + ├─ monorepo + ├─ pkg-a@workspace (requires workspace:*) + │ └─ pkg-c@workspace (requires workspace:*) + │ └─ pkg-d@workspace (requires workspace:*) + │ └─ (deeper dependencies hidden) + │ + └─ pkg-c@workspace (requires workspace:*) + └─ pkg-d@workspace (requires workspace:*) + └─ pkg-f@workspace (requires workspace:*)" + `); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + // pkg-a and pkg-p depend on each other. Walking up from pkg-x, the cycle is + // first reached through pkg-l1 -> pkg-l2 -> pkg-a, where pkg-p's only + // dependent (pkg-a) is cut off as circular, and again through pkg-p itself, + // three levels closer to pkg-x. + const cycle = { + "pkg-x": [], + "pkg-l1": ["pkg-x"], + "pkg-l2": ["pkg-l1"], + "pkg-a": ["pkg-l2", "pkg-p"], + "pkg-p": ["pkg-a", "pkg-x"], + "pkg-q1": ["pkg-a"], + "pkg-q2": ["pkg-q1"], + "pkg-q3": ["pkg-q2"], + }; + + it("does not dedupe a package whose circular branch hides what --depth would show closer to the root", async () => { + using dir = workspaceFixture("why-deduped-cycle-depth", cycle); + + // pkg-q3 only fits within the depth limit under the shorter chain. + const { stdout, stderr, exitCode } = await installAndWhy(String(dir), ["pkg-x", "--depth", "5"]); + expect(stdout).toMatchInlineSnapshot(` + "pkg-x@workspace:p/pkg-x + ├─ monorepo + ├─ pkg-l1@workspace (requires workspace:*) + │ └─ pkg-l2@workspace (requires workspace:*) + │ └─ pkg-a@workspace (requires workspace:*) + │ ├─ pkg-p@workspace (requires workspace:*) + │ │ └─ pkg-a@workspace (requires workspace:*) + │ │ └─ *circular + │ └─ pkg-q1@workspace (requires workspace:*) + │ └─ pkg-q2@workspace (requires workspace:*) + │ └─ (deeper dependencies hidden) + │ + └─ pkg-p@workspace (requires workspace:*) + └─ pkg-a@workspace (requires workspace:*) + ├─ pkg-p@workspace (requires workspace:*) + │ └─ *circular + └─ pkg-q1@workspace (requires workspace:*) + └─ pkg-q2@workspace (requires workspace:*) + └─ pkg-q3@workspace (requires workspace:*)" + `); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + it("dedupes a package inside a cycle once its dependents were printed in full", async () => { + using dir = workspaceFixture("why-deduped-cycle", cycle); + + const { stdout, stderr, exitCode } = await installAndWhy(String(dir), ["pkg-x"]); + expect(stdout).toMatchInlineSnapshot(` + "pkg-x@workspace:p/pkg-x + ├─ monorepo + ├─ pkg-l1@workspace (requires workspace:*) + │ └─ pkg-l2@workspace (requires workspace:*) + │ └─ pkg-a@workspace (requires workspace:*) + │ ├─ pkg-p@workspace (requires workspace:*) + │ │ └─ pkg-a@workspace (requires workspace:*) + │ │ └─ *circular + │ └─ pkg-q1@workspace (requires workspace:*) + │ └─ pkg-q2@workspace (requires workspace:*) + │ └─ pkg-q3@workspace (requires workspace:*) + │ + └─ pkg-p@workspace (requires workspace:*) + └─ *deduped" + `); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + it("keeps the output linear when every package depends on the next two", async () => { + // w00 depends on w01 and w02, w01 on w02 and w03, and so on, so the + // number of dependency paths between w15 and any one package grows like + // the Fibonacci sequence. + const count = 16; + const name = (i: number) => `w${String(i).padStart(2, "0")}`; + const ladder: Record = {}; + for (let i = 0; i < count; i++) { + ladder[name(i)] = [i + 1, i + 2].filter(j => j < count).map(name); + } + using dir = workspaceFixture("why-ladder", ladder); + + const { stdout, stderr, exitCode } = await installAndWhy(String(dir), [name(count - 1)]); + const lines = stdout.split("\n"); + expect({ + // w08 depends on w09 and w10, so it is listed under each of them, and + // each of those is expanded exactly once. + w08: lines.filter(line => line.includes("w08@workspace")).length, + // One marker each for w02 through w13. w00 and w01 have at most one line + // below them, so they are repeated instead, and w14 is only listed once + // because it only depends on w15. + deduped: lines.filter(line => line.endsWith("*deduped")).length, + stderr, + exitCode, + }).toEqual({ w08: 2, deduped: count - 4, stderr: "", exitCode: 0 }); + }); + }); }); From 1ed855041963882ee2406987ab30b21a23efa03b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:09 +0000 Subject: [PATCH 050/258] pm pkg: support arrays in set and delete (#38025) --- docs/pm/cli/pm.mdx | 2 + src/runtime/cli/pm_pkg_command.rs | 419 ++++++++++++++-------------- src/runtime/error.rs | 3 - test/cli/install/bun-pm-pkg.test.ts | 222 +++++++++++++-- 4 files changed, 410 insertions(+), 236 deletions(-) diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index f937d84b6b07..5b6ba556d06a 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -379,6 +379,8 @@ bun pm pkg get scripts.build # nested property bun pm pkg set name="my-package" # simple property bun pm pkg set scripts.test="jest" version=2.0.0 # multiple properties bun pm pkg set private=true --json # JSON values with --json flag +bun pm pkg set 'keywords[]=cli' # append to an array (created if missing) +bun pm pkg set 'contributors[0].name=Jane' # update an array element # delete bun pm pkg delete description # single property diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 556f5cfd0d0c..2ad62ff0123b 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -5,6 +5,7 @@ use crate::cli::command::Context; use bun_ast::{E, Expr, ExprData, G}; use bun_ast::{Loc, Log, Source}; use bun_collections::{StringArrayHashMap, VecExt}; +use bun_core::fmt::quote; use bun_core::strings; use bun_core::{Global, Output}; use bun_install::PackageManager; @@ -48,6 +49,40 @@ struct PackageJson { indentation: bun_ast::Indentation, } +/// One step of a key path such as `contributors[0].name` or `keywords[]`. +#[derive(Clone, Copy)] +struct Segment<'a> { + kind: SegmentKind<'a>, + /// Offset just past this segment in the key; errors name a value by `&key[..end]`. + end: usize, +} + +#[derive(Clone, Copy)] +enum SegmentKind<'a> { + /// `b` in `a.b` or `a[b]`: an index when applied to an array, otherwise a property name. + Key { name: &'a [u8], bracketed: bool }, + /// `[]`: the slot after the last element of an array. + Append, +} + +impl Segment<'_> { + /// `files[0]=x` and `files[]=x` create an array; `config.0=x` creates an object keyed `"0"`. + fn creates_array(self) -> bool { + match self.kind { + SegmentKind::Key { + name, + bracketed: true, + } => array_index(name).is_some(), + SegmentKind::Key { .. } => false, + SegmentKind::Append => true, + } + } +} + +fn array_index(name: &[u8]) -> Option { + bun_core::fmt::parse_decimal::(name) +} + impl PmPkgCommand { pub(crate) fn exec( ctx: &Context, @@ -106,7 +141,9 @@ impl PmPkgCommand { $ bun pm pkg set config='{"port":3000,"debug":true}' --json $ bun pm pkg set scripts.test="bun test" $ bun pm pkg set bin.mycli=cli.js + $ bun pm pkg set 'keywords[]=cli' 'files[0]=dist' $ bun pm pkg delete scripts.test devDependencies.webpack + $ bun pm pkg delete 'keywords[0]' $ bun pm pkg fix More info: https://bun.com/docs/cli/pm#pkg @@ -490,128 +527,66 @@ impl PmPkgCommand { } fn resolve_path(root: Expr, key: &[u8]) -> Result { - if !matches!(root.data, ExprData::EObject(_)) { - return Err(crate::Error::NotFound); - } - - let mut parts = strings::tokenize(key, b"."); let mut current = root; - - while let Some(part) = parts.next() { - if let Some(first_bracket) = strings::index_of(part, b"[") { - let mut remaining_part = part; - - if first_bracket > 0 { - let prop_name = &part[..first_bracket]; - if !matches!(current.data, ExprData::EObject(_)) { - return Err(crate::Error::NotFound); - } - current = current.get(prop_name).ok_or(crate::Error::NotFound)?; - remaining_part = &part[first_bracket..]; - } - - while let Some(bracket_start) = strings::index_of(remaining_part, b"[") { - let bracket_end = strings::index_of(&remaining_part[bracket_start..], b"]") - .ok_or(crate::Error::InvalidPath)?; - let actual_bracket_end = bracket_start + bracket_end; - let index_str = &remaining_part[bracket_start + 1..actual_bracket_end]; - - if index_str.is_empty() { - return Err(crate::Error::InvalidPath); - } - - if let Some(index) = bun_core::fmt::parse_decimal::(index_str) { - let ExprData::EArray(arr) = ¤t.data else { - return Err(crate::Error::NotFound); - }; - - if index >= arr.items.len_u32() as usize { - return Err(crate::Error::NotFound); - } - - current = arr.items.slice()[index]; - } else { - if !matches!(current.data, ExprData::EObject(_)) { - return Err(crate::Error::NotFound); - } - current = current.get(index_str).ok_or(crate::Error::NotFound)?; - } - - remaining_part = &remaining_part[actual_bracket_end + 1..]; - if remaining_part.is_empty() { - break; - } - } - } else { - if let Some(index) = bun_core::fmt::parse_decimal::(part) { - match ¤t.data { - ExprData::EArray(arr) => { - if index >= arr.items.len_u32() as usize { - return Err(crate::Error::NotFound); - } - current = arr.items.slice()[index]; - } - ExprData::EObject(_) => { - current = current.get(part).ok_or(crate::Error::NotFound)?; - } - _ => return Err(crate::Error::NotFound), - } - } else { - if !matches!(current.data, ExprData::EObject(_)) { - return Err(crate::Error::NotFound); - } - current = current.get(part).ok_or(crate::Error::NotFound)?; - } - } + for segment in Self::parse_key_path(key)? { + let SegmentKind::Key { name, .. } = segment.kind else { + return Err(crate::Error::InvalidPath); + }; + current = match ¤t.data { + ExprData::EArray(array) => array_index(name) + .and_then(|index| array.items.slice().get(index).copied()) + .ok_or(crate::Error::NotFound)?, + ExprData::EObject(_) => current.get(name).ok_or(crate::Error::NotFound)?, + _ => return Err(crate::Error::NotFound), + }; } - Ok(current) } - /// Splits `a.b[0][c]` into `["a", "b", "0", "c"]`. Segments are sub-slices - /// of `key`: `E::Object::put` stores keys by reference (no copy into the - /// AST arena), so they must outlive the `Expr` tree, which `key` (an argv - /// slice) does. Returning owned buffers here would leave dangling keys. - fn parse_key_path(key: &[u8]) -> Result, Error> { - let mut path_parts: Vec<&[u8]> = Vec::new(); - - let mut parts = strings::tokenize(key, b"."); - - while let Some(part) = parts.next() { - if let Some(first_bracket) = strings::index_of(part, b"[") { - let mut remaining_part = part; - - if first_bracket > 0 { - path_parts.push(&part[..first_bracket]); - remaining_part = &part[first_bracket..]; - } - - while let Some(bracket_start) = strings::index_of(remaining_part, b"[") { - let Some(bracket_end) = - strings::index_of(&remaining_part[bracket_start..], b"]") - else { - return Err(crate::Error::InvalidPath); - }; - let actual_bracket_end = bracket_start + bracket_end; - let index_str = &remaining_part[bracket_start + 1..actual_bracket_end]; - - if index_str.is_empty() { - return Err(crate::Error::InvalidPath); - } - - path_parts.push(index_str); + /// Names are sub-slices of `key`; `E::Object::put` stores them by reference (#33186). + fn parse_key_path(key: &[u8]) -> Result>, Error> { + let mut segments: Vec> = Vec::new(); + + let mut part_start = 0; + for part in strings::split(key, b".") { + let start = part_start; + part_start += part.len() + b".".len(); + + let name_len = strings::index_of(part, b"[").unwrap_or(part.len()); + if name_len > 0 { + segments.push(Segment { + kind: SegmentKind::Key { + name: &part[..name_len], + bracketed: false, + }, + end: start + name_len, + }); + } - remaining_part = &remaining_part[actual_bracket_end + 1..]; - if remaining_part.is_empty() { - break; - } - } - } else { - path_parts.push(part); + let mut cursor = name_len; + while let Some(open) = strings::index_of(&part[cursor..], b"[") { + let open = cursor + open; + let Some(close) = strings::index_of(&part[open..], b"]") else { + return Err(crate::Error::InvalidPath); + }; + let close = open + close; + let name = &part[open + 1..close]; + segments.push(Segment { + kind: if name.is_empty() { + SegmentKind::Append + } else { + SegmentKind::Key { + name, + bracketed: true, + } + }, + end: start + close + 1, + }); + cursor = close + 1; } } - Ok(path_parts) + Ok(segments) } fn set_value(root: &mut Expr, key: &[u8], value: &[u8], parse_json: bool) -> Result<(), Error> { @@ -619,70 +594,105 @@ impl PmPkgCommand { return Err(crate::Error::InvalidRoot); } - let path_parts = Self::parse_key_path(key)?; - - if path_parts.is_empty() { + let path = Self::parse_key_path(key)?; + if path.is_empty() { return Err(crate::Error::EmptyKey); } - if path_parts.len() == 1 { - let expr = Self::parse_value(value, parse_json)?; - - root.data - .e_object_mut() - .unwrap() - .put(dummy_bump(), path_parts[0], expr)?; - - return Ok(()); - } - - Self::set_nested(root, &path_parts, value, parse_json) + let expr = Self::parse_value(value, parse_json)?; + Self::set_in_container(root, b"package.json", key, &path, expr) } - fn set_nested( - root: &mut Expr, - path: &[&[u8]], - value: &[u8], - parse_json: bool, + /// As in npm, only the final segment may replace a value; anything else in the way is an error. + fn set_in_container( + container: &mut Expr, + container_name: &[u8], + key: &[u8], + path: &[Segment<'_>], + value: Expr, ) -> Result<(), Error> { - if path.is_empty() { + let [segment, rest @ ..] = path else { return Ok(()); + }; + let slot_name = &key[..segment.end]; + + if let Some(array) = container.data.e_array_mut() { + let len = array.items.len(); + let index = match segment.kind { + SegmentKind::Append => len, + SegmentKind::Key { name, .. } => match array_index(name) { + Some(index) if index <= len => index, + Some(index) => { + Output::err_generic( + "{s}: index {s} is out of range for {s} (length {s})", + (quote(key), index, quote(container_name), len), + ); + bun_core::note!( + "{}[] appends to the end of the array", + bstr::BStr::new(container_name) + ); + Global::exit(1); + } + None => { + Output::err_generic( + "{s}: {s} is an array, so {s} must be an index or []", + (quote(key), quote(container_name), quote(name)), + ); + Global::exit(1); + } + }, + }; + if index == len { + array.push(dummy_bump(), Expr::init(E::Null {}, Loc::EMPTY))?; + } + let Some(next) = rest.first() else { + array.items[index] = value; + return Ok(()); + }; + let mut child = Self::child_container(Some(array.items[index]), *next, key, slot_name); + array.items[index] = child; + return Self::set_in_container(&mut child, slot_name, key, rest, value); } - let current_key = path[0]; - let remaining_path = &path[1..]; - - if remaining_path.is_empty() { - let expr = Self::parse_value(value, parse_json)?; - - root.data - .e_object_mut() - .unwrap() - .put(dummy_bump(), current_key, expr)?; - + let SegmentKind::Key { name, .. } = segment.kind else { + Output::err_generic( + "{s}: cannot append to {s} because it is not an array", + (quote(key), quote(container_name)), + ); + Global::exit(1); + }; + let object = container + .data + .e_object_mut() + .expect("set_value checks the root and child_container only returns arrays or objects"); + let Some(next) = rest.first() else { + object.put(dummy_bump(), name, value)?; return Ok(()); - } - - let mut nested_obj = root.get(current_key); - if nested_obj.is_none() - || !matches!(nested_obj.as_ref().unwrap().data, ExprData::EObject(_)) - { - let new_obj = Expr::init(E::Object::default(), Loc::EMPTY); - - root.data - .e_object_mut() - .unwrap() - .put(dummy_bump(), current_key, new_obj)?; - - nested_obj = root.get(current_key); - } + }; + let mut child = Self::child_container(object.get(name), *next, key, slot_name); + object.put(dummy_bump(), name, child)?; + Self::set_in_container(&mut child, slot_name, key, rest, value) + } - if !matches!(nested_obj.as_ref().unwrap().data, ExprData::EObject(_)) { - return Err(crate::Error::ExpectedObject); + /// A missing or `null` slot gets a new container shaped for `next`. + fn child_container( + existing: Option, + next: Segment<'_>, + key: &[u8], + slot_name: &[u8], + ) -> Expr { + match existing { + Some(expr) if matches!(expr.data, ExprData::EArray(_) | ExprData::EObject(_)) => expr, + Some(expr) if !matches!(expr.data, ExprData::ENull(_)) => { + Output::err_generic( + "{s}: {s} already exists and is not an object or array", + (quote(key), quote(slot_name)), + ); + Global::exit(1); + } + _ if next.creates_array() => Expr::init(E::Array::default(), Loc::EMPTY), + _ => Expr::init(E::Object::default(), Loc::EMPTY), } - - let mut nested = nested_obj.unwrap(); - Self::set_nested(&mut nested, remaining_path, value, parse_json) } fn parse_value(value: &[u8], parse_json: bool) -> Result { @@ -724,60 +734,41 @@ impl PmPkgCommand { return Ok(false); } - let mut path_parts: Vec<&[u8]> = Vec::new(); - for part in strings::tokenize(key, b".") { - path_parts.push(part); - } - - if path_parts.is_empty() { - return Ok(false); - } - - if path_parts.len() == 1 { - let exists = root.get(path_parts[0]).is_some(); - if exists { - return Self::remove_property(root, path_parts[0]); - } - return Ok(false); - } - - Self::delete_nested(root, &path_parts) + Self::delete_in_container(root, &Self::parse_key_path(key)?) } - fn delete_nested(root: &mut Expr, path: &[&[u8]]) -> Result { - if path.is_empty() { + /// Splices out an array element or removes a property; returns whether anything was removed. + fn delete_in_container(container: &mut Expr, path: &[Segment<'_>]) -> Result { + let [segment, rest @ ..] = path else { return Ok(false); - } - - let current_key = path[0]; - let remaining_path = &path[1..]; + }; + let SegmentKind::Key { name, .. } = segment.kind else { + Output::err_generic( + "Empty brackets are not valid syntax for deleting values.", + (), + ); + Global::exit(1); + }; - if remaining_path.is_empty() { - let exists = root.get(current_key).is_some(); - if exists { - return Self::remove_property(root, current_key); + let mut child = if let Some(array) = container.data.e_array_mut() { + let Some(index) = array_index(name).filter(|&index| index < array.items.len()) else { + return Ok(false); + }; + if rest.is_empty() { + array.items.remove(index); + return Ok(true); } - return Ok(false); - } - - let nested_obj = root.get(current_key); - if nested_obj.is_none() - || !matches!(nested_obj.as_ref().unwrap().data, ExprData::EObject(_)) - { - return Ok(false); - } - - let mut nested = nested_obj.unwrap(); - let deleted = Self::delete_nested(&mut nested, remaining_path)?; - - if deleted { - root.data - .e_object_mut() - .unwrap() - .put(dummy_bump(), current_key, nested)?; - } - - Ok(deleted) + array.items[index] + } else { + if rest.is_empty() { + return Self::remove_property(container, name); + } + let Some(child) = container.get(name) else { + return Ok(false); + }; + child + }; + Self::delete_in_container(&mut child, rest) } fn remove_property(obj: &mut Expr, key: &[u8]) -> Result { diff --git a/src/runtime/error.rs b/src/runtime/error.rs index f68f3158b592..d872df9deb14 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -248,8 +248,6 @@ pub enum Error { InvalidRoot, #[error("EmptyKey")] EmptyKey, - #[error("ExpectedObject")] - ExpectedObject, #[error("FormatFailed")] FormatFailed, #[error("SelfExePathFailed")] @@ -694,7 +692,6 @@ impl Error { Self::NotFound => "NotFound", Self::InvalidRoot => "InvalidRoot", Self::EmptyKey => "EmptyKey", - Self::ExpectedObject => "ExpectedObject", Self::FormatFailed => "FormatFailed", Self::SelfExePathFailed => "SelfExePathFailed", Self::SpawnFailed => "SpawnFailed", diff --git a/test/cli/install/bun-pm-pkg.test.ts b/test/cli/install/bun-pm-pkg.test.ts index 1c9bfdc9f6f7..3b28026edd91 100644 --- a/test/cli/install/bun-pm-pkg.test.ts +++ b/test/cli/install/bun-pm-pkg.test.ts @@ -23,6 +23,7 @@ async function runPmPkg(args: string[], cwd: string, expectSuccess = true) { } const readPkg = (dir: string) => Bun.file(join(dir, "package.json")).json(); +const readRaw = (dir: string) => Bun.file(join(dir, "package.json")).text(); function createTestPackageJson(overrides = {}) { return JSON.stringify( @@ -312,12 +313,156 @@ describe.concurrent("bun pm pkg", () => { expect(await readPkg(dir)).toEqual({ name: "x", version: "1.0.0", - contributors: { "0": "alice" }, - nested: { deep: { "0": "value" } }, + contributors: ["alice"], + nested: { deep: ["value"] }, scripts: { lint: "eslint ." }, }); }); + describe("arrays", () => { + it("should append, index, and create arrays", async () => { + using dir = makeTestDir(); + const { error, code } = await runPmPkg( + [ + "set", + // existing array: append, then replace by bracket and dot index, then add at index == length + "keywords[]=cli", + "keywords[0]=first", + "keywords.1=second", + "keywords[3]=fourth", + // missing properties: [] and a bracketed index create arrays, a dotted number creates an object + "files[]=dist", + "files[]=README.md", + "os[0]=linux", + "config.0=zero", + // existing object: either notation is a property name + "scripts[0]=bracket", + "scripts.1=dot", + // null is treated as missing + "testNull[0]=x", + "matrix[0][0]=a", + "matrix[0][]=b", + "matrix[][0]=c", + ], + dir, + ); + expect(error).toBe(""); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual({ + ...JSON.parse(createTestPackageJson()), + keywords: ["first", "second", "cli", "fourth"], + files: ["dist", "README.md"], + os: ["linux"], + config: { "0": "zero" }, + scripts: { test: "echo 'test'", build: "echo 'build'", "0": "bracket", "1": "dot" }, + testNull: ["x"], + matrix: [["a", "b"], ["c"]], + }); + + const { output } = await runPmPkg(["get", "scripts[0]", "scripts.1", "keywords[3]"], dir); + expect(JSON.parse(output)).toEqual({ "scripts[0]": "bracket", "scripts.1": "dot", "keywords[3]": "fourth" }); + }); + + it("should set properties of objects inside an array, creating elements as needed", async () => { + using dir = makeTestDir(); + const { error, code } = await runPmPkg( + [ + "set", + "contributors[0].email=john@new.example", + "contributors[1].email=jane@example.com", + "contributors[2].name=Third", + "contributors[].name=Fourth", + "testNull.key=x", + ], + dir, + ); + expect(error).toBe(""); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual({ + ...JSON.parse(createTestPackageJson()), + contributors: [ + { name: "John Doe", email: "john@new.example" }, + { name: "Jane Smith", email: "jane@example.com" }, + { name: "Third" }, + { name: "Fourth" }, + ], + testNull: { key: "x" }, + }); + }); + + it("should append --json values", async () => { + using dir = makeTestDir(); + const { code } = await runPmPkg(["set", "keywords[]=42", 'contributors[]={"name":"Obj"}', "--json"], dir); + expect(code).toBe(0); + expect(await readPkg(dir)).toMatchObject({ + keywords: ["test", "package", 42], + contributors: [{ name: "John Doe", email: "john@example.com" }, { name: "Jane Smith" }, { name: "Obj" }], + }); + }); + + it("should still replace a whole array or element when it is the final segment", async () => { + using dir = makeTestDir(); + const { code } = await runPmPkg(["set", "keywords=none", "contributors[0]=alice"], dir); + expect(code).toBe(0); + expect(await readPkg(dir)).toMatchObject({ + keywords: "none", + contributors: ["alice", { name: "Jane Smith" }], + }); + }); + + const rejected = [ + [ + "keywords[3]=x", + 'error: "keywords[3]": index 3 is out of range for "keywords" (length 2)\n' + + "note: keywords[] appends to the end of the array\n", + ], + ["keywords.foo=x", 'error: "keywords.foo": "keywords" is an array, so "foo" must be an index or []\n'], + ["scripts[]=x", 'error: "scripts[]": cannot append to "scripts" because it is not an array\n'], + ["name[]=x", 'error: "name[]": "name" already exists and is not an object or array\n'], + ["name.first=x", 'error: "name.first": "name" already exists and is not an object or array\n'], + // nested values are named by the part of the key that leads to them + [ + "matrix[0][9]=x", + 'error: "matrix[0][9]": index 9 is out of range for "matrix[0]" (length 2)\n' + + "note: matrix[0][] appends to the end of the array\n", + ], + ["matrix[0].foo=x", 'error: "matrix[0].foo": "matrix[0]" is an array, so "foo" must be an index or []\n'], + [ + "contributors[0].name.first=x", + 'error: "contributors[0].name.first": "contributors[0].name" already exists and is not an object or array\n', + ], + [ + "nested.matrix[0][9]=x", + 'error: "nested.matrix[0][9]": index 9 is out of range for "nested.matrix[0]" (length 1)\n' + + "note: nested.matrix[0][] appends to the end of the array\n", + ], + // an empty part between dots is skipped but still counts towards the name + [ + "nested..matrix[0].foo=x", + 'error: "nested..matrix[0].foo": "nested..matrix[0]" is an array, so "foo" must be an index or []\n', + ], + ] as const; + + it.each(rejected)("should reject %s without touching package.json", async (arg, expectedError) => { + using dir = tempDir("pm-pkg-reject", { + "package.json": createTestPackageJson({ matrix: [["a", "b"]], nested: { matrix: [["a"]] } }), + }); + const before = await readRaw(dir); + const { output, error, code } = await runPmPkg(["set", arg], dir, false); + expect({ output, error, code }).toEqual({ output: "", error: expectedError, code: 1 }); + expect(await readRaw(dir)).toBe(before); + }); + + it("should not write earlier arguments when a later one is rejected", async () => { + using dir = makeTestDir(); + const before = await readRaw(dir); + const { error, code } = await runPmPkg(["set", "description=changed", "keywords[9]=x"], dir, false); + expect(error).toStartWith('error: "keywords[9]": index 9 is out of range'); + expect(code).toBe(1); + expect(await readRaw(dir)).toBe(before); + }); + }); + it("should fail with invalid key=value format", async () => { using dir = makeTestDir(); const { error, code } = await runPmPkg(["set", "invalidformat"], dir, false); @@ -379,6 +524,43 @@ describe.concurrent("bun pm pkg", () => { expect(error).toContain("delete expects key args"); expect(code).toBe(1); }); + + it("should delete array elements and bracketed properties", async () => { + using dir = makeTestDir(); + const { error, code } = await runPmPkg( + ["delete", "keywords[0]", "contributors.1", "contributors[0].email", "scripts[test]"], + dir, + ); + expect(error).toBe(""); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual({ + ...JSON.parse(createTestPackageJson()), + keywords: ["package"], + contributors: [{ name: "John Doe" }], + scripts: { build: "echo 'build'" }, + }); + }); + + it("should leave package.json alone when the path does not lead anywhere", async () => { + using dir = makeTestDir(); + const before = await readRaw(dir); + const { error, code } = await runPmPkg( + ["delete", "keywords[2]", "keywords.foo", "contributors[0].missing", "name[0]", "missing[0]"], + dir, + ); + expect(error).toBe(""); + expect(code).toBe(0); + expect(await readRaw(dir)).toBe(before); + }); + + it("should reject empty brackets", async () => { + using dir = makeTestDir(); + const before = await readRaw(dir); + const { error, code } = await runPmPkg(["delete", "keywords[]"], dir, false); + expect(error).toBe("error: Empty brackets are not valid syntax for deleting values.\n"); + expect(code).toBe(1); + expect(await readRaw(dir)).toBe(before); + }); }); describe("help command", () => { @@ -668,20 +850,22 @@ describe.concurrent("bun pm pkg", () => { it("should handle numeric indices with different data types", async () => { using dir = makeTestDir(); - const [arr0, arr1] = await Promise.all([ - runPmPkg(["get", "keywords.0"], dir, false), - runPmPkg(["get", "keywords.1"], dir, false), + const setThenGet = async () => { + const { code } = await runPmPkg(["set", "config.0=zero-value"], dir); + expect(code).toBe(0); + return runPmPkg(["get", "config.0"], dir); + }; + // The read-only gets use the shared fixture so they can overlap with the set/get round trip. + const [arr0, arr1, config0] = await Promise.all([ + runPmPkg(["get", "keywords.0"], readonlyDir, false), + runPmPkg(["get", "keywords.1"], readonlyDir, false), + setThenGet(), ]); expect(arr0.output.trim()).toBe('"test"'); expect(arr0.code).toBe(0); expect(arr1.output.trim()).toBe('"package"'); expect(arr1.code).toBe(0); - - const { code: setCode } = await runPmPkg(["set", "config.0=zero-value"], dir); - expect(setCode).toBe(0); - - const { output } = await runPmPkg(["get", "config.0"], dir); - expect(output.trim()).toBe('"zero-value"'); + expect(config0.output.trim()).toBe('"zero-value"'); }); it("should gracefully handle invalid notation patterns", async () => { @@ -705,15 +889,15 @@ describe.concurrent("bun pm pkg", () => { it("should maintain consistency between set and get operations", async () => { using dir = makeTestDir(); - const { code: setCode1 } = await runPmPkg(["set", "test.array.0=first"], dir); - expect(setCode1).toBe(0); - const { output: getOutput1 } = await runPmPkg(["get", "test.array.0"], dir); - expect(getOutput1.trim()).toBe('"first"'); + const { code: setCode } = await runPmPkg(["set", "test.array.0=first", "test.bracket.access=success"], dir); + expect(setCode).toBe(0); - const { code: setCode2 } = await runPmPkg(["set", "test.bracket.access=success"], dir); - expect(setCode2).toBe(0); - const { output: getOutput2 } = await runPmPkg(["get", "test.bracket.access"], dir); - expect(getOutput2.trim()).toBe('"success"'); + const [getOutput1, getOutput2] = await Promise.all([ + runPmPkg(["get", "test.array.0"], dir), + runPmPkg(["get", "test.bracket.access"], dir), + ]); + expect(getOutput1.output.trim()).toBe('"first"'); + expect(getOutput2.output.trim()).toBe('"success"'); }); it("should handle edge cases with special characters", async () => { From 3ad2bcfce1e4b775042d4a3d99096b6fb8d72a4c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:12 +0000 Subject: [PATCH 051/258] install: format versions with the string buffer they were parsed from (bun outdated table, minimum-release-age messages) (#38649) --- .../PackageManager/PackageManagerEnqueue.rs | 7 +- src/runtime/cli/outdated_command.rs | 4 +- test/cli/install/minimum-release-age.test.ts | 189 ++++++++++++++++++ 3 files changed, 196 insertions(+), 4 deletions(-) diff --git a/src/install/PackageManager/PackageManagerEnqueue.rs b/src/install/PackageManager/PackageManagerEnqueue.rs index 79caa72f7e3a..6631caff81ee 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.rs +++ b/src/install/PackageManager/PackageManagerEnqueue.rs @@ -1086,7 +1086,7 @@ pub fn enqueue_dependency_with_main_and_success_fn( format_args!( "Version \"{}@{}\" was published within minimum release age of {} seconds", bstr::BStr::new(package_name), - find_result.version.fmt(this.lockfile.buffers.string_bytes.as_slice()), + find_result.version.fmt(&loaded_manifest.as_ref().unwrap().string_buf), min_age_seconds, ), ); @@ -2457,7 +2457,10 @@ fn get_or_put_resolved_package( } dependency::version::Tag::Npm => { // SAFETY: `version.tag == Npm`. - let version_str = &version.npm().version.fmt(manifest_buf); + let version_str = &version + .npm() + .version + .fmt(this.lockfile.buffers.string_bytes.as_slice()); bun_core::pretty_errorln!( "[minimum-release-age] {}@{} selected {} instead of {} due to {}-second filter", bstr::BStr::new(package_name), diff --git a/src/runtime/cli/outdated_command.rs b/src/runtime/cli/outdated_command.rs index 18757f7d0d32..87ab0f2f9b6b 100644 --- a/src/runtime/cli/outdated_command.rs +++ b/src/runtime/cli/outdated_command.rs @@ -482,7 +482,7 @@ impl OutdatedCommand { write!(version_buf, "{}", uv.version.fmt(&manifest.string_buf)) .expect("OOM writing version"); } else { - write!(version_buf, "{}", current_version.fmt(&manifest.string_buf)) + write!(version_buf, "{}", current_version.fmt(string_buf)) .expect("OOM writing version"); } let update_version_len = @@ -496,7 +496,7 @@ impl OutdatedCommand { write!(version_buf, "{}", lv.version.fmt(&manifest.string_buf)) .expect("OOM writing version"); } else { - write!(version_buf, "{}", current_version.fmt(&manifest.string_buf)) + write!(version_buf, "{}", current_version.fmt(string_buf)) .expect("OOM writing version"); } let latest_version_len = diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 1f7a0f934640..4692b5a16045 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -793,6 +793,78 @@ describe("minimum-release-age", () => { return Response.json(packageData); } + // TEST PACKAGE 14: snapshot-package (prerelease tag longer than the 8 bytes a + // semver string stores inline; every published version is younger than the + // filters used in the tests, so nothing can be selected) + if (url.pathname === "/snapshot-package") { + const packageData = { + name: "snapshot-package", + "dist-tags": { + latest: "1.0.0", + snapshot: "1.0.0-snapshot.20240101", + }, + versions: { + "1.0.0-snapshot.20240101": { + name: "snapshot-package", + version: "1.0.0-snapshot.20240101", + dist: { + tarball: `${mockRegistryUrl}/snapshot-package/-/snapshot-package-1.0.0-snapshot.20240101.tgz`, + integrity: "sha512-snapshot==", + }, + }, + "1.0.0": { + name: "snapshot-package", + version: "1.0.0", + dist: { + tarball: `${mockRegistryUrl}/snapshot-package/-/snapshot-package-1.0.0.tgz`, + integrity: "sha512-stable==", + }, + }, + }, + time: { + "1.0.0-snapshot.20240101": daysAgo(2), + "1.0.0": daysAgo(1), + }, + }; + + return Response.json(packageData); + } + + // TEST PACKAGE 15: nightly-package (prerelease tags longer than the inline + // limit; the older nightly passes a 5 day filter, the newer one does not) + if (url.pathname === "/nightly-package") { + const packageData = { + name: "nightly-package", + "dist-tags": { + latest: "1.0.0-nightly.20240102", + }, + versions: { + "1.0.0-nightly.20240101": { + name: "nightly-package", + version: "1.0.0-nightly.20240101", + dist: { + tarball: `${mockRegistryUrl}/nightly-package/-/nightly-package-1.0.0-nightly.20240101.tgz`, + integrity: "sha512-nightly1==", + }, + }, + "1.0.0-nightly.20240102": { + name: "nightly-package", + version: "1.0.0-nightly.20240102", + dist: { + tarball: `${mockRegistryUrl}/nightly-package/-/nightly-package-1.0.0-nightly.20240102.tgz`, + integrity: "sha512-nightly2==", + }, + }, + }, + time: { + "1.0.0-nightly.20240101": daysAgo(30), + "1.0.0-nightly.20240102": daysAgo(1), + }, + }; + + return Response.json(packageData); + } + // TEST PACKAGE: many-versions-package (large version count, time entries // in reverse order relative to versions). Exercises the publish-time // index built during manifest parse. @@ -1762,6 +1834,123 @@ describe("minimum-release-age", () => { }); }); + // Semver strings longer than 8 bytes are not stored inline: they are offsets into + // the string buffer they were parsed from, and the lockfile and each package + // manifest have their own buffer. The prerelease tags of snapshot-package and + // nightly-package are long enough to live in those buffers, so these tests + // notice when a version is printed through the other side's buffer. + describe("prerelease tags longer than an inline semver string", () => { + const minimumReleaseAge = `${5 * SECONDS_PER_DAY}`; + + async function run(cmd: string[], cwd: string, env: NodeJS.Dict = bunEnv) { + await using proc = Bun.spawn({ cmd, cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("bun outdated sizes the Update and Latest columns by the current version when nothing passes the filter", async () => { + using dir = tempDir("outdated-long-prerelease", { + "package.json": JSON.stringify({ + dependencies: { + // Already on their latest versions, so they stay out of the table. They + // sort before snapshot-package in bun.lock, so their names and tarball + // URLs fill the lockfile string buffer first and snapshot-package's + // prerelease tag ends up at an offset past the end of its (much smaller) + // manifest buffer. Closer to the start of the buffer, a read through the + // wrong buffer returns wrong bytes of the right length and the table + // lines up by accident. + "bugfix-package": "1.0.3", + "exact-threshold-package": "2.0.0", + "regular-package": "3.0.0", + "snapshot-package": "snapshot", + }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + // Installed without the filter: the snapshot is only two days old. + const install = await run([bunExe(), "install", "--no-verify"], String(dir)); + expect(install).toMatchObject({ exitCode: 0 }); + + // With the filter, neither the `snapshot` tag nor `latest` has a version old + // enough, so both columns fall back to showing the current version. + const { stdout, exitCode } = await run( + [bunExe(), "outdated", "--minimum-release-age", minimumReleaseAge], + String(dir), + ); + const table = stdout.slice(stdout.indexOf("\n") + 1); + expect(table).toMatchInlineSnapshot(` + "|----------------------------------------------------------------------------------------------------| + | Package | Current | Update | Latest | + |------------------|-------------------------|---------------------------|---------------------------| + | snapshot-package | 1.0.0-snapshot.20240101 | 1.0.0-snapshot.20240101 * | 1.0.0-snapshot.20240101 * | + |----------------------------------------------------------------------------------------------------| + Note: The * indicates that version isn't true latest due to minimum release age + " + `); + const widths = table + .split("\n") + .filter(line => line.startsWith("|")) + .map(line => line.length); + expect(new Set(widths).size).toBe(1); + expect(exitCode).toBe(0); + }); + + test("--verbose prints the dependency's range from the lockfile", async () => { + using dir = tempDir("verbose-long-prerelease", { + "package.json": JSON.stringify({ + dependencies: { "nightly-package": "^1.0.0-nightly.20240101" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + const { stderr, exitCode } = await run( + [bunExe(), "install", "--minimum-release-age", minimumReleaseAge, "--no-verify", "--verbose"], + String(dir), + ); + expect(stderr.split("\n").filter(line => line.includes("[minimum-release-age]"))).toEqual([ + "[minimum-release-age] nightly-package@>=1.0.0-nightly.20240101 <2.0.0 selected 1.0.0-nightly.20240101 instead of 1.0.0-nightly.20240102 due to 432000-second filter", + ]); + expect(exitCode).toBe(0); + }); + + test("an exact pin rejected from a cached manifest is reported with the manifest's version", async () => { + using dir = tempDir("cached-manifest-long-prerelease", { + "package.json": JSON.stringify({ + dependencies: { "nightly-package": "^1.0.0-nightly.20240101" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + // Puts nightly-package's manifest, including publish times, in the cache. + const first = await run( + [bunExe(), "install", "--minimum-release-age", minimumReleaseAge, "--no-verify"], + String(dir), + ); + expect(first).toMatchObject({ exitCode: 0 }); + + await Bun.write( + `${dir}/package.json`, + JSON.stringify({ dependencies: { "nightly-package": "1.0.0-nightly.20240102" } }), + ); + // BUN_MANIFEST_CACHE=1 treats every cached manifest as stale, which is how a + // manifest cached more than a few minutes ago looks. Exact pins are then + // checked against the cached manifest instead of a fresh download, and that + // check is what reports the rejected version. + const second = await run( + [bunExe(), "install", "--minimum-release-age", minimumReleaseAge, "--no-verify"], + String(dir), + { ...bunEnv, BUN_MANIFEST_CACHE: "1" }, + ); + expect(second.stderr).toMatchInlineSnapshot(` + "error: Version "nightly-package@1.0.0-nightly.20240102" was published within minimum release age of 432000 seconds + error: nightly-package@1.0.0-nightly.20240102 failed to resolve + " + `); + expect(second.exitCode).toBe(1); + }); + }); + describe("transitive dependencies", () => { test("transitive dependencies are not filtered by minimum-release-age", async () => { // Only direct dependencies should be filtered, not transitive ones From 0da2a66d6c936e90d26a0643ac9b2257f6cc8412 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:17 +0000 Subject: [PATCH 052/258] audit: list --production, --omit, --dry-run and --cwd in bun audit --help (#38836) --- completions/bun-cli.json | 32 ++++++++++++++ .../PackageManager/CommandLineArguments.rs | 44 ++++++++++++++----- test/cli/install/bun-audit.test.ts | 32 ++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 2a14b1a1ab85..1c608da3651e 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -1530,6 +1530,38 @@ "valueType": "val", "required": false, "multiple": false + }, + { + "name": "production", + "shortName": "p", + "description": "Skip packages that are only needed by devDependencies (alias: --prod)", + "hasValue": false, + "required": false, + "multiple": false + }, + { + "name": "omit", + "description": "Skip packages that are only needed by the given dependency types: dev, optional, or peer (repeatable)", + "hasValue": true, + "valueType": "val", + "required": false, + "multiple": false + }, + { + "name": "cwd", + "description": "Set a specific cwd", + "hasValue": true, + "valueType": "val", + "required": false, + "multiple": false + }, + { + "name": "help", + "shortName": "h", + "description": "Print this help menu", + "hasValue": false, + "required": false, + "multiple": false } ], "positionalArgs": [ diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index dfd92eb3c21a..d2a7b0c47cd5 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -324,24 +324,49 @@ static OUTDATED_PARAMS: &[ParamType] = concat_params![ ] ]; -const AUDIT_PARAMS: &[ParamType] = &[ +static AUDIT_PARAMS: &[ParamType] = concat_params![ + SHARED_PARAMS, + &[ + clap::param!( + " ... Check installed packages for vulnerabilities" + ), + clap::param!("--json Output in JSON format"), + clap::param!( + "--audit-level Only print advisories with severity greater than or equal to \\ (low, moderate, high, critical)" + ), + clap::param!( + "--ignore ... Ignore advisories by GHSA or numeric advisory ID (repeatable)" + ), + clap::param!( + "-L, --latest Also apply fixes your declared ranges exclude, rewriting package.json" + ), + ] +]; + +const AUDIT_HELP_PARAMS: &[ParamType] = &[ clap::param!( - " ... Check installed packages for vulnerabilities" + "--audit-level Only print advisories with severity greater than or equal to \\ (low, moderate, high, critical)" ), - clap::param!("--json Output in JSON format"), clap::param!( - "--audit-level Only print advisories with severity greater than or equal to \\ (low, moderate, high, critical)" + "-p, --production Skip packages that are only needed by devDependencies (alias: --prod)" + ), + clap::param!( + "--omit ... Skip packages that are only needed by the given dependency types: dev, optional, or peer (repeatable)" ), clap::param!( "--ignore ... Ignore advisories by GHSA or numeric advisory ID (repeatable)" ), + clap::param!("--json Output in JSON format"), + clap::param!( + "--dry-run Show what bun audit fix would change without changing anything" + ), clap::param!( "-L, --latest Also apply fixes your declared ranges exclude, rewriting package.json" ), + clap::param!("--cwd Set a specific cwd"), + clap::param!("-h, --help Print this help menu"), ]; -static AUDIT_PARAMS_FULL: &[ParamType] = concat_params![SHARED_PARAMS, AUDIT_PARAMS]; - static INFO_PARAMS: &[ParamType] = concat_params![ SHARED_PARAMS, &[ @@ -1059,7 +1084,7 @@ Full documentation is available at https://bun.com/docs/install/audithttps://bun.com/docs/pm/cli/prune Subcommand::Why => WHY_PARAMS, Subcommand::Dedupe => DEDUPE_PARAMS, Subcommand::Prune => PRUNE_PARAMS, - - // TODO: we will probably want to do this for other *_params. this way extra params - // are not included in the help text - Subcommand::Audit => AUDIT_PARAMS_FULL, + Subcommand::Audit => AUDIT_PARAMS, Subcommand::Info => INFO_PARAMS, }; diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index b0197c013fb3..431037cc5bf0 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -1040,6 +1040,38 @@ describe("`bun audit --omit`", () => { }); }); +describe("`bun audit --help`", () => { + test.concurrent("lists the flags bun audit and bun audit fix act on", async () => { + using dir = tempDir("audit-help-", {}); + + const { stdout, stderr, exitCode } = await audit(dir, "--help"); + expect(stdout).toContain("Usage: bun audit [flags]"); + const out = normalizeBunSnapshot(stdout).split("\n"); + const flagsStart = out.indexOf("Flags:") + 1; + expect(flagsStart).toBeGreaterThan(0); + const flagLines = out.slice(flagsStart, out.indexOf("", flagsStart)); + expect(flagLines.map(line => line.match(/--[\w-]+/)![0])).toStrictEqual([ + "--audit-level", + "--production", + "--omit", + "--ignore", + "--json", + "--dry-run", + "--latest", + "--cwd", + "--help", + ]); + expect(flagLines.find(line => line.includes("--production"))).toStartWith(" -p, --production"); + expect(flagLines.find(line => line.includes("--production"))).toContain("(alias: --prod)"); + expect(flagLines.find(line => line.includes("--omit"))).toContain("dev, optional, or peer"); + expect(flagLines.find(line => line.includes("--dry-run"))).toContain("bun audit fix"); + expect(flagLines.find(line => line.includes("--latest"))).toStartWith(" -L, --latest"); + expect(flagLines.find(line => line.includes("--help"))).toStartWith(" -h, --help"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); +}); + describe("`bun audit` report", () => { test.concurrent("an unknown severity is counted and filtered as moderate", async () => { await using server = startRegistry({ "a-dep": [{ ...adv("<1.0.4"), severity: "info" }] }); From 943d87aff2b24d9cede49918d1cc92bf456a28de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:21 +0000 Subject: [PATCH 053/258] install: say --ignore-scripts also skips trusted dependencies' scripts in --help (#38893) --- completions/bun-cli.json | 22 ++++++------- completions/bun.zsh | 14 ++++---- .../PackageManager/CommandLineArguments.rs | 2 +- .../bun-install-lifecycle-scripts.test.ts | 33 +++++++++++++++++++ 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index 1c608da3651e..a708a90db102 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -373,7 +373,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -694,7 +694,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -1025,7 +1025,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -1296,7 +1296,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -1746,7 +1746,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -2127,7 +2127,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -2410,7 +2410,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -2669,7 +2669,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -2921,7 +2921,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -3226,7 +3226,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false @@ -3655,7 +3655,7 @@ }, { "name": "ignore-scripts", - "description": "Skip lifecycle scripts in the project's package.json (dependency scripts are never run)", + "description": "Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies", "hasValue": false, "required": false, "multiple": false diff --git a/completions/bun.zsh b/completions/bun.zsh index 3a02d58b200c..9ef9930f7a85 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -23,7 +23,7 @@ _bun_add_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--global[Add a package globally]' \ '-g[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -77,7 +77,7 @@ _bun_unlink_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--global[Add a package globally]' \ '-g[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -121,7 +121,7 @@ _bun_link_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--global[Add a package globally]' \ '-g[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -387,7 +387,7 @@ _bun_install_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--global[Add a package globally]' \ '-g[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -437,7 +437,7 @@ _bun_remove_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--global[Add a package globally]' \ '-g[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -656,7 +656,7 @@ _bun_update_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '-g[Add a package globally]' \ '--global[Add a package globally]' \ '--cwd[Set a specific cwd]:cwd' \ @@ -713,7 +713,7 @@ _bun_dedupe_completion() { '--no-progress[Disable the progress bar]' \ '--no-summary[Don'"'"'t print a summary]' \ '--no-verify[Skip verifying integrity of newly downloaded packages]' \ - '--ignore-scripts[Skip lifecycle scripts in the package.json (dependency scripts are never run)]' \ + '--ignore-scripts[Skip lifecycle scripts for all packages, including the project'"'"'s package.json and trusted dependencies]' \ '--cwd[Set a specific cwd]:cwd' \ '--backend[Platform-specific optimizations for installing dependencies]:backend:("copyfile" "hardlink" "symlink")' \ '--linker[Linker strategy]:linker:(isolated hoisted)' \ diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index d2a7b0c47cd5..999ce9016546 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -98,7 +98,7 @@ const SHARED_TAIL_PARAMS: &[ParamType] = &[ "--no-verify Skip verifying integrity of newly downloaded packages" ), clap::param!( - "--ignore-scripts Skip lifecycle scripts in the project's package.json (dependency scripts are never run)" + "--ignore-scripts Skip lifecycle scripts for all packages, including the project's package.json and trusted dependencies" ), clap::param!( "--trust Add to trustedDependencies in the project's package.json and install the package(s)" diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index d641b05600a8..636331993ff3 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -1104,6 +1104,39 @@ test.concurrent( }, ); +// Every subcommand below prints the flag table shared by the install family. The behavior the +// entry describes is covered by "ignore-scripts is read from npmrc" (above) and "--ignore-scripts +// should skip lifecycle scripts" (below): both install a trustedDependencies package. +test.concurrent.each([ + "install", + "add", + "update", + "remove", + "link", + "unlink", + "patch", + "patch-commit", + "outdated", + "publish", + "info", +])("bun %s --help says --ignore-scripts also skips the scripts of trusted dependencies", async subcommand => { + await using proc = spawn({ + cmd: [bunExe(), subcommand, "--help"], + env: baseEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const entry = stdout.split(/\r?\n/).find(line => line.includes("--ignore-scripts")); + expect(entry).toBeDefined(); + expect(entry).toContain("trusted dependencies"); + // The wording from before trustedDependencies existed. + expect(entry).not.toContain("dependency scripts are never run"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + // waiter thread is only a thing on Linux. for (const forceWaiterThread of isLinux ? [false, true] : [false]) { describe.concurrent("lifecycle scripts" + (forceWaiterThread ? " (waiter thread)" : ""), async () => { From 6e9b4b6414ad51c4f1e361bb1ecd7f76d875602d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:26 +0000 Subject: [PATCH 054/258] pm: polish dedupe, prune, pm ls and pm licenses output (#38952) --- .../from-npm-install-to-bun-install.mdx | 2 +- docs/pm/cli/dedupe.mdx | 4 +- docs/pm/cli/pm.mdx | 10 +- docs/pm/cli/prune.mdx | 4 +- src/install/dedupe.rs | 60 +++-- src/install/prune.rs | 89 ++++++-- src/runtime/cli/package_manager_command.rs | 2 +- src/runtime/cli/pm_licenses_command.rs | 56 ++++- test/cli/install/bun-dedupe.test.ts | 34 +-- .../bun-install-lifecycle-scripts.test.ts | 4 +- test/cli/install/bun-pm-licenses.test.ts | 210 +++++++++++++----- test/cli/install/bun-pm.test.ts | 16 +- test/cli/install/bun-prune.test.ts | 198 +++++++++-------- test/cli/install/isolated-relink.test.ts | 2 +- 14 files changed, 469 insertions(+), 222 deletions(-) diff --git a/docs/guides/install/from-npm-install-to-bun-install.mdx b/docs/guides/install/from-npm-install-to-bun-install.mdx index f97fae445048..1fcf44967f03 100644 --- a/docs/guides/install/from-npm-install-to-bun-install.mdx +++ b/docs/guides/install/from-npm-install-to-bun-install.mdx @@ -155,7 +155,7 @@ bun pm ls ``` ```txt -my-pkg node_modules (781) +my-pkg node_modules (781 installed) ├── @types/node@20.16.5 ├── @types/react@18.3.8 ├── @types/react-dom@18.3.0 diff --git a/docs/pm/cli/dedupe.mdx b/docs/pm/cli/dedupe.mdx index 67c09efadb3a..47d947a6c137 100644 --- a/docs/pm/cli/dedupe.mdx +++ b/docs/pm/cli/dedupe.mdx @@ -15,7 +15,7 @@ bun dedupe v1.4.0 (abc12345) ↳ esbuild 0.15.10 → 0.15.11 ↳ react 18.2.0 → 18.3.1 -2 duplicate versions removed, 3 packages installed (checked 5 packages) [12.00ms] +2 duplicate versions removed, 3 packages installed (checked 5 packages in bun.lock) [12.00ms] ``` Each row is a version Bun removed and the version its dependents now use. @@ -36,7 +36,7 @@ bun dedupe v1.4.0 (abc12345) ↳ esbuild 0.15.10 → 0.15.11 ↳ react 18.2.0 → 18.3.1 -2 duplicate versions can be removed (checked 5 packages) [9.00ms] +2 duplicate versions can be removed (checked 5 packages in bun.lock) [9.00ms] bun dedupe ``` diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index 5b6ba556d06a..ee0255597ae2 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -118,7 +118,7 @@ bun list ``` ```txt -/path/to/project node_modules (135) +/path/to/project node_modules (135 installed) ├── eslint@8.38.0 ├── react@18.2.0 ├── react-dom@18.2.0 @@ -135,7 +135,7 @@ bun list --all ``` ```txt -/path/to/project node_modules (135) +/path/to/project node_modules (135 installed) ├── @eslint-community/eslint-utils@4.4.0 ├── @eslint-community/regexpp@4.5.0 ├── @eslint/eslintrc@2.0.2 @@ -159,7 +159,7 @@ bun list --trusted ``` ```txt -/path/to/project node_modules (135) +/path/to/project node_modules (135 installed) └── esbuild@0.21.5 ``` @@ -174,6 +174,8 @@ bun pm licenses ls ``` ```txt +bun pm licenses v1.3.0 (a4b2f86f) + MIT (2) ├── path-parse@1.0.6 └── resolve@1.9.0 @@ -183,6 +185,8 @@ Unknown (4) ├── no-deps@1.0.0 ├── no-deps@1.0.1 └── one-dep@1.0.0 + +6 packages across 2 licenses (checked 6 packages in bun.lock) [4.00ms] ``` | Flag | Description | diff --git a/docs/pm/cli/prune.mdx b/docs/pm/cli/prune.mdx index 3988e1a0b28a..97ca936e013f 100644 --- a/docs/pm/cli/prune.mdx +++ b/docs/pm/cli/prune.mdx @@ -16,7 +16,7 @@ bun prune v1.4.0 (abc12345) - @types/node@20.11.5 - left-pad@1.3.0 -2 packages removed (checked 948) [22.00ms] +2 packages removed (checked 948 installed packages) [22.00ms] ``` Packages removed from a workspace or nested `node_modules` folder show the folder in parentheses, e.g. `- typescript@5.4.0 (packages/app/node_modules)`. @@ -47,7 +47,7 @@ bun prune --production --dry-run bun prune v1.4.0 (abc12345) - typescript@5.4.0 -1 package can be removed (checked 948) [9.00ms] +1 package can be removed (checked 948 installed packages) [9.00ms] bun prune --production ``` diff --git a/src/install/dedupe.rs b/src/install/dedupe.rs index e524e2b6e54a..bf50240ada80 100644 --- a/src/install/dedupe.rs +++ b/src/install/dedupe.rs @@ -325,8 +325,15 @@ fn sort_by_name_then_version(lockfile: &Lockfile, ids: &mut [PackageID]) { index_sort::sort_indices(ids, &mut |a, b| order_by_name_then_version(lockfile, a, b)); } -// (name, removed version, surviving version(s) its dependents now resolve to) -type Row = (Box<[u8]>, Box<[u8]>, Box<[u8]>); +struct Row { + name: Box<[u8]>, + /// The removed version. + from: Box<[u8]>, + /// Surviving version(s); empty when `from` is dropped outright. + to: Box<[u8]>, + /// Every survivor is a lower major than `from`. + downgrade: bool, +} #[derive(Default)] pub(crate) struct Report { @@ -540,8 +547,9 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report { .iter() .zip(&targets) .map(|(&id, moved_to)| { + let from_version = pkg_res[id as usize].npm().version; let mut from: Vec = Vec::new(); - let _ = write!(from, "{}", pkg_res[id as usize].npm().version.fmt(buf)); + let _ = write!(from, "{}", from_version.fmt(buf)); let mut survivors: Vec = moved_to.clone(); index_sort::sort_vec_by(&mut survivors, |&a, &b| { pkg_res[a as usize] @@ -556,11 +564,15 @@ fn dedupe_lockfile(lockfile: &mut Lockfile) -> Report { } let _ = write!(to, "{}", pkg_res[c as usize].npm().version.fmt(buf)); } - ( - Box::from(names[id as usize].slice(buf)), - from.into_boxed_slice(), - to.into_boxed_slice(), - ) + let downgrade = survivors + .last() + .is_some_and(|&c| pkg_res[c as usize].npm().version.major < from_version.major); + Row { + name: Box::from(names[id as usize].slice(buf)), + from: from.into_boxed_slice(), + to: to.into_boxed_slice(), + downgrade, + } }) .collect(); @@ -730,7 +742,7 @@ fn report_already_deduplicated(manager: &PackageManager, report: &Report) -> ! { bun_core::pretty!("\n"); } bun_core::pretty!( - "🎉 No duplicates — checked {} package{}, every one already resolves to a single version ", + "🎉 No duplicates — checked {} package{} in bun.lock, every one already resolves to a single version ", report.checked, plural(report.checked) ); @@ -758,7 +770,7 @@ fn print_would_remove(manager: &PackageManager, report: &Report) { } let n = report.rows.len(); bun_core::pretty!( - "\n{} duplicate version{} can be removed (checked {} package{}) ", + "\n{} duplicate version{} can be removed (checked {} package{} in bun.lock) ", n, plural(n), report.checked, @@ -774,22 +786,32 @@ fn print_rows(report: &Report) { } else { ("~", "->") }; - for (name, from, to) in &report.rows { - if to.is_empty() { + for row in &report.rows { + if row.to.is_empty() { + bun_core::prettyln!( + "{} {} {} {} (removed)", + glyph, + BStr::new(&row.name), + BStr::new(&row.from), + arrow + ); + } else if row.downgrade { bun_core::prettyln!( - "{} {} {}", + "{} {} {} {} {} (downgrade)", glyph, - BStr::new(name), - BStr::new(from) + BStr::new(&row.name), + BStr::new(&row.from), + arrow, + BStr::new(&row.to) ); } else { bun_core::prettyln!( "{} {} {} {} {}", glyph, - BStr::new(name), - BStr::new(from), + BStr::new(&row.name), + BStr::new(&row.from), arrow, - BStr::new(to) + BStr::new(&row.to) ); } } @@ -818,7 +840,7 @@ pub(crate) fn print_dedupe_summary(manager: &PackageManager, installed: u32, sta ); } bun_core::pretty!( - " (checked {} package{}) ", + " (checked {} package{} in bun.lock) ", report.checked, plural(report.checked) ); diff --git a/src/install/prune.rs b/src/install/prune.rs index 85376162d1e5..e4d382b4009e 100644 --- a/src/install/prune.rs +++ b/src/install/prune.rs @@ -14,7 +14,7 @@ use crate::isolated_install::store::{EntryColumns as _, NodeColumns as _, entry use crate::isolated_install::{Store, Timings, build_store}; use crate::lockfile::package::PackageColumns as _; use crate::lockfile::tree::is_filtered_dependency_or_workspace; -use crate::lockfile::{LoadResult, Lockfile, reachable, tree}; +use crate::lockfile::{LoadResult, Lockfile, PackageIndexEntry, reachable, tree}; use crate::lockfile_real::package::{Diff, DiffSummary, Package}; use crate::package_manager::Options::{Enable, LogLevel}; use crate::package_manager::ROOT_PACKAGE_JSON_PATH; @@ -311,7 +311,7 @@ pub fn prune(manager: &mut PackageManager, original_cwd: &[u8]) -> crate::Result .filter(|f| matches!(f.kind, FolderKind::NodeModules | FolderKind::Store)) .count(); bun_core::pretty!( - "Done! Checked {} package{} across {} folder{} (nothing to prune) ", + "Done! Checked {} installed package{} across {} folder{} (nothing to prune) ", checked, plural(checked), folders, @@ -333,10 +333,11 @@ pub fn prune(manager: &mut PackageManager, original_cwd: &[u8]) -> crate::Result plan.print_row(removal); } bun_core::pretty!( - "{} package{} can be removed (checked {}) ", + "{} package{} can be removed (checked {} installed package{}) ", n, plural(n), - checked + checked, + plural(checked) ); print_elapsed(); print_apply_hint(); @@ -354,7 +355,11 @@ pub fn prune(manager: &mut PackageManager, original_cwd: &[u8]) -> crate::Result if failed > 0 { bun_core::pretty!(", {} failed", failed); } - bun_core::pretty!(" (checked {}) ", checked); + bun_core::pretty!( + " (checked {} installed package{}) ", + checked, + plural(checked) + ); print_elapsed(); } if failed > 0 { @@ -641,10 +646,13 @@ struct HoistedTree<'a> { paths: Vec, expected: Vec<(&'a [u8], PackageID)>, quiet: bool, + /// The expected tree excludes dev/optional/peer dependencies. + filtered: bool, kept_mismatched: Cell, checked: RefCell, matched: RefCell, missing: RefCell, + other_version: RefCell, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -652,10 +660,14 @@ enum Installed { Matches, Missing, Mismatch, + /// A version the lockfile installs elsewhere; the filter just favors a + /// different copy at this position, so it is kept without warning. + /// Only produced when `filtered` is set. + OtherVersion, } impl<'a> HoistedTree<'a> { - fn init(lockfile: &'a Lockfile, quiet: bool) -> HoistedTree<'a> { + fn init(lockfile: &'a Lockfile, quiet: bool, filtered: bool) -> HoistedTree<'a> { let trees = lockfile.buffers.trees.as_slice(); let deps = lockfile.buffers.dependencies.as_slice(); let resolutions = lockfile.buffers.resolutions.as_slice(); @@ -700,6 +712,7 @@ impl<'a> HoistedTree<'a> { let checked = handle_oom(DynamicBitSet::init_empty(expected.len())); let matched = handle_oom(DynamicBitSet::init_empty(expected.len())); let missing = handle_oom(DynamicBitSet::init_empty(expected.len())); + let other_version = handle_oom(DynamicBitSet::init_empty(expected.len())); HoistedTree { lockfile, trees, @@ -707,10 +720,12 @@ impl<'a> HoistedTree<'a> { paths, expected, quiet, + filtered, kept_mismatched: Cell::new(false), checked: RefCell::new(checked), matched: RefCell::new(matched), missing: RefCell::new(missing), + other_version: RefCell::new(other_version), } } @@ -750,8 +765,10 @@ impl<'a> HoistedTree<'a> { }; let (alias, pkg_id) = self.expected[idx]; let installed = self.verified_installed(id, idx, alias, pkg_id); - if installed == Installed::Matches { - return true; + match installed { + Installed::Matches => return true, + Installed::OtherVersion => return false, + Installed::Missing | Installed::Mismatch => {} } self.kept_mismatched.set(true); if !self.quiet { @@ -789,6 +806,8 @@ impl<'a> HoistedTree<'a> { Installed::Matches } else if self.missing.borrow().is_set(idx) { Installed::Missing + } else if self.other_version.borrow().is_set(idx) { + Installed::OtherVersion } else { Installed::Mismatch }; @@ -798,6 +817,7 @@ impl<'a> HoistedTree<'a> { match installed { Installed::Matches => self.matched.borrow_mut().set(idx), Installed::Missing => self.missing.borrow_mut().set(idx), + Installed::OtherVersion => self.other_version.borrow_mut().set(idx), Installed::Mismatch => {} } installed @@ -837,12 +857,20 @@ impl<'a> HoistedTree<'a> { let expected_name = self.lockfile.packages.items_name()[pkg_id as usize].slice(buf); let matches = match res.tag { ResolutionTag::Npm => { - installed_package_json(&package).is_some_and(|(name, version)| { - let expected = res.npm().version.fmt(buf).to_string(); - version.is_some_and(|version| { - without_build(&version) == without_build(expected.as_bytes()) - }) && name == expected_name - }) + let Some((name, Some(version))) = installed_package_json(&package) else { + return Installed::Mismatch; + }; + if name != expected_name { + return Installed::Mismatch; + } + let expected = res.npm().version.fmt(buf).to_string(); + if without_build(&version) == without_build(expected.as_bytes()) { + return Installed::Matches; + } + if self.filtered && self.version_in_lockfile(pkg_id, &version) { + return Installed::OtherVersion; + } + return Installed::Mismatch; } ResolutionTag::Git | ResolutionTag::Github => { sys::File::read_from(package.fd(), b".bun-tag") @@ -859,6 +887,29 @@ impl<'a> HoistedTree<'a> { Installed::Mismatch } } + + /// Whether some npm package with the same name in the lockfile resolves + /// to `version`. + fn version_in_lockfile(&self, pkg_id: PackageID, version: &[u8]) -> bool { + let lockfile = self.lockfile; + let buf = lockfile.buffers.string_bytes.as_slice(); + let name_hash = lockfile.packages.items_name_hash()[pkg_id as usize]; + let Some(entry) = lockfile.package_index.get(&name_hash) else { + return false; + }; + let ids: &[PackageID] = match entry { + PackageIndexEntry::Id(id) => core::slice::from_ref(id), + PackageIndexEntry::Ids(ids) => ids, + }; + let pkg_res = lockfile.packages.items_resolution(); + ids.iter().any(|&id| { + pkg_res.get(id as usize).is_some_and(|res| { + res.tag == ResolutionTag::Npm + && without_build(version) + == without_build(res.npm().version.fmt(buf).to_string().as_bytes()) + }) + }) + } } fn installed_package_json(package: &Dir) -> Option<(Vec, Option>)> { @@ -904,8 +955,12 @@ fn plan_hoisted( hoist_filtered(manager); let quiet = manager.options.log_level == LogLevel::Silent; + let features = manager.options.local_package_features; + let filtered = !features.dev_dependencies + || !features.optional_dependencies + || !features.peer_dependencies; let lockfile: &Lockfile = &manager.lockfile; - let hoisted = HoistedTree::init(lockfile, quiet); + let hoisted = HoistedTree::init(lockfile, quiet, filtered); let buf = lockfile.buffers.string_bytes.as_slice(); let deps = lockfile.buffers.dependencies.as_slice(); let trees = lockfile.buffers.trees.as_slice(); @@ -1089,8 +1144,8 @@ pub(crate) fn remove_collapsed_copies(manager: &PackageManager, before: &Lockfil return; } let quiet = manager.options.log_level == LogLevel::Silent; - let old = HoistedTree::init(before, true); - let new = HoistedTree::init(after, quiet); + let old = HoistedTree::init(before, true, false); + let new = HoistedTree::init(after, quiet, false); let targets = manager.filtered_link_targets.as_ref(); let selected: Option> = targets.map(|targets| targets.package_ids(before)); let importers = selected.as_ref().map(|_| tree_importers(before)); diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 05cfe0a302ca..3cd4c0af057e 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -619,7 +619,7 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let root_deps = slice.items_dependencies()[0]; Output::println(format_args!( - "{} node_modules ({})", + "{} node_modules ({} installed)", bstr::BStr::new(path), lockfile.buffers.hoisted_dependencies.len(), )); diff --git a/src/runtime/cli/pm_licenses_command.rs b/src/runtime/cli/pm_licenses_command.rs index 0079a85383ee..ad4da0f6fd8e 100644 --- a/src/runtime/cli/pm_licenses_command.rs +++ b/src/runtime/cli/pm_licenses_command.rs @@ -154,6 +154,15 @@ impl PmLicensesCommand { (selection.ids, false) }; + // After the exits above so error output keeps a clean stdout. + if pm.options.should_print_command_name() && !json_output { + bun_core::pretty!( + "bun pm licenses v{}\n\n", + Global::package_json_version_with_sha + ); + Output::flush(); + } + let options = reachable::Options { root: 0, dev: features.dev_dependencies, @@ -300,7 +309,12 @@ impl PmLicensesCommand { if json_output { print_json(&entries); } else { - print_text(&entries, flags.long, checked); + print_text( + &entries, + flags.long, + checked, + pm.options.should_print_command_name(), + ); } Output::flush(); @@ -681,18 +695,29 @@ impl DiskIndex { } } -fn print_text(entries: &[Entry], long: bool, checked: usize) { +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +fn print_text(entries: &[Entry], long: bool, checked: usize, summary: bool) { if entries.is_empty() { - bun_core::pretty!( - "No packages to list (checked {} package{} in bun.lock) ", - checked, - if checked == 1 { "" } else { "s" } - ); - Output::print_start_end_stdout(bun_core::start_time(), bun_core::time::nano_timestamp()); + bun_core::pretty!("No packages to list"); + if summary { + bun_core::pretty!( + " (checked {} package{} in bun.lock) ", + checked, + plural(checked) + ); + Output::print_start_end_stdout( + bun_core::start_time(), + bun_core::time::nano_timestamp(), + ); + } bun_core::pretty!("\n"); return; } + let mut licenses = 0; let mut start = 0; while start < entries.len() { let license = &entries[start].license; @@ -704,6 +729,7 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) { if start > 0 { Output::print(format_args!("\n")); } + licenses += 1; bun_core::prettyln!( "{} ({})", BStr::new(&printable(license)), @@ -737,6 +763,20 @@ fn print_text(entries: &[Entry], long: bool, checked: usize) { start = end; } + + if summary { + bun_core::pretty!( + "\n{} package{} across {} license{} (checked {} package{} in bun.lock) ", + entries.len(), + plural(entries.len()), + licenses, + plural(licenses), + checked, + plural(checked) + ); + Output::print_start_end_stdout(bun_core::start_time(), bun_core::time::nano_timestamp()); + bun_core::pretty!("\n"); + } } fn json_string(out: &mut Vec, s: &[u8]) { diff --git a/test/cli/install/bun-dedupe.test.ts b/test/cli/install/bun-dedupe.test.ts index 0bae63226a61..72873a224653 100644 --- a/test/cli/install/bun-dedupe.test.ts +++ b/test/cli/install/bun-dedupe.test.ts @@ -55,9 +55,9 @@ const packagesWord = (n: number) => `${n} package${n === 1 ? "" : "s"}`; const versionsWord = (n: number) => `${n} duplicate version${n === 1 ? "" : "s"}`; const noDuplicates = (checked: number) => - `🎉 No duplicates — checked ${packagesWord(checked)}, every one already resolves to a single version`; + `🎉 No duplicates — checked ${packagesWord(checked)} in bun.lock, every one already resolves to a single version`; const wouldRemove = (removed: number, checked: number) => - `${versionsWord(removed)} can be removed (checked ${packagesWord(checked)})`; + `${versionsWord(removed)} can be removed (checked ${packagesWord(checked)} in bun.lock)`; const HINT = " bun dedupe"; const row = (label: string) => `~ ${label}`; @@ -77,7 +77,7 @@ function removedSummary(removed: number, checked?: number) { return new RegExp( `^${versionsWord(removed)} removed(, [1-9]\\d* packages? installed)? \\(checked ${ checked === undefined ? "[1-9]\\d* packages?" : packagesWord(checked) - }\\)$`, + } in bun\\.lock\\)$`, ); } @@ -125,7 +125,7 @@ function expectNoDuplicates({ stdout, stderr, exitCode }: Result, checked?: numb ...kept, ...(kept.length ? [""] : []), checked === undefined - ? expect.stringMatching(/^🎉 No duplicates — checked [1-9]\d* packages?, /) + ? expect.stringMatching(/^🎉 No duplicates — checked [1-9]\d* packages? in bun\.lock, /) : noDuplicates(checked), ]); expectTimed(stdout, NO_DUPLICATES); @@ -215,7 +215,7 @@ test.concurrent("collapses a range onto the exact version that satisfies every e HEADER, "~ no-deps 1.1.0 -> 1.0.0", "", - "1 duplicate version removed (checked 4 packages)", + "1 duplicate version removed (checked 4 packages in bun.lock)", ]); expectRemoved(stdout, "no-deps 1.1.0 -> 1.0.0", { checked: 4 }); expect(lines(stderr)).toStrictEqual(["Saved lockfile"]); @@ -279,7 +279,7 @@ test.concurrent("--check reports and exits 1 without writing", async () => { "bun dedupe ()", "~ no-deps 1.1.0 -> 1.0.0", "", - "1 duplicate version can be removed (checked 4 packages)", + "1 duplicate version can be removed (checked 4 packages in bun.lock)", " bun dedupe", ] `); @@ -347,7 +347,7 @@ test.concurrent("already deduplicated", async () => { expect(lines(second.stdout)).toMatchInlineSnapshot(` [ "bun dedupe ()", - "🎉 No duplicates — checked 3 packages, every one already resolves to a single version", + "🎉 No duplicates — checked 3 packages in bun.lock, every one already resolves to a single version", ] `); expect(second.stderr).toBe(""); @@ -672,7 +672,7 @@ test.concurrent("cascading removal lists every unreachable duplicate in name ord expect(lockfile).toContain(label); } - const rows = ["no-deps 2.0.0", "one-fixed-dep 2.0.0 -> 1.0.0"]; + const rows = ["no-deps 2.0.0 -> (removed)", "one-fixed-dep 2.0.0 -> 1.0.0 (downgrade)"]; const checked = lockPackageCount(lockfile); expect(checked).toBe(5); const check = await dedupe(packageDir, "--check"); @@ -706,7 +706,7 @@ test.concurrent("multiple names removed in one run are sorted, scoped names firs expect(lockfile).toContain(label); } - const rows = ["@types/is-number 2.0.0 -> 1.0.0", "no-deps 1.1.0 -> 1.0.0"]; + const rows = ["@types/is-number 2.0.0 -> 1.0.0 (downgrade)", "no-deps 1.1.0 -> 1.0.0"]; const checked = lockPackageCount(lockfile); expect(checked).toBe(6); const check = await dedupe(packageDir, "--check"); @@ -850,7 +850,7 @@ test.concurrent("the report is printed as one block after root lifecycle script expect(out.slice(firstRow)).toStrictEqual([ "~ no-deps 1.1.0 -> 1.0.0", "", - "1 duplicate version removed (checked 4 packages)", + "1 duplicate version removed (checked 4 packages in bun.lock)", ]); expectRemoved(stdout, "no-deps 1.1.0 -> 1.0.0", { checked: 4 }); expect(stderr).not.toContain("error:"); @@ -945,7 +945,7 @@ test.concurrent("--lockfile-only rewrites bun.lock without installing", async () HEADER, "~ no-deps 1.1.0 -> 1.0.0", "", - "1 duplicate version removed (checked 4 packages)", + "1 duplicate version removed (checked 4 packages in bun.lock)", ]); expectRemoved(stdout, "no-deps 1.1.0 -> 1.0.0", { checked: 4 }); expect(lines(stderr)).toStrictEqual(["Saved lockfile"]); @@ -1554,7 +1554,7 @@ test.concurrent("a version that is the only way to reach a patched package is ke "~ dep-with-tags 3.0.1 -> 3.0.0", " kept one-fixed-dep@1.0.0 (needed to reach patched no-deps@1.0.0)", "", - "1 duplicate version can be removed (checked 7 packages)", + "1 duplicate version can be removed (checked 7 packages in bun.lock)", " bun dedupe", ] `); @@ -1588,7 +1588,7 @@ test.concurrent("a version that is the only way to reach a patched package is ke "bun dedupe ()", " kept one-fixed-dep@1.0.0 (needed to reach patched no-deps@1.0.0)", "", - "🎉 No duplicates — checked 6 packages, every one already resolves to a single version", + "🎉 No duplicates — checked 6 packages in bun.lock, every one already resolves to a single version", ] `); expectNoDuplicates(recheck, 6, kept); @@ -1631,7 +1631,7 @@ test.concurrent("a version removed by the run does not vote for its own dependen } expect(await nodeModulesVersion(packageDir, "no-deps")).toBe("1.1.0"); - const rows = ["no-deps 1.0.0", "one-fixed-dep 1.0.0 -> 2.0.0"]; + const rows = ["no-deps 1.0.0 -> (removed)", "one-fixed-dep 1.0.0 -> 2.0.0"]; const checked = lockPackageCount(lockfile); expect(checked).toBe(7); const check = await dedupe(packageDir, "--check"); @@ -1837,7 +1837,7 @@ test.concurrent("a lockfile whose root has no dependencies is reported as dedupl expect(lines(check.stdout)).toMatchInlineSnapshot(` [ "bun dedupe ()", - "🎉 No duplicates — checked 1 package, every one already resolves to a single version", + "🎉 No duplicates — checked 1 package in bun.lock, every one already resolves to a single version", ] `); expectNoDuplicates(check, 1); @@ -1905,7 +1905,9 @@ test.concurrent("migrates package-lock.json when there is no bun.lock", async () // Nothing was installed before this run, so the summary also carries the install count. const { stdout, stderr, exitCode } = await dedupe(packageDir); expectRemoved(stdout, "no-deps 1.1.0 -> 1.0.0", { checked: 4 }); - expect(lines(stdout).at(-1)).toBe("1 duplicate version removed, 2 packages installed (checked 4 packages)"); + expect(lines(stdout).at(-1)).toBe( + "1 duplicate version removed, 2 packages installed (checked 4 packages in bun.lock)", + ); expect(stderr).toContain("migrated lockfile from package-lock.json"); expect(stderr).toContain("Saved lockfile"); expect(stderr).not.toContain("error:"); diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index 636331993ff3..45ceb6e999b4 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -2874,7 +2874,7 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { }); const out = await stdout.text(); expect(await stderr.text()).toBe(""); - expect(out).toBe(`${packageDir} node_modules (2) + expect(out).toBe(`${packageDir} node_modules (2 installed) └── electron@1.0.0 `); expect(await exited).toBe(0); @@ -2919,7 +2919,7 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { }); const out = await stdout.text(); expect(await stderr.text()).toBe(""); - expect(out).toBe(`${packageDir} node_modules (2) + expect(out).toBe(`${packageDir} node_modules (2 installed) └── no-deps@1.0.0 `); expect(await exited).toBe(0); diff --git a/test/cli/install/bun-pm-licenses.test.ts b/test/cli/install/bun-pm-licenses.test.ts index 791f5260f09b..ec349296495a 100644 --- a/test/cli/install/bun-pm-licenses.test.ts +++ b/test/cli/install/bun-pm-licenses.test.ts @@ -60,11 +60,13 @@ async function gitRepo(manifest: Record) { return repoDir; } +const HEADER = "bun pm licenses ()"; + const emptyText = (checked: number) => `No packages to list (checked ${checked} packages in bun.lock)`; function expectEmptyText(stdout: string, checked: number) { - expect(normalizeBunSnapshot(stdout)).toBe(emptyText(checked)); - expect(stdout).toMatch(/^No packages to list \(checked \d+ packages in bun\.lock\) \[\d+\.\d+m?s\]\n$/); + expect(normalizeBunSnapshot(stdout)).toBe(`${HEADER}\n\n${emptyText(checked)}`); + expect(stdout).toMatch(/\nNo packages to list \(checked \d+ packages in bun\.lock\) \[\d+\.\d+m?s\]\n$/); } const MISSING_NOTE = "note: run 'bun install' first"; @@ -269,7 +271,9 @@ describe("bun pm licenses", () => { test.concurrent("text output groups packages by license, Unknown last, dev-only packages marked", async () => { const [stdout, stderr, exitCode] = await licenses(hoistedDir); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 └── resolve@1.9.0 @@ -277,7 +281,9 @@ describe("bun pm licenses", () => { ├── a-dep@1.0.1 (dev) ├── no-deps@1.0.0 ├── no-deps@1.0.1 - └── one-dep@1.0.0" + └── one-dep@1.0.0 + + 6 packages across 2 licenses (checked 6 packages in bun.lock)" `); expect(stdout.split("\n").filter(line => line.endsWith(" (dev)"))).toStrictEqual(["├── a-dep@1.0.1 (dev)"]); expect(stdout).not.toContain(hoistedDir); @@ -355,7 +361,9 @@ describe("bun pm licenses", () => { const [stdout, stderr, exitCode] = await licenses(dir); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "BSD-3-Clause (1) + "bun pm licenses () + + BSD-3-Clause (1) └── no-deps@1.0.1 ISC (1) @@ -377,7 +385,9 @@ describe("bun pm licenses", () => { └── a-dep@1.0.9 Unknown (1) - └── uses-a-dep-9@1.0.0" + └── uses-a-dep-9@1.0.0 + + 8 packages across 8 licenses (checked 8 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -491,11 +501,15 @@ describe("bun pm licenses", () => { const [stdout, stderr, exitCode] = await licenses(dir); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "Unknown (4) + "bun pm licenses () + + Unknown (4) ├── a-dep@1.0.9 ├── a-dep@1.0.10 ├── uses-a-dep-10@1.0.0 - └── uses-a-dep-9@1.0.0" + └── uses-a-dep-9@1.0.0 + + 4 packages across 1 license (checked 4 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -511,9 +525,13 @@ describe("bun pm licenses", () => { const [stdout, stderr, exitCode] = await licenses(dir); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "Unknown (2) + "bun pm licenses () + + Unknown (2) ├── a-dep@1.0.9 - └── uses-a-dep-9@1.0.0 (dev)" + └── uses-a-dep-9@1.0.0 (dev) + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -531,9 +549,13 @@ describe("bun pm licenses", () => { Unknown: [u("a-dep", "1.0.9"), u("uses-a-dep-9", "1.0.0")], }); expect(normalizeBunSnapshot(await licensesText(dir, "--dev"))).toMatchInlineSnapshot(` - "Unknown (2) + "bun pm licenses () + + Unknown (2) ├── a-dep@1.0.9 - └── uses-a-dep-9@1.0.0 (dev)" + └── uses-a-dep-9@1.0.0 (dev) + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); }); @@ -547,21 +569,29 @@ describe("bun pm licenses", () => { }); expect(normalizeBunSnapshot(await licensesText(dir))).toMatchInlineSnapshot(` - "Unknown (6) + "bun pm licenses () + + Unknown (6) ├── a-dep@1.0.9 (dev) ├── a-dep@1.0.10 ├── no-deps@1.0.1 ├── one-dep@1.0.0 (dev) ├── uses-a-dep-10@1.0.0 - └── uses-a-dep-9@1.0.0 (dev)" + └── uses-a-dep-9@1.0.0 (dev) + + 6 packages across 1 license (checked 6 packages in bun.lock)" `); expect(normalizeBunSnapshot(await licensesText(dir, "--dev"))).toMatchInlineSnapshot(` - "Unknown (4) + "bun pm licenses () + + Unknown (4) ├── a-dep@1.0.9 (dev) ├── no-deps@1.0.1 ├── one-dep@1.0.0 (dev) - └── uses-a-dep-9@1.0.0 (dev)" + └── uses-a-dep-9@1.0.0 (dev) + + 4 packages across 1 license (checked 4 packages in bun.lock)" `); expect(await licensesJson(dir, "--dev")).toStrictEqual({ Unknown: [u("a-dep", "1.0.9"), u("no-deps", "1.0.1"), u("one-dep", "1.0.0"), u("uses-a-dep-9", "1.0.0")], @@ -577,14 +607,22 @@ describe("bun pm licenses", () => { expect(await licensesJson(dir, "--dev")).toStrictEqual(expected); expect(await licensesJson(dir, "-D")).toStrictEqual(expected); expect(normalizeBunSnapshot(await licensesText(dir, "--dev"))).toMatchInlineSnapshot(` - "Unknown (2) + "bun pm licenses () + + Unknown (2) ├── no-deps@1.0.1 (dev) - └── one-dep@1.0.0 (dev)" + └── one-dep@1.0.0 (dev) + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); expect(normalizeBunSnapshot(await licensesText(hoistedDir, "--dev"))).toMatchInlineSnapshot(` - "Unknown (1) - └── a-dep@1.0.1 (dev)" + "bun pm licenses () + + Unknown (1) + └── a-dep@1.0.1 (dev) + + 1 package across 1 license (checked 1 package in bun.lock)" `); expect(await licensesJson(hoistedDir, "--dev")).toStrictEqual({ Unknown: [u("a-dep", "1.0.1")] }); }); @@ -620,14 +658,18 @@ describe("bun pm licenses", () => { test.concurrent("--prod omits devDependencies (text)", async () => { const [stdout, stderr, exitCode] = await licenses(hoistedDir, "--prod"); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 └── resolve@1.9.0 Unknown (3) ├── no-deps@1.0.0 ├── no-deps@1.0.1 - └── one-dep@1.0.0" + └── one-dep@1.0.0 + + 5 packages across 2 licenses (checked 5 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -669,7 +711,9 @@ describe("bun pm licenses", () => { test.concurrent("--long prints author, description and homepage under each entry", async () => { const [stdout, stderr, exitCode] = await licenses(hoistedDir, "--long"); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 │ Javier Blanco │ Node.js path.parse() ponyfill @@ -683,12 +727,14 @@ describe("bun pm licenses", () => { ├── a-dep@1.0.1 (dev) ├── no-deps@1.0.0 ├── no-deps@1.0.1 - └── one-dep@1.0.0" + └── one-dep@1.0.0 + + 6 packages across 2 licenses (checked 6 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); - expect(await licensesText(hoistedDir, "ls", "--long")).toBe(stdout); + expect(normalizeBunSnapshot(await licensesText(hoistedDir, "ls", "--long"))).toBe(normalizeBunSnapshot(stdout)); expect(stdout).not.toContain(nm(hoistedDir)); const [plainJson, longJson] = await Promise.all([ @@ -767,7 +813,9 @@ describe("bun pm licenses", () => { ], }); expect(normalizeBunSnapshot(await licensesText(dir, "--long"))).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 │ Javier Blanco │ Node.js path.parse() ponyfill @@ -783,7 +831,9 @@ describe("bun pm licenses", () => { │ https://no-deps.example ├── no-deps@1.0.1 └── one-dep@1.0.0 - git+ssh://git@github.com/example/one-dep.git" + git+ssh://git@github.com/example/one-dep.git + + 6 packages across 2 licenses (checked 6 packages in bun.lock)" `); }); @@ -850,9 +900,11 @@ describe("bun pm licenses", () => { test.concurrent("isolated linker matches hoisted: marker, --dev and --long", async () => { const dir = await setup("isolated"); const [[expected], [stdout, stderr, exitCode]] = await Promise.all([licenses(hoistedDir), licenses(dir)]); - expect(stdout).toBe(expected); + expect(normalizeBunSnapshot(stdout)).toBe(normalizeBunSnapshot(expected)); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 └── resolve@1.9.0 @@ -860,7 +912,9 @@ describe("bun pm licenses", () => { ├── a-dep@1.0.1 (dev) ├── no-deps@1.0.0 ├── no-deps@1.0.1 - └── one-dep@1.0.0" + └── one-dep@1.0.0 + + 6 packages across 2 licenses (checked 6 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -876,8 +930,8 @@ describe("bun pm licenses", () => { licensesJson(dir, "--long"), licensesJson(hoistedDir, "--long"), ]); - expect(isoLong).toBe(hoistedLong); - expect(isoDev).toBe(hoistedDev); + expect(normalizeBunSnapshot(isoLong)).toBe(normalizeBunSnapshot(hoistedLong)); + expect(normalizeBunSnapshot(isoDev)).toBe(normalizeBunSnapshot(hoistedDev)); expect(isoDevJson).toStrictEqual(hoistedDevJson); expect(isoLongJson).toStrictEqual(hoistedLongJson); expect(isoLong).toContain("│ Javier Blanco \n"); @@ -1044,13 +1098,17 @@ describe("bun pm licenses", () => { }); expect(normalizeBunSnapshot(await licensesText(dir))).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 └── resolve@1.9.0 Unknown (2) ├── a-dep@1.0.1 (dev) - └── no-deps@1.0.0" + └── no-deps@1.0.0 + + 4 packages across 2 licenses (checked 4 packages in bun.lock)" `); expect(await licensesText(join(dir, "packages", "foo"))).toContain("├── a-dep@1.0.1 (dev)\n└── no-deps@1.0.0\n"); }); @@ -1097,9 +1155,13 @@ describe("bun pm licenses", () => { expect(packagesGlob).toStrictEqual(monoJson); expectEmptyText(rootOnly, 0); expect(normalizeBunSnapshot(fooText)).toMatchInlineSnapshot(` - "Unknown (2) + "bun pm licenses () + + Unknown (2) ├── a-dep@1.0.1 (dev) - └── no-deps@1.0.0" + └── no-deps@1.0.0 + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); expect(await licensesJson(monoDir, "--filter", "foo", "--prod")).toStrictEqual({ Unknown: [u("no-deps", "1.0.0")], @@ -1158,9 +1220,13 @@ describe("bun pm licenses", () => { `"warn: No workspace packages matched the filters "nomatch", "alsonone""`, ); expect(normalizeBunSnapshot(text)).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 - └── resolve@1.9.0" + └── resolve@1.9.0 + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); expect(textExit).toBe(0); }); @@ -1209,6 +1275,30 @@ describe("bun pm licenses", () => { expect(jsonExit).toBe(0); }); + test.concurrent("--no-summary prints the bare listing without banner or summary", async () => { + const [stdout, stderr, exitCode] = await licenses(hoistedDir, "--no-summary"); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "MIT (2) + ├── path-parse@1.0.6 + └── resolve@1.9.0 + + Unknown (4) + ├── a-dep@1.0.1 (dev) + ├── no-deps@1.0.0 + ├── no-deps@1.0.1 + └── one-dep@1.0.0" + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + // The empty listing keeps its message but drops the checked-count and timing. + const dir = await setup("hoisted", { "package.json": pkg({ devDependencies: { "no-deps": "1.0.0" } }) }); + const [empty, emptyStderr, emptyExit] = await licenses(dir, "--prod", "--no-summary"); + expect(empty).toBe("No packages to list\n"); + expect(emptyStderr).toBe(""); + expect(emptyExit).toBe(0); + }); + test.concurrent("--silent still prints the listing but no diagnostics", async () => { const dir = await setup(); rmSync(nm(dir, "path-parse"), { recursive: true }); @@ -1220,7 +1310,9 @@ describe("bun pm licenses", () => { `warn: 1 package in bun.lock is not installed and was skipped\n${MISSING_NOTE}`, ); expect(quietStderr).toBe(""); - expect(quiet).toBe(loud); + // The loud run wraps the same listing in the banner and summary. + expect(loud).toContain(quiet); + expect(normalizeBunSnapshot(loud)).toStartWith(HEADER); expect(normalizeBunSnapshot(quiet)).toMatchInlineSnapshot(` "MIT (1) └── resolve@1.9.0 @@ -1279,8 +1371,8 @@ describe("bun pm licenses", () => { licenses(hoistedDir, "ls"), ]); expect(plain).toContain("MIT (2)"); - expect(list).toBe(plain); - expect(ls).toBe(plain); + expect(normalizeBunSnapshot(list)).toBe(normalizeBunSnapshot(plain)); + expect(normalizeBunSnapshot(ls)).toBe(normalizeBunSnapshot(plain)); expect([plainExit, listExit, lsExit]).toStrictEqual([0, 0, 0]); const [stdout, stderr, exitCode] = await licenses(hoistedDir, "bogus"); @@ -1433,9 +1525,13 @@ describe("bun pm licenses", () => { const [stdout, stderr, exitCode] = await licenses(dir); expect(normalizeBunSnapshot(stdout).replace(/git\+file:\/\/\S+/, "git+file://")).toMatchInlineSnapshot(` - "Unknown (2) + "bun pm licenses () + + Unknown (2) ├── no-deps@1.0.0 - └── no-deps@git+file://" + └── no-deps@git+file:// + + 2 packages across 1 license (checked 2 packages in bun.lock)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -1479,7 +1575,9 @@ describe("bun pm licenses", () => { test.concurrent("--omit=dev and bunfig install.production behave like --prod", async () => { expect(await licensesJson(hoistedDir, "--omit=dev")).toStrictEqual(prodJson); expect(await licensesText(hoistedDir, "--omit=dev")).not.toContain("(dev)"); - expect(await licensesText(hoistedDir, "--omit", "dev")).toBe(await licensesText(hoistedDir, "--prod")); + expect(normalizeBunSnapshot(await licensesText(hoistedDir, "--omit", "dev"))).toBe( + normalizeBunSnapshot(await licensesText(hoistedDir, "--prod")), + ); const dir = await setup(); const bunfig = readFileSync(join(dir, "bunfig.toml"), "utf8"); @@ -1499,8 +1597,12 @@ describe("bun pm licenses", () => { expect(await licensesJson(dir)).toStrictEqual({ Unknown: [u("no-deps", "1.0.0", "1.0.1"), u("one-dep", "1.0.0")] }); expect(await licensesJson(dir, "--omit=optional")).toStrictEqual({ Unknown: [u("no-deps", "1.0.0")] }); expect(normalizeBunSnapshot(await licensesText(dir, "--omit=optional"))).toMatchInlineSnapshot(` - "Unknown (1) - └── no-deps@1.0.0" + "bun pm licenses () + + Unknown (1) + └── no-deps@1.0.0 + + 1 package across 1 license (checked 1 package in bun.lock)" `); }); @@ -1510,8 +1612,12 @@ describe("bun pm licenses", () => { expect(await licensesJson(dir)).toStrictEqual({ Unknown: [u("no-deps", "1.0.0")] }); expect(normalizeBunSnapshot(await licensesText(dir))).toMatchInlineSnapshot(` - "Unknown (1) - └── no-deps@1.0.0" + "bun pm licenses () + + Unknown (1) + └── no-deps@1.0.0 + + 1 package across 1 license (checked 1 package in bun.lock)" `); expectEmptyText(await licensesText(dir, "--omit=peer"), 0); expect(await licensesJson(dir, "--omit=peer")).toStrictEqual({}); @@ -1634,7 +1740,9 @@ describe("bun pm licenses", () => { ], }); expect(normalizeBunSnapshot(await licensesText(dir, "--long"))).toMatchInlineSnapshot(` - "MIT (2) + "bun pm licenses () + + MIT (2) ├── path-parse@1.0.6 │ Javier Blanco │ Node.js path.parse() ponyfill @@ -1650,7 +1758,9 @@ describe("bun pm licenses", () => { ├── no-deps@1.0.1 │ (https://nd.example) └── one-dep@1.0.0 - " + + + 6 packages across 2 licenses (checked 6 packages in bun.lock)" `); }); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index ebf54aad16ea..9f5fb0152216 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -73,7 +73,7 @@ it("should list top-level dependency", async () => { env, }); expect(await stderr.text()).toBe(""); - expect(await stdout.text()).toBe(`${package_dir} node_modules (2) + expect(await stdout.text()).toBe(`${package_dir} node_modules (2 installed) └── moo@moo `); expect(await exited).toBe(0); @@ -244,7 +244,7 @@ it("should list top-level aliased dependency", async () => { env, }); expect(await stderr.text()).toBe(""); - expect(await stdout.text()).toBe(`${package_dir} node_modules (2) + expect(await stdout.text()).toBe(`${package_dir} node_modules (2 installed) └── moo-1@moo `); expect(await exited).toBe(0); @@ -370,7 +370,7 @@ it("should list only trusted dependencies with --trusted", async () => { env, }); expect(await stderr.text()).toBe(""); - expect(await stdout.text()).toBe(`${package_dir} node_modules (2) + expect(await stdout.text()).toBe(`${package_dir} node_modules (2 installed) └── bar@0.0.2 `); expect(await exited).toBe(0); @@ -387,7 +387,7 @@ it("should list only trusted dependencies with --trusted", async () => { env, }); expect(await stderr.text()).toBe(""); - expect(await stdout.text()).toBe(`${package_dir} node_modules (2) + expect(await stdout.text()).toBe(`${package_dir} node_modules (2 installed) ├── bar@0.0.2 └── moo@moo `); @@ -579,7 +579,7 @@ it("should list nothing with --trusted when no dependencies are trusted", async env, }); expect(await stderr.text()).toBe(""); - expect(await stdout.text()).toBe(`${package_dir} node_modules (1) + expect(await stdout.text()).toBe(`${package_dir} node_modules (1 installed) `); expect(await exited).toBe(0); }); @@ -778,7 +778,7 @@ test.each([ cmd: ["list"], packageName: "test-list", dependencies: { bar: "latest" }, - expectedOutput: (dir: string) => `${dir} node_modules (1)\n└── bar@0.0.2\n`, + expectedOutput: (dir: string) => `${dir} node_modules (1 installed)\n└── bar@0.0.2\n`, checkReservationMessage: true, }, { @@ -786,7 +786,7 @@ test.each([ cmd: ["pm", "list"], packageName: "test-pm-list", dependencies: { bar: "latest" }, - expectedOutput: (dir: string) => `${dir} node_modules (1)\n└── bar@0.0.2\n`, + expectedOutput: (dir: string) => `${dir} node_modules (1 installed)\n└── bar@0.0.2\n`, checkReservationMessage: false, }, { @@ -794,7 +794,7 @@ test.each([ cmd: ["pm", "ls"], packageName: "test-pm-ls", dependencies: { bar: "latest" }, - expectedOutput: (dir: string) => `${dir} node_modules (1)\n└── bar@0.0.2\n`, + expectedOutput: (dir: string) => `${dir} node_modules (1 installed)\n└── bar@0.0.2\n`, checkReservationMessage: false, }, ])("$name", async ({ cmd, packageName, dependencies, expectedOutput, checkReservationMessage }) => { diff --git a/test/cli/install/bun-prune.test.ts b/test/cli/install/bun-prune.test.ts index 47483f97d9f9..b59827f13b97 100644 --- a/test/cli/install/bun-prune.test.ts +++ b/test/cli/install/bun-prune.test.ts @@ -42,9 +42,11 @@ const PRUNED_NOTE = 'note: skipped 1 workspace listed in bun.lock but not on dis const BANNER = "bun prune ()"; const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`; const NOTHING = (packages: number, folders: number) => - `Done! Checked ${plural(packages, "package")} across ${plural(folders, "folder")} (nothing to prune)`; -const REMOVED = (n: number, checked: number) => `${plural(n, "package")} removed (checked ${checked})`; -const CAN_BE_REMOVED = (n: number, checked: number) => `${plural(n, "package")} can be removed (checked ${checked})`; + `Done! Checked ${plural(packages, "installed package")} across ${plural(folders, "folder")} (nothing to prune)`; +const REMOVED = (n: number, checked: number) => + `${plural(n, "package")} removed (checked ${plural(checked, "installed package")})`; +const CAN_BE_REMOVED = (n: number, checked: number) => + `${plural(n, "package")} can be removed (checked ${plural(checked, "installed package")})`; // The copy-pasteable line `--dry-run` prints last: the invocation with `--dry-run` taken out. const APPLY_HINT = (...flags: string[]) => [" bun prune", ...flags].join(" "); const DURATION = /\) \[\d+(\.\d+)?m?s\]$/m; @@ -283,9 +285,9 @@ test.concurrent("removes extraneous packages, keeps everything the lockfile inst - @other/thing - @scoped/junk - junk - 3 packages removed (checked 5)" + 3 packages removed (checked 5 installed packages)" `); - expect(first.stdout).toMatch(/\(checked 5\) \[\d+(\.\d+)?m?s\]\n?$/); + expect(first.stdout).toMatch(/\(checked 5 installed packages\) \[\d+(\.\d+)?m?s\]\n?$/); expect(first.exitCode).toBe(0); for (const path of planted) { @@ -316,7 +318,7 @@ test.concurrent("prunes nested node_modules folders the tree installs into", asy "bun prune () - junk (node_modules/one-dep/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -344,7 +346,7 @@ test.concurrent.each([["--production"], ["--prod"], ["--omit=dev"]])( - no-deps-bins@1.0.0 - one-fixed-dep-bins@1.0.0 - what-bin@1.0.0 - 3 packages removed (checked 5)" + 3 packages removed (checked 5 installed packages)" `); expect(exitCode).toBe(0); @@ -375,7 +377,7 @@ test.concurrent("--production keeps a package that prod and dev both need", asyn "bun prune () - one-fixed-dep@1.0.0 - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(production.exitCode).toBe(0); expect(existsSync(join(dir, "node_modules", "no-deps"))).toBeTrue(); @@ -385,7 +387,7 @@ test.concurrent("--production keeps a package that prod and dev both need", asyn expect(out(plain.stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 2 packages across 1 folder (nothing to prune)" + Done! Checked 2 installed packages across 1 folder (nothing to prune)" `); expect(plain.exitCode).toBe(0); expect(existsSync(join(plainDir, "node_modules", "one-fixed-dep"))).toBeTrue(); @@ -400,7 +402,7 @@ test.concurrent("--dry-run prints without deleting; --silent deletes without pri "bun prune () - junk - 1 package can be removed (checked 2) + 1 package can be removed (checked 2 installed packages) bun prune" `); expect(dryRun.stdout).toMatch(DURATION); @@ -458,7 +460,7 @@ test.concurrent("nothing to prune when node_modules is missing or clean", async expect(out(clean.stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 1 package across 1 folder (nothing to prune)" + Done! Checked 1 installed package across 1 folder (nothing to prune)" `); expect(clean.exitCode).toBe(0); expect(existsSync(join(cleanDir, "node_modules", "no-deps"))).toBeTrue(); @@ -479,7 +481,7 @@ test.concurrent("never follows symlinks out of node_modules", async () => { "bun prune () - linked-junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(link)).toThrow(); @@ -563,7 +565,7 @@ test.concurrent("workspaces: prunes workspace folders, keeps workspace links, ru - a-dep@1.0.1 - junk (node_modules/a/node_modules) - 2 packages removed (checked 5)" + 2 packages removed (checked 5 installed packages)" `); expect(exitCode).toBe(0); @@ -582,7 +584,7 @@ test.concurrent("keeps dependencies bundled inside a package", async () => { expect(out(stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 3 packages across 1 folder (nothing to prune)" + Done! Checked 3 installed packages across 1 folder (nothing to prune)" `); expect(exitCode).toBe(0); expect(existsSync(bundled)).toBeTrue(); @@ -621,7 +623,7 @@ test.concurrent("isolated linker: removes unused store entries and their links", - no-deps@1.0.1 - one-dep@1.0.0 - zzz@1.0.0 - 6 packages removed (checked 9)" + 6 packages removed (checked 9 installed packages)" `); expect(exitCode).toBe(0); @@ -691,7 +693,7 @@ test.concurrent("isolated linker: prune removes the peer-hash variants a peer bu "", "- no-deps@1.0.0", `- ${before}`, - "2 packages removed (checked 6)", + "2 packages removed (checked 6 installed packages)", ]); expect(exitCode).toBe(0); expect(peerEntries()).toStrictEqual([after]); @@ -715,7 +717,7 @@ test.concurrent("isolated linker + global store: unlinks the store link, never d const { stdout, exitCode } = await prune(dir, "--production"); expect(out(stdout)).toContain("- one-dep@1.0.0"); - expect(out(stdout)).toEndWith("2 packages removed (checked 3)"); + expect(out(stdout)).toEndWith("2 packages removed (checked 3 installed packages)"); expect(exitCode).toBe(0); expect(() => lstatSync(storeEntry)).toThrow(); @@ -734,7 +736,7 @@ test.concurrent("isolated linker: bins of removed packages are removed, live one expectBinInstalled(nm, "has-bin-entry"); const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toEndWith("- what-bin@1.0.0\n1 package removed (checked 4)"); + expect(out(stdout)).toEndWith("- what-bin@1.0.0\n1 package removed (checked 4 installed packages)"); expect(exitCode).toBe(0); expectBinRemoved(nm, "what-bin"); @@ -773,7 +775,7 @@ test.concurrent("hoisted: dot entries and files are never touched even when the "bun prune () - junk - 1 package removed (checked 1)" + 1 package removed (checked 1 installed package)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -833,7 +835,7 @@ test.concurrent( expect(out(stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 2 packages across 1 folder (nothing to prune)" + Done! Checked 2 installed packages across 1 folder (nothing to prune)" `); expect(exitCode).toBe(0); expect(existsSync(keepMe)).toBeTrue(); @@ -854,7 +856,7 @@ test.concurrent("hoisted: a symlinked scope dir is unlinked, not followed", asyn - @fake - @real/junk - 2 packages removed (checked 3)" + 2 packages removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(join(nm, "@fake"))).toThrow(); @@ -888,7 +890,7 @@ test.concurrent( "bun prune () - junk (node_modules/a/node_modules) - 1 package can be removed (checked 4) + 1 package can be removed (checked 4 installed packages) bun prune --linker hoisted" `); expect(dryRun.exitCode).toBe(0); @@ -899,7 +901,7 @@ test.concurrent( "bun prune () - junk (node_modules/a/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -924,7 +926,7 @@ test.concurrent.skipIf(isWindows)("a symlinked .bin directory is never cleaned t "bun prune () - junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -944,7 +946,7 @@ test.concurrent("isolated: extraneous symlinks are removed even when the store i - @ext/thing - ext - 2 packages removed (checked 4)" + 2 packages removed (checked 4 installed packages)" `); expect(first.exitCode).toBe(0); expect(() => lstatSync(join(nm, "ext"))).toThrow(); @@ -978,7 +980,7 @@ test.concurrent( - @scoped/has-bin-entry@1.0.0 - one-dep@1.0.0 - 2 packages removed (checked 3)" + 2 packages removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(join(nm, "one-dep"))).toBeFalse(); @@ -1004,7 +1006,7 @@ test.concurrent("hoisted: removing only a scoped package also removes its bin li "bun prune () - @scoped/has-bin-entry@1.0.0 - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(join(nm, "@scoped"))).toBeFalse(); @@ -1032,7 +1034,7 @@ test.concurrent( - @scoped/has-bin-entry@1.0.0 - no-deps@1.0.1 - one-dep@1.0.0 - 3 packages removed (checked 7)" + 3 packages removed (checked 7 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); @@ -1065,7 +1067,7 @@ test.concurrent("hoisted: --production empties a workspace folder that only held "bun prune () - no-deps@1.0.0 (packages/a/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -1105,7 +1107,7 @@ test.concurrent( - a-dep@1.0.1 - tool@1.0.0 (packages/app/node_modules) - 2 packages can be removed (checked 6) + 2 packages can be removed (checked 6 installed packages) bun prune --production --linker isolated" `); expect(dryRun.exitCode).toBe(0); @@ -1117,7 +1119,7 @@ test.concurrent( - a-dep@1.0.1 - tool@1.0.0 (packages/app/node_modules) - 2 packages removed (checked 6)" + 2 packages removed (checked 6 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(join(appNm, "a-dep"))).toThrow(); @@ -1145,7 +1147,7 @@ test.concurrent("isolated: a real directory named like a workspace is never dele "bun prune () - a-dep@1.0.1 - 1 package removed (checked 6)" + 1 package removed (checked 6 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(join(planted, "package.json"))).toBeTrue(); @@ -1182,7 +1184,7 @@ test.concurrent( "bun prune () - @scope/tool@1.0.0 (packages/app/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(mixed.exitCode).toBe(0); expect(() => lstatSync(join(mixedScope, "tool"))).toThrow(); @@ -1195,7 +1197,7 @@ test.concurrent( "bun prune () - @scope/tool@1.0.0 (packages/app/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(devOnly.exitCode).toBe(0); expect(existsSync(devOnlyScope)).toBeFalse(); @@ -1225,7 +1227,7 @@ test.concurrent( "bun prune () - tool@1.0.0 (packages/app/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(appTool)).toThrow(); @@ -1372,7 +1374,7 @@ test.concurrent.each(linkers)("%s: a workspace lifecycle script is not out of sy "bun prune () - junk - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -1393,7 +1395,7 @@ test.concurrent("trustedDependencies stripped from bun.lock is not out of sync", "bun prune () - junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -1449,7 +1451,7 @@ test.concurrent("isolated: a workspace missing from disk no longer keeps its sto - junk - left-pad@1.0.0 - 2 packages removed (checked 4)" + 2 packages removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -1478,7 +1480,7 @@ test.concurrent("hoisted: --filter on a pruned checkout does not protect the mis - left-pad@1.0.0 - other - 2 packages removed (checked 4)" + 2 packages removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(join(nm, "left-pad"))).toBeFalse(); @@ -1520,7 +1522,7 @@ test.concurrent( "bun prune () - shared-alias@1.0.0 - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(isSymlink(join(nm, "app"))).toBeTrue(); @@ -1560,7 +1562,7 @@ test.concurrent( - a-dep@1.0.1 - junk (node_modules/a/node_modules) - 2 packages removed (checked 8)" + 2 packages removed (checked 8 installed packages)" `); expect(onlyA.exitCode).toBe(0); expect(existsSync(aJunk)).toBeFalse(); @@ -1575,7 +1577,7 @@ test.concurrent( "bun prune () - left-pad@1.0.0 - 1 package removed (checked 5)" + 1 package removed (checked 5 installed packages)" `); expect(onlyRoot.exitCode).toBe(0); expect(existsSync(bJunk)).toBeTrue(); @@ -1587,7 +1589,7 @@ test.concurrent( - junk (node_modules/b/node_modules) - one-fixed-dep@1.0.0 - 2 packages removed (checked 7)" + 2 packages removed (checked 7 installed packages)" `); expect(everything.exitCode).toBe(0); expect(existsSync(bJunk)).toBeFalse(); @@ -1615,7 +1617,7 @@ test.concurrent( - a-dep@1.0.1 (packages/a/node_modules) - one-fixed-dep@1.0.0 - 2 packages removed (checked 7)" + 2 packages removed (checked 7 installed packages)" `); expect(onlyA.exitCode).toBe(0); expect(existsSync(join(store, "a-dep@1.0.1"))).toBeTrue(); @@ -1633,7 +1635,7 @@ test.concurrent( - a-dep@1.0.1 - left-pad@1.0.0 - 2 packages removed (checked 6)" + 2 packages removed (checked 6 installed packages)" `); expect(everything.exitCode).toBe(0); expect(() => lstatSync(join(bNm, "a-dep"))).toThrow(); @@ -1657,7 +1659,7 @@ test.concurrent( "bun prune () - a-dep@1.0.1 (packages/selected/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(first.exitCode).toBe(0); expect(() => lstatSync(selectedADep)).toThrow(); @@ -1669,7 +1671,7 @@ test.concurrent( "bun prune () - a-dep@1.0.1 (packages/unselected/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(second.exitCode).toBe(0); expect(() => lstatSync(unselectedADep)).toThrow(); @@ -1680,7 +1682,7 @@ test.concurrent( "bun prune () - a-dep@1.0.1 - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(everything.exitCode).toBe(0); expect(existsSync(storeEntry)).toBeFalse(); @@ -1824,7 +1826,7 @@ test.concurrent("hoisted: --filter with no match is an error; path filters resol "bun prune () - junk (node_modules/a/node_modules) - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -1905,7 +1907,7 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( "bun prune () - test-postinstall-skip-native@1.0.0 - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(other.exitCode).toBe(0); expect(existsSync(native)).toBeFalse(); @@ -1929,9 +1931,7 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( const junk = plant(dir, "node_modules/junk"); const { stdout, exitCode } = await prune(dir, "--linker", linker); - expect(out(stdout)).toBe( - `bun prune ()\n\n- junk\n1 package removed (checked ${linker === "hoisted" ? 4 : 6})`, - ); + expect(out(stdout)).toBe(`bun prune ()\n\n- junk\n${REMOVED(1, linker === "hoisted" ? 4 : 6)}`); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); expect(await file(join(nm, "my-alias", "package.json")).json()).toMatchObject({ @@ -1966,7 +1966,7 @@ test.concurrent("isolated + publicHoistPattern: hoisted links follow their store - no-deps@1.0.1 - one-dep@1.0.0 - 2 packages removed (checked 5)" + 2 packages removed (checked 5 installed packages)" `); expect(production.exitCode).toBe(0); expect(() => lstatSync(join(store, "node_modules", "one-dep"))).toThrow(); @@ -1992,7 +1992,7 @@ test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( "", "- junk-a", expect.stringMatching(failure), - "1 package removed, 1 failed (checked 3)", + "1 package removed, 1 failed (checked 3 installed packages)", ]); expect(exitCode).toBe(1); expect(existsSync(junkA)).toBeFalse(); @@ -2013,7 +2013,7 @@ test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( "bun prune () - junk-b - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junkB)).toBeFalse(); @@ -2042,7 +2042,7 @@ test.concurrent("never runs the project's lifecycle scripts", async () => { "bun prune () - junk - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(plain.exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2053,7 +2053,7 @@ test.concurrent("never runs the project's lifecycle scripts", async () => { "bun prune () - a-dep@1.0.1 - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(production.exitCode).toBe(0); expect(existsSync(ran)).toBeFalse(); @@ -2112,7 +2112,7 @@ test.concurrent.each(["peer-deps-fixed", "optional-peer-deps"])( "bun prune ()", "", "- no-deps@1.0.0", - "1 package removed (checked 4)", + "1 package removed (checked 4 installed packages)", ]); expect(exitCode).toBe(0); expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0", entry]); @@ -2179,7 +2179,7 @@ test.concurrent( "", "- a-dep@1.0.1", "- no-deps@2.0.0", - "2 packages removed (checked 10)", + "2 packages removed (checked 10 installed packages)", ]); expect(exitCode).toBe(0); expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", "one-dep@1.0.0", productionEntry, fullEntry].toSorted()); @@ -2216,7 +2216,7 @@ test.concurrent("isolated: --production removes the stale peer-hash variant and "- a-dep@1.0.1", "- no-deps@1.0.0", `- ${before}`, - "3 packages removed (checked 8)", + "3 packages removed (checked 8 installed packages)", ]); expect(exitCode).toBe(0); expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", after]); @@ -2250,7 +2250,7 @@ test.concurrent("keeps dependencies bundled inside a file: dependency", async () "bun prune () - junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2297,7 +2297,7 @@ test.concurrent( "bun prune () - no-deps@1.0.1 (node_modules/one-dep/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(stderr).toBe(""); expect(exitCode).toBe(0); @@ -2317,18 +2317,30 @@ test.concurrent( expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" }); + // The root copy is the one a full install hoists there, not a stale tree: + // no "is not the version bun.lock expects" warning, no install hint. const { stdout, stderr, exitCode } = await prune(dir, "--production"); expect(out(stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 3 packages across 2 folders (nothing to prune)" + Done! Checked 3 installed packages across 2 folders (nothing to prune)" `); - expect(out(stderr)).toBe( - `${WARN("node_modules/no-deps", "node_modules/one-fixed-dep/node_modules/no-deps")}\n${NOTE}`, - ); + expect(stderr).toBe(""); expect(exitCode).toBe(0); expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" }); expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); + + // A root copy whose version the lockfile does not know anywhere is a + // genuinely stale tree and still warns under --production. + const rootPkg = await file(rootPkgJson).json(); + await write(rootPkgJson, JSON.stringify({ ...rootPkg, version: "9.9.9" })); + const stale = await prune(dir, "--production", "--dry-run"); + expect(out(stale.stderr)).toBe( + `${WARN("node_modules/no-deps", "node_modules/one-fixed-dep/node_modules/no-deps")}\n${NOTE}`, + ); + expect(stale.exitCode).toBe(0); + await write(rootPkgJson, JSON.stringify(rootPkg)); + await runBunInstall(installEnv(dir), dir, { production: true }); const silent = await prune(silentDir, "--production", "--silent"); @@ -2399,13 +2411,15 @@ test.concurrent( expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" }); + // The root copy is the dev version a full install hoists there; not a + // stale tree, so no warning under --production. const { stdout, stderr, exitCode } = await prune(dir, "--production", "--linker", "hoisted"); expect(out(stdout)).toMatchInlineSnapshot(` "bun prune () - Done! Checked 3 packages across 2 folders (nothing to prune)" + Done! Checked 3 installed packages across 2 folders (nothing to prune)" `); - expect(out(stderr)).toBe(`${WARN("node_modules/no-deps", "packages/a/node_modules/no-deps")}\n${NOTE}`); + expect(stderr).toBe(""); expect(exitCode).toBe(0); expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" }); expect(isSymlink(join(nm, "a"))).toBeTrue(); @@ -2433,7 +2447,7 @@ test.concurrent( "bun prune () - what-bin@1.0.0 - 1 package can be removed (checked 4) + 1 package can be removed (checked 4 installed packages) bun prune --production --linker isolated" `); expect(dryRun.exitCode).toBe(0); @@ -2445,7 +2459,7 @@ test.concurrent( "bun prune () - what-bin@1.0.0 - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(() => lstatSync(join(nm, "what-bin"))).toThrow(); @@ -2478,7 +2492,7 @@ test.concurrent("hoisted: nested node_modules of packages without a tree node ar - @other/thing (node_modules/@scoped/has-bin-entry/node_modules) - junk (node_modules/no-deps/node_modules) - 2 packages removed (checked 4)" + 2 packages removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2534,7 +2548,7 @@ test.concurrent("refuses to prune an isolated install with the hoisted linker", - no-deps@1.0.1 - one-dep@1.0.0 - 2 packages removed (checked 3)" + 2 packages removed (checked 3 installed packages)" `); expect(same.exitCode).toBe(0); expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); @@ -2653,7 +2667,7 @@ test.concurrent( - git-pkg@1.0.0 - junk@1.0.0 - local@file+elsewhere - 3 packages removed (checked 11)" + 3 packages removed (checked 11 installed packages)" `); expect(exitCode).toBe(0); expect(storeEntries(dir)).toStrictEqual(installed); @@ -2688,7 +2702,7 @@ test.concurrent( - a-dep@1.0.1 - junk@1.0.0 - 2 packages removed (checked 6)" + 2 packages removed (checked 6 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2731,7 +2745,7 @@ test.concurrent( - a-dep@1.0.1 - junk - 2 packages removed (checked 5)" + 2 packages removed (checked 5 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2763,7 +2777,7 @@ test.concurrent("without --linker, a project without workspaces is pruned with t - a-dep@1.0.1 - junk - 2 packages removed (checked 4)" + 2 packages removed (checked 4 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -2793,7 +2807,7 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( "bun prune () - test-postinstall-skip-native@1.0.0 - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(other.exitCode).toBe(0); expect(existsSync(join(store, "test-postinstall-skip-native@1.0.0"))).toBeFalse(); @@ -2849,7 +2863,7 @@ test.concurrent("hoisted: the nested tree of a package with bundled dependencies "bun prune () - junk (node_modules/one-dep/node_modules) - 1 package removed (checked 5)" + 1 package removed (checked 5 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeTrue(); @@ -2887,7 +2901,7 @@ test.concurrent( "bun prune () - git-pkg (node_modules/no-deps/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -2943,7 +2957,7 @@ test.concurrent( "bun prune () - left-pad (node_modules/no-deps/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -2975,7 +2989,7 @@ test.concurrent("hoisted: a nested copy of a link: dependency is removed when th "bun prune () - linked (node_modules/no-deps/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -3009,7 +3023,7 @@ test.concurrent("hoisted: a nested copy left behind by an override to a tarball "bun prune () - no-deps@1.0.1 (node_modules/one-dep/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -3034,7 +3048,7 @@ test.concurrent( "bun prune () - no-deps-build-metadata (node_modules/no-deps/node_modules) - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(nested)).toBeFalse(); @@ -3066,7 +3080,7 @@ test.concurrent( - no-deps@1.0.1 - one-dep@1.0.0 - 2 packages removed (checked 6)" + 2 packages removed (checked 6 installed packages)" `); expect(production.exitCode).toBe(0); expect(existsSync(join(store, "no-deps@1.0.1"))).toBeFalse(); @@ -3105,7 +3119,7 @@ test.concurrent( - no-deps@1.0.1 - one-dep@1.0.0 - one-dep@1.0.0+0123456789abcdef - 4 packages removed (checked 7)" + 4 packages removed (checked 7 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(variant)).toBeFalse(); @@ -3136,7 +3150,7 @@ test.concurrent("isolated: an emptied scope dir of dangling hidden-hoist links i - @scope/zzz@1.0.0 - no-deps@1.0.1 - one-dep@1.0.0 - 3 packages removed (checked 6)" + 3 packages removed (checked 6 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(scopeDir)).toBeFalse(); @@ -3156,7 +3170,7 @@ test.concurrent("isolated: --filter on a pruned checkout does not protect the mi "bun prune () - left-pad@1.0.0 - 1 package removed (checked 3)" + 1 package removed (checked 3 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(join(store, "left-pad@1.0.0"))).toBeFalse(); @@ -3244,7 +3258,7 @@ test.concurrent("missing package.json is an error; --cwd prunes another director "bun prune () - junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -3285,7 +3299,7 @@ test.concurrent( "bun prune () - junk - 1 package removed (checked 2)" + 1 package removed (checked 2 installed packages)" `); expect(pruned.exitCode).toBe(0); expect(existsSync(junk)).toBeFalse(); @@ -3315,7 +3329,7 @@ test.concurrent("--no-optional is not --omit=optional", async () => { "bun prune () - a-dep@1.0.1 - 1 package can be removed (checked 2) + 1 package can be removed (checked 2 installed packages) bun prune --omit=optional" `); expect(omit.exitCode).toBe(0); @@ -3387,7 +3401,7 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a "bun prune () - junk (node_modules/b/node_modules) - 1 package removed (checked 5)" + 1 package removed (checked 5 installed packages)" `); expect(exitCode).toBe(0); expect(existsSync(aJunk)).toBeTrue(); diff --git a/test/cli/install/isolated-relink.test.ts b/test/cli/install/isolated-relink.test.ts index c1e736ba0c45..a20ca431eb28 100644 --- a/test/cli/install/isolated-relink.test.ts +++ b/test/cli/install/isolated-relink.test.ts @@ -204,7 +204,7 @@ test.concurrent("the orphaned store entry survives the re-link until bun prune", "bun prune () - no-deps@1.1.0 - 1 package removed (checked 4)" + 1 package removed (checked 4 installed packages)" `); expect(err).not.toContain("error:"); expect(exitCode).toBe(0); From 5f82df2d0641bfef5cf83bd9959027b57087f5f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:31 +0000 Subject: [PATCH 055/258] prune: add --check, a CI gate that exits 1 when anything would be removed (#39216) --- completions/bun-cli.json | 9 ++++- completions/bun.bash | 2 +- completions/bun.fish | 1 + completions/bun.zsh | 1 + docs/pm/cli/prune.mdx | 8 ++++ .../PackageManager/CommandLineArguments.rs | 11 ++++- src/install/prune.rs | 4 +- test/cli/install/bun-prune.test.ts | 40 +++++++++++++++++++ 8 files changed, 71 insertions(+), 5 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index a708a90db102..365c2ac427ec 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -1917,6 +1917,13 @@ "required": false, "multiple": false }, + { + "name": "check", + "description": "Exit with code 1 if node_modules has packages that can be removed, without deleting anything", + "hasValue": false, + "required": false, + "multiple": false + }, { "name": "os", "description": "Prune for a different operating system than the current one", @@ -1982,7 +1989,7 @@ "type": "string" } ], - "examples": ["bun prune", "bun prune --production", "bun prune --dry-run"], + "examples": ["bun prune", "bun prune --production", "bun prune --dry-run", "bun prune --check"], "usage": "Usage: bun prune [flags]", "documentationUrl": "https://bun.com/docs/pm/cli/prune.", "dynamicCompletions": {} diff --git a/completions/bun.bash b/completions/bun.bash index 29304d7e69ca..92adf0b700ed 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -105,7 +105,7 @@ _bun_completions() { PACKAGE_OPTIONS[SHARED_OPTIONS_SHORT]="-c -y -p -f -g"; PACKAGE_OPTIONS[DEDUPE_OPTIONS_LONG]="--check"; - PACKAGE_OPTIONS[PRUNE_OPTIONS_LONG]="--production --prod --omit --filter --dry-run --os --cpu --linker --silent --cwd --help"; + PACKAGE_OPTIONS[PRUNE_OPTIONS_LONG]="--production --prod --omit --filter --dry-run --check --os --cpu --linker --silent --cwd --help"; PACKAGE_OPTIONS[PRUNE_OPTIONS_SHORT]="-p -P -F -h"; PACKAGE_OPTIONS[AUDIT_OPTIONS_LONG]="--json --audit-level --ignore --prod --production --omit --dry-run --latest --cwd --help"; PACKAGE_OPTIONS[AUDIT_OPTIONS_SHORT]="-L"; diff --git a/completions/bun.fish b/completions/bun.fish index 5e94166f5ecb..3332391a6aa7 100644 --- a/completions/bun.fish +++ b/completions/bun.fish @@ -220,6 +220,7 @@ complete -c bun -n "__fish_seen_subcommand_from audit" -l "ignore" -r -d "Ignore complete -c bun -n "__fish_seen_subcommand_from audit" -l "prod" -d "Omit devDependencies" -f complete -c bun -n "__fish_seen_subcommand_from audit prune" -l "omit" -r -a "dev optional peer" -d "Omit the given dependency type" -f complete -c bun -n "__fish_seen_subcommand_from audit prune" -l "dry-run" -d "Print what would change without changing anything" -f +complete -c bun -n "__fish_seen_subcommand_from prune" -l "check" -d "Exit with code 1 if node_modules has packages that can be removed, without deleting anything" -f complete -c bun -n "__fish_seen_subcommand_from audit; and __fish_seen_subcommand_from fix" -s "L" -l "latest" -d "Also apply fixes that fall outside the ranges declared in package.json or catalogs" -f complete -c bun -n "__fish_seen_subcommand_from prune" -s "p" -l "production" -d "Also remove packages that are only needed by devDependencies" -f complete -c bun -n "__fish_seen_subcommand_from prune" -s "P" -l "prod" -d "Also remove packages that are only needed by devDependencies" -f diff --git a/completions/bun.zsh b/completions/bun.zsh index 9ef9930f7a85..790da62bd25d 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -737,6 +737,7 @@ _bun_prune_completion() { '--prod[Also remove packages that are only needed by devDependencies]' \ '*--omit[Also remove packages that are only needed by the given dependency types]:type:(dev optional peer)' \ '--dry-run[Print what would be removed without deleting anything]' \ + '--check[Exit with code 1 if node_modules has packages that can be removed, without deleting anything]' \ '*--os[Prune for a different operating system than the current one]:os' \ '*--cpu[Prune for a different CPU architecture than the current one]:cpu' \ '--linker[Prune a node_modules installed with the given linker]:linker:(isolated hoisted)' \ diff --git a/docs/pm/cli/prune.mdx b/docs/pm/cli/prune.mdx index 97ca936e013f..5db2c86cd61f 100644 --- a/docs/pm/cli/prune.mdx +++ b/docs/pm/cli/prune.mdx @@ -51,6 +51,14 @@ bun prune v1.4.0 (abc12345) bun prune --production ``` +### `--check` + +Print the same report as `--dry-run`, but exit `1` if anything would be removed. Use it in CI: + +```bash terminal icon="terminal" +bun prune --production --check +``` + ### `--filter` Prune only the selected workspaces' `node_modules` folders (same patterns as [`bun install --filter`](/pm/filter)). Bun also cleans shared locations: the root `node_modules`, or `node_modules/.bun` with the isolated linker. In those locations, Bun keeps anything an unselected workspace still needs. diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 999ce9016546..b7307ba874a6 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -460,6 +460,9 @@ const DEDUPE_HELP_PARAMS: &[ParamType] = &[ static PRUNE_PARAMS: &[ParamType] = concat_params![ SHARED_PARAMS, &[ + clap::param!( + "--check Exit with code 1 if node_modules has packages that can be removed, without deleting anything" + ), clap::param!( "-F, --filter ... Only prune the node_modules folders of the matching workspaces" ), @@ -474,6 +477,9 @@ const PRUNE_HELP_PARAMS: &[ParamType] = &[ clap::param!( "--omit ... Also remove packages that are only needed by the given dependency types" ), + clap::param!( + "--check Exit with code 1 if node_modules has packages that can be removed, without deleting anything" + ), clap::param!( "--dry-run Print what would be removed without deleting anything" ), @@ -1190,6 +1196,9 @@ Full documentation is available at https://bun.com/docs/pm/cli/dedupeShow what would be removed without deleting anything bun prune --dry-run + Only report what would be removed; exit code 1 if there is anything (for CI) + bun prune --check + Only prune what the app workspace no longer needs bun prune --production --filter app @@ -1388,7 +1397,7 @@ Full documentation is available at https://bun.com/docs/pm/cli/prune // cli.json_output = args.flag(b"--json"); } - if subcommand == Subcommand::Dedupe && args.flag(b"--check") { + if matches!(subcommand, Subcommand::Dedupe | Subcommand::Prune) && args.flag(b"--check") { cli.check = true; cli.dry_run = true; } diff --git a/src/install/prune.rs b/src/install/prune.rs index e4d382b4009e..1684eae0de35 100644 --- a/src/install/prune.rs +++ b/src/install/prune.rs @@ -342,7 +342,7 @@ pub fn prune(manager: &mut PackageManager, original_cwd: &[u8]) -> crate::Result print_elapsed(); print_apply_hint(); } - return Ok(()); + Global::exit(manager.options.check as u32); } let failed = execute(&plan, quiet); @@ -403,7 +403,7 @@ fn print_apply_hint() { .skip_while(|arg| **arg != *b"prune") .skip(1) { - if arg.starts_with(b"--dry-run") { + if arg.starts_with(b"--dry-run") || arg.starts_with(b"--check") { continue; } bun_core::pretty!(" {}", BStr::new(arg)); diff --git a/test/cli/install/bun-prune.test.ts b/test/cli/install/bun-prune.test.ts index b59827f13b97..adc4b67e87cb 100644 --- a/test/cli/install/bun-prune.test.ts +++ b/test/cli/install/bun-prune.test.ts @@ -438,6 +438,42 @@ test.concurrent("--dry-run prints without deleting; --silent deletes without pri expect(clean.exitCode).toBe(0); }); +// `--check` is the CI gate: the same report as `--dry-run`, but exits 1 when anything would be removed, like `bun dedupe --check`. +test.concurrent("--check reports and exits 1 without deleting", async () => { + const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); + const junk = plant(dir, "node_modules/junk"); + + const check = await prune(dir, "--check"); + expect(lines(check.stdout)).toStrictEqual([BANNER, "", "- junk", CAN_BE_REMOVED(1, 2), APPLY_HINT()]); + expect(check.stderr).toBe(""); + expect(existsSync(junk)).toBeTrue(); + expect(check.exitCode).toBe(1); + + // The report is identical to --dry-run; only the exit code differs. + const dryRun = await prune(dir, "--dry-run"); + expect(out(dryRun.stdout)).toBe(out(check.stdout)); + expect(dryRun.exitCode).toBe(0); + + // The hint omits both flags, so it stays copy-pasteable. + const both = await prune(dir, "--dry-run", "--check"); + expect(lines(both.stdout)).toStrictEqual(lines(check.stdout)); + expect(both.exitCode).toBe(1); + + const silent = await prune(dir, "--check", "--silent"); + expect(silent.stdout).toBe(""); + expect(silent.stderr).toBe(""); + expect(existsSync(junk)).toBeTrue(); + expect(silent.exitCode).toBe(1); + + const apply = await prune(dir); + expect(apply.exitCode).toBe(0); + expect(existsSync(junk)).toBeFalse(); + + const clean = await prune(dir, "--check"); + expect(lines(clean.stdout)).toStrictEqual([BANNER, "", NOTHING(1, 1)]); + expect(clean.exitCode).toBe(0); +}); + test.concurrent("nothing to prune when node_modules is missing or clean", async () => { const { packageDir, packageJson } = await registry.createTestDir(); await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } })); @@ -3354,6 +3390,7 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a Flags: -p, --production Also remove packages that are only needed by devDependencies (alias: --prod) --omit= Also remove packages that are only needed by the given dependency types + --check Exit with code 1 if node_modules has packages that can be removed, without deleting anything --dry-run Print what would be removed without deleting anything --os= Prune for a different operating system than the current one --cpu= Prune for a different CPU architecture than the current one @@ -3373,6 +3410,9 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a Show what would be removed without deleting anything bun prune --dry-run + Only report what would be removed; exit code 1 if there is anything (for CI) + bun prune --check + Only prune what the app workspace no longer needs bun prune --production --filter app From c046da3f14860840b7cc770b94e4865025d5b163 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:35 +0000 Subject: [PATCH 056/258] audit fix: never downgrade a package out of its installed release line (#39317) --- docs/pm/cli/audit.mdx | 4 +- src/install/audit_fix.rs | 16 +- test/cli/install/bun-audit.test.ts | 199 +++++++++++++++++- .../packages/create-zero-major-packages.ts | 48 +++++ .../registry/packages/zero-major/package.json | 49 +++++ .../packages/zero-major/zero-major-0.4.0.tgz | Bin 0 -> 159 bytes .../packages/zero-major/zero-major-0.4.1.tgz | Bin 0 -> 160 bytes .../packages/zero-major/zero-major-0.5.0.tgz | Bin 0 -> 160 bytes .../packages/zero-major/zero-major-0.5.1.tgz | Bin 0 -> 160 bytes 9 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 test/cli/install/registry/packages/create-zero-major-packages.ts create mode 100644 test/cli/install/registry/packages/zero-major/package.json create mode 100644 test/cli/install/registry/packages/zero-major/zero-major-0.4.0.tgz create mode 100644 test/cli/install/registry/packages/zero-major/zero-major-0.4.1.tgz create mode 100644 test/cli/install/registry/packages/zero-major/zero-major-0.5.0.tgz create mode 100644 test/cli/install/registry/packages/zero-major/zero-major-0.5.1.tgz diff --git a/docs/pm/cli/audit.mdx b/docs/pm/cli/audit.mdx index 8fa35e1734fb..6285756ffc30 100644 --- a/docs/pm/cli/audit.mdx +++ b/docs/pm/cli/audit.mdx @@ -96,8 +96,8 @@ Fixed 2 vulnerabilities in 2 packages ``` - **blocked by a dependent's range** — no safe version fits a dependent's declared range. If the range is in your own `package.json` or catalog, `bun audit fix --latest` gets past it. Otherwise, update the dependent or add an [`overrides`](/pm/overrides) entry. -- **no published version fixes** — every published version is vulnerable. Replace the package, or silence the advisory with the printed `--ignore` command. -- If no newer version is safe but an older one is, Bun downgrades and marks the row `(downgrade)`. +- **no published version fixes** — every version Bun would install is vulnerable. Replace the package, or silence the advisory with the printed `--ignore` command. +- If no newer version is safe but an older one is, Bun downgrades and marks the row `(downgrade)`. Bun only downgrades within the installed major version (within the installed minor version below 1.0.0), with or without `--latest`. When an advisory covers every `2.x` release, Bun lists the package under **no published version fixes** instead of installing a `1.x` release. - Bun still installs a safe version newer than `--minimum-release-age` and marks the row `(newer than --minimum-release-age)`. - Bun upgrades patched dependencies (`patchedDependencies`) like any other package. Re-create the patch afterwards with `bun patch`. - After installing, Bun re-audits the new lockfile. The `remaining` count and exit code reflect that second audit, so they match what a follow-up `bun audit` would report. diff --git a/src/install/audit_fix.rs b/src/install/audit_fix.rs index 505bd97a225c..c1d01cd6d5ca 100644 --- a/src/install/audit_fix.rs +++ b/src/install/audit_fix.rs @@ -214,6 +214,11 @@ struct Candidate { downgrade: bool, } +/// Downgrades stay in the installed major (minor below 1.0.0): an older line is a different API, and the bulk response only carries advisories matching the installed versions, so its releases merely look safe (#39309). +fn same_release_line(a: Semver::Version, b: Semver::Version) -> bool { + a.major == b.major && (a.major != 0 || a.minor == b.minor) +} + fn fmt_version(version: Semver::Version, buf: &[u8]) -> Box<[u8]> { let mut out: Vec = Vec::new(); let _ = write!(out, "{}", version.fmt(buf)); @@ -691,7 +696,13 @@ pub fn plan_fixes(manager: &mut PackageManager, advisories: &[Advisory]) -> crat } let upgrade_count = candidates.len(); for (i, &v) in releases.iter().enumerate().rev() { - if v.order(inst.current, manifest_buf, buf) == Ordering::Less && is_safe(v) { + if v.order(inst.current, manifest_buf, buf) != Ordering::Less { + continue; + } + if !same_release_line(v, inst.current) { + break; + } + if is_safe(v) { candidates.push(Candidate { version: v, index: i, @@ -819,7 +830,8 @@ pub fn plan_fixes(manager: &mut PackageManager, advisories: &[Advisory]) -> crat edits.push(edit); } // A rewritten peer row is deferred by the differ and rebinds to the old package unless the edge is pinned too. - if edge.peer { + // A rewritten range re-resolves to the newest release it allows, which for a downgrade is the vulnerable one again (`^1.0.1` still takes 1.1.0). + if edge.peer || candidate.downgrade { edges.push(PlannedEdge { dep_id: edge.dep_id, parent: edge.parent, diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index 431037cc5bf0..9d23b5c0abf7 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -1558,25 +1558,112 @@ describe("`bun audit fix`", () => { }); test.concurrent("a range that rejects every safe release is blocked on the highest safe downgrade", async () => { + await using server = startRegistry({ "no-deps": [adv(">=1.1.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.1.0" } }); + const lockBefore = await lock(dir); + expect(lockBefore).toContain('"no-deps@1.1.0"'); + + const { stdout, exitCode } = await auditFix(dir); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + blocked by a dependent's range: + v no-deps 1.1.0 -> 1.0.1 (downgrade) + package.json depends on no-deps@^1.1.0 + bun audit fix --latest + + Fixed 0 of 1 vulnerability (checked 1) + 1 vulnerability remaining" + `); + expect(exitCode).toBe(1); + expect(await lock(dir)).toBe(lockBefore); + }); + + test.concurrent("a safe release in an older major is not a downgrade candidate", async () => { await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] }); using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^2.0.0" } }); const lockBefore = await lock(dir); expect(lockBefore).toContain('"no-deps@2.0.0"'); + const { stdout, stderr, exitCode } = await auditFix(dir); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + no published version fixes: + no-deps@2.0.0 1 + bun audit fix --ignore 1 + + Fixed 0 of 1 vulnerability (checked 1) + 1 vulnerability remaining" + `); + expect(stderr).not.toContain("Saved lockfile"); + expect(exitCode).toBe(1); + expect(await lock(dir)).toBe(lockBefore); + }); + + // peer-deps@1.0.0 declares no-deps@* as a peer, so every older release is inside the range; the installed major is still the floor. + test.concurrent("a dependent's wide range does not let a fix downgrade into an older major", async () => { + await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "peer-deps": "1.0.0" } }); + const lockBefore = await lock(dir); + expect(lockBefore).toContain('"no-deps@2.0.0"'); + const { stdout, exitCode } = await auditFix(dir); expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` "bun audit fix () - blocked by a dependent's range: - v no-deps 2.0.0 -> 1.1.0 (downgrade) - package.json depends on no-deps@^2.0.0 - bun audit fix --latest + no published version fixes: + no-deps@2.0.0 1 + bun audit fix --ignore 1 + + Fixed 0 of 1 vulnerability (checked 2) + 1 vulnerability remaining" + `); + expect(exitCode).toBe(1); + expect(await lock(dir)).toBe(lockBefore); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + }); + + // zero-major publishes 0.4.0, 0.4.1, 0.5.0 and 0.5.1; below 1.0.0 the minor is the release line. + test.concurrent("a 0.x package downgrades within its minor line", async () => { + await using server = startRegistry({ "zero-major": [adv(">=0.5.1")] }); + using dir = await setup(server, { name: "foo", dependencies: { "zero-major": "*" } }); + expect(await lock(dir)).toContain('"zero-major@0.5.1"'); + + const { stdout, exitCode } = await auditFix(dir); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + fixing: + v zero-major 0.5.1 -> 0.5.0 (downgrade) + + Fixed 1 vulnerability in 1 package (checked 1)" + `); + expect(exitCode).toBe(0); + expect(await lock(dir)).toContain('"zero-major@0.5.0"'); + expect(await installedVersion(dir, "zero-major")).toBe("0.5.0"); + }); + + test.concurrent("a 0.x package is not downgraded into an older minor line", async () => { + await using server = startRegistry({ "zero-major": [adv(">=0.5.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "zero-major": "*" } }); + const lockBefore = await lock(dir); + expect(lockBefore).toContain('"zero-major@0.5.1"'); + + const { stdout, exitCode } = await auditFix(dir); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + no published version fixes: + zero-major@0.5.1 1 + bun audit fix --ignore 1 Fixed 0 of 1 vulnerability (checked 1) 1 vulnerability remaining" `); expect(exitCode).toBe(1); expect(await lock(dir)).toBe(lockBefore); + expect(await installedVersion(dir, "zero-major")).toBe("0.5.1"); }); test.concurrent("no vulnerabilities", async () => { @@ -3915,8 +4002,8 @@ describe("`bun audit fix`", () => { }); test.concurrent("--json marks a blocked fix that would be a downgrade", async () => { - await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] }); - using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^2.0.0" } }); + await using server = startRegistry({ "no-deps": [adv(">=1.1.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.1.0" } }); const lockBefore = await lock(dir); const { stdout, exitCode } = await auditFix(dir, "--json"); @@ -3924,11 +4011,11 @@ describe("`bun audit fix`", () => { expect(doc.blocked).toStrictEqual([ { name: "no-deps", - from: "2.0.0", - to: "1.1.0", + from: "1.1.0", + to: "1.0.1", downgrade: true, latestFixes: true, - blockers: [{ dependent: "package.json", range: "^2.0.0", bundled: false }], + blockers: [{ dependent: "package.json", range: "^1.1.0", bundled: false }], }, ]); expect(doc).toMatchObject({ dryRun: false, fixed: 0, remaining: 1, fixes: [] }); @@ -4296,6 +4383,100 @@ describe("`bun audit fix --latest`", () => { expect(await lock(dir)).toContain('"no-deps@2.0.0"'); }); + // Every no-deps 2.x release is vulnerable. The safe 1.x releases are a different major, so there is nothing to rewrite the range to. + test.concurrent("never rewrites a range to downgrade into an older major", async () => { + await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^2.0.0" } }); + const lockBefore = await lock(dir); + expect(lockBefore).toContain('"no-deps@2.0.0"'); + const pkgJsonBefore = await pkgJsonText(dir); + + const { stdout, stderr, exitCode } = await auditFix(dir, "--latest"); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + no published version fixes: + no-deps@2.0.0 1 + bun audit fix --ignore 1 + + Fixed 0 of 1 vulnerability (checked 1) + 1 vulnerability remaining" + `); + expect(stderr).not.toContain("Saved lockfile"); + expect(exitCode).toBe(1); + expect(await pkgJsonText(dir)).toBe(pkgJsonBefore); + expect(await lock(dir)).toBe(lockBefore); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + + const json = await auditFix(dir, "--latest", "--json"); + expect(JSON.parse(json.stdout)).toMatchObject({ + fixed: 0, + remaining: 1, + fixes: [], + blocked: [], + unfixable: [{ name: "no-deps", from: "2.0.0", advisories: ["1"] }], + }); + expect(json.exitCode).toBe(1); + expect(await pkgJsonText(dir)).toBe(pkgJsonBefore); + expect(await lock(dir)).toBe(lockBefore); + }); + + test.concurrent("rewrites a range for a downgrade that stays in the installed major", async () => { + await using server = startRegistry({ "no-deps": [adv(">=1.1.0")] }); + using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.1.0" } }); + expect(await lock(dir)).toContain('"no-deps@1.1.0"'); + + const { stdout, exitCode } = await auditFix(dir, "--latest"); + expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(` + "bun audit fix () + + fixing: + v no-deps 1.1.0 -> 1.0.1 (downgrade) + package.json: ^1.1.0 -> ^1.0.1 + + Fixed 1 vulnerability in 1 package (checked 1)" + `); + expect(exitCode).toBe(0); + expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "^1.0.1" }); + const lockfile = await lock(dir); + expect(lockfile).toContain('"no-deps@1.0.1"'); + expect(lockfile).not.toContain('"no-deps@1.1.0"'); + expect(await installedVersion(dir, "no-deps")).toBe("1.0.1"); + + const recheck = await audit(dir); + expectClean(recheck, 1); + + await runBunInstall(installEnv(dir), dir, { frozenLockfile: true }); + }); + + test.concurrent("rewrites a catalog entry for a downgrade that stays in the installed major", async () => { + await using server = startRegistry({ "no-deps": [adv(">=1.1.0")] }); + const member = JSON.stringify({ name: "a", dependencies: { "no-deps": "catalog:" } }); + using dir = await setup( + server, + { name: "root", workspaces: ["packages/*"], catalog: { "no-deps": "^1.1.0" } }, + { "packages/a/package.json": member }, + ); + expect(await lock(dir)).toContain('"no-deps@1.1.0"'); + + const { stdout, exitCode } = await auditFix(dir, "--latest"); + expect(stdout).toContain(" v no-deps 1.1.0 -> 1.0.1 (downgrade)\n package.json (catalog): ^1.1.0 -> ^1.0.1\n"); + expect(stdout).toContain("Fixed 1 vulnerability in 1 package"); + expect(exitCode).toBe(0); + + expect((await pkgJson(dir)).catalog).toStrictEqual({ "no-deps": "^1.0.1" }); + expect(await pkgJsonText(dir, "packages", "a")).toBe(member); + const lockfile = await lock(dir); + expect(lockfile).toContain('"no-deps@1.0.1"'); + expect(lockfile).not.toContain('"no-deps@1.1.0"'); + expect((await pkgJson(dir, "packages", "a", "node_modules", "no-deps")).version).toBe("1.0.1"); + + const recheck = await audit(dir); + expectClean(recheck, 1); + + await runBunInstall(installEnv(dir), dir, { frozenLockfile: true }); + }); + test.concurrent("a package blocked only by a transitive dependent stays blocked", async () => { await using server = startRegistry({ "no-deps": [adv("<1.1.0")] }); using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0" } }); diff --git a/test/cli/install/registry/packages/create-zero-major-packages.ts b/test/cli/install/registry/packages/create-zero-major-packages.ts new file mode 100644 index 000000000000..ad76055a878f --- /dev/null +++ b/test/cli/install/registry/packages/create-zero-major-packages.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env bun +// Generates the `zero-major` fixture (0.4.x and 0.5.x releases, nothing else) used by bun-audit.test.ts to check that +// `bun audit fix` treats 0.x minors as separate release lines. + +import { mkdir, writeFile } from "fs/promises"; +import { join } from "path"; + +const packagesDir = import.meta.dir; + +const packages: Record = { + "zero-major": ["0.4.0", "0.4.1", "0.5.0", "0.5.1"], +}; + +for (const [name, versionList] of Object.entries(packages)) { + const dir = join(packagesDir, name); + await mkdir(dir, { recursive: true }); + + const versions: Record = {}; + let latest = ""; + for (const version of versionList) { + const pkgJson = { name, version }; + const tarball = join(dir, `${name}-${version}.tgz`); + await Bun.Archive.write( + tarball, + { "package/package.json": JSON.stringify(pkgJson, null, 2) }, + { compress: "gzip" }, + ); + + const bytes = await Bun.file(tarball).bytes(); + versions[version] = { + ...pkgJson, + _id: `${name}@${version}`, + dist: { + integrity: `sha512-${Buffer.from(new Bun.CryptoHasher("sha512").update(bytes).digest()).toString("base64")}`, + shasum: new Bun.CryptoHasher("sha1").update(bytes).digest("hex"), + tarball: `http://localhost:4873/${name}/-/${name}-${version}.tgz`, + }, + }; + latest = version; + } + + await writeFile( + join(dir, "package.json"), + JSON.stringify({ _id: name, name, "dist-tags": { latest }, versions }, null, 2), + ); +} + +console.log("Created zero-major test package"); diff --git a/test/cli/install/registry/packages/zero-major/package.json b/test/cli/install/registry/packages/zero-major/package.json new file mode 100644 index 000000000000..134fc8dfefb1 --- /dev/null +++ b/test/cli/install/registry/packages/zero-major/package.json @@ -0,0 +1,49 @@ +{ + "_id": "zero-major", + "name": "zero-major", + "dist-tags": { + "latest": "0.5.1" + }, + "versions": { + "0.4.0": { + "name": "zero-major", + "version": "0.4.0", + "_id": "zero-major@0.4.0", + "dist": { + "integrity": "sha512-xY4I6VAgjzMCnNTpToxaeCeV1wtNlO92G/rB/V8hUIlhXnNSfiOwoWKr38ElkI9ro09fwPIaM34s7INVa2uCMw==", + "shasum": "60ec5ccba9d9ab89bebaa5a0fd117e50a3181e3f", + "tarball": "http://localhost:4873/zero-major/-/zero-major-0.4.0.tgz" + } + }, + "0.4.1": { + "name": "zero-major", + "version": "0.4.1", + "_id": "zero-major@0.4.1", + "dist": { + "integrity": "sha512-m9SPZLJj+qAzlWZKR+s0MA8V+nTc9iJiMCM327hhW8ib2cHgj4sC33R61Mffid5uohvhs8VKmTEkLIX+wAGcdw==", + "shasum": "ec28d936c91a26ed55ff0ccb57229b9199214d9a", + "tarball": "http://localhost:4873/zero-major/-/zero-major-0.4.1.tgz" + } + }, + "0.5.0": { + "name": "zero-major", + "version": "0.5.0", + "_id": "zero-major@0.5.0", + "dist": { + "integrity": "sha512-XXcn5EnWRgNs1wTxAyNamlP5WwzpwinVz29ki/ph99Utwq5NaTa4jTM2d0W8DH1+5SRK4pSD6rE03TFYypOPfQ==", + "shasum": "e678e28002fe6c54c5a81ea5353172890d97f07e", + "tarball": "http://localhost:4873/zero-major/-/zero-major-0.5.0.tgz" + } + }, + "0.5.1": { + "name": "zero-major", + "version": "0.5.1", + "_id": "zero-major@0.5.1", + "dist": { + "integrity": "sha512-bNixOAGH2uJiY8pqjQq9XERxmxCwl1GsgdrUWrwZDfzn+j8rC97F/uJAPjvRJLL+jfhUZRlN3C/KjgNlWdah+A==", + "shasum": "5873c91085ace4ae7c3162b9467848d0db4203fe", + "tarball": "http://localhost:4873/zero-major/-/zero-major-0.5.1.tgz" + } + } + } +} \ No newline at end of file diff --git a/test/cli/install/registry/packages/zero-major/zero-major-0.4.0.tgz b/test/cli/install/registry/packages/zero-major/zero-major-0.4.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5244bac82e92912db4433150cfb920638098c128 GIT binary patch literal 159 zcmb2|=3oGW|8LJ9Y$i?+eQEx;1|6KfB*% zj^>-b#QV9+!avvbE&gp_yn3&Ay8r1}k-0%3OSf;@wCY3B(QC7gb?(%+IKO&bcaQjD|I@3o7C(A*Dmyg$?Vc64vu;ZTerA3CQ)JV{?c1k4 z|951qz{X^WMQ@M9CHuWr{Gxd~R53~4@O-<;Ex$F>_LXegAq{Z@lsw>H!Qc`(laWD# GfdK$Sc}6e* literal 0 HcmV?d00001 diff --git a/test/cli/install/registry/packages/zero-major/zero-major-0.5.0.tgz b/test/cli/install/registry/packages/zero-major/zero-major-0.5.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..26ed88f9a52396c445a7dd6bc5852a16fd4eff01 GIT binary patch literal 160 zcmb2|=3oGW|8LK4_Ju^>;f1&XN_N~b&G>5nWN;b^@z!0hZLyJL! GfdK$mUq;vf literal 0 HcmV?d00001 From 090ab60074670028c9750c998cc2e7bdc8498ae7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:07:39 +0000 Subject: [PATCH 057/258] run: set npm_package_name/version/json and npm_config_local_prefix per run (#38071) --- src/runtime/cli/pack_command.rs | 13 +++- src/runtime/cli/run_command.rs | 29 ++++---- test/cli/install/bun-pack.test.ts | 55 +++++++++++++++ test/cli/install/bun-publish.test.ts | 28 ++++++++ test/cli/run/run-process-env.test.ts | 100 ++++++++++++++++++++++++++- 5 files changed, 209 insertions(+), 16 deletions(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index d9a0f0e03bcb..d4d680602518 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -2048,7 +2048,18 @@ pub(crate) fn pack( // not repeated. unsafe { (*transpiler_for_deinit).deinit() }; } - ctx.manager.env_mut().map.put(b"npm_command", b"pack")?; + let script_env = ctx.manager.env_mut(); + script_env.map.put(b"npm_command", b"pack")?; + // `configure_env_for_run` described the package.json of the directory the + // package manager chdir'd to, which for a workspace member is the + // workspace root; the lifecycle scripts belong to the manifest being packed. + script_env + .map + .put(b"npm_package_json", abs_package_json_path.as_bytes())?; + script_env.map.put(b"npm_package_name", package_name)?; + script_env + .map + .put(b"npm_package_version", package_version)?; let (postpack_script, publish_script, postpublish_script, ran_scripts): ( Option>, diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index b0a92bc2ac43..83fba6b67574 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -686,9 +686,14 @@ Full documentation is available at https://bun.com/docs/cli/run // remaining env-var seeding. let env_loader = this_transpiler.env_mut(); + // Like npm, `npm_config_local_prefix` and the `npm_package_*` vars below + // describe this run and overwrite what an outer `bun run` exported + // (`cd pkg && bun run x` must see `pkg`'s values); name/version are left + // alone only when package.json lacks them. `npm_config_user_agent` / + // `npm_execpath` are deliberately inherited. env_loader .map - .put_default(b"npm_config_local_prefix", top_level_dir) + .put(b"npm_config_local_prefix", top_level_dir) .expect("unreachable"); // Propagate --no-orphans / [run] noOrphans to the script's env so any @@ -738,26 +743,22 @@ Full documentation is available at https://bun.com/docs/cli/run if let Some(package_json) = root_dir_info.enclosing_package_json { if !package_json.name.is_empty() { - if env_loader.map.get(NpmArgs::PACKAGE_NAME).is_none() { - env_loader - .map - .put(NpmArgs::PACKAGE_NAME, &package_json.name) - .expect("unreachable"); - } + env_loader + .map + .put(NpmArgs::PACKAGE_NAME, &package_json.name) + .expect("unreachable"); } env_loader .map - .put_default(b"npm_package_json", package_json.source.path.text) + .put(b"npm_package_json", package_json.source.path.text) .expect("unreachable"); if !package_json.version.is_empty() { - if env_loader.map.get(NpmArgs::PACKAGE_VERSION).is_none() { - env_loader - .map - .put(NpmArgs::PACKAGE_VERSION, &package_json.version) - .expect("unreachable"); - } + env_loader + .map + .put(NpmArgs::PACKAGE_VERSION, &package_json.version) + .expect("unreachable"); } if let Some(config) = package_json.config.as_deref() { diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 8f67964bbbcd..21c64646b81e 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -639,6 +639,61 @@ describe("workspaces", () => { const tarball = readTarball(join(packageDir, "pkgs", "pkg1", "pkg1-1.1.1.tgz")); expect(tarball.entries).toMatchObject([{ "pathname": "package/package.json" }, { "pathname": "package/index.js" }]); }); + + describe("lifecycle scripts describe the workspace member being packed", () => { + const echoPackageEnv = "$npm_package_name $npm_package_version $npm_package_json $npm_config_local_prefix"; + let pkg1Dir: string; + let expectedLines: string[]; + + beforeEach(async () => { + pkg1Dir = join(packageDir, "pkgs", "pkg1"); + // npm_config_local_prefix is the workspace root, as with npm. + const memberEnv = `pkg1 1.1.1 ${join(pkg1Dir, "package.json")} ${packageDir}`; + expectedLines = [`prepack ${memberEnv}`, `postpack ${memberEnv}`]; + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ name: "pack-workspace", version: "2.2.2", workspaces: ["pkgs/*"] }), + ), + write( + join(pkg1Dir, "package.json"), + JSON.stringify({ + name: "pkg1", + version: "1.1.1", + scripts: { + prepack: `echo prepack ${echoPackageEnv}`, + postpack: `echo postpack ${echoPackageEnv}`, + release: `'${bunExe()}' pm pack --dry-run`, + }, + }), + ), + ]); + }); + + function lifecycleLines(out: string) { + return out.split("\n").filter(line => line.startsWith("prepack ") || line.startsWith("postpack ")); + } + + test("bun pm pack in the member", async () => { + const { out } = await pack(pkg1Dir, bunEnv, "--dry-run"); + expect(lifecycleLines(out)).toEqual(expectedLines); + }); + + test("bun pm pack from a member script", async () => { + await using proc = spawn({ + cmd: [bunExe(), "run", "--silent", "release"], + cwd: pkg1Dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(lifecycleLines(out)).toEqual(expectedLines); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + }); + }); + test("replaces workspace: protocol without lockfile", async () => { await Promise.all([ write( diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 981a56c307d9..9046680fa541 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -1290,6 +1290,34 @@ it("$npm_lifecycle_event is accurate during publish", async () => { expect(exitCode).toBe(0); }); +it("lifecycle scripts get the workspace member's npm_package_* when publishing a member", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const memberDir = join(packageDir, "packages", "publish-pkg-12"); + const echoPackageEnv = "$npm_package_name $npm_package_version $npm_package_json $npm_config_local_prefix"; + const events = ["prepublishOnly", "prepack", "postpack", "publish", "postpublish"]; + await Promise.all([ + write(join(packageDir, "bunfig.toml"), await registry.authBunfig("npm_package_env")), + write(packageJson, JSON.stringify({ name: "root", version: "0.0.1", workspaces: ["packages/*"] })), + write( + join(memberDir, "package.json"), + JSON.stringify({ + name: "publish-pkg-12", + version: "12.0.0", + scripts: Object.fromEntries(events.map(event => [event, `echo ${event} ${echoPackageEnv}`])), + }), + ), + ]); + + const { out, err, exitCode } = await publish(env, memberDir, "--dry-run"); + // npm_config_local_prefix is the workspace root, as with npm. + const memberEnv = `publish-pkg-12 12.0.0 ${join(memberDir, "package.json")} ${packageDir}`; + expect(out.split("\n").filter(line => events.some(event => line.startsWith(`${event} `)))).toEqual( + events.map(event => `${event} ${memberEnv}`), + ); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); +}); + describe("readme", () => { // Regression for https://github.com/oven-sh/bun/issues/30255 — `bun publish` // packed the README into the tarball but never populated the version-level diff --git a/test/cli/run/run-process-env.test.ts b/test/cli/run/run-process-env.test.ts index 726aa60fda06..b179405f3477 100644 --- a/test/cli/run/run-process-env.test.ts +++ b/test/cli/run/run-process-env.test.ts @@ -1,5 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { bunExe, bunRunAsScript, tempDir } from "harness"; +import { bunEnv, bunExe, bunRunAsScript, tempDir } from "harness"; +import { join } from "node:path"; + +// Run with `bun ` (not `bun run`), so it prints exactly what the +// `bun run