From 6157f122081130b5f2c9c1ea1e6cb705d0688757 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 08:45:20 -0400 Subject: [PATCH 01/55] Start bun open-issue sweep Co-Authored-By: Claude Opus 5.5 (1M context) From ac6eb0dc0851b852a182eb0868d127c8a31eb703 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:42:03 -0400 Subject: [PATCH 02/55] Find Bun globals in their real global dir, not /.. (#443) Global mode asked `bun pm bin -g` for Bun's global bin dir and assumed the packages sit at `/../install/global/node_modules`. Bun moves its bin dir (BUN_INSTALL_BIN, bunfig globalBinDir) and its global dir (BUN_INSTALL_GLOBAL_DIR) independently, so with either one set the guessed dir did not exist, get_global_node_modules_paths dropped it silently, and `scan -g` reported a clean, empty result while every Bun global package stayed unpatched. Ask Bun where the packages are instead: the first line of `bun pm ls -g` is ` node_modules (N[ installed])` on every Bun from 1.0 to 1.4. When bun can't be asked (not on PATH, timed out, unreadable answer), follow Bun's own openGlobalDir resolution from the environment: BUN_INSTALL_GLOBAL_DIR, then $BUN_INSTALL/install/global, then .bun/install/global under XDG_CACHE_HOME or the home dir. Verified against real Bun 1.1.45, 1.2.23, 1.3.14 and 1.4.2 on macOS with BUN_INSTALL_BIN and BUN_INSTALL_GLOBAL_DIR set: `scan -g` now sends the globally installed package to the patch API in every cell. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/crawlers/npm_crawler.rs | 74 +++++--- .../tests/crawler_npm_e2e.rs | 77 ++++---- .../tests/global_probe_spawn_e2e.rs | 167 +++++++++++++++++- 3 files changed, 252 insertions(+), 66 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 8acd18f16..15eddcf7b 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1227,47 +1227,79 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option { Some(path) } -/// Get the bun global `node_modules` path via `bun pm bin -g`. +/// Get the bun global `node_modules` path: the global dir `bun pm ls -g` +/// reports, else (no `bun` to ask, or an answer we can't read) the one +/// Bun's own resolution picks from the environment, see +/// [`bun_global_dir_from_env`]. +/// +/// The packages' dir is never derived from `bun pm bin -g` (#443): Bun +/// moves its bin dir (`BUN_INSTALL_BIN`, bunfig `globalBinDir`) and its +/// global dir (`BUN_INSTALL_GLOBAL_DIR`) independently, so `/..` +/// named a dir that didn't exist and every Bun global vanished from a +/// global scan. pub fn get_bun_global_prefix() -> Option { - get_bun_global_prefix_with(&GlobalProbeRunner) + get_bun_global_prefix_with(&GlobalProbeRunner).or_else(|| { + bun_global_dir_from_env(&|var| std::env::var_os(var)) + .map(|dir| dir.join("node_modules").to_string_lossy().to_string()) + }) } /// Version of `get_bun_global_prefix` that accepts an injected -/// `CommandRunner`. See `get_npm_global_prefix_with`. +/// `CommandRunner` and only asks `bun` (no environment fallback). See +/// `get_npm_global_prefix_with`. pub fn get_bun_global_prefix_with(runner: &dyn CommandRunner) -> Option { - parse_bun_bin_output( + parse_bun_ls_global_output( runner - .run("bun", &["pm", "bin", "-g"]) + .run("bun", &["pm", "ls", "-g"]) .as_deref() .unwrap_or(""), ) } -/// Pure parser for `bun pm bin -g` stdout. Extracted so the -/// derive-the-global-node_modules-path logic is unit-testable -/// without shelling out. -/// -/// Given output like `"/Users/foo/.bun/bin\n"` returns -/// `Some("/Users/foo/.bun/install/global/node_modules")`. Returns -/// `None` on empty input or a root-only path with no parent. -pub fn parse_bun_bin_output(stdout: &str) -> Option { - let bin_path = stdout.trim().to_string(); - if bin_path.is_empty() { +/// Pure parser for `bun pm ls -g` stdout, whose first line names the +/// global dir: ` node_modules (N)` (Bun 1.0 - 1.3) or +/// ` node_modules (N installed)` (1.4). Returns `/node_modules`, +/// or `None` when the first line has no such shape. The dir may itself +/// contain spaces, so the LAST ` node_modules (` splits it off. +pub fn parse_bun_ls_global_output(stdout: &str) -> Option { + let first = stdout.trim().lines().next()?; + let (dir, _) = first.rsplit_once(" node_modules (")?; + let dir = dir.trim(); + if dir.is_empty() { return None; } - - let bun_root = PathBuf::from(&bin_path); - let bun_root = bun_root.parent()?; Some( - bun_root - .join("install") - .join("global") + PathBuf::from(dir) .join("node_modules") .to_string_lossy() .to_string(), ) } +/// The global dir Bun installs `bun add -g` packages into, resolved the way +/// Bun does it (`openGlobalDir`): `BUN_INSTALL_GLOBAL_DIR`, else +/// `$BUN_INSTALL/install/global`, else `.bun/install/global` under +/// `XDG_CACHE_HOME` or the home dir (`USERPROFILE` on Windows). +/// +/// A set-but-empty or relative variable counts as unset, the same rule as +/// `composer_home_candidates`: it would otherwise name a dir relative to +/// the scanned project. +pub fn bun_global_dir_from_env(var: &impl Fn(&str) -> Option) -> Option { + let absolute = |name: &str| { + var(name) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + }; + let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + absolute("BUN_INSTALL_GLOBAL_DIR") + .or_else(|| absolute("BUN_INSTALL").map(|dir| dir.join("install").join("global"))) + .or_else(|| { + absolute("XDG_CACHE_HOME") + .or_else(|| absolute(home_var)) + .map(|dir| dir.join(".bun").join("install").join("global")) + }) +} + // --------------------------------------------------------------------------- // Helpers: synchronous wildcard directory resolver // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index cef842932..9f30819ac 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -6,11 +6,11 @@ use std::path::Path; use socket_patch_core::crawlers::npm_crawler::{ - build_npm_purl, get_bun_global_prefix, get_bun_global_prefix_with, get_npm_global_prefix, - get_npm_global_prefix_with, get_pnpm_global_prefix, get_pnpm_global_prefix_with, - get_yarn_global_prefix, get_yarn_global_prefix_with, parse_bun_bin_output, - parse_npm_root_output, parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output, - read_package_json, + build_npm_purl, bun_global_dir_from_env, get_bun_global_prefix, get_bun_global_prefix_with, + get_npm_global_prefix, get_npm_global_prefix_with, get_pnpm_global_prefix, + get_pnpm_global_prefix_with, get_yarn_global_prefix, get_yarn_global_prefix_with, + parse_bun_ls_global_output, parse_npm_root_output, parse_package_name, parse_pnpm_root_output, + parse_yarn_dir_output, read_package_json, }; use socket_patch_core::crawlers::types::CrawlerOptions; use socket_patch_core::crawlers::NpmCrawler; @@ -209,41 +209,40 @@ async fn get_node_modules_paths_global_mode_no_prefix() { let _paths = crawler.get_node_modules_paths(&opts).await.unwrap(); } -// ── parse_bun_bin_output ─────────────────────────────────────── +// ── parse_bun_ls_global_output ───────────────────────────────── -/// Bun's global node_modules lives at `/install/global/node_modules` -/// — the parser strips the trailing `bin` segment and joins the well-known -/// suffix. +/// Bun's global node_modules is `/node_modules`, where `` heads +/// the `bun pm ls -g` tree. Both the pre-1.4 `(N)` and the 1.4 +/// `(N installed)` count suffixes parse, and a dir with spaces survives. /// -/// Skipped on Windows: `PathBuf::join` uses `\` there, which produces -/// `/home/foo/.bun\install\global\node_modules` from Unix-style input. -/// The pure-parser semantics are still correct (parent stripping + -/// suffix join), just expressed in the host's path-separator. Real -/// bun installs on Windows would feed Windows-style paths into the -/// same parser. +/// Skipped on Windows: `PathBuf::join` uses `\` there, so the joined +/// suffix is expressed in the host's separator. #[cfg(unix)] #[test] #[serial_test::parallel] -fn parse_bun_bin_output_well_formed_unix() { - let parsed = parse_bun_bin_output("/home/foo/.bun/bin\n"); - assert_eq!( - parsed.as_deref(), - Some("/home/foo/.bun/install/global/node_modules") - ); -} - -#[test] -#[serial_test::parallel] -fn parse_bun_bin_output_empty_returns_none() { - assert_eq!(parse_bun_bin_output(""), None); - assert_eq!(parse_bun_bin_output(" \n "), None); +fn parse_bun_ls_global_output_well_formed_unix() { + for (stdout, want) in [ + ( + "/home/foo/.bun/install/global node_modules (1)\n\u{2514}\u{2500}\u{2500} is-number@7.0.0\n", + "/home/foo/.bun/install/global/node_modules", + ), + ( + "/home/foo/g dir \u{fc} node_modules (2 installed)\n", + "/home/foo/g dir \u{fc}/node_modules", + ), + ] { + assert_eq!(parse_bun_ls_global_output(stdout).as_deref(), Some(want)); + } } -/// Root-only path has no parent — must yield None instead of panicking. #[test] #[serial_test::parallel] -fn parse_bun_bin_output_root_path_returns_none() { - assert_eq!(parse_bun_bin_output("/"), None); +fn parse_bun_ls_global_output_empty_or_unrecognized_returns_none() { + assert_eq!(parse_bun_ls_global_output(""), None); + assert_eq!(parse_bun_ls_global_output(" \n "), None); + // `bun pm bin -g`'s answer is not a global dir. + assert_eq!(parse_bun_ls_global_output("/home/foo/.bun/bin\n"), None); + assert_eq!(parse_bun_ls_global_output(" node_modules (1)"), None); } // ── shell-out wrappers via PATH stubbing ────────────────────── @@ -293,11 +292,15 @@ fn get_pnpm_global_prefix_returns_none_when_pnpm_not_on_path() { }); } +/// #443: with no `bun` to ask, the bun prefix is still Bun's own +/// resolution of its global dir from the environment. #[test] #[serial_test::serial] -fn get_bun_global_prefix_returns_none_when_bun_not_on_path() { +fn get_bun_global_prefix_falls_back_to_env_when_bun_not_on_path() { with_empty_path(|| { - assert_eq!(get_bun_global_prefix(), None); + let want = bun_global_dir_from_env(&|var| std::env::var_os(var)) + .map(|dir| dir.join("node_modules").to_string_lossy().to_string()); + assert_eq!(get_bun_global_prefix(), want); }); } @@ -327,7 +330,7 @@ fn get_npm_global_prefix_with_mock_runner_empty_stdout_returns_err() { } // Skipped on Windows: same path-separator reason as -// `parse_bun_bin_output_well_formed_unix` above. +// `parse_bun_ls_global_output_well_formed_unix` above. #[cfg(unix)] #[test] #[serial_test::parallel] @@ -358,15 +361,15 @@ fn get_pnpm_global_prefix_with_mock_runner_success() { } // Skipped on Windows: same path-separator reason as -// `parse_bun_bin_output_well_formed_unix` above. +// `parse_bun_ls_global_output_well_formed_unix` above. #[cfg(unix)] #[test] #[serial_test::parallel] fn get_bun_global_prefix_with_mock_runner_success() { let runner = common::MockCommandRunner::new().with_response( "bun", - &["pm", "bin", "-g"], - Some("/Users/foo/.bun/bin\n"), + &["pm", "ls", "-g"], + Some("/Users/foo/.bun/install/global node_modules (1 installed)\n"), ); assert_eq!( get_bun_global_prefix_with(&runner).as_deref(), diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index e447bf955..4ac5e486b 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -162,7 +162,8 @@ fn npm_family_global_probes_find_the_installed_shims() { let bun_bin = l.home.join(".bun").join("bin"); fake_tool(&l.bin, "npm", Some(&npm_root.to_string_lossy())); fake_tool(&l.bin, "pnpm", Some(&pnpm_root.to_string_lossy())); - fake_tool(&l.bin, "bun", Some(&bun_bin.to_string_lossy())); + let bun_global = l.home.join(".bun").join("install").join("global"); + fake_bun(&l.bin, &bun_bin, &bun_global); let mut env = Env::new(); point_env_at(&mut env, &l); @@ -174,13 +175,7 @@ fn npm_family_global_probes_find_the_installed_shims() { assert_eq!(get_pnpm_global_prefix().map(PathBuf::from), Some(pnpm_root)); assert_eq!( get_bun_global_prefix().map(PathBuf::from), - Some( - l.home - .join(".bun") - .join("install") - .join("global") - .join("node_modules") - ) + Some(bun_global.join("node_modules")) ); } @@ -366,3 +361,159 @@ async fn composer_home_falls_back_to_xdg_config_home() { .unwrap(); assert_eq!(paths, vec![vendor]); } + +// ──────────────────────────── bun global dir (#443) ──────────────────────────── + +/// A fake `bun` that answers like real Bun with `BUN_INSTALL_BIN` and/or +/// `BUN_INSTALL_GLOBAL_DIR` set: `bun pm bin -g` prints the (relocated) +/// bin dir, `bun pm ls -g` heads its tree with the global dir the packages +/// actually live in (` node_modules (N installed)` on 1.4.x). +fn fake_bun(bin: &Path, bin_dir: &Path, global_dir: &Path) { + std::fs::create_dir_all(bin).unwrap(); + let (bin_dir, global_dir) = (bin_dir.display(), global_dir.display()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = bin.join("bun"); + std::fs::write( + &path, + format!( + "#!/bin/sh\n\ + if [ \"$2\" = ls ]; then\n\ + printf '%s\\n' '{global_dir} node_modules (1 installed)' '└── semver@7.6.0'\n\ + else\n\ + printf '%s\\n' '{bin_dir}'\n\ + fi\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(windows)] + std::fs::write( + bin.join("bun.cmd"), + format!( + "@echo off\r\n\ + if \"%2\"==\"ls\" goto ls\r\n\ + echo {bin_dir}\r\n\ + exit /b 0\r\n\ + :ls\r\n\ + echo {global_dir} node_modules ^(1 installed^)\r\n\ + echo semver@7.6.0\r\n" + ), + ) + .unwrap(); +} + +/// #443: with `BUN_INSTALL_BIN` moved (here to `~/.local/bin`), the global +/// packages stay in `$BUN_INSTALL/install/global/node_modules`. The probe +/// must report where Bun keeps the packages, not guess `/..`. +#[test] +#[serial] +fn bun_global_probe_ignores_a_relocated_bin_dir() { + let l = layout(); + let global = l.home.join(".bun").join("install").join("global"); + fake_bun(&l.bin, &l.home.join(".local").join("bin"), &global); + let mut env = Env::new(); + point_env_at(&mut env, &l); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some(global.join("node_modules")) + ); +} + +/// #443: with `BUN_INSTALL_GLOBAL_DIR` set, `bun pm bin -g` still prints +/// the default bin dir, while the packages live in `/node_modules`. +#[test] +#[serial] +fn bun_global_probe_follows_bun_install_global_dir() { + let l = layout(); + let global = l.home.join("gdir with space ü"); + fake_bun(&l.bin, &l.home.join(".bun").join("bin"), &global); + let mut env = Env::new(); + point_env_at(&mut env, &l); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some(global.join("node_modules")) + ); +} + +/// #443: with no `bun` to ask, Bun's own resolution of its global dir is +/// followed (`BUN_INSTALL_GLOBAL_DIR`, then `$BUN_INSTALL/install/global`, +/// then `$XDG_CACHE_HOME/.bun/install/global`, then `~/.bun/install/global`), +/// so a global scan still finds the packages instead of reporting a clean, +/// empty result. +#[tokio::test] +#[serial] +async fn bun_global_dir_falls_back_to_bun_env_resolution() { + use socket_patch_core::crawlers::NpmCrawler; + + let l = layout(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + let nm = |dir: &Path| { + let nm = dir.join("node_modules"); + std::fs::create_dir_all(nm.join("semver")).unwrap(); + nm + }; + let explicit = nm(&l.home.join("gdir")); + let bun_install = nm(&l.home.join("bun-install").join("install").join("global")); + let xdg = nm(&l + .home + .join("xdg") + .join(".bun") + .join("install") + .join("global")); + let home = nm(&l.home.join(".bun").join("install").join("global")); + let cases: [(&[(&'static str, PathBuf)], &PathBuf); 4] = [ + ( + &[ + ("BUN_INSTALL_GLOBAL_DIR", l.home.join("gdir")), + ("BUN_INSTALL", l.home.join("bun-install")), + ("XDG_CACHE_HOME", l.home.join("xdg")), + ], + &explicit, + ), + ( + &[ + ("BUN_INSTALL", l.home.join("bun-install")), + ("XDG_CACHE_HOME", l.home.join("xdg")), + ], + &bun_install, + ), + (&[("XDG_CACHE_HOME", l.home.join("xdg"))], &xdg), + (&[], &home), + ]; + for (vars, want) in cases { + let mut case_env = Env::new(); + for name in ["BUN_INSTALL_GLOBAL_DIR", "BUN_INSTALL", "XDG_CACHE_HOME"] { + let value = vars.iter().find(|(n, _)| *n == name).map(|(_, v)| v); + case_env.set(name, value.map(|v| v.as_os_str())); + } + let paths = NpmCrawler + .get_node_modules_paths(&CrawlerOptions { + cwd: l.proj.clone(), + global: true, + global_prefix: None, + }) + .await + .unwrap(); + assert!( + paths.contains(want), + "{vars:?}: global paths must include {}; got {paths:?}", + want.display() + ); + for other in [&explicit, &bun_install, &xdg, &home] { + if other != want { + assert!( + !paths.contains(other), + "{vars:?}: Bun does not use {}; got {paths:?}", + other.display() + ); + } + } + drop(case_env); + } +} From 78da8f593f614b8b5a1cc47c8fc08adfa0a65b44 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:11:14 -0400 Subject: [PATCH 03/55] Pick the live Bun lock through one stat-based predicate (#735) Seven call sites each asked "is bun.lock the live lock?" with their own presence check. The lock inventory, the vendored GC probe, the wired integrity probe and VEX lockfile discovery used lstat, so a dangling bun.lock symlink counted as present; hosted and vendored routing used Path::exists, which follows links. Bun opens bun.lock through symlinks and falls back to bun.lockb only when the open finds nothing: with Bun 1.2.23 and 1.3.14, `bun install --frozen-lockfile` installs from bun.lockb beside a dangling bun.lock link. On that tree the inventory returned no packages and no diagnostic, the GC probe never decided, and VEX discovery saw no refs, while vendored and hosted mode wired bun.lockb. Add lock_inventory::bun_text_lock_drives (stat through links, over a ProjectView) and bun_binary_lock_drives, and route the inventory, the live-sibling fallback, wired_vendor_integrity, vendored_entry_in_use, vendored routing and revert, bun_workspace, the hosted engine, CLI repair's reference scan and VEX discovery through them. Remove bun_text_lock_present{,_in} and hosted::engine::bun_lock_present. A bun.lock directory is not treated like a dangling link: Bun opens it, fails to read it and ignores both locks ("warn: Ignoring lockfile"), so bun.lockb is not live. The text lock keeps winning there and its guarded read refuses, which is what every path already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/vendored_backend/repair.rs | 4 +- crates/socket-patch-core/src/hosted/engine.rs | 18 +---- .../socket-patch-core/src/vendor/bun_lock.rs | 14 ++-- .../src/vendor/bun_workspace.rs | 4 +- .../src/vendor/lock_inventory/bun.rs | 35 +++++++--- .../src/vendor/lock_inventory/mod.rs | 1 + .../src/vendor/lock_inventory/npm_family.rs | 10 ++- .../src/vendor/lock_inventory/tests.rs | 66 +++++++++++++++++++ .../src/vendor/lock_inventory/wired.rs | 2 +- .../src/vendor/npm_flavor.rs | 41 +++++++++++- .../socket-patch-core/src/vex/discover/bun.rs | 31 ++++++++- 11 files changed, 180 insertions(+), 46 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 268540822..f6c76caf4 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -41,7 +41,9 @@ struct Candidate { pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); - if !project_root.join("bun.lock").exists() { + if !socket_patch_core::vendor::lock_inventory::bun_text_lock_drives( + &socket_patch_core::vendor::lock_inventory::ProjectView::Disk(project_root), + ) { if let Ok(paths) = socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await { diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 161c7d3d4..bc3b43be4 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -39,7 +39,7 @@ use crate::patch::redirect::{ }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::purl::purl_parts; -use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; +use crate::vendor::lock_inventory::{bun_text_lock_drives, MemoryEntry, ProjectView}; use super::guidance::{ npm_allow_remote_already_detail, npm_allow_remote_configured_detail, @@ -252,23 +252,11 @@ pub fn build_candidates( candidates } -/// Whether the text `bun.lock` is present (disk: `exists`). Text retains -/// Bun's precedence when both lock spellings are present. -pub fn bun_lock_present(view: &ProjectView<'_>) -> bool { - match view { - ProjectView::Disk(cwd) - | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { - root: cwd, .. - }) => cwd.join("bun.lock").exists(), - ProjectView::Memory(project) => project.contains("bun.lock"), - } -} - /// Whether an npm candidate would rewrite a `bun.lockb` that is a symbolic /// link (atomic replacement cannot preserve a link; previews refuse too). pub fn bun_lockb_symlinked(view: &ProjectView<'_>, candidates: &[Candidate]) -> bool { candidates.iter().any(|c| c.dep.ecosystem == "npm") - && !bun_lock_present(view) + && !bun_text_lock_drives(view) && view.is_symlink("bun.lockb") } @@ -1085,7 +1073,7 @@ pub async fn rewrite( // candidate filter, so it can never disagree with `candidates`. let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); let bun_lockb = bun_lockb_present(view); - let binary_bun = !bun_lock_present(view) && bun_lockb; + let binary_bun = !bun_text_lock_drives(view) && bun_lockb; let binary_content = if binary_bun && overrides.iter().any(|o| o.ecosystem == "npm") { Some( view.read_bytes("bun.lockb") diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4e20d5d52..9bf409394 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -647,12 +647,12 @@ pub(crate) async fn vendor_bun<'a>( } } -/// Whether the project's installs are driven by the native binary lock: -/// no `bun.lock` beside a `bun.lockb`. [`vendor_bun`] routes those to -/// [`super::bun_binary`], and the vendor loop's download plan follows the -/// same routing. +/// Whether the project's installs are driven by the native binary lock +/// ([`super::lock_inventory::bun_binary_lock_drives`]). [`vendor_bun`] +/// routes those to [`super::bun_binary`], and the vendor loop's download +/// plan follows the same routing. pub(super) fn binary_lock_drives(project_root: &Path) -> bool { - !project_root.join(BUN_LOCK).exists() && project_root.join("bun.lockb").exists() + super::lock_inventory::bun_binary_lock_drives(project_root) } /// The text lock, read and strictly parsed before any write: version- @@ -851,9 +851,7 @@ pub(crate) async fn revert_bun_opts( .wiring .iter() .any(|r| r.kind == super::bun_binary::KIND || r.kind == "bun_lockb_workspace_artifact") - || (entry.wiring.is_empty() - && !project_root.join(BUN_LOCK).exists() - && project_root.join("bun.lockb").exists()) + || (entry.wiring.is_empty() && binary_lock_drives(project_root)) { return super::bun_binary::revert(entry, project_root, opts).await; } diff --git a/crates/socket-patch-core/src/vendor/bun_workspace.rs b/crates/socket-patch-core/src/vendor/bun_workspace.rs index ebb20fab7..be9a5c110 100644 --- a/crates/socket-patch-core/src/vendor/bun_workspace.rs +++ b/crates/socket-patch-core/src/vendor/bun_workspace.rs @@ -15,7 +15,9 @@ fn required_mirrors( ) -> Result, String> { if entry.ecosystem != "npm" || entry.flavor.as_deref() != Some("bun") - || root.join("bun.lock").exists() + || super::lock_inventory::bun_text_lock_drives(&super::lock_inventory::ProjectView::Disk( + root, + )) { return Ok(Vec::new()); } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index d1cda817f..9ed52efb1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -8,7 +8,7 @@ use crate::patch::redirect::hosted_url_version; use crate::vendor::bun_lock_text::{self, BunEntry}; use crate::vendor::bun_lockb::BunLockb; -use super::view::ProjectView; +use super::view::{DiskSnapshot, ProjectView}; use super::{http_url, LockIntegrity, LockfileEntry, UnsupportedNpmLayout}; /// Every `packages` entry of a text `bun.lock`, read with the ONE @@ -29,17 +29,32 @@ pub(crate) fn bun_text_entries(text: &str) -> Result, String> { // ── file selection ── -/// Whether the project root has a text `bun.lock` (lstat, so a dangling -/// symlink counts): bun reads it whenever it exists, so the binary -/// `bun.lockb` beside it is not the live lock. Lockfile discovery answers -/// the same question with `DiscoverCtx::exists` (the same lstat). -pub(crate) async fn bun_text_lock_present(root: &Path) -> bool { - bun_text_lock_present_in(&ProjectView::Disk(root)).await +/// Whether bun installs from the text `bun.lock` rather than a binary +/// `bun.lockb` beside it — the ONE answer every Bun-aware path routes +/// through (inventory, hosted, vendored, GC, repair, lockfile discovery). +/// +/// Bun opens `bun.lock` following symlinks and falls back to `bun.lockb` +/// only when that open finds nothing, so this is `stat`, not `lstat`: a +/// dangling link is absent and leaves the binary lock live (#735). +/// Anything the open does find shadows the binary lock even when bun +/// cannot read it — a directory (bun then ignores BOTH locks: "Ignoring +/// lockfile"), a FIFO — so the text lock is chosen and its guarded read +/// refuses rather than wiring a `bun.lockb` bun would not install from. +/// (Bun 1.2.23 and 1.3.14, `bun install --frozen-lockfile`.) An +/// in-memory link has no target to follow, so it keeps shadowing. +pub fn bun_text_lock_drives(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { + std::fs::metadata(root.join(BUN_LOCK)).is_ok() + } + ProjectView::Memory(project) => project.contains(BUN_LOCK) || project.is_dir(BUN_LOCK), + } } -/// [`bun_text_lock_present`] over a [`ProjectView`]. -pub(crate) async fn bun_text_lock_present_in(view: &ProjectView<'_>) -> bool { - view.exists_no_follow(BUN_LOCK).await +/// Whether the binary `bun.lockb` is the lock bun installs from: present, +/// and not shadowed by [`bun_text_lock_drives`]. +pub fn bun_binary_lock_drives(root: &Path) -> bool { + !bun_text_lock_drives(&ProjectView::Disk(root)) && root.join(BUN_LOCKB).exists() } // ── registry view ── diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 622a3d3e8..1bff3cd06 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -70,6 +70,7 @@ pub(crate) mod vlt; pub(crate) mod wired; pub(crate) mod yarn; +pub use self::bun::{bun_binary_lock_drives, bun_text_lock_drives}; pub(crate) use self::npm::{ npm_legacy_identity, npm_lock_bundled_nodes, npm_lock_legacy_mirror_nodes, npm_lock_located_nodes, NpmLockNode, diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs index 7255825d7..b47d06d48 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs @@ -5,13 +5,11 @@ #[cfg(test)] use std::path::Path; -use crate::constants::npm_family::{ - BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_SHRINKWRAP_LEGACY, VLT_LOCK, -}; +use crate::constants::npm_family::{BUN_LOCKB, NPM_LOCKS, PNPM_SHRINKWRAP_LEGACY, VLT_LOCK}; use crate::utils::purl::npm_purl; use crate::vendor::npm_flavor::NpmLockFlavor; -use super::bun::{bun_text_lock_present_in, inventory_bun_binary_in, inventory_bun_in}; +use super::bun::{bun_text_lock_drives, inventory_bun_binary_in, inventory_bun_in}; use super::npm::inventory_package_lock_in; use super::pnpm::{ inventory_pnpm_lock_in, inventory_pnpm_lock_rel_in, inventory_rush_pnpm_locks_in, @@ -151,7 +149,7 @@ pub(super) async fn inventory_npm_lock_raw_in( NpmLockFlavor::YarnClassic => inventory_yarn_classic_in(view).await, NpmLockFlavor::YarnBerry => inventory_yarn_berry_in(view).await, NpmLockFlavor::Bun => { - if bun_text_lock_present_in(view).await { + if bun_text_lock_drives(view) { inventory_bun_in(view).await } else { Some(inventory_bun_binary_in(view).await?) @@ -184,7 +182,7 @@ pub(super) async fn inventory_live_sibling_lock_in( // when the version refusal fired no bun.lock can actually be present; // probed anyway to keep this a literal transcription of the router's // order. The binary lock shares the same routing precedence. - if view.exists(BUN_LOCK).await { + if bun_text_lock_drives(view) { return Some(( NpmLockFlavor::Bun, inventory_bun_in(view).await.unwrap_or_default(), diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index cef50b43a..5645420be 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -3450,3 +3450,69 @@ async fn requirements_index_option_in_an_include_spans_the_tree() { LockIntegrity::Sha256AnyOf(vec![sha.clone()]) ); } + +/// REGRESSION (#735): Bun opens `bun.lock` through symlinks, so a +/// dangling `bun.lock` link is absent to it and it installs from the +/// `bun.lockb` beside it (verified with Bun 1.2.23 and 1.3.14: +/// `bun install --frozen-lockfile` installs from the binary lock). The +/// inventory, the wired-integrity probe and vendored routing must all pick +/// `bun.lockb` too, instead of losing every package of the live lock. +#[cfg(unix)] +#[tokio::test] +async fn bun_dangling_text_lock_link_leaves_the_binary_lock_live() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); + let minimist = lock + .packages() + .unwrap() + .into_iter() + .find(|package| package.name == "minimist") + .unwrap(); + let rel = ".socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz"; + let sri = format!("sha512-{}", "A".repeat(86) + "=="); + lock.set_package(minimist.id, rel, &sri).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + std::os::unix::fs::symlink("missing-target", tmp.path().join("bun.lock")).unwrap(); + + let (entries, diagnoses) = inventory_project_diagnosed(tmp.path()).await; + assert!(diagnoses.is_empty(), "{diagnoses:?}"); + assert_eq!( + sorted_pairs(&entries), + vec![("is-number".into(), "7.0.0".into())], + "the binary lock's registry packages (minimist is vendored)" + ); + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri(sri)) + ); + assert!(super::super::bun_lock::binary_lock_drives(tmp.path())); + // The hosted engine's view-level answer (disk and snapshot) agrees. + assert!(!bun_text_lock_drives(&ProjectView::Disk(tmp.path()))); + let snapshot = DiskSnapshot::new(tmp.path()); + assert!(!bun_text_lock_drives(&ProjectView::Snapshot(&snapshot))); +} + +/// The #735 control: a `bun.lock` DIRECTORY is not absent to Bun — it +/// opens it, fails to read it and ignores BOTH locks ("warn: Ignoring +/// lockfile", Bun 1.2.23 and 1.3.14). The binary lock is therefore not +/// live; every reader keeps choosing the text lock, whose unreadable read +/// refuses rather than wiring a `bun.lockb` Bun would not install from. +#[tokio::test] +async fn bun_text_lock_directory_still_shadows_the_binary_lock() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), bytes) + .await + .unwrap(); + tokio::fs::create_dir(tmp.path().join("bun.lock")) + .await + .unwrap(); + + let (entries, _) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{entries:?}"); + assert!(!super::super::bun_lock::binary_lock_drives(tmp.path())); + assert!(bun_text_lock_drives(&ProjectView::Disk(tmp.path()))); +} diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index 9ed45e49d..e929c5aa1 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -88,7 +88,7 @@ pub async fn wired_vendor_integrity( // Read active binary resolution records, never the append-only string // pool: it can retain paths and digests from earlier patch generations. - if !super::bun::bun_text_lock_present(project_root).await { + if !super::bun::bun_text_lock_drives(&super::ProjectView::Disk(project_root)) { if let Ok(bytes) = read_regular_to_bytes(&project_root.join(BUN_LOCKB)).await { if let Ok(packages) = BunLockb::parse_packages(&bytes) { let mut pinned: Option = None; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 21fb4aa46..392bd76b2 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -606,7 +606,9 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> lock_text_mentions_uuid(project_root, &["yarn.lock"], &entry.uuid).await } NpmLockFlavor::Bun => { - if super::lock_inventory::bun::bun_text_lock_present(project_root).await { + if super::lock_inventory::bun_text_lock_drives( + &super::lock_inventory::ProjectView::Disk(project_root), + ) { return lock_text_mentions_uuid(project_root, &[BUN_LOCK], &entry.uuid).await; } let bytes = read_regular_to_bytes(&project_root.join(BUN_LOCKB)) @@ -1864,6 +1866,43 @@ mod tests { ); } + /// REGRESSION (#735): a dangling `bun.lock` link is absent to Bun, + /// which installs from `bun.lockb`, so the GC probe resolves through + /// the binary lock instead of reading the link and never deciding. + #[cfg(unix)] + #[tokio::test] + async fn binary_bun_in_use_sees_through_a_dangling_text_lock_link() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap(); + let package = lock + .packages() + .unwrap() + .into_iter() + .find(|package| package.name == "minimist") + .unwrap(); + let entry = probe_entry(Some("bun")); + let target = format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz"); + let sri = format!("sha512-{}", "A".repeat(86) + "=="); + lock.set_package(package.id, &target, &sri).unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + std::os::unix::fs::symlink("missing-target", tmp.path().join("bun.lock")).unwrap(); + assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(true)); + + lock.set_package( + package.id, + "https://registry.example/minimist-1.2.2.tgz", + &sri, + ) + .unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes()) + .await + .unwrap(); + assert_eq!(vendored_entry_in_use(&entry, tmp.path()).await, Some(false)); + } + /// An entry stamped `flavor="pnpm-legacy"` must dispatch to the legacy /// backend's structural packages-key probe — not fall into the /// unknown-flavor `Some(_) => None` arm (a typo'd match string would diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index b219ebaae..0f5bda4ae 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -4,8 +4,10 @@ //! ## Which lock //! //! Exactly ONE of the two is read, because bun itself reads exactly one: -//! `bun.lock` whenever it exists (lstat — a squatting FIFO / dangling link -//! still counts, and is then diagnosed unreadable), else `bun.lockb`. A +//! `bun.lock` whenever its open finds something (stat, through links — a +//! squatting FIFO or directory still counts, and is then diagnosed +//! unreadable; a dangling link does not, #735), else `bun.lockb`: the +//! shared [`bun_text_lock_drives`] predicate. A //! stale binary lock left beside a text lock wires nothing, so it must not //! become a ref (rule 10 — the same gate `vendor::bun_workspace` and //! `lock_inventory::wired_vendor_integrity` apply). @@ -96,11 +98,12 @@ use crate::utils::digest::is_sri_pin; use crate::vendor::bun_lock_text::{decode_json_string, is_bundled_entry, split_name_spec}; use crate::vendor::bun_lockb::BunLockb; use crate::vendor::lock_inventory::bun::bun_text_entries; +use crate::vendor::lock_inventory::bun_text_lock_drives; use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::npm_common::tgz_leaf_version; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { - if ctx.exists(BUN_LOCK).await { + if bun_text_lock_drives(&ctx.view) { extract_text(ctx, out).await; // bun reads bun.lock whenever it exists: whatever a leftover // bun.lockb still names is recognized as unwired (rule 11). @@ -1427,6 +1430,28 @@ mod tests { assert_eq!(diag_codes(&out), vec![DIAG_LOCKFILE_UNREADABLE]); } + /// REGRESSION (#735): a dangling `bun.lock` link is absent to bun, + /// which installs from `bun.lockb` — so discovery reads the binary + /// lock's refs instead of diagnosing the link unreadable. + #[cfg(unix)] + #[tokio::test] + async fn dangling_text_lock_link_leaves_the_binary_lock_live() { + let bytes = std::fs::read(fixture_path("bun-lockb/1.3.14/bun.lockb")).expect("fixture"); + let p = Project::new(); + p.write( + "bun.lockb", + rewire(&bytes, &[hosted_minimist("1.2.2", UUID_A)]), + ); + std::os::unix::fs::symlink("missing-target", p.root().join("bun.lock")).unwrap(); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/minimist@1.2.2", UUID_A, WiringMode::Hosted)], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + assert_eq!(out.refs[0].source_file, Path::new("bun.lockb")); + } + /// The full orchestrator picks the bun refs up. #[tokio::test] async fn orchestrator_includes_bun() { From 48e52f66069d8d22de7f30e5a06a932d1176f928 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:23:17 -0400 Subject: [PATCH 04/55] Treat only ENOENT as an absent bun.lock (#735) bun_text_lock_drives counted any stat error on bun.lock as "absent", so bun.lockb was picked as the live lock. Bun's loadFromDir falls back to bun.lockb only when opening bun.lock fails with ENOENT. Any other open error (ELOOP from a self-referencing link, ENOTDIR from a link through a regular file, EACCES from a link into an unreadable directory) makes Bun print "Ignoring lockfile" and install from neither lock. Verified with Bun 1.3.14 in a scratch project: a bun.lock -> bun.lock self-link plus bun.lockb gives "ELOOP: failed to open lockfile" and a frozen-lockfile refusal, with nothing installed. The inventory, the GC probe and VEX discovery therefore read refs from a bun.lockb Bun would not install from. Before #735 these sites used lstat, which chose the text lock here. Only io::ErrorKind::NotFound now means absent. Every other error keeps the text lock shadowing, and its guarded read then refuses or diagnoses. A unix control test covers an ELOOP self-link and an ENOTDIR link through a file. It fails on the previous predicate. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/lock_inventory/bun.rs | 24 +++++++----- .../src/vendor/lock_inventory/tests.rs | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index 9ed52efb1..675cce190 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -34,18 +34,24 @@ pub(crate) fn bun_text_entries(text: &str) -> Result, String> { /// through (inventory, hosted, vendored, GC, repair, lockfile discovery). /// /// Bun opens `bun.lock` following symlinks and falls back to `bun.lockb` -/// only when that open finds nothing, so this is `stat`, not `lstat`: a -/// dangling link is absent and leaves the binary lock live (#735). -/// Anything the open does find shadows the binary lock even when bun -/// cannot read it — a directory (bun then ignores BOTH locks: "Ignoring -/// lockfile"), a FIFO — so the text lock is chosen and its guarded read -/// refuses rather than wiring a `bun.lockb` bun would not install from. -/// (Bun 1.2.23 and 1.3.14, `bun install --frozen-lockfile`.) An -/// in-memory link has no target to follow, so it keeps shadowing. +/// only when that open fails with ENOENT, so this is `stat`, not `lstat`, +/// and only `NotFound` means absent: a dangling link leaves the binary +/// lock live (#735). An open that fails with anything but ENOENT — a +/// self-referencing link (ELOOP), a link through a regular file +/// (ENOTDIR), a link into an unreadable directory (EACCES) — shadows the +/// binary lock just like an entry bun finds but cannot read (a directory, +/// a FIFO): bun then ignores BOTH locks ("Ignoring lockfile"), so the +/// text lock is chosen and its guarded read refuses rather than wiring a +/// `bun.lockb` bun would not install from. (Bun 1.2.23 and 1.3.14, +/// `bun install --frozen-lockfile`.) An in-memory link has no target to +/// follow, so it keeps shadowing. pub fn bun_text_lock_drives(view: &ProjectView<'_>) -> bool { match view { ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { - std::fs::metadata(root.join(BUN_LOCK)).is_ok() + match std::fs::metadata(root.join(BUN_LOCK)) { + Ok(_) => true, + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } } ProjectView::Memory(project) => project.contains(BUN_LOCK) || project.is_dir(BUN_LOCK), } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 5645420be..713de3180 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -3516,3 +3516,41 @@ async fn bun_text_lock_directory_still_shadows_the_binary_lock() { assert!(!super::super::bun_lock::binary_lock_drives(tmp.path())); assert!(bun_text_lock_drives(&ProjectView::Disk(tmp.path()))); } + +/// The #735 errno control: Bun falls back to `bun.lockb` only when opening +/// `bun.lock` fails with ENOENT. A self-referencing link fails with ELOOP +/// and a link through a regular file with ENOTDIR; Bun then prints +/// "Ignoring lockfile" and installs from NEITHER lock (Bun 1.2.23 and +/// 1.3.14). So the text lock keeps shadowing the binary one and nothing is +/// inventoried from a `bun.lockb` Bun would not install from. +#[cfg(unix)] +#[tokio::test] +async fn bun_text_lock_link_failing_with_other_errno_still_shadows_the_binary_lock() { + let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); + for target in ["bun.lock", "package.json/x"] { + let tmp = tempfile::tempdir().unwrap(); + tokio::fs::write(tmp.path().join("bun.lockb"), bytes) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("package.json"), "{}") + .await + .unwrap(); + std::os::unix::fs::symlink(target, tmp.path().join("bun.lock")).unwrap(); + + let (entries, _) = inventory_project_diagnosed(tmp.path()).await; + assert!(entries.is_empty(), "{target}: {entries:?}"); + assert!( + !super::super::bun_lock::binary_lock_drives(tmp.path()), + "{target}" + ); + assert!( + bun_text_lock_drives(&ProjectView::Disk(tmp.path())), + "{target}" + ); + let snapshot = DiskSnapshot::new(tmp.path()); + assert!( + bun_text_lock_drives(&ProjectView::Snapshot(&snapshot)), + "{target}" + ); + } +} From 76700c61b20d63a74095d6c1c50bcaeb5bf4f727 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:16:06 -0400 Subject: [PATCH 05/55] Keep the project registry's tarball URL in Bun restores (#992) Hosted rollback, remove and the hosted -> vendored takeover rebuild a bun.lock registry 4-tuple with an empty registry slot. Bun writes "" only for a package from registry.npmjs.org and the full tarball URL for any other registry, and Bun 1.1.39 through 1.3.6 read "" as npmjs whatever bunfig.toml says. So on a project with a private registry or mirror the restored lock fetched from npmjs on a cold frozen install: a 404 for a private package, a silent bypass of the mirror for a public one. The bun.lockb takeover restore had the same gap: it wrote the default registry's dist.tarball into the record, which Bun fetches from as is. Both restores now read the registry Bun resolves each package against (a scope's .npmrc @scope:registry or bunfig [install.scopes] entry, else BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY, the .npmrc registry, then bunfig [install] registry, in Bun's order), fetch the version document from it through fetch_dists_on as the berry and vlt restores do (#918), and record its dist.tarball. When that registry can't be read, the default registry's conventional URL is re-based on it (with the existing upstream_registry_fallback warning). The text slot stays "" for npmjs, matching Bun's own prefix test, so projects without a custom registry restore byte for byte as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/in_process_redirect.rs | 74 ++++++ .../src/patch/redirect/upstream/bun_lockb.rs | 51 +++- .../src/patch/redirect/upstream/npm.rs | 251 +++++++++++++++++- 4 files changed, 364 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7eddb127c..ce834519d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -918,7 +918,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control. * **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: - * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. + * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read). A `bun.lock` 4-tuple's registry slot is rebuilt the way Bun writes it (#992): `""` for a package from registry.npmjs.org, otherwise the full tarball URL — Bun 1.1.39–1.3.6 read `""` as npmjs whatever the project configures. The registry is the one Bun resolves the package against: a scope's `.npmrc` `@scope:registry` or `bunfig.toml` `[install.scopes]` entry, else `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`, the `.npmrc` `registry`, then `bunfig.toml` `[install] registry`; its version document's `dist.tarball` fills the slot, and when it can't be read (`upstream_registry_fallback`) the default registry's conventional URL is re-based on it. The `bun.lockb` takeover restore records the same URL. Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 0a0b430d4..c9a471236 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1621,6 +1621,80 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi } } +/// #992: Bun writes a package's full tarball URL into the `bun.lock` +/// registry slot whenever it is not under registry.npmjs.org, and Bun +/// 1.1.39–1.3.6 read an empty slot as npmjs whatever bunfig says. A hosted +/// rollback in a project whose `bunfig.toml` names a mirror must write the +/// mirror's URL back, not `""`: read from the mirror's own version document +/// when it answers, and rebuilt on the mirror from the default registry's +/// conventional URL (with `upstream_registry_fallback`) when it doesn't. +#[tokio::test] +#[serial] +async fn bun_rollback_keeps_the_bunfig_registry_tarball_url() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let integrity = "sha512-UPSTREAMupstream=="; + mock_npm_registry(&server, integrity, None).await; + // A mirror that serves its version document (conventional URLs). + let mirror_tarball = format!("{}/mirror/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()); + Mock::given(method("GET")) + .and(path(format!("/mirror/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": mirror_tarball, "integrity": integrity }, + }))) + .mount(&server) + .await; + + // `private` answers nothing: the restore falls back to the default + // registry's document and re-bases its conventional URL on the mirror. + for (mirror, readable) in [("mirror", true), ("private", false)] { + let tmp = tempfile::tempdir().unwrap(); + write_bun_project(tmp.path(), 1); + let lock_path = tmp.path().join("bun.lock"); + let slot = format!("{}/{mirror}/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()); + let pristine = std::fs::read_to_string(&lock_path) + .unwrap() + .replace("\"\", {}", &format!("\"{slot}\", {{}}")); + std::fs::write(&lock_path, &pristine).unwrap(); + std::fs::write( + tmp.path().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/{mirror}/\"\n", server.uri()), + ) + .unwrap(); + + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 1, "{mirror}: {env:#}"); + assert!( + std::fs::read_to_string(&lock_path) + .unwrap() + .contains(HOSTED_URL), + "{mirror}: the lock is hosted" + ); + + let (code, env) = rollback_json(tmp.path(), &server); + assert_eq!(code, Some(0), "{mirror}: rollback: {env:#}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([PURL]), + "{mirror}: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + pristine, + "{mirror}: rollback writes the mirror's tarball URL back into the registry slot" + ); + assert_eq!( + env.to_string().contains("upstream_registry_fallback"), + !readable, + "{mirror}: {env:#}" + ); + } +} + // Native binary lockfiles are parsed and patched without invoking Bun. const INVALID_LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs index f51142f69..7297f9417 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -17,7 +17,7 @@ use std::collections::BTreeSet; -use super::npm::{by_uuid, fetch_dists, refuse_all_in}; +use super::npm::{bun_tarball_url, by_uuid, fetch_dists_on, refuse_all_in, BunRegistrySettings}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::bun_lockb::{BunLockb, NORMALIZED_FORMAT_1, NORMALIZED_WORKSPACE}; @@ -102,17 +102,20 @@ pub(super) async fn restore( .iter() .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) .collect(); - let dists = fetch_dists(&wanted, ctx, &mut result).await; + // The record keeps the tarball URL Bun fetches from, which is the + // project registry's for a mirror (#992). + let settings = BunRegistrySettings::read(view, rel).await; + let dists = fetch_dists_on(&wanted, |n| settings.registry(n), ctx, &mut result).await; let mut changed = false; let mut restored = Vec::new(); for (id, uuid, name, version) in hits { if result.refused.contains_key(&uuid) { continue; } - let Some(dist) = dists.get(&(name.clone(), version.clone())) else { + let Some(found) = dists.get(&(name.clone(), version.clone())) else { continue; }; - let Some(integrity) = dist.integrity.as_deref() else { + let Some(integrity) = found.dist.integrity.as_deref() else { result.refuse( &uuid, format!("the registry records no integrity for {name}@{version}"), @@ -120,7 +123,9 @@ pub(super) async fn restore( continue; }; // Transactional per record: a failed rebuild leaves `lock` as is. - match lock.set_registry_package(id, &version, &dist.tarball, integrity) { + let tarball = + bun_tarball_url(settings.registry(&name).as_deref(), &name, &version, found); + match lock.set_registry_package(id, &version, &tarball, integrity) { Ok(()) => { restored.push(uuid); changed = true; @@ -290,6 +295,42 @@ mod tests { } } + /// #992: in a project whose `bunfig.toml` names a mirror, the rebuilt + /// record keeps the mirror's tarball URL (Bun fetches from the URL + /// the record holds), read from the mirror's own version document. + #[tokio::test] + #[serial_test::serial] + async fn hosted_record_restores_the_bunfig_registry_tarball() { + let original = fixture("1.2.23"); + let integrity = minimist(&original).integrity.unwrap(); + let server = registry(&integrity).await; + let mirror_url = format!("{}/mirror/minimist/-/minimist-1.2.2.tgz", server.uri()); + Mock::given(method("GET")) + .and(path("/mirror/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dist": { "tarball": mirror_url, "integrity": integrity } + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), hosted(&original)).unwrap(); + std::fs::write( + tmp.path().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/mirror/\"\n", server.uri()), + ) + .unwrap(); + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let discovery = crate::vex::discover_patched_refs(tmp.path()).await; + let pins = HostedPin::all(&discovery); + let outcome = restore_upstream(tmp.path(), &pins, &vendor_opts()).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + let after = std::fs::read(tmp.path().join("bun.lockb")).unwrap(); + let restored = minimist(&after); + assert_eq!(restored.resolution, mirror_url); + assert_eq!(restored.integrity.as_deref(), Some(integrity.as_str())); + } + /// Where the hosted rewrite had to normalize workspace dependency /// behaviors (not invertible), the restore refuses with the checkout /// remedy and writes nothing, instead of returning a non-exact lock. diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index c2715635d..9f38d3dc3 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -987,6 +987,155 @@ pub(crate) async fn restore_pnpm_locks( // ── bun.lock ───────────────────────────────────────────────────────────────── +/// The registry Bun resolves `name` against, from the settings beside the +/// lock (#992): a scoped package's `.npmrc` `@scope:registry`, else its +/// `bunfig.toml` `[install.scopes]` entry; otherwise `env_registry` +/// (`BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), the `.npmrc` +/// `registry`, then `bunfig.toml` `[install] registry` — Bun's own order. +/// `None` means Bun's default registry, npmjs. +fn bun_lookup_registry( + npmrc: Option<&str>, + bunfig: Option<&str>, + env_registry: Option<&str>, + name: &str, +) -> Option { + use super::super::npmrc::npmrc_top_level_value; + + fn url(value: &str) -> Option { + let value = value.trim().trim_matches(['"', '\'']); + (value.starts_with("https://") || value.starts_with("http://")).then(|| value.to_string()) + } + // A registry is a URL string or a table carrying `url`. + fn toml_url(item: Option<&toml_edit::Item>) -> Option { + let item = item?; + let value = item + .as_str() + .or_else(|| item.get("url").and_then(toml_edit::Item::as_str))?; + url(value) + } + let bunfig = bunfig.and_then(|text| text.parse::().ok()); + let install = bunfig.as_ref().and_then(|doc| doc.get("install")); + if let Some((scope, _)) = name.strip_prefix('@').and_then(|rest| rest.split_once('/')) { + let scoped = npmrc + .and_then(|text| npmrc_top_level_value(text, &format!("@{scope}:registry"))) + .and_then(|value| url(&value)) + .or_else(|| { + let scopes = install?.get("scopes")?; + toml_url(scopes.get(scope)).or_else(|| toml_url(scopes.get(format!("@{scope}")))) + }); + if scoped.is_some() { + return scoped; + } + } + env_registry + .and_then(url) + .or_else(|| { + npmrc + .and_then(|text| npmrc_top_level_value(text, "registry")) + .and_then(|value| url(&value)) + }) + .or_else(|| toml_url(install?.get("registry"))) +} + +/// The settings beside a Bun lock that decide which registry Bun resolves +/// each package against, and so which tarball URL it recorded (#992). +pub(super) struct BunRegistrySettings { + npmrc: Option, + bunfig: Option, + env_registry: Option, +} + +impl BunRegistrySettings { + pub(super) async fn read(view: &mut View<'_>, rel: &str) -> Self { + let dir_prefix = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/"), + None => String::new(), + }; + let npmrc = view + .read(&format!("{dir_prefix}.npmrc")) + .await + .ok() + .flatten(); + let bunfig = view + .read(&format!("{dir_prefix}bunfig.toml")) + .await + .ok() + .flatten(); + let env_registry = [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] + .iter() + .find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty())); + Self { + npmrc, + bunfig, + env_registry, + } + } + + /// The registry Bun resolves `name` against; `None` means npmjs. + pub(super) fn registry(&self, name: &str) -> Option { + bun_lookup_registry( + self.npmrc.as_deref(), + self.bunfig.as_deref(), + self.env_registry.as_deref(), + name, + ) + } +} + +/// The tarball URL Bun recorded for `name@version` resolved against +/// `project_registry`: the project registry's own `dist.tarball`, or — +/// for a document read from the default registry instead (a fallback, or +/// a project on `SOCKET_NPM_REGISTRY` itself) — its conventional URL +/// re-based on the project's registry, the URL Bun derived. With no +/// project registry, the default registry's `dist.tarball`, as before. +pub(super) fn bun_tarball_url( + project_registry: Option<&str>, + name: &str, + version: &str, + found: &ProjectDist, +) -> String { + use crate::vendor::registry_fetch::{ + npm_registry_base, npm_tarball_is_conventional, npm_tarball_url, + }; + let tarball = &found.dist.tarball; + match project_registry.map(|r| r.trim().trim_end_matches('/')) { + Some(base) + if !found.from_project + && npm_tarball_is_conventional(&npm_registry_base(), name, version, tarball) => + { + npm_tarball_url(base, name, version) + } + _ => tarball.clone(), + } +} + +/// The registry slot Bun writes in a `bun.lock` 4-tuple: `""` for a +/// package from registry.npmjs.org (Bun's own prefix test), else the full +/// tarball URL — which Bun 1.1.39–1.3.6 need, as they read `""` as npmjs +/// whatever the project configures (#992). +fn bun_registry_slot( + project_registry: Option<&str>, + name: &str, + version: &str, + found: &ProjectDist, +) -> String { + use crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY; + let Some(base) = project_registry.filter(|base| !base.trim().starts_with(DEFAULT_NPM_REGISTRY)) + else { + return String::new(); + }; + let url = bun_tarball_url(Some(base), name, version, found); + if url.starts_with(DEFAULT_NPM_REGISTRY) { + String::new() + } else { + url + } +} + pub(crate) async fn restore_bun_locks( view: &mut View<'_>, pins: &[&HostedPin], @@ -1047,16 +1196,19 @@ pub(crate) async fn restore_bun_locks( .iter() .map(|(_, u, n, v, _)| (u.clone(), n.clone(), v.clone())) .collect(); - let dists = fetch_dists(&wanted, ctx, &mut result).await; + // Bun records the tarball URL of a package from any registry but + // npmjs, so the restore reads the project's registry settings. + let settings = BunRegistrySettings::read(view, rel).await; + let dists = fetch_dists_on(&wanted, |n| settings.registry(n), ctx, &mut result).await; let mut changed = false; for (line_idx, uuid, name, version, deps) in hits { if result.refused.contains_key(&uuid) { continue; } - let Some(integrity) = dists - .get(&(name.clone(), version.clone())) - .and_then(|d| d.integrity.clone()) - else { + let Some(found) = dists.get(&(name.clone(), version.clone())) else { + continue; + }; + let Some(integrity) = found.dist.integrity.clone() else { result.refuse( &uuid, format!("the registry records no integrity for {name}@{version}"), @@ -1069,11 +1221,14 @@ pub(crate) async fn restore_bun_locks( let original = &lines[line_idx]; let cr = if original.ends_with('\r') { "\r" } else { "" }; let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); + let slot = + bun_registry_slot(settings.registry(&name).as_deref(), &name, &version, found); lines[line_idx] = format!( - "{indent}{key}: [{spec}, \"\", {deps}, {integrity}]{comma}{cr}", + "{indent}{key}: [{spec}, {slot}, {deps}, {integrity}]{comma}{cr}", indent = entry.indent, key = entry.key_raw, spec = json(&format!("{name}@{version}")), + slot = json(&slot), integrity = json(&integrity), comma = if entry.trailing_comma { "," } else { "" }, ); @@ -1165,9 +1320,89 @@ pub(crate) async fn cleanup_side_config( #[cfg(test)] mod tests { use super::{ - berry_lookup_registry, berry_registry_locator, non_default_registry, - registry_derives_tarball, yaml_top_level_value, + berry_lookup_registry, berry_registry_locator, bun_lookup_registry, bun_registry_slot, + non_default_registry, registry_derives_tarball, yaml_top_level_value, ProjectDist, }; + use crate::patch::redirect::upstream::client::NpmDist; + + #[test] + fn bun_reads_the_registry_in_bun_s_own_order() { + let bunfig = "[install]\nregistry = \"https://b.example/\"\n\n\ + [install.scopes]\ns = \"https://s.example/\"\n\"@t\" = { url = \"https://t.example/\", token = \"x\" }\n"; + let npmrc = "registry=https://n.example/\n@u:registry=https://u.example/\n"; + let lookup = |npmrc, bunfig, env, name| bun_lookup_registry(npmrc, bunfig, env, name); + assert_eq!( + lookup(None, Some(bunfig), None, "a").as_deref(), + Some("https://b.example/") + ); + // A table registry carries `url`. + assert_eq!( + lookup( + None, + Some("[install.registry]\nurl = \"https://c.example\"\n"), + None, + "a" + ) + .as_deref(), + Some("https://c.example") + ); + // .npmrc wins over bunfig, the environment over both. + assert_eq!( + lookup(Some(npmrc), Some(bunfig), None, "a").as_deref(), + Some("https://n.example/") + ); + assert_eq!( + lookup(Some(npmrc), Some(bunfig), Some("https://e.example"), "a").as_deref(), + Some("https://e.example") + ); + // A scope's registry wins over every default one; either spelling. + for (name, want) in [ + ("@s/a", "https://s.example/"), + ("@t/a", "https://t.example/"), + ("@u/a", "https://u.example/"), + ("@v/a", "https://e.example"), + ] { + assert_eq!( + lookup(Some(npmrc), Some(bunfig), Some("https://e.example"), name).as_deref(), + Some(want), + "{name}" + ); + } + // Nothing configured, or nothing that is a URL: npmjs. + assert_eq!(lookup(None, None, None, "a"), None); + assert_eq!( + lookup(Some("registry=${R}\n"), Some("not toml ["), Some("x"), "a"), + None + ); + } + + #[test] + fn bun_writes_the_tarball_url_unless_it_is_on_npmjs() { + let found = |tarball: &str, from_project| ProjectDist { + dist: NpmDist { + tarball: tarball.to_string(), + integrity: None, + shasum: None, + }, + from_project, + }; + let mirror = found("https://m.example/npm/a/-/a-1.0.0.tgz", true); + assert_eq!( + bun_registry_slot(Some("https://m.example/npm/"), "a", "1.0.0", &mirror), + "https://m.example/npm/a/-/a-1.0.0.tgz" + ); + // A mirror's off-path URL is kept as the mirror advertises it. + let cdn = found("https://cdn.example/f/a.tgz", true); + assert_eq!( + bun_registry_slot(Some("https://m.example"), "a", "1.0.0", &cdn), + "https://cdn.example/f/a.tgz" + ); + // npmjs, configured or not, is Bun's empty slot. + let npmjs = found("https://registry.npmjs.org/a/-/a-1.0.0.tgz", false); + for registry in [None, Some("https://registry.npmjs.org/")] { + assert_eq!(bun_registry_slot(registry, "a", "1.0.0", &npmjs), ""); + } + } #[test] fn berry_reads_the_project_registry_except_for_npm_scopes() { From 3a9092b6d4dfa33bf70ad61cefcd64b69433c679 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:46:57 -0400 Subject: [PATCH 06/55] Match Bun's registry choice for aliases and env in Bun restores (#992) Review follow-ups to the Bun registry-slot restore: - An npmjs alias as the project registry (registry.yarnpkg.com, or http://registry.npmjs.org) had the default document's conventional URL re-based onto the alias, so the bun.lock slot carried the alias URL where Bun writes "" (its writer prefix-tests the manifest's dist.tarball, which those registries advertise on npmjs). Only a fallback from a non-default registry (non_default_registry is Some) is re-based now, and the slot short-circuits on any npmjs host. - The env registry lookup took the first non-empty of BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY / npm_config_registry and then dropped it if it was not a URL; Bun skips a non-http(s) key and reads the next one. bun_env_registry now filters inside the search. - A bunfig [install.scopes] entry with no url (token only) takes the configured default registry (.npmrc / bunfig), not the environment, matching Bun's `registry.url = base.url`. - Unit tests no longer read the ambient env registry, and the CLI subprocess harnesses strip it, so a shell where npm exports npm_config_registry no longer turns the Bun restore tests red. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_redirect.rs | 9 + .../tests/in_process_rollback_hosted.rs | 9 + .../src/patch/redirect/upstream/npm.rs | 180 ++++++++++++++---- 3 files changed, 157 insertions(+), 41 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index c9a471236..30b41c343 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -2031,6 +2031,15 @@ fn scrubbed_cli() -> std::process::Command { .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_ECOSYSTEMS") .env_remove("SOCKET_MANIFEST_PATH"); + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registry. + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index d1b5b1607..70c952468 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -179,6 +179,15 @@ fn scrubbed_cli() -> std::process::Command { .env_remove("SOCKET_ECOSYSTEMS") .env_remove("SOCKET_MANIFEST_PATH") .env_remove("SOCKET_PRESERVE_STATE"); + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registry. + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 9f38d3dc3..93f160636 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -44,18 +44,25 @@ pub(super) struct ProjectDist { pub from_project: bool, } +/// Whether `base` is registry.npmjs.org or its registry.yarnpkg.com alias +/// (either scheme). +fn is_npmjs_registry(base: &str) -> bool { + let base = base.trim().trim_end_matches('/'); + let host = base + .strip_prefix("https://") + .or_else(|| base.strip_prefix("http://")) + .unwrap_or(base); + matches!(host, "registry.npmjs.org" | "registry.yarnpkg.com") +} + /// The registry base a project names, unless it is the default registry /// (npmjs, its registry.yarnpkg.com alias, or `SOCKET_NPM_REGISTRY`), whose /// document [`fetch_dists`] already reads. pub(super) fn non_default_registry(base: &str) -> Option { use crate::vendor::registry_fetch::npm_registry_base; let base = base.trim().trim_end_matches('/'); - let host = base - .strip_prefix("https://") - .or_else(|| base.strip_prefix("http://")) - .unwrap_or(base); - let npmjs = matches!(host, "registry.npmjs.org" | "registry.yarnpkg.com"); - (!base.is_empty() && !npmjs && base != npm_registry_base()).then(|| base.to_string()) + (!base.is_empty() && !is_npmjs_registry(base) && base != npm_registry_base()) + .then(|| base.to_string()) } /// [`fetch_dists`], reading each version document from the registry the @@ -1015,26 +1022,51 @@ fn bun_lookup_registry( } let bunfig = bunfig.and_then(|text| text.parse::().ok()); let install = bunfig.as_ref().and_then(|doc| doc.get("install")); + // The configured default registry before the environment applies. + let configured = || { + npmrc + .and_then(|text| npmrc_top_level_value(text, "registry")) + .and_then(|value| url(&value)) + .or_else(|| toml_url(install?.get("registry"))) + }; if let Some((scope, _)) = name.strip_prefix('@').and_then(|rest| rest.split_once('/')) { - let scoped = npmrc + if let Some(scoped) = npmrc .and_then(|text| npmrc_top_level_value(text, &format!("@{scope}:registry"))) .and_then(|value| url(&value)) - .or_else(|| { - let scopes = install?.get("scopes")?; - toml_url(scopes.get(scope)).or_else(|| toml_url(scopes.get(format!("@{scope}")))) - }); - if scoped.is_some() { - return scoped; + { + return Some(scoped); + } + let entry = install.and_then(|i| i.get("scopes")).and_then(|scopes| { + scopes + .get(scope) + .or_else(|| scopes.get(format!("@{scope}"))) + }); + if let Some(entry) = entry { + // A scope entry with no URL (a token only) takes the configured + // default registry, never the environment's. + if let Some(scoped) = toml_url(Some(entry)) { + return Some(scoped); + } + if entry.is_table_like() && entry.get("url").is_none() { + return configured(); + } } } - env_registry - .and_then(url) - .or_else(|| { - npmrc - .and_then(|text| npmrc_top_level_value(text, "registry")) - .and_then(|value| url(&value)) - }) - .or_else(|| toml_url(install?.get("registry"))) + env_registry.and_then(url).or_else(configured) +} + +/// The registry Bun takes from its environment: the first of +/// `BUN_CONFIG_REGISTRY`, `NPM_CONFIG_REGISTRY`, `npm_config_registry` +/// that is an http(s) URL — Bun skips a key that is not and reads the +/// next (`PackageManagerOptions.load`). +fn bun_env_registry(var: impl Fn(&str) -> Option) -> Option { + [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] + .iter() + .find_map(|key| var(key).filter(|v| v.starts_with("https://") || v.starts_with("http://"))) } /// The settings beside a Bun lock that decide which registry Bun resolves @@ -1061,13 +1093,14 @@ impl BunRegistrySettings { .await .ok() .flatten(); - let env_registry = [ - "BUN_CONFIG_REGISTRY", - "NPM_CONFIG_REGISTRY", - "npm_config_registry", - ] - .iter() - .find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty())); + // Unit tests read no ambient registry: npm exports + // `npm_config_registry` to child processes whenever one is + // configured, which would otherwise steer the fixtures' restores. + let env_registry = if cfg!(test) { + None + } else { + bun_env_registry(|key| std::env::var(key).ok()) + }; Self { npmrc, bunfig, @@ -1087,11 +1120,10 @@ impl BunRegistrySettings { } /// The tarball URL Bun recorded for `name@version` resolved against -/// `project_registry`: the project registry's own `dist.tarball`, or — -/// for a document read from the default registry instead (a fallback, or -/// a project on `SOCKET_NPM_REGISTRY` itself) — its conventional URL -/// re-based on the project's registry, the URL Bun derived. With no -/// project registry, the default registry's `dist.tarball`, as before. +/// `project_registry`: the `dist.tarball` of the document read, except +/// that a fallback from an unreadable mirror to the default registry's +/// document re-bases its conventional URL on the mirror, the URL Bun +/// derived there. pub(super) fn bun_tarball_url( project_registry: Option<&str>, name: &str, @@ -1102,12 +1134,16 @@ pub(super) fn bun_tarball_url( npm_registry_base, npm_tarball_is_conventional, npm_tarball_url, }; let tarball = &found.dist.tarball; - match project_registry.map(|r| r.trim().trim_end_matches('/')) { + // Only a fallback from a registry the default document does not stand + // for is re-based: npmjs, its yarnpkg alias and `SOCKET_NPM_REGISTRY` + // advertise the very `dist.tarball` that was read, which is what Bun + // recorded. + match project_registry.and_then(non_default_registry) { Some(base) if !found.from_project && npm_tarball_is_conventional(&npm_registry_base(), name, version, tarball) => { - npm_tarball_url(base, name, version) + npm_tarball_url(&base, name, version) } _ => tarball.clone(), } @@ -1124,8 +1160,10 @@ fn bun_registry_slot( found: &ProjectDist, ) -> String { use crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY; - let Some(base) = project_registry.filter(|base| !base.trim().starts_with(DEFAULT_NPM_REGISTRY)) - else { + // Bun resolved npmjs (unconfigured, or npmjs / its yarnpkg alias, + // whose documents advertise npmjs tarballs): the empty slot, whatever + // `SOCKET_NPM_REGISTRY` the restore itself read. + let Some(base) = project_registry.filter(|base| !is_npmjs_registry(base)) else { return String::new(); }; let url = bun_tarball_url(Some(base), name, version, found); @@ -1320,8 +1358,9 @@ pub(crate) async fn cleanup_side_config( #[cfg(test)] mod tests { use super::{ - berry_lookup_registry, berry_registry_locator, bun_lookup_registry, bun_registry_slot, - non_default_registry, registry_derives_tarball, yaml_top_level_value, ProjectDist, + berry_lookup_registry, berry_registry_locator, bun_env_registry, bun_lookup_registry, + bun_registry_slot, bun_tarball_url, non_default_registry, registry_derives_tarball, + yaml_top_level_value, ProjectDist, }; use crate::patch::redirect::upstream::client::NpmDist; @@ -1368,6 +1407,18 @@ mod tests { "{name}" ); } + // A scope entry with no URL takes the configured default registry, + // not the environment's (Bun's `registry.url = base.url`). + let token_only = "[install]\nregistry = \"https://b.example/\"\n\n\ + [install.scopes]\nw = { token = \"x\" }\n"; + assert_eq!( + lookup(None, Some(token_only), Some("https://e.example"), "@w/a").as_deref(), + Some("https://b.example/") + ); + assert_eq!( + lookup(None, Some(token_only), Some("https://e.example"), "a").as_deref(), + Some("https://e.example") + ); // Nothing configured, or nothing that is a URL: npmjs. assert_eq!(lookup(None, None, None, "a"), None); assert_eq!( @@ -1377,6 +1428,7 @@ mod tests { } #[test] + #[serial_test::serial] fn bun_writes_the_tarball_url_unless_it_is_on_npmjs() { let found = |tarball: &str, from_project| ProjectDist { dist: NpmDist { @@ -1397,11 +1449,57 @@ mod tests { bun_registry_slot(Some("https://m.example"), "a", "1.0.0", &cdn), "https://cdn.example/f/a.tgz" ); - // npmjs, configured or not, is Bun's empty slot. + // npmjs, configured or not, or its aliases, is Bun's empty slot: + // their documents advertise npmjs tarballs, never re-based. let npmjs = found("https://registry.npmjs.org/a/-/a-1.0.0.tgz", false); - for registry in [None, Some("https://registry.npmjs.org/")] { + for registry in [ + None, + Some("https://registry.npmjs.org/"), + Some("http://registry.npmjs.org/"), + Some("https://registry.yarnpkg.com/"), + ] { assert_eq!(bun_registry_slot(registry, "a", "1.0.0", &npmjs), ""); + assert_eq!( + bun_tarball_url(registry, "a", "1.0.0", &npmjs), + "https://registry.npmjs.org/a/-/a-1.0.0.tgz", + "{registry:?}" + ); } + // A fallback from an unreadable mirror re-bases the conventional URL. + assert_eq!( + bun_tarball_url(Some("https://m.example/npm/"), "a", "1.0.0", &npmjs), + "https://m.example/npm/a/-/a-1.0.0.tgz" + ); + } + + #[test] + fn bun_env_registry_skips_keys_that_are_not_urls() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |key: &str| { + pairs + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.to_string()) + } + }; + assert_eq!( + bun_env_registry(env(&[ + ("BUN_CONFIG_REGISTRY", "not-a-url"), + ("NPM_CONFIG_REGISTRY", ""), + ("npm_config_registry", "https://n.example/"), + ])) + .as_deref(), + Some("https://n.example/") + ); + assert_eq!( + bun_env_registry(env(&[ + ("BUN_CONFIG_REGISTRY", "http://b.example"), + ("npm_config_registry", "https://n.example/"), + ])) + .as_deref(), + Some("http://b.example") + ); + assert_eq!(bun_env_registry(env(&[("BUN_CONFIG_REGISTRY", "x")])), None); } #[test] From db8c7e11caf9c1e45ad0875d0cec0ddc859cea75 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:20:41 -0400 Subject: [PATCH 07/55] Refuse Bun global store copies and walk its links (#635) Bun 1.3.14 added a machine-wide store for the isolated linker (`[install] globalStore = true` or BUN_INSTALL_GLOBAL_STORE=1). Each node_modules/.bun/ is then a symlink into /links/-, and every project using that cache links to the same directory. socket-patch did not know this layout. patch/shared_store.rs only recognized pnpm's global virtual store and PDM's cache, so agent apply wrote through the link into the shared directory and patched every other project, and a rollback in one project silently unpatched them all. And the .bun store walk kept only entries that are real directories, so every globalStore entry was skipped: agent mode reported transitive dependencies as package_not_installed, and hosted vex attested a pinned transitive package as not_affected without reading the unpatched copy that is actually installed. Add a BunGlobalStore shared-store kind, recognized on the real path /links/@<...>-/node_modules/, so agent apply and rollback refuse it like the pnpm and PDM stores (#361), naming the store and how to get a private copy. Have the .bun walk also follow entries that link into a Bun global store entry, so scan, apply's resolver, the peer-copy fan-out and vex see this project's copies there. Other links in .bun are still skipped. Tested with real Bun 1.3.14: two projects sharing a cache, agent apply in one now refuses both the direct and the transitive package and the other project's bytes stay untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/apply/bun_global_store.rs | 196 ++++++++++++++++++ crates/socket-patch-cli/tests/apply/main.rs | 1 + .../tests/e2e_vex_redirect.rs | 67 ++++++ .../tests/in_process_alternate_installers.rs | 86 ++++++++ .../src/crawlers/npm_crawler.rs | 80 ++++++- .../src/patch/shared_store.rs | 176 +++++++++++++++- docs/ecosystems.md | 11 +- 7 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 crates/socket-patch-cli/tests/apply/bun_global_store.rs diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs new file mode 100644 index 000000000..a3133ff7a --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -0,0 +1,196 @@ +//! Agent-mode `apply` / `rollback` on a project installed with Bun's global +//! store (`[install] globalStore = true`, Bun >= 1.3.14, #635). +//! +//! Each `node_modules/.bun/` is then a link into +//! `/links/-`, and every project on the machine links +//! to the same dir. Writing through it patched (and a rollback unpatched) +//! every other project using the store, with `success`. And because the +//! `.bun` walk kept only real dirs, a transitive dependency was reported +//! `package_not_installed` and left unpatched. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::common; + +use common::{git_sha256, parse_json_envelope, run_with_env}; + +const BEFORE: &[u8] = b"module.exports = 'pristine';\n"; +const AFTER: &[u8] = b"module.exports = 'patched';\n"; + +fn link_dir(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let link: PathBuf = link.components().collect(); + let target: PathBuf = target.components().collect(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .status() + .unwrap(); + assert!(status.success(), "mklink /J failed"); + } +} + +/// The layout Bun 1.3.14 writes with `linker = "isolated"` and +/// `globalStore = true`: `proj/node_modules/{left-pad,is-odd}` link to +/// `.bun//node_modules/`, each `.bun/` links to +/// `/links/-`, and is-odd's entry links `is-number` to +/// its sibling entry. Returns the project dir and the store's `left-pad` +/// and `is-number` dirs. +fn stage(tmp: &Path, bytes: &[u8]) -> (PathBuf, PathBuf, PathBuf) { + let links = tmp.join("bun-cache").join("links"); + let proj = tmp.join("proj"); + let nm = proj.join("node_modules"); + let bun = nm.join(".bun"); + std::fs::create_dir_all(&bun).unwrap(); + let entry = |name: &str, version: &str, hash: &str| { + let shared = links.join(format!("{name}@{version}-{hash}")); + let pkg = shared.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), bytes).unwrap(); + link_dir(&shared, &bun.join(format!("{name}@{version}"))); + pkg + }; + let left_pad = entry("left-pad", "1.3.0", "6a490709ba3c5c8f"); + let odd = entry("is-odd", "3.0.1", "630ebdaa4b425d00"); + let number = entry("is-number", "6.0.0", "fe514fa0667977a7"); + link_dir(&number, &odd.parent().unwrap().join("is-number")); + for name in ["left-pad", "is-odd"] { + let version = if name == "left-pad" { "1.3.0" } else { "3.0.1" }; + link_dir( + &bun.join(format!("{name}@{version}")) + .join("node_modules") + .join(name), + &nm.join(name), + ); + } + std::fs::write( + proj.join("package.json"), + r#"{ "name": "p", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0", "is-odd": "3.0.1" } }"#, + ) + .unwrap(); + std::fs::write( + proj.join("bunfig.toml"), + "[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ) + .unwrap(); + (proj, left_pad, number) +} + +fn write_manifest(root: &Path, purls: &[&str]) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + for bytes in [BEFORE, AFTER] { + std::fs::write(socket.join("blobs").join(git_sha256(bytes)), bytes).unwrap(); + } + let patches: serde_json::Map = purls + .iter() + .enumerate() + .map(|(i, purl)| { + let patch = json!({ + "uuid": format!("63563563-0000-4000-8000-00000000000{i}"), + "exportedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + }}, + "vulnerabilities": {}, + "description": "bun global store fixture", + "license": "MIT", + "tier": "free" + }); + (purl.to_string(), patch) + }) + .collect(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), + ) + .unwrap(); +} + +/// `purl`'s entry in an apply envelope's `events` or a rollback +/// envelope's `results`. +fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { + v["events"] + .as_array() + .or_else(|| v["results"].as_array()) + .expect("events or results array") + .iter() + .find(|e| e["purl"] == purl) + .unwrap_or_else(|| panic!("no event for {purl}: {v}")) +} + +fn run(proj: &Path, command: &str) -> Value { + let (code, stdout, stderr) = run_with_env( + proj, + &[command, "--offline", "--json"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + let v = parse_json_envelope(&stdout); + assert_ne!( + code, 0, + "a shared-store refusal fails the {command}; {v}\n{stderr}" + ); + v +} + +fn assert_refused(v: &Value, purl: &str) { + let ev = event(v, purl); + assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); + let text = ev.to_string(); + assert!( + text.contains("shared by other projects") && text.contains("globalStore = false"), + "the refusal names the shared store and its remedy; {ev}" + ); +} + +/// #635: the direct `left-pad` and the transitive `is-number` (reachable +/// only through `.bun`) are both refused as shared, and the store keeps +/// its bytes for the other projects linked to it. +#[test] +fn apply_refuses_bun_global_store_packages() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, number) = stage(tmp.path(), BEFORE); + write_manifest( + &proj, + &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], + ); + + let v = run(&proj, "apply"); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_refused(&v, "pkg:npm/left-pad@1.3.0"); + assert_refused(&v, "pkg:npm/is-number@6.0.0"); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), BEFORE); + assert_eq!(std::fs::read(number.join("index.js")).unwrap(), BEFORE); +} + +/// #635: a rollback in one project must not restore the original bytes +/// into the shared store, which would unpatch every other project +/// relying on them. +#[test] +fn rollback_refuses_bun_global_store_packages() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, number) = stage(tmp.path(), AFTER); + write_manifest( + &proj, + &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], + ); + + let v = run(&proj, "rollback"); + assert_eq!(v["status"], "partial_failure", "{v}"); + assert_refused(&v, "pkg:npm/left-pad@1.3.0"); + assert_refused(&v, "pkg:npm/is-number@6.0.0"); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), AFTER); + assert_eq!(std::fs::read(number.join("index.js")).unwrap(), AFTER); +} diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index 20dc853e8..9b5be8b59 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -11,6 +11,7 @@ mod vlt_vendored; mod apply_invariants; mod apply_network; +mod bun_global_store; mod cli_gem_variant_mismatch_policy; mod covgap_commands_apply; mod e2e_safety_advisories; diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index dc88b49ff..74ccf58b5 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -2225,6 +2225,73 @@ fn bun_hosted_ref_is_judged_by_the_bun_store_copy() { } } +/// #635: with Bun's global store (`[install] globalStore = true`, Bun >= +/// 1.3.14) the `.bun` entry is a link into `/links/-`, +/// a dir shared across projects. That linked store copy is still what this +/// project loads, so a pristine one is `not_applied` while `bun.lock` pins +/// the hosted tarball. Skipping the link read as "nothing installed" and +/// attested the pinned lock. +#[cfg(unix)] +#[test] +fn bun_hosted_ref_is_judged_by_a_global_store_copy() { + let (pristine, patched) = ( + &b"module.exports = 'pristine'\n"[..], + &b"module.exports = 'patched'\n"[..], + ); + let purl = "pkg:npm/left-pad@1.3.0"; + let url = hosted_npm_url("left-pad", "1.3.0", UUID); + let tmp = tempfile::tempdir().unwrap(); + let cwd = &tmp.path().join("app"); + put( + cwd, + "package.json", + br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, + ); + put( + cwd, + "bunfig.toml", + b"[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ); + put( + cwd, + "bun.lock", + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n \ + \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ + \"sha512-{dep}==\"],\n\n \ + \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", + dep = "D".repeat(86), + ) + .as_bytes(), + ); + let shared = tmp + .path() + .join("bun-cache/links/left-pad@1.3.0-6a490709ba3c5c8f"); + put( + &shared, + "node_modules/left-pad/package.json", + br#"{ "name": "left-pad", "version": "1.3.0" }"#, + ); + put(&shared, "node_modules/left-pad/index.js", pristine); + std::fs::create_dir_all(cwd.join("node_modules/.bun")).unwrap(); + std::os::unix::fs::symlink(&shared, cwd.join("node_modules/.bun/left-pad@1.3.0")).unwrap(); + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + one_file_view(UUID, purl, "package/index.js", pristine, patched), + )]); + let args = ["--proxy-url", &server.uri()]; + + let (code, env) = vex_json(cwd, &args); + assert_eq!(code, Some(1), "a pristine global store copy: {env}"); + assert_eq!(skipped_reason(&env, purl), "not_applied", "{env}"); + + put(&shared, "node_modules/left-pad/index.js", patched); + let (code, env) = vex_json(cwd, &args); + assert_attested(cwd, code, &env, UUID, "the global store copy verifies"); +} + /// The patch view for `name@version` (the [`left_pad_view`] shape). fn npm_view(name: &str, version: &str, after_hash: &str) -> Value { let mut view = left_pad_view(after_hash); diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 6d5553413..14febe4d9 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -1217,6 +1217,92 @@ async fn bun_isolated_linker_transitive_only_dep_apply_patches_store() { ); } +/// #635: with Bun's global store (`globalStore = true`, Bun >= 1.3.14) every +/// `node_modules/.bun/` links into `/links/-`, +/// which every project using that cache shares. Agent-mode apply in one +/// project must refuse the transitive `is-number` there (not skip it as not +/// installed, and not write through the link into the other project). +#[tokio::test] +#[serial] +async fn bun_global_store_transitive_dep_apply_is_refused() { + if !has("bun") { + println!("SKIP: bun not on PATH"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("bun-cache"); + for project in ["a", "b"] { + let dir = tmp.path().join(project); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!( + r#"{{ "name": "bun-gs-{project}", "version": "0.0.0", "dependencies": {{ "is-odd": "3.0.1" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + dir.join("bunfig.toml"), + "[install]\nlinker = \"isolated\"\nglobalStore = true\n", + ) + .unwrap(); + let out = pm_command("bun", &["npm_config_", "BUN_"]) + .args(["install", "--no-progress"]) + .current_dir(&dir) + .env("BUN_INSTALL_CACHE_DIR", &cache) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("bun install"); + if !out.status.success() { + println!( + "SKIP: bun install failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + let project = tmp.path().join("a"); + let entry = project.join("node_modules/.bun/is-number@6.0.0"); + if !std::fs::symlink_metadata(&entry).is_ok_and(|m| m.file_type().is_symlink()) { + println!("SKIP: this bun has no global store (Bun < 1.3.14)"); + return; + } + let other = tmp + .path() + .join("b/node_modules/.bun/is-number@6.0.0/node_modules/is-number/index.js"); + assert_eq!( + std::fs::canonicalize(&other).unwrap(), + std::fs::canonicalize(entry.join("node_modules/is-number/index.js")).unwrap(), + "premise: both projects load is-number from the shared store" + ); + + let index = entry.join("node_modules/is-number/index.js"); + let original = std::fs::read(&index).expect("read is-number/index.js"); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-BUN-GLOBAL-STORE-MARKER\n"); + let after_hash = git_sha256(&patched); + let socket = project.join(".socket"); + write_manifest( + &socket, + "pkg:npm/is-number@6.0.0", + &before_hash, + &after_hash, + ); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(&project)).await; + assert_ne!(code, 0, "apply must refuse the shared store copy"); + assert_eq!( + std::fs::read(&other).unwrap(), + original, + "the other project's copy must stay untouched" + ); +} + /// #373: Deno's isolated `nodeModulesDir` keeps a transitive npm package /// only at `node_modules/.deno/@/node_modules/` /// (beside `.deno/.deno.lock` and the `.deno/node_modules` hoist dir). diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 15eddcf7b..b59767215 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -544,6 +544,20 @@ fn decode_bun_store_entry_name(entry_name: &str) -> Option<(String, String)> { decode_pnpm_store_entry_name(entry_name).filter(|(_, version)| !version.contains('+')) } +/// Whether the `.bun` entry `entry_name` of `store` is a link into Bun's +/// global store (`[install] globalStore`, Bun >= 1.3.14, #635): every +/// entry is then a link to `/links/-`, a dir +/// shared by every project on the machine. The entry is still this +/// project's installed copy (its transitive dependencies live nowhere +/// else), so it is walked: VEX must see its bytes, and agent apply and +/// rollback refuse it as shared (see [`crate::patch::shared_store`]) +/// instead of reporting it as not installed. Other links are skipped. +fn is_bun_global_store_link_sync(store: &Path, entry_name: &str) -> bool { + std::fs::canonicalize(store.join(entry_name)).is_ok_and(|real| { + real.is_dir() && crate::patch::shared_store::is_bun_global_store_entry(&real, entry_name) + }) +} + /// The `node_modules` child that is npm's `install-strategy=linked` store, /// also written by Yarn 4's pnpm linker (see /// [`store_entry_own_package_sync`]). @@ -2580,7 +2594,12 @@ impl NpmCrawler { .into_iter() .filter(|entry| { !(entry.name_str.starts_with('.') || entry.name_str == "node_modules") - && entry.file_type.is_some_and(|ft| ft.is_dir()) + && entry.file_type.is_some_and(|ft| { + ft.is_dir() + || (ft.is_symlink() + && layout == StoreLayout::Bun + && is_bun_global_store_link_sync(store_path, &entry.name_str)) + }) }) .collect(); @@ -5637,6 +5656,65 @@ mod tests { .await; } + /// #635: with Bun's global store (`[install] globalStore`, Bun >= + /// 1.3.14) every `.bun/` is a link to + /// `/links/-`, shared across projects. The project's + /// transitive packages live only there, so the walks follow those links + /// (reporting each copy under `.bun`, where apply then refuses it as + /// shared), and still skip a `.bun` link to anything else. + #[tokio::test] + async fn test_bun_global_store_transitive_packages_are_found() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + let links = tmp.join("bun-cache").join("links"); + let link_entry = |entry: &str, name: &str, version: &str| { + let shared = links.join(format!("{entry}-6a490709ba3c5c8f")); + write_pkg(&shared.join("node_modules").join(name), name, version); + link_dir(&shared, &store.join(entry)); + store.join(entry).join("node_modules").join(name) + }; + + let odd = link_entry("is-odd@3.0.1", "is-odd", "3.0.1"); + let number = link_entry("is-number@6.0.0", "is-number", "6.0.0"); + let number_twin = link_entry("is-number@6.0.0+3c4e1d2a", "is-number", "6.0.0"); + let frame = link_entry("@babel+code-frame@7.0.0", "@babel/code-frame", "7.0.0"); + link_dir(&number, &odd.parent().unwrap().join("is-number")); + link_dir(&odd, &nm.join("is-odd")); + // A `.bun` link that is not into a global store entry. + let elsewhere = tmp.join("elsewhere"); + write_pkg( + &elsewhere.join("node_modules/left-pad"), + "left-pad", + "1.3.0", + ); + link_dir(&elsewhere, &store.join("left-pad@1.3.0")); + + assert_store_copies_found( + &root, + &[ + "pkg:npm/@babel/code-frame@7.0.0", + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-odd@3.0.1", + ], + &[ + ( + "pkg:npm/is-number@6.0.0", + vec![number.clone(), number_twin.clone()], + ), + ("pkg:npm/@babel/code-frame@7.0.0", vec![frame.clone()]), + ("pkg:npm/is-odd@3.0.1", vec![nm.join("is-odd")]), + ("pkg:npm/left-pad@1.3.0", vec![]), + ], + &number, + vec![number_twin.clone()], + ) + .await; + } + /// #373: Deno's isolated `nodeModulesDir` keeps every npm package in /// `node_modules/.deno/@[_]/node_modules/` /// (scoped `@scope+leaf@…`), beside `.deno/.deno.lock` and the diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index b0e6d0ef6..1bc578dad 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -7,7 +7,7 @@ //! module cache), but not a package *directory* that is itself a symlink //! into a store every project on the machine links to: the rename then lands //! inside the shared directory, patching (or, on rollback, unpatching) every -//! other project that uses it. Two package managers install that way: +//! other project that uses it. Three package managers install that way: //! //! * **PDM's package cache** (`install.cache = true` with //! `install.cache_method = symlink`, PDM 2.0–2.12): `site-packages/` @@ -18,6 +18,11 @@ //! `node_modules/` is a symlink (a junction on Windows) into //! `/v/links/…/node_modules/`, beside the store's //! `files/` content directory. +//! * **Bun's global store** (`[install] globalStore = true` in +//! `bunfig.toml`, or `BUN_INSTALL_GLOBAL_STORE=1`, Bun >= 1.3.14, isolated +//! linker): each `node_modules/.bun/` is a symlink into +//! `/links/-/node_modules/` in Bun's install +//! cache. //! //! Detection is positive and marker-based, on the package directory's real //! path: a per-project store reached through a symlink (pnpm's @@ -53,6 +58,8 @@ pub enum SharedStoreKind { PdmPackageCache, /// `/v/links`, pnpm's global virtual store. PnpmGlobalVirtualStore, + /// `/links/-`, Bun's global store. + BunGlobalStore, /// A `node_modules` entry linked to first-party source outside every /// `node_modules` tree (a workspace member, a `file:` / `link:` /// directory dependency, an `npm link` target). @@ -81,6 +88,11 @@ impl SharedStore { "pnpm's global virtual store (enableGlobalVirtualStore)", "set enableGlobalVirtualStore to false and reinstall", ), + SharedStoreKind::BunGlobalStore => ( + "Bun's global store (install.globalStore / BUN_INSTALL_GLOBAL_STORE)", + "set `globalStore = false` under `[install]` in bunfig.toml (and unset \ + BUN_INSTALL_GLOBAL_STORE), then reinstall", + ), SharedStoreKind::LinkedSource => { return format!( "Refusing to {action} {path}: node_modules links to it, but it is \ @@ -166,6 +178,14 @@ fn shared_store_of_blocking(pkg_path: &Path) -> Option { }); } + // Bun: /links/-/node_modules//…. + if name == "links" && real.strip_prefix(dir).is_ok_and(is_bun_global_store_path) { + return Some(SharedStore { + kind: SharedStoreKind::BunGlobalStore, + real_path: real.clone(), + }); + } + // PDM: /packages//lib/…, the entry carrying its // `referrers` registry. if name == "lib" @@ -324,6 +344,59 @@ pub(crate) fn is_pnpm_global_virtual_store_dir(dir: &Path) -> bool { && parent.is_some_and(|p| p.join("files").is_dir()) } +/// Whether `rest`, a real path below a `links` dir, is a package inside an +/// entry of Bun's global store: `-/node_modules//…`, +/// where `` is the `.bun` entry name the project links it under +/// (`@`, a scoped name's `/` written `+`, or `@` and a +/// mangled tarball URL), `` is hex, and `` is the package the +/// entry name starts with. A bundled dependency nested inside the package +/// is in the same entry, so it is matched too. +fn is_bun_global_store_path(rest: &Path) -> bool { + let mut parts = rest.components().map(|c| c.as_os_str().to_str()); + let (Some(Some(entry)), Some(Some("node_modules")), Some(Some(first))) = + (parts.next(), parts.next(), parts.next()) + else { + return false; + }; + if !entry + .rsplit_once('-') + .is_some_and(|(_, hash)| is_bun_store_hash(hash)) + { + return false; + } + let name = if first.starts_with('@') { + let Some(Some(leaf)) = parts.next() else { + return false; + }; + format!("{first}+{leaf}") + } else { + first.to_string() + }; + entry + .strip_prefix(&name) + .is_some_and(|tail| tail.starts_with('@')) +} + +/// Whether `real`, a real (canonical) directory, is the Bun global store +/// entry a `node_modules/.bun/` link points to: +/// `/links/-`. +pub(crate) fn is_bun_global_store_entry(real: &Path, entry_name: &str) -> bool { + real.parent() + .and_then(Path::file_name) + .is_some_and(|n| n == "links") + && real + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_prefix(entry_name)) + .and_then(|tail| tail.strip_prefix('-')) + .is_some_and(is_bun_store_hash) +} + +/// The hex hash Bun appends to a global store entry's name. +fn is_bun_store_hash(hash: &str) -> bool { + (1..=16).contains(&hash.len()) && hash.bytes().all(|b| b.is_ascii_hexdigit()) +} + /// `v3`, `v10`, `v11`, …: the layout-version directory of a pnpm store. fn is_pnpm_store_version_dir(name: &str) -> bool { name.strip_prefix('v') @@ -351,6 +424,22 @@ pub(crate) mod test_support { pkg } + /// `/bun-cache/links/-/node_modules/`, beside + /// the cache's own `@@@@1` extraction, for `name` + /// (`left-pad`, or scoped `@scope/leaf`) at `version`. + pub(crate) fn make_bun_global_store_entry(root: &Path, name: &str, version: &str) -> PathBuf { + let cache = root.join("bun-cache"); + let entry = format!("{}@{version}", name.replace('/', "+")); + std::fs::create_dir_all(cache.join(format!("{entry}@@@1"))).unwrap(); + let pkg = cache + .join("links") + .join(format!("{entry}-6a490709ba3c5c8f")) + .join("node_modules") + .join(name); + std::fs::create_dir_all(&pkg).unwrap(); + pkg + } + /// `/pdm-cache/packages/urllib3-1.26.18-py2.py3-none-any/{referrers,lib/urllib3}`. pub(crate) fn make_pdm_cache_entry(root: &Path) -> PathBuf { let entry = root @@ -460,6 +549,84 @@ mod tests { assert_eq!(shared_store_of(&nm.join("is-odd")).await, None); } + /// #635: with Bun's global store each `node_modules/.bun/` is a + /// link into `/links/-`, shared by every project on + /// the machine. A package reached through that link (the importer's + /// `node_modules/` or the `.bun` entry itself), scoped or not, a + /// file below it, and a bundled dependency inside it are all refused. + #[cfg(unix)] + #[tokio::test] + async fn bun_global_store_is_shared() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let nm = dir.path().join("proj").join("node_modules"); + let bun = nm.join(".bun"); + std::fs::create_dir_all(&bun).unwrap(); + std::fs::create_dir_all(nm.join("@isaacs")).unwrap(); + for (name, link) in [ + ("left-pad", nm.join("left-pad")), + ( + "@isaacs/string-locale-compare", + nm.join("@isaacs/string-locale-compare"), + ), + ] { + let pkg = make_bun_global_store_entry(dir.path(), name, "1.3.0"); + let entry = pkg + .ancestors() + .find(|a| a.parent().and_then(Path::file_name) == Some("links".as_ref())) + .unwrap(); + let bun_entry = bun.join(format!("{}@1.3.0", name.replace('/', "+"))); + symlink(entry, &bun_entry).unwrap(); + let via_bun = bun_entry.join("node_modules").join(name); + symlink(&via_bun, &link).unwrap(); + std::fs::create_dir_all(pkg.join("node_modules").join("bundled")).unwrap(); + for path in [ + link.clone(), + via_bun.clone(), + pkg.join("node_modules/bundled"), + ] { + let got = shared_store_of(&path).await.expect("shared"); + assert_eq!( + got.kind, + SharedStoreKind::BunGlobalStore, + "{}", + path.display() + ); + } + let got = shared_store_of_patch_dirs(&link, ["lib/new/a.js"]).await; + assert_eq!(got.map(|s| s.kind), Some(SharedStoreKind::BunGlobalStore)); + } + } + + /// Bun's per-project isolated store (`node_modules/.bun/` a real + /// dir), and `links` dirs that do not hold a store entry's package, are + /// not shared. + #[tokio::test] + async fn bun_per_project_store_is_not_shared() { + let dir = tempfile::tempdir().unwrap(); + let private = dir + .path() + .join("node_modules/.bun/left-pad@1.3.0/node_modules/left-pad"); + std::fs::create_dir_all(&private).unwrap(); + assert_eq!(shared_store_of(&private).await, None); + for not_entry in [ + // No hex hash after the entry name. + "links/left-pad@1.3.0/node_modules/left-pad", + "links/left-pad@1.3.0-xyz/node_modules/left-pad", + // The package is not the one the entry names. + "links/is-odd@3.0.1-6a490709ba3c5c8f/node_modules/left-pad", + "links/left-pad-6a490709ba3c5c8f/node_modules/left-pad", + // Not under the entry's `node_modules`. + "links/left-pad@1.3.0-6a490709ba3c5c8f/left-pad", + // A package that is itself named `links`. + "node_modules/links/lib", + ] { + let path = dir.path().join("x").join(not_entry); + std::fs::create_dir_all(&path).unwrap(); + assert_eq!(shared_store_of(&path).await, None, "{not_entry}"); + } + } + /// A PyPI patch is rooted at `site-packages` (keys `/`), so /// the directory link into PDM's cache sits below the root and is found /// through the keys, including a key under a subdir that does not exist @@ -763,6 +930,13 @@ mod tests { real_path: PathBuf::from("/s/v10/links/x"), }; assert!(s.refusal("roll back").contains("enableGlobalVirtualStore")); + let s = SharedStore { + kind: SharedStoreKind::BunGlobalStore, + real_path: PathBuf::from("/c/links/x@1.0.0-abc/node_modules/x"), + }; + let msg = s.refusal("patch"); + assert!(msg.contains(SHARED_STORE_REFUSAL_MARKER), "{msg}"); + assert!(msg.contains("globalStore = false"), "{msg}"); let s = SharedStore { kind: SharedStoreKind::LinkedSource, real_path: PathBuf::from("/ws/packages/left-pad"), diff --git a/docs/ecosystems.md b/docs/ecosystems.md index fa2b049b1..3959ebbdb 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -205,12 +205,19 @@ links on to other entries. A workspace member's `node_modules` has no `.modules.yaml` of its own (pnpm writes it only at the workspace root), so the root's record is used, and the member's own links seed the walk. Agent-mode `apply` and `rollback` then fail on those copies, direct and transitive alike, instead of writing through -them, and never report a transitive one as "not installed". PDM's symlink install +them, and never report a transitive one as "not installed". Bun's global +store (`[install] globalStore = true` in `bunfig.toml`, or +`BUN_INSTALL_GLOBAL_STORE=1`, Bun 1.3.14 and later) is handled the same +way: each `node_modules/.bun/` is then a link into +`/links/-` in Bun's install cache, and those linked +entries are walked (so `vex` checks their bytes) but refused by agent-mode +`apply` and `rollback`. PDM's symlink install cache gets the same treatment: with `install.cache` and `cache_method = symlink` (PDM 2.0–2.12), `site-packages/` links into `/packages//lib`, and that package is refused too. The error names the store and how to get a private copy (disable the global virtual -store, or `pdm config install.cache_method hardlink`, then reinstall), or +store or Bun's `globalStore`, or `pdm config install.cache_method +hardlink`, then reinstall), or use hosted or vendored mode. Agent-mode `apply` and `rollback` also fail, dry run included, on a From 2c9bbf0667e6f7a5a159b12c30332a955fc6e986 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:48:40 -0400 Subject: [PATCH 08/55] Reword apply's Bun note for a global store project (#635) Human-mode apply printed "bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." for every Bun project. With Bun's global store (install.globalStore / BUN_INSTALL_GLOBAL_STORE, Bun >= 1.3.14) the installed package dirs are the cache's shared /links entries, and apply now refuses them, so the note said the opposite of what happens. Add crawlers::bun_uses_global_store, which reads the installed tree (a node_modules/.bun entry linking into /links/-) rather than bunfig or the env, and print a note naming the shared store and the globalStore = false remedy in that case. Other Bun projects keep the old note. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/apply.rs | 22 ++++++++++--- .../tests/apply/bun_global_store.rs | 31 +++++++++++++++++++ crates/socket-patch-core/src/crawlers/mod.rs | 2 +- .../src/crawlers/npm_crawler.rs | 20 ++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index d3a23819f..e043ccc3f 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2,7 +2,9 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler}; +use socket_patch_core::crawlers::{ + bun_uses_global_store, detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, +}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ @@ -1121,9 +1123,21 @@ pub(crate) async fn run_locked( } NpmPkgManager::Bun => { if !args.common.json && !args.common.silent { - eprintln!( - "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." - ); + if bun_uses_global_store(&args.common.cwd) { + // #635: the installed package dirs ARE the shared + // store (/links/...), so copy-on-write cannot + // isolate them; core refuses each such package. + eprintln!( + "Note: bun global store detected (install.globalStore). Packages linked \ + from the shared Bun cache are used by other projects and will not be \ + patched; set `globalStore = false` under `[install]` in bunfig.toml \ + (and unset BUN_INSTALL_GLOBAL_STORE), then reinstall." + ); + } else { + eprintln!( + "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." + ); + } } // Same shape as pnpm: bun hard-links from its global // install cache by default. The rename-over write handles the diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs index a3133ff7a..2f1ffa26e 100644 --- a/crates/socket-patch-cli/tests/apply/bun_global_store.rs +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -194,3 +194,34 @@ fn rollback_refuses_bun_global_store_packages() { assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), AFTER); assert_eq!(std::fs::read(number.join("index.js")).unwrap(), AFTER); } + +/// #635: the human-mode Bun note must not promise that copy-on-write +/// keeps the install cache untouched when the installed packages ARE the +/// cache's shared `links` dirs; it says they are refused and how to fix. +#[test] +fn apply_note_names_the_bun_global_store() { + let tmp = tempfile::tempdir().unwrap(); + let (proj, left_pad, _) = stage(tmp.path(), BEFORE); + std::fs::write(proj.join("bun.lock"), "{}\n").unwrap(); + write_manifest(&proj, &["pkg:npm/left-pad@1.3.0"]); + + let (code, _stdout, stderr) = run_with_env( + &proj, + &["apply", "--offline"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_ne!( + code, 0, + "the shared-store refusal fails the apply; {stderr}" + ); + assert!( + stderr.contains("Note: bun global store detected") + && stderr.contains("globalStore = false"), + "{stderr}" + ); + assert!( + !stderr.contains("will keep ~/.bun/install/cache/ untouched"), + "{stderr}" + ); + assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), BEFORE); +} diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index e85731c8d..48655ca17 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -28,7 +28,7 @@ pub use composer_crawler::ComposerCrawler; pub use deno_crawler::DenoCrawler; pub use go_crawler::GoCrawler; pub use maven_crawler::MavenCrawler; -pub use npm_crawler::NpmCrawler; +pub use npm_crawler::{bun_uses_global_store, NpmCrawler}; pub use nuget_crawler::NuGetCrawler; pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager}; pub use python_crawler::PythonCrawler; diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index b59767215..56ba4d757 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -558,6 +558,26 @@ fn is_bun_global_store_link_sync(store: &Path, entry_name: &str) -> bool { }) } +/// Whether `project_root` was installed with Bun's global store (#635): +/// some `node_modules/.bun/` is a link into +/// `/links/-`. Read from the installed tree rather +/// than bunfig.toml or `BUN_INSTALL_GLOBAL_STORE`, which may not match +/// the layout the last install actually wrote. Stops at the first such +/// link; only links are resolved. +pub fn bun_uses_global_store(project_root: &Path) -> bool { + let store = project_root.join("node_modules").join(".bun"); + let Ok(entries) = std::fs::read_dir(&store) else { + return false; + }; + entries.flatten().any(|entry| { + entry.file_type().is_ok_and(|ft| ft.is_symlink()) + && entry + .file_name() + .to_str() + .is_some_and(|name| is_bun_global_store_link_sync(&store, name)) + }) +} + /// The `node_modules` child that is npm's `install-strategy=linked` store, /// also written by Yarn 4's pnpm linker (see /// [`store_entry_own_package_sync`]). From a3dc8206d20cb7122f841ac73c616b8f0f828f96 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:23:41 -0400 Subject: [PATCH 09/55] Revert vendored bun.lockb after Bun migrates it to bun.lock (#784) Vendored mode records bun.lockb wiring as binary package snapshots. Bun's own `bun install --save-text-lockfile` deletes bun.lockb and carries the vendored tuples into bun.lock, but every unwind path still treated the entry as binary-only: - bun_binary::revert failed hard on the missing bun.lockb, so `vendor --revert`, `rollback` and the hosted takeover could not unwind the vendored wiring. - carry_forward_wiring only carried an original across a matching surface, so a superseding re-vendor on the migrated lock (which records no original for our own stale tuple) lost the registry original, and revert then left the project patched while reporting success. When bun.lockb is gone but bun.lock exists, revert now restores each recorded binary package in bun.lock: every tuple still pointing into the entry's vendor artifact is rewritten to the registry 4-tuple Bun writes for the pristine binary record (key, indent, dependency object and comma kept verbatim; an empty registry slot under https://registry.npmjs.org and the tarball URL otherwise, as Bun's text writer does). Records whose originals disagree are left alone as drift. A re-vendor's bun.lock record whose predecessor only has bun.lockb records gets that registry tuple as its pre-vendor original. Real-Bun fixtures (1.2.23 lockb migrated by 1.2.23 and 1.4.2, pristine and vendored) pin the exact restored bytes; a Bun-gated e2e covers vendor --revert, rollback and the hosted takeover, and joins the 1.4.2 CI leg. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 +- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 126 +++++- .../src/vendor/bun_binary.rs | 397 ++++++++++++++++-- crates/socket-patch-core/src/vendor/state.rs | 21 +- .../bun-lockb/1.2.23-migrated/README.md | 14 + .../1.2.23-migrated/pristine-1.2.23.lock | 17 + .../1.2.23-migrated/pristine-1.4.2.lock | 18 + .../1.2.23-migrated/vendored-1.2.23.lock | 17 + .../1.2.23-migrated/vendored-1.4.2.lock | 18 + docs/testing/bun-compatibility.md | 9 + 10 files changed, 613 insertions(+), 29 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b14d8d4..92151a525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1065,8 +1065,9 @@ jobs: - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.0.36', test_filter: --include-ignored} - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.1.45', test_filter: --include-ignored} # Bun >= 1.4 migrating a hosted workspace bun.lockb to bun.lock - # (#803): the only binary-lock test whose reader must be 1.4+. - - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored workspace_text_migration_heals_on_rerun} + # (#803; its reader must be 1.4+) and a vendored one, then + # reverting it (#784; skipped by the < 1.2 readers above). + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration} # Real-vlt capstones (DESIGN §8.4): wiremock patch service and a local # npm registry fed from npmjs, driven by the pinned vlt release # (`node vlt.js`, installed below from a sha512-checked `npm pack`). diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 413bb91a6..023e4cb44 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -1078,6 +1078,127 @@ async fn native_binary_alias_and_transitive() { } } +/// #784: after Bun migrates a vendored `bun.lockb` to `bun.lock` +/// (`bun install --save-text-lockfile`), `vendor --revert` and `rollback` +/// must restore the registry tuple Bun writes when it migrates the pristine +/// binary lock, instead of failing on the missing `bun.lockb`, and a hosted +/// takeover must replace the vendored wiring; a fresh frozen install of the +/// result gets the original (or, hosted, the patched) bytes. Needs a Bun >= 1.2 +/// reader (the text lock releases the vendored text path parses). Not named +/// `native_binary_*`, like the #803 test: it runs on the 1.4.2 CI leg. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn vendored_text_migration_reverts_to_registry() { + for unwind in [ + &["vendor", "--revert"][..], + &["rollback", "--yes"][..], + &["scan"][..], + ] { + let Some(fixture) = Fixture::new("direct") else { + return; + }; + let raw = String::from_utf8_lossy( + &command(&fixture.reader, &fixture.project) + .arg("--version") + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let major_minor: Vec = raw + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if major_minor.as_slice() < [1, 2].as_slice() { + eprintln!("SKIP vendored text migration: Bun {raw} < 1.2"); + return; + } + let migrate = |dir: &Path, label: &str| { + std::fs::remove_file(dir.join("bunfig.toml")).unwrap(); + let _ = std::fs::remove_dir_all(dir.join("node_modules")); + let output = command(&fixture.reader, dir) + .args(["install", "--save-text-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join(format!("{label}-cache")), + ) + .env( + "BUN_INSTALL", + fixture.temp.path().join(format!("{label}-home")), + ) + .output() + .unwrap(); + require_success(output, &format!("{label}: bun.lockb -> bun.lock migration")); + assert!(!dir.join("bun.lockb").exists(), "{label}"); + std::fs::read_to_string(dir.join("bun.lock")).unwrap() + }; + // What Bun writes for the pristine binary lock. + let pristine_dir = fixture.temp.path().join("pristine-migration"); + std::fs::create_dir_all(&pristine_dir).unwrap(); + for file in ["package.json", "bun.lockb", "bunfig.toml"] { + std::fs::copy(fixture.project.join(file), pristine_dir.join(file)).unwrap(); + } + let pristine = migrate(&pristine_dir, "pristine"); + + let server = MockServer::start().await; + mock_api(&server, &fixture, "minimist").await; + let project = &fixture.project; + let vendored = scan(project, &server, "vendored", &[]); + assert_eq!(vendored["vendor"]["summary"]["applied"], 1, "{vendored}"); + let migrated = migrate(project, "vendored"); + assert!( + migrated.contains(&format!("minimist@.socket/vendor/npm/{UUID}/")), + "the migration carries the vendored tuple:\n{migrated}" + ); + + let result = if unwind == ["scan"] { + // The hosted takeover reverts the vendored wiring first. + let hosted = scan(project, &server, "hosted", &[]); + assert_eq!(hosted["redirect"]["redirected"], 1, "{hosted}"); + hosted + } else { + cli(project, unwind) + }; + assert_eq!(result["status"], "success", "{unwind:?}: {result}"); + assert!(!project.join(".socket/vendor").exists(), "{unwind:?}"); + let expected = if unwind == ["scan"] { + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!(lock.contains("/patch/npm/minimist/"), "{lock}"); + &fixture.patched + } else { + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + pristine, + "{unwind:?} restores the registry tuple" + ); + &fixture.original + }; + + let checkout = fixture.temp.path().join("reverted-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + for file in ["package.json", "bun.lock"] { + std::fs::copy(project.join(file), checkout.join(file)).unwrap(); + } + let output = command(&fixture.reader, &checkout) + .args(["install", "--frozen-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join("revert-cache"), + ) + .env("BUN_INSTALL", fixture.temp.path().join("revert-home")) + .output() + .unwrap(); + require_success(output, "frozen install of the reverted bun.lock"); + assert_eq!( + std::fs::read(installed_target(&checkout).join("index.js")).unwrap(), + *expected, + "{unwind:?}: a fresh frozen install" + ); + } +} + /// #803: Bun 1.4 migrates a hosted workspace `bun.lockb` to `bun.lock` /// with the member path the binary normalization wrote as the root's /// `consumer` literal, so a frozen install of the migrated lock fails and @@ -1156,7 +1277,10 @@ async fn workspace_text_migration_heals_on_rerun() { } let output = command(&fixture.reader, &checkout) .args(["install", "--frozen-lockfile", "--ignore-scripts"]) - .env("BUN_INSTALL_CACHE_DIR", fixture.temp.path().join("text-cache")) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join("text-cache"), + ) .env("BUN_INSTALL", fixture.temp.path().join("text-home")) .output() .unwrap(); diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 76d59fc88..c0c1db8c9 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1,5 +1,8 @@ //! Native binary Bun vendoring. Package records are edited without re-resolving //! dependencies or requiring a Bun executable. +use super::bun_lock_text::{ + decode_json_string, packages_bounds, parse_entry_line, split_name_spec, +}; use super::bun_lockb::{BinaryPackage, BunLockb}; use super::common::{already_patched_result, refused}; use super::npm_common::{ @@ -14,12 +17,15 @@ use super::state::{ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; -use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_bytes_sync}; +use crate::utils::fs::{ + atomic_write_bytes_preserving_mode, read_regular_to_bytes_sync, read_regular_to_string, +}; use std::path::{Path, PathBuf}; pub(crate) const LOCK: &str = "bun.lockb"; pub(crate) const KIND: &str = "bun_lockb_package"; const MIRROR_KIND: &str = "bun_lockb_workspace_artifact"; +const TEXT_LOCK: &str = "bun.lock"; fn is_ours(package: &BinaryPackage, name: &str, leaf: &str) -> bool { package.name == name @@ -421,36 +427,67 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - ); } let mut outcome = RevertOutcome::ok(); - let original_bytes = match read_regular_to_bytes_sync(&root.join(LOCK)) { - Ok(v) => v, + let (mut lock, original_bytes) = match read_regular_to_bytes_sync(&root.join(LOCK)) { + Ok(bytes) => match BunLockb::parse(&bytes) { + Ok(v) => (RevertLock::Binary(v), bytes), + Err(e) => return RevertOutcome::failed(e), + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { if entry.wiring.is_empty() && opts.keep_artifact { return outcome; } - return RevertOutcome::failed(format!( - "{LOCK} is missing; cannot safely revert the binary lock" - )); + // `bun install --save-text-lockfile` deletes bun.lockb and + // carries the vendored tuples into bun.lock (#784): restore the + // recorded registry packages there instead. + match read_regular_to_string(&root.join(TEXT_LOCK)).await { + Ok(text) => ( + RevertLock::Migrated(text.split('\n').map(str::to_string).collect()), + text.into_bytes(), + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return RevertOutcome::failed(format!( + "{LOCK} is missing; cannot safely revert the binary lock" + )); + } + Err(e) => return RevertOutcome::failed(format!("cannot read {TEXT_LOCK}: {e}")), + } } Err(e) => return RevertOutcome::failed(format!("cannot read {LOCK}: {e}")), }; - let mut lock = match BunLockb::parse(&original_bytes) { - Ok(v) => v, - Err(e) => return RevertOutcome::failed(e), - }; if !entry.wiring.iter().any(|rec| rec.kind == KIND) && !opts.keep_artifact { - match lock.packages() { - Ok(packages) - if !packages.iter().any(|p| { - parse_vendor_path(&p.resolution).is_some_and(|p| p.uuid == entry.uuid) - }) => {} - _ => { - return RevertOutcome::failed(format!( - "{LOCK} still references {} but the original wiring is missing", - entry.uuid - )) - } + let referenced = match &lock { + RevertLock::Binary(lock) => lock.packages().map(|packages| { + packages + .iter() + .any(|p| parse_vendor_path(&p.resolution).is_some_and(|p| p.uuid == entry.uuid)) + }), + RevertLock::Migrated(lines) => Ok(lines + .iter() + .any(|line| migrated_vendor_uuid(line).as_deref() == Some(entry.uuid.as_str()))), + }; + if referenced != Ok(false) { + return RevertOutcome::failed(format!( + "{} still references {} but the original wiring is missing", + lock.file(), + entry.uuid + )); } } + // Several binary records can carry different registry originals, which + // the migration collapsed into the same text entries: never guess which + // one each entry had. + let originals: Vec<_> = entry + .wiring + .iter() + .filter(|rec| rec.kind == KIND) + .filter_map(|rec| rec.original.as_ref()) + .collect(); + let ambiguous_migration = matches!(lock, RevertLock::Migrated(_)) + && originals.windows(2).any(|pair| { + ["name", "version", "resolution", "integrity"] + .iter() + .any(|key| pair[0].get(key) != pair[1].get(key)) + }); let mut mirrors_to_remove = Vec::new(); for rec in entry.wiring.iter().rev() { if rec.kind == MIRROR_KIND { @@ -513,6 +550,27 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - .original .as_ref() .ok_or("missing pre-vendor binary package snapshot")?; + let lock = match &mut lock { + RevertLock::Binary(lock) => lock, + RevertLock::Migrated(_) if ambiguous_migration => { + return Err(format!( + "binary package #{id} has a different registry original than another \ + record of this package, and {TEXT_LOCK} no longer tells them apart" + )); + } + RevertLock::Migrated(lines) => { + if !restore_migrated(lines, original, &entry.uuid)? { + outcome.warnings.push(VendorWarning::new( + super::LOCK_ENTRY_REMOVED_CODE, + format!( + "{TEXT_LOCK} has no entry for binary package #{id}; nothing to \ + restore" + ), + )); + } + return Ok(()); + } + }; let new = rec .new .as_ref() @@ -540,13 +598,28 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - if opts.dry_run { return outcome; } - let bytes = lock.bytes(); + let bytes = match &lock { + RevertLock::Binary(lock) => lock.bytes(), + RevertLock::Migrated(lines) => lines.join("\n").into_bytes(), + }; if bytes != original_bytes { - if let Err(e) = atomic_write_bytes_preserving_mode(&root.join(LOCK), &bytes).await { - return RevertOutcome::failed(format!("cannot write {LOCK}: {e}")); + if let Err(e) = atomic_write_bytes_preserving_mode(&root.join(lock.file()), &bytes).await { + return RevertOutcome::failed(format!("cannot write {}: {e}", lock.file())); } } if !opts.keep_artifact { + if matches!(lock, RevertLock::Migrated(_)) + && super::npm_flavor::keep_artifact_while_lock_references_it( + &mut outcome, + root, + &[TEXT_LOCK], + &entry.uuid, + &dir, + ) + .await + { + return outcome; + } for mirror in mirrors_to_remove { if let Err(e) = tokio::fs::remove_file(&mirror).await { return RevertOutcome::failed(format!( @@ -573,6 +646,110 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - outcome } +/// The lock a binary entry's wiring is reverted in. +enum RevertLock { + Binary(BunLockb), + /// The `bun.lock` Bun migrated the binary lock to, as lines (#784). + Migrated(Vec), +} + +impl RevertLock { + fn file(&self) -> &'static str { + match self { + RevertLock::Binary(_) => LOCK, + RevertLock::Migrated(_) => TEXT_LOCK, + } + } +} + +/// The vendor uuid a `bun.lock` package line's local tarball tuple points +/// into, if it is one. +fn migrated_vendor_uuid(line: &str) -> Option { + let entry = parse_entry_line(line).ok()?; + if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { + return None; + } + let spec = decode_json_string(&entry.elems[0])?; + let vendored = parse_vendor_path(split_name_spec(&spec)?.1)?; + (vendored.eco == "npm").then_some(vendored.uuid) +} + +/// The registry tuple Bun writes in `bun.lock` for the binary `original` +/// snapshot, in place of `line`: that package's vendored tuple in a +/// `bun.lock` Bun migrated from the binary lock (#784). The key, indent, +/// dependency object and trailing comma stay verbatim, as Bun carried them +/// over. Bun leaves the registry slot empty for a tarball under its default +/// registry and writes the full URL for any other. +pub(crate) fn migrated_registry_line(line: &str, original: &serde_json::Value) -> Option { + let entry = parse_entry_line(line).ok()?; + if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { + return None; + } + let field = |key| original.get(key).and_then(serde_json::Value::as_str); + let (name, version) = (field("name")?, field("version")?); + let (resolution, integrity) = (field("resolution")?, field("integrity")?); + let spec = decode_json_string(&entry.elems[0])?; + let (spec_name, path) = split_name_spec(&spec)?; + let vendored = parse_vendor_path(path)?; + if spec_name != name || vendored.eco != "npm" || vendored.leaf != tgz_rel_leaf(name, version) { + return None; + } + let slot = if resolution.starts_with("https://registry.npmjs.org") { + "" + } else { + resolution + }; + let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); + Some(format!( + "{indent}{key}: [{spec}, {slot}, {deps}, {integrity}]{comma}{cr}", + indent = entry.indent, + key = entry.key_raw, + spec = json(&format!("{name}@{version}")), + slot = json(slot), + deps = entry.elems[1], + integrity = json(integrity), + comma = if entry.trailing_comma { "," } else { "" }, + cr = if line.ends_with('\r') { "\r" } else { "" }, + )) +} + +/// Put `original` back over every migrated `bun.lock` entry that still +/// resolves into `uuid`'s artifact. `false` when no entry does and none +/// already holds the restored tuple either (Bun dropped the package). +fn restore_migrated( + lines: &mut [String], + original: &serde_json::Value, + uuid: &str, +) -> Result { + let (start, end) = + packages_bounds(lines).ok_or(format!("{TEXT_LOCK} has no packages section"))?; + let restored = |line: &str| { + let entry = parse_entry_line(line).ok()?; + let integrity = original.get("integrity")?.as_str()?; + let spec = format!( + "{}@{}", + original.get("name")?.as_str()?, + original.get("version")?.as_str()? + ); + (entry.elems.len() == 4 + && decode_json_string(&entry.elems[0]) == Some(spec) + && decode_json_string(&entry.elems[3]).as_deref() == Some(integrity)) + .then_some(()) + }; + let mut found = false; + for line in &mut lines[start + 1..end] { + if migrated_vendor_uuid(line).as_deref() == Some(uuid) { + *line = migrated_registry_line(line, original).ok_or_else(|| { + format!("{TEXT_LOCK} entry for {uuid} no longer matches its binary original") + })?; + found = true; + } else if restored(line).is_some() { + found = true; + } + } + Ok(found) +} + /// Mirrors are confined to a workspace's own Socket artifact directory. /// Check every existing component so a workspace symlink cannot redirect a /// write or deletion outside the project. @@ -781,9 +958,13 @@ mod rebuild_tests { } pub(super) async fn flip_fixture() -> Fixture { + fixture_with(ORIGINAL) + } + + fn fixture_with(lock: &[u8]) -> Fixture { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - std::fs::write(root.join(LOCK), ORIGINAL).unwrap(); + std::fs::write(root.join(LOCK), lock).unwrap(); let installed = root.join("node_modules/minimist"); std::fs::create_dir_all(&installed).unwrap(); std::fs::write(installed.join("package.json"), PACKAGE).unwrap(); @@ -1067,4 +1248,170 @@ mod rebuild_tests { assert!(!root.join(&mirror.file).exists()); } } + + // ── bun.lockb migrated to bun.lock (#784) ───────────────────────────── + + const MIGRATED_LOCKB: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + /// `(migrating Bun, pristine bun.lock, vendored bun.lock)`: the 1.2.23 + /// fixture lock migrated by `bun install --save-text-lockfile` before + /// and after vendoring (see that fixture directory's README). + const MIGRATIONS: [(&str, &str, &str); 2] = [ + ( + "1.2.23", + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock"), + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock"), + ), + ( + "1.4.2", + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock"), + include_str!("../../tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock"), + ), + ]; + + /// Vendor the binary fixture, then migrate it as `bun` would: bun.lockb + /// is gone and bun.lock carries the vendored tuple. + async fn vendored_then_migrated(vendored: &str) -> (Fixture, VendorEntry) { + let fx = fixture_with(MIGRATED_LOCKB); + let (result, entry, _) = ts::expect_done(flip_run(&fx, None).await); + assert!(result.success, "{result:?}"); + let entry = entry.expect("vendoring rewires bun.lockb"); + ts::persist(fx.root(), PURL, entry.clone()).await; + let integrity = entry.wiring[0].new.as_ref().unwrap()["integrity"] + .as_str() + .unwrap(); + if vendored.matches("sha512-").count() == 2 { + assert!( + vendored.contains(integrity), + "the fixture was captured from this packing" + ); + } + std::fs::remove_file(fx.root().join(LOCK)).unwrap(); + std::fs::write(fx.root().join(TEXT_LOCK), vendored).unwrap(); + (fx, entry) + } + + /// After Bun migrates a vendored bun.lockb to bun.lock, revert restores + /// the registry tuple Bun itself writes for the pristine binary lock, + /// instead of failing on the missing bun.lockb. + #[tokio::test] + async fn revert_restores_registry_tuple_after_text_lock_migration() { + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, entry) = vendored_then_migrated(vendored).await; + let dry = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) + .await; + assert!(dry.success && dry.warnings.is_empty(), "{bun}: {dry:?}"); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + vendored + ); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + assert!(!fx.root().join(LOCK).exists()); + assert!(!fx.root().join(".socket/vendor/npm").exists(), "{bun}"); + } + } + + /// A superseding patch vendored on the migrated bun.lock rewrites our + /// own tuple, so it records no original itself: the ledger must carry + /// the binary record's registry original over to it, and revert must + /// then restore the pristine tuple. + #[tokio::test] + async fn superseding_vendor_after_migration_keeps_the_registry_original() { + const UUID2: &str = "22222222-2222-4222-8222-222222222222"; + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, _) = vendored_then_migrated(vendored).await; + let record = PatchRecord { + uuid: UUID2.to_string(), + ..fx.record.clone() + }; + let blobs = fx.root().join(".socket/blobs"); + let (result, entry, _) = ts::expect_done( + crate::vendor::test_support::vendor_bun( + PURL, + &fx.installed(), + fx.root(), + &record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{bun}: {result:?}"); + ts::persist(fx.root(), PURL, entry.expect("re-pinned")).await; + let state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries[PURL].clone(); + let minimist = pristine + .lines() + .find(|l| l.contains("\"minimist\": [")) + .unwrap(); + assert_eq!( + entry.wiring[0].original, + Some(serde_json::Value::String(minimist.to_string())), + "{bun}" + ); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + } + } + + /// Bun writes the full tarball URL for any registry but its default one, + /// and the rebuilt tuple keeps the vendored line's spelling. + #[test] + fn migrated_registry_line_spells_the_registry_slot_like_bun() { + let original = |resolution: &str| { + serde_json::json!({ + "name": "@s/p", "version": "1.0.0", "resolution": resolution, + "integrity": "sha512-AA==", + }) + }; + let line = format!( + " \"x/@s/p\": [\"@s/p@.socket/vendor/npm/{UUID}/@s/p-1.0.0.tgz\", {{ \"bin\": {{}} }}],\r" + ); + assert_eq!( + migrated_registry_line( + &line, + &original("https://registry.npmjs.org/@s/p/-/p-1.0.0.tgz") + ), + Some( + " \"x/@s/p\": [\"@s/p@1.0.0\", \"\", { \"bin\": {} }, \"sha512-AA==\"],\r" + .to_string() + ) + ); + assert_eq!( + migrated_registry_line(&line, &original("http://127.0.0.1:4873/@s/p/-/p-1.0.0.tgz")), + Some( + " \"x/@s/p\": [\"@s/p@1.0.0\", \"http://127.0.0.1:4873/@s/p/-/p-1.0.0.tgz\", { \"bin\": {} }, \"sha512-AA==\"],\r" + .to_string() + ) + ); + let other = serde_json::json!({ + "name": "@s/p", "version": "2.0.0", + "resolution": "https://registry.npmjs.org/@s/p/-/p-2.0.0.tgz", "integrity": "sha512-AA==", + }); + assert_eq!( + migrated_registry_line(&line, &other), + None, + "another version's tarball" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 39e43d955..8fbef9722 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -519,7 +519,9 @@ pub fn carry_forward_wiring(prev: &VendorEntry, entry: &mut VendorEntry) { .wiring .iter() .filter(|p| wiring_surface_matches(p, rec)); - if let Some(prev_rec) = candidates.next() { + if rec.kind == "bun_lock_package" && !prev.wiring.iter().any(|p| p.kind == rec.kind) { + rec.original = migrated_bun_original(prev, rec); + } else if let Some(prev_rec) = candidates.next() { // Multiple equal binary resolutions can have different // registry originals. Renumbered IDs cannot disambiguate // them, so do not attach a guessed restore payload. @@ -563,6 +565,23 @@ pub fn carry_forward_wiring(prev: &VendorEntry, entry: &mut VendorEntry) { } } +/// The pre-vendor original of a `bun.lock` record that re-pinned a tuple +/// Bun migrated from the binary lock (#784): the previous entry recorded it +/// as a `bun.lockb` package snapshot, rebuilt here as the registry tuple Bun +/// writes for it. `None` when the binary records disagree on it. +fn migrated_bun_original(prev: &VendorEntry, current: &WiringRecord) -> Option { + let line = current.new.as_ref()?.as_str()?; + let mut lines = prev + .wiring + .iter() + .filter(|p| p.kind == "bun_lockb_package") + .filter_map(|p| super::bun_binary::migrated_registry_line(line, p.original.as_ref()?)); + let first = lines.next()?; + lines + .all(|other| other == first) + .then_some(serde_json::Value::String(first)) +} + /// Binary IDs are offsets into Bun's package array and may change after an /// installer re-save. Match the predecessor's semantic resolution instead. fn wiring_surface_matches(previous: &WiringRecord, current: &WiringRecord) -> bool { diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md new file mode 100644 index 000000000..f0f8e4c18 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/README.md @@ -0,0 +1,14 @@ +# `bun.lockb` migrated to `bun.lock` + +The `1.2.23` fixture lock (`../1.2.23/bun.lockb`) migrated to the text lock +with `bun install --save-text-lockfile --ignore-scripts` (macOS arm64, empty +`BUN_INSTALL_CACHE_DIR`, `node_modules` removed first), by the Bun release +named in each file: + +- `pristine-.lock`: migrated straight from the fixture. +- `vendored-.lock`: migrated after socket-patch vendored + `minimist@1.2.2` into the binary lock with the `rebuild_tests` patch in + `src/vendor/bun_binary.rs` (uuid `11111111-1111-4111-8111-111111111111`). + 1.2.23 drops the local tarball's integrity; 1.4.2 keeps it. + +A vendored revert after the migration must write the pristine lock (#784). diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock new file mode 100644 index 000000000..79eb6053a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.2.23.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock new file mode 100644 index 000000000..d148dc057 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/pristine-1.4.2.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock new file mode 100644 index 000000000..4acf49428 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.2.23.lock @@ -0,0 +1,17 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@.socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz", {}], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock new file mode 100644 index 000000000..39d398ddf --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/vendored-1.4.2.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "binary-lock-fixture", + "dependencies": { + "is-number": "7.0.0", + "minimist": "1.2.2", + }, + }, + }, + "packages": { + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "minimist": ["minimist@.socket/vendor/npm/11111111-1111-4111-8111-111111111111/minimist-1.2.2.tgz", {}, "sha512-tJoY6Sh0/e+0aiqRNMYyJXEdNdp95czoj6rBpEerH5xRDOb3J889JCSXuyZuRm/KRNl0PHCP/7j+iiHAyeJAbQ=="], + } +} diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index b98a5cec4..9862236e1 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -234,6 +234,15 @@ runners) from the GitHub releases and verifies it against `SHASUMS256.txt`. Ever writes the bare path itself, are left alone. Covered by `e2e_bun_lockb::workspace_text_migration_heals_on_rerun` on the 1.4.2 leg. +- **Binary → text migration of a vendored lock.** The migration deletes + `bun.lockb` and carries the vendored tuples into `bun.lock`, while the + vendor state still records `bun.lockb` package snapshots. `vendor + --revert`, `rollback` and the hosted takeover then restore each recorded + registry package as the tuple Bun writes for it (an empty registry slot + under `https://registry.npmjs.org`, the tarball URL otherwise), and a + superseding re-vendor carries that tuple over as its pre-vendor original + (#784). Covered by `e2e_bun_lockb::vendored_text_migration_reverts_to_registry` + (skipped below Bun 1.2) on the 1.4.2 leg. - **Workspace-member local tarballs.** Bun 1.2.x–1.3.x resolve a local-tarball dependency declared by a workspace member relative to the member (`.socket/vendor/…` → ENOENT on `bun install`); 1.4.x resolve it From dbe21fb458c2c828d3cfd9d120f5c6bfa721b48a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:23:52 -0400 Subject: [PATCH 10/55] Check out migrated bun.lock fixtures byte-exact (#784) The migrated-lock revert tests compare restored bun.lock bytes against real Bun output; a CRLF checkout on Windows would break that comparison. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 6e2026463..2fc3fa8cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -47,3 +47,7 @@ crates/socket-patch-core/tests/fixtures/sbt/** -text # (sbt-compatibility.yml); a CRLF checkout breaks them ($'\r'). scripts/sbt-warm-seed.sh text eol=lf scripts/sbt-compat-matrix.sh text eol=lf + +# Real `bun install --save-text-lockfile` output: the migrated-lock revert +# tests compare restored bun.lock bytes against it exactly (#784). +crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/*.lock -text From b661a449f655ef2ad315b38e752f8c5ed8148cce Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:55:05 -0400 Subject: [PATCH 11/55] Revert same-uuid re-pins on a migrated bun.lock (#784) A vendored re-run on a bun.lock that Bun migrated from bun.lockb re-pins our own tuple at the same uuid whenever the committed artifact is missing (fresh clone, `repair` rebuild) or the tuple's digest differs. The new entry carries a `bun_lock_package` record (its original rebuilt from the binary snapshot), and the same-uuid wiring union in carry_forward_wiring also keeps the predecessor's `bun_lockb_package` records, since no surface matches across kinds. revert_bun_opts routes any entry with binary records to bun_binary::revert, which rejected the text record as "unexpected binary wiring file or kind": that counted as drift, so revert exited 0 with bun.lock still vendored and the artifact kept. In the migrated mode, bun_binary::revert now restores `bun_lock_package` records in bun.lock through the text revert's own per-record restore (which also converges silently when a binary record already restored the line). The binary and workspace-mirror records stay in the entry so mirror tarballs are still cleaned up. A regression test re-pins the same uuid on both migrated fixtures (1.2.23 digest-less, 1.4.2 with a changed digest) and checks the revert restores the pristine bun.lock with no warnings. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/bun_binary.rs | 77 +++++++++++++++++++ .../socket-patch-core/src/vendor/bun_lock.rs | 4 +- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index c0c1db8c9..e9243bd4f 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -538,6 +538,22 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - continue; } let restore = (|| { + // A same-uuid re-vendor on the migrated bun.lock re-pins our own + // tuple as a text record next to the binary records it carried + // forward (#784): restore it like the text revert does. + if let RevertLock::Migrated(lines) = &mut lock { + if rec.file == TEXT_LOCK && rec.kind == super::bun_lock::KIND_LOCK_PACKAGE { + let mut dirty = false; + super::bun_lock::revert_one_record( + lines, + rec, + &entry.uuid, + &mut dirty, + &mut outcome.warnings, + ); + return Ok(()); + } + } if rec.file != LOCK || rec.kind != KIND { return Err("unexpected binary wiring file or kind".to_string()); } @@ -1374,6 +1390,67 @@ mod rebuild_tests { } } + /// A same-uuid re-run on the migrated bun.lock (artifact missing, or the + /// tuple's digest changed) re-pins our own tuple as a text record, and + /// the ledger carries the binary records forward beside it. Revert must + /// restore the pristine tuple from that mixed entry, not report the text + /// record as drift and leave the project vendored. + #[tokio::test] + async fn same_uuid_repin_after_migration_reverts_the_mixed_entry() { + for (bun, pristine, vendored) in MIGRATIONS { + let (fx, first) = vendored_then_migrated(vendored).await; + std::fs::remove_dir_all(fx.root().join(".socket/vendor/npm")).unwrap(); + let integrity = first.wiring[0].new.as_ref().unwrap()["integrity"] + .as_str() + .unwrap() + .to_string(); + if vendored.contains(&integrity) { + // Force a re-pin of the digest-carrying tuple too. + let lock = vendored.replace(&integrity, "sha512-AAAA"); + std::fs::write(fx.root().join(TEXT_LOCK), lock).unwrap(); + } + let blobs = fx.root().join(".socket/blobs"); + let (result, entry, _) = ts::expect_done( + crate::vendor::test_support::vendor_bun( + PURL, + &fx.installed(), + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{bun}: {result:?}"); + ts::persist(fx.root(), PURL, entry.expect("re-pinned")).await; + let state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries[PURL].clone(); + let kinds: Vec<_> = entry.wiring.iter().map(|r| r.kind.as_str()).collect(); + assert!( + kinds.contains(&"bun_lock_package") && kinds.contains(&KIND), + "{bun}: {kinds:?}" + ); + let dry = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) + .await; + assert!(dry.success && dry.warnings.is_empty(), "{bun}: {dry:?}"); + let outcome = + super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) + .await; + assert!(outcome.success, "{bun}: {outcome:?}"); + assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + assert_eq!( + std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), + pristine, + "{bun}" + ); + assert!(!fx.root().join(".socket/vendor/npm").exists(), "{bun}"); + } + } + /// Bun writes the full tarball URL for any registry but its default one, /// and the rebuilt tuple keeps the vendored line's spelling. #[test] diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 9bf409394..771f818ad 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -82,7 +82,7 @@ pub async fn cleanup_binary_workspace_artifacts( /// The `WiringRecord.kind` this backend owns: key = the `packages` map key, /// original/new = the verbatim entry LINE. -const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; +pub(super) const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; /// Workspace gate: a `workspace:` packages entry in a lock whose /// `lockfileVersion` is below 2 refuses with `vendor_bun_workspace_unsupported`. @@ -980,7 +980,7 @@ pub(crate) async fn revert_bun_opts( outcome } -fn revert_one_record( +pub(super) fn revert_one_record( lines: &mut [String], rec: &WiringRecord, entry_uuid: &str, From c409cfb88102d7a85eb62a4bd4c0e2b75eacf7f8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:51:35 -0400 Subject: [PATCH 12/55] Fold late duplicate bun.lockb records into the vendored tarball (#861) After a workspace bun.lockb is vendored, a new dependent of the patched name@version (a member added later, or `bun add` in a member) makes Bun write a second, nested registry record of it, because the hoisted record is now a local tarball. The vendored re-run rewired every matching record to the same .socket/vendor tarball, leaving two package records with one resolution - a shape Bun's own writer never produces. On the isolated linker both map to one node_modules/.bun store directory, so cold frozen installs failed intermittently with EEXIST (reproduced on Bun 1.3.9 and 1.4.2: 3/8 fresh installs on 1.4.2), while scan/vendor reported success. The re-run now folds such duplicates into one kept record (the one at the target tarball, else ours, else the first), the way Bun's re-save would: their dependency edges resolve to the kept record, the duplicate rows leave every package column with later IDs (resolution buffer and meta.id) renumbered, and the hoisting trees are rewritten as Bun re-hoists them - an edge whose nearest same-name ancestor now holds the same package is deduplicated and an emptied tree dropped. Bun 1.3.x re-hoists a frozen binary lock and refuses one whose trees differ (Lockfile.eql), so merely re-pointing edges, or leaving an orphan record (which 1.4.2 refuses), is not enough. The buffers are re-laid with Bun's eight-byte data alignment and the metadata hash updated; the same workspace normalization as any record edit is applied. The tree rewrite is exact only where hoisting is predictable, so the merge is limited to records without dependencies of their own, no peer edge to them and no bundled edge in the lock; otherwise the records are rewired as before with a new vendor_bun_lockb_duplicate_records warning. Tests: real-Bun e2e (new member and `bun add` triggers, 6 cold frozen installs each, idempotent re-run, revert) on 1.3.9/1.3.14/1.4.2, run in CI on the 1.3.14 and 1.4.2 legs; hermetic codec and vendor tests over locks captured from Bun 1.3.9 and 1.4.2. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 8 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 170 +++++++- .../src/vendor/bun_binary.rs | 173 +++++++- .../socket-patch-core/src/vendor/bun_lockb.rs | 391 ++++++++++++++++++ .../tests/fixtures/bun-lockb/README.md | 5 + .../late-dependent/1.3.9-adder.lockb | Bin 0 -> 3128 bytes .../bun-lockb/late-dependent/1.3.9-late.lockb | Bin 0 -> 3096 bytes .../late-dependent/1.4.2-adder.lockb | Bin 0 -> 3104 bytes .../bun-lockb/late-dependent/1.4.2-late.lockb | Bin 0 -> 3072 bytes docs/testing/bun-compatibility.md | 22 +- 11 files changed, 761 insertions(+), 10 deletions(-) create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92151a525..edcc81f11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1066,8 +1066,12 @@ jobs: - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.1.45', test_filter: --include-ignored} # Bun >= 1.4 migrating a hosted workspace bun.lockb to bun.lock # (#803; its reader must be 1.4+) and a vendored one, then - # reverting it (#784; skipped by the < 1.2 readers above). - - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration} + # reverting it (#784; skipped by the < 1.2 readers above). Both + # 1.4.2 and 1.3.14 also run the isolated-linker vendored re-run + # after a late dependent (#861); 1.3 re-hoists a frozen binary lock + # and refuses one whose trees changed. + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration workspace_late_dependent} + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.3.14', test_filter: --include-ignored workspace_late_dependent} # Real-vlt capstones (DESIGN §8.4): wiremock patch service and a local # npm registry fed from npmjs, driven by the pinned vlt release # (`node vlt.js`, installed below from a sha512-checked `npm pack`). diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index ce834519d..61a1d7192 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -708,7 +708,7 @@ to **six flavors**. | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` redownloads on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` redownloads through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | -| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | +| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record dropped and the hoisting trees re-derived — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where the re-hoist is not exactly predictable (a record with dependencies of its own) both are rewired with `vendor_bun_lockb_duplicate_records`. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 023e4cb44..10f7581f5 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -321,7 +321,9 @@ impl Fixture { let dependencies = match shape { "alias" => json!({"alias":"npm:minimist@1.2.2", "is-number":"7.0.0"}), "transitive" => json!({"mkdirp":"0.5.3", "is-number":"7.0.0"}), - "workspace" => json!({"consumer":"workspace:*", "is-number":"7.0.0"}), + "workspace" | "workspace-adder" => { + json!({"consumer":"workspace:*", "is-number":"7.0.0"}) + } "workspace-nested" => { json!({"consumer":"workspace:*", "minimist":"1.2.8", "is-number":"7.0.0"}) } @@ -351,6 +353,14 @@ impl Fixture { ) .unwrap(); } + if shape == "workspace-adder" { + std::fs::create_dir_all(project.join("packages/adder")).unwrap(); + std::fs::write( + project.join("packages/adder/package.json"), + br#"{"name":"adder","version":"1.0.0","dependencies":{"is-number":"7.0.0"}}"#, + ) + .unwrap(); + } if shape == "extensions" { package["dependencies"]["consumer"] = json!("workspace:*"); package["dependencies"]["git-number"] = json!("github:jonschlinkert/is-number#7.0.0"); @@ -1304,6 +1314,164 @@ async fn workspace_text_migration_heals_on_rerun() { ); } +/// The `(name, resolution)` package lines of Bun's own yarn-style dump of +/// the project's `bun.lockb`. +fn dumped_records(fixture: &Fixture) -> Vec { + let output = command(&fixture.reader, &fixture.project) + .arg("bun.lockb") + .output() + .unwrap(); + let dump = + String::from_utf8_lossy(&require_success(output, "bun bun.lockb").stdout).into_owned(); + dump.lines() + .filter(|line| line.trim_start().starts_with("resolved ")) + .map(str::trim) + .map(str::to_string) + .collect() +} + +/// #861: after a vendored workspace `bun.lockb`, a new dependent of the +/// patched package (a member added later, or `bun add` in an existing +/// member) makes Bun write a second, nested REGISTRY record of the same +/// `name@version`, since the hoisted one is a local tarball now. The +/// vendored re-run must not rewire that record to the same tarball: two +/// records with one tarball resolution share one isolated store directory, +/// and frozen installs fail intermittently with `EEXIST`. It folds the +/// record into the existing tarball record instead, so every fresh frozen +/// install links the patched bytes into the new dependent too. The member +/// names hoist the late dependent after (`late`) and before (`adder`) the +/// vendored one. Isolated linker (Bun >= 1.3); not named `native_binary_*` +/// (the backtest matrix runs exactly those). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn workspace_late_dependent_rerun_shares_the_tarball_record() { + for (shape, member) in [("workspace", "late"), ("workspace-adder", "adder")] { + let Some(fixture) = Fixture::new(shape) else { + return; + }; + let raw = String::from_utf8_lossy( + &command(&fixture.reader, &fixture.project) + .arg("--version") + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let major_minor: Vec = raw + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if major_minor.as_slice() < [1, 3].as_slice() { + eprintln!("SKIP late workspace dependent: Bun {raw} has no isolated linker"); + return; + } + late_dependent_rerun(&fixture, member).await; + } +} + +async fn late_dependent_rerun(fixture: &Fixture, member: &str) { + let project = &fixture.project; + std::fs::write( + project.join("bunfig.toml"), + "[install]\nsaveTextLockfile = false\nlinker = \"isolated\"\n", + ) + .unwrap(); + let server = MockServer::start().await; + mock_api(&server, fixture, "minimist").await; + fixture.stage(); + let first = cli(project, &["vendor", "--offline"]); + assert_eq!( + first["summary"]["applied"], 1, + "{member}: first vendor: {first}" + ); + + let dir = project.join("packages").join(member); + let mut add = if dir.exists() { + let mut add = command(&fixture.reader, &dir); + add.args(["add", "minimist@1.2.2", "--ignore-scripts"]); + add + } else { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!( + r#"{{"name":"{member}","version":"1.0.0","dependencies":{{"minimist":"1.2.2"}}}}"# + ), + ) + .unwrap(); + let mut install = command(&fixture.reader, project); + install.args(["install", "--ignore-scripts"]); + install + }; + let output = add + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join("late-cache"), + ) + .env("BUN_INSTALL", fixture.temp.path().join("late-home")) + .output() + .unwrap(); + require_success(output, &format!("{member}: the late dependent")); + let before = dumped_records(fixture); + assert!( + before.iter().any( + |line| line.contains("minimist-1.2.2.tgz") && !line.contains(".socket/vendor/npm/") + ), + "{member}: Bun writes a nested registry record: {before:?}" + ); + + let rerun = cli(project, &["vendor", "--offline"]); + assert_eq!( + rerun["summary"]["applied"], 1, + "{member}: vendored re-run: {rerun}" + ); + let after = dumped_records(fixture); + assert_eq!( + after + .iter() + .filter(|line| line.contains("minimist-1.2.2.tgz")) + .collect::>(), + [&format!( + "resolved \".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz\"" + )], + "{member}: one record, the tarball: {after:?}" + ); + // EEXIST was intermittent (about half the cold frozen installs). + for attempt in 0..6 { + let checkout = fixture.frozen_install( + &format!("{member}-{attempt}"), + &fixture.patched, + &fixture.bystander, + false, + ); + assert_eq!( + std::fs::read( + checkout + .join("packages") + .join(member) + .join("node_modules/minimist/index.js") + ) + .unwrap(), + fixture.patched, + "{member} attempt {attempt}: the late dependent links the patched bytes" + ); + } + let again = cli(project, &["vendor", "--offline"]); + assert_eq!( + again["summary"]["skipped"], 1, + "{member}: idempotent: {again}" + ); + cli(project, &["vendor", "--revert"]); + fixture.frozen_install( + &format!("{member}-reverted"), + &fixture.original, + &fixture.bystander, + false, + ); +} + fn production_scoped_rollback() { const SECOND_PURL: &str = "pkg:npm/is-number@7.0.0"; for first in [PURL, SECOND_PURL] { diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index e9243bd4f..8c759a8b1 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -33,6 +33,72 @@ fn is_ours(package: &BinaryPackage, name: &str, leaf: &str) -> bool { && package.resolution.ends_with(&format!("/{leaf}")) } +/// A record vendoring `coords` rewires: the exact `name@version`, or one of +/// our own tarballs for it. +fn is_target(package: &BinaryPackage, coords: &NpmCoords, leaf: &str) -> bool { + (package.name == coords.name && package.version.as_deref() == Some(&coords.version)) + || is_ours(package, &coords.name, leaf) +} + +/// Bun's writer keeps one package record per resolution, but after a project +/// is vendored a new dependent of the package (a member added later, `bun +/// add` in a member) gets a second, nested registry record of the same +/// `name@version`, because the hoisted one is a local tarball now. Rewiring +/// it to the same tarball gives two records one isolated store directory, +/// and frozen installs then fail intermittently with `EEXIST` (#861). So +/// such records are folded into ONE kept record (the one already at +/// `target`, else one of ours, else the first), as Bun's own re-save +/// would. A record some bundled edge reaches is left to the rewrite: its +/// parent's tarball ships that copy. Where the lock's hoisting is not +/// exactly predictable ([`BunLockb::merge_packages`]) the records are all +/// rewritten as before, with a warning. Returns the records left to +/// rewrite, re-read after renumbering, and whether the lock changed. +fn merge_duplicates( + lock: &mut BunLockb, + matches: Vec, + target: &str, + coords: &NpmCoords, + leaf: &str, + warnings: &mut Vec, +) -> Result<(Vec, bool), String> { + let mut candidates: Vec<_> = matches.iter().filter(|p| !p.bundled).collect(); + candidates.sort_by_key(|p| { + ( + p.resolution != target, + !is_ours(p, &coords.name, leaf), + p.id, + ) + }); + let Some((kept, duplicates)) = candidates.split_first() else { + return Ok((matches, false)); + }; + if duplicates.is_empty() { + return Ok((matches, false)); + } + let duplicates: Vec = duplicates.iter().map(|p| p.id).collect(); + if !lock.merge_packages(kept.id, &duplicates)? { + warnings.push(VendorWarning::new( + "vendor_bun_lockb_duplicate_records", + format!( + "{LOCK} has {} records of {}@{} that cannot be folded into one, so each is \ + rewired to the same tarball; Bun's isolated linker can fail to install two \ + records with one tarball (EEXIST); the hoisted linker \ + (`[install] linker = \"hoisted\"` in bunfig.toml) installs it", + duplicates.len() + 1, + coords.name, + coords.version + ), + )); + return Ok((matches, false)); + } + let matches = lock + .packages()? + .into_iter() + .filter(|p| is_target(p, coords, leaf) && !p.bundled_only) + .collect(); + Ok((matches, true)) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn vendor( purl: &str, @@ -105,6 +171,19 @@ pub(crate) async fn vendor( }; }; let mut wiring = Vec::new(); + let (matches, merged) = match merge_duplicates( + &mut lock, + matches, + &staged.rel_tgz, + &coords, + &leaf, + &mut warnings, + ) { + Ok(v) => v, + Err(e) => { + return done_failure_unstage(purl, e, root, &coords.uuid_dir_rel, preexisted).await + } + }; for package in matches { if package.resolution == staged.rel_tgz && package.integrity.as_deref() == Some(&staged.packed.integrity) @@ -161,7 +240,7 @@ pub(crate) async fn vendor( .await } }; - let lock_changed = !wiring.is_empty(); + let lock_changed = merged || !wiring.is_empty(); let mut mirror_backups: Vec<(PathBuf, Option>)> = Vec::new(); for (workspace, rel) in &mirrors { let path = root.join(rel); @@ -335,10 +414,7 @@ pub(super) fn preflight_package( let (bundled_only, matches): (Vec<_>, Vec<_>) = project .packages .iter() - .filter(|p| { - (p.name == coords.name && p.version.as_deref() == Some(&coords.version)) - || is_ours(p, &coords.name, leaf) - }) + .filter(|p| is_target(p, coords, leaf)) .cloned() .partition(|p| p.bundled_only); let bundled: Vec<_> = matches @@ -1492,3 +1568,90 @@ mod rebuild_tests { ); } } + +#[cfg(test)] +mod duplicate_tests { + use super::*; + use crate::hash::git_sha256::compute_git_sha256_from_bytes; + use crate::vendor::test_support as ts; + + /// The uuid the fixtures were first vendored under. + const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; + const PURL: &str = "pkg:npm/minimist@1.2.2"; + const BEFORE: &[u8] = b"module.exports = 'original';\n"; + const AFTER: &[u8] = b"module.exports = 'patched';\n"; + + /// REGRESSION (#861): the vendored re-run after Bun gave a late + /// dependent its own registry record of minimist@1.2.2 (see + /// `bun_lockb::tests::LATE_DEPENDENT`) leaves ONE record, the tarball, + /// that every dependency edge resolves to — never two records with one + /// tarball resolution, which the isolated linker installs into the same + /// store directory (`EEXIST`). (The e2e `workspace_late_dependent_*` + /// test reverts it through the first run's ledger.) + #[tokio::test] + async fn rerun_folds_the_late_registry_copy_into_the_tarball_record() { + for name in ["1.3.9-late", "1.3.9-adder", "1.4.2-late", "1.4.2-adder"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let fixture = format!( + "{}/tests/fixtures/bun-lockb/late-dependent/{name}.lockb", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::copy(&fixture, root.join(LOCK)).unwrap(); + let installed = root.join("node_modules/minimist"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("package.json"), + br#"{"name":"minimist","version":"1.2.2"}"#, + ) + .unwrap(); + std::fs::write(installed.join("index.js"), BEFORE).unwrap(); + let blobs = root.join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let after_hash = compute_git_sha256_from_bytes(AFTER); + std::fs::write(blobs.join(&after_hash), AFTER).unwrap(); + let record: PatchRecord = serde_json::from_value(serde_json::json!({ + "uuid": UUID, "exportedAt": "", "files": {"package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(BEFORE), "afterHash": after_hash, + }}, "vulnerabilities": {}, "description": "", "license": "MIT", "tier": "free", + })) + .unwrap(); + let (result, entry, warnings) = ts::expect_done( + ts::vendor_bun( + PURL, + &installed, + root, + &record, + &PatchSources::blobs_only(&blobs), + "", + false, + false, + None, + ) + .await, + ); + assert!(result.success, "{name}: {result:?}"); + assert!( + !ts::has_warning(&warnings, "vendor_bun_lockb_duplicate_records"), + "{name}: {warnings:?}" + ); + let entry = entry.expect("the lock changed"); + let lock = BunLockb::parse(&std::fs::read(root.join(LOCK)).unwrap()).unwrap(); + lock.validate_mutation().unwrap(); + let minimist: Vec<_> = lock + .packages() + .unwrap() + .into_iter() + .filter(|p| p.name == "minimist") + .collect(); + assert_eq!( + minimist + .iter() + .map(|p| p.resolution.as_str()) + .collect::>(), + [entry.artifact.path.as_str()], + "{name}: one record per tarball resolution" + ); + } + } +} diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index f349f94b6..6d5fe1e36 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -25,6 +25,8 @@ const INTEGRITY_LEN: usize = 65; /// parent (verified against a real Bun 1.3.14 lock, fixture /// `bun-lockb-bundled`). const BEHAVIOR_BUNDLED: u8 = 0x40; +/// Bun's `Dependency.Behavior.peer` bit. +const BEHAVIOR_PEER: u8 = 0x10; /// Written by this codec in the last eight bytes of the root package's /// resolution (its value union, which a root resolution never reads — early /// writers leave uninitialized bytes there, and every supported reader @@ -355,6 +357,272 @@ impl BunLockb { Ok(found) } + /// Fold the leaf `duplicates` of package `kept` into it, the lock Bun + /// itself writes for one package that several dependents resolve to: + /// their dependency edges resolve to `kept`, the edges hoisting would + /// now deduplicate leave the trees (and a tree they leave empty goes + /// too), and their rows leave every package column, later IDs moving + /// down by one. Bun's frozen install re-hoists the lock and refuses one + /// whose trees differ, so this is only done where hoisting is exactly + /// predictable: every record involved has no dependencies of its own (a + /// leaf spawns no subtree), and no edge to it is a peer, nor any edge in + /// the lock bundled (a bundled edge starts a new hoisting root). + /// `Ok(false)` leaves the lock unchanged. + pub(crate) fn merge_packages( + &mut self, + kept: usize, + duplicates: &[usize], + ) -> Result { + let mut candidate = self.clone(); + let merged = candidate.merge_packages_inner(kept, duplicates)?; + if merged { + *self = candidate; + } + Ok(merged) + } + + fn merge_packages_inner(&mut self, kept: usize, duplicates: &[usize]) -> Result { + self.check_editable()?; + self.check_id(kept)?; + for &id in duplicates { + self.check_id(id)?; + if id == 0 || id == kept { + return Err("bun.lockb: invalid duplicate package".into()); + } + } + if duplicates.is_empty() { + return Ok(false); + } + let style = self.hash_style()?; + // The same normalizations as any record edit (`set_package`). + self.promote_legacy_format()?; + self.normalize_workspace_behaviors()?; + for &id in duplicates.iter().chain([&kept]) { + if !self.package_dependency_range(id)?.is_empty() { + return Ok(false); + } + } + let dependencies = self.dependency_array()?; + let resolutions = self.buffer_array(2)?; + let edges = resolutions.data.len() / 4; + let mut resolved = Vec::with_capacity(edges); + for index in 0..edges { + let behavior = self.data[dependencies.data.start + index * 26 + 16]; + let mut id = u32_at(&self.data, resolutions.data.start + index * 4)? as usize; + if duplicates.contains(&id) { + id = kept; + } + if behavior & BEHAVIOR_BUNDLED != 0 || (id == kept && behavior & BEHAVIOR_PEER != 0) { + return Ok(false); + } + resolved.push(id); + } + let name_hash = |edge: usize| u64_at(&self.data, dependencies.data.start + edge * 26 + 8); + + // Bun's hoister places an edge where it is declared unless the + // nearest ancestor tree holding the same name holds the same + // package, which deduplicates it. Merging only turns other packages + // into `kept`, so an edge to `kept` leaves its tree exactly when + // that nearest ancestor (parents precede children) now resolves to + // `kept` too. + let trees = self.buffer_array(0)?; + let hoisted = self.buffer_array(1)?; + if trees.data.len() % 20 != 0 { + return Err("bun.lockb: invalid tree buffer".into()); + } + let mut nodes = Vec::new(); + for at in trees.data.clone().step_by(20) { + let field = |i: usize| u32_at(&self.data, at + i * 4).map(|v| v as usize); + let (off, len) = (field(3)?, field(4)?); + if off + len > hoisted.data.len() / 4 { + return Err("bun.lockb: invalid tree dependency slice".into()); + } + let placed = (off..off + len) + .map(|i| u32_at(&self.data, hoisted.data.start + i * 4).map(|v| v as usize)) + .collect::, _>>()?; + nodes.push((field(0)?, field(1)?, field(2)?, placed)); + } + for index in 0..nodes.len() { + let parent = nodes[index].2; + if parent != u32::MAX as usize && parent >= index { + return Ok(false); + } + let mut placed = std::mem::take(&mut nodes[index].3); + let mut deduplicated = Vec::new(); + for &edge in &placed { + if resolved.get(edge) != Some(&kept) { + continue; + } + let hash = name_hash(edge)?; + let mut ancestor = parent; + while ancestor != u32::MAX as usize { + let mut same_name = nodes[ancestor] + .3 + .iter() + .filter(|&&e| e < edges && name_hash(e).is_ok_and(|h| h == hash)); + if let Some(&nearest) = same_name.next() { + if resolved[nearest] == kept { + deduplicated.push(edge); + } + break; + } + ancestor = nodes[ancestor].2; + } + } + placed.retain(|edge| !deduplicated.contains(edge)); + if placed.is_empty() && !deduplicated.is_empty() { + if nodes.iter().any(|node| node.2 == index) { + return Ok(false); + } + nodes[index].0 = usize::MAX; + } + nodes[index].3 = placed; + } + let mut renumbered = Vec::with_capacity(nodes.len()); + let mut next = 0; + for node in &nodes { + renumbered.push(next); + if node.0 != usize::MAX { + next += 1; + } + } + let (mut tree_bytes, mut hoisted_bytes) = (Vec::new(), Vec::new()); + for (index, (id, dependency, parent, placed)) in nodes.iter().enumerate() { + if *id == usize::MAX { + continue; + } + let id = if *id == index { renumbered[index] } else { *id }; + let parent = if *parent == u32::MAX as usize { + *parent + } else { + renumbered[*parent] + }; + for value in [ + id, + *dependency, + parent, + hoisted_bytes.len() / 4, + placed.len(), + ] { + tree_bytes.extend_from_slice(&(value as u32).to_le_bytes()); + } + for &edge in placed { + hoisted_bytes.extend_from_slice(&(edge as u32).to_le_bytes()); + } + } + + let new_id = |id: usize| id - duplicates.iter().filter(|&&d| d < id).count(); + let mut resolution_bytes = Vec::with_capacity(edges * 4); + for (index, &id) in resolved.iter().enumerate() { + let raw = u32_at(&self.data, resolutions.data.start + index * 4)?; + let id = if id < self.count { + new_id(id) as u32 + } else { + raw + }; + resolution_bytes.extend_from_slice(&id.to_le_bytes()); + } + let meta = self.package_start + self.count * (32 + self.resolution_size); + for row in 0..self.count { + let at = meta + row * 88 + 8; + if u32_at(&self.data, at)? as usize == row { + self.data[at..at + 4].copy_from_slice(&(new_id(row) as u32).to_le_bytes()); + } + } + let mut widths = vec![8, 8, self.resolution_size, 8, 8, 88, 20]; + if self.fields == 8 { + widths.push(49); + } + let mut columns = Vec::new(); + let mut column = self.package_start; + for width in widths { + for row in (0..self.count).filter(|row| !duplicates.contains(row)) { + let at = column + row * width; + columns.extend_from_slice(&self.data[at..at + width]); + } + column += self.count * width; + } + let count = self.count - duplicates.len(); + *self = self.relayout( + count, + &columns, + [ + Some(tree_bytes), + Some(hoisted_bytes), + Some(resolution_bytes), + ], + )?; + self.normalize_production_pool()?; + self.update_hash(style)?; + Ok(true) + } + + /// The lock re-laid with `count` packages in `columns` and the first + /// buffers replaced, the way Bun's serializer writes one: each buffer + /// keeps its descriptor and type prefix, then zero padding to an + /// eight-byte data start; everything after the six buffers moves by the + /// size difference, which must keep the extensions' alignment. + fn relayout( + &self, + count: usize, + columns: &[u8], + replaced: [Option>; 3], + ) -> Result { + let arrays = (0..6) + .map(|index| self.buffer_array(index)) + .collect::, _>>()?; + let mut data = self.data[..self.package_start].to_vec(); + data.extend_from_slice(columns); + put_u64(&mut data, PACKAGES_AT, count); + let package_end = data.len(); + put_u64(&mut data, PACKAGES_AT + 32, package_end); + for (index, array) in arrays.iter().enumerate() { + let bytes = match replaced.get(index) { + Some(Some(bytes)) => bytes.as_slice(), + _ => &self.data[array.data.clone()], + }; + let padding = self.data[array.descriptor + 16..array.data.start] + .iter() + .rev() + .take_while(|b| **b == 0) + .count(); + let descriptor = data.len(); + data.extend_from_slice(&self.data[array.descriptor..array.data.start - padding]); + if !bytes.is_empty() { + data.resize(data.len().next_multiple_of(8), 0); + } + let start = data.len(); + data.extend_from_slice(bytes); + let end = data.len(); + put_u64(&mut data, descriptor, start); + put_u64(&mut data, descriptor + 8, end); + } + let end = arrays[5].data.end; + if (data.len() as i128 - end as i128) % 8 != 0 { + return Err("bun.lockb: re-laid buffers would misalign the extensions".into()); + } + let shift = |value: usize| (value as i128 + data.len() as i128 - end as i128) as usize; + let total = shift(self.total); + let extensions: Vec<_> = self + .extensions + .iter() + .map(|array| { + ( + shift(array.descriptor), + shift(array.data.start), + shift(array.data.end), + ) + }) + .collect(); + data.extend_from_slice(&self.data[end..]); + put_u64(&mut data, TOTAL_AT, total); + for (descriptor, start, end) in extensions { + put_u64(&mut data, descriptor, start); + put_u64(&mut data, descriptor + 8, end); + } + Self::parse(&data) + } + pub(crate) fn set_package( &mut self, id: usize, @@ -2388,4 +2656,127 @@ mod tests { assert_eq!(lock.package(1).unwrap().resolution, "a.tgz"); assert_eq!(lock.bytes().len(), original_len); } + + /// The `bun.lockb` each Bun wrote for a workspace vendored once (uuid + /// `80630680-…`) after a late dependent of minimist@1.2.2 was added: a + /// new member (`late`, hoisted after the vendored record, so it nests + /// its registry copy) or `bun add` in an existing one (`adder`, hoisted + /// first, so the vendored record nests instead). + const LATE_DEPENDENT: [(&str, &[u8]); 4] = [ + ( + "1.3.9-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb"), + ), + ( + "1.3.9-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb"), + ), + ( + "1.4.2-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-late.lockb"), + ), + ( + "1.4.2-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb"), + ), + ]; + + /// Each hoisting tree as `(parent, names of the packages it places)`. + fn tree_placements(lock: &BunLockb) -> Vec<(u32, Vec)> { + let trees = lock.buffer_array(0).unwrap(); + let hoisted = lock.buffer_array(1).unwrap(); + let resolutions = lock.buffer_array(2).unwrap(); + let packages = lock.packages().unwrap(); + trees + .data + .step_by(20) + .map(|at| { + let field = |i: usize| u32_at(&lock.data, at + i * 4).unwrap() as usize; + let mut names: Vec<_> = (field(3)..field(3) + field(4)) + .map(|i| { + let edge = u32_at(&lock.data, hoisted.data.start + i * 4).unwrap(); + let id = u32_at(&lock.data, resolutions.data.start + edge as usize * 4); + packages[id.unwrap() as usize].name.clone() + }) + .collect(); + names.sort(); + (field(2) as u32, names) + }) + .collect() + } + + /// REGRESSION (#861): the late dependent's registry record folds into + /// the vendored tarball record — one record per resolution, as Bun + /// writes it, so the isolated linker gets one store directory — and the + /// trees become what Bun's frozen install re-hoists: the nested copy is + /// deduplicated against the hoisted one and its emptied tree dropped. + /// The re-laid buffers keep Bun's eight-byte data alignment. + #[test] + fn late_duplicate_folds_into_the_tarball_record_as_bun_hoists_it() { + for (label, bytes) in LATE_DEPENDENT { + let member = label.rsplit('-').next().unwrap(); + let mut lock = BunLockb::parse(bytes).unwrap(); + let minimist: Vec<_> = lock + .packages() + .unwrap() + .into_iter() + .filter(|p| p.name == "minimist") + .collect(); + assert_eq!(minimist.len(), 2, "{label}"); + let kept = minimist + .iter() + .find(|p| p.resolution.starts_with(".socket/")); + let kept = kept.unwrap().id; + let duplicate = minimist.iter().find(|p| p.id != kept).unwrap().id; + assert_eq!(tree_placements(&lock).len(), 2, "{label}: Bun nests a copy"); + + assert!(lock.merge_packages(kept, &[duplicate]).unwrap(), "{label}"); + lock.validate_mutation().unwrap(); + let merged = BunLockb::parse(&lock.bytes()).unwrap(); + let packages = merged.packages().unwrap(); + assert_eq!(packages.len(), 5, "{label}"); + let minimist: Vec<_> = packages.iter().filter(|p| p.name == "minimist").collect(); + assert_eq!(minimist.len(), 1, "{label}: {packages:?}"); + assert!(minimist[0] + .resolution + .starts_with(".socket/vendor/npm/80630680-")); + let meta = merged.package_start + merged.count * (32 + merged.resolution_size); + for row in 0..merged.count { + assert_eq!( + u32_at(&merged.data, meta + row * 88 + 8).unwrap() as usize, + row + ); + } + let mut expected = ["consumer", member, "is-number", "minimist"]; + expected.sort(); + assert_eq!( + tree_placements(&merged), + [(u32::MAX, expected.iter().map(|n| n.to_string()).collect())], + "{label}" + ); + for array in (0..6) + .map(|i| merged.buffer_array(i).unwrap()) + .chain(merged.extensions.iter().cloned()) + .filter(|a| !a.data.is_empty()) + { + assert_eq!(array.data.start % 8, 0, "{label}: aligned"); + } + } + } + + /// A record with dependencies of its own spawns a subtree whose + /// hoisting the merge does not reproduce: it declines, lock unchanged. + #[test] + fn merge_declines_records_with_dependencies() { + let bytes = + include_bytes!("../../tests/fixtures/bun-lockb/0.8.1-production-complex/bun.lockb"); + let mut lock = BunLockb::parse(bytes).unwrap(); + let parent = (1..lock.count) + .find(|&id| !lock.package_dependency_range(id).unwrap().is_empty()) + .unwrap(); + let other = (1..lock.count).find(|&id| id != parent).unwrap(); + assert!(!lock.merge_packages(parent, &[other]).unwrap()); + assert!(!lock.merge_packages(other, &[parent]).unwrap()); + assert_eq!(lock.bytes(), bytes); + } } diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index a8b22805f..965dbcea5 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -22,6 +22,11 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include 1.0.0, 1.1.45 and 1.3.14, pinning the metadata hash's semver order. - `0.8.1-production-complex`: two production patch targets, a transitive dependency with a bin, root lifecycle scripts, and development dependencies. +- `late-dependent/-.lockb`: the lock Bun 1.3.9 / 1.4.2 + wrote for the `e2e_bun_lockb` workspace (vendored once, uuid + `80630680-…`) after a late dependent of minimist@1.2.2 — a new `late` + member, or `bun add` in the existing `adder` member — gave it a second, + nested registry record (#861). Other releases capture the stable major/minor eras. `two-versions` covers multiple package versions and scoped restoration. The earliest writers include diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-adder.lockb new file mode 100755 index 0000000000000000000000000000000000000000..d5838145f202a44f1125bffe81d9ed5e9a113c04 GIT binary patch literal 3128 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_p+#U)%R} zoICU(Z-RdmS7T_bqIK;`5$|I0??)$f#cw+kyn-932ng696axnu-2mm!gDC*>1sNC` ztbr^}Af23_S6rH#T9liamzkScT#}fQl3E0mgYi#3GZnd~(QTlm@A2nHw8*CgHI0^X`;wfo;zWws_WVOl`8A3ga_EZKRZ9f*Rlgq^*GNWGB-=52!wQAPw?2na03F zwhm~37X{|P+yitA10MzEz{2MQ&>Rb(Ik0etiNk1O{L2DV1i~NyqCsY{f;d0`qCsXM zhd*iR|MCBWISynXH2}~&Mh1wv$VRg~4p>y@VO4$P(^Re4tyXcm!CHG2T*6J>ZmB-v zV)x_vtMv2(y!(Ak9vtz0T*75z>Gk#Cdb4@Gsc9i>VaH;73g;o2OG@|=^b(S}pj-_D zEc4=)S!!GNHVJQOEx&$jsZuC|hqPX1TLN%bKp)R-0yjNJ=~YFSuWvuL0h&8h z0PUSR7;Sg}ttU93*`3QKGY?oNqSRD71tSB6;>@bl{4^Z}6NSW_%=Enc zG%i@&{`(IBAU>=-f%z83w}9H<1GlNv*vJlO7;a<1X3K3?2)PwRg z2!q@LG6zJ1()S6d{yJ#&$z>C5WENX$0raLB+&zY<-h|bYu=-Dd4OXJVYE)QFslW!S zd(dT=*bIQS1FLX&Z49ernHbIW4D>*KW-70cBt{I4OEgLwyLNxonD4 zbIVeT^js@SQj78ubAn5XGV{{WxONJLh;XoghrM_7KZUEXgcO z)djYD5{oKzflZ!*#N_P6^weT~a7e;=urMmmFUl?kil$m=Wftq^mF6a;7G;!_6ck(O z>ldY_16wLZm3nyvxmm?}`98!npgw&I z12ba-GYbPgS501Q;TGMSm^75107@%9yh=g1Dyb4 zBJ@Fmh=HNpzsS2dAko)D{G{zuE!&p;?msIqTI~9%-qc4lAOenRFEPV|Ku}Mk$W2523qLV_8 zKd!$@Pd~uB-`C{95%0$(TsD?oUk|P~o7bC~7Qz;GEVid`9+J7FgdagKA(;!x)iA&^ zFK(Howsmil@Rru{>&KQVg)(?Z>t#lsKFeUuHDh1zAJ6mr4vees+}>H4Teo*fNJDtf zms7F}OB=tm#vePjzc~%bTv+_U%%+G&G8dK(K<2Tm>6&e|Y4(StwB!Gxi<3*ghZ~w^g$_BxN$!A3-zuNiw_G25MxkCle z-l>Dph6m7W&H*iMxok4?ic1o6a`a#olwL?tYO0-rk%2;SW>so_nvQ~rLSjy4dR~4S z7c6f7{f7V$A6A~gd<)}SKyC1W+f-_7WCt`1x3S=I2$X+7W`NRV1yt_@BFq5gXOKP+ z4RQ;}9FQ3Ypn7AVRVSBCw2@hCsRhuBW^lI{BD@HzFJbkd0voJmfz_O_8c~5w31~j3 zj7QhQ#HJ6F0ao4cS{PQlGBKL#8R!`RWnuL%tX^eeG}JTFGr}vg66yyS&1F-Znp>7y zq~}^ul3J9Pm=j!5l$n=~#$8c2_V2_0}2#FY+(YD&Py!GEKAh| zwrdiLDs_QPnu5gS?8NlcVtttJ;R2w5D$g&*kf_CZ!f-l#~<{Tj}c; zrKSTLB1M&Yc?G#y#d`Tg>H2Ur`nm|VE;y|8O46(Jih<3i)Dr!&)V!4ZB7LAfeG3CK zV*@h_16`ApL^E9z(=w?1;WD*`Xz!U?W0AnKb zfr0=8%KeMHivtp!C7_u>14;u!&&M4L~d;m898kb?IMa7x< zd3sPfq_QAY18BCUoq{R0XoTfEh%LmEhEB1Qii5r h2!$M;NLdu@Nm#Cg=RvD< literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb new file mode 100755 index 0000000000000000000000000000000000000000..376bbf00ad816ded531db047f263c5a452bcc03d GIT binary patch literal 3104 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_p+#U)%R} zoICU(Z-RdmS7T_bqIK;`5$|I0??)$f#cw+kJb@di2ng696axnu-2mm!gDC*>1sNC` ztbr^}Af23_S6rH#T9liamzkScT#}fQl3E0mgYi#3GZnd~(QTlm@A2nHw8*CgHI0^X`;wfo;zWws_WVOl`8A3ga_EZKRZ9f*Rlgq^*GNWGB-=52!wQAPw?2na03F zwhm~37X{|P+yitA10MzEz{2MQ&>Rb(Ik0etiNk1O{L2DV1i~NyqCsY{f;d0`qCsXM zhd*iR|MCBWISynXH2}~&Mh1wv$VRg~4p>y@VO4$P(^Re4tyXcm!CHG2T*6J>ZmB-v zV)x_vtMv2(y!(Ak9vtz0T*75z>Gk#Cdb4@Gsc9i>VaH;73g;o2OG@|=^b(S}pj-_D zEc4=)S!!GNHVJQOEx&$jsZuC|hqPX1TLN%bKp)R-0yjNJ=~YFSuWvuL0h&8h z0PUSR7;Sg}ttU93*`3QKGY?oNqSRD71tSB6;>@bl{4^Z}6NSW_%=Enc zG%i@&{`(IBAU>=-f%z83w}9H<1GlNv*vJlO7;a<1X3K3?2)PwRg z2!q@LG6zJ1()S6d{yJ#&$z>C5WENX$0raLB+&zY<-h|bYu=-Dd4OXJVYE)QFslW!S zd(dT=*bIQS1FLX&Z49ernHbIW4D>*KW-70cBt{I4OEgLwyLNxonD4 zbIVeT^js@SQj78ubAn5XGV{{WxONJLh;XoghrM_7KZUEXgcO z)djYD5{oKzflZ!*#N_P6^weT~a7e;=urMmmFUl?kil$m=Wftq^mF6a;7G;!_6ck(O z>ldY_16wLZm3nyvxmm?}`98!npgw&I z12ba-GYbPgS501Q;TGMSm^75107@%9yh=g1Dyb4 zBJ@Fmh=HNpzsS2dAkoRoc3XEB87(KV^Z{h)M`Lmsuob1RR0!0^)8VIK)?o}7&y@A1}J|XOaYiL$iUEG z1!Qpo>E!&p;?msIqTI~9%-qc4lAOenRFEPV|Ku}Mk$W2523qLV_8 zKd!$@Pd~uB-`C{95%0$(TsD?oUk|P~o7bC~7Qz;GEVid`9+J7FgdagKA(;!x)iA&^ zFK(Howsmil@Rru{>&KQVg)(?Z>t#lsKFeUuHDh1zAJ6mr4vees+}>H4Teo*fNJDtf zms7F}OB=tm#vePjzc~%bTv+_U%%+G&G8dK(K<2Tm>6&e|Y4(StwB!Gxi<3*ghZ~w^g$_BxN$!A3-zuNiw_G25MxkCle z-l>Dph6m7W&H*iMxok4?ic1o6a`a#olwL?tYO0-rk%2;SW>so_nvQ~rLSjy4dR~4S z7c6f7{f7V$A6A~gd<)}SKyC1W+f-_7WCt`1x3S=I2$X+7W`NRV1yt_@BFq5gXOKP+ z4RQ;}9FQ3Ypn7AVRVSBCw2@hCsRhuBW^lI{BD@HzFJbkd0voJmfz_O_8c~5w31~j3 zj7QhQ#HJ6F0ao4cS{PQlGBKL#8R!`RWnuL%tX^eeG}JTFGr}vg66yyS&1F-Znp>7y zq~}^ul3J9Pm=j!5l$n=~#$8c2_V2_0}2#FY+(YD&Py!GEKAh| zwrdiLDs_QPnu5gS?8NlcVtttJ;R2w5D$g&*kf_CZ!f-l#~<{Tj}c; zrKSTLB1M&Yc?G#y#d`Tg>H2Ur`nm|VE;y|8O46(Jih<3i)Dr!&)V!4ZB7LAfeG3CK zV*@h_16`ApL^E9z(=w?1;WD*`Xz!U?W0AnKb zfr0=8%KeMHivtp!C7_u>14;u!&&dAm=ya6`=8kb?IMa7x< zd3sRVPQesg?19rb#AMP)SUx397Xt$av?VG5r3rZ$-M;})eF<>=Q2#>GEOO8x Date: Wed, 7 Oct 2026 10:49:26 -0400 Subject: [PATCH 13/55] Add vex discover golden entry for late-dependent fixture (#861) The bun-lockb/late-dependent fixture added for the duplicate-record fix is walked by the vex discovery golden corpus, but bun-lockb.json had no entry for it, so committed_fixture_corpus_matches_golden failed. Regenerated with SOCKET_PATCH_UPDATE_GOLDEN=1; the only change is the new empty entry for that fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/fixtures/vex-discover-golden/bun-lockb.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json index c9896da89..a66d08672 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json @@ -430,6 +430,14 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/late-dependent": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/prerelease-pair-0.8.1": { "refs": [], "diagnostics": [], From 0f45844aefa7339694b776c349842597e05ca690 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:29:45 -0400 Subject: [PATCH 14/55] Reconcile #861 with #784's migrated-lock fixture golden entry The vex discovery golden corpus walks every bun-lockb fixture directory. #861 added the golden entry for its late-dependent fixture, but the bun-lockb/1.2.23-migrated fixture added by the #784 fix had no entry in bun-lockb.json, so committed_fixture_corpus_matches_golden still failed on the combined branch. Regenerated with SOCKET_PATCH_UPDATE_GOLDEN=1; the only change is the new empty entry for that fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/fixtures/vex-discover-golden/bun-lockb.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json index a66d08672..50fd530f2 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json @@ -354,6 +354,14 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/1.2.23-migrated": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/1.3.0": { "refs": [], "diagnostics": [], From b8475ca3b61cca1837b21b7d4de9b4fe72a5286e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:37:40 -0400 Subject: [PATCH 15/55] Check bun spec match before the bundled-entry parse (#578) Since #472, rewrite_bun_lock called is_bundled_entry(entry) before comparing the entry's spec with the dep. is_bundled_entry JSON-parses the entry's meta object, so a hosted rewrite of N patched deps over a lock of M entries paid N x M serde_json parses where the loop used to do string compares. Callgrind put 62% of bun/hosted instructions there, and bun/hosted and bun/rescan wall time roughly doubled. The vendored path had the same shape: classify_rewritable and bundled_matches in vendor/bun_lock.rs ran the bundled check on every entry before classify, once per target package. Both checks are pure, so evaluate the cheap spec match / classify first and run is_bundled_entry only on an entry that resolves the target. The result is unchanged: a bundled copy is still skipped and reported. A test-only BUNDLED_CHECKS counter (same pattern as lock_fragments' RENDERS) lets regression tests pin the call count: 20 deps over 201 entries made 4020 checks before and 21 now; the vendor scans made 402 and now make at most 4. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 73 ++++++++++++++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 53 ++++++++++++-- .../src/vendor/bun_lock_text.rs | 10 +++ 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index b255246a6..d964c2d95 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4895,10 +4895,12 @@ fn rewrite_bun_lock( // reads the entry's spec (#469), so a rewrite here would count // as redirected (and VEX-attest the patch) while the unpatched // bundled bytes keep installing. Mirrors npm's `inBundle` guard. - if is_bundled_entry(entry) - && (spec == target_spec - || spec == url_spec - || is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url)) + // The spec compare runs first: the bundled check JSON-parses the + // meta, which per dep × entry doubled bun/hosted wall (#578). + if (spec == target_spec + || spec == url_spec + || is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url)) + && is_bundled_entry(entry) { matched_any = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); @@ -10618,6 +10620,69 @@ mod tests { ); } + /// REGRESSION (#578): the bundled-copy check JSON-parses an entry's meta, + /// so running it before the spec compare cost one parse per dep × entry + /// (bun/hosted wall +110%). Only an entry whose spec matches the dep may + /// pay it, and the bundled copy must still be skipped and reported. + #[test] + fn bun_lock_bundled_check_runs_only_on_matching_entries() { + use crate::vendor::bun_lock_text::BUNDLED_CHECKS; + + const ENTRIES: usize = 200; + const DEPS: usize = 20; + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let mut body: Vec = (0..ENTRIES) + .map(|i| format!("\"pkg{i}\": [\"pkg{i}@1.0.0\", \"\", {{}}, \"sha512-OLD==\"],")) + .collect(); + body.push( + "\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }, \"sha512-OLD==\"]," + .into(), + ); + let mut files = BTreeMap::new(); + files.insert( + "bun.lock".to_string(), + bun_lock_file(&body.join("\n "), 1), + ); + let overrides: Vec = (0..DEPS) + .map(|i| { + npm_override( + &format!("pkg{i}"), + "1.0.0", + &format!("http://p.test/pkg{i}.tgz"), + &sha512, + ) + }) + .collect(); + + BUNDLED_CHECKS.with(|checks| checks.set(0)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, &overrides, &mut r); + let checks = BUNDLED_CHECKS.with(std::cell::Cell::get); + + let out = r.files.get("bun.lock").expect("matching deps are rewired"); + for i in 0..DEPS { + assert!(out.contains(&format!("http://p.test/pkg{i}.tgz")), "{out}"); + } + assert!( + out.contains("\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }"), + "the bundled copy is never rewired: {out}" + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_bun_bundled_instance_skipped"), + "{:?}", + r.warnings + ); + // One check per spec-matching entry (DEPS registry tuples + the + // bundled copy), not DEPS × (ENTRIES + 1). + assert!( + checks <= DEPS + 1, + "{checks} bundled checks for {DEPS} deps over {} entries", + ENTRIES + 1 + ); + } + /// A bun.lock already redirected by an earlier run holds a URL 3-tuple — /// the registry `name@version` spec is gone — so when the artifact URL /// changes (patch republish rotates the uuid segment, token rotation diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 771f818ad..9f8223741 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1083,17 +1083,15 @@ enum TupleShape { /// [`classify`] for the instances vendoring may rewrite: a bundled entry /// ([`is_bundled_entry`]) is unpacked from its parent's tarball and never -/// read, so it is no rewrite target whatever its spec says (#469). +/// read, so it is no rewrite target whatever its spec says (#469). The +/// bundled check JSON-parses the meta, so it runs only on a match (#578). fn classify_rewritable( entry: &BunEntry, target_spec: &str, name: &str, target_leaf: &str, ) -> Option { - if is_bundled_entry(entry) { - return None; - } - classify(entry, target_spec, name, target_leaf) + classify(entry, target_spec, name, target_leaf).filter(|_| !is_bundled_entry(entry)) } /// Keys of the bundled entries that resolve the target `name@version`. @@ -1105,7 +1103,7 @@ fn bundled_matches( ) -> Vec { entries .iter() - .filter(|e| is_bundled_entry(e) && classify(e, target_spec, name, target_leaf).is_some()) + .filter(|e| classify(e, target_spec, name, target_leaf).is_some() && is_bundled_entry(e)) .map(|e| e.key.clone()) .collect() } @@ -4338,4 +4336,47 @@ mod tests { assert_eq!(looped, Err("vendor_lockfile_missing")); assert_eq!(planned, looped); } + + /// REGRESSION (#578): the vendor twin of the hosted hot loop. Every + /// per-target scan (`classify_rewritable`, `bundled_matches`) ran the + /// JSON-parsing bundled check on EVERY entry before the cheap spec + /// match; it may run only on the entries that resolve the target. + #[test] + fn bundled_check_runs_only_on_matching_entries() { + use crate::vendor::bun_lock_text::BUNDLED_CHECKS; + + const ENTRIES: usize = 200; + let mut entries: Vec = (0..ENTRIES) + .map(|i| { + parse_entry_line(&format!( + "\"pkg{i}\": [\"pkg{i}@1.0.0\", \"\", {{}}, \"sha512-OLD==\"]," + )) + .unwrap() + }) + .collect(); + entries.push( + parse_entry_line( + "\"parent/pkg0\": [\"pkg0@1.0.0\", \"\", { \"bundled\": true }, \"sha512-OLD==\"],", + ) + .unwrap(), + ); + + BUNDLED_CHECKS.with(|checks| checks.set(0)); + let (spec, leaf) = ("pkg0@1.0.0", tgz_rel_leaf("pkg0", "1.0.0")); + let rewritable = entries + .iter() + .filter(|e| classify_rewritable(e, spec, "pkg0", &leaf).is_some()) + .count(); + let bundled = bundled_matches(&entries, spec, "pkg0", &leaf); + let checks = BUNDLED_CHECKS.with(std::cell::Cell::get); + + assert_eq!(rewritable, 1, "only the registry tuple is rewritable"); + assert_eq!(bundled, vec!["parent/pkg0".to_string()]); + // Two matching entries, two scans: at most four checks, not 2 × 201. + assert!( + checks <= 4, + "{checks} bundled checks over {} entries", + ENTRIES + 1 + ); + } } diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 0b6e72220..06f833a05 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -217,6 +217,14 @@ pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool { }) } +#[cfg(test)] +thread_local! { + /// [`is_bundled_entry`] calls this thread made. Each one JSON-parses the + /// entry's meta, so a rewrite loop over N deps × M entries must check the + /// cheap spec match first and pay it only for matching entries (#578). + pub(crate) static BUNDLED_CHECKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// True when `entry` is a `bundleDependencies` copy: bun records it as its /// own `parent/child` entry whose `{meta}` object carries `"bundled": true`, /// and unpacks it from the PARENT's tarball without ever reading the @@ -226,6 +234,8 @@ pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool { /// tarball tuple). A meta that does not parse as JSON but mentions /// `"bundled"` counts as bundled: fail closed, never rewire or attest it. pub(crate) fn is_bundled_entry(entry: &BunEntry) -> bool { + #[cfg(test)] + BUNDLED_CHECKS.with(|checks| checks.set(checks.get() + 1)); let Some(meta) = entry.elems.iter().skip(1).find(|e| e.starts_with('{')) else { return false; }; From 656dc78a9f702fcd9087e33fa8c250e60c5c3901 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:17:26 -0400 Subject: [PATCH 16/55] Warn that Bun keeps patched copies after rollback and revert (#764) Bun's hoisted linker (its default for non-workspace projects, and for workspaces on 1.2.x) does not re-extract a package whose lock entry moves from a hosted URL or vendored local tarball back to the registry record of the same name@version: `bun install` and `bun install --frozen-lockfile` print "no changes" and node_modules keeps the Socket-patched bytes. rollback, remove and vendor --revert restored the lock correctly but then advised a plain install ("until the next package-manager install", "Run `bun install` to resync"), which is a no-op there; remove and vendor --revert --json emitted no warning at all. Measured with real Bun 1.2.23, 1.3.14 and 1.4.2: `bun install --force` reinstalls the upstream bytes and leaves the lock unchanged. Name the install that works, as the vlt unwind's advisory does: - core bun_lock: a wet vendored Bun revert (bun.lock and bun.lockb, preserve-state included, not drift-keeps or dry runs) adds `vendor_bun_reinstall_required` when node_modules/ is a real directory or the tree has no node_modules/.bun (a hoisted install). An isolated link into .bun relinks, and a tree with no node_modules has nothing to keep, so neither warns. Every reverting command (vendor --revert, rollback, remove) already surfaces these. - rollback's hosted leg (shared by remove and vendor --revert's upstream restore) adds one run-level `redirect_bun_reinstall_required` for restored bun.lock/bun.lockb pins under the same rule. - vendor --revert's human hint names `bun install --force` for Bun. Document both codes in CLI_CONTRACT.md and docs/ecosystems.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 + .../socket-patch-cli/src/commands/rollback.rs | 17 ++ .../socket-patch-cli/src/commands/vendor.rs | 30 ++- .../tests/covgap_commands_rollback.rs | 68 ++++++ .../socket-patch-core/src/vendor/bun_lock.rs | 218 ++++++++++++++++-- docs/ecosystems.md | 6 +- 6 files changed, 316 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 61a1d7192..5b6c2fc74 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1291,6 +1291,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | | `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | +| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]` | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run or a drift-kept revert. | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt and the npm-family tarball flavors: npm, pnpm, bun, yarn classic, yarn berry): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` or `vendor/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. A file rule such as `*.tgz` is overridden by the `/.gitignore` vendoring writes; if the written artifact still reads as ignored, the run refuses and removes the uuid dir it created. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt and the npm-family tarball flavors): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | @@ -1337,6 +1338,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_vlt_no_lockfile` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | +| `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). | | `vendor_prebuilt_stub_invalid` | `failed` | RubyGems: the server stub lacks required attributes or is otherwise invalid; no local stub fallback is permitted. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index c3a7f8592..d80a5223b 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -949,6 +949,23 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H ); out.edited_files .extend(outcome.reverted_files.iter().cloned()); + // Bun's hoisted linker keeps the patched copies of the pins it no + // longer pins (#764): say so, with the install that does reinstall. + if !common.dry_run && outcome.flush_error.is_none() { + use socket_patch_core::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; + use socket_patch_core::vendor::bun_lock; + let bun_purls = outcome + .restored() + .filter(|pin| pin.files.iter().any(|f| f == BUN_LOCK || f == BUN_LOCKB)); + let stale = + bun_lock::stale_hoisted_copies(&common.cwd, bun_purls.map(|p| p.purl.as_str())).await; + if !stale.is_empty() { + out.warnings.push(( + "redirect_bun_reinstall_required".to_string(), + bun_lock::reinstall_advisory(&stale), + )); + } + } let unwound: Vec<_> = vlt_targets .into_iter() .filter(|t| out.reverted.iter().any(|p| p == &t.purl)) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index becee8673..194431859 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -442,10 +442,7 @@ async fn unwired_check_failure( /// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose /// probe covers the requirements flavor only) have no in-use probe yet, /// and a missing/unreadable lockfile proves nothing. -pub(crate) async fn dispatch_in_use_one( - entry: &VendorEntry, - project_root: &Path, -) -> Option { +pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { match entry.ecosystem.as_str() { "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, // Cargo probes the lock entry's shape: detached + `[patch]` pointing @@ -1237,8 +1234,7 @@ async fn run_check(args: &VendorArgs) -> i32 { // know (the ledger was ignored or dropped from the commit along with the // manifest) leaves every fresh install failing; the manifest keys above // cannot see it, so the references are read from the wiring itself. - let references = - crate::commands::vendored_backend::repair::scan_vendor_references(root).await; + let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await; for (eco, uuid, rel) in references { let ledgered = state .entries @@ -3696,6 +3692,16 @@ fn flavor_install_command(flavor: &str) -> Option<&'static str> { } } +/// The install that resyncs an installed tree after a revert: Bun's +/// hoisted linker keeps the vendored copy through a plain `bun install` +/// (#764), so Bun's needs `--force`. +fn flavor_revert_install_command(flavor: &str) -> Option<&'static str> { + match flavor { + "bun" => Some("bun install --force"), + other => flavor_install_command(other), + } +} + /// Drop installed npm copies that resolve into `.socket/vendor/` (or no /// longer resolve at all): vlt links a vendored `file:` dependency straight /// to its committed dir, which is this tool's own artifact and never a @@ -4028,7 +4034,7 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { if summary.reverted > 0 && !common.dry_run { let mut installs: Vec<&str> = reverted_flavors .iter() - .filter_map(|f| flavor_install_command(f)) + .filter_map(|f| flavor_revert_install_command(f)) .collect(); installs.sort_unstable(); installs.dedup(); @@ -6629,6 +6635,16 @@ mod ui_format_tests { ); } + #[test] + fn revert_install_hint_forces_bun() { + assert_eq!( + flavor_revert_install_command("bun"), + Some("bun install --force") + ); + assert_eq!(flavor_revert_install_command("pnpm"), Some("pnpm install")); + assert_eq!(flavor_revert_install_command("cargo"), None); + } + #[test] fn revert_install_hint_names_the_command() { assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 2c2ca6b33..c0ccd808d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -2088,6 +2088,74 @@ fn bun_lock_pin_restores_to_the_registry_tuple() { assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } +/// #764: Bun's hoisted linker keeps an installed copy whose lock entry +/// returns to the registry record (a plain `bun install` reports "no +/// changes"), so a rollback over a hoisted `node_modules/left-pad` warns +/// `redirect_bun_reinstall_required` and names `bun install --force`, in +/// the JSON `warnings[]` and on stderr. An isolated install (the copy a +/// link into `node_modules/.bun/`) relinks, so it stays silent. +#[test] +fn bun_lock_rollback_warns_that_a_hoisted_copy_is_kept() { + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + let project = |installed: bool| { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("bun.lock"), + bun_lock(&bun_redirected_line()), + ) + .unwrap(); + if installed { + let pkg = tmp.path().join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write(pkg.join("index.js"), "// PATCHED\n").unwrap(); + } + tmp + }; + let has_code = |v: &serde_json::Value| { + v["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "redirect_bun_reinstall_required" + && w["detail"].as_str().is_some_and(|d| { + d.contains("left-pad@1.2.3") && d.contains("`bun install --force`") + }) + }) + }) + }; + + let hoisted = project(true); + let (code, stdout, stderr) = run_hosted( + hoisted.path(), + &["rollback", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + has_code(&parse_envelope(&stdout, &stderr)), + "stdout=\n{stdout}" + ); + + let hoisted = project(true); + let (code, stdout, stderr) = + run_hosted(hoisted.path(), &["rollback", "--yes"], Some(®istry)); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stderr.contains("`bun install --force`"), + "the human run names the forcing install; stderr=\n{stderr}" + ); + + let fresh = project(false); + let (code, stdout, stderr) = run_hosted( + fresh.path(), + &["rollback", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + !has_code(&parse_envelope(&stdout, &stderr)), + "nothing installed, nothing kept; stdout=\n{stdout}" + ); +} + /// Dry-run twin of `bun_lock_pin_restores_to_the_registry_tuple`: "Would /// restore …", bun.lock byte-identical afterwards. #[test] diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 9f8223741..a8a369d3a 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -841,11 +841,93 @@ pub(crate) async fn revert_bun( /// [`revert_bun`] with full [`RevertOpts`]: `keep_artifact` skips the /// artifact deletion — and the refusals that exist only to protect it — -/// while the wiring restore runs unchanged. +/// while the wiring restore runs unchanged. A wet revert that restored the +/// wiring adds [`REINSTALL_REQUIRED`] when the installed tree may keep the +/// vendored copy. pub(crate) async fn revert_bun_opts( entry: &VendorEntry, project_root: &Path, opts: RevertOpts, +) -> RevertOutcome { + let mut outcome = revert_bun_wiring(entry, project_root, opts).await; + if outcome.success && !outcome.kept_artifact && !opts.dry_run { + let stale = stale_hoisted_copies(project_root, [entry.base_purl.as_str()]).await; + if !stale.is_empty() { + outcome.warnings.push(VendorWarning::new( + REINSTALL_REQUIRED, + reinstall_advisory(&stale), + )); + } + } + outcome +} + +/// A revert (or hosted unwind) left an installed copy Bun may keep: its +/// hoisted linker does not re-extract a package whose lock entry moves +/// from a local or URL tarball back to the registry record of the same +/// `name@version`, so a plain `bun install` (`--frozen-lockfile` too) +/// reports "no changes" and the patched bytes stay installed (#764, +/// measured on 1.1.45, 1.2.23, 1.3.9, 1.3.14 and 1.4.2). The isolated +/// linker relinks to the registry entry, and `bun install --force` +/// reinstalls on both without touching the lock. +pub const REINSTALL_REQUIRED: &str = "vendor_bun_reinstall_required"; + +/// The `name@version` of each npm purl in `purls` whose installed copy Bun +/// may keep after its lock entry returns to the registry (see +/// [`REINSTALL_REQUIRED`]): `node_modules/` is a real directory (an +/// isolated install links it into `node_modules/.bun/`), or the tree has +/// no `node_modules/.bun/` at all (a hoisted install, where a copy may sit +/// nested under another package). A project with no `node_modules/` has +/// nothing installed to keep. +pub async fn stale_hoisted_copies<'a>( + project_root: &Path, + purls: impl IntoIterator, +) -> Vec { + let modules = project_root.join("node_modules"); + if !tokio::fs::metadata(&modules) + .await + .is_ok_and(|m| m.is_dir()) + { + return Vec::new(); + } + let hoisted_tree = tokio::fs::symlink_metadata(modules.join(".bun")) + .await + .is_err(); + let mut stale = Vec::new(); + for purl in purls { + let Some((name, version)) = super::npm_common::parse_npm_purl(purl) else { + continue; + }; + let copy = tokio::fs::symlink_metadata(modules.join(&name)).await; + let kept = match copy { + Ok(m) => m.is_dir(), + Err(_) => hoisted_tree, + }; + let label = format!("{name}@{version}"); + if kept && !stale.contains(&label) { + stale.push(label); + } + } + stale +} + +/// The [`REINSTALL_REQUIRED`] detail for the `name@version` labels in +/// `stale` (shared with the hosted unwind's run-level advisory). +pub fn reinstall_advisory(stale: &[String]) -> String { + format!( + "Bun's hoisted linker keeps the installed copy of {} when its lock entry returns to \ + the registry: a plain `bun install` reports no changes and node_modules may still \ + hold the patched bytes; run `bun install --force` (or delete node_modules and run \ + `bun install`) to reinstall the upstream copy", + stale.join(", ") + ) +} + +/// The wiring restore behind [`revert_bun_opts`]. +async fn revert_bun_wiring( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, ) -> RevertOutcome { if entry .wiring @@ -1946,7 +2028,7 @@ mod tests { for entry in [&entry_b, &entry_a] { let outcome = revert_bun(entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); } assert_eq!(fx.read_lock().await, BN4C_BEFORE_LOCK, "lock byte-restored"); assert!(!fx @@ -2028,7 +2110,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!( fx.read_lock().await, crlf_before, @@ -2502,7 +2584,7 @@ mod tests { ); let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!( fx.read_lock().await, lock, @@ -2813,7 +2895,7 @@ mod tests { let entry = entry.expect("success carries a ledger entry"); let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, lock, "lock byte-restored"); } @@ -2982,7 +3064,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "an unmoved entry is ours, not drift: {:?}", outcome.warnings ); @@ -2997,6 +3079,17 @@ mod tests { .exists()); } + /// A revert's lock-wiring advisories: every fixture installs a hoisted + /// `node_modules/left-pad`, whose [`REINSTALL_REQUIRED`] the tests below + /// that are about the lock leave out. + fn lock_warnings(outcome: &RevertOutcome) -> Vec<&VendorWarning> { + outcome + .warnings + .iter() + .filter(|w| w.code != REINSTALL_REQUIRED) + .collect() + } + #[tokio::test] async fn dry_run_writes_nothing() { let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; @@ -3032,7 +3125,9 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + // The hoisted install's copy is the only advisory (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!(codes, [REINSTALL_REQUIRED], "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "lock byte-restored"); assert!(!tgz_path.exists()); assert!(!fx @@ -3041,6 +3136,95 @@ mod tests { .exists()); } + /// #764: Bun's hoisted linker keeps `node_modules/` when the lock + /// entry returns from the vendored tarball to the registry record, so a + /// plain `bun install` leaves the vendored bytes installed. The revert + /// says so and names the install that does reinstall (measured on Bun + /// 1.2.23, 1.3.14 and 1.4.2: `bun install --force` reinstalls and keeps + /// the lock). + #[tokio::test] + async fn revert_warns_that_bun_keeps_a_hoisted_copy() { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + + let dry = revert_bun(&entry, fx.root(), true).await; + assert!( + dry.warnings.is_empty(), + "a preview changes nothing installed" + ); + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + let w = outcome + .warnings + .iter() + .find(|w| w.code == REINSTALL_REQUIRED) + .expect("the hoisted copy is reported"); + assert!(w.detail.contains("left-pad@1.3.0"), "{}", w.detail); + assert!(w.detail.contains("`bun install --force`"), "{}", w.detail); + } + + /// The isolated linker relinks `node_modules/` to the restored + /// registry entry, and a project with nothing installed has nothing to + /// keep: neither warns. + #[cfg(unix)] + #[tokio::test] + async fn revert_skips_the_advisory_without_a_hoisted_copy() { + for isolated in [true, false] { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let modules = fx.root().join("node_modules"); + if isolated { + let store = modules.join(".bun/left-pad@1.3.0/node_modules/left-pad"); + tokio::fs::create_dir_all(store.parent().unwrap()) + .await + .unwrap(); + tokio::fs::rename(modules.join("left-pad"), &store) + .await + .unwrap(); + std::os::unix::fs::symlink(&store, modules.join("left-pad")).unwrap(); + } else { + tokio::fs::remove_dir_all(&modules).await.unwrap(); + } + + let outcome = revert_bun(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "isolated={isolated}: {:?}", + outcome.warnings + ); + } + } + + /// A hoisted tree (no `node_modules/.bun/`) may hold the copy nested + /// under another package; only an isolated tree proves it absent. + #[tokio::test] + async fn stale_hoisted_copies_reads_the_linker_layout() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let purls = ["pkg:npm/@scope/a@1.0.0", "pkg:npm/b@2.0.0"]; + assert!(stale_hoisted_copies(root, purls).await.is_empty()); + + tokio::fs::create_dir_all(root.join("node_modules/@scope/a")) + .await + .unwrap(); + assert_eq!( + stale_hoisted_copies(root, purls).await, + ["@scope/a@1.0.0", "b@2.0.0"] + ); + + tokio::fs::create_dir_all(root.join("node_modules/.bun")) + .await + .unwrap(); + assert_eq!(stale_hoisted_copies(root, purls).await, ["@scope/a@1.0.0"]); + } + /// bun.lock is a user-owned file we merely edit: the vendor rewrite and /// the revert restore must keep its permission bits (a 0600 private lock /// must not silently become umask-default 0644). @@ -3188,7 +3372,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "no drift left after the undo: {:?}", outcome.warnings ); @@ -3305,7 +3489,7 @@ mod tests { deletion guard must not fire: {:?}", outcome.error ); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); assert!(tgz_path.exists(), "artifact kept"); assert_eq!( @@ -3468,7 +3652,7 @@ mod tests { ) .await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert!(!outcome.kept_artifact, "preserve-state is not a drift-keep"); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK, "wiring restored"); assert!(tgz_path.exists(), "artifact deliberately kept"); @@ -3476,7 +3660,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "converged records are silent: {:?}", outcome.warnings ); @@ -3671,7 +3855,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "already-converged is silent: {:?}", outcome.warnings ); @@ -3762,7 +3946,7 @@ mod tests { // The healed lock reverts through the ORIGINAL entry byte-exactly. let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(lock_warnings(&outcome).is_empty(), "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, BN3_BEFORE_LOCK); assert!(!fx .root() @@ -3786,7 +3970,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "a digest-less spelling of our own tuple is not drift: {:?}", outcome.warnings ); @@ -3828,7 +4012,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!( - outcome.success && outcome.warnings.is_empty(), + outcome.success && lock_warnings(&outcome).is_empty(), "{outcome:?}" ); assert_eq!(fx.read_lock().await, crlf_before); @@ -3866,7 +4050,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!( - outcome.success && outcome.warnings.is_empty(), + outcome.success && lock_warnings(&outcome).is_empty(), "v{version}: {outcome:?}" ); let original_line = entry.wiring[0] @@ -4200,7 +4384,7 @@ mod tests { let outcome = revert_bun(&entry, fx.root(), false).await; assert!(outcome.success, "{:?}", outcome.error); assert!( - outcome.warnings.is_empty(), + lock_warnings(&outcome).is_empty(), "the converged re-run is silent: {:?}", outcome.warnings ); diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 3959ebbdb..bea10ee71 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -162,7 +162,11 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. rolled back (v5.0 keeps no hosted ledger, and a rebuilt binary record is not byte-exact for every lock): rollback and remove refuse it with the `git checkout -- bun.lockb` remedy, while the hosted → vendored takeover rebuilds its npm registry record natively - and vendors over it. + and vendors over it. Bun's hoisted linker keeps an installed copy whose lock entry + returns to the registry record (a plain `bun install` reports no changes), so after + `rollback`, `remove` or `vendor --revert` the patched bytes stay in `node_modules` until + `bun install --force` (or deleting `node_modules`); `redirect_bun_reinstall_required` / + `vendor_bun_reinstall_required` say so whenever such a copy may be installed. Bun verifies the sha512 of URL and local-tarball tuples only from 1.3.10 (registry tuples from 1.2.0), so on 1.1.39–1.3.9 a hosted or vendored rewrite removes digest enforcement for the patched package. Every boundary here is measured against real From aa51efe101794bd6327e83db4cf9f01b0003e405 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:57:55 -0400 Subject: [PATCH 17/55] Narrow the Bun reinstall advisory to reverts that restored an entry (#764) Review follow-ups for the Bun stale-copy advisory: - revert_bun_opts emitted vendor_bun_reinstall_required even when the revert restored nothing: bun.lock missing (vendor_lockfile_missing) or the dependency removed by the user (vendor_lock_entry_removed). On a hoisted tree a missing node_modules/ counts as "may be nested", so a `bun remove`d package was told to `bun install --force`. Skip the advisory in both cases; the two existing tests now assert it is absent. - rollback/remove's generic reinstall_required note said patched bytes stay "until the next package-manager install", contradicting the Bun advisory. When a Bun advisory fires in the same run, the JSON detail and the human note now add "(Bun: a plain `bun install` keeps them; run `bun install --force`)". - vendor --revert of a pre-v5 entry vendored over a hosted pin printed both the per-entry vendor_bun_reinstall_required and the hosted unwind's run-level redirect_bun_reinstall_required for the same package. Drop the run-level one when every re-hosted purl already carries the per-entry advisory. - Restore two unrelated rustfmt reflows in vendor.rs to their origin/main formatting. CLI_CONTRACT.md rows updated to match. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../socket-patch-cli/src/commands/rollback.rs | 68 ++++++++++++++++--- .../socket-patch-cli/src/commands/vendor.rs | 26 ++++++- .../socket-patch-core/src/vendor/bun_lock.rs | 26 ++++++- 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 5b6c2fc74..8e5067ed8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1224,7 +1224,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | | `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. | | `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | -| `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. | +| `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. When the run also emits a Bun advisory (`vendor_bun_reinstall_required` / `redirect_bun_reinstall_required`) the detail and the human note add " (Bun: a plain `bun install` keeps them; run `bun install --force`)". | | `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted pins were restored to upstream anyway — the lockfile pins are hosted mode's only record, so there is no local state to preserve; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `vendor_ledger_entry_unwired` | scan `warnings[]` | a vendored entry's dependency left the lockfile (upgraded or removed), so the ledger supplement skipped it; the detail names the purls and points at `scan --prune`, which reverts them (no warning on a pruning non-hosted run). An entry that prune drift-keeps (its lock entries were re-resolved since vendoring, e.g. an npm uninstall re-locked it away) is reported on the prune's `GC: kept` line and keeps being warned about. | @@ -1291,7 +1291,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | | `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | -| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]` | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run or a drift-kept revert. | +| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]` | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt and the npm-family tarball flavors: npm, pnpm, bun, yarn classic, yarn berry): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` or `vendor/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. A file rule such as `*.tgz` is overridden by the `/.gitignore` vendoring writes; if the written artifact still reads as ignored, the run refuses and removes the uuid dir it created. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt and the npm-family tarball flavors): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | @@ -1338,7 +1338,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_vlt_no_lockfile` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | | `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | | `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | -| `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). | +| `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). `vendor --revert` leaves it out when the vendored revert already advised `vendor_bun_reinstall_required` for every package it re-hosted. | | `vendor_prebuilt_stub_invalid` | `failed` | RubyGems: the server stub lacks required attributes or is otherwise invalid; no local stub fallback is permitted. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index d80a5223b..19654f40d 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -355,9 +355,24 @@ fn format_gc_freed(bytes: u64, dry_run: bool) -> String { ) } +/// Appended to the generic stale-install advisory when a Bun advisory +/// fired in the same run: Bun's hoisted linker keeps the patched copy +/// through a plain `bun install` (#764), so "the next package-manager +/// install" alone would contradict it. +const BUN_REINSTALL_QUALIFIER: &str = + " (Bun: a plain `bun install` keeps them; run `bun install --force`)"; + +/// True when the run's leg warnings carry a Bun reinstall advisory. +fn bun_reinstall_advised<'a>(mut codes: impl Iterator) -> bool { + codes.any(|c| { + c == socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED + || c == "redirect_bun_reinstall_required" + }) +} + /// The reinstall note for packages whose wiring was undone but whose /// installed tree still holds patched bytes. -fn format_reinstall_note(still_patched: usize, dry_run: bool) -> String { +fn format_reinstall_note(still_patched: usize, dry_run: bool, bun: bool) -> String { let keep = match (still_patched == 1, dry_run) { (true, false) => "keeps its", (true, true) => "would keep its", @@ -366,8 +381,9 @@ fn format_reinstall_note(still_patched: usize, dry_run: bool) -> String { }; format!( "Note: {} {keep} patched bytes in installed trees until the next \ - package-manager install.", - plural(still_patched, "unwired package", "unwired packages") + package-manager install{}.", + plural(still_patched, "unwired package", "unwired packages"), + if bun { BUN_REINSTALL_QUALIFIER } else { "" } ) } @@ -1647,12 +1663,25 @@ pub async fn run(args: RollbackArgs) -> i32 { let unwired_any = !vendored_leg.reverted.is_empty() || !vendored_leg.preserved.is_empty() || !hosted_leg.reverted.is_empty(); + let bun_advised = bun_reinstall_advised( + vendored_leg + .warnings + .iter() + .chain(hosted_leg.warnings.iter()) + .map(|(code, _)| code.as_str()), + ); if unwired_any { run_warnings.push(( "reinstall_required".into(), - "unwired packages keep their patched bytes in installed trees until \ - the next package-manager install" - .into(), + format!( + "unwired packages keep their patched bytes in installed trees until \ + the next package-manager install{}", + if bun_advised { + BUN_REINSTALL_QUALIFIER + } else { + "" + } + ), )); } if args.preserve_state && !hosted_leg.reverted.is_empty() { @@ -1943,7 +1972,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if still_patched > 0 { println!( "\n{}", - format_reinstall_note(still_patched, args.common.dry_run) + format_reinstall_note(still_patched, args.common.dry_run, bun_advised) ); } } @@ -5137,14 +5166,35 @@ mod tests { #[test] fn reinstall_note_tense_and_number() { assert_eq!( - format_reinstall_note(1, false), + format_reinstall_note(1, false, false), "Note: 1 unwired package keeps its patched bytes in installed trees until the \ next package-manager install." ); assert_eq!( - format_reinstall_note(2, true), + format_reinstall_note(2, true, false), "Note: 2 unwired packages would keep their patched bytes in installed trees \ until the next package-manager install." ); } + + /// #764: next to a Bun advisory the generic note must not imply that + /// any install refreshes the copy. + #[test] + fn reinstall_note_defers_to_the_bun_advisory() { + assert_eq!( + format_reinstall_note(1, false, true), + "Note: 1 unwired package keeps its patched bytes in installed trees until the \ + next package-manager install (Bun: a plain `bun install` keeps them; run \ + `bun install --force`)." + ); + assert!(bun_reinstall_advised( + ["cleanup_failed", "vendor_bun_reinstall_required"].into_iter() + )); + assert!(bun_reinstall_advised( + ["redirect_bun_reinstall_required"].into_iter() + )); + assert!(!bun_reinstall_advised( + ["redirect_vlt_reinstall_required"].into_iter() + )); + } } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 194431859..82870f272 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -442,7 +442,10 @@ async fn unwired_check_failure( /// entry (fail-safe): ecosystems other than npm, cargo and pypi (whose /// probe covers the requirements flavor only) have no in-use probe yet, /// and a missing/unreadable lockfile proves nothing. -pub(crate) async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { +pub(crate) async fn dispatch_in_use_one( + entry: &VendorEntry, + project_root: &Path, +) -> Option { match entry.ecosystem.as_str() { "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, // Cargo probes the lock entry's shape: detached + `[patch]` pointing @@ -1234,7 +1237,8 @@ async fn run_check(args: &VendorArgs) -> i32 { // know (the ledger was ignored or dropped from the commit along with the // manifest) leaves every fresh install failing; the manifest keys above // cannot see it, so the references are read from the wiring itself. - let references = crate::commands::vendored_backend::repair::scan_vendor_references(root).await; + let references = + crate::commands::vendored_backend::repair::scan_vendor_references(root).await; for (eco, uuid, rel) in references { let ledgered = state .entries @@ -3969,7 +3973,25 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { .with_error("hosted_restore_failed", why.clone()), ); } + // The vendored revert above already advised a Bun reinstall + // per package (#764); the hosted unwind's run-level twin for + // the same packages would only repeat it. + let bun_advised: HashSet = env + .events + .iter() + .filter(|e| { + e.error_code.as_deref() + == Some(socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED) + }) + .filter_map(|e| e.purl.as_deref().map(canonical_purl)) + .collect(); + let bun_repeat = rehosted + .iter() + .all(|pin| bun_advised.contains(&canonical_purl(&pin.purl))); for (code, detail) in &leg.warnings { + if bun_repeat && code == "redirect_bun_reinstall_required" { + continue; + } env.warnings.push(RunWarning { code: code.clone(), detail: detail.clone(), diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index a8a369d3a..073ad6283 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -850,7 +850,15 @@ pub(crate) async fn revert_bun_opts( opts: RevertOpts, ) -> RevertOutcome { let mut outcome = revert_bun_wiring(entry, project_root, opts).await; - if outcome.success && !outcome.kept_artifact && !opts.dry_run { + // Only a revert that put a lock entry back can leave Bun keeping a + // copy: with the lock missing nothing was restored, and with the entry + // removed (`bun remove`) a plain `bun install` prunes the copy. + let restored_nothing = outcome.lock_entry_removed() + || outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lockfile_missing"); + if outcome.success && !outcome.kept_artifact && !opts.dry_run && !restored_nothing { let stale = stale_hoisted_copies(project_root, [entry.base_purl.as_str()]).await; if !stale.is_empty() { outcome.warnings.push(VendorWarning::new( @@ -3595,6 +3603,14 @@ mod tests { "{:?}", outcome.warnings ); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "nothing restored, so nothing for Bun to keep (#764): {:?}", + outcome.warnings + ); assert!(!outcome.kept_artifact, "a missing lock is not a drift-keep"); assert!( !fx.root() @@ -3786,6 +3802,14 @@ mod tests { "{:?}", outcome.warnings ); + assert!( + outcome + .warnings + .iter() + .all(|w| w.code != REINSTALL_REQUIRED), + "a removed dependency is pruned by a plain `bun install` (#764): {:?}", + outcome.warnings + ); assert!(!outcome.kept_artifact, "{:?}", outcome.warnings); assert_eq!(fx.read_lock().await, without_entry, "nothing rewritten"); assert!( From 15c5ff8ec2d521aa64de32a9c15e867ff3b322dc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:59:07 -0400 Subject: [PATCH 18/55] Reconcile #764 with #784's migrated-lock revert tests #784's migrated bun.lockb -> bun.lock revert tests route through bun_lock::revert_bun_opts and asserted the outcome carried no warnings. #764 makes that wrapper add vendor_bun_reinstall_required after any wet revert that restored a lock entry while a hoisted node_modules copy is installed, which these fixtures have (node_modules/minimist). The advisory is correct there: Bun's hoisted linker keeps the vendored copy after the migrated lock returns to the registry record too. Assert it is the only warning instead of asserting none. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/bun_binary.rs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 8c759a8b1..4dcf69704 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1401,7 +1401,14 @@ mod rebuild_tests { super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) .await; assert!(outcome.success, "{bun}: {outcome:?}"); - assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); assert_eq!( std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), pristine, @@ -1457,7 +1464,14 @@ mod rebuild_tests { super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) .await; assert!(outcome.success, "{bun}: {outcome:?}"); - assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); assert_eq!( std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), pristine, @@ -1517,7 +1531,14 @@ mod rebuild_tests { super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) .await; assert!(outcome.success, "{bun}: {outcome:?}"); - assert!(outcome.warnings.is_empty(), "{bun}: {outcome:?}"); + // The fixture's hoisted node_modules/minimist is the only + // advisory: Bun keeps it after the restore (#764). + let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {outcome:?}" + ); assert_eq!( std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), pristine, From a668776784d40bb4ae3057048b6fc7cfca390297 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:52:07 -0400 Subject: [PATCH 19/55] Warn when a Bun rewire drops default trust (#371) Bun runs dependency lifecycle scripts only for packages in the project's trustedDependencies or, when the project declares none, for names on its built-in default trusted list (better-sqlite3, esbuild, sharp, ...). From Bun 1.3.5 that default applies only to packages resolved from the npm registry (Lockfile.hasTrustedDependency now checks resolution.tag == .npm). Rewiring a default-trusted package to a hosted tarball URL or a vendored local tarball therefore made `bun install` skip its install scripts with exit 0, leaving native bindings unbuilt, while socket-patch reported success with no warning. Measured with real Bun: a local-tarball better-sqlite3 runs its install script on 1.3.4 and not on 1.4.2; adding trustedDependencies restores it. Adding the package to trustedDependencies automatically is not safe: declaring the field replaces Bun's whole default list, and package.json and the lock's mirror would both have to change. Like the pnpm --trust-lockfile precedent, the run now says so instead: - bun_lock_text gains loses_default_trust (no trustedDependencies in the root manifest or bun.lock's mirror, and the name is on Bun's default list, embedded from upstream as of 1.4.2) and a shared detail text. - Hosted: rewrite_bun_lock warns redirect_bun_default_trust_lost for each non-bundled pin it writes or confirms; the engine does the same for bun.lockb pins after rewrite_bun_binary. - Vendored: the text and binary backends warn vendor_bun_default_trust_lost, reading the root manifest once with the patchedDependencies lookup. The warning repeats on every run while the pin stays and no list is declared. Documented in CLI_CONTRACT.md and bun-compatibility.md. The new bun-lockb-trusted fixture is real Bun 1.4.2 output. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 1 + crates/socket-patch-core/src/hosted/engine.rs | 115 +++++- .../src/patch/redirect/mod.rs | 128 ++++++ .../src/vendor/bun_binary.rs | 16 +- .../src/vendor/bun_default_trusted.txt | 367 ++++++++++++++++++ .../socket-patch-core/src/vendor/bun_lock.rs | 151 ++++++- .../src/vendor/bun_lock_text.rs | 60 ++- .../fixtures/bun-lockb-trusted/bun.lockb | Bin 0 -> 1673 bytes .../fixtures/bun-lockb-trusted/bunfig.toml | 2 + .../fixtures/bun-lockb-trusted/package.json | 9 + .../bun-lockb-trusted/provenance.json | 8 + .../bun-lockb-trusted.json | 19 + docs/testing/bun-compatibility.md | 1 + 13 files changed, 857 insertions(+), 20 deletions(-) create mode 100644 crates/socket-patch-core/src/vendor/bun_default_trusted.txt create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8e5067ed8..37e2e8d89 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1329,6 +1329,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_lockb_invalid` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | +| `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index bc3b43be4..d29abbe34 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1163,7 +1163,25 @@ pub async fn rewrite( }) .cloned() .collect(); - crate::patch::redirect::rewrite_bun_binary(&bytes, &binary_overrides, &mut rewrite) + crate::patch::redirect::rewrite_bun_binary(&bytes, &binary_overrides, &mut rewrite); + // A pinned default-trusted package loses Bun's default trust + // (#371); the text rewriter warns the same way. + for o in &binary_overrides { + let name = crate::patch::redirect::full_name(o); + if rewrite.confirmed_bun_binary_uuids.contains(&o.patch_uuid) + && crate::vendor::bun_lock_text::loses_default_trust( + files.get("package.json").map(String::as_str), + None, + &name, + ) + { + rewrite + .warnings + .push(crate::patch::redirect::bun_default_trust_warning( + &name, &o.version, + )); + } + } } Err(warning) => rewrite.warnings.push(warning), } @@ -2438,6 +2456,101 @@ mod tests { } } + /// REGRESSION (#371), binary lock: a default-trusted package pinned to + /// its hosted URL in a `bun.lockb` loses Bun 1.3.5+'s default trust, so + /// its install scripts are silently skipped; the run says so unless the + /// root manifest declares `trustedDependencies`. The fixture is real Bun + /// 1.4.2 output: `simple-git-hooks` is on the default list, `is-number` + /// is not. + #[tokio::test] + async fn issue_371_bun_lockb_default_trusted_package_warns_that_trust_is_lost() { + use crate::patch::redirect::Integrity; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-trusted"); + let candidate = |name: &str, version: &str, uuid: &str| Candidate { + purl: format!("pkg:npm/{name}@{version}"), + dep: DepOverride { + ecosystem: "npm".into(), + name: name.into(), + namespace: None, + version: version.into(), + token: "tok".into(), + patch_uuid: uuid.into(), + artifact_url: format!("https://patch.test/{name}-{version}.tgz"), + registry_override: None, + integrity: Integrity { + sha512: Some(format!("sha512-{}==", "A".repeat(86))), + ..Default::default() + }, + }, + }; + let candidates = vec![ + candidate("simple-git-hooks", "2.11.1", "uuid-hooks"), + candidate("is-number", "7.0.0", "uuid-isn"), + ]; + let manifest = std::fs::read_to_string(fixture.join("package.json")).unwrap(); + let declared = manifest.replacen( + "\"private\": true,", + "\"private\": true,\n \"trustedDependencies\": [\"simple-git-hooks\"],", + 1, + ); + assert_ne!(declared, manifest); + for trusted in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::copy(fixture.join("bun.lockb"), tmp.path().join("bun.lockb")).unwrap(); + std::fs::write( + tmp.path().join("package.json"), + if trusted { &declared } else { &manifest }, + ) + .unwrap(); + let view = ProjectView::Disk(tmp.path()); + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + blocking: false, + }; + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + let done = rewrite( + &view, + read, + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + &[], + options, + ) + .await; + assert!( + done.rewrite.binary_files.contains_key("bun.lockb"), + "{:?}", + done.rewrite.warnings + ); + assert_eq!(done.confirmed.len(), 2, "{:?}", done.confirmed); + let lost: Vec<&RewriteWarning> = done + .rewrite + .warnings + .iter() + .filter(|w| w.code == "redirect_bun_default_trust_lost") + .collect(); + if trusted { + assert!(lost.is_empty(), "{:?}", done.rewrite.warnings); + } else { + assert_eq!(lost.len(), 1, "{:?}", done.rewrite.warnings); + assert!( + lost[0].detail.contains("simple-git-hooks@2.11.1"), + "{}", + lost[0].detail + ); + } + } + } + /// REGRESSION (#367), text lock: the root manifest is read beside a /// `bun.lock` even when the lock has no `workspaces` section to reach it /// through, and a package the project patches itself is never diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d964c2d95..1c1a57dae 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4804,6 +4804,19 @@ fn parse_bun_hosted_lock( Ok((lines, entries)) } +/// The warning for a package the bun rewriters pinned to its hosted URL that +/// Bun 1.3.5+ no longer trusts by default (#371). +pub(crate) fn bun_default_trust_warning(name: &str, version: &str) -> RewriteWarning { + RewriteWarning { + code: "redirect_bun_default_trust_lost".into(), + detail: crate::vendor::bun_lock_text::default_trust_detail( + name, + version, + "a hosted tarball URL", + ), + } +} + /// Leave `dep` on its registry resolution when the project's own /// `patchedDependencies` patches it (#367): Bun applies that patch only to /// the registry `name@version`, so a hosted pin would silently drop it from @@ -4887,6 +4900,8 @@ fn rewrite_bun_lock( let target_spec = format!("{fname}@{}", dep.version); let url_spec = format!("{fname}@{}", dep.artifact_url); let mut matched_any = false; + // A non-bundled instance now resolves to the hosted URL. + let mut wired = false; for entry in &entries { let Some(spec) = entry.elems.first().and_then(|e| decode_json_string(e)) else { continue; @@ -4938,6 +4953,7 @@ fn rewrite_bun_lock( // `new` (`bun_lock_text::same_wiring_modulo_integrity`), so // the chain still unwinds to the pristine registry line. matched_any = true; + wired = true; if entry.elems.len() == 3 && entry.elems[2] == format!("\"{sha512}\"") { continue; } @@ -4963,6 +4979,7 @@ fn rewrite_bun_lock( continue; } matched_any = true; + wired = true; let original = lines[entry.line_idx].clone(); // Lines come from a bare `split('\n')`, so a CRLF lock's lines // carry a trailing `\r` (the grammar trims it away when parsing). @@ -4997,6 +5014,17 @@ fn rewrite_bun_lock( changed = true; } pinned_any |= matched_any; + if wired + && crate::vendor::bun_lock_text::loses_default_trust( + files.get("package.json").map(String::as_str), + Some(content), + &fname, + ) + { + result + .warnings + .push(bun_default_trust_warning(&fname, &dep.version)); + } if !matched_any { // Mirrors the pnpm/berry/uv rewriters: a granted dep that matched // no rewritable tuple (lock re-resolved to another version, entry @@ -11195,6 +11223,106 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// REGRESSION (#371): from Bun 1.3.5 on, Bun's default trusted list + /// (better-sqlite3, esbuild, sharp, simple-git-hooks, …) applies only to + /// packages resolved from the npm registry, so a default-trusted package + /// rewired to a hosted URL has its install scripts skipped with exit 0. + /// The rewrite says so, on the first run and on an already-wired re-run; + /// a project that declares `trustedDependencies` (in package.json or + /// bun.lock's mirror) decides trust by name alone and is not warned, nor + /// is a package off the default list. + #[test] + fn bun_lock_default_trusted_package_warns_that_trust_is_lost() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let hooks = npm_override( + "simple-git-hooks", + "2.11.1", + "http://p.test/simple-git-hooks-2.11.1.tgz", + &sha512, + ); + let mut other = npm_override("is-number", "7.0.0", "http://p.test/isn.tgz", &sha512); + other.patch_uuid = "22222222-2222-4222-8222-222222222222".into(); + let entries = "\"is-number\": [\"is-number@7.0.0\", \"\", {}, \"sha512-UP==\"],\n \ + \"simple-git-hooks\": [\"simple-git-hooks@2.11.1\", \"\", { \"bin\": \ + { \"simple-git-hooks\": \"cli.js\" } }, \"sha512-OLD==\"],"; + let manifest = + r#"{"name":"app","dependencies":{"is-number":"7.0.0","simple-git-hooks":"2.11.1"}}"#; + let overrides = [hooks.clone(), other.clone()]; + let run = |lock: &str, manifest: Option<&str>| { + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), lock.to_string()); + if let Some(manifest) = manifest { + files.insert("package.json".to_string(), manifest.to_string()); + } + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, &overrides, &mut r); + r + }; + + let lock = bun_lock_file(entries, 1); + let first = run(&lock, Some(manifest)); + let wired = first.files.get("bun.lock").expect("both rewired").clone(); + assert_eq!(first.edits.len(), 2, "{:?}", first.edits); + assert_eq!( + warning_codes(&first), + vec!["redirect_bun_default_trust_lost"], + "{:?}", + first.warnings + ); + let detail = &first.warnings[0].detail; + assert!( + detail.contains("simple-git-hooks@2.11.1") + && detail.contains("trustedDependencies") + && detail.contains("1.3.5"), + "{detail}" + ); + // Without a readable manifest Bun still has no explicit list. + assert_eq!( + warning_codes(&run(&lock, None)), + vec!["redirect_bun_default_trust_lost"] + ); + // An already-wired re-run keeps saying so until trust is declared. + let rerun = run(&wired, Some(manifest)); + assert!(rerun.files.is_empty() && rerun.edits.is_empty()); + assert_eq!( + warning_codes(&rerun), + vec!["redirect_bun_default_trust_lost"] + ); + + // An explicit list (even one that omits the package: Bun then never + // trusted it by default) leaves trust unchanged by the rewire. + for declared in [ + r#"{"name":"app","trustedDependencies":["simple-git-hooks"]}"#, + r#"{"name":"app","trustedDependencies":[]}"#, + "{\n // JSONC, as Bun reads it\n \"trustedDependencies\": [\"simple-git-hooks\",],\n}", + ] { + let r = run(&lock, Some(declared)); + assert_eq!(r.edits.len(), 2); + assert!(r.warnings.is_empty(), "{declared}: {:?}", r.warnings); + } + let mirrored = lock.replacen( + " \"packages\": {", + " \"trustedDependencies\": [\n \"simple-git-hooks\",\n ],\n \"packages\": {", + 1, + ); + assert_ne!(mirrored, lock); + assert!(run(&mirrored, None).warnings.is_empty()); + + // A bundled copy is not rewired, so its trust is not the rewire's + // to lose (its own warning says it stays unpatched). + let bundled = bun_lock_file( + "\"p/simple-git-hooks\": [\"simple-git-hooks@2.11.1\", \"\", { \"bundled\": true }, \ + \"sha512-OLD==\"],", + 1, + ); + let r = run(&bundled, Some(manifest)); + assert!( + !warning_codes(&r).contains(&"redirect_bun_default_trust_lost"), + "{:?}", + r.warnings + ); + } + /// A CRLF bun.lock (Windows `core.autocrlf` checkout) must keep CRLF on /// the REWRITTEN line too — the vendored engine already does — so the /// file never ends up mixed-EOL, and the ledger `new` fragment carries diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 4dcf69704..6497b031c 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -128,8 +128,16 @@ pub(crate) async fn vendor( Ok(v) => v, Err(o) => return *o, }; - let BinaryProject { mut lock, .. } = project; + let BinaryProject { + mut lock, manifest, .. + } = project; let mut warnings = Vec::new(); + warnings.extend(super::bun_lock::default_trust_warning( + manifest.as_deref(), + None, + &coords.name, + &coords.version, + )); for package in bundled { // LOUD: this copy ships inside its PARENT's tarball, which we do not // repack — it stays the unpatched bytes after vendor (#469). @@ -356,6 +364,8 @@ pub(super) struct BinaryProject { packages: Vec, /// The project's own `patchedDependencies` keys (#367). user_patched: Vec, + /// The root `package.json` text, `None` when unreadable. + manifest: Option, } /// Read the lock, refusing (before any write) a symlinked, unreadable, @@ -381,11 +391,13 @@ pub(super) async fn read_project(root: &Path) -> Result v, Err(e) => return Err(Box::new(refused("vendor_bun_lockb_invalid", e))), }; - let user_patched = super::bun_lock::read_user_patched(root, None).await; + let manifest = super::bun_lock::read_manifest(root).await; + let user_patched = super::bun_lock_text::patched_dependency_keys(manifest.as_deref(), None); Ok(BinaryProject { lock, packages, user_patched, + manifest, }) } diff --git a/crates/socket-patch-core/src/vendor/bun_default_trusted.txt b/crates/socket-patch-core/src/vendor/bun_default_trusted.txt new file mode 100644 index 000000000..f8a85ba83 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/bun_default_trusted.txt @@ -0,0 +1,367 @@ +@airbnb/node-memwatch +@anthropic-ai/claude-code +@apollo/protobufjs +@apollo/rover +@appsignal/nodejs +@arkweid/lefthook +@aws-amplify/cli +@bahmutov/add-typescript-to-cypress +@bazel/concatjs +@bazel/cypress +@bazel/esbuild +@bazel/hide-bazel-files +@bazel/jasmine +@bazel/protractor +@bazel/rollup +@bazel/terser +@bazel/typescript +@bufbuild/buf +@cdktf/node-pty-prebuilt-multiarch +@ckeditor/ckeditor5-vue +@cloudflare/wrangler +@contrast/fn-inspect +@cubejs-backend/cubestore +@cubejs-backend/native +@cypress/snapshot +@danmarshall/deckgl-typings +@datadog/mobile-react-native +@discordjs/opus +@eversdk/lib-node +@evilmartians/lefthook +@ffmpeg-installer/darwin-arm64 +@ffmpeg-installer/darwin-x64 +@ffmpeg-installer/linux-arm +@ffmpeg-installer/linux-arm64 +@ffmpeg-installer/linux-ia32 +@ffmpeg-installer/linux-x64 +@ffprobe-installer/darwin-arm64 +@ffprobe-installer/darwin-x64 +@ffprobe-installer/linux-arm +@ffprobe-installer/linux-arm64 +@ffprobe-installer/linux-ia32 +@ffprobe-installer/linux-x64 +@fingerprintjs/fingerprintjs-pro-react +@ghaiklor/x509 +@go-task/cli +@injectivelabs/sdk-ts +@instana/autoprofile +@intlify/vue-i18n-bridge +@intlify/vue-router-bridge +@matteodisabatino/gc_info +@memlab/cli +@microsoft.azure/autorest-core +@microsoft/teamsfx-cli +@microsoft/ts-command-line +@napi-rs/pinyin +@nativescript/core +@netlify/esbuild +@newrelic/native-metrics +@notarize/qlc-cli +@nx-dotnet/core +@opensearch-project/oui +@pact-foundation/pact-node +@paloaltonetworks/postman-code-generators +@pdftron/pdfnet-node +@percy/core +@pnpm/exe +@prisma/client +@prisma/engines +@progress/kendo-licensing +@pulumi/aws-native +@pulumi/awsx +@pulumi/command +@pulumi/kubernetes +@railway/cli +@replayio/cypress +@replayio/playwright +@roots/bud-framework +@sap/hana-client +@sap/hana-performance-tools +@sap/hana-theme-vscode +@scarf/scarf +@sematext/gc-stats +@sentry/capacitor +@sentry/profiling-node +@serialport/bindings +@serialport/bindings-cpp +@shopify/ngrok +@shopify/plugin-cloudflare +@sitespeed.io/chromedriver +@sitespeed.io/edgedriver +@softvisio/core +@splunk/otel +@strapi/strapi +@sveltejs/kit +@syncfusion/ej2-angular-base +@taquito/taquito +@temporalio/core-bridge +@tensorflow/tfjs-node +@trufflesuite/bigint-buffer +@typescript-tools/rust-implementation +@vaadin/vaadin-usage-statistics +@vscode/ripgrep +@vscode/sqlite3 +abstract-socket +admin-lte +appdynamics +appium-chromedriver +appium-windows-driver +applicationinsights-native-metrics +argon2 +autorest +aws-crt +azure-functions-core-tools +azure-streamanalytics-cicd +backport +bcrypt +better-sqlite3 +bigint-buffer +blake-hash +bs-platform +bufferutil +bun +canvacord +canvas +cbor-extract +chromedriver +chromium +classic-level +cld +cldr-data +clevertap-react-native +clientjs +cmark-gfm +compresion +contentlayer +contextify +cordova.plugins.diagnostic +couchbase +cpu-features +cwebp-bin +cy2 +cypress +dd-trace +deasync +detox +detox-recorder +diskusage +dotnet-2.0.0 +dprint +drivelist +dtrace-provider +duckdb +dugite +eccrypto +egg-bin +egg-ci +electron +electron-chromedriver +electron-prebuilt +electron-winstaller +elm +elm-format +esbuild +esoftplay +event-loop-stats +exifreader +farmhash +fast-folder-size +faunadb +ffi +ffi-napi +ffmpeg-static +fibers +fmerge +free-email-domains +fs-xattr +full-icu +gatsby +gc-stats +gcstats.js +geckodriver +gentype +ghooks +gif2webp-bin +gifsicle +git-commit-msg-linter +git-validate +git-win +gl +go-ios +grpc +grpc-tools +handbrake-js +hasura-cli +heapdump +hiredis +hnswlib-node +hugo-bin +hummus +ibm_db +iconv +iedriver +iltorb +incremental-json-parser +install-peers +interruptor +iobroker.js-controller +iso-constants +isolated-vm +java +jest-preview +jpeg-recompress-bin +jpegtran-bin +keccak +kerberos +keytar +lefthook +leveldown +libpg-query +libpq +libxmljs +libxmljs2 +lightningcss-cli +lint +lmdb +lmdb-store +local-cypress +lz4 +lzma-native +lzo +macos-alias +mbt +memlab +microtime +minidump +mmmagic +modern-syslog +mongodb-client-encryption +mongodb-crypt-library-dummy +mongodb-crypt-library-version +mongodb-memory-server +mozjpeg +ms-chromium-edge-driver +msgpackr-extract +msnodesqlv8 +msw +muhammara +netlify-cli +ngrok +ngx-popperjs +nice-napi +node +node-expat +node-hid +node-jq +node-libcurl +node-mac-contacts +node-pty +node-rdkafka +node-sass +node-webcrypto-ossl +node-zopfli +node-zopfli-es +nodegit +nodejieba +nodent-runtime +nx +odiff-bin +oniguruma +opencode-ai +optipng-bin +oracledb +os-dns-native +parse-server +phantomjs +phantomjs-prebuilt +pkcs11js +playwright-chromium +playwright-firefox +playwright-webkit +pngout-bin +pngquant-bin +posix +pprof +pre-commit +pre-push +prisma +protoc +protoc-gen-grpc-web +puppeteer +purescript +re2 +react-jsx-parser +react-native-stylex +react-particles +react-tsparticles +react-vertical-timeline-component +realm +redis-memory-server +ref +ref-napi +registry-js +robotjs +sauce-connect-launcher +saucectl +secp256k1 +segfault-handler +shared-git-hooks +sharp +simple-git-hooks +sleep +slice2js +snyk +sockopt +sodium-native +sonar-scanner +spago +spectron +spellchecker +sq-native +sqlite3 +sse4_crc32 +ssh2 +storage-engine +subrequests +subrequests-express +subrequests-json-merger +supabase +svf-lib +swagger-ui +swiftlint +taiko +tldjs +tree-sitter +tree-sitter-cli +tree-sitter-json +tree-sitter-kotlin +tree-sitter-typescript +tree-sitter-yaml +truffle +tsparticles-engine +ttag-cli +ttf2woff2 +typemoq +unix-dgram +ursa-optional +usb +utf-8-validate +v8-profiler-next +vue-demi +vue-echarts +vue-inbrowser-compiler-demi +wd +wdeasync +weak-napi +webdev-toolkit +windows-build-tools +wix-style-react +wordpos +workerd +wrtc +xxhash +yo +yorkie +zeromq +zlib-sync +zopflipng-bin diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 073ad6283..7cbf02347 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -41,9 +41,10 @@ use crate::patch::apply::PatchSources; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ - decode_json_string, has_workspace_packages, is_bundled_entry, lock_version, packages_bounds, - parse_entry_line, patched_dependency_detail, patched_dependency_key, patched_dependency_keys, - split_name_spec, BunEntry, + decode_json_string, default_trust_detail, has_workspace_packages, is_bundled_entry, + lock_version, loses_default_trust, packages_bounds, parse_entry_line, + patched_dependency_detail, patched_dependency_key, patched_dependency_keys, split_name_spec, + BunEntry, }; use super::common::{already_patched_result, refused}; @@ -372,6 +373,12 @@ pub(crate) async fn vendor_bun<'a>( ), )); } + warnings.extend(default_trust_warning( + project.manifest.as_deref(), + Some(&project.lock_text), + name, + version, + )); let BunProject { mut lines, entries, .. } = project; @@ -666,16 +673,33 @@ pub(super) struct BunProject { entries: Vec, /// The project's own `patchedDependencies` keys (#367). user_patched: Vec, + /// The root `package.json` text, `None` when unreadable. + manifest: Option, } -/// The root manifest's `patchedDependencies` keys, unioned with the copy -/// Bun mirrors into the text `lock` when there is one. An unreadable or -/// non-JSON manifest contributes none; the lock read stands on its own. -pub(super) async fn read_user_patched(project_root: &Path, lock: Option<&str>) -> Vec { - let manifest = read_regular_to_string(&project_root.join("package.json")) +/// The root `package.json` text, `None` when unreadable: the manifest +/// lookups ([`patched_dependency_keys`], [`loses_default_trust`]) then fall +/// back on what the lock mirrors. +pub(super) async fn read_manifest(project_root: &Path) -> Option { + read_regular_to_string(&project_root.join("package.json")) .await - .ok(); - patched_dependency_keys(manifest.as_deref(), lock) + .ok() +} + +/// The warning for a package vendored to a local tarball that Bun 1.3.5+ no +/// longer trusts by default (#371); `None` when its trust is unchanged. +pub(super) fn default_trust_warning( + manifest: Option<&str>, + lock: Option<&str>, + name: &str, + version: &str, +) -> Option { + loses_default_trust(manifest, lock, name).then(|| { + VendorWarning::new( + "vendor_bun_default_trust_lost", + default_trust_detail(name, version, "a vendored local tarball"), + ) + }) } /// Refuse to vendor a package the project patches itself with `bun patch` @@ -724,12 +748,14 @@ pub(super) async fn read_project(project_root: &Path) -> Result Vec { + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_bun( + purl, + &fx.installed, + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, _, warnings) = expect_done(outcome); + assert!(result.success, "{:?}", result.error); + warnings + .into_iter() + .filter(|w| w.code == "vendor_bun_default_trust_lost") + .map(|w| w.detail) + .collect() + } + + /// REGRESSION (#371): from Bun 1.3.5 on, Bun's default trusted list + /// applies only to packages resolved from the npm registry, so a + /// default-trusted package vendored to a local tarball has its install + /// scripts skipped with exit 0. Vendoring says so, unless the project + /// declares `trustedDependencies` (package.json, or bun.lock's mirror), + /// which decides trust by name alone. + #[tokio::test] + async fn default_trusted_package_warns_that_trust_is_lost() { + let lock = BN3_BEFORE_LOCK.replace("left-pad", "simple-git-hooks"); + let purl = "pkg:npm/simple-git-hooks@1.3.0"; + let fx = fixture_with(&lock, "node_modules/simple-git-hooks").await; + let lost = trust_lost_details(&fx, purl).await; + assert_eq!(lost.len(), 1, "{lost:?}"); + assert!( + lost[0].contains("simple-git-hooks@1.3.0") && lost[0].contains("trustedDependencies"), + "{}", + lost[0] + ); + + let mirrored = lock.replacen( + " \"packages\": {", + " \"trustedDependencies\": [\n \"simple-git-hooks\",\n ],\n \"packages\": {", + 1, + ); + assert_ne!(mirrored, lock); + let fx = fixture_with(&mirrored, "node_modules/simple-git-hooks").await; + assert!(trust_lost_details(&fx, purl).await.is_empty()); + + let fx = fixture_with(&lock, "node_modules/simple-git-hooks").await; + tokio::fs::write( + fx.root().join("package.json"), + BN3_PKG.replacen( + "\"version\": \"1.0.0\",", + "\"version\": \"1.0.0\",\n \"trustedDependencies\": [],", + 1, + ), + ) + .await + .unwrap(); + assert!(trust_lost_details(&fx, purl).await.is_empty()); + + // A package off the default list keeps the plain run quiet. + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + assert!(trust_lost_details(&fx, "pkg:npm/left-pad@1.3.0") + .await + .is_empty()); + } + + /// REGRESSION (#371), `bun.lockb`: the binary lock has no text mirror, + /// so the root manifest's `trustedDependencies` alone decides. Real Bun + /// 1.4.2 fixture: `simple-git-hooks` is on the default list. + #[tokio::test] + async fn binary_default_trusted_package_warns_that_trust_is_lost() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-trusted"); + let manifest = std::fs::read_to_string(dir.join("package.json")).unwrap(); + let declared = manifest.replacen( + "\"private\": true,", + "\"private\": true,\n \"trustedDependencies\": [\"simple-git-hooks\"],", + 1, + ); + assert_ne!(declared, manifest); + for (manifest, warns) in [(&manifest, true), (&declared, false)] { + let fx = fixture_with("", "node_modules/simple-git-hooks").await; + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + tokio::fs::copy(dir.join("bun.lockb"), fx.root().join("bun.lockb")) + .await + .unwrap(); + tokio::fs::write(fx.root().join("package.json"), manifest) + .await + .unwrap(); + let lost = trust_lost_details(&fx, "pkg:npm/simple-git-hooks@2.11.1").await; + assert_eq!(lost.len(), usize::from(warns), "{lost:?}"); + } + } + #[tokio::test] async fn unparseable_entry_line_fails_closed_before_any_write() { for bad in [ diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 06f833a05..651c1220c 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -33,12 +33,7 @@ const SUPPORTED_LOCK_VERSIONS: [u64; 3] = [0, 1, 2]; /// plainly. pub(crate) fn patched_dependency_keys(manifest: Option<&str>, lock: Option<&str>) -> Vec { let mut keys: Vec = manifest - .map(crate::utils::serde::strip_bom) - .and_then(|text| { - serde_json::from_str::(text) - .or_else(|_| serde_json::from_str(&strip_jsonc(text))) - .ok() - }) + .and_then(parse_manifest) .and_then(|value| match value.get("patchedDependencies") { Some(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()), _ => None, @@ -59,6 +54,59 @@ pub(crate) fn patched_dependency_keys(manifest: Option<&str>, lock: Option<&str> keys } +/// A root `package.json` parsed as Bun reads it: a leading BOM, comments +/// and trailing commas allowed. `None` when Bun could not parse it either. +fn parse_manifest(text: &str) -> Option { + let text = crate::utils::serde::strip_bom(text); + serde_json::from_str(text) + .or_else(|_| serde_json::from_str(&strip_jsonc(text))) + .ok() +} + +/// Bun's built-in default-trusted package names, the union of every release +/// up to 1.4.2 (`src/install/default-trusted-dependencies.txt` upstream). +const DEFAULT_TRUSTED: &str = include_str!("bun_default_trusted.txt"); + +/// Whether Bun runs `name`'s lifecycle scripts only through its built-in +/// default trust, which a hosted or vendored rewire takes away (#371). +/// +/// Bun trusts a package when the project's `trustedDependencies` lists it, +/// or, when the project declares no `trustedDependencies` at all, when its +/// name is on Bun's default list. From Bun 1.3.5 on that default applies +/// only to packages resolved from the npm registry, so a package on a hosted +/// URL or a local tarball loses it: `bun install` skips its `install` / +/// `postinstall` script without failing, and a native addon is left +/// unbuilt. An explicit list (in the root manifest, or the copy Bun mirrors +/// at the top of a text `bun.lock`) already decides trust by name alone, +/// so the rewire changes nothing there. +pub(crate) fn loses_default_trust(manifest: Option<&str>, lock: Option<&str>, name: &str) -> bool { + let declared = manifest.and_then(parse_manifest).is_some_and(|value| { + value + .get("trustedDependencies") + .is_some_and(|v| v.is_array()) + }) || lock.is_some_and(|lock| { + lock.split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .any(|l| l.starts_with(" \"trustedDependencies\": [")) + }); + !declared && DEFAULT_TRUSTED.lines().any(|trusted| trusted == name) +} + +/// The user-facing warning for a rewired package that loses Bun's default +/// trust ([`loses_default_trust`]), shared by the hosted and vendored paths. +/// `target` names what the package now resolves to. +pub(crate) fn default_trust_detail(name: &str, version: &str, target: &str) -> String { + format!( + "{name}@{version} is on Bun's default trusted list, which Bun 1.3.5 and later apply \ + only to packages installed from the npm registry; now that it resolves to {target}, \ + `bun install` skips its install scripts without failing (`bun pm untrusted` lists \ + it), which can leave native bindings unbuilt. Add \"{name}\" to \ + \"trustedDependencies\" in package.json and run `bun install`. Declaring \ + trustedDependencies replaces Bun's default list, so also list any other \ + default-trusted dependency whose scripts you rely on" + ) +} + /// `text` with the JSONC Bun accepts in a `package.json` removed: `//` and /// `/* */` comments and a comma before a closing `}` or `]`, all outside /// strings. Everything else, strings included, is kept byte for byte. diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..8d31f258e980d8a2ede588aae71def76414eaaea GIT binary patch literal 1673 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_lQGPNp86 zq=&Y`a*qX^4sdq7`_)HJ~2W~pp;^Q8lVTHJ%RSY z_}ox&V(o$HD+lVc0-D1P)lUuuG7DxNj0Ty5UHl*aKVlt8tYd)YF)~2RMK+pc8`lO~ zXXb(n4|o0zuU|Ad=*+wo=?NMqiZkl?8K>o)mz1h6p)U*(`uw$`3h4YXdiR4F+9kBG2oRg`SRg5G_ z0TYLH6qtyi^#U|wJb}_&HkoV8gp~kHj7EBfhI)oTd!VHZjOMZ_PR%V# zEz)zXC`m2KOUwx_Day=CN8{Ql7$RI?0e67`)Ubz88W=c0#~NaDE6`C4B}JvhC8;Uk z0y0TAEwiGev?w*RSU0aUHz~EKI5W2ZSZJnamgr{W=VupZl#~<{Tj}c;rKV>VmlRd% z$0%1d{#I+{=QB{pgU0 JAr_KI0sw|47>@t| literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml new file mode 100644 index 000000000..30e63e0e1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/bunfig.toml @@ -0,0 +1,2 @@ +[install] +saveTextLockfile = false diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json new file mode 100644 index 000000000..5d2f020d9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/package.json @@ -0,0 +1,9 @@ +{ + "name": "trusted-lockb-fixture", + "version": "1.0.0", + "private": true, + "dependencies": { + "simple-git-hooks": "2.11.1", + "is-number": "7.0.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json new file mode 100644 index 000000000..07e9e3a15 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-trusted/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install --ignore-scripts", + "sha256": "d556a6f991edb902cf74833fc00c8f639e13301871a63e231ce53477f3b32350", + "note": "simple-git-hooks is on Bun's default trusted list (#371); is-number is not" +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json new file mode 100644 index 000000000..a0f9be4a8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-trusted.json @@ -0,0 +1,19 @@ +{ + "bun-lockb-trusted": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@7.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/simple-git-hooks@2.11.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + } +} diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 66c06ca09..2587a3e1f 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -44,6 +44,7 @@ other npm lockfile flavors. | Version-2 lock (Bun 1.4+) with `workspace:` packages, nested versions included | Rewritten (golden `lock-v2-workspace-nested` — provenance: its nested same-version `consumer/left-pad` entry is a synthetic, grammar-valid extension of the 1.4.2 capture; bun hoists identical resolutions and never writes that entry itself, but bun 1.4.2 installs the fixture unchanged, and it is the only case pinning the rewrite of every matching tuple in one lock). | Vendored (matrix 1.4.0 / 1.4.2 `workspace`, `workspace-nested`, `already-vendored-workspace`). | Works. | | Binary `bun.lockb` (binary format revisions 1, 2 and 3) | Package resolution and integrity records are rewritten in place. The CLI does not spawn Bun or produce a text lock. `rollback` / `remove` cannot restore a hosted `bun.lockb` entry to its upstream registry entry (v5.0 keeps no ledger to replay, and the binary lock is not re-derived), so they refuse it with the `git checkout -- bun.lockb` remedy. | Native local-tarball wiring, committed artifact, repair and vendored → hosted takeover. Hosted → vendored rebuilds a hosted `bun.lockb` pin's npm registry record from the registry (byte-exact for a lock socket-patch wired hosted), then vendors; `vendor --revert` returns the pre-hosted lock. Offline it refuses (`redirect_revert_failed`), leaving it hosted. | Registry package records are inventoried directly, including lockfile-only projects without `node_modules`. | | A package the project patches itself with `bun patch` (a `patchedDependencies` key for its `name@version`, or its bare name, in the root `package.json` or mirrored in `bun.lock`) (#367) | Left on its registry tuple (text and binary lock): Bun applies the user's patch only to the registry `name@version`, so a hosted URL would drop it from every install with exit 0. Warns `redirect_bun_patched_dependency_skipped` naming the key, and the in-run VEX never assumes the patch applied; other packages in the lock are still rewired. | Refused `vendor_lock_entry_unsupported` before any write or download (text and binary lock), naming the key. | The installed tree is patched in place, as for any package. | +| A package on Bun's default trusted list (better-sqlite3, esbuild, sharp, …) in a project that declares no `trustedDependencies` (root `package.json`, or mirrored in `bun.lock`) (#371) | Rewired (text and binary lock), with warning `redirect_bun_default_trust_lost`: Bun 1.3.5+ apply the default list only to npm-registry resolutions, so a hosted URL makes `bun install` skip the package's install scripts with exit 0 (measured: 1.3.4 runs them, 1.3.5–1.4.2 do not). The remedy is adding the package to `trustedDependencies`, which replaces the default list. | Same, `vendor_bun_default_trust_lost`, for the local tarball tuple (text and binary lock). | Not affected: the installed tree keeps its registry resolution. | | Truncated, corrupt or unrecognized binary `bun.lockb` | Refused with `redirect_bun_lockb_invalid`, preserving the lock. | Refused with `vendor_bun_lockb_invalid` before downloads or artifact creation. | The inventory reports the malformed lock. | | `bun.lock` with a `lockfileVersion` ≥ 3, no integer version, or a `packages` section outside bun's single-line grammar | Refused `redirect_bun_lock_unsupported`. | Refused `vendor_lockfile_version_unsupported` (preflight and engine). | The inventory skips the lock. | From 4cb430418fb4e8c2a2d3030efb4fd12f4f763825 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:58:54 -0400 Subject: [PATCH 20/55] Keep the Bun trust warning out of the unredirected hint count (#371) format_unredirected appends "(see the warning below)" to an unconfirmed package's "no lockfile entry pinning it could be rewritten" line when the lockfile rewriters emitted any warning. The new redirect_bun_default_trust_lost warning rides in the same rewrite.warnings vector, but it is about a pin that was written, so a bun.lock rewiring a default-trusted package (e.g. simple-git-hooks) made an unrelated unconfirmed package point at the trust warning. Count the rewriter warnings through lock_entry_warning_count, which skips redirect_bun_default_trust_lost, matching how pnpm trust guidance is already kept out of the count. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e28629891..a80e490e6 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1511,8 +1511,9 @@ pub(crate) async fn run_redirect_selected( confirmed.is_empty(), // Only the lockfile rewriters' own warnings explain a // missing lock entry; unrelated guidance (pnpm trust, VEX, - // stale installs) is not what the hint points at. - rewrite.warnings.len(), + // stale installs, Bun default trust) is not what the hint + // points at. + lock_entry_warning_count(&rewrite.warnings), ) { eprintln!("{line}"); } @@ -2436,6 +2437,20 @@ fn describe_skip_reason(reason: &str) -> String { } } +/// How many of the lockfile rewriters' warnings can explain why a granted +/// package has no lock entry: the count `format_unredirected` turns into its +/// "(see the warning below)" hint. `redirect_bun_default_trust_lost` rides in +/// the same vector but is about a pin that *was* written (Bun's default trust +/// lost on the hosted URL, #371), so it never explains a missing entry. +fn lock_entry_warning_count( + warnings: &[socket_patch_core::patch::redirect::RewriteWarning], +) -> usize { + warnings + .iter() + .filter(|w| w.code != "redirect_bun_default_trust_lost") + .count() +} + /// The per-package "not redirected" lines, `skipped` (with a reason code) /// first, then `unconfirmed` (granted, but nothing in the project's files /// pins it). When nothing at all was redirected they sit under a @@ -2674,8 +2689,8 @@ mod tests { use super::{ describe_skip_reason, format_error_line, format_next_steps, format_redirect_summary, format_takeover_line, format_unredirected, format_warning, join_names, - pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, sentence_case, split_sentences, - wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, + lock_entry_warning_count, pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, + sentence_case, split_sentences, wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; @@ -4300,6 +4315,30 @@ mod tests { } } + #[test] + fn lock_entry_warning_count_skips_bun_default_trust() { + use socket_patch_core::patch::redirect::RewriteWarning; + let w = |code: &str| RewriteWarning { + code: code.into(), + detail: String::new(), + }; + assert_eq!(lock_entry_warning_count(&[]), 0); + // A trust warning alone must not make an unconfirmed package's + // line point at it (#371 review). + assert_eq!( + lock_entry_warning_count(&[w("redirect_bun_default_trust_lost")]), + 0 + ); + assert_eq!( + lock_entry_warning_count(&[ + w("redirect_bun_default_trust_lost"), + w("redirect_lock_unparseable"), + w("redirect_bun_default_trust_lost"), + ]), + 1 + ); + } + #[test] fn unredirected_lines_empty_partial_and_nothing_redirected() { assert!(format_unredirected(&[], &[], true, 1).is_empty()); From 94da1cba961c4378fabd9197a4bf1fb447e51c6e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:07:26 -0400 Subject: [PATCH 21/55] Reconcile #371 with #578's bun.lock hot-loop budget #578 moved the bun.lock bundled-entry JSON parse behind the cheap spec compare so the per-dep x per-entry loop in rewrite_bun_lock stays a string compare. #371 added a loses_default_trust call per wired dep that parsed package.json and scanned the whole lock for a trustedDependencies mirror before checking whether the name is on Bun's default trusted list at all. Almost no dependency is on that list, so check the list first and return early; the manifest parse and lock scan now run only for the rare default-trusted package. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vendor/bun_lock_text.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 651c1220c..67ad68482 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -80,6 +80,12 @@ const DEFAULT_TRUSTED: &str = include_str!("bun_default_trusted.txt"); /// at the top of a text `bun.lock`) already decides trust by name alone, /// so the rewire changes nothing there. pub(crate) fn loses_default_trust(manifest: Option<&str>, lock: Option<&str>, name: &str) -> bool { + // The list lookup is cheap and almost always false, so it runs first: + // the hosted rewriter calls this per wired dep, and the manifest parse + // plus whole-lock scan must not land in its per-dep loop (#578). + if !DEFAULT_TRUSTED.lines().any(|trusted| trusted == name) { + return false; + } let declared = manifest.and_then(parse_manifest).is_some_and(|value| { value .get("trustedDependencies") @@ -89,7 +95,7 @@ pub(crate) fn loses_default_trust(manifest: Option<&str>, lock: Option<&str>, na .map(|l| l.strip_suffix('\r').unwrap_or(l)) .any(|l| l.starts_with(" \"trustedDependencies\": [")) }); - !declared && DEFAULT_TRUSTED.lines().any(|trusted| trusted == name) + !declared } /// The user-facing warning for a rewired package that loses Bun's default From 7c9daf2973ab0f6c898ec16daa2a5c8d1f07bee8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:51:43 -0400 Subject: [PATCH 22/55] Warn on Bun URL and file: tarball copies; stop attesting them (#497) Bun installs a dependency declared by remote tarball URL or `file:` tarball from that spec, never from the registry. When the lock also holds a registry copy of the same name@version (for example nested under a dependent), hosted and vendored mode rewired only the registry copy and reported plain success, while the root copy that the app loads stayed unpatched after `bun install --frozen-lockfile`. Lockfile VEX then attested not_affected for that name@version. That covers vendored (default and --no-verify), hosted --no-verify, and hosted in a lockfile-only checkout. This is the Bun twin of npm's #326 / #345. The tarball tuple / record carries no version of its own, so its version is read from the artifact leaf, `-.tgz` (`user_tarball_version`, shared by every Bun path). Then: - the hosted bun.lock and bun.lockb rewriters leave such a copy alone with a `redirect_bun_non_registry_entry_skipped` stays-UNPATCHED warning, and keep the uuid out of the in-run VEX assumptions; - the vendored text and binary backends warn with `vendor_non_registry_entry_skipped`, and refuse with `vendor_lock_entry_not_rewritable` when no registry copy is left; - lockfile VEX discovery counts the copy as an unpatched install of that version, so every ref for it in the lock is withdrawn with a patched_ref_unattributable diagnostic and other locks are contested. Regression tests use fixture text locks and real Bun 1.1.45 bun.lockb fixtures (URL and file: shapes) generated for this issue. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 3 +- .../src/patch/redirect/bun_binary.rs | 68 +++++++ .../src/patch/redirect/mod.rs | 141 ++++++++++++-- .../src/vendor/bun_binary.rs | 47 ++++- .../socket-patch-core/src/vendor/bun_lock.rs | 157 ++++++++++++++- .../src/vendor/bun_lock_text.rs | 72 +++++++ .../socket-patch-core/src/vex/discover/bun.rs | 180 ++++++++++++++++-- .../bun-lockb-user-tarball/file/bun.lockb | Bin 0 -> 1934 bytes .../bun-lockb-user-tarball/file/package.json | 1 + .../file/provenance.json | 8 + .../bun-lockb-user-tarball/url/bun.lockb | Bin 0 -> 1887 bytes .../bun-lockb-user-tarball/url/package.json | 1 + .../url/provenance.json | 8 + .../bun-lockb-user-tarball.json | 36 ++++ 14 files changed, 691 insertions(+), 31 deletions(-) create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/provenance.json create mode 100755 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 37e2e8d89..7ac741061 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -363,7 +363,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec, together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | | pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required | | yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | -| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | +| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `-.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | | vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | | cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…//index/"`, confirmed by `Cargo.toml`: a crate the root manifest declares must pin `registry = "socket-patch-"`. A reverted pin is diagnosed, no ref. | `[patch.] = { path = ".socket/vendor/cargo//-" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `` is `package` when renamed, else the key, so `-socket-` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `checksum` (v1: `[metadata]`), required | | golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/ ` | `replace M v => ./.socket/vendor/golang//M@v` | both go.sum lines, required. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. | @@ -1330,6 +1330,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | | `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | +| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | diff --git a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs index be7970545..306016e7b 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -1,6 +1,7 @@ //! Native hosted redirects for bun.lockb. Structured package snapshots keep //! scoped rollback independent of other packages in the same binary lock. use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; +use crate::vendor::bun_lock_text::user_tarball_version; use crate::vendor::bun_lockb::BunLockb; pub(crate) const KIND: &str = "redirect_bun_lockb_package"; @@ -64,6 +65,29 @@ pub fn rewrite_bun_binary(content: &[u8], overrides: &[DepOverride], result: &mu )) }) .collect(); + // A user URL / `file:` tarball record of this version installs + // from its own resolution beside any redirected copy and stays + // unpatched (#497): never rewired, always reported. + for p in packages.iter().filter(|p| { + p.name == name + && p.version.is_none() + && !matching.iter().any(|m| m.id == p.id) + && user_tarball_version(&name, &p.resolution) == Some(dep.version.as_str()) + }) { + skipped.push(RewriteWarning { + code: "redirect_bun_non_registry_entry_skipped".into(), + detail: format!( + "bun.lockb package #{} installs {name}@{} from a URL or local tarball, \ + not the registry, and CANNOT be redirected — bun installs it from that \ + resolution, so that copy stays UNPATCHED; depend on the registry \ + release to patch it", + p.id, dep.version, + ), + }); + } + if matching.is_empty() && !skipped.is_empty() { + return Ok((Vec::new(), false)); + } if matching.is_empty() { return Err(format!( "no rewritable bun.lockb entry for {name}@{}", @@ -219,6 +243,50 @@ mod tests { assert!(result.bundled_skipped_uuids.contains("7.0.0")); } + /// REGRESSION (#497): Bun 1.1.45 locks a root URL / `file:` tarball + /// dependency on is-number@6.0.0 beside is-odd's nested registry copy. + /// Bun installs the tarball record from its own resolution, so only the + /// registry record is redirected and the run says, loudly, that the + /// tarball copy stays unpatched; the in-run VEX must not assume it. + #[test] + fn user_tarball_records_are_reported_unpatched() { + let is_number_6 = DepOverride { + name: "is-number".into(), + artifact_url: "https://patch.example.test/6.0.0/is-number-6.0.0.tgz".into(), + ..dep("6.0.0") + }; + for shape in ["url", "file"] { + let lock = std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-user-tarball") + .join(shape) + .join("bun.lockb"), + ) + .unwrap(); + let mut result = RewriteResult::default(); + rewrite_bun_binary(&lock, std::slice::from_ref(&is_number_6), &mut result); + assert_eq!(result.edits.len(), 1, "{shape}: {:?}", result.edits); + assert!(result.confirmed_bun_binary_uuids.contains("6.0.0")); + let codes: Vec<_> = result.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_bun_non_registry_entry_skipped"], + "{shape}: {:?}", + result.warnings + ); + assert!(result.warnings[0].detail.contains("UNPATCHED")); + assert!(result.bundled_skipped_uuids.contains("6.0.0"), "{shape}"); + let rewired = BunLockb::parse_packages(&result.binary_files["bun.lockb"]).unwrap(); + assert!( + rewired.iter().any(|p| p.name == "is-number" + && p.version.is_none() + && p.resolution.ends_with("is-number-6.0.0.tgz") + && !p.resolution.contains("patch.example.test")), + "{shape}: the tarball record keeps its resolution: {rewired:?}" + ); + } + } + #[test] fn malformed_metahash_cannot_confirm_an_existing_binary_redirect() { let mut result = RewriteResult::default(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 1c1a57dae..e1624b6f2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4847,7 +4847,9 @@ fn rewrite_bun_lock( overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::vendor::bun_lock_text::{decode_json_string, is_bundled_entry}; + use crate::vendor::bun_lock_text::{ + decode_json_string, is_bundled_entry, is_user_tarball_entry, + }; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); if npm.is_empty() { @@ -4902,6 +4904,7 @@ fn rewrite_bun_lock( let mut matched_any = false; // A non-bundled instance now resolves to the hosted URL. let mut wired = false; + let mut user_tarball_skipped = false; for entry in &entries { let Some(spec) = entry.elems.first().and_then(|e| decode_json_string(e)) else { continue; @@ -4975,7 +4978,24 @@ fn rewrite_bun_lock( deps_verbatim = entry.elems[1].clone(); } else { // Same-name-but-unowned entry (user file:/URL dep, other - // version) — never touched. + // version) — never touched. A user URL / `file:` tarball of + // this very version is installed from that spec beside the + // pinned copy and stays unpatched (#497, npm's #326): say so, + // and keep the in-run VEX from assuming the uuid patched. + if spec != url_spec && is_user_tarball_entry(entry, &fname, &dep.version) { + user_tarball_skipped = true; + result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_bun_non_registry_entry_skipped".into(), + detail: format!( + "bun.lock entry `{}` installs {fname}@{} from a URL or local \ + tarball, not the registry, and CANNOT be redirected — bun installs \ + it from that spec, so that copy stays UNPATCHED; depend on the \ + registry release to patch it", + entry.key, dep.version + ), + }); + } continue; } matched_any = true; @@ -5025,7 +5045,7 @@ fn rewrite_bun_lock( .warnings .push(bun_default_trust_warning(&fname, &dep.version)); } - if !matched_any { + if !matched_any && !user_tarball_skipped { // Mirrors the pnpm/berry/uv rewriters: a granted dep that matched // no rewritable tuple (lock re-resolved to another version, entry // occupied by an unowned URL/file: spec) must be diagnosable, not @@ -10777,7 +10797,11 @@ mod tests { r.files.is_empty(), "foreign-origin URL dep must not be touched" ); - assert_eq!(r.warnings[0].code, "redirect_bun_entry_not_found"); + // ...but bun installs it from that URL, unpatched (#497). + assert_eq!( + r.warnings[0].code, + "redirect_bun_non_registry_entry_skipped" + ); // Our origin but ANOTHER version's leaf is never claimed either. let other_version_url = "https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz"; @@ -11156,6 +11180,82 @@ mod tests { ); } + /// REGRESSION (#497): bun installs a remote-URL or `file:` tarball + /// dependency from its own spec, never the registry, so a registry copy + /// of the same version rewired beside it leaves the copy the app loads + /// unpatched. The tarball tuple is left alone LOUDLY (npm's #326), the + /// uuid is kept out of the in-run VEX's assumptions, and a lock holding + /// only the tarball copy says why instead of `redirect_bun_entry_not_found`. + #[test] + fn bun_lock_user_tarball_copy_is_skipped_with_loud_warning() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override( + "is-number", + "6.0.0", + "http://p.test/is-number-6.0.0.tgz", + &sha512, + ); + let nested = "\"is-odd/is-number\": [\"is-number@6.0.0\", \"\", {}, \"sha512-UP==\"],"; + for user in [ + "\"is-number\": [\"is-number@https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@./is-number-6.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@vendor/is-number-6.0.0.tgz\", {}],", + ] { + let both = format!("{user}\n {nested}"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&both, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{user}: {:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("is-odd/is-number")); + let out = r.files.get("bun.lock").expect("nested copy rewired"); + assert!(out.contains(user), "{user}: tarball line untouched: {out}"); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{user}: {:?}", + r.warnings + ); + assert!( + r.warnings[0].detail.contains("`is-number`") + && r.warnings[0].detail.contains("UNPATCHED"), + "{}", + r.warnings[0].detail + ); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + + // The tarball copy alone: nothing to rewire, and the warning + // names the real reason. + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(user, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.edits); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{user}: {:?}", + r.warnings + ); + } + + // A tarball of ANOTHER version, or one whose leaf names no version, + // is not this copy: no warning. + for other in [ + "\"is-number\": [\"is-number@https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz\", {}, \"sha512-UP==\"],", + "\"is-number\": [\"is-number@./is-number.tgz\", {}, \"sha512-UP==\"],", + ] { + let both = format!("{other}\n {nested}"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&both, 2)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{other}: {:?}", r.edits); + assert!(r.warnings.is_empty(), "{other}: {:?}", r.warnings); + assert!(r.bundled_skipped_uuids.is_empty()); + } + } + /// REGRESSION (#367): `bun patch --commit` keys the project's own patch /// on the registry `name@version` in package.json (and bun.lock's /// mirror). Rewiring that package to a hosted URL makes Bun drop the @@ -20356,13 +20456,23 @@ packages: Some(format!(" {stale}").as_str()) ); - for unowned in [ - // Foreign origin, same leaf: a user's own URL dep. - "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + for (unowned, code) in [ + // Foreign origin, same leaf: a user's own URL dep, which bun + // installs from that URL, unpatched (#497). + ( + "\"left-pad\": [\"left-pad@https://example.com/mirror/left-pad-1.3.0.tgz\", {}],", + "redirect_bun_non_registry_entry_skipped", + ), // Our origin, another version's leaf. - "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + ( + "\"left-pad\": [\"left-pad@https://patch.socket.dev/patch/npm/oldtoken-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.2.0.tgz\", {}],", + "redirect_bun_entry_not_found", + ), // Registry spec in a 2-tuple: not bun's registry grammar. - "\"left-pad\": [\"left-pad@1.3.0\", {}],", + ( + "\"left-pad\": [\"left-pad@1.3.0\", {}],", + "redirect_bun_entry_not_found", + ), ] { let mut files = BTreeMap::new(); files.insert("bun.lock".to_string(), bun_lock_file(unowned, 1)); @@ -20374,7 +20484,7 @@ packages: ); assert_eq!( r.warnings.iter().map(|w| w.code.as_str()).collect::>(), - vec!["redirect_bun_entry_not_found"], + vec![code], "{unowned}" ); } @@ -20398,7 +20508,8 @@ packages: /// Fail-closed ownership legs of the URL-tuple takeover: an OTHER-name /// spec, a non-http `file:` spec, and a foreign-origin URL all survive /// byte-identically while the target registry tuple in the same lock is - /// rewritten. + /// rewritten. The foreign-origin URL names the target's own leaf, so it + /// is reported as a copy that stays unpatched (#497). #[test] fn bun_lock_unowned_url_and_file_tuples_survive_untouched() { let sha = format!("sha512-{}==", "A".repeat(86)); @@ -20433,7 +20544,13 @@ packages: ); } assert_eq!(r.edits.len(), 1, "only the target is edited: {:?}", r.edits); - assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_non_registry_entry_skipped"], + "{:?}", + r.warnings + ); + assert!(r.warnings[0].detail.contains("`mirror/left-pad`")); } /// The uv block iteration must find the target mid-file and leave both diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 6497b031c..b855e9d0e 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1,7 +1,7 @@ //! Native binary Bun vendoring. Package records are edited without re-resolving //! dependencies or requiring a Bun executable. use super::bun_lock_text::{ - decode_json_string, packages_bounds, parse_entry_line, split_name_spec, + decode_json_string, packages_bounds, parse_entry_line, split_name_spec, user_tarball_version, }; use super::bun_lockb::{BinaryPackage, BunLockb}; use super::common::{already_patched_result, refused}; @@ -124,6 +124,7 @@ pub(crate) async fn vendor( matches, mirrors, bundled, + user_tarballs, } = match preflight_package(&project, root, &coords, &leaf) { Ok(v) => v, Err(o) => return *o, @@ -154,6 +155,18 @@ pub(crate) async fn vendor( ), )); } + for package in user_tarballs { + // LOUD: bun installs this copy from its own URL / `file:` + // resolution, which vendoring does not touch (#497). + warnings.push(super::VendorWarning::new( + "vendor_non_registry_entry_skipped", + super::bun_lock::user_tarball_detail( + &format!("{LOCK} package #{}", package.id), + &coords.name, + &coords.version, + ), + )); + } let preexisted = root.join(&coords.uuid_dir_rel).exists(); let (staged, result) = match stage_patch_pack( purl, @@ -409,6 +422,9 @@ pub(super) struct BinaryTargets { /// Matching records some bundled edge reaches (#469): each one's /// bundled copy stays unpatched, which vendoring reports loudly. bundled: Vec, + /// User URL / `file:` tarball records of the same version (#497): bun + /// installs them from their own resolution, so they stay unpatched. + user_tarballs: Vec, } /// The per-package pre-flight against an already-read lock: the records @@ -435,6 +451,18 @@ pub(super) fn preflight_package( .chain(&bundled_only) .cloned() .collect(); + let user_tarballs: Vec<_> = project + .packages + .iter() + .filter(|p| { + p.name == coords.name + && p.version.is_none() + && !p.bundled_only + && user_tarball_version(&coords.name, &p.resolution) + == Some(coords.version.as_str()) + }) + .cloned() + .collect(); if matches.is_empty() && !bundled_only.is_empty() { // Only a bundled edge reaches the record: Bun unpacks that copy // from the parent's tarball, so rewiring the record installs @@ -449,6 +477,22 @@ pub(super) fn preflight_package( ), ))); } + if matches.is_empty() && !user_tarballs.is_empty() { + // The package IS locked, from a URL / `file:` resolution bun + // installs as written (#497): "run `bun install`" would not help. + let ids: Vec = user_tarballs.iter().map(|p| format!("#{}", p.id)).collect(); + return Err(Box::new(refused( + "vendor_lock_entry_not_rewritable", + format!( + "every {LOCK} record for {}@{} ({}) installs it from a URL or local tarball, \ + not the registry, and cannot be rewritten — those copies stay UNPATCHED and \ + `bun install` will not help; depend on the registry release to vendor it", + coords.name, + coords.version, + ids.join(", ") + ), + ))); + } if matches.is_empty() { return Err(Box::new(refused( "vendor_lock_entry_not_found", @@ -480,6 +524,7 @@ pub(super) fn preflight_package( matches, mirrors, bundled, + user_tarballs, }) } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 7cbf02347..c433ae580 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -42,7 +42,7 @@ use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_strin use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ decode_json_string, default_trust_detail, has_workspace_packages, is_bundled_entry, - lock_version, loses_default_trust, packages_bounds, parse_entry_line, + is_user_tarball_entry, lock_version, loses_default_trust, packages_bounds, parse_entry_line, patched_dependency_detail, patched_dependency_key, patched_dependency_keys, split_name_spec, BunEntry, }; @@ -379,6 +379,14 @@ pub(crate) async fn vendor_bun<'a>( name, version, )); + for key in user_tarball_matches(&project.entries, name, version) { + // LOUD: bun installs this copy from its own URL / `file:` spec, + // never the vendored tuple, so it stays the unpatched bytes (#497). + warnings.push(VendorWarning::new( + "vendor_non_registry_entry_skipped", + user_tarball_detail(&format!("{BUN_LOCK} entry `{key}`"), name, version), + )); + } let BunProject { mut lines, entries, .. } = project; @@ -792,6 +800,21 @@ pub(super) fn preflight_package( ), ))); } + let user_tarballs = user_tarball_matches(&project.entries, name, version); + if !user_tarballs.is_empty() { + // Likewise: the package IS locked, from a URL / `file:` spec + // bun installs as written (#497). + return Err(Box::new(refused( + "vendor_lock_entry_not_rewritable", + format!( + "every {BUN_LOCK} entry for {name}@{version} ({}) installs it from a URL \ + or local tarball, not the registry, and cannot be rewritten — those \ + copies stay UNPATCHED and `bun install` will not help; depend on the \ + registry release to vendor it", + user_tarballs.join(", ") + ), + ))); + } return Err(Box::new(refused( "vendor_lock_entry_not_found", format!( @@ -1224,6 +1247,27 @@ fn bundled_matches( .collect() } +/// Keys of the non-bundled entries that install `name@version` from a user +/// URL / `file:` tarball (#497): bun installs those from their own spec, so +/// no vendored tuple reaches them. +fn user_tarball_matches(entries: &[BunEntry], name: &str, version: &str) -> Vec { + entries + .iter() + .filter(|e| !is_bundled_entry(e) && is_user_tarball_entry(e, name, version)) + .map(|e| e.key.clone()) + .collect() +} + +/// The stays-UNPATCHED detail for a user tarball copy at `label`, shared +/// by the text and binary backends. +pub(super) fn user_tarball_detail(label: &str, name: &str, version: &str) -> String { + format!( + "{label} installs {name}@{version} from a URL or local tarball, not the registry, \ + and CANNOT be rewritten — bun installs it from that spec, so that copy stays \ + UNPATCHED; depend on the registry release to vendor it" + ) +} + /// Classify an entry against the target: `Some(Registry)` for the exact /// `name@version` registry tuple, `Some(Ours{..})` for one of our own /// `.socket/vendor/npm/` tuples for the same `name@version` (any uuid), @@ -2214,6 +2258,58 @@ mod tests { ); } + /// REGRESSION (#497): bun installs a remote-URL or `file:` tarball + /// dependency from its own spec, so vendoring the registry copy beside + /// it leaves the copy the app loads unpatched. The tarball tuple is + /// never rewired and is reported loudly (npm's #326); with no registry + /// copy the vendor refuses with the real reason and writes nothing. + #[tokio::test] + async fn user_tarball_copy_is_never_rewired_silently() { + let regular_line = BN3_BEFORE_LOCK + .lines() + .find(|l| l.contains("\"left-pad\": [")) + .unwrap(); + let nested_line = regular_line.replace("\"left-pad\": [", "\"dep/left-pad\": ["); + for user_line in [ + r#" "left-pad": ["left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#, + r#" "left-pad": ["left-pad@./left-pad-1.3.0.tgz", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="],"#, + ] { + // The tarball copy only. + let lock = BN3_BEFORE_LOCK.replace(regular_line, user_line); + let fx = fixture_with(&lock, "node_modules/left-pad").await; + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_rewritable"); + assert!( + detail.contains("left-pad") && detail.contains("UNPATCHED"), + "{detail}" + ); + assert_eq!(fx.read_lock().await, lock, "refusal writes nothing"); + assert!(!fx.root().join(".socket/vendor").exists()); + + // Beside a nested registry copy of the same version. + let lock = + BN3_BEFORE_LOCK.replace(regular_line, &format!("{user_line}\n\n{nested_line}")); + let fx = fixture_with(&lock, "node_modules/dep/node_modules/left-pad").await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + assert_eq!(entry.wiring.len(), 1); + assert_eq!(entry.wiring[0].key.as_deref(), Some("dep/left-pad")); + assert!( + fx.read_lock().await.contains(user_line), + "the tarball line keeps its bytes" + ); + let skipped = warnings + .iter() + .find(|w| w.code == "vendor_non_registry_entry_skipped") + .unwrap_or_else(|| panic!("{user_line}: {warnings:?}")); + assert!( + skipped.detail.contains("`left-pad`") && skipped.detail.contains("UNPATCHED"), + "{}", + skipped.detail + ); + } + } + /// REGRESSION (#469), `bun.lockb`: a record only a bundled edge /// reaches refuses with the real reason; a record Bun shares between a /// regular and a bundled install is vendored for the regular install, @@ -2279,6 +2375,65 @@ mod tests { ); } + /// REGRESSION (#497), `bun.lockb`: Bun 1.1.45's record of a root URL / + /// `file:` tarball dependency is installed from its own resolution, so + /// vendoring is-odd's nested registry copy reports the tarball copy as + /// staying unpatched instead of succeeding silently, and that record is + /// left alone. + #[tokio::test] + async fn binary_user_tarball_record_is_reported_unpatched() { + for shape in ["url", "file"] { + let fx = fixture_with("", "node_modules/is-odd/node_modules/is-number").await; + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb-user-tarball") + .join(shape); + tokio::fs::remove_file(fx.root().join(BUN_LOCK)) + .await + .unwrap(); + for file in ["bun.lockb", "package.json"] { + tokio::fs::copy(dir.join(file), fx.root().join(file)) + .await + .unwrap(); + } + tokio::fs::write( + fx.installed.join("package.json"), + br#"{"name":"is-number","version":"6.0.0"}"#, + ) + .await + .unwrap(); + let blobs = fx.root().join(".socket/blobs"); + let outcome = crate::vendor::test_support::vendor_bun( + "pkg:npm/is-number@6.0.0", + &fx.installed, + fx.root(), + &fx.record, + &PatchSources::blobs_only(&blobs), + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, warnings) = expect_done(outcome); + assert!(result.success, "{shape}: {:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 1, "{shape}: the registry copy"); + let skipped = warnings + .iter() + .find(|w| w.code == "vendor_non_registry_entry_skipped") + .unwrap_or_else(|| panic!("{shape}: {warnings:?}")); + assert!(skipped.detail.contains("UNPATCHED"), "{}", skipped.detail); + let lock = tokio::fs::read(fx.root().join("bun.lockb")).await.unwrap(); + let packages = crate::vendor::bun_lockb::BunLockb::parse_packages(&lock).unwrap(); + assert!( + packages.iter().any(|p| p.name == "is-number" + && p.version.is_none() + && parse_vendor_path(&p.resolution).is_none() + && p.resolution.ends_with("is-number-6.0.0.tgz")), + "{shape}: the tarball record keeps its resolution: {packages:?}" + ); + } + } + /// REGRESSION (#367): a package the project patches itself with /// `bun patch` (package.json `patchedDependencies`, mirrored in /// bun.lock) is keyed on its registry `name@version`; a local tarball diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 67ad68482..345a20a7c 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -224,6 +224,43 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { Some((&s[..at], &s[at + 1..])) } +/// The version a user tarball dependency of `name` installs (#497). Bun +/// records a remote-URL or `file:` tarball dependency as `name@` +/// (text) or as a tarball resolution (binary) with no version of its own, +/// and installs it from that spec, never from the registry, so no rewire +/// of a registry `name@version` reaches it. The version is read from the +/// artifact leaf, `-.tgz` (or `.tar.gz`) with a semver +/// `` (`` = the name without its `@scope/`): the leaf every +/// registry tarball and `npm pack` output carries. A leaf naming no version +/// yields `None`, and so does our own vendored path. +pub(crate) fn user_tarball_version<'t>(name: &str, target: &'t str) -> Option<&'t str> { + if crate::vendor::path::parse_vendor_path(target).is_some() { + return None; + } + let path = target.split(['?', '#']).next().unwrap_or(target); + let leaf = path.rsplit(['/', '\\']).next()?; + let bare = name.rsplit('/').next().unwrap_or(name); + let version = leaf.strip_prefix(bare)?.strip_prefix('-')?; + let version = version + .strip_suffix(".tgz") + .or_else(|| version.strip_suffix(".tar.gz"))?; + semver::Version::parse(version).is_ok().then_some(version) +} + +/// [`user_tarball_version`] for a text `packages` entry: true when the +/// entry's spec is `name@` and the tarball's leaf names `version`. +pub(crate) fn is_user_tarball_entry(entry: &BunEntry, name: &str, version: &str) -> bool { + entry + .elems + .first() + .and_then(|raw| decode_json_string(raw)) + .is_some_and(|spec| { + split_name_spec(&spec).is_some_and(|(entry_name, target)| { + entry_name == name && user_tarball_version(name, target) == Some(version) + }) + }) +} + /// `"lockfileVersion": ` head check — only the fixture-pinned text /// lockfile versions are spliced (fail-closed on anything newer/older). /// @@ -712,6 +749,41 @@ pub(crate) fn heal_workspace_literals( mod tests { use super::*; + /// #497: a user tarball's version is its `-.tgz` leaf, + /// for remote URLs and local paths alike; a registry version, a leaf + /// naming no version or another package, and our own vendored path are + /// not user tarballs. + #[test] + fn user_tarball_version_reads_the_leaf() { + for (name, target, want) in [ + ( + "is-number", + "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz", + Some("6.0.0"), + ), + ("is-number", "./is-number-6.0.0.tgz", Some("6.0.0")), + ("is-number", "file:./is-number-6.0.0.tgz", Some("6.0.0")), + ("is-number", "vendor\\is-number-6.0.0.tar.gz", Some("6.0.0")), + ( + "@s/p", + "https://h.test/@s/p/-/p-1.0.0-2.tgz?t=1", + Some("1.0.0-2"), + ), + ("is-number", "6.0.0", None), + ("is-number", "./is-number.tgz", None), + ("is-number", "./is-number-latest.tgz", None), + ("is-number", "./is-odd-6.0.0.tgz", None), + ("is-number", "github:jonschlinkert/is-number#6.0.0", None), + ( + "is-number", + ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/is-number-6.0.0.tgz", + None, + ), + ] { + assert_eq!(user_tarball_version(name, target), want, "{name} {target}"); + } + } + /// #367: the keys come from the manifest and from the lock's mirror, /// and match the exact `name@version` (scoped too) or a bare name. #[test] diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 0f5bda4ae..9ba3aec7f 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -95,7 +95,9 @@ use super::{ use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; use crate::patch::redirect::hosted_url_version; use crate::utils::digest::is_sri_pin; -use crate::vendor::bun_lock_text::{decode_json_string, is_bundled_entry, split_name_spec}; +use crate::vendor::bun_lock_text::{ + decode_json_string, is_bundled_entry, split_name_spec, user_tarball_version, +}; use crate::vendor::bun_lockb::BunLockb; use crate::vendor::lock_inventory::bun::bun_text_entries; use crate::vendor::lock_inventory::bun_text_lock_drives; @@ -157,7 +159,12 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if is_bundled_entry(entry) { bundled.record(ctx, BUN_LOCK, classified, out); } else { - unwired.record(classify(ctx, BUN_LOCK, classified, out), &entry.key); + let user_tarball = user_tarball_version(name, target).is_some(); + unwired.record( + classify(ctx, BUN_LOCK, classified, out), + &entry.key, + user_tarball, + ); } } bundled.contest(BUN_LOCK, out); @@ -289,7 +296,13 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { if p.bundled { bundled.record(ctx, BUN_LOCKB, classified, out); } else { - unwired.record(classify(ctx, BUN_LOCKB, classified, out), &label); + let user_tarball = + p.version.is_none() && user_tarball_version(&p.name, &p.resolution).is_some(); + unwired.record( + classify(ctx, BUN_LOCKB, classified, out), + &label, + user_tarball, + ); } } bundled.contest(BUN_LOCKB, out); @@ -352,13 +365,17 @@ fn classify( } if vendored.is_none() && hosted_uuid.is_none() { // Registry / git / workspace / user tarball dependency: not ours. A - // registry entry (an exact version) is evidence against another - // lock's wiring of the same package. - let version = recorded_version.or_else(|| { - target - .starts_with(|c: char| c.is_ascii_digit()) - .then_some(target) - }); + // registry entry (an exact version), or a user URL / `file:` + // tarball whose leaf names its version (#497), is an unpatched + // install of that version: evidence against wiring of the same + // package in this lock and any other. + let version = recorded_version + .or_else(|| { + target + .starts_with(|c: char| c.is_ascii_digit()) + .then_some(target) + }) + .or_else(|| user_tarball_version(name, target)); let purl = version.and_then(|version| npm_purl(name, version)); out.resolved_elsewhere(file, purl.clone()); return purl; @@ -427,17 +444,22 @@ fn classify( /// The registry copies one lock records, keyed by purl (#588): bun installs /// every entry, so a second entry resolving a wired `name@version` from the /// registry (e.g. a workspace member added after the rewire, then `bun -/// install`) installs unpatched beside the rewired one. +/// install`) installs unpatched beside the rewired one. A user URL / +/// `file:` tarball of the version (#497) is such a copy too, one no re-run +/// can rewire: bun installs it from its own spec. #[derive(Default)] struct Unwired { - /// purl → the first such entry's label. - copies: std::collections::BTreeMap, + /// purl → the first such entry's label, and whether it is a user + /// tarball. + copies: std::collections::BTreeMap, } impl Unwired { - fn record(&mut self, purl: Option, label: &str) { + fn record(&mut self, purl: Option, label: &str, user_tarball: bool) { if let Some(purl) = purl { - self.copies.entry(purl).or_insert_with(|| label.to_string()); + self.copies + .entry(purl) + .or_insert_with(|| (label.to_string(), user_tarball)); } } @@ -452,10 +474,24 @@ impl Unwired { let unwired_at = (r.source_file == std::path::Path::new(file)) .then(|| self.copies.get(&r.purl)) .flatten(); - let Some(label) = unwired_at else { + let Some((label, user_tarball)) = unwired_at else { out.refs.push(r); continue; }; + if *user_tarball { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {} is wired to a Socket patch but entry {label:?} of the same \ + lock installs that version from a URL or local tarball, not the \ + registry; bun installs it from that spec, so that copy stays \ + UNPATCHED and nothing is attested", + r.purl, + ), + ); + continue; + } out.diag( DIAG_REF_UNATTRIBUTABLE, file, @@ -773,6 +809,118 @@ mod tests { } } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name a user tarball. + fn user_tarball_contests(out: &Discovery) -> usize { + out.diagnostics + .iter() + .filter(|d| { + d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("from a URL or local tarball") + && d.detail.contains("UNPATCHED") + }) + .count() + } + + /// REGRESSION (#497): a root dependency on `left-pad` by remote URL or + /// `file:` tarball is installed from that spec, never the registry, so + /// a hosted / vendored rewire of the nested registry copy of the same + /// version leaves the copy the app loads unpatched. Nothing may be + /// attested from the lock alone (npm's #326 contest). A tarball of + /// another version, or one whose leaf names no version, contests + /// nothing. + #[tokio::test] + async fn issue_497_user_tarball_copy_contests_the_ref() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let vendored = format!("left-pad@.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"); + for (label, spec) in [ + ("hosted", format!("left-pad@{hosted}")), + ("vendored", vendored), + ] { + for user in [ + "left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "left-pad@./left-pad-1.3.0.tgz", + "left-pad@vendor/left-pad-1.3.0.tgz", + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("left-pad", user, Some(SRI)), + tuple("dep/left-pad", &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{label} {user}: {:#?}", out.refs); + assert_eq!( + user_tarball_contests(&out), + 1, + "{label} {user}: {:#?}", + out.diagnostics + ); + } + for other in [ + "left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz", + "left-pad@./left-pad.tgz", + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("left-pad", other, Some(SRI)), + tuple("dep/left-pad", &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{label} {other}: {:#?}", out.refs); + assert_eq!(user_tarball_contests(&out), 0, "{label} {other}"); + } + } + } + + /// REGRESSION (#497), `bun.lockb`: Bun 1.1.45's tarball record of a + /// root URL / `file:` dependency contests a hosted or vendored rewire + /// of is-odd's nested registry copy of the same version. + #[tokio::test] + async fn issue_497_binary_user_tarball_record_contests_the_ref() { + let hosted = hosted_url("npm", "is-number", "6.0.0", UUID_A, "is-number-6.0.0.tgz"); + let vendored = format!(".socket/vendor/npm/{UUID_A}/is-number-6.0.0.tgz"); + for shape in ["url", "file"] { + for wired in [&hosted, &vendored] { + let bytes = std::fs::read( + fixture_path("bun-lockb-user-tarball") + .join(shape) + .join("bun.lockb"), + ) + .expect("user tarball fixture"); + let mut lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).unwrap(); + let id = lock + .packages() + .unwrap() + .into_iter() + .find(|p| p.name == "is-number" && p.version.as_deref() == Some("6.0.0")) + .expect("nested registry record") + .id; + lock.set_package(id, wired, SRI).unwrap(); + let p = Project::new(); + p.write("bun.lockb", lock.bytes()); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + user_tarball_contests(&out), + 1, + "{shape} {wired}: {:#?}", + out.diagnostics + ); + } + } + } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name a bundled copy. fn bundled_contests(out: &Discovery) -> usize { out.diagnostics diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/file/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..d270a85d0be1396f23838bfe5e5966a97029dd59 GIT binary patch literal 1934 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p*xOkAnC za|3oHwum`>y8Y&l`>DT%+@-#|=CDojyl_f^?;jgb5fHFICd~`k?^;HH9z*3!#ZXrJ_Lngmqx50J#B#L2ib*A4Y>z z?3ZL z^<0ceAxtVW9qTKkl3N~6NqGIQ{Ntw$T~Q*tbG_ysR7psFwQ%nW&v|KqcHj4GTrJCS z>TpqyJ^SHvNaiAk8!=wRs|MNgcoou4au7HH-E)jPpt=76l;*N2E=tzR%quQQ%*oM% z6+L<(MX9NF3PuJB#hF#9`Dr=|CJKo;ndy1?Xd zO{uYw9ndITh60s?%mC^4fa(b$LN6%Y!1Q%M)n9|_i#9TgEwuo8#td$+A*yF!X*`pv4f3Mh)TO)ZDVvB0blNlGLKS z#GK%gqRhN>I|W08<1FBg3xJyR4N3#U59kj=T;5;+RT@@$`annLmF6a;7U_arq*sz& z1sBRFDJdwn($_CaP0uVYDXP@VE6B|%*2^zS2WbP=0{Xg8hA!BrAkFyoK&*mjL$?;I KTY!N#C;EZk+?)NL2dwHkegxdhtVKI@XP<> z|3|E6L52|nfaWnWK+HupnuU`qJ>u8lL&;x%DznGV$kn@)|J`TN$?&H;vc;b%JB4{Y zbh>Kp%lIZR`Pg?(*~yn4l+Wp1!eQIXsPWyQF|$I<0LfhBaA%2n8!G#Vn`J#0V^Ro{ z%1p=l3aR9l$5Rqs|11CaX+u|($nIRPxd&Ael3y*{yTWr`TAvNw38eJPC)k@;|^%JDL^)mB{OA>Q(^k7AgUPw`D zs-1$7fkJU+Rcd~kj)I9oVoqjyUVa)E#K?dDAppdKrGJ=zVSE8rh*}va&1F++Y-9&C z3YVckbx;QFGC%wkI|fSxgf+iQsG8CZD-E7zF72@zBx zz)D6~3CP4~tY@HS2(%AF4`ZAeP|ARTK>=C}!D!SFE>6uYOD)oKttd$?%1g`%E-A{) zOSe-nL^#d@?zjM`NyniyF#Lf2FvR5zhK!Puf?_Lu{i4+L%;J)wO1->-+^k}~{GxPy sphxmbbCXhw^mP$zU9gMwO46&~()f)4))El?K!z?jBtV*hp)n`{0JK~VI{*Lx literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json new file mode 100644 index 000000000..b97dfcfee --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"is-odd":"3.0.1","is-number":"https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json new file mode 100644 index 000000000..a36b871e3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb-user-tarball/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on is-number by its registry tarball URL; is-odd@3.0.1 depends on is-number@^6.0.0, a nested registry copy of the same version", + "sha256": "7da8ccbaf8f73baf34c31091d5ff2f97c3e6c3ef8661980fdedb43ed09789086" +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json new file mode 100644 index 000000000..de5b452a3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb-user-tarball.json @@ -0,0 +1,36 @@ +{ + "bun-lockb-user-tarball/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@6.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/is-odd@3.0.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-lockb-user-tarball/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/is-number@6.0.0", + "file": "bun.lockb" + }, + { + "purl": "pkg:npm/is-odd@3.0.1", + "file": "bun.lockb" + } + ], + "live_claims": [] + } +} From edc8745b33eaab64e53fd51b752e06928d2161d3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:08:57 -0400 Subject: [PATCH 23/55] Tighten Bun user-tarball checks from #497 review (#497) rewrite_bun_lock ran is_user_tarball_entry in its unowned branch, which every non-target entry reaches for every granted dep. That re-decoded the entry's spec, already decoded at the top of the loop, in the hot loop #578 flags. Add is_user_tarball_spec, which takes the decoded spec and rejects other package names with one prefix strip, and use it there. vex's Unwired::record kept the first copy per purl. When an unwired registry copy came before a same-version user tarball, the diagnostic gave the "re-run to rewire every copy" remedy, which cannot reach the tarball. A user-tarball copy now replaces a recorded registry copy. CLI_CONTRACT: redirect_bun_entry_not_found no longer covers a same-version user tarball, which reports redirect_bun_non_registry_entry_skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../src/patch/redirect/mod.rs | 4 +- .../src/vendor/bun_lock_text.rs | 45 ++++++++++++++-- .../socket-patch-core/src/vex/discover/bun.rs | 52 +++++++++++++++++-- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7ac741061..ff17ceecc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -163,7 +163,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and only then is it redirected. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is refused BEFORE its revert, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). A taken-over package whose wiring was reverted but that was then not pinned to hosted now installs the unpatched registry release in both modes. Causes include a refused lock, unavailable hosted wheel metadata, or a vendored ledger update that failed after the revert (refused with `redirect_vendored_revert_failed`). It is reported as `redirect_takeover_unpatched` with `status: "partial_failure"` and exit 1, never as success. That warning also prints under `--silent`. Human output prints no `Migrated …` progress line for the package and no "keep the hosted patches" next steps. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`). @@ -1327,7 +1327,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | | `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | | `redirect_bun_lockb_invalid` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | -| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | +| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | | `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | | `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. | diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e1624b6f2..330ee2ac3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4848,7 +4848,7 @@ fn rewrite_bun_lock( result: &mut RewriteResult, ) { use crate::vendor::bun_lock_text::{ - decode_json_string, is_bundled_entry, is_user_tarball_entry, + decode_json_string, is_bundled_entry, is_user_tarball_spec, }; let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); @@ -4982,7 +4982,7 @@ fn rewrite_bun_lock( // this very version is installed from that spec beside the // pinned copy and stays unpatched (#497, npm's #326): say so, // and keep the in-run VEX from assuming the uuid patched. - if spec != url_spec && is_user_tarball_entry(entry, &fname, &dep.version) { + if spec != url_spec && is_user_tarball_spec(&spec, &fname, &dep.version) { user_tarball_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 345a20a7c..3d539be76 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -254,11 +254,16 @@ pub(crate) fn is_user_tarball_entry(entry: &BunEntry, name: &str, version: &str) .elems .first() .and_then(|raw| decode_json_string(raw)) - .is_some_and(|spec| { - split_name_spec(&spec).is_some_and(|(entry_name, target)| { - entry_name == name && user_tarball_version(name, target) == Some(version) - }) - }) + .is_some_and(|spec| is_user_tarball_spec(&spec, name, version)) +} + +/// [`is_user_tarball_entry`] on an already-decoded `name@` spec, +/// for hot loops that decoded it once already. The name is matched by a +/// prefix strip, so an entry of another package costs one compare. +pub(crate) fn is_user_tarball_spec(spec: &str, name: &str, version: &str) -> bool { + spec.strip_prefix(name) + .and_then(|rest| rest.strip_prefix('@')) + .is_some_and(|target| user_tarball_version(name, target) == Some(version)) } /// `"lockfileVersion": ` head check — only the fixture-pinned text @@ -784,6 +789,36 @@ mod tests { } } + #[test] + fn is_user_tarball_spec_checks_the_name_first() { + assert!(is_user_tarball_spec( + "left-pad@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(is_user_tarball_spec( + "@s/p@file:./p-1.0.0.tgz", + "@s/p", + "1.0.0" + )); + assert!(!is_user_tarball_spec( + "left-pad@./left-pad-1.2.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!is_user_tarball_spec("left-pad@1.3.0", "left-pad", "1.3.0")); + assert!(!is_user_tarball_spec( + "left-pad-x@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + assert!(!is_user_tarball_spec( + "is-odd@./left-pad-1.3.0.tgz", + "left-pad", + "1.3.0" + )); + } + /// #367: the keys come from the manifest and from the lock's mirror, /// and match the exact `name@version` (scoped too) or a bare name. #[test] diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 9ba3aec7f..8ae7ccdf4 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -449,17 +449,27 @@ fn classify( /// can rewire: bun installs it from its own spec. #[derive(Default)] struct Unwired { - /// purl → the first such entry's label, and whether it is a user - /// tarball. + /// purl → the first such entry's label (a user tarball's in preference + /// to a registry copy's), and whether it is a user tarball. copies: std::collections::BTreeMap, } impl Unwired { fn record(&mut self, purl: Option, label: &str, user_tarball: bool) { if let Some(purl) = purl { - self.copies - .entry(purl) - .or_insert_with(|| (label.to_string(), user_tarball)); + // A user-tarball copy wins over a registry one: a re-run rewires + // the registry copy but never the tarball, so the diagnostic must + // name the copy whose remedy is "depend on the registry version". + match self.copies.entry(purl) { + std::collections::btree_map::Entry::Vacant(v) => { + v.insert((label.to_string(), user_tarball)); + } + std::collections::btree_map::Entry::Occupied(mut o) => { + if user_tarball && !o.get().1 { + o.insert((label.to_string(), true)); + } + } + } } } @@ -883,6 +893,38 @@ mod tests { } } + /// REGRESSION (#497 review): when the same version has both an unwired + /// registry copy and a user-tarball copy, and the registry copy comes + /// first, the diagnostic still names the tarball copy, since a re-run + /// cannot rewire it. The "re-run to rewire every copy" remedy would be + /// wrong here. + #[tokio::test] + async fn issue_497_user_tarball_copy_outranks_an_earlier_registry_copy() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + format!("\"a/left-pad\": [\"left-pad@1.3.0\", \"\", {{}}, \"{SRI}\"]"), + tuple("left-pad", "left-pad@./left-pad-1.3.0.tgz", Some(SRI)), + tuple("z/left-pad", &format!("left-pad@{hosted}"), Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert_eq!(user_tarball_contests(&out), 1, "{:#?}", out.diagnostics); + assert!( + !out.diagnostics + .iter() + .any(|d| d.detail.contains("rewire every copy")), + "{:#?}", + out.diagnostics + ); + } + /// REGRESSION (#497), `bun.lockb`: Bun 1.1.45's tarball record of a /// root URL / `file:` dependency contests a hosted or vendored rewire /// of is-odd's nested registry copy of the same version. From 4528fde544d0befc506f9e21090c1b521bfec8e2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:17:01 -0400 Subject: [PATCH 24/55] Reconcile #497 with #578's bundled-check budget #578 made every per-target scan in vendor/bun_lock.rs run the cheap spec match before is_bundled_entry, which JSON-parses the entry's meta, and pinned the count with BUNDLED_CHECKS. #497 added a third per-target scan, user_tarball_matches, that called is_bundled_entry first on every entry, so vendoring paid one meta parse per lock entry per target again. Check is_user_tarball_entry first; the bundled check now runs only on a user-tarball copy of the target. Both checks are pure, so the result is unchanged. bundled_check_runs_only_on_matching_entries now also runs the user-tarball scan and keeps the same at-most-4 budget. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vendor/bun_lock.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index c433ae580..4bdbb116c 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1249,11 +1249,12 @@ fn bundled_matches( /// Keys of the non-bundled entries that install `name@version` from a user /// URL / `file:` tarball (#497): bun installs those from their own spec, so -/// no vendored tuple reaches them. +/// no vendored tuple reaches them. The spec check runs first: the bundled +/// check JSON-parses the meta, so it may run only on a match (#578). fn user_tarball_matches(entries: &[BunEntry], name: &str, version: &str) -> Vec { entries .iter() - .filter(|e| !is_bundled_entry(e) && is_user_tarball_entry(e, name, version)) + .filter(|e| is_user_tarball_entry(e, name, version) && !is_bundled_entry(e)) .map(|e| e.key.clone()) .collect() } @@ -4860,11 +4861,15 @@ mod tests { .filter(|e| classify_rewritable(e, spec, "pkg0", &leaf).is_some()) .count(); let bundled = bundled_matches(&entries, spec, "pkg0", &leaf); + // The #497 user-tarball scan is a third per-target pass; with no + // tarball entry for the target it must make no bundled check. + let user_tarballs = user_tarball_matches(&entries, "pkg0", "1.0.0"); let checks = BUNDLED_CHECKS.with(std::cell::Cell::get); assert_eq!(rewritable, 1, "only the registry tuple is rewritable"); assert_eq!(bundled, vec!["parent/pkg0".to_string()]); - // Two matching entries, two scans: at most four checks, not 2 × 201. + assert!(user_tarballs.is_empty()); + // Two matching entries, two scans: at most four checks, not 3 × 201. assert!( checks <= 4, "{checks} bundled checks over {} entries", From d4541caa4de2e1315d5f679c149b34d9c56be887 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:22:40 -0400 Subject: [PATCH 25/55] Skip orphaned Bun store entries as installed copies (#599) Bun's isolated linker never prunes node_modules/.bun. After the usual in-place `bun install` of a hosted-rewired lock (or any version churn), the old `@` entry stays on disk while every importer, the `.bun/node_modules` hoist links and the dependents' entries are re-linked to the new entry. The npm crawler's pnpm-shaped store walk (since #496) enumerated every `.bun` entry dir as an installed copy with no reachability check, so vex judged the install by an orphan nothing can load and refused every hosted patch as not_applied (exit 1) until `rm -rf node_modules`; vendored mode raised false vendored_tree_out_of_sync warnings, and agent-mode apply fanned out to the orphans. The `.bun` store listing, shared by the scan, the purl resolver (apply) and the peer-variant finder, now keeps only entries reachable through links from the importer `node_modules`, the hoist dir, workspace members' `node_modules` (the crawler's own workspace walk, only when needed) and live entries. A stale link Bun left behind still counts, since the runtime resolves through it, and a store with no links at all keeps every entry. A quick first walk guesses link targets by package name (unique names, and scopes without duplicated packages) and reuses the entry listings the scan reads anyway, so a clean store costs no extra readdir per entry; only when it leaves an entry unreached is the store re-walked reading every link before anything is dropped (an alias link can defeat the name guess). bench bun-isolated/{hosted,rescan} stay within noise. Verified against real Bun 1.3.14 and 1.4.2 trees (orphaned is-number@6.0.0 after rewiring is-odd/is-number to tarball URLs): vex exits 1 on main, 0 with this change. Fixtures that left store entries unlinked are updated to link them the way Bun does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vex_redirect.rs | 88 ++++ .../src/crawlers/npm_crawler.rs | 424 +++++++++++++++++- .../tests/crawler_npm_e2e.rs | 10 + 3 files changed, 517 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 74ccf58b5..2fb430209 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -2292,6 +2292,94 @@ fn bun_hosted_ref_is_judged_by_a_global_store_copy() { assert_attested(cwd, code, &env, UUID, "the global store copy verifies"); } +/// #599: Bun never prunes `.bun`. After the usual in-place `bun install` +/// of a hosted-rewired lock, the pre-scan `left-pad@1.3.0` registry entry +/// stays on disk, pristine, but the importer's dependency entry and the +/// `.bun/node_modules` hoist link now point at the hosted tarball's entry +/// (real Bun 1.3.14 / 1.4.2 layout). Nothing can load the orphan, so it is +/// no installed copy: the patched live copy attests, where vex used to +/// refuse the patch as `not_applied` until `rm -rf node_modules`. +#[cfg(unix)] +#[test] +fn bun_hosted_ref_ignores_orphaned_registry_store_entry() { + let (pristine, patched) = ( + &b"module.exports = 'pristine'\n"[..], + &b"module.exports = 'patched'\n"[..], + ); + let purl = "pkg:npm/left-pad@1.3.0"; + let url = hosted_npm_url("left-pad", "1.3.0", UUID); + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + put( + cwd, + "package.json", + br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, + ); + put( + cwd, + "bun.lock", + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n \ + \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ + \"sha512-{dep}==\"],\n\n \ + \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", + dep = "D".repeat(86), + ) + .as_bytes(), + ); + let live_entry = format!("left-pad@{}", url.replace([':', '/'], "+")); + for (entry, bytes) in [("left-pad@1.3.0", pristine), (live_entry.as_str(), patched)] { + let store = format!("node_modules/.bun/{entry}/node_modules/left-pad"); + put( + cwd, + &format!("{store}/package.json"), + br#"{ "name": "left-pad", "version": "1.3.0" }"#, + ); + put(cwd, &format!("{store}/index.js"), bytes); + } + put( + cwd, + "node_modules/.bun/dep@1.0.0/node_modules/dep/package.json", + br#"{ "name": "dep", "version": "1.0.0" }"#, + ); + let link = |target: String, at: &str| { + let at = cwd.join(at); + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(target, at).unwrap(); + }; + link( + format!("../../{live_entry}/node_modules/left-pad"), + "node_modules/.bun/dep@1.0.0/node_modules/left-pad", + ); + link( + format!("../{live_entry}/node_modules/left-pad"), + "node_modules/.bun/node_modules/left-pad", + ); + link( + "../dep@1.0.0/node_modules/dep".to_string(), + "node_modules/.bun/node_modules/dep", + ); + link( + ".bun/dep@1.0.0/node_modules/dep".to_string(), + "node_modules/dep", + ); + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + one_file_view(UUID, purl, "package/index.js", pristine, patched), + )]); + + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_attested( + cwd, + code, + &env, + UUID, + "the orphaned registry entry is no copy", + ); +} + /// The patch view for `name@version` (the [`left_pad_view`] shape). fn npm_view(name: &str, version: &str, after_hash: &str) -> Value { let mut view = left_pad_view(after_hash); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 56ba4d757..1eb47d02e 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -578,6 +578,240 @@ pub fn bun_uses_global_store(project_root: &Path) -> bool { }) } +/// Keep only the `.bun` store entries an install can still load (#599). +/// Bun never prunes its store: an in-place `bun install` that re-resolves +/// a package (a hosted tarball rewire, a version bump) writes a new entry, +/// re-links every dependent to it and leaves the old `@` +/// dir behind with nothing pointing at it. Such an orphan is not an +/// installed copy: apply must not fan out to it, and vex must not judge +/// the install by its stale bytes. +/// +/// An entry is live when a link reaches it from the `node_modules` +/// holding the store, from Bun's hidden hoist dir `.bun/node_modules` +/// (every store package resolves through it), from a workspace member's +/// `node_modules` (the crawler's own workspace walk, run only when the +/// cheaper seeds leave some entry unreached), or from a live entry's +/// `node_modules`. A stale link Bun left behind still counts: the runtime +/// resolves through it. A store no link reaches at all gives no evidence +/// either way, so every entry is kept. +/// +/// A first, quick walk guesses link targets by name (see +/// [`BunStoreNames`]), which keeps a clean store's walk to the listings +/// the scan reads anyway. The guesses only ever over-reach, except that an +/// alias link can defeat one (`lp` linking `left-pad@…` while an `lp@…` +/// entry exists), so when the quick walk leaves any entry unreached, the +/// store is walked again reading every link before anything is dropped. +/// +/// Returns the `node_modules` listings of the entries the walk read, by +/// entry name, so the scan does not list them a second time. +fn retain_live_bun_store_entries_sync( + store_path: &Path, + candidates: &mut Vec, +) -> HashMap { + let (Ok(real_store), Some(importer)) = (std::fs::canonicalize(store_path), store_path.parent()) + else { + return HashMap::new(); + }; + if candidates.is_empty() { + return HashMap::new(); + } + let names = BunStoreNames::new(candidates); + let walk = |unique: Option<&BunStoreNames>| { + let mut live: HashSet = HashSet::new(); + let mut listings = HashMap::new(); + let seeds = [store_path.join("node_modules"), importer.to_path_buf()]; + reach_bun_store_entries_sync(&seeds, &real_store, unique, &mut live, &mut listings); + if !live.is_empty() && candidates.iter().any(|e| !live.contains(&e.name)) { + let root = match importer.parent() { + Some(root) if !root.as_os_str().is_empty() => root, + _ => Path::new("."), + }; + let members = NpmCrawler::find_workspace_node_modules(root, list_dir_sync(root)); + reach_bun_store_entries_sync(&members, &real_store, unique, &mut live, &mut listings); + } + (live, listings) + }; + let (mut live, mut listings) = walk(Some(&names)); + if candidates.iter().any(|e| !live.contains(&e.name)) { + (live, listings) = walk(None); + } + if !live.is_empty() { + candidates.retain(|e| live.contains(&e.name)); + } + listings +} + +/// The quick walk's guesses: a link named for a package only one `.bun` +/// entry holds points at that entry, and a `@scope` dir none of whose +/// packages has a second entry links (at most) that scope's entries, so +/// neither is read. Only a package with several entries (where an orphan +/// hides) needs its links read. +struct BunStoreNames { + unique: HashMap, + scopes: HashMap>, +} + +impl BunStoreNames { + fn new(candidates: &[ListedEntry]) -> Self { + let mut by_package: HashMap> = HashMap::new(); + for entry in candidates { + if let Some(package) = bun_store_entry_package(&entry.name_str) { + by_package + .entry(package) + .and_modify(|only| *only = None) + .or_insert(Some(&entry.name)); + } + } + let mut unique = HashMap::new(); + let mut scopes: HashMap>> = HashMap::new(); + for (package, only) in by_package { + let scope = package.split_once('/').map(|(scope, _)| scope.to_string()); + match only { + Some(entry) => { + if let Some(scope) = scope { + if let Some(entries) = scopes.entry(scope).or_insert(Some(Vec::new())) { + entries.push(entry.clone()); + } + } + unique.insert(package, entry.clone()); + } + None => { + if let Some(scope) = scope { + scopes.insert(scope, None); + } + } + } + } + let scopes = scopes + .into_iter() + .filter_map(|(scope, entries)| Some((scope, entries?))) + .collect(); + Self { unique, scopes } + } +} + +/// The package a `.bun` entry name is for, spelled the way a link to it +/// is named (`@scope+leaf@…` is `@scope/leaf`); `None` without a version. +fn bun_store_entry_package(entry_name: &str) -> Option { + let skip = usize::from(entry_name.starts_with('@')); + let at = skip + entry_name.get(skip..)?.find('@')?; + let package = entry_name.get(..at).filter(|p| p.len() > skip)?; + Some(if skip == 1 { + package.replacen('+', "/", 1) + } else { + package.to_string() + }) +} + +/// Add to `live` every `.bun` entry (by name) reachable through links from +/// the `seeds` dirs, following each reached entry's own `node_modules` +/// links, and record each entry's listing in `listings`. Read one +/// frontier at a time, each frontier's dirs in parallel. With `names`, +/// targets are guessed by name where they can be. +fn reach_bun_store_entries_sync( + seeds: &[PathBuf], + real_store: &Path, + names: Option<&BunStoreNames>, + live: &mut HashSet, + listings: &mut HashMap, +) { + let mut frontier: Vec<(Option, PathBuf)> = seeds + .iter() + .filter_map(|nm| Some((None, std::fs::canonicalize(nm).ok()?))) + .collect(); + while !frontier.is_empty() { + let visited = par_map(frontier, |(entry, nm)| { + let listing = read_dir_entries_sync(&nm) + .map(|(entries, complete)| Listing::from_entries(entries, complete)); + let targets = listing + .as_ref() + .map(|listing| bun_store_link_targets_sync(&nm, listing, real_store, names)) + .unwrap_or_default(); + (entry, listing, targets) + }); + frontier = Vec::new(); + for (entry, listing, targets) in visited { + if let (Some(entry), Some(listing)) = (entry, listing) { + listings.insert(entry, listing); + } + for target in targets { + if live.insert(target.clone()) { + let nm = real_store.join(&target).join("node_modules"); + frontier.push((Some(target), nm)); + } + } + } + } +} + +/// The `.bun` entries the package links in `listing` (of the real dir +/// `nm`; scoped ones under `@scope/`) point into. With `names`, a link +/// or scope dir it can guess is not read (see [`BunStoreNames`]); any +/// other link is read and +/// resolved lexically first (Bun writes relative targets, and `nm` is +/// real, so each `..` climbs a real dir), and one that lands outside the +/// store that way (an absolute Windows junction, a linked `node_modules`) +/// is canonicalized instead. +fn bun_store_link_targets_sync( + nm: &Path, + listing: &Listing, + real_store: &Path, + names: Option<&BunStoreNames>, +) -> Vec { + let mut targets = Vec::new(); + // (link, the package its name spells) + let mut links: Vec<(PathBuf, String)> = Vec::new(); + for entry in &listing.entries { + let Some(file_type) = entry.file_type else { + continue; + }; + if entry.name_str.starts_with('.') { + continue; + } + if file_type.is_symlink() { + links.push((nm.join(&entry.name), entry.name_str.clone())); + } else if file_type.is_dir() && entry.name_str.starts_with('@') { + if let Some(entries) = names.and_then(|names| names.scopes.get(&entry.name_str)) { + targets.extend(entries.iter().cloned()); + continue; + } + let scope = nm.join(&entry.name); + for scoped in list_dir_sync(&scope).entries { + if scoped.file_type.is_some_and(|ft| ft.is_symlink()) { + let package = format!("{}/{}", entry.name_str, scoped.name_str); + links.push((scope.join(&scoped.name), package)); + } + } + } + } + let links: Vec = links + .into_iter() + .filter_map( + |(link, package)| match names.and_then(|names| names.unique.get(&package)) { + Some(entry) => { + targets.push(entry.clone()); + None + } + None => Some(link), + }, + ) + .collect(); + let entry_of = |path: &Path| match path.strip_prefix(real_store).ok()?.components().next() { + Some(std::path::Component::Normal(name)) => Some(name.to_os_string()), + _ => None, + }; + targets.extend(links.iter().filter_map(|link| { + let lexical = std::fs::read_link(link) + .ok() + .and_then(|target| Some(normalize_lexically(&link.parent()?.join(target)))); + lexical + .as_deref() + .and_then(entry_of) + .or_else(|| entry_of(&std::fs::canonicalize(link).ok()?)) + })); + targets +} + /// The `node_modules` child that is npm's `install-strategy=linked` store, /// also written by Yarn 4's pnpm linker (see /// [`store_entry_own_package_sync`]). @@ -2609,7 +2843,7 @@ impl NpmCrawler { read_listings: bool, ) -> Vec { let decode = |name: &str| layout.decode_pnpm_shaped(name); - let candidates: Vec = list_dir_sync(store_path) + let mut candidates: Vec = list_dir_sync(store_path) .entries .into_iter() .filter(|entry| { @@ -2622,17 +2856,34 @@ impl NpmCrawler { }) }) .collect(); + // pnpm prunes its store on install; Bun never does (#599). + let mut listings = if layout == StoreLayout::Bun { + retain_live_bun_store_entries_sync(store_path, &mut candidates) + } else { + HashMap::new() + }; + let candidates: Vec<(ListedEntry, Option)> = candidates + .into_iter() + .map(|entry| { + let listing = listings.remove(&entry.name).filter(|_| read_listings); + (entry, listing) + }) + .collect(); - par_map(candidates, |entry| { + par_map(candidates, |(entry, listing)| { let entry_path = store_path.join(&entry.name); let entry_nm = entry_path.join("node_modules"); if read_listings { - if let Some((entries, complete)) = read_dir_entries_sync(&entry_nm) { + let listing = listing.or_else(|| { + read_dir_entries_sync(&entry_nm) + .map(|(entries, complete)| Listing::from_entries(entries, complete)) + }); + if let Some(listing) = listing { return vec![StoreEntryDir { advertised: decode(&entry.name_str), name: entry.name_str, node_modules: entry_nm, - listing: Some(Listing::from_entries(entries, complete)), + listing: Some(listing), }]; } } @@ -5649,9 +5900,17 @@ mod tests { link_dir(&number, &odd_entry.join("is-number")); let frame = store.join("@babel+code-frame@7.0.0/node_modules/@babel/code-frame"); write_pkg(&frame, "@babel/code-frame", "7.0.0"); - std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::create_dir_all(store.join("node_modules/@babel")).unwrap(); link_dir(&number, &store.join("node_modules/is-number")); + link_dir(&hosted, &store.join("node_modules/to-regex-range")); + link_dir(&frame, &store.join("node_modules/@babel/code-frame")); link_dir(&odd_entry.join("is-odd"), &nm.join("is-odd")); + // Every entry is linked from somewhere, as Bun writes it (an + // unlinked one is an orphan, #599); the peer twin from a workspace + // member's importer. + let member_nm = root.join("packages/a/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&number_twin, &member_nm.join("is-number")); assert_store_copies_found( &root, @@ -5735,6 +5994,161 @@ mod tests { .await; } + /// #599: Bun never prunes `.bun`. After an in-place `bun install` that + /// rewires packages to hosted tarballs (or bumps a version), the old + /// `@` entries stay on disk with nothing linking to + /// them, while the importers, the `.bun/node_modules` hoist links and + /// the dependents' entries all point at the new entries (the layout + /// real Bun 1.3.14 / 1.4.2 writes). An orphan is no installed copy: + /// scan, the resolver and the peer-variant finder must all skip it, or + /// vex judges the install by stale bytes nothing can load. A live entry + /// linked only from a workspace member (an unhoisted second version) + /// stays found. + #[tokio::test] + async fn test_bun_isolated_store_orphaned_entries_are_not_copies() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + let hoist = store.join("node_modules"); + let member_a = root.join("packages/a/node_modules"); + let member_b = root.join("packages/b/node_modules"); + for dir in [&hoist.join("@s"), &member_a, &member_b] { + std::fs::create_dir_all(dir).unwrap(); + } + + // Live: the hosted rewires, and two left-pad versions (1.3.0 + // hoisted, 1.2.0 linked only from member b). + let odd = store.join("is-odd@http+++127.0.0.1+is-odd.tgz/node_modules"); + write_pkg(&odd.join("is-odd"), "is-odd", "3.0.1"); + let number = store.join("is-number@http+++127.0.0.1+is-number.tgz/node_modules/is-number"); + write_pkg(&number, "is-number", "6.0.0"); + link_dir(&number, &odd.join("is-number")); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let old_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&old_pad, "left-pad", "1.2.0"); + link_dir(&odd.join("is-odd"), &nm.join("is-odd")); + link_dir(&odd.join("is-odd"), &hoist.join("is-odd")); + link_dir(&number, &hoist.join("is-number")); + link_dir(&pad, &hoist.join("left-pad")); + link_dir(&number, &member_a.join("is-number")); + link_dir(&pad, &member_a.join("left-pad")); + link_dir(&old_pad, &member_b.join("left-pad")); + let frame = store.join("@s+frame@http+++127.0.0.1+frame.tgz/node_modules/@s/frame"); + write_pkg(&frame, "@s/frame", "7.0.0"); + link_dir(&frame, &hoist.join("@s/frame")); + + // Orphans: the pre-rewire registry entries (still linked to each + // other) and a churned-away version. + let stale_odd = store.join("is-odd@3.0.1/node_modules"); + write_pkg(&stale_odd.join("is-odd"), "is-odd", "3.0.1"); + let stale_number = store.join("is-number@6.0.0/node_modules/is-number"); + write_pkg(&stale_number, "is-number", "6.0.0"); + link_dir(&stale_number, &stale_odd.join("is-number")); + let stale_frame = store.join("@s+frame@7.0.0/node_modules/@s/frame"); + write_pkg(&stale_frame, "@s/frame", "7.0.0"); + write_pkg( + &store.join("left-pad@1.1.0/node_modules/left-pad"), + "left-pad", + "1.1.0", + ); + + let scanned = scan_paths(&root).await; + for (purl, path) in &scanned { + assert!( + !path.starts_with(&stale_odd) + && !path.starts_with(stale_number.parent().unwrap()) + && !path.starts_with(&stale_frame) + && purl != "pkg:npm/left-pad@1.1.0", + "an orphaned entry was scanned: {scanned:?}" + ); + } + assert!( + scanned.contains(&("pkg:npm/left-pad@1.2.0".to_string(), old_pad.clone())), + "{scanned:?}" + ); + + let purls: Vec = [ + "pkg:npm/@s/frame@7.0.0", + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-odd@3.0.1", + "pkg:npm/left-pad@1.1.0", + "pkg:npm/left-pad@1.2.0", + ] + .map(String::from) + .to_vec(); + let found = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); + let paths = |purl: &str| -> Vec { + let mut got: Vec = found + .get(purl) + .map(|copies| copies.iter().map(|p| p.path.clone()).collect()) + .unwrap_or_default(); + got.sort(); + got + }; + assert_eq!(paths("pkg:npm/@s/frame@7.0.0"), vec![frame.clone()]); + assert_eq!(paths("pkg:npm/is-number@6.0.0"), vec![number.clone()]); + assert_eq!(paths("pkg:npm/is-odd@3.0.1"), vec![nm.join("is-odd")]); + assert_eq!(paths("pkg:npm/left-pad@1.1.0"), Vec::::new()); + assert_eq!(paths("pkg:npm/left-pad@1.2.0"), vec![old_pad.clone()]); + + assert_eq!( + find_store_peer_variant_copies(&number).await, + Vec::::new() + ); + } + + /// #599: the orphan filter's quick walk takes a link named for a + /// package only one entry holds to be that entry. An alias link + /// (`node_modules/lp` -> `left-pad@1.3.0`) beside an unrelated `lp@…` + /// entry defeats the guess, so the entry it really reaches must still + /// be found (by the exact re-walk) and the never-linked `lp@` dropped. + #[tokio::test] + async fn test_bun_isolated_store_alias_link_is_resolved_exactly() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + write_pkg(&store.join("lp@2.0.0/node_modules/lp"), "lp", "2.0.0"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + link_dir(&pad, &nm.join("lp")); + + let purls = ["pkg:npm/left-pad@1.3.0", "pkg:npm/lp@2.0.0"].map(String::from); + let found = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); + let paths: Vec<_> = found["pkg:npm/left-pad@1.3.0"] + .iter() + .map(|p| p.path.clone()) + .collect(); + assert_eq!(paths, vec![pad.clone()]); + assert!(!found.contains_key("pkg:npm/lp@2.0.0"), "{found:?}"); + let scanned = scan_paths(&root).await; + assert!( + scanned.iter().all(|(purl, _)| purl != "pkg:npm/lp@2.0.0"), + "{scanned:?}" + ); + } + + #[test] + fn test_bun_store_entry_package() { + for (entry, want) in [ + ("is-number@6.0.0", Some("is-number")), + ("is-number@http+++127.0.0.1+t.tgz", Some("is-number")), + ("@babel+code-frame@7.0.0", Some("@babel/code-frame")), + ( + "@babel+code-frame@7.0.0+3c4e1d2a", + Some("@babel/code-frame"), + ), + ("no-version", None), + ("@scope+only", None), + ("@1.0.0", None), + ] { + assert_eq!(bun_store_entry_package(entry).as_deref(), want, "{entry}"); + } + } + /// #373: Deno's isolated `nodeModulesDir` keeps every npm package in /// `node_modules/.deno/@[_]/node_modules/` /// (scoped `@scope+leaf@…`), beside `.deno/.deno.lock` and the diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 9f30819ac..dd1e13d77 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -2376,6 +2376,16 @@ async fn find_by_purls_returns_bundled_copy_of_an_already_found_target() { .unwrap(); nm.join("left-pad") } else { + // Bun counts only linked entries as installed (#599); its + // hoist dir links a transitive-only package. + if store_name == ".bun" { + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::os::unix::fs::symlink( + normal_nm.join("left-pad"), + store.join("node_modules/left-pad"), + ) + .unwrap(); + } normal_nm.join("left-pad") }; From 5257701870ede5e072770ff5619bd05f61234e55 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:08:05 -0400 Subject: [PATCH 26/55] Keep orphaned Bun store entries for rollback, seed members from Bun (#599) Review of the #599 fix found three problems with filtering orphaned `.bun` entries inside the shared store listing: - Rollback and remove resolve through the same listing, so a patched entry that became an orphan (version bump + `bun install`) was no longer found: rollback reported "no matching installed package", dropped the manifest entry and freed its blobs, while the entry kept its patched bytes. Bun re-links the orphan as-is when a later install resolves back to that version, so the rolled-back patch silently came back. - Workspace members were found with the crawler's heuristic directory walk, which skips hidden dirs and `vendor`/`tmp`/`build`/... . An unhoisted second version linked only from a member under such a dir was dropped as an orphan, so vex could attest without checking a copy Node really loads (fail-open). - That full-tree walk ran on every listing call (scan, resolver, every peer-variant lookup) whenever the store held an orphan, about 6x slower on a large tree. The orphan filter now applies only to judgements of the live install: the scan's store listing, and the copy sets vex checks (a new `retain_live_store_copies`, run once over all purls in `find_manifest_package_copies_reusing` and over the hosted consumed copies, each store walked once). The resolver and the peer-variant finder keep every entry, as on main, so apply, rollback, remove and the rollback blob gate still reach orphans. Members are now the ones Bun installs: the `workspaces` keys of `bun.lock` plus the dirs the root `package.json` `workspaces` patterns match (the only source for `bun.lockb`), with `*`/`?` matching dot-names and `!` exclusions ignored, since an extra member can only keep an entry. When the member set cannot be known (unparseable `package.json`, an odd `workspaces` shape, a `**` walk past its budget), nothing is dropped. No full-tree walk remains. Tests: the crawler unit test now places members under `vendor/` (from package.json) and a hidden dir (from bun.lock only) and checks that the resolver and peer-variant finder still return orphans while the scan and `retain_live_store_copies` drop them; new tests cover unknown members keeping every entry and the glob expansion. A binary-driven rollback test restores a patched orphaned `.bun` entry; it fails when the resolver filters orphans. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/vex_consumed.rs | 9 + .../src/ecosystem_dispatch.rs | 15 +- .../tests/covgap_commands_rollback.rs | 73 +++ .../src/crawlers/npm_crawler.rs | 538 ++++++++++++++---- .../src/hosted/governing_root.rs | 2 +- 5 files changed, 524 insertions(+), 113 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b55788ef4..c49e7b805 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -154,6 +154,15 @@ pub(crate) async fn hosted_consumed_copies( }, ); } + // An orphaned Bun store entry is no consumed copy (#599); the + // installed lookup dropped its own, this drops the ones the alias + // and identity fallbacks' variant expansion added. + socket_patch_core::crawlers::npm_crawler::retain_live_store_copies( + out.iter_mut() + .filter(|(purl, _)| npm.contains(purl)) + .map(|(_, copies)| &mut copies.paths), + ) + .await; } // Distinct-store ecosystems: only the hosted artifact's own store entry. diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index e2620eaa6..5ca203fb9 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -7,7 +7,9 @@ use std::path::PathBuf; use crate::args::GlobalArgs; -use socket_patch_core::crawlers::npm_crawler::with_store_peer_variant_copies; +use socket_patch_core::crawlers::npm_crawler::{ + retain_live_store_copies, with_store_peer_variant_copies, +}; use socket_patch_core::crawlers::walk_pool; use socket_patch_core::crawlers::CargoCrawler; use socket_patch_core::crawlers::ComposerCrawler; @@ -640,6 +642,17 @@ pub async fn find_manifest_package_copies_reusing( *paths = with_store_peer_variant_copies(std::mem::take(paths)).await; } } + // A Bun store entry nothing links any more (Bun never prunes `.bun`) + // is no copy the install loads, so the check skips it (#599). Rollback + // and remove, which resolve without this, still restore it. + retain_live_store_copies( + copies + .iter_mut() + .filter(|(purl, _)| purl.starts_with("pkg:npm/")) + .map(|(_, paths)| paths), + ) + .await; + copies.retain(|_, paths| !paths.is_empty()); // Verification also READS a `.bundle/config` bundle path the crawler // refused as a write root (it resolves outside the project): bundler // installs into and loads from it, so a copy there must verify too — diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index c0ccd808d..91b8b5338 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -3472,3 +3472,76 @@ fn vlt_hosted_rollback_dry_run_keeps_the_store_and_wet_human_run_heals() { assert!(!store.exists()); assert!(!root.join("node_modules/.vlt-lock.json").exists()); } + +/// #599: Bun never prunes `node_modules/.bun`. A patched `is-number@6.0.0` +/// entry the project has since moved off (an in-place `bun install` of +/// 7.0.0 re-linked the importer and hoist dir to the new entry) is an +/// orphan nothing loads now, but a later install that resolves back to +/// 6.0.0 re-links it as it is ("no changes"). Rollback must still restore +/// it, or the rolled-back patch silently returns: the orphan filter that +/// keeps `vex` from judging the install by such an entry is for checks of +/// the live install only. +#[cfg(unix)] +#[test] +fn bun_orphaned_store_entry_is_still_rolled_back() { + let before: &[u8] = b"module.exports = 'original'\n"; + let after: &[u8] = b"module.exports = 'original' // PATCHED-599\n"; + let (before_hash, after_hash) = (git_sha256(before), git_sha256(after)); + let purl = "pkg:npm/is-number@6.0.0"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "app", "version": "0.0.0", "dependencies": { "is-number": "7.0.0" } }"#, + ) + .expect("write root package.json"); + let orphan = install_npm_pkg( + root, + "node_modules/.bun/is-number@6.0.0/node_modules", + "is-number", + "6.0.0", + after, + ); + install_npm_pkg( + root, + "node_modules/.bun/is-number@7.0.0/node_modules", + "is-number", + "7.0.0", + b"module.exports = 7\n", + ); + std::fs::create_dir_all(root.join("node_modules/.bun/node_modules")).expect("hoist dir"); + std::os::unix::fs::symlink( + "../is-number@7.0.0/node_modules/is-number", + root.join("node_modules/.bun/node_modules/is-number"), + ) + .expect("hoist link"); + std::os::unix::fs::symlink( + ".bun/is-number@7.0.0/node_modules/is-number", + root.join("node_modules/is-number"), + ) + .expect("importer link"); + let socket = write_socket_manifest( + root, + &[manifest_entry( + purl, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + &before_hash, + &after_hash, + )], + ); + stage_blob(&socket, &before_hash, before); + stage_blob(&socket, &after_hash, after); + + let (code, stdout, stderr) = run(root, &["rollback", "--offline", "--yes"]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + !stderr.contains("no matching installed package"), + "the orphaned entry must be found; stderr=\n{stderr}" + ); + assert_eq!( + std::fs::read(orphan.join("index.js")).expect("read orphan index.js"), + before, + "the orphaned entry keeps its patched bytes; stdout=\n{stdout}\nstderr=\n{stderr}" + ); +} diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 1eb47d02e..e2ea7e3f4 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -527,6 +527,25 @@ const PNPM_SHAPED_STORES: [(&str, StoreLayout); 3] = [ (".deno", StoreLayout::Deno), ]; +/// The entry dirs of a pnpm-shaped store: every real dir but the hidden +/// metadata and the `node_modules` hoist dir, and for Bun each link into +/// its global store (#635). +fn pnpm_shaped_store_candidates_sync(store_path: &Path, layout: StoreLayout) -> Vec { + list_dir_sync(store_path) + .entries + .into_iter() + .filter(|entry| { + !(entry.name_str.starts_with('.') || entry.name_str == "node_modules") + && entry.file_type.is_some_and(|ft| { + ft.is_dir() + || (ft.is_symlink() + && layout == StoreLayout::Bun + && is_bun_global_store_link_sync(store_path, &entry.name_str)) + }) + }) + .collect() +} + /// The pnpm-shaped store layout a `node_modules` child named `name` is. fn pnpm_shaped_store_layout(name: &str) -> Option { PNPM_SHAPED_STORES @@ -578,20 +597,27 @@ pub fn bun_uses_global_store(project_root: &Path) -> bool { }) } -/// Keep only the `.bun` store entries an install can still load (#599). -/// Bun never prunes its store: an in-place `bun install` that re-resolves -/// a package (a hosted tarball rewire, a version bump) writes a new entry, -/// re-links every dependent to it and leaves the old `@` -/// dir behind with nothing pointing at it. Such an orphan is not an -/// installed copy: apply must not fan out to it, and vex must not judge -/// the install by its stale bytes. +/// The `.bun` store entries an install can still load (#599), with the +/// `node_modules` listings of the entries the walk read (by entry name, +/// so the scan does not list them a second time); `None` when the walk +/// cannot tell, and every entry must be kept. Bun never prunes its store: +/// an in-place `bun install` that re-resolves a package (a hosted tarball +/// rewire, a version bump) writes a new entry, re-links every dependent +/// to it and leaves the old `@` dir behind with nothing +/// pointing at it. Such an orphan is not an installed copy, and a +/// judgement of the live install (the scan, `vex`) must not count it. +/// Restoring operations still must (rollback, remove): a later install +/// that resolves back to that version re-links the orphan as it is, so +/// only those judgement callers use this. /// /// An entry is live when a link reaches it from the `node_modules` /// holding the store, from Bun's hidden hoist dir `.bun/node_modules` /// (every store package resolves through it), from a workspace member's -/// `node_modules` (the crawler's own workspace walk, run only when the -/// cheaper seeds leave some entry unreached), or from a live entry's -/// `node_modules`. A stale link Bun left behind still counts: the runtime +/// `node_modules`, or from a live entry's `node_modules`. The members are +/// the ones Bun itself installs (see +/// [`bun_workspace_member_node_modules_sync`]), read only when the cheaper +/// seeds leave some entry unreached; when they cannot be read, nothing +/// is dropped. A stale link Bun left behind still counts: the runtime /// resolves through it. A store no link reaches at all gives no evidence /// either way, so every entry is kept. /// @@ -601,44 +627,218 @@ pub fn bun_uses_global_store(project_root: &Path) -> bool { /// alias link can defeat one (`lp` linking `left-pad@…` while an `lp@…` /// entry exists), so when the quick walk leaves any entry unreached, the /// store is walked again reading every link before anything is dropped. -/// -/// Returns the `node_modules` listings of the entries the walk read, by -/// entry name, so the scan does not list them a second time. -fn retain_live_bun_store_entries_sync( +fn live_bun_store_entries_sync( store_path: &Path, - candidates: &mut Vec, -) -> HashMap { - let (Ok(real_store), Some(importer)) = (std::fs::canonicalize(store_path), store_path.parent()) - else { - return HashMap::new(); - }; + candidates: &[ListedEntry], +) -> Option<(HashSet, HashMap)> { + let real_store = std::fs::canonicalize(store_path).ok()?; + let importer = store_path.parent()?; if candidates.is_empty() { - return HashMap::new(); + return None; } + let root = match importer.parent() { + Some(root) if !root.as_os_str().is_empty() => root, + _ => Path::new("."), + }; let names = BunStoreNames::new(candidates); - let walk = |unique: Option<&BunStoreNames>| { + let unreached = |live: &HashSet| candidates.iter().any(|e| !live.contains(&e.name)); + // Read at most once, shared by both walks. + let mut members: Option>> = None; + let mut walk = |unique: Option<&BunStoreNames>| { let mut live: HashSet = HashSet::new(); let mut listings = HashMap::new(); let seeds = [store_path.join("node_modules"), importer.to_path_buf()]; reach_bun_store_entries_sync(&seeds, &real_store, unique, &mut live, &mut listings); - if !live.is_empty() && candidates.iter().any(|e| !live.contains(&e.name)) { - let root = match importer.parent() { - Some(root) if !root.as_os_str().is_empty() => root, - _ => Path::new("."), - }; - let members = NpmCrawler::find_workspace_node_modules(root, list_dir_sync(root)); - reach_bun_store_entries_sync(&members, &real_store, unique, &mut live, &mut listings); + if unreached(&live) { + let members = members + .get_or_insert_with(|| bun_workspace_member_node_modules_sync(root)) + .as_deref()?; + reach_bun_store_entries_sync(members, &real_store, unique, &mut live, &mut listings); } - (live, listings) + (!live.is_empty()).then_some((live, listings)) }; - let (mut live, mut listings) = walk(Some(&names)); - if candidates.iter().any(|e| !live.contains(&e.name)) { - (live, listings) = walk(None); + let quick = walk(Some(&names))?; + if !unreached(&quick.0) { + return Some(quick); } - if !live.is_empty() { - candidates.retain(|e| live.contains(&e.name)); + walk(None) +} + +/// The `node_modules` dirs of the workspace members a Bun install at +/// `root` links: every member `bun.lock` lists under `workspaces`, plus +/// every dir the root `package.json` `workspaces` patterns match (the only +/// source for a binary `bun.lockb`). A pattern's `*` and `?` match any +/// name, dot-names included, and `!` exclusions are ignored: an extra +/// member can only keep an entry, never drop one. `None` when the member +/// set cannot be known (an unreadable or unparseable `package.json`, a +/// `workspaces` field of another shape, a `**` walk past its budget), so +/// the caller keeps every entry. +fn bun_workspace_member_node_modules_sync(root: &Path) -> Option> { + use crate::vendor::bun_lock_text::{is_plain_member_dir, workspace_member_dirs}; + + let mut members: Vec = Vec::new(); + match crate::utils::fs::read_regular_to_string_sync(&root.join("bun.lock")) { + Ok(text) => { + let lines: Vec = text.lines().map(str::to_string).collect(); + members.extend( + workspace_member_dirs(&lines) + .into_iter() + .filter(|dir| !dir.is_empty() && is_plain_member_dir(dir)) + .map(|dir| root.join(dir)), + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, + } + match crate::utils::fs::read_regular_to_string_sync(&root.join("package.json")) { + Ok(text) => { + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + let doc: serde_json::Value = serde_json::from_str(text).ok()?; + let patterns = match doc.get("workspaces") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(serde_json::Value::Array(list)) => list.clone(), + Some(serde_json::Value::Object(map)) => match map.get("packages") { + None => Vec::new(), + Some(packages) => packages.as_array()?.clone(), + }, + Some(_) => return None, + }; + for pattern in &patterns { + let pattern = pattern.as_str()?; + if pattern.starts_with('!') { + continue; + } + members.extend(expand_workspace_pattern_sync(root, pattern)?); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, + } + let mut seen = HashSet::new(); + Some( + members + .into_iter() + .map(|member| member.join("node_modules")) + .filter(|nm| seen.insert(nm.clone()) && is_dir_sync(nm)) + .collect(), + ) +} + +/// The dirs below `root` a `workspaces` glob matches (`/`-separated; `*` +/// and `?` within one component, `**` any number of them), never inside a +/// `node_modules` and never through a link for `**`. `None` when a `**` +/// walk passes [`WORKSPACE_GLOB_DIR_BUDGET`] dirs. +fn expand_workspace_pattern_sync(root: &Path, pattern: &str) -> Option> { + let segments: Vec<&str> = pattern + .trim() + .split(['/', '\\']) + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + let mut dirs = vec![root.to_path_buf()]; + let mut budget = WORKSPACE_GLOB_DIR_BUDGET; + for segment in segments { + let mut next = Vec::new(); + for dir in dirs { + if segment == "**" { + // Zero or more components: `dir` itself and every real dir + // below it. + let mut stack = vec![dir]; + while let Some(dir) = stack.pop() { + budget = budget.checked_sub(1)?; + for entry in list_dir_sync(&dir).entries { + if entry.name_str != "node_modules" + && entry.file_type.is_some_and(|ft| ft.is_dir()) + { + stack.push(dir.join(&entry.name)); + } + } + next.push(dir); + } + } else if segment.contains(['*', '?']) { + for entry in list_dir_sync(&dir).entries { + if entry.name_str != "node_modules" + && crate::hosted::governing_root::segment_glob_matches( + segment.as_bytes(), + entry.name_str.as_bytes(), + ) + && is_dir_sync(&dir.join(&entry.name)) + { + next.push(dir.join(&entry.name)); + } + } + } else { + next.push(dir.join(segment)); + } + } + dirs = next; + } + Some(dirs) +} + +/// How many dirs one `workspaces` `**` pattern may walk before the member +/// set is called unknown (see [`bun_workspace_member_node_modules_sync`]). +const WORKSPACE_GLOB_DIR_BUDGET: usize = 20_000; + +/// Paths among `paths` that are copies inside an orphaned `.bun` store +/// entry (see [`live_bun_store_entries_sync`]), judged by where each +/// canonicalizes; each store is walked once. +fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { + let mut stores: HashMap>> = HashMap::new(); + let mut orphans = HashSet::new(); + for path in paths { + let Ok(real) = std::fs::canonicalize(path) else { + continue; + }; + let Some((store, entry)) = bun_store_entry_of(&real) else { + continue; + }; + let live = stores.entry(store).or_insert_with_key(|store| { + let candidates = pnpm_shaped_store_candidates_sync(store, StoreLayout::Bun); + live_bun_store_entries_sync(store, &candidates).map(|(live, _)| live) + }); + if live.as_ref().is_some_and(|live| !live.contains(&entry)) { + orphans.insert(path.clone()); + } + } + orphans +} + +/// The `node_modules/.bun` store a real path lies in, and the name of the +/// entry holding it. +fn bun_store_entry_of(real: &Path) -> Option<(PathBuf, OsString)> { + let mut child: Option<&OsStr> = None; + for dir in real.ancestors() { + if dir + .file_name() + .and_then(OsStr::to_str) + .and_then(pnpm_shaped_store_layout) + == Some(StoreLayout::Bun) + && dir.parent().and_then(Path::file_name) == Some(OsStr::new("node_modules")) + { + return Some((dir.to_path_buf(), child?.to_os_string())); + } + child = dir.file_name(); + } + None +} + +/// Drop from each list every copy that sits in an orphaned Bun store entry +/// (#599): one no link from the install reaches, which nothing can load. +/// For a check of the live install (`vex`), never for an operation that +/// restores copies. Each store is walked once across all the lists. +pub async fn retain_live_store_copies<'a>(lists: impl IntoIterator>) { + let lists: Vec<&mut Vec> = lists.into_iter().collect(); + let paths: Vec = lists.iter().flat_map(|list| list.iter().cloned()).collect(); + if paths.is_empty() { + return; + } + let orphans = run_walk(move || orphaned_bun_store_copies_sync(&paths)).await; + if orphans.is_empty() { + return; + } + for list in lists { + list.retain(|path| !orphans.contains(path)); } - listings } /// The quick walk's guesses: a link named for a package only one `.bun` @@ -2120,7 +2320,7 @@ impl NpmCrawler { return Vec::new(); } let store = nm_path.join(&entry.name); - let entries = Self::list_pnpm_shaped_store_entries_sync(&store, layout, false) + let entries = Self::list_pnpm_shaped_store_entries_sync(&store, layout, false, false) .into_iter() .map(|e| StoreEntry { advertised: e.advertised, @@ -2592,7 +2792,8 @@ impl NpmCrawler { } for (store_path, layout) in pnpm_shaped_stores { - let entries = Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, true); + let entries = + Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, true, true); events.extend(Self::gather_store_entries(entries)); } for store_path in legacy_stores { @@ -2832,36 +3033,38 @@ impl NpmCrawler { /// the `is_dir` stat so an unreadable-but-present dir keeps its /// flat-entry classification. fn list_pnpm_store_entries_sync(store_path: &Path, read_listings: bool) -> Vec { - Self::list_pnpm_shaped_store_entries_sync(store_path, StoreLayout::Pnpm, read_listings) + Self::list_pnpm_shaped_store_entries_sync( + store_path, + StoreLayout::Pnpm, + read_listings, + false, + ) } /// [`Self::list_pnpm_store_entries_sync`] for any pnpm-shaped store /// (see [`PNPM_SHAPED_STORES`]), entry names decoded under `layout`. + /// + /// With `live_only` (the scan) a Bun store's orphaned entries are + /// skipped (see [`live_bun_store_entries_sync`]); the resolver and the + /// peer-variant finder keep them, since rollback must still restore a + /// patched orphan a later install can re-link. fn list_pnpm_shaped_store_entries_sync( store_path: &Path, layout: StoreLayout, read_listings: bool, + live_only: bool, ) -> Vec { let decode = |name: &str| layout.decode_pnpm_shaped(name); - let mut candidates: Vec = list_dir_sync(store_path) - .entries - .into_iter() - .filter(|entry| { - !(entry.name_str.starts_with('.') || entry.name_str == "node_modules") - && entry.file_type.is_some_and(|ft| { - ft.is_dir() - || (ft.is_symlink() - && layout == StoreLayout::Bun - && is_bun_global_store_link_sync(store_path, &entry.name_str)) - }) - }) - .collect(); - // pnpm prunes its store on install; Bun never does (#599). - let mut listings = if layout == StoreLayout::Bun { - retain_live_bun_store_entries_sync(store_path, &mut candidates) - } else { - HashMap::new() - }; + let mut candidates = pnpm_shaped_store_candidates_sync(store_path, layout); + // pnpm prunes its store on install; Bun never does (#599), so the + // scan, a judgement of the live install, skips its orphans. + let mut listings = HashMap::new(); + if layout == StoreLayout::Bun && live_only { + if let Some((live, walked)) = live_bun_store_entries_sync(store_path, &candidates) { + candidates.retain(|e| live.contains(&e.name)); + listings = walked; + } + } let candidates: Vec<(ListedEntry, Option)> = candidates .into_iter() .map(|entry| { @@ -2933,7 +3136,7 @@ impl NpmCrawler { ) -> Vec { let store_path = store_path.to_path_buf(); run_walk(move || { - Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, false) + Self::list_pnpm_shaped_store_entries_sync(&store_path, layout, false, false) .into_iter() .map(|entry| StoreEntry { advertised: entry.advertised, @@ -5908,6 +6111,11 @@ mod tests { // Every entry is linked from somewhere, as Bun writes it (an // unlinked one is an orphan, #599); the peer twin from a workspace // member's importer. + std::fs::write( + root.join("package.json"), + r#"{"workspaces":["packages/*"]}"#, + ) + .unwrap(); let member_nm = root.join("packages/a/node_modules"); std::fs::create_dir_all(&member_nm).unwrap(); link_dir(&number_twin, &member_nm.join("is-number")); @@ -5999,26 +6207,43 @@ mod tests { /// `@` entries stay on disk with nothing linking to /// them, while the importers, the `.bun/node_modules` hoist links and /// the dependents' entries all point at the new entries (the layout - /// real Bun 1.3.14 / 1.4.2 writes). An orphan is no installed copy: - /// scan, the resolver and the peer-variant finder must all skip it, or - /// vex judges the install by stale bytes nothing can load. A live entry - /// linked only from a workspace member (an unhoisted second version) - /// stays found. + /// real Bun 1.3.14 / 1.4.2 writes). An orphan is no installed copy, so + /// the scan and the live-copy filter `vex` uses skip it. The resolver + /// and the peer-variant finder keep it: rollback must restore a patched + /// orphan, which a later install that resolves back re-links as is. + /// + /// A live entry linked only from a workspace member (an unhoisted + /// second version) stays live wherever the member sits: under a dir + /// the workspace walk skips (`vendor/`), under a hidden dir listed only + /// by `bun.lock`'s `workspaces`, or under `packages/`. #[tokio::test] - async fn test_bun_isolated_store_orphaned_entries_are_not_copies() { + async fn test_bun_isolated_store_orphaned_entries_are_not_live_copies() { let tmp = tempfile::tempdir().unwrap(); let root: PathBuf = tmp.path().components().collect(); let nm = root.join("node_modules"); let store = nm.join(".bun"); let hoist = store.join("node_modules"); + std::fs::write( + root.join("package.json"), + r#"{"name":"app","workspaces":["packages/*","vendor/*"]}"#, + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"app\",\n },\n \".internal/c\": {\n \"name\": \"c\",\n \ + },\n },\n \"packages\": {\n }\n}\n", + ) + .unwrap(); let member_a = root.join("packages/a/node_modules"); - let member_b = root.join("packages/b/node_modules"); - for dir in [&hoist.join("@s"), &member_a, &member_b] { + let member_b = root.join("vendor/b/node_modules"); + let member_c = root.join(".internal/c/node_modules"); + for dir in [&hoist.join("@s"), &member_a, &member_b, &member_c] { std::fs::create_dir_all(dir).unwrap(); } - // Live: the hosted rewires, and two left-pad versions (1.3.0 - // hoisted, 1.2.0 linked only from member b). + // Live: the hosted rewires, and three left-pad versions (1.3.0 + // hoisted, 1.2.0 and 1.0.0 each linked only from one member). let odd = store.join("is-odd@http+++127.0.0.1+is-odd.tgz/node_modules"); write_pkg(&odd.join("is-odd"), "is-odd", "3.0.1"); let number = store.join("is-number@http+++127.0.0.1+is-number.tgz/node_modules/is-number"); @@ -6026,15 +6251,18 @@ mod tests { link_dir(&number, &odd.join("is-number")); let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); write_pkg(&pad, "left-pad", "1.3.0"); - let old_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); - write_pkg(&old_pad, "left-pad", "1.2.0"); + let vendor_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&vendor_pad, "left-pad", "1.2.0"); + let hidden_pad = store.join("left-pad@1.0.0/node_modules/left-pad"); + write_pkg(&hidden_pad, "left-pad", "1.0.0"); link_dir(&odd.join("is-odd"), &nm.join("is-odd")); link_dir(&odd.join("is-odd"), &hoist.join("is-odd")); link_dir(&number, &hoist.join("is-number")); link_dir(&pad, &hoist.join("left-pad")); link_dir(&number, &member_a.join("is-number")); link_dir(&pad, &member_a.join("left-pad")); - link_dir(&old_pad, &member_b.join("left-pad")); + link_dir(&vendor_pad, &member_b.join("left-pad")); + link_dir(&hidden_pad, &member_c.join("left-pad")); let frame = store.join("@s+frame@http+++127.0.0.1+frame.tgz/node_modules/@s/frame"); write_pkg(&frame, "@s/frame", "7.0.0"); link_dir(&frame, &hoist.join("@s/frame")); @@ -6048,11 +6276,8 @@ mod tests { link_dir(&stale_number, &stale_odd.join("is-number")); let stale_frame = store.join("@s+frame@7.0.0/node_modules/@s/frame"); write_pkg(&stale_frame, "@s/frame", "7.0.0"); - write_pkg( - &store.join("left-pad@1.1.0/node_modules/left-pad"), - "left-pad", - "1.1.0", - ); + let churned = store.join("left-pad@1.1.0/node_modules/left-pad"); + write_pkg(&churned, "left-pad", "1.1.0"); let scanned = scan_paths(&root).await; for (purl, path) in &scanned { @@ -6064,46 +6289,100 @@ mod tests { "an orphaned entry was scanned: {scanned:?}" ); } - assert!( - scanned.contains(&("pkg:npm/left-pad@1.2.0".to_string(), old_pad.clone())), - "{scanned:?}" - ); + for (purl, path) in [ + ("pkg:npm/left-pad@1.2.0", &vendor_pad), + ("pkg:npm/left-pad@1.0.0", &hidden_pad), + ] { + assert!( + scanned.contains(&(purl.to_string(), path.clone())), + "{purl}: {scanned:?}" + ); + } - let purls: Vec = [ - "pkg:npm/@s/frame@7.0.0", - "pkg:npm/is-number@6.0.0", - "pkg:npm/is-odd@3.0.1", - "pkg:npm/left-pad@1.1.0", - "pkg:npm/left-pad@1.2.0", - ] - .map(String::from) - .to_vec(); + // Restoring operations still reach the orphans. + let purls: Vec = ["pkg:npm/is-number@6.0.0", "pkg:npm/left-pad@1.1.0"] + .map(String::from) + .to_vec(); let found = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); let paths = |purl: &str| -> Vec { - let mut got: Vec = found + found .get(purl) .map(|copies| copies.iter().map(|p| p.path.clone()).collect()) - .unwrap_or_default(); - got.sort(); - got + .unwrap_or_default() }; - assert_eq!(paths("pkg:npm/@s/frame@7.0.0"), vec![frame.clone()]); - assert_eq!(paths("pkg:npm/is-number@6.0.0"), vec![number.clone()]); - assert_eq!(paths("pkg:npm/is-odd@3.0.1"), vec![nm.join("is-odd")]); - assert_eq!(paths("pkg:npm/left-pad@1.1.0"), Vec::::new()); - assert_eq!(paths("pkg:npm/left-pad@1.2.0"), vec![old_pad.clone()]); - + assert!( + paths("pkg:npm/is-number@6.0.0").contains(&stale_number), + "{found:?}" + ); + assert_eq!(paths("pkg:npm/left-pad@1.1.0"), vec![churned.clone()]); assert_eq!( find_store_peer_variant_copies(&number).await, - Vec::::new() + vec![stale_number.clone()] ); + + // The live-copy filter drops exactly the orphans. + let mut copies = vec![ + number.clone(), + stale_number.clone(), + churned.clone(), + vendor_pad.clone(), + hidden_pad.clone(), + stale_frame.clone(), + frame.clone(), + nm.join("is-odd"), + stale_odd.join("is-odd"), + ]; + let mut other = vec![pad.clone(), churned.clone()]; + retain_live_store_copies([&mut copies, &mut other]).await; + assert_eq!( + copies, + vec![ + number.clone(), + vendor_pad.clone(), + hidden_pad.clone(), + frame.clone(), + nm.join("is-odd"), + ] + ); + assert_eq!(other, vec![pad.clone()]); + } + + /// #599: when the workspace members cannot be known (a root + /// `package.json` that does not parse), an entry no other seed reaches + /// may be a member's, so nothing is dropped. + #[tokio::test] + async fn test_bun_isolated_store_unknown_members_keep_every_entry() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::write(root.join("package.json"), "{ not json").unwrap(); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let member_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&member_pad, "left-pad", "1.2.0"); + link_dir(&pad, &store.join("node_modules/left-pad")); + let member_nm = root.join("weird/place/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&member_pad, &member_nm.join("left-pad")); + + let scanned = scan_paths(&root).await; + assert!( + scanned.contains(&("pkg:npm/left-pad@1.2.0".to_string(), member_pad.clone())), + "{scanned:?}" + ); + let mut copies = vec![pad.clone(), member_pad.clone()]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad, member_pad]); } /// #599: the orphan filter's quick walk takes a link named for a /// package only one entry holds to be that entry. An alias link /// (`node_modules/lp` -> `left-pad@1.3.0`) beside an unrelated `lp@…` /// entry defeats the guess, so the entry it really reaches must still - /// be found (by the exact re-walk) and the never-linked `lp@` dropped. + /// count as live (by the exact re-walk) and the never-linked `lp@` + /// not. #[tokio::test] async fn test_bun_isolated_store_alias_link_is_resolved_exactly() { let tmp = tempfile::tempdir().unwrap(); @@ -6112,23 +6391,60 @@ mod tests { let store = nm.join(".bun"); let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); write_pkg(&pad, "left-pad", "1.3.0"); - write_pkg(&store.join("lp@2.0.0/node_modules/lp"), "lp", "2.0.0"); + let lp = store.join("lp@2.0.0/node_modules/lp"); + write_pkg(&lp, "lp", "2.0.0"); std::fs::create_dir_all(store.join("node_modules")).unwrap(); link_dir(&pad, &nm.join("lp")); - let purls = ["pkg:npm/left-pad@1.3.0", "pkg:npm/lp@2.0.0"].map(String::from); - let found = NpmCrawler::new().find_by_purls(&nm, &purls).await.unwrap(); - let paths: Vec<_> = found["pkg:npm/left-pad@1.3.0"] - .iter() - .map(|p| p.path.clone()) - .collect(); - assert_eq!(paths, vec![pad.clone()]); - assert!(!found.contains_key("pkg:npm/lp@2.0.0"), "{found:?}"); let scanned = scan_paths(&root).await; assert!( scanned.iter().all(|(purl, _)| purl != "pkg:npm/lp@2.0.0"), "{scanned:?}" ); + assert!( + scanned + .iter() + .any(|(purl, _)| purl == "pkg:npm/left-pad@1.3.0"), + "{scanned:?}" + ); + let mut copies = vec![pad.clone(), lp]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad]); + } + + #[test] + fn test_expand_workspace_pattern() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in [ + "packages/a", + "packages/b", + ".github/actions/x", + "apps/web/sub", + "apps/node_modules/skip", + ] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + std::fs::write(root.join("packages/file"), "").unwrap(); + let expand = |pattern: &str| { + let mut got: Vec = expand_workspace_pattern_sync(root, pattern) + .unwrap() + .into_iter() + .map(|p| p.strip_prefix(root).unwrap().to_path_buf()) + .collect(); + got.sort(); + got + }; + assert_eq!( + expand("packages/*"), + ["packages/a", "packages/b"].map(PathBuf::from) + ); + assert_eq!(expand("./packages/a"), [PathBuf::from("packages/a")]); + assert_eq!(expand(".github/*/*"), [PathBuf::from(".github/actions/x")]); + assert_eq!( + expand("apps/**"), + ["apps", "apps/web", "apps/web/sub"].map(PathBuf::from) + ); } #[test] diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 988c25a14..38e87de0e 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -395,7 +395,7 @@ fn path_glob_matches(pattern: &[&str], path: &[String]) -> bool { } } -fn segment_glob_matches(pattern: &[u8], name: &[u8]) -> bool { +pub(crate) fn segment_glob_matches(pattern: &[u8], name: &[u8]) -> bool { match pattern.split_first() { None => name.is_empty(), Some((b'*', rest)) => { From 766164504a88c273bb65fa519b11a252fe368285 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:34:02 -0400 Subject: [PATCH 27/55] Reconcile #599 with #635's Bun global store links With Bun's global store (#635) every .bun entry is a link into the shared /links, and those shared entries link their dependencies to one another inside the cache, never back into this project's .bun. The #599 orphan walk only counts link targets that land in the project's store, so with relative importer links (what Bun writes) it reached the direct dependencies alone and the scan dropped every transitive package as an orphan; vex then saw no copy for them. The cherry-pick moved #635's global-store link filter into pnpm_shaped_store_candidates_sync (shared by the #599 callers); now live_bun_store_entries_sync gives no answer (keep every entry) when any candidate is such a link, since reachability cannot be judged through the shared cache. A new unit test builds a global store with relative importer links, cache-internal dependency links and a duplicate entry name, and checks the walk keeps every entry and the scan still reports the transitive package. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/crawlers/npm_crawler.rs | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index e2ea7e3f4..7619888ee 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -633,7 +633,14 @@ fn live_bun_store_entries_sync( ) -> Option<(HashSet, HashMap)> { let real_store = std::fs::canonicalize(store_path).ok()?; let importer = store_path.parent()?; - if candidates.is_empty() { + // A global store entry (#635) is a link into the shared + // `/links`, whose entries link one another there, never back + // into this `.bun`, so the walk cannot see what reaches them. + if candidates.is_empty() + || candidates + .iter() + .any(|e| e.file_type.is_some_and(|ft| ft.is_symlink())) + { return None; } let root = match importer.parent() { @@ -6202,6 +6209,58 @@ mod tests { .await; } + /// #599 with #635: a global store's shared `/links` entries link + /// their dependencies to one another, never back into a project's + /// `.bun`, so the orphan walk cannot tell which `.bun` links are live. + /// The scan keeps every global store entry rather than dropping the + /// transitive packages it only reaches through the cache. + #[tokio::test] + async fn test_bun_global_store_entries_are_kept_by_the_orphan_walk() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "proj", "dependencies": { "is-odd": "3.0.1" } }"#, + ) + .unwrap(); + let links = tmp.join("bun-cache").join("links"); + let link_entry = |entry: &str, name: &str, version: &str| { + let shared = links.join(format!("{entry}-6a490709ba3c5c8f")); + write_pkg(&shared.join("node_modules").join(name), name, version); + link_dir(&shared, &store.join(entry)); + shared.join("node_modules") + }; + let odd = link_entry("is-odd@3.0.1", "is-odd", "3.0.1"); + let number = link_entry("is-number@6.0.0", "is-number", "6.0.0"); + // A second entry for the name, so links are read, not guessed. + link_entry("is-number@6.0.0+3c4e1d2a", "is-number", "6.0.0"); + link_dir(&number.join("is-number"), &odd.join("is-number")); + // Bun writes the importer's links relative, into `.bun`. + #[cfg(unix)] + std::os::unix::fs::symlink(".bun/is-odd@3.0.1/node_modules/is-odd", nm.join("is-odd")) + .unwrap(); + #[cfg(windows)] + link_dir( + &store.join("is-odd@3.0.1/node_modules/is-odd"), + &nm.join("is-odd"), + ); + + let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); + assert_eq!(candidates.len(), 3); + assert!(live_bun_store_entries_sync(&store, &candidates).is_none()); + let scanned = scan_paths(&root).await; + let purls: Vec<&str> = scanned.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!( + purls, + ["pkg:npm/is-number@6.0.0", "pkg:npm/is-odd@3.0.1"], + "{scanned:?}" + ); + } + /// #599: Bun never prunes `.bun`. After an in-place `bun install` that /// rewires packages to hosted tarballs (or bumps a version), the old /// `@` entries stay on disk with nothing linking to From eb0e65361cb100ab515d220643efa2f095d70b56 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:46:23 -0400 Subject: [PATCH 28/55] Warn when Bun's global dir can't be determined (#443) When `bun pm ls -g` can't answer, global mode falls back to Bun's own openGlobalDir resolution from the environment. That fallback treated an empty or relative BUN_INSTALL_GLOBAL_DIR / BUN_INSTALL / XDG_CACHE_HOME as unset and moved on to the next variable, but Bun uses a set variable as it is (a relative BUN_INSTALL_GLOBAL_DIR resolves against wherever `bun add -g` ran), so the fallback named a dir Bun never used, the p.is_dir() check dropped it, and the scan reported a clean result for Bun's globals without a word. The first set variable now decides, as in Bun; one that is empty or relative (or having no home dir) makes the dir undeterminable, and when Bun is in use (bun on PATH, or a BUN_INSTALL* variable set) a global run prints one warning naming why and pointing at --global-prefix. It goes through core's notice_once Warning level, so --json keeps it on stderr and only --silent mutes it. A machine without Bun stays silent, and a fallback dir that merely does not exist stays silent too: it is where Bun would put its globals, so there are none. Bunfig is deliberately not consulted. Measured with real Bun 1.0.36, 1.1.45, 1.2.23, 1.3.14 and 1.4.2: `bun add -g` installs into ~/.bun/install/global even when ~/.bunfig.toml, $XDG_CONFIG_HOME/.bunfig.toml or a local bunfig.toml sets install.globalDir (globalBinDir is honored from 1.1, but moves only the bins), and `bun pm ls -g` reports the same dir. A test pins that so the fallback is not "fixed" to follow bunfig. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/get/global_packages_e2e.rs | 51 +++++++++ .../src/crawlers/npm_crawler.rs | 108 ++++++++++++++---- .../tests/crawler_npm_e2e.rs | 99 +++++++++++++++- .../tests/global_probe_spawn_e2e.rs | 41 +++++++ 4 files changed, 276 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs index 25bdadd21..6f9dbef9c 100644 --- a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs @@ -438,6 +438,57 @@ fn rollback_global_with_empty_path_handles_missing_npm() { assert_rollback_noop(&stdout); } +/// #443: when Bun is in use but its global dir can't be told (here +/// `BUN_INSTALL_GLOBAL_DIR` is relative, so it names a dir relative to +/// wherever `bun add -g` ran), a global run says so on stderr instead of +/// silently leaving Bun's globals out. `--json` keeps the warning (stdout +/// stays the envelope); `--silent` mutes it. +#[test] +fn apply_global_warns_when_bun_global_dir_is_undeterminable() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + write_manifest(tmp.path(), "pkg:npm/__bun_undetermined__@1.0.0"); + + let run = |extra: &[&str]| { + let mut args = vec!["apply", "--global", "--offline", "--json"]; + args.extend_from_slice(extra); + let out = cli(tmp.path()) + .args(&args) + .env("PATH", "/nonexistent-dir-for-test") + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("BUN_INSTALL_GLOBAL_DIR", "relative/bun-global") + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + out.status.code(), + Some(1), + "stdout={stdout}\nstderr={stderr}" + ); + assert_apply_not_installed(&stdout, "pkg:npm/__bun_undetermined__@1.0.0"); + stderr + }; + + let stderr = run(&[]); + assert!( + stderr.contains( + "Warning: could not determine Bun's global package directory \ + (BUN_INSTALL_GLOBAL_DIR is \"relative/bun-global\", not an absolute path)" + ), + "stderr={stderr}" + ); + assert_eq!( + stderr.matches("could not determine Bun's global").count(), + 1, + "said once per run; stderr={stderr}" + ); + let stderr = run(&["--silent"]); + assert!(!stderr.contains("Bun's global"), "stderr={stderr}"); +} + // --------------------------------------------------------------------------- // Stub-script PATH — controlled npm output exercises success + empty-output // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index ccd502baa..c34f22c32 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -1705,7 +1705,8 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option { /// Get the bun global `node_modules` path: the global dir `bun pm ls -g` /// reports, else (no `bun` to ask, or an answer we can't read) the one /// Bun's own resolution picks from the environment, see -/// [`bun_global_dir_from_env`]. +/// [`bun_global_dir_from_env`]. `None` when neither names a dir; see +/// [`resolve_bun_global_prefix`] for why. /// /// The packages' dir is never derived from `bun pm bin -g` (#443): Bun /// moves its bin dir (`BUN_INSTALL_BIN`, bunfig `globalBinDir`) and its @@ -1713,10 +1714,43 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option { /// named a dir that didn't exist and every Bun global vanished from a /// global scan. pub fn get_bun_global_prefix() -> Option { - get_bun_global_prefix_with(&GlobalProbeRunner).or_else(|| { - bun_global_dir_from_env(&|var| std::env::var_os(var)) - .map(|dir| dir.join("node_modules").to_string_lossy().to_string()) - }) + resolve_bun_global_prefix().ok().flatten() +} + +/// [`get_bun_global_prefix`], telling "Bun is not in use" (`Ok(None)`) +/// apart from "Bun is in use but its global dir can't be told" (`Err` with +/// the reason, #443), so a global scan can say so instead of reporting a +/// clean, empty result. +pub fn resolve_bun_global_prefix() -> Result, String> { + resolve_bun_global_prefix_with( + &GlobalProbeRunner, + &|var| std::env::var_os(var), + crate::utils::process::resolve_tool("bun").is_some(), + ) +} + +/// [`resolve_bun_global_prefix`] over an injected runner and environment. +/// Bun counts as in use when `bun_on_path`, or when `BUN_INSTALL_GLOBAL_DIR` +/// or `BUN_INSTALL` is set; otherwise an undeterminable dir is `Ok(None)`. +pub fn resolve_bun_global_prefix_with( + runner: &dyn CommandRunner, + var: &impl Fn(&str) -> Option, + bun_on_path: bool, +) -> Result, String> { + if let Some(prefix) = get_bun_global_prefix_with(runner) { + return Ok(Some(prefix)); + } + match bun_global_dir_from_env(var) { + Ok(dir) => Ok(Some(dir.join("node_modules").to_string_lossy().to_string())), + Err(why) + if bun_on_path + || var("BUN_INSTALL_GLOBAL_DIR").is_some() + || var("BUN_INSTALL").is_some() => + { + Err(why) + } + Err(_) => Ok(None), + } } /// Version of `get_bun_global_prefix` that accepts an injected @@ -1756,23 +1790,51 @@ pub fn parse_bun_ls_global_output(stdout: &str) -> Option { /// `$BUN_INSTALL/install/global`, else `.bun/install/global` under /// `XDG_CACHE_HOME` or the home dir (`USERPROFILE` on Windows). /// -/// A set-but-empty or relative variable counts as unset, the same rule as -/// `composer_home_candidates`: it would otherwise name a dir relative to -/// the scanned project. -pub fn bun_global_dir_from_env(var: &impl Fn(&str) -> Option) -> Option { - let absolute = |name: &str| { - var(name) - .map(PathBuf::from) - .filter(|path| path.is_absolute()) +/// Bunfig is not consulted: measured on Bun 1.0.36 - 1.4.2, `bun add -g` +/// ignores `install.globalDir` in the global (`~/.bunfig.toml`, +/// `$XDG_CONFIG_HOME/.bunfig.toml`) and the local bunfig alike (it does +/// honor `globalBinDir`, which moves only the bins), and so does +/// `bun pm ls -g`. +/// +/// Bun uses a set variable as it is, so the first one set decides. One that +/// is empty or relative names a dir relative to wherever `bun add -g` ran, +/// which can't be known here: that is an `Err` naming the variable, as is +/// having no home dir at all. +pub fn bun_global_dir_from_env(var: &impl Fn(&str) -> Option) -> Result { + let lookup = |name: &str| { + let value = var(name)?; + let path = PathBuf::from(&value); + Some(if path.is_absolute() { + Ok(path) + } else { + Err(format!("{name} is {value:?}, not an absolute path")) + }) }; let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - absolute("BUN_INSTALL_GLOBAL_DIR") - .or_else(|| absolute("BUN_INSTALL").map(|dir| dir.join("install").join("global"))) - .or_else(|| { - absolute("XDG_CACHE_HOME") - .or_else(|| absolute(home_var)) - .map(|dir| dir.join(".bun").join("install").join("global")) - }) + if let Some(dir) = lookup("BUN_INSTALL_GLOBAL_DIR") { + return dir; + } + if let Some(dir) = lookup("BUN_INSTALL") { + return dir.map(|dir| dir.join("install").join("global")); + } + lookup("XDG_CACHE_HOME") + .or_else(|| lookup(home_var)) + .unwrap_or_else(|| Err(format!("neither XDG_CACHE_HOME nor {home_var} is set"))) + .map(|dir| dir.join(".bun").join("install").join("global")) +} + +/// Say once (muted only by `--silent`) that a global scan left Bun's global +/// packages out because their dir can't be told (#443), instead of +/// reporting a clean, empty result for them. +fn warn_bun_global_dir_undetermined(why: &str) { + static SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + crate::utils::notice::notice_once(crate::utils::notice::Notice::Warning, &SHOWN, || { + format!( + "Warning: could not determine Bun's global package directory ({why}), so Bun's \ + global packages were not scanned. Pass --global-prefix /node_modules to scan \ + them." + ) + }); } // --------------------------------------------------------------------------- @@ -2495,8 +2557,10 @@ impl NpmCrawler { if let Some(yarn_path) = get_yarn_global_prefix() { add(PathBuf::from(yarn_path)); } - if let Some(bun_path) = get_bun_global_prefix() { - add(PathBuf::from(bun_path)); + match resolve_bun_global_prefix() { + Ok(Some(bun_path)) => add(PathBuf::from(bun_path)), + Ok(None) => {} + Err(why) => warn_bun_global_dir_undetermined(&why), } // macOS-specific fallback paths diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index dd1e13d77..d2ab7e18f 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -10,7 +10,7 @@ use socket_patch_core::crawlers::npm_crawler::{ get_npm_global_prefix, get_npm_global_prefix_with, get_pnpm_global_prefix, get_pnpm_global_prefix_with, get_yarn_global_prefix, get_yarn_global_prefix_with, parse_bun_ls_global_output, parse_npm_root_output, parse_package_name, parse_pnpm_root_output, - parse_yarn_dir_output, read_package_json, + parse_yarn_dir_output, read_package_json, resolve_bun_global_prefix_with, }; use socket_patch_core::crawlers::types::CrawlerOptions; use socket_patch_core::crawlers::NpmCrawler; @@ -299,6 +299,7 @@ fn get_pnpm_global_prefix_returns_none_when_pnpm_not_on_path() { fn get_bun_global_prefix_falls_back_to_env_when_bun_not_on_path() { with_empty_path(|| { let want = bun_global_dir_from_env(&|var| std::env::var_os(var)) + .ok() .map(|dir| dir.join("node_modules").to_string_lossy().to_string()); assert_eq!(get_bun_global_prefix(), want); }); @@ -377,6 +378,102 @@ fn get_bun_global_prefix_with_mock_runner_success() { ); } +// ── bun global dir: env resolution and the undetermined case (#443) ── + +/// An environment holding exactly `vars`. +fn env_of<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + vars.iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| std::ffi::OsString::from(v)) + } +} + +/// Bun uses the first of `BUN_INSTALL_GLOBAL_DIR`, `BUN_INSTALL`, +/// `XDG_CACHE_HOME`, home that is SET, as it is: one that is empty or +/// relative names a dir relative to wherever `bun add -g` ran, so the dir +/// can't be told, and a later variable is not a stand-in for it. +#[cfg(unix)] +#[test] +#[serial_test::parallel] +fn bun_global_dir_from_env_follows_the_first_set_variable() { + let ok = |vars: &[(&str, &str)], want: &str| { + assert_eq!( + bun_global_dir_from_env(&env_of(vars)), + Ok(std::path::PathBuf::from(want)), + "{vars:?}" + ); + }; + ok( + &[ + ("BUN_INSTALL_GLOBAL_DIR", "/g"), + ("BUN_INSTALL", "/b"), + ("HOME", "/h"), + ], + "/g", + ); + ok( + &[("BUN_INSTALL", "/b"), ("XDG_CACHE_HOME", "/x")], + "/b/install/global", + ); + ok( + &[("XDG_CACHE_HOME", "/x"), ("HOME", "/h")], + "/x/.bun/install/global", + ); + ok(&[("HOME", "/h")], "/h/.bun/install/global"); + + for (vars, named) in [ + ( + &[("BUN_INSTALL_GLOBAL_DIR", "rel/g"), ("HOME", "/h")][..], + "BUN_INSTALL_GLOBAL_DIR", + ), + ( + &[("BUN_INSTALL_GLOBAL_DIR", ""), ("BUN_INSTALL", "/b")][..], + "BUN_INSTALL_GLOBAL_DIR", + ), + (&[("BUN_INSTALL", "rel"), ("HOME", "/h")][..], "BUN_INSTALL"), + ( + &[("XDG_CACHE_HOME", "rel"), ("HOME", "/h")][..], + "XDG_CACHE_HOME", + ), + (&[][..], "HOME"), + ] { + let why = bun_global_dir_from_env(&env_of(vars)).unwrap_err(); + assert!(why.contains(named), "{vars:?}: {why}"); + } +} + +/// #443: when `bun pm ls -g` can't answer and the environment can't name +/// the dir either, a global scan learns why (to say so) as long as Bun is +/// in use: `bun` on PATH, or a `BUN_INSTALL*` variable set. Without Bun +/// there is nothing to report. +#[test] +#[serial_test::parallel] +fn resolve_bun_global_prefix_reports_an_undeterminable_dir() { + let silent_bun = common::MockCommandRunner::new(); + let relative = [("BUN_INSTALL_GLOBAL_DIR", "rel/g"), ("HOME", "/h")]; + let why = resolve_bun_global_prefix_with(&silent_bun, &env_of(&relative), false).unwrap_err(); + assert!(why.contains("BUN_INSTALL_GLOBAL_DIR"), "{why}"); + assert!(resolve_bun_global_prefix_with(&silent_bun, &env_of(&[]), true).is_err()); + assert_eq!( + resolve_bun_global_prefix_with(&silent_bun, &env_of(&[]), false), + Ok(None), + "no Bun in use: nothing to report" + ); + + // Bun's own answer wins over an environment we can't read. + let answering_bun = common::MockCommandRunner::new().with_response( + "bun", + &["pm", "ls", "-g"], + Some("/g node_modules (1 installed)\n"), + ); + let want = std::path::PathBuf::from("/g").join("node_modules"); + assert_eq!( + resolve_bun_global_prefix_with(&answering_bun, &env_of(&relative), true), + Ok(Some(want.to_string_lossy().to_string())) + ); +} + // ── parse_npm_root_output ────────────────────────────────────── #[test] diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index 4ac5e486b..4cd9f219d 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -517,3 +517,44 @@ async fn bun_global_dir_falls_back_to_bun_env_resolution() { drop(case_env); } } + +/// #443: Bun's global dir does not follow bunfig. Measured on Bun 1.0.36 - +/// 1.4.2, `bun add -g` installs into `~/.bun/install/global` even when the +/// global bunfig (`~/.bunfig.toml`, `$XDG_CONFIG_HOME/.bunfig.toml`) sets +/// `install.globalDir` (its `globalBinDir` moves only the bins), so the +/// environment fallback must not follow it either. +#[test] +#[serial] +fn bun_global_dir_fallback_ignores_bunfig_global_dir() { + let l = layout(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + for name in ["BUN_INSTALL_GLOBAL_DIR", "BUN_INSTALL", "XDG_CACHE_HOME"] { + env.set(name, None); + } + let config = l.home.join("xdg-config"); + env.set("XDG_CONFIG_HOME", Some(config.as_os_str())); + let bunfig = |dir: &Path, global: &str| { + std::fs::create_dir_all(dir).unwrap(); + let toml = format!( + "[install]\nglobalDir = {:?}\nglobalBinDir = {:?}\n", + l.home.join(global).to_string_lossy(), + l.home.join("bunfig-bin").to_string_lossy(), + ); + std::fs::write(dir.join(".bunfig.toml"), toml).unwrap(); + std::fs::create_dir_all(l.home.join(global).join("node_modules")).unwrap(); + }; + bunfig(&l.home, "home-bunfig-global"); + bunfig(&config, "xdg-bunfig-global"); + + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some( + l.home + .join(".bun") + .join("install") + .join("global") + .join("node_modules") + ) + ); +} From 575ff30a566d5af2c0a6bd6b081147c80dd18210 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:50:46 -0400 Subject: [PATCH 29/55] Withhold Bun refs beside a copy of unknown version (#497) The #497 fix reads a URL / file: tarball dependency's version from its -.tgz leaf. Bun records no version for any other own-source copy: a tarball named pkg.tgz, a codeload.github.com URL, a git or github: dependency, a file: folder or link:. Real Bun 1.1.45 / 1.2.23 / 1.4.2 locks carry only the spec and an empty meta object for them, and the bun.lockb record is a tarball / git / folder resolution with no npm version. Such a copy was silently ignored, so when a folder dependency's nested registry copy of the same package was rewired, lockfile VEX (hosted --no-verify, vendored default, and hosted in a lockfile-only checkout) attested not_affected while the root copy the app loads stayed unpatched. Treat such a copy conservatively: it may be the wired version, so it withdraws every ref of the same package in that lock with a patched_ref_unattributable warning. The binary codec now exposes whether a record installs from its own source (not npm, root or workspace); the text reader treats every spec other than an exact version or workspace: as one. Without a version it is no name@version evidence, so the cross-lock contest is unchanged. Fixtures are real Bun captures (file / url / git shapes, text v0/v1/v2 and 1.1.45 bun.lockb). Unit tests cover synthetic and real locks; the hermetic CLI suite covers hosted `vex --no-verify` and vendored `vex` on every fixture, with a control that attests once the root copy is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitattributes | 4 + crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_vex_lockfile/bun.rs | 92 ++++- .../socket-patch-core/src/vendor/bun_lockb.rs | 5 + .../socket-patch-core/src/vex/discover/bun.rs | 323 +++++++++++++++--- .../bun-unversioned-copy/lockb/file/bun.lockb | Bin 0 -> 1857 bytes .../lockb/file/package.json | 1 + .../lockb/file/provenance.json | 8 + .../bun-unversioned-copy/lockb/git/bun.lockb | Bin 0 -> 1904 bytes .../lockb/git/package.json | 1 + .../lockb/git/provenance.json | 8 + .../bun-unversioned-copy/lockb/url/bun.lockb | Bin 0 -> 1905 bytes .../lockb/url/package.json | 1 + .../lockb/url/provenance.json | 8 + .../bun-unversioned-copy/text-0/file/bun.lock | 19 ++ .../text-0/file/package.json | 1 + .../text-0/file/provenance.json | 8 + .../bun-unversioned-copy/text-1/file/bun.lock | 19 ++ .../text-1/file/package.json | 1 + .../text-1/file/provenance.json | 8 + .../bun-unversioned-copy/text-2/file/bun.lock | 20 ++ .../text-2/file/package.json | 1 + .../text-2/file/provenance.json | 8 + .../bun-unversioned-copy/text-2/git/bun.lock | 20 ++ .../text-2/git/package.json | 1 + .../text-2/git/provenance.json | 8 + .../bun-unversioned-copy/text-2/url/bun.lock | 20 ++ .../text-2/url/package.json | 1 + .../text-2/url/provenance.json | 8 + .../bun-unversioned-copy.json | 106 ++++++ 30 files changed, 653 insertions(+), 49 deletions(-) create mode 100755 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json create mode 100755 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json create mode 100755 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json create mode 100644 crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json diff --git a/.gitattributes b/.gitattributes index 2fc3fa8cc..585890399 100644 --- a/.gitattributes +++ b/.gitattributes @@ -51,3 +51,7 @@ scripts/sbt-compat-matrix.sh text eol=lf # Real `bun install --save-text-lockfile` output: the migrated-lock revert # tests compare restored bun.lock bytes against it exactly (#784). crates/socket-patch-core/tests/fixtures/bun-lockb/1.2.23-migrated/*.lock -text + +# Real Bun locks with a version-less own-source copy (#497): the VEX tests +# rewire the nested registry entry of the captured bytes in place. +crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/** -text diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4e9f9edad..da28040d4 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1334,7 +1334,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | | `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | | `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | -| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. | +| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the takeover is refused before the revert (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs index ff9291795..7153bee5d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -202,7 +202,12 @@ fn text_tuple(wiring: &Wiring) -> String { /// `bun.lockb` for `release`, rewired by the production binary rewriter. fn binary_lock(release: &str, wiring: &Wiring) -> Vec { - let fixture = binary_fixture(release); + rewire_binary(binary_fixture(release), wiring) +} + +/// `fixture` with its registry minimist record rewired per `wiring` by the +/// production binary rewriter. +fn rewire_binary(fixture: Vec, wiring: &Wiring) -> Vec { let (artifact_url, sri, uuid) = match wiring { Wiring::Registry => return fixture, Wiring::Hosted { url, sri } => ( @@ -725,6 +730,91 @@ fn b_api_without_the_record_is_record_unavailable() { } } +/// REGRESSION (#497 follow-up): real Bun locks (1.4.2 / 1.2.23 / 1.1.45 +/// text, 1.1.45 binary) where the root depends on minimist by +/// `file:./pkg.tgz`, a codeload tarball URL or `github:…#v1.2.2`, and a +/// folder dependency pulls minimist@1.2.2 from the registry. Bun records +/// no version for the root copy and installs it from its own spec, so +/// wiring the nested registry copy is never attested: hosted under +/// `--no-verify`, vendored by default (both lockfile-only), and the run +/// says why. The same text lock without the root copy attests. +#[test] +fn unversioned_own_source_copy_withholds_the_nested_wiring() { + let api = Api::start(); + api.serve_view(UUID, view(UUID, PURL)); + for dir in [ + "text-0/file", + "text-1/file", + "text-2/file", + "text-2/url", + "text-2/git", + "lockb/file", + "lockb/url", + "lockb/git", + ] { + let fixture = fixture_dir(&format!("bun-unversioned-copy/{dir}")); + for mode in modes() { + let what = format!("{dir} {mode}"); + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::copy(fixture.join("package.json"), cwd.join("package.json")).unwrap(); + let wiring = match mode { + "hosted" => hosted(UUID), + _ => commit_artifact(cwd, UUID, PATCHED), + }; + let extra: &[&str] = if mode == "hosted" { + &["--no-verify"] + } else { + &[] + }; + if dir.starts_with("lockb/") { + let bytes = std::fs::read(fixture.join("bun.lockb")).unwrap(); + std::fs::write(cwd.join("bun.lockb"), rewire_binary(bytes, &wiring)).unwrap(); + } else { + let text = std::fs::read_to_string(fixture.join("bun.lock")).unwrap(); + let nested = format!("\"dep/{NAME}\": "); + let lock: String = text + .lines() + .map( + |line| match line.trim_start().strip_prefix(nested.as_str()) { + Some(_) => format!(" {nested}{},\n", text_tuple(&wiring)), + None => format!("{line}\n"), + }, + ) + .collect(); + std::fs::write(cwd.join("bun.lock"), &lock).unwrap(); + + // Control: without the root copy the wiring attests. + let root = format!("\"{NAME}\": "); + let alone: String = lock + .lines() + .filter(|line| !line.trim_start().starts_with(root.as_str())) + .map(|line| format!("{line}\n")) + .collect(); + let control = tempfile::tempdir().unwrap(); + let ccwd = control.path(); + std::fs::copy(cwd.join("package.json"), ccwd.join("package.json")).unwrap(); + std::fs::write(ccwd.join("bun.lock"), alone).unwrap(); + if mode == "vendored" { + commit_artifact(ccwd, UUID, PATCHED); + } + let (code, env) = vex_online(ccwd, &api, extra); + assert_attested(ccwd, code, &env, UUID, mode, &format!("{what} control")); + } + // The withdrawn wiring was the only patch reference. + let (code, env) = vex_online(cwd, &api, extra); + assert_nothing_found(code, &env, &what); + assert!(read_doc(&cwd.join("out.vex.json")).is_none(), "{what}"); + let text = env.to_string(); + assert!( + text.contains("patched_ref_unattributable") + && text.contains("whose version the lock does not record"), + "{what}: the withheld wiring must be explained: {env}" + ); + } + } +} + // ────────────────────────────────────────────────────────────────────── // c) + d) + embedded: the state written by the REAL CLI. // ────────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index 6d5fe1e36..999dcccb6 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -55,6 +55,10 @@ pub(crate) struct BinaryPackage { /// EVERY edge resolving to this record is bundled (and there is at /// least one): rewiring its resolution installs nothing. pub(crate) bundled_only: bool, + /// Bun installs the record from its own spec — a local or remote + /// tarball, git, a folder or a link — not from the registry, and it is + /// neither the root nor a workspace member (#497). + pub(crate) own_source: bool, } #[derive(Clone, Debug)] @@ -293,6 +297,7 @@ impl BunLockb { integrity, bundled: false, bundled_only: false, + own_source: !matches!(tag, 0 | 1 | 2 | 72), }) } diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 8ae7ccdf4..070620f7f 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -86,11 +86,14 @@ //! //! Non-goals: nested workspace-member locks (bun keeps one lock at the //! workspace root); git / github / workspace / folder entries (never -//! Socket-written). +//! Socket-written). Those entries still contest: a URL / `file:` tarball, +//! git, folder or link copy whose version the lock does not record (Bun +//! keeps none for it) withdraws every ref of the same package in that lock +//! ([`Unwired`], #497). use super::{ - npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, - DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, + canonical_base_purl, npm_purl, DiscoverCtx, Discovery, LocateOpts, Located, PatchedRef, + DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; use crate::patch::redirect::hosted_url_version; @@ -155,6 +158,10 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { recorded_version: None, integrity, shape_ok: tarball_tuple, + // Every other spec is a registry `name@` or a member's + // `name@workspace:`. + own_source: !target.starts_with(|c: char| c.is_ascii_digit()) + && !target.starts_with("workspace:"), }; if is_bundled_entry(entry) { bundled.record(ctx, BUN_LOCK, classified, out); @@ -162,6 +169,7 @@ async fn extract_text(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let user_tarball = user_tarball_version(name, target).is_some(); unwired.record( classify(ctx, BUN_LOCK, classified, out), + name, &entry.key, user_tarball, ); @@ -288,6 +296,7 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // The codec only yields a resolution STRING for registry and // tarball-like records; git records come back empty. shape_ok: true, + own_source: p.own_source, }; // A record some bundled edge reaches installs (also) as a copy // unpacked from that parent's tarball. Bun keeps ONE record for a @@ -300,6 +309,7 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { p.version.is_none() && user_tarball_version(&p.name, &p.resolution).is_some(); unwired.record( classify(ctx, BUN_LOCKB, classified, out), + &p.name, &label, user_tarball, ); @@ -326,18 +336,29 @@ struct Entry<'a> { integrity: Option, /// The entry is in a shape bun reads as a tarball tuple. shape_ok: bool, + /// Bun installs the entry from its own spec (a URL or local tarball, + /// git, a folder or a link), never the registry or a workspace member. + own_source: bool, +} + +/// What [`classify`] found an entry that is not a ref to install. +enum Install { + /// Nothing that contests a ref: a ref itself, an invalid wiring, or a + /// registry spec naming no exact version. + Nothing, + /// An unpatched copy of this exact purl. + Unpatched(String), + /// An own-source copy whose version the lock does not record (#497): + /// a tarball whose leaf names no version, git, a folder. + Unversioned, } /// Push `entry`'s ref when it is Socket-wired (see the module docs); stay -/// silent for anything else. Returns the purl of a registry entry (an exact -/// version resolved from a non-Socket source), which contests a ref for the -/// same version in this lock ([`Unwired::contest`]) and in any other. -fn classify( - ctx: &DiscoverCtx<'_>, - file: &str, - entry: Entry<'_>, - out: &mut Discovery, -) -> Option { +/// silent for anything else. Returns what a non-Socket entry installs: the +/// purl of an exact version, which contests a ref for the same version in +/// this lock ([`Unwired::contest`]) and in any other, or a copy of unknown +/// version, which contests every ref of the package in this lock. +fn classify(ctx: &DiscoverCtx<'_>, file: &str, entry: Entry<'_>, out: &mut Discovery) -> Install { let Entry { label, name, @@ -345,6 +366,7 @@ fn classify( recorded_version, integrity, shape_ok, + own_source, } = entry; let Located { vendored, @@ -361,14 +383,16 @@ fn classify( .socket/vendor/npm// path; it is ignored" ), ); - return None; + return Install::Nothing; } if vendored.is_none() && hosted_uuid.is_none() { // Registry / git / workspace / user tarball dependency: not ours. A // registry entry (an exact version), or a user URL / `file:` // tarball whose leaf names its version (#497), is an unpatched // install of that version: evidence against wiring of the same - // package in this lock and any other. + // package in this lock and any other. Any other own-source copy + // (a tarball whose leaf names no version, git, a folder) may be + // the wired version: Bun records no version for it. let version = recorded_version .or_else(|| { target @@ -376,9 +400,16 @@ fn classify( .then_some(target) }) .or_else(|| user_tarball_version(name, target)); - let purl = version.and_then(|version| npm_purl(name, version)); + let Some(version) = version else { + return if own_source { + Install::Unversioned + } else { + Install::Nothing + }; + }; + let purl = npm_purl(name, version); out.resolved_elsewhere(file, purl.clone()); - return purl; + return purl.map_or(Install::Nothing, Install::Unpatched); } let invalid = |out: &mut Discovery, why: String| { out.diag(DIAG_REF_INVALID, file, format!("{file}: {label}: {why}")); @@ -390,12 +421,12 @@ fn classify( "{name}@{target} is not in bun's tarball tuple shape [spec, {{meta}}, integrity]" ), ); - return None; + return Install::Nothing; } let version = match &vendored { Some(vref) if vref.eco != "npm" => { invalid(out, format!("{target:?} is not a vendored npm tarball")); - return None; + return Install::Nothing; } Some(vref) => tgz_leaf_version(name, &vref.leaf) .filter(|version| semver::Version::parse(version).is_ok()), @@ -406,7 +437,7 @@ fn classify( out, format!("{target:?} is not an artifact of {name:?} (its leaf must be the package's own -.tgz)"), ); - return None; + return Install::Nothing; }; if recorded_version.is_some_and(|recorded| recorded != version) { invalid( @@ -416,14 +447,14 @@ fn classify( recorded_version.unwrap_or_default() ), ); - return None; + return Install::Nothing; } let Some(purl) = npm_purl(name, version) else { invalid( out, format!("Socket-wired entry {name:?}@{version:?} has unsafe coordinates"), ); - return None; + return Install::Nothing; }; // Hosted: both bun rewriters always write the sha512 (see module docs). if let Some(vref) = vendored { @@ -438,7 +469,7 @@ fn classify( true, )); } - None + Install::Nothing } /// The registry copies one lock records, keyed by purl (#588): bun installs @@ -446,47 +477,87 @@ fn classify( /// registry (e.g. a workspace member added after the rewire, then `bun /// install`) installs unpatched beside the rewired one. A user URL / /// `file:` tarball of the version (#497) is such a copy too, one no re-run -/// can rewire: bun installs it from its own spec. +/// can rewire: bun installs it from its own spec. So is an own-source copy +/// whose version the lock does not record (a tarball named `pkg.tgz`, a +/// codeload URL, git, a folder): it may be the wired version, so it +/// contests every ref of the package. Only this lock's refs: without a +/// version it is no `name@version` evidence for the cross-lock contest. #[derive(Default)] struct Unwired { /// purl → the first such entry's label (a user tarball's in preference /// to a registry copy's), and whether it is a user tarball. copies: std::collections::BTreeMap, + /// Version-less purl (`pkg:npm/`) → the first unversioned + /// own-source entry's label. + unversioned: std::collections::BTreeMap, +} + +/// `purl` without its `@`. +fn package_of(purl: &str) -> &str { + purl.rsplit_once('@').map_or(purl, |(package, _)| package) } impl Unwired { - fn record(&mut self, purl: Option, label: &str, user_tarball: bool) { - if let Some(purl) = purl { - // A user-tarball copy wins over a registry one: a re-run rewires - // the registry copy but never the tarball, so the diagnostic must - // name the copy whose remedy is "depend on the registry version". - match self.copies.entry(purl) { - std::collections::btree_map::Entry::Vacant(v) => { - v.insert((label.to_string(), user_tarball)); + fn record(&mut self, install: Install, name: &str, label: &str, user_tarball: bool) { + let purl = match install { + Install::Nothing => return, + Install::Unpatched(purl) => purl, + Install::Unversioned => { + if let Some(purl) = npm_purl(name, "0") { + let package = package_of(&canonical_base_purl(&purl)).to_string(); + self.unversioned + .entry(package) + .or_insert_with(|| label.to_string()); } - std::collections::btree_map::Entry::Occupied(mut o) => { - if user_tarball && !o.get().1 { - o.insert((label.to_string(), true)); - } + return; + } + }; + // A user-tarball copy wins over a registry one: a re-run rewires the + // registry copy but never the tarball, so the diagnostic must name + // the copy whose remedy is "depend on the registry version". + match self.copies.entry(purl) { + std::collections::btree_map::Entry::Vacant(v) => { + v.insert((label.to_string(), user_tarball)); + } + std::collections::btree_map::Entry::Occupied(mut o) => { + if user_tarball && !o.get().1 { + o.insert((label.to_string(), true)); } } } } /// Withdraw every ref of `file` whose `name@version` another entry of - /// the same lock resolves from the registry. + /// the same lock resolves from elsewhere, or whose package an + /// unversioned own-source entry installs. fn contest(&self, file: &str, out: &mut Discovery) { - if self.copies.is_empty() { + if self.copies.is_empty() && self.unversioned.is_empty() { return; } let refs = std::mem::take(&mut out.refs); for r in refs { - let unwired_at = (r.source_file == std::path::Path::new(file)) - .then(|| self.copies.get(&r.purl)) - .flatten(); - let Some((label, user_tarball)) = unwired_at else { + if r.source_file != std::path::Path::new(file) { out.refs.push(r); continue; + } + let Some((label, user_tarball)) = self.copies.get(&r.purl) else { + if let Some(label) = self.unversioned.get(package_of(&r.purl)) { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {} is wired to a Socket patch but entry {label:?} of the \ + same lock installs that package from a URL, local tarball, git \ + or folder spec whose version the lock does not record; bun \ + installs it from that spec, so if it is this version that copy \ + stays UNPATCHED, and nothing is attested", + r.purl, + ), + ); + } else { + out.refs.push(r); + } + continue; }; if *user_tarball { out.diag( @@ -836,8 +907,7 @@ mod tests { /// a hosted / vendored rewire of the nested registry copy of the same /// version leaves the copy the app loads unpatched. Nothing may be /// attested from the lock alone (npm's #326 contest). A tarball of - /// another version, or one whose leaf names no version, contests - /// nothing. + /// another version contests nothing. #[tokio::test] async fn issue_497_user_tarball_copy_contests_the_ref() { let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); @@ -871,10 +941,7 @@ mod tests { out.diagnostics ); } - for other in [ - "left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz", - "left-pad@./left-pad.tgz", - ] { + for other in ["left-pad@https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz"] { let p = Project::new(); p.write( "bun.lock", @@ -893,6 +960,168 @@ mod tests { } } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name an own-source + /// copy of unknown version. + fn unversioned_contests(out: &Discovery) -> usize { + out.diagnostics + .iter() + .filter(|d| { + d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("whose version the lock does not record") + }) + .count() + } + + /// REGRESSION (#497 follow-up): Bun records no version for a URL / + /// `file:` tarball whose leaf names none (`pkg.tgz`, a codeload URL), + /// nor for a git or folder dependency, and installs it from that spec. + /// It may be the wired version, so it contests every ref of the package + /// in that lock, scoped or not. A copy of another package, a workspace + /// member and a registry copy of another version contest nothing. + #[tokio::test] + async fn issue_497_unversioned_own_source_copy_contests_the_ref() { + for (name, file) in [ + ("left-pad", "left-pad-1.3.0.tgz"), + ("@s/pad", "pad-1.3.0.tgz"), + ] { + let hosted = hosted_url("npm", name, "1.3.0", UUID_A, file); + let vendored = format!("{name}@.socket/vendor/npm/{UUID_A}/{name}-1.3.0.tgz"); + for (label, spec) in [ + ("hosted", format!("{name}@{hosted}")), + ("vendored", vendored), + ] { + for user in [ + format!("{name}@./pkg.tgz"), + format!("{name}@https://codeload.github.com/o/r/tar.gz/refs/tags/v1.3.0"), + format!("{name}@github:o/r#c12a808"), + format!("{name}@file:vend/pad"), + format!("{name}@link:pad"), + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple(name, &user, None), + tuple(&format!("dep/{name}"), &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{label} {user}: {:#?}", out.refs); + assert_eq!( + unversioned_contests(&out), + 1, + "{label} {user}: {:#?}", + out.diagnostics + ); + } + for other in [ + "other@./pkg.tgz".to_string(), + format!("{name}@workspace:packages/pad"), + format!("{name}@1.2.0"), + ] { + let p = Project::new(); + p.write( + "bun.lock", + text_lock( + 2, + &[ + tuple("x", &other, None), + tuple(&format!("dep/{name}"), &spec, Some(SRI)), + ], + ), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{label} {other}: {:#?}", out.refs); + assert_eq!(unversioned_contests(&out), 0, "{label} {other}"); + } + } + } + } + + /// REGRESSION (#497 follow-up), real Bun locks: the root depends on + /// minimist by `file:./pkg.tgz`, a codeload tarball URL or + /// `github:…#v1.2.2` (Bun 1.4.2 / 1.2.23 / 1.1.45 text, 1.1.45 binary), + /// and a folder dependency on minimist@1.2.2. Wiring that nested + /// registry copy (hosted or vendored) is never attested; dropping the + /// root copy from the text lock attests it. + #[tokio::test] + async fn issue_497_real_unversioned_copies_contest_the_nested_ref() { + let hosted = hosted_url("npm", "minimist", "1.2.2", UUID_A, "minimist-1.2.2.tgz"); + let vendored = format!(".socket/vendor/npm/{UUID_A}/minimist-1.2.2.tgz"); + let root = fixture_path("bun-unversioned-copy"); + for dir in [ + "text-0/file", + "text-1/file", + "text-2/file", + "text-2/url", + "text-2/git", + ] { + let text = std::fs::read_to_string(root.join(dir).join("bun.lock")).unwrap(); + for wired in [&hosted, &vendored] { + let lock: String = text + .lines() + .map( + |line| match line.trim_start().strip_prefix("\"dep/minimist\": ") { + Some(_) => format!( + " \"dep/minimist\": [\"minimist@{wired}\", {{}}, \"{SRI}\"],\n" + ), + None => format!("{line}\n"), + }, + ) + .collect(); + assert!(lock.contains(wired.as_str()), "{dir}"); + let p = Project::new(); + p.write("bun.lock", &lock); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + unversioned_contests(&out), + 1, + "{dir} {wired}: {:#?}", + out.diagnostics + ); + + let without_root: String = lock + .lines() + .filter(|line| !line.trim_start().starts_with("\"minimist\": ")) + .map(|line| format!("{line}\n")) + .collect(); + let p = Project::new(); + p.write("bun.lock", &without_root); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{dir} {wired} control: {:#?}", out); + assert_eq!(unversioned_contests(&out), 0, "{dir} {wired} control"); + } + } + for shape in ["file", "url", "git"] { + let bytes = std::fs::read(root.join("lockb").join(shape).join("bun.lockb")).unwrap(); + for wired in [&hosted, &vendored] { + let mut lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).unwrap(); + let id = lock + .packages() + .unwrap() + .into_iter() + .find(|p| p.name == "minimist" && p.version.as_deref() == Some("1.2.2")) + .expect("nested registry record") + .id; + lock.set_package(id, wired, SRI).unwrap(); + let p = Project::new(); + p.write("bun.lockb", lock.bytes()); + let out = run(&p).await; + assert_refs(&out, &[]); + assert_eq!( + unversioned_contests(&out), + 1, + "{shape} {wired}: {:#?}", + out.diagnostics + ); + } + } + } + /// REGRESSION (#497 review): when the same version has both an unwired /// registry copy and a user-tarball copy, and the registry copy comes /// first, the diagnostic still names the tarball copy, since a re-run diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..06d7705421551f3ee6cf07a3305b54fd0d07475f GIT binary patch literal 1857 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p*pfm*XZ z7e1f9Bs)vy>h9c`H&6Cm&a~WrX6=Xmxn`W1r#7+y6#)SYgks=8qZ^?75|{#p#DW5_ zSZ-!sW^QJ2NlGdze_nP#(D#Zj+B5G?*%sLL%wXHymrrKBug!w-8KEYUOTkOpLf8O%t!@XP<>|3_&cBH0CI z0?lJ&fS5~Fqmj%dC4A7GhrvfO7dhNm)^yFb+BEw^Qrhu<(Z$Iojwcd>x_e@^Vy}wa znOe>GI4D;CY3{s^`x#T}+Beotj<vt%ci&}SuZoMxFj(rM-Nu?=!F!errIeO87LHIR;A{r=_r^eB<5tM=jEqy zLA?I&KLmhyuyhX#6c}HC6(S-7rMYZMjg9PpM&U9Ps2mgoApIUtJt0Ksh1my6KOIo@ z*Wmi1jm%<8Er6aegWGF}>KR!10W05_z+nUQXalTdgq3(q%pmXcVUuAr)HBjE0?H~t z%V8Le8p6e?xn-$Edae~EsYQ8-Il(1GnR)4U3Wf;BS->3^05xePlm><$&>x1lyukpf zG_3UWf!=|#3bNDnO46(J^r4K5l9GaAD}DW<)O28RUsS1=SCE@kte0Put`GC6zAl`p O3-%Gn0HCi1B>(`M^!SDV literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json new file mode 100644 index 000000000..ca8d78a37 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "27c7816d285c04441e4c21fa5f05cd5a2be755a439463ef7ec61ddccf03292f4" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..ac0500980af50efbad9bccdd3b9e1d83030475c4 GIT binary patch literal 1904 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p*}VH5uT z2(UX@^N#m^?{q2VlUyoY@ezKizvomsznwd+>j4{35fHFIC3eXsbUJ@f9AZGmmi47S~U`DE7n+AJ8K5o#j2lmJwtHjw6G zU}%83qs&mxNY99Y!4xPi4HSne!=PXufw>z)0RaI}sDeNM5L*E40r>|c1``J|2?AvG zAU4PhW+Yws<^S>jqcjka>;f}^<}orr%%!T)Nam6fKIqQF;3Juf9BwRYx@KE#n*AXu z?fAdw;$#!Y6Ny3HJ+WG`S4Hkjt!8{26s!L!9| zuXet^{n&=8p2u)CQ3518f%Y00c0e=D11Qa9Q(Tm+mzh^wl9-dD2P=B?LW)vT?G%g* z6pAyeQuEVv6igHnb28KO^3#AB6$JkMhX4>8mhM4p5PIor2FU&qr`sskGzXsPAZDbZ(Y60|&8Qfk&RL{W54_Nue1P&YI5(!q~ zF>!#ruZUDu!DSesg%*PXv>b-fs3BaOnp>7yq~}^ul3J9Pm=j!5l$n=qr(lS1oCVx* z0Z@}}Kxtt30sUc!%Nq=!O2bM|ALyO*%#w`KBr8~fnpLb1V=6-mR~R>|7{StoF?Ev- zjS?*kEHX+;3W}}t^@~!|fweQx_ZndL`*qz#tlw0030^ B4*>uG literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json new file mode 100644 index 000000000..f0e2c65b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"github:minimistjs/minimist#v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json new file mode 100644 index 000000000..aea9125c3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/git/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by github:minimistjs/minimist#v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "ce8fd5434678e14e6f559d2636c54c9599df24e8d7ab9a8e6a2315dd3d061da4" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..b576809535ae41b5ffb8383a4ea7e3ecde80ee69 GIT binary patch literal 1905 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p*ib?IHZ zo<=%IOXLSf@vWAxF3eXsbUJ@f9AZGmmi47S~U`DE7n+AJ8K5o#j2Q~*>X7m#*m zU}zxK9GFL7?j}_~C{#fp5Qr^+_JjNb5`&2YnFIl{dJr3A1~ZZ_{PO?!|4|x}IbaAqYMV*-Z_(4!5ok`Y$oF)_o+DhwG$Lp>usBcKZu zpye=(Mh)TO)ZDVvB0blNlGLKS#GK%gqRhN>I|W08<1FBg3xJw*6G{Wa59kj=T;5;+ zRT@@$`athwl#~<{Tj}d3=clCR(_Y0tll3 literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json new file mode 100644 index 000000000..51a3bcfe8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json new file mode 100644 index 000000000..54a02f6c1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/lockb/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install (default binary lockfile)", + "note": "the root depends on minimist by a codeload.github.com tarball URL of tag v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "5b49af15e9e354b16e6746645200a54b2985ba0ff7a6ca167f040e25311a0f4c" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock new file mode 100644 index 000000000..60d659d10 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 0, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json new file mode 100644 index 000000000..16967a838 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-0/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.1.45", + "os": "darwin", + "arch": "aarch64", + "command": "bun install --save-text-lockfile", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "126159615d2e80170534d3764670a391595b1c8a7837280c6469f2210f7c18b7" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock new file mode 100644 index 000000000..0f2e65ad6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json new file mode 100644 index 000000000..f125d4452 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-1/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.2.23", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "0c175213adde5a2c042b0200575c8103d0f6162e2fd262146040dcf536331b01" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock new file mode 100644 index 000000000..b9986c91f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "file:./pkg.tgz", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@./pkg.tgz", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json new file mode 100644 index 000000000..a32db8d7d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"file:./pkg.tgz","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json new file mode 100644 index 000000000..38f14075e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/file/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a file: tarball named pkg.tgz (the minimist-1.2.2 registry tarball, renamed); the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "b61aa666ff9f0dc4311987f0ef1225ac18bd364f86bde63d207f985016410891" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock new file mode 100644 index 000000000..a9fa0132e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "github:minimistjs/minimist#v1.2.2", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@github:minimistjs/minimist#c12a808", {}, "minimistjs-minimist-c12a808", "sha512-0W/PkdzQok01aYvzupeEUiLDVdl+KxE8JD35lx23fMz2xi1LP844aaBqSg7IxqXVwwQpZJyCrNdbw9hoLkyZ6w=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json new file mode 100644 index 000000000..f0e2c65b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"github:minimistjs/minimist#v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json new file mode 100644 index 000000000..b8a42e566 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/git/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by github:minimistjs/minimist#v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "05009e304b2d84819797c873e0b0444e297bdd45ee1e3d0d04843fc8efe49cf2" +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock new file mode 100644 index 000000000..3b0f7e3e6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "dep": "file:./dep", + "minimist": "https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2", + }, + }, + }, + "packages": { + "dep": ["dep@file:dep", { "dependencies": { "minimist": "1.2.2" } }], + + "minimist": ["minimist@https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2", {}, "sha512-sW82WEAaqJ7QaESr+23ttzPbDAvjqsV1lgV56rlSYb+2MrIMo6wGZJPqUb06uRI51KpGdsMrzTUAFpgaDNkQow=="], + + "dep/minimist": ["minimist@1.2.2", "", {}, "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="], + } +} diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json new file mode 100644 index 000000000..51a3bcfe8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/package.json @@ -0,0 +1 @@ +{"name":"app","version":"1.0.0","dependencies":{"minimist":"https://codeload.github.com/minimistjs/minimist/tar.gz/refs/tags/v1.2.2","dep":"file:./dep"}} \ No newline at end of file diff --git a/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json new file mode 100644 index 000000000..55f02c074 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-unversioned-copy/text-2/url/provenance.json @@ -0,0 +1,8 @@ +{ + "bun": "1.4.2", + "os": "darwin", + "arch": "aarch64", + "command": "bun install", + "note": "the root depends on minimist by a codeload.github.com tarball URL of tag v1.2.2; the folder dependency dep (file:./dep) depends on minimist@1.2.2, a nested registry copy. Bun records no version for the root copy", + "sha256": "00826b8f468df16110cf007cd95b9cdb122f859a0702a4dd201dc0638aeeef64" +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json new file mode 100644 index 000000000..2cda4120d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-unversioned-copy.json @@ -0,0 +1,106 @@ +{ + "bun-unversioned-copy/lockb/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/lockb/git": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/lockb/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lockb" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-0/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-1/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/file": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/git": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + }, + "bun-unversioned-copy/text-2/url": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/minimist@1.2.2", + "file": "bun.lock" + } + ], + "live_claims": [] + } +} From 43a06347f6dd4ba33dff65221a7b8131d34a5838 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:57:20 -0400 Subject: [PATCH 30/55] Cover re-vendor and re-run after a bun.lock migration end to end (#784) #784's fixes for a vendored bun.lockb that Bun migrated to bun.lock had real-Bun e2e coverage only for an immediate unwind (vendor --revert, rollback, hosted takeover). The silent case 2 of the issue, a superseding re-vendor that dropped the pre-vendor original so revert reported success with the project still patched, and the same-uuid re-pin that left a mixed bun.lockb/bun.lock entry, were covered only in-process with hand-placed fixture locks. vendored_text_migration_rerun_and_supersede_revert drives both through the CLI against real Bun on Bun's own migration: - rerun: repair and a vendored re-run succeed, a second re-run changes nothing, a frozen install stays patched, repair rebuilds a missing artifact without touching the lock, and a stale tuple digest is re-pinned at the same uuid into a mixed entry; - supersede: a new uuid re-pins the migrated lock and a frozen install gets the new patch. Either way every bun.lock record keeps a pre-vendor original, and vendor --revert restores the bytes Bun writes for the pristine binary lock with no drift or kept-artifact warning, so a fresh frozen install gets the registry bytes. Without the carry_forward_wiring original (state.rs) the supersede case fails; without the migrated-mode text-record revert (bun_binary.rs) the rerun case fails. The test shares setup helpers with vendored_text_migration_reverts_to_registry, skips below Bun 1.2, and its name already matches the 1.4.2 CI leg's text_migration filter. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 343 ++++++++++++++---- 1 file changed, 265 insertions(+), 78 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 10f7581f5..69b9612f9 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -690,34 +690,40 @@ fn file_mode(_p: &Path, name: &str) -> u32 { } async fn mock_api(server: &MockServer, fixture: &Fixture, _target: &str) { - let tgz = make_tgz_from_installed(&installed_target(&fixture.project), &fixture.patched); - prebuilt_common::mount_download(server, PURL, UUID, "minimist-1.2.2.tgz", &tgz).await; + mock_api_patch(server, fixture, UUID, &fixture.patched).await; +} + +/// [`mock_api`] serving patch `uuid`, which writes `patched` as minimist's +/// `index.js` (a superseding patch is a second server with a new uuid). +async fn mock_api_patch(server: &MockServer, fixture: &Fixture, uuid: &str, patched: &[u8]) { + let tgz = make_tgz_from_installed(&installed_target(&fixture.project), patched); + prebuilt_common::mount_download(server, PURL, uuid, "minimist-1.2.2.tgz", &tgz).await; std::fs::write(fixture.temp.path().join("hosted.tgz"), &tgz).unwrap(); - let url = format!("{}/patch/npm/minimist/1.2.2/33333333-3333-4333-8333-333333333333/{UUID}/minimist-1.2.2.tgz", server.uri()); + let url = format!("{}/patch/npm/minimist/1.2.2/33333333-3333-4333-8333-333333333333/{uuid}/minimist-1.2.2.tgz", server.uri()); let sri = format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) ); Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"packages":[{ - "purl":PURL,"patches":[{"uuid":UUID,"purl":PURL,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) + "purl":PURL,"patches":[{"uuid":uuid,"purl":PURL,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) .mount(server).await; Mock::given(method("GET")).and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.*minimist.*$"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"patches":[{ - "uuid":UUID,"purl":PURL,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) + "uuid":uuid,"purl":PURL,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) .mount(server).await; Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/package"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{UUID:{ + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{uuid:{ "status":"granted","url":url,"purl":PURL,"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":sri}}],"registryOverride":null}}}))) .mount(server).await; Mock::given(method("GET")) - .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"uuid":UUID,"purl":PURL, + ResponseTemplate::new(200).set_body_json(json!({"uuid":uuid,"purl":PURL, "publishedAt":"2026-01-01T00:00:00Z","files":{"package/index.js":{ "beforeHash":compute_git_sha256_from_bytes(&fixture.original), - "afterHash":compute_git_sha256_from_bytes(&fixture.patched), - "blobContent":base64::engine::general_purpose::STANDARD.encode(&fixture.patched)}}, + "afterHash":compute_git_sha256_from_bytes(patched), + "blobContent":base64::engine::general_purpose::STANDARD.encode(patched)}}, "vulnerabilities":{GHSA:{"cves":[CVE],"summary":"binary lock vuln","severity":"high","description":"d"}}, "description":"binary lock patch","license":"MIT","tier":"free"})), ) @@ -1107,61 +1113,10 @@ async fn vendored_text_migration_reverts_to_registry() { let Some(fixture) = Fixture::new("direct") else { return; }; - let raw = String::from_utf8_lossy( - &command(&fixture.reader, &fixture.project) - .arg("--version") - .output() - .unwrap() - .stdout, - ) - .trim() - .to_string(); - let major_minor: Vec = raw - .split('.') - .take(2) - .filter_map(|p| p.parse().ok()) - .collect(); - if major_minor.as_slice() < [1, 2].as_slice() { - eprintln!("SKIP vendored text migration: Bun {raw} < 1.2"); + let Some((pristine, server)) = vendor_then_migrate(&fixture).await else { return; - } - let migrate = |dir: &Path, label: &str| { - std::fs::remove_file(dir.join("bunfig.toml")).unwrap(); - let _ = std::fs::remove_dir_all(dir.join("node_modules")); - let output = command(&fixture.reader, dir) - .args(["install", "--save-text-lockfile", "--ignore-scripts"]) - .env( - "BUN_INSTALL_CACHE_DIR", - fixture.temp.path().join(format!("{label}-cache")), - ) - .env( - "BUN_INSTALL", - fixture.temp.path().join(format!("{label}-home")), - ) - .output() - .unwrap(); - require_success(output, &format!("{label}: bun.lockb -> bun.lock migration")); - assert!(!dir.join("bun.lockb").exists(), "{label}"); - std::fs::read_to_string(dir.join("bun.lock")).unwrap() }; - // What Bun writes for the pristine binary lock. - let pristine_dir = fixture.temp.path().join("pristine-migration"); - std::fs::create_dir_all(&pristine_dir).unwrap(); - for file in ["package.json", "bun.lockb", "bunfig.toml"] { - std::fs::copy(fixture.project.join(file), pristine_dir.join(file)).unwrap(); - } - let pristine = migrate(&pristine_dir, "pristine"); - - let server = MockServer::start().await; - mock_api(&server, &fixture, "minimist").await; let project = &fixture.project; - let vendored = scan(project, &server, "vendored", &[]); - assert_eq!(vendored["vendor"]["summary"]["applied"], 1, "{vendored}"); - let migrated = migrate(project, "vendored"); - assert!( - migrated.contains(&format!("minimist@.socket/vendor/npm/{UUID}/")), - "the migration carries the vendored tuple:\n{migrated}" - ); let result = if unwind == ["scan"] { // The hosted takeover reverts the vendored wiring first. @@ -1186,29 +1141,261 @@ async fn vendored_text_migration_reverts_to_registry() { &fixture.original }; - let checkout = fixture.temp.path().join("reverted-checkout"); - std::fs::create_dir_all(&checkout).unwrap(); - for file in ["package.json", "bun.lock"] { - std::fs::copy(project.join(file), checkout.join(file)).unwrap(); - } - let output = command(&fixture.reader, &checkout) - .args(["install", "--frozen-lockfile", "--ignore-scripts"]) - .env( - "BUN_INSTALL_CACHE_DIR", - fixture.temp.path().join("revert-cache"), - ) - .env("BUN_INSTALL", fixture.temp.path().join("revert-home")) - .output() - .unwrap(); - require_success(output, "frozen install of the reverted bun.lock"); assert_eq!( - std::fs::read(installed_target(&checkout).join("index.js")).unwrap(), + frozen_text_install(&fixture, "reverted"), *expected, "{unwind:?}: a fresh frozen install" ); } } +/// The Bun >= 1.2 setup of the #784 tests: vendor the fixture's binary lock, +/// then let Bun migrate it to `bun.lock` (`bun install +/// --save-text-lockfile`). Returns what Bun writes when it migrates the +/// pristine binary lock, and the mock server that vendored it; `None` (a +/// skip) under a reader older than 1.2. +async fn vendor_then_migrate(fixture: &Fixture) -> Option<(String, MockServer)> { + let raw = String::from_utf8_lossy( + &command(&fixture.reader, &fixture.project) + .arg("--version") + .output() + .unwrap() + .stdout, + ) + .trim() + .to_string(); + let major_minor: Vec = raw + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if major_minor.as_slice() < [1, 2].as_slice() { + eprintln!("SKIP vendored text migration: Bun {raw} < 1.2"); + return None; + } + let pristine_dir = fixture.temp.path().join("pristine-migration"); + std::fs::create_dir_all(&pristine_dir).unwrap(); + for file in ["package.json", "bun.lockb", "bunfig.toml"] { + std::fs::copy(fixture.project.join(file), pristine_dir.join(file)).unwrap(); + } + let pristine = migrate_to_text_lock(fixture, &pristine_dir, "pristine"); + + let server = MockServer::start().await; + mock_api(&server, fixture, "minimist").await; + let vendored = scan(&fixture.project, &server, "vendored", &[]); + assert_eq!(vendored["vendor"]["summary"]["applied"], 1, "{vendored}"); + let migrated = migrate_to_text_lock(fixture, &fixture.project, "vendored"); + assert!( + migrated.contains(&format!("minimist@.socket/vendor/npm/{UUID}/")), + "the migration carries the vendored tuple:\n{migrated}" + ); + Some((pristine, server)) +} + +/// `bun install --save-text-lockfile` in `dir`: Bun deletes `bun.lockb` and +/// writes `bun.lock`, which is returned. +fn migrate_to_text_lock(fixture: &Fixture, dir: &Path, label: &str) -> String { + std::fs::remove_file(dir.join("bunfig.toml")).unwrap(); + let _ = std::fs::remove_dir_all(dir.join("node_modules")); + let output = command(&fixture.reader, dir) + .args(["install", "--save-text-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join(format!("{label}-cache")), + ) + .env( + "BUN_INSTALL", + fixture.temp.path().join(format!("{label}-home")), + ) + .output() + .unwrap(); + require_success(output, &format!("{label}: bun.lockb -> bun.lock migration")); + assert!(!dir.join("bun.lockb").exists(), "{label}"); + std::fs::read_to_string(dir.join("bun.lock")).unwrap() +} + +/// An empty-cache `bun install --frozen-lockfile` of a fresh checkout of +/// the project's `package.json`, `bun.lock` and `.socket`: the installed +/// minimist `index.js`. +fn frozen_text_install(fixture: &Fixture, label: &str) -> Vec { + let checkout = fixture.temp.path().join(format!("{label}-checkout")); + std::fs::create_dir_all(&checkout).unwrap(); + for file in ["package.json", "bun.lock"] { + std::fs::copy(fixture.project.join(file), checkout.join(file)).unwrap(); + } + if fixture.project.join(".socket").exists() { + copy_tree(&fixture.project.join(".socket"), &checkout.join(".socket")); + } + let output = command(&fixture.reader, &checkout) + .args(["install", "--frozen-lockfile", "--ignore-scripts"]) + .env( + "BUN_INSTALL_CACHE_DIR", + fixture.temp.path().join(format!("{label}-cache")), + ) + .env( + "BUN_INSTALL", + fixture.temp.path().join(format!("{label}-home")), + ) + .output() + .unwrap(); + require_success(output, &format!("{label}: frozen install of bun.lock")); + assert!(!checkout.join("bun.lockb").exists(), "{label}"); + std::fs::read(installed_target(&checkout).join("index.js")).unwrap() +} + +/// #784, after Bun migrated a vendored `bun.lockb` to `bun.lock`: +/// +/// - `repair` and a vendored re-run keep the project vendored (a frozen +/// install still gets the patched bytes), and a re-run whose committed +/// artifact is gone (a same-uuid re-pin) rebuilds it; +/// - a superseding patch (new uuid) re-vendored on the migrated lock pins +/// the new artifact; +/// +/// and either way `vendor --revert` then restores the registry tuple Bun +/// writes for the pristine binary lock, byte for byte, with no drift left +/// behind, so a fresh frozen install gets the original bytes. Named +/// `*text_migration*` to run on the 1.4.2 CI leg. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn vendored_text_migration_rerun_and_supersede_revert() { + const UUID2: &str = "c0ffee00-4da6-45f9-bba8-b888e0ffd58c"; + for case in ["rerun", "supersede"] { + let Some(fixture) = Fixture::new("direct") else { + return; + }; + let Some((pristine, server)) = vendor_then_migrate(&fixture).await else { + return; + }; + let project = &fixture.project; + let migrated = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + let artifact = project.join(format!(".socket/vendor/npm/{UUID}")); + let expected_patched = if case == "rerun" { + let uri = server.uri(); + let repair_args = ["repair", "--patch-server-url", &uri]; + let repair = cli(project, &repair_args); + assert_eq!(repair["status"], "success", "{repair}"); + let rerun = scan(project, &server, "vendored", &[]); + assert_eq!(rerun["status"], "success", "{rerun}"); + // Bun 1.2 migrates the vendored tuple without its digest, which + // the re-run heals in place; a newer Bun's lock is kept as is. + let digestless = migrated + .lines() + .any(|l| l.contains("\"minimist\": [") && !l.contains("sha512-")); + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!( + lock == migrated + || (digestless + && lock.contains(&format!("minimist@.socket/vendor/npm/{UUID}/"))), + "a vendored re-run keeps Bun's migrated lock:\n{migrated}\n{lock}" + ); + let again = scan(project, &server, "vendored", &[]); + assert_eq!(again["status"], "success", "{again}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "a second re-run changes nothing" + ); + assert_eq!( + frozen_text_install(&fixture, "rerun"), + fixture.patched, + "the re-run keeps the project vendored" + ); + // A fresh clone that lost the artifact: repair rebuilds it. + std::fs::remove_dir_all(&artifact).unwrap(); + let repair = cli(project, &repair_args); + assert_eq!(repair["status"], "success", "{repair}"); + assert!(artifact.is_dir(), "repair rebuilds the artifact: {repair}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "repair leaves the migrated lock alone" + ); + // A tuple whose digest no longer matches the artifact: the + // re-run re-pins the same uuid as a `bun.lock` record, next to + // the `bun.lockb` records the entry carries forward. + let line = lock + .lines() + .find(|l| l.contains("\"minimist\": [")) + .unwrap(); + let digest = &line[line.find("\"sha512-").unwrap()..line.rfind('"').unwrap() + 1]; + std::fs::write( + project.join("bun.lock"), + lock.replace(line, &line.replace(digest, "\"sha512-AAAA\"")), + ) + .unwrap(); + let repin = scan(project, &server, "vendored", &[]); + assert_eq!(repin["status"], "success", "{repin}"); + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + lock, + "the re-pin restores the artifact's digest" + ); + let state = std::fs::read_to_string(project.join(".socket/vendor/state.json")).unwrap(); + assert!( + state.contains("\"bun_lock_package\"") && state.contains("\"bun_lockb_package\""), + "the re-pin leaves a mixed entry: {state}" + ); + fixture.patched.clone() + } else { + let patched2 = [ + b"/* SOCKET SUPERSEDING PATCH */\n".as_slice(), + &fixture.original, + ] + .concat(); + let server2 = MockServer::start().await; + mock_api_patch(&server2, &fixture, UUID2, &patched2).await; + let supersede = scan(project, &server2, "vendored", &[]); + assert_eq!(supersede["status"], "success", "{supersede}"); + let lock = std::fs::read_to_string(project.join("bun.lock")).unwrap(); + assert!( + lock.contains(&format!("minimist@.socket/vendor/npm/{UUID2}/")) + && !lock.contains(UUID), + "the superseding patch re-pins the migrated lock:\n{lock}" + ); + patched2 + }; + let state: Value = serde_json::from_slice( + &std::fs::read(project.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + let wiring = state["entries"][PURL]["wiring"].as_array().unwrap(); + assert!( + wiring + .iter() + .filter(|w| w["file"] == "bun.lock") + .all(|w| w["original"].is_string()), + "{case}: every bun.lock record keeps its pre-vendor original: {state}" + ); + assert_eq!( + frozen_text_install(&fixture, &format!("{case}-vendored")), + expected_patched, + "{case}: a fresh frozen install of the re-vendored lock" + ); + + let revert = cli(project, &["vendor", "--revert"]); + assert_eq!(revert["status"], "success", "{case}: {revert}"); + let revert_text = revert.to_string(); + for code in [ + "vendor_lock_entry_drifted", + "vendor_artifact_kept", + "vendor_revert_kept", + ] { + assert!(!revert_text.contains(code), "{case}: {revert}"); + } + assert_eq!( + std::fs::read_to_string(project.join("bun.lock")).unwrap(), + pristine, + "{case}: revert restores the registry tuple" + ); + assert!(!project.join(".socket/vendor").exists(), "{case}"); + assert_eq!( + frozen_text_install(&fixture, &format!("{case}-reverted")), + fixture.original, + "{case}: a fresh frozen install of the reverted lock" + ); + } +} + /// #803: Bun 1.4 migrates a hosted workspace `bun.lockb` to `bun.lock` /// with the member path the binary normalization wrote as the root's /// `consumer` literal, so a frozen install of the migrated lock fails and From 2eb2db811c80935aab336c7aa014cba7206aa5cf Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:43:44 -0400 Subject: [PATCH 31/55] Cover Bun custom-registry unwinds end to end (#992) The #992 fix (restoring the project registry's tarball URL in Bun restores) was covered by core unit tests and one hosted rollback test, but not by the other unwinds the issue names, nor by a scoped registry. These run through the built binary: - bun.lock, bunfig [install] registry for left-pad plus an [install.scopes] entry for @corp/widget: `remove ` of each hosted pin, and the hosted -> vendored takeover followed by `vendor --revert`, both land on the Bun-written lock byte for byte, and the vendor ledger records each registry's line as its original. - bun.lockb with a bunfig mirror: the takeover rebuilds the record with the mirror's dist.tarball, `vendor --revert` writes it back, and a second hosted -> vendored -> revert round trip is byte exact. Each registry advertises an off-path tarball URL and the default registry does not know the scoped package, so a restore that read the wrong registry or re-based a fallback URL fails the tests (checked by mutating the scope lookup, the bun.lock slot and the bun.lockb registry choice). The harnesses now strip BUN_CONFIG_REGISTRY / NPM_CONFIG_REGISTRY / npm_config_registry, as the other Bun restore harnesses do, so an npm-exported registry cannot steer the fixtures. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_vendor_bun_takeover.rs | 15 + .../registry.rs | 264 ++++++++++++++++++ .../tests/vendor_eject_bun_lockb.rs | 135 +++++++++ 3 files changed, 414 insertions(+) create mode 100644 crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 45fdb940f..cce133678 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -35,6 +35,10 @@ //! `vendor_bun_workspace_unsupported` BEFORE the takeover restores //! anything, so the hosted wiring survives byte-for-byte; the v2 twin //! still takes over. +//! 6. A project with its own registries (bunfig `[install] registry` and +//! `[install.scopes]`, #992): `remove ` and the takeover + +//! `vendor --revert` chain keep each registry's tarball URL in the +//! slot (`in_process_vendor_bun_takeover/registry.rs`). //! //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. @@ -53,6 +57,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[path = "vex_e2e_common/bun.rs"] mod bun_vex; +#[path = "in_process_vendor_bun_takeover/registry.rs"] +mod registry; #[path = "in_process_vendor_bun_takeover/vlt.rs"] mod vlt; #[path = "vlt_hosted_common/mod.rs"] @@ -354,6 +360,15 @@ fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { cmd.env_remove(key); } } + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the Bun restores off the fixtures' registries (#992). + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_NPM_REGISTRY", registry_uri()); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs new file mode 100644 index 000000000..05b3ae79d --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs @@ -0,0 +1,264 @@ +//! Bun hosted unwinds in a project with its own registries (#992), through +//! the built binary: `bunfig.toml` `[install] registry` for `left-pad` and +//! an `[install.scopes]` entry for `@corp/widget`. Bun writes the full +//! tarball URL into a `bun.lock` registry slot for any registry but npmjs, +//! and Bun 1.1.39–1.3.6 read an empty slot as npmjs whatever bunfig says, +//! so every unwind of a hosted pin — `remove `, and the hosted → +//! vendored takeover that `vendor --revert` later returns to — must give +//! back the URL Bun wrote, read from each package's own registry. +//! +//! Each registry's version document advertises an off-path (CDN-style) +//! tarball URL, so a restore that read the wrong registry, or fell back to +//! the default one and re-based its conventional URL, cannot land on the +//! pristine bytes by accident. The default registry (`SOCKET_NPM_REGISTRY`, +//! the shared mirror) does not know `@corp/widget` at all. + +use std::path::Path; + +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::{ + assert_no_event_code, find_event, hosted_line, line_integrity, lock_line, patch_record, read, + run_json, vendor_cli, write_bun_project, HOSTED_URL, LEFT_PAD_REGISTRY_LINE, NAME, + PATCHED_INDEX, PATCHED_SHA512, PURL, UUID, VERSION, +}; + +const SCOPED_NAME: &str = "@corp/widget"; +const SCOPED_VERSION: &str = "2.0.0"; +const SCOPED_PURL: &str = "pkg:npm/@corp/widget@2.0.0"; +const SCOPED_UUID: &str = "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"; +const SCOPED_HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/@corp/widget/2.0.0/55555555-5555-4555-8555-555555555555/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/widget-2.0.0.tgz"; +const SCOPED_INTEGRITY: &str = "sha512-corpWIDGETcorp0123456789=="; +const SCOPED_PATCHED_SHA512: &str = "sha512-corpPATCHEDcorp0123456789=="; + +/// The project's registries, all on one wiremock: the bunfig default +/// registry (`/mirror/`) and the `@corp` scope's (`/corp/`). +struct Registries { + server: MockServer, +} + +impl Registries { + async fn start() -> Self { + let server = MockServer::start().await; + let registries = Self { server }; + let mirror_doc = json!({ + "name": NAME, + "version": VERSION, + "dist": { + "tarball": registries.mirror_tarball(), + "integrity": line_integrity(LEFT_PAD_REGISTRY_LINE), + } + }); + Mock::given(method("GET")) + .and(path(format!("/mirror/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(mirror_doc)) + .mount(®istries.server) + .await; + // Bun and npm ask for a scoped document as `@scope%2fname`. + Mock::given(method("GET")) + .and(path_regex(r"^/corp/@corp(%2[fF]|/)widget/2\.0\.0$")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": SCOPED_NAME, + "version": SCOPED_VERSION, + "dist": { + "tarball": registries.scoped_tarball(), + "integrity": SCOPED_INTEGRITY, + } + }))) + .mount(®istries.server) + .await; + registries + } + + fn mirror_tarball(&self) -> String { + format!( + "{}/mirror-cdn/files/{NAME}-{VERSION}.tgz", + self.server.uri() + ) + } + + fn scoped_tarball(&self) -> String { + format!("{}/corp-cdn/widget/{SCOPED_VERSION}.tgz", self.server.uri()) + } + + fn bunfig(&self) -> String { + let uri = self.server.uri(); + format!( + "[install]\nregistry = \"{uri}/mirror/\"\n\n\ + [install.scopes]\ncorp = {{ url = \"{uri}/corp/\", token = \"t\" }}\n" + ) + } + + /// The registry lines Bun writes for this project: each slot holds the + /// tarball URL of the registry the package resolved against. + fn left_pad_line(&self) -> String { + LEFT_PAD_REGISTRY_LINE.replace("\"\", {}", &format!("\"{}\", {{}}", self.mirror_tarball())) + } + + fn scoped_line(&self) -> String { + format!( + " \"{SCOPED_NAME}\": [\"{SCOPED_NAME}@{SCOPED_VERSION}\", \"{}\", {{}}, \"{SCOPED_INTEGRITY}\"],", + self.scoped_tarball() + ) + } + + /// The Bun-written registry lock (lockfileVersion 2) for both packages. + fn pristine_lock(&self) -> String { + format!( + "{{\n \"lockfileVersion\": 2,\n \"configVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"bun-takeover-fixture\",\n \"dependencies\": {{\n \"{SCOPED_NAME}\": \"{SCOPED_VERSION}\",\n \"left-pad\": \"1.3.0\",\n }},\n }},\n }},\n \"packages\": {{\n{}\n\n{}\n }}\n}}\n", + self.scoped_line(), + self.left_pad_line(), + ) + } +} + +/// A project with both packages pinned hosted, as `scan --mode hosted` +/// leaves it (the lock's URL 3-tuples, no ledger) beside its bunfig.toml; +/// returns the pristine registry lock. +fn write_hosted_project(root: &Path, registries: &Registries) -> String { + let pristine = registries.pristine_lock(); + write_bun_project( + root, + &pristine, + &[(NAME, VERSION), (SCOPED_NAME, SCOPED_VERSION)], + ); + std::fs::write(root.join("bunfig.toml"), registries.bunfig()).unwrap(); + let hosted = pristine + .replace( + ®istries.left_pad_line(), + &hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + ) + .replace( + ®istries.scoped_line(), + &hosted_line( + SCOPED_NAME, + SCOPED_NAME, + SCOPED_HOSTED_URL, + SCOPED_PATCHED_SHA512, + ), + ); + assert!( + !hosted.contains("/mirror-cdn/") && !hosted.contains("/corp-cdn/"), + "both lines are hosted:\n{hosted}" + ); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + pristine +} + +/// `.socket/manifest.json` with both records + the after-hash blob, the +/// records `vendor` takes over. +fn seed_manifest(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut bytes = serde_json::to_vec_pretty(&json!({ "patches": { + PURL: patch_record(UUID), + SCOPED_PURL: patch_record(SCOPED_UUID), + }})) + .unwrap(); + bytes.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &bytes).unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +fn assert_no_registry_fallback(env: &Value) { + assert!( + !env.to_string().contains("upstream_registry_fallback"), + "every registry was readable: {env:#}" + ); +} + +/// `remove ` of each hosted pin in turn restores its line with the +/// tarball URL of the registry Bun resolved it against — the bunfig +/// default registry for `left-pad`, the `[install.scopes]` registry for +/// `@corp/widget` — landing on the pristine lock byte for byte. +#[tokio::test(flavor = "multi_thread")] +async fn bun_remove_restores_the_bunfig_and_scope_registry_tarball_urls() { + let registries = Registries::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_hosted_project(root, ®istries); + let cwd = root.to_str().unwrap(); + + let (code, env) = run_json(root, &["remove", PURL, "--yes", "--json", "--cwd", cwd]); + assert_eq!(code, 0, "remove left-pad: {env:#}"); + assert!(env["error"].is_null(), "{env:#}"); + assert_no_registry_fallback(&env); + let lock = read(root, "bun.lock"); + assert_eq!( + lock_line(&lock, NAME), + lock_line(&pristine, NAME), + "left-pad's slot is the bunfig registry's tarball URL:\n{lock}" + ); + assert!( + lock_line(&lock, SCOPED_NAME).contains(SCOPED_HOSTED_URL), + "the scoped pin stays hosted:\n{lock}" + ); + + let (code, env) = run_json( + root, + &["remove", SCOPED_PURL, "--yes", "--json", "--cwd", cwd], + ); + assert_eq!(code, 0, "remove @corp/widget: {env:#}"); + assert!(env["error"].is_null(), "{env:#}"); + assert_no_registry_fallback(&env); + assert_eq!( + read(root, "bun.lock"), + pristine, + "the scope's tarball URL is restored and the lock is pristine" + ); +} + +/// The hosted → vendored takeover restores both registry lines (with their +/// registries' tarball URLs) before vendoring, records them as the vendor +/// ledger's originals, and `vendor --revert` returns the pristine lock. +#[tokio::test(flavor = "multi_thread")] +async fn bun_takeover_then_vendor_revert_keeps_the_bunfig_and_scope_registry_urls() { + let registries = Registries::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_hosted_project(root, ®istries); + seed_manifest(root); + + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor over the hosted pins: {env:#}"); + assert_eq!(env["summary"]["applied"], 2, "{env:#}"); + find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + assert_no_event_code(&env, "redirect_revert_failed"); + assert_no_registry_fallback(&env); + let lock = read(root, "bun.lock"); + assert!( + !lock.contains(HOSTED_URL) && !lock.contains(SCOPED_HOSTED_URL), + "no hosted pin is left:\n{lock}" + ); + + let state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + for (purl, key) in [(PURL, NAME), (SCOPED_PURL, SCOPED_NAME)] { + let original = state["entries"][purl]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lock_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("{purl}: bun_lock_package wiring: {state:#}")); + assert_eq!( + original, + json!(lock_line(&pristine, key)), + "{purl}: the ledger's original carries the registry's tarball URL: {state:#}" + ); + } + + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "vendor --revert: {env:#}"); + assert_eq!( + read(root, "bun.lock"), + pristine, + "the revert lands on the Bun-written registry lock" + ); +} diff --git a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs index 1e4911e29..cb76c97a1 100644 --- a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs @@ -93,6 +93,15 @@ impl Project { cmd.env_remove(key); } } + // A registry exported by npm (`npm_config_registry`) or Bun would + // steer the takeover's restore off the fixtures' registry (#992). + for key in [ + "BUN_CONFIG_REGISTRY", + "NPM_CONFIG_REGISTRY", + "npm_config_registry", + ] { + cmd.env_remove(key); + } let uri = self.server.uri(); cmd.env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_API_URL", &uri) @@ -399,6 +408,132 @@ async fn rollback_and_offline_vendor_refuse_with_the_checkout_remedy() { assert_eq!(p.lock(), hosted, "a refused rollback writes nothing"); } +/// #992: in a project whose `bunfig.toml` names a mirror, the takeover +/// rebuilds the binary record with the tarball URL the mirror's version +/// document advertises (Bun fetches from the URL the record holds), the +/// vendor ledger keeps it as the original, and `vendor --revert` writes it +/// back. A second hosted → vendored → revert round trip from that lock +/// lands on it byte for byte. +#[tokio::test] +async fn takeover_and_revert_keep_the_bunfig_registry_tarball_url() { + let p = hosted_project("1.1.38").await; + // An off-path (CDN-style) URL: a restore that fell back to the default + // registry and re-based its conventional URL cannot produce it. + let mirror_tarball = format!("{}/mirror-cdn/minimist-1.2.2.tgz", p.server.uri()); + Mock::given(method("GET")) + .and(path("/mirror/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "minimist", + "version": "1.2.2", + "dist": { "tarball": mirror_tarball, "integrity": UPSTREAM_INTEGRITY } + }))) + .mount(&p.server) + .await; + std::fs::write( + p.root().join("bunfig.toml"), + format!("[install]\nregistry = \"{}/mirror/\"\n", p.server.uri()), + ) + .unwrap(); + stage_record(p.root()); + + let mut reverted: Option> = None; + for round in 0..2 { + if let Some(lock) = &reverted { + // Pin the mirror lock hosted again, as `scan --mode hosted` does. + let dep: DepOverride = serde_json::from_value(json!({ + "ecosystem": "npm", + "name": "minimist", + "version": "1.2.2", + "token": GRANT, + "patchUuid": UUID, + "artifactUrl": p.hosted_url(), + "integrity": { "sha512": format!( + "sha512-{}", base64::engine::general_purpose::STANDARD.encode([42u8; 64])) } + })) + .unwrap(); + let mut rewrite = RewriteResult::default(); + rewrite_bun_binary(lock, &[dep], &mut rewrite); + assert!(rewrite.warnings.is_empty(), "{:?}", rewrite.warnings); + std::fs::write( + p.root().join("bun.lockb"), + &rewrite.binary_files["bun.lockb"], + ) + .unwrap(); + } + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!( + code, 0, + "round {round}: vendor over the hosted pin: {env:#}" + ); + assert_eq!(env["summary"]["applied"], 1, "round {round}: {env:#}"); + let codes = codes(&env); + assert!( + codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "round {round}: {env:#}" + ); + assert!( + !codes.iter().any(|c| c == "upstream_registry_fallback"), + "round {round}: the mirror was readable: {env:#}" + ); + let original = vendored_original(p.root()); + assert_eq!( + original["resolution"], mirror_tarball, + "round {round}: {original}" + ); + assert_eq!( + original["integrity"], UPSTREAM_INTEGRITY, + "round {round}: {original}" + ); + + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "round {round}: revert: {env:#}"); + let lock = p.lock(); + let text = String::from_utf8_lossy(&lock); + // The string pool keeps the replaced npmjs URL as dead bytes (the + // codec appends); the record references the mirror's. + assert!( + text.contains(&mirror_tarball), + "round {round}: the reverted record fetches from the mirror" + ); + assert!( + !text.contains(GRANT) && !text.contains(".socket/vendor"), + "round {round}: no hosted or vendored residue" + ); + if let Some(first) = &reverted { + assert!( + &lock == first, + "a mirror lock round-trips through hosted, vendored and revert byte for byte" + ); + } + reverted = Some(lock); + } + + // The reverted lock's own record (what a plain vendor snapshots from + // it) is the mirror's, not just a string left in its pool. + let reverted = reverted.unwrap(); + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "vendor over the reverted lock: {env:#}"); + assert_eq!(vendored_original(p.root())["resolution"], mirror_tarball); + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "{env:#}"); + assert!(p.lock() == reverted, "the plain revert is exact"); +} + +/// The vendor ledger's recorded pre-vendor `bun_lockb_package` record. +fn vendored_original(root: &Path) -> Value { + let state: Value = + serde_json::from_slice(&std::fs::read(root.join(".socket/vendor/state.json")).unwrap()) + .unwrap(); + state["entries"][PURL]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lockb_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("bun_lockb_package wiring: {state:#}")) +} + /// A Bun workspace's member-relative mirror of the vendored tarball goes /// missing while the canonical tarball still matches its ledger SHA-256: /// an offline re-vendor rewrites the mirror from the committed tarball From e1465bbb724571970cf221dec959cce6565589b0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 14:04:26 -0400 Subject: [PATCH 32/55] Send Bun's registry credentials when restoring from a private scope (#992) A Bun restore (`remove `, the hosted -> vendored takeover) reads each package's version document from the registry Bun resolved it against, but sent no credentials. The usual reason for an `[install.scopes]` entry with a `token` is a private registry, which answers 401; the restore then fell back to the default registry, where a private package is a 404 (the unwind refused and could not be undone) or, for a name that also exists publicly, the default registry's tarball URL landed in the slot with only an `upstream_registry_fallback` warning -- the #992 bug again. The scoped-registry end-to-end test passed only because its mock served the scope's document to anyone. The Bun registry lookup now carries the credentials Bun itself sends: the bunfig entry's `token` (Bearer) or `username` / `password` (Basic), `$VAR` / `${VAR}` expanded, else the `.npmrc` `//host/path/:_authToken`, `_auth` or `username` + `_password` covering the registry URL on the same host. `fetch_dists_on` passes them as a sensitive `Authorization` header for that registry only (reqwest drops it on a cross-host redirect), the dist cache is keyed by the credentials too, and a registry with credentials is read even when it is the default one (a private package on npmjs). Errors and warnings never include them. The CLI fixture's scope registry now answers 401 unless the bunfig token is sent, and asserts the token is never reported; dropping the credentials fails both registry tests. A core unit test pins the precedence and expansion rules. docs/ecosystems.md records what is read (BUN_CONFIG_TOKEN is not). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../registry.rs | 28 +- .../src/patch/redirect/upstream/bun_lockb.rs | 13 +- .../src/patch/redirect/upstream/client.rs | 60 ++- .../src/patch/redirect/upstream/npm.rs | 355 ++++++++++++++++-- docs/ecosystems.md | 9 +- 5 files changed, 417 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs index 05b3ae79d..3aaf3a7c2 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/registry.rs @@ -11,13 +11,15 @@ //! tarball URL, so a restore that read the wrong registry, or fell back to //! the default one and re-based its conventional URL, cannot land on the //! pristine bytes by accident. The default registry (`SOCKET_NPM_REGISTRY`, -//! the shared mirror) does not know `@corp/widget` at all. +//! the shared mirror) does not know `@corp/widget` at all, and the scope's +//! registry is private: it answers 401 unless the restore sends the +//! `[install.scopes]` entry's token, as Bun does. use std::path::Path; use serde_json::{json, Value}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; -use wiremock::matchers::{method, path, path_regex}; +use wiremock::matchers::{header, method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::{ @@ -33,6 +35,8 @@ const SCOPED_UUID: &str = "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"; const SCOPED_HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/@corp/widget/2.0.0/55555555-5555-4555-8555-555555555555/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f/widget-2.0.0.tgz"; const SCOPED_INTEGRITY: &str = "sha512-corpWIDGETcorp0123456789=="; const SCOPED_PATCHED_SHA512: &str = "sha512-corpPATCHEDcorp0123456789=="; +/// The `@corp` scope registry's token, from `bunfig.toml`. +const SCOPE_TOKEN: &str = "corp-secret-token"; /// The project's registries, all on one wiremock: the bunfig default /// registry (`/mirror/`) and the `@corp` scope's (`/corp/`). @@ -57,9 +61,20 @@ impl Registries { .respond_with(ResponseTemplate::new(200).set_body_json(mirror_doc)) .mount(®istries.server) .await; - // Bun and npm ask for a scoped document as `@scope%2fname`. + // Bun and npm ask for a scoped document as `@scope%2fname`. The + // private scope registry serves it only with the scope's token. + Mock::given(method("GET")) + .and(path_regex(r"^/corp/")) + .respond_with(ResponseTemplate::new(401)) + .with_priority(10) + .mount(®istries.server) + .await; Mock::given(method("GET")) .and(path_regex(r"^/corp/@corp(%2[fF]|/)widget/2\.0\.0$")) + .and(header( + "authorization", + format!("Bearer {SCOPE_TOKEN}").as_str(), + )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "name": SCOPED_NAME, "version": SCOPED_VERSION, @@ -68,6 +83,7 @@ impl Registries { "integrity": SCOPED_INTEGRITY, } }))) + .with_priority(1) .mount(®istries.server) .await; registries @@ -88,7 +104,7 @@ impl Registries { let uri = self.server.uri(); format!( "[install]\nregistry = \"{uri}/mirror/\"\n\n\ - [install.scopes]\ncorp = {{ url = \"{uri}/corp/\", token = \"t\" }}\n" + [install.scopes]\ncorp = {{ url = \"{uri}/corp/\", token = \"{SCOPE_TOKEN}\" }}\n" ) } @@ -174,6 +190,10 @@ fn assert_no_registry_fallback(env: &Value) { !env.to_string().contains("upstream_registry_fallback"), "every registry was readable: {env:#}" ); + assert!( + !env.to_string().contains(SCOPE_TOKEN), + "the scope's token is never reported: {env:#}" + ); } /// `remove ` of each hosted pin in turn restores its line with the diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs index 7297f9417..2e90bfb7c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -105,7 +105,13 @@ pub(super) async fn restore( // The record keeps the tarball URL Bun fetches from, which is the // project registry's for a mirror (#992). let settings = BunRegistrySettings::read(view, rel).await; - let dists = fetch_dists_on(&wanted, |n| settings.registry(n), ctx, &mut result).await; + let dists = fetch_dists_on( + &wanted, + |n| settings.registry_with_credentials(n), + ctx, + &mut result, + ) + .await; let mut changed = false; let mut restored = Vec::new(); for (id, uuid, name, version) in hits { @@ -373,7 +379,10 @@ mod tests { let package_start = u64::from_le_bytes(lock[110..118].try_into().unwrap()) as usize; // The root resolution's flag byte (its last). let flags_at = package_start + count * 16 + 63; - assert_eq!(lock[flags_at], crate::vendor::bun_lockb::NORMALIZED_FORMAT_1); + assert_eq!( + lock[flags_at], + crate::vendor::bun_lockb::NORMALIZED_FORMAT_1 + ); lock[flags_at] |= 0x40; BunLockb::parse(&lock).unwrap().validate_mutation().unwrap(); let (outcome, after) = run(&lock, &vendor_opts()).await; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 63567b55d..1c055b800 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -150,8 +150,8 @@ pub(crate) const OFFLINE: &str = type Cache = Mutex>>; -/// [`Cache`] keyed by (registry base, name, version). -type RegistryCache = Mutex>>; +/// [`Cache`] keyed by (registry base, `Authorization` sent, name, version). +type RegistryCache = Mutex, String, String), Result>>; /// One client per restore run; every lookup is cached (success and /// failure alike) so a pin wired in several files costs one request. @@ -185,10 +185,27 @@ impl UpstreamClient { } async fn get_json(&self, url: &str) -> Result { - let resp = self - .http - .get(url) - .header("accept", "application/json") + self.get_json_authorized(url, None).await + } + + /// [`Self::get_json`] sending `authorization` (a private registry's + /// credentials) as the `Authorization` header. reqwest drops the header + /// on a redirect to another host. + async fn get_json_authorized( + &self, + url: &str, + authorization: Option<&str>, + ) -> Result { + let mut request = self.http.get(url).header("accept", "application/json"); + if let Some(authorization) = authorization { + let mut value = + reqwest::header::HeaderValue::from_str(authorization).map_err(|_| { + format!("GET {url}: the configured credentials are not a valid header") + })?; + value.set_sensitive(true); + request = request.header(reqwest::header::AUTHORIZATION, value); + } + let resp = request .send() .await .map_err(|e| format!("GET {url}: {e}"))?; @@ -221,13 +238,33 @@ impl UpstreamClient { base: &str, name: &str, version: &str, + ) -> Result { + self.npm_dist_authorized(base, None, name, version).await + } + + /// [`Self::npm_dist_on`] sending `authorization` as the `Authorization` + /// header: the credentials the project configures for the private + /// registry at `base` (#992). + pub(crate) async fn npm_dist_authorized( + &self, + base: &str, + authorization: Option<&str>, + name: &str, + version: &str, ) -> Result { let base = base.trim_end_matches('/'); - let key = (base.to_string(), name.to_string(), version.to_string()); + let key = ( + base.to_string(), + authorization.map(str::to_string), + name.to_string(), + version.to_string(), + ); if let Some(hit) = self.npm.lock().await.get(&key) { return hit.clone(); } - let result = self.fetch_npm_dist(base, name, version).await; + let result = self + .fetch_npm_dist(base, authorization, name, version) + .await; self.npm.lock().await.insert(key, result.clone()); result } @@ -235,6 +272,7 @@ impl UpstreamClient { async fn fetch_npm_dist( &self, base: &str, + authorization: Option<&str>, name: &str, version: &str, ) -> Result { @@ -246,7 +284,7 @@ impl UpstreamClient { "{base}/{encoded_name}/{}", crate::utils::uri::encode_uri_component(version) ); - let doc = self.get_json(&url).await?; + let doc = self.get_json_authorized(&url, authorization).await?; let dist = doc .get("dist") .ok_or_else(|| format!("{url} carries no `dist` block"))?; @@ -797,7 +835,7 @@ mod tests { .await; let client = UpstreamClient::new(false); client.npm.lock().await.insert( - (npm_registry_base(), "left-pad".into(), "1.3.0".into()), + (npm_registry_base(), None, "left-pad".into(), "1.3.0".into()), Ok(NpmDist { tarball: format!("{}/archive.tgz", server.uri()), integrity: registry_sri, @@ -857,7 +895,7 @@ mod tests { .await; let client = UpstreamClient::new(false); client.npm.lock().await.insert( - (npm_registry_base(), "left-pad".into(), "1.3.0".into()), + (npm_registry_base(), None, "left-pad".into(), "1.3.0".into()), Ok(NpmDist { tarball: format!("{}/archive.tgz", server.uri()), integrity: Some("sha512-other".into()), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index c964154d8..ff9db3144 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -30,7 +30,7 @@ pub(super) async fn fetch_dists( ctx: &Ctx<'_>, result: &mut FormatResult, ) -> BTreeMap<(String, String), NpmDist> { - fetch_dists_on(wanted, |_| None, ctx, result) + fetch_dists_on(wanted, |_| None::, ctx, result) .await .into_iter() .map(|(key, found)| (key, found.dist)) @@ -65,25 +65,63 @@ pub(super) fn non_default_registry(base: &str) -> Option { .then(|| base.to_string()) } +/// A registry a project resolves a package against, with the +/// `Authorization` header value its settings configure for it (a private +/// registry's token or basic credentials). Never `Debug`: it holds a secret. +#[derive(Clone)] +pub(super) struct ProjectRegistry { + pub base: String, + pub authorization: Option, +} + +impl From for ProjectRegistry { + fn from(base: String) -> Self { + ProjectRegistry { + base, + authorization: None, + } + } +} + /// [`fetch_dists`], reading each version document from the registry the /// project resolves `name` against (`registry(name)`; `None` means the /// default registry), since a mirror's `dist.tarball` need not be the -/// default registry's (#521, #908). When the project's registry can't be -/// read (a private mirror that wants credentials the restore does not -/// send), the default registry's document is used, as before, and -/// `upstream_registry_fallback` says so. -pub(super) async fn fetch_dists_on( +/// default registry's (#521, #908), with the credentials the project +/// configures for it (#992). A registry with credentials is read even when +/// it is the default one (a private package on npmjs). When the project's +/// registry can't be read, the default registry's document is used, as +/// before, and `upstream_registry_fallback` says so. +pub(super) async fn fetch_dists_on>( wanted: &BTreeSet<(String, String, String)>, - registry: impl Fn(&str) -> Option, + registry: impl Fn(&str) -> Option, ctx: &Ctx<'_>, result: &mut FormatResult, ) -> BTreeMap<(String, String), ProjectDist> { let lookups = wanted.iter().map(|(uuid, name, version)| { - let project = registry(name).as_deref().and_then(non_default_registry); + let project = registry(name) + .map(Into::into) + .and_then(|r: ProjectRegistry| { + let base = non_default_registry(&r.base).or_else(|| { + r.authorization + .is_some() + .then(|| r.base.trim().trim_end_matches('/').to_string()) + })?; + Some(ProjectRegistry { + base, + authorization: r.authorization, + }) + }); async move { let mut fell_back = None; let found = match project { - Some(base) => match ctx.client.npm_dist_on(&base, name, version).await { + Some(ProjectRegistry { + base, + authorization, + }) => match ctx + .client + .npm_dist_authorized(&base, authorization.as_deref(), name, version) + .await + { Ok(dist) => Ok(ProjectDist { dist, from_project: true, @@ -1003,41 +1041,80 @@ pub(crate) async fn restore_pnpm_locks( /// (`BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), the `.npmrc` /// `registry`, then `bunfig.toml` `[install] registry` — Bun's own order. /// `None` means Bun's default registry, npmjs. +/// +/// The registry carries the credentials Bun sends it: the bunfig entry's +/// own `token` (Bearer) or `username` / `password` (Basic), else the +/// `.npmrc` `//host/path/:_authToken` / `:_auth` / `:username` + +/// `:_password` whose path covers the registry URL. `$VAR` / `${VAR}` in a +/// bunfig value and `${VAR}` in an `.npmrc` value read `var`, as Bun +/// expands them. A private scope registry answers 401 without them. fn bun_lookup_registry( npmrc: Option<&str>, bunfig: Option<&str>, env_registry: Option<&str>, + var: &dyn Fn(&str) -> Option, name: &str, -) -> Option { +) -> Option { use super::super::npmrc::npmrc_top_level_value; fn url(value: &str) -> Option { let value = value.trim().trim_matches(['"', '\'']); (value.starts_with("https://") || value.starts_with("http://")).then(|| value.to_string()) } - // A registry is a URL string or a table carrying `url`. - fn toml_url(item: Option<&toml_edit::Item>) -> Option { + // A bunfig registry is a URL string or a table carrying `url` and + // maybe its credentials. + let toml_url = |item: Option<&toml_edit::Item>| -> Option { let item = item?; let value = item .as_str() .or_else(|| item.get("url").and_then(toml_edit::Item::as_str))?; - url(value) - } + url(&expand_env(value, var, true)) + }; + let toml_auth = |item: &toml_edit::Item| -> Option { + let field = |key: &str| { + item.get(key) + .and_then(toml_edit::Item::as_str) + .map(|v| expand_env(v, var, true)) + .filter(|v| !v.is_empty()) + }; + if let Some(token) = field("token") { + return Some(format!("Bearer {token}")); + } + let (user, password) = (field("username")?, field("password")?); + use base64::Engine as _; + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")) + )) + }; + let npmrc_value = |key: &str| { + npmrc + .and_then(|text| npmrc_top_level_value(text, key)) + .map(|v| expand_env(&v, var, false)) + }; + let with_npmrc_auth = |base: String, own: Option| -> ProjectRegistry { + let authorization = own.or_else(|| npmrc_registry_auth(&base, &npmrc_value)); + ProjectRegistry { + base, + authorization, + } + }; let bunfig = bunfig.and_then(|text| text.parse::().ok()); let install = bunfig.as_ref().and_then(|doc| doc.get("install")); // The configured default registry before the environment applies. - let configured = || { - npmrc - .and_then(|text| npmrc_top_level_value(text, "registry")) - .and_then(|value| url(&value)) - .or_else(|| toml_url(install?.get("registry"))) + let configured = || -> Option { + if let Some(base) = npmrc_value("registry").and_then(|value| url(&value)) { + return Some(with_npmrc_auth(base, None)); + } + let item = install?.get("registry")?; + let base = toml_url(Some(item))?; + Some(with_npmrc_auth(base, toml_auth(item))) }; if let Some((scope, _)) = name.strip_prefix('@').and_then(|rest| rest.split_once('/')) { - if let Some(scoped) = npmrc - .and_then(|text| npmrc_top_level_value(text, &format!("@{scope}:registry"))) - .and_then(|value| url(&value)) + if let Some(scoped) = + npmrc_value(&format!("@{scope}:registry")).and_then(|value| url(&value)) { - return Some(scoped); + return Some(with_npmrc_auth(scoped, None)); } let entry = install.and_then(|i| i.get("scopes")).and_then(|scopes| { scopes @@ -1045,17 +1122,102 @@ fn bun_lookup_registry( .or_else(|| scopes.get(format!("@{scope}"))) }); if let Some(entry) = entry { - // A scope entry with no URL (a token only) takes the configured - // default registry, never the environment's. if let Some(scoped) = toml_url(Some(entry)) { - return Some(scoped); + return Some(with_npmrc_auth(scoped, toml_auth(entry))); } + // A scope entry with no URL (a token only) takes the configured + // default registry, never the environment's, with its own + // credentials. if entry.is_table_like() && entry.get("url").is_none() { - return configured(); + return configured().map(|r| match toml_auth(entry) { + Some(own) => ProjectRegistry { + authorization: Some(own), + ..r + }, + None => r, + }); + } + } + } + match env_registry.and_then(url) { + Some(base) => Some(with_npmrc_auth(base, None)), + None => configured(), + } +} + +/// `value` with each `${VAR}` (and, for a bunfig value, `$VAR`) replaced +/// by `var(VAR)`, empty when unset. +fn expand_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { + let is_name = |c: char| c.is_ascii_alphanumeric() || c == '_'; + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(at) = rest.find('$') { + out.push_str(&rest[..at]); + let after = &rest[at + 1..]; + if let Some(braced) = after.strip_prefix('{') { + if let Some(end) = braced.find('}') { + out.push_str(&var(&braced[..end]).unwrap_or_default()); + rest = &braced[end + 1..]; + continue; + } + } else if bare { + let end = after.find(|c| !is_name(c)).unwrap_or(after.len()); + if end > 0 { + out.push_str(&var(&after[..end]).unwrap_or_default()); + rest = &after[end..]; + continue; } } + out.push('$'); + rest = after; + } + out.push_str(rest); + out +} + +/// The `Authorization` an `.npmrc` configures for the registry at `base`: +/// the `//host[:port]/path/:`-keyed `_authToken` (Bearer), `_auth` (Basic) +/// or `username` + base64 `_password` (Basic) of the longest path that +/// covers `base`'s, on the same host. +fn npmrc_registry_auth(base: &str, value: &dyn Fn(&str) -> Option) -> Option { + let rest = base + .strip_prefix("https://") + .or_else(|| base.strip_prefix("http://"))?; + let rest = rest.split(['?', '#']).next().unwrap_or(rest); + let (host, path) = rest.split_once('/').unwrap_or((rest, "")); + let host = host.rsplit_once('@').map_or(host, |(_, h)| h); + if host.is_empty() { + return None; + } + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let non_empty = |v: Option| v.filter(|v| !v.is_empty()); + for depth in (0..=segments.len()).rev() { + let mut dart = format!("//{host}/"); + for segment in &segments[..depth] { + dart.push_str(segment); + dart.push('/'); + } + if let Some(token) = non_empty(value(&format!("{dart}:_authToken"))) { + return Some(format!("Bearer {token}")); + } + if let Some(auth) = non_empty(value(&format!("{dart}:_auth"))) { + return Some(format!("Basic {auth}")); + } + if let (Some(user), Some(password)) = ( + non_empty(value(&format!("{dart}:username"))), + non_empty(value(&format!("{dart}:_password"))), + ) { + use base64::Engine as _; + let engine = base64::engine::general_purpose::STANDARD; + let password = engine.decode(password.trim()).ok()?; + let password = String::from_utf8(password).ok()?; + return Some(format!( + "Basic {}", + engine.encode(format!("{user}:{password}")) + )); + } } - env_registry.and_then(url).or_else(configured) + None } /// The registry Bun takes from its environment: the first of @@ -1113,10 +1275,24 @@ impl BunRegistrySettings { /// The registry Bun resolves `name` against; `None` means npmjs. pub(super) fn registry(&self, name: &str) -> Option { + self.registry_with_credentials(name).map(|r| r.base) + } + + /// [`Self::registry`] with the credentials Bun sends it. + pub(super) fn registry_with_credentials(&self, name: &str) -> Option { + // Unit tests read no ambient variables, as for `env_registry`. + let var = |key: &str| { + if cfg!(test) { + None + } else { + std::env::var(key).ok() + } + }; bun_lookup_registry( self.npmrc.as_deref(), self.bunfig.as_deref(), self.env_registry.as_deref(), + &var, name, ) } @@ -1240,7 +1416,13 @@ pub(crate) async fn restore_bun_locks( // Bun records the tarball URL of a package from any registry but // npmjs, so the restore reads the project's registry settings. let settings = BunRegistrySettings::read(view, rel).await; - let dists = fetch_dists_on(&wanted, |n| settings.registry(n), ctx, &mut result).await; + let dists = fetch_dists_on( + &wanted, + |n| settings.registry_with_credentials(n), + ctx, + &mut result, + ) + .await; let mut changed = false; for (line_idx, uuid, name, version, deps) in hits { if result.refused.contains_key(&uuid) { @@ -1372,7 +1554,9 @@ mod tests { let bunfig = "[install]\nregistry = \"https://b.example/\"\n\n\ [install.scopes]\ns = \"https://s.example/\"\n\"@t\" = { url = \"https://t.example/\", token = \"x\" }\n"; let npmrc = "registry=https://n.example/\n@u:registry=https://u.example/\n"; - let lookup = |npmrc, bunfig, env, name| bun_lookup_registry(npmrc, bunfig, env, name); + let lookup = |npmrc, bunfig, env, name| { + bun_lookup_registry(npmrc, bunfig, env, &|_| None, name).map(|r| r.base) + }; assert_eq!( lookup(None, Some(bunfig), None, "a").as_deref(), Some("https://b.example/") @@ -1430,6 +1614,117 @@ mod tests { ); } + #[test] + fn bun_sends_the_credentials_its_settings_give_each_registry() { + let vars = |key: &str| match key { + "CORP_TOKEN" => Some("from-env".to_string()), + "NPMRC_TOKEN" => Some("npmrc-env".to_string()), + _ => None, + }; + let auth = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { + bun_lookup_registry(npmrc, bunfig, env, &vars, name).map(|r| (r.base, r.authorization)) + }; + let some = + |base: &str, auth: Option<&str>| Some((base.to_string(), auth.map(str::to_string))); + // The scope entry's own token, `$VAR` / `${VAR}` expanded; Basic + // from username + password. + let bunfig = "[install]\nregistry = { url = \"https://b.example/\", token = \"bt\" }\n\n\ + [install.scopes]\n\ + corp = { url = \"https://corp.example/npm/\", token = \"$CORP_TOKEN\" }\n\ + braced = { url = \"https://br.example/\", token = \"x${CORP_TOKEN}y\" }\n\ + basic = { url = \"https://ba.example/\", username = \"u\", password = \"p\" }\n\ + bare = \"https://bare.example/\"\n\ + own = { token = \"own\" }\n\ + unset = { url = \"https://un.example/\", token = \"$NOPE\" }\n"; + assert_eq!( + auth(None, Some(bunfig), None, "@corp/w"), + some("https://corp.example/npm/", Some("Bearer from-env")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@braced/w"), + some("https://br.example/", Some("Bearer xfrom-envy")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@basic/w"), + some("https://ba.example/", Some("Basic dTpw")) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@bare/w"), + some("https://bare.example/", None) + ); + assert_eq!( + auth(None, Some(bunfig), None, "@unset/w"), + some("https://un.example/", None) + ); + // A token-only scope: the configured default registry, its own token. + assert_eq!( + auth(None, Some(bunfig), Some("https://e.example/"), "@own/w"), + some("https://b.example/", Some("Bearer own")) + ); + // The default registry's table token; the environment's registry + // carries none of bunfig's. + assert_eq!( + auth(None, Some(bunfig), None, "a"), + some("https://b.example/", Some("Bearer bt")) + ); + assert_eq!( + auth(None, Some(bunfig), Some("https://e.example/"), "a"), + some("https://e.example/", None) + ); + + // `.npmrc` nerf-darted credentials: the longest covering path on + // the same host; never another host's. + let npmrc = "@corp:registry=https://corp.example/npm/private/\n\ + @other:registry=https://other.example/\n\ + @basic:registry=https://nb.example/\n\ + @legacy:registry=https://lg.example/r/\n\ + registry=https://n.example/\n\ + //corp.example/:_authToken=host-wide\n\ + //corp.example/npm/private/:_authToken=${NPMRC_TOKEN}\n\ + //n.example/:_authToken=default\n\ + //nb.example/:_auth=dTpw\n\ + //lg.example/r/:username=u\n//lg.example/r/:_password=cA==\n"; + assert_eq!( + auth(Some(npmrc), None, None, "@corp/w"), + some( + "https://corp.example/npm/private/", + Some("Bearer npmrc-env") + ) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@other/w"), + some("https://other.example/", None) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@basic/w"), + some("https://nb.example/", Some("Basic dTpw")) + ); + assert_eq!( + auth(Some(npmrc), None, None, "@legacy/w"), + some("https://lg.example/r/", Some("Basic dTpw")) + ); + assert_eq!( + auth(Some(npmrc), None, None, "a"), + some("https://n.example/", Some("Bearer default")) + ); + // A bunfig scope registry picks up the `.npmrc` credentials for + // its URL; its own token wins over them. + let corp_npmrc = "//corp.example/:_authToken=host-wide\n"; + assert_eq!( + auth( + Some(corp_npmrc), + Some("[install.scopes]\ncorp = \"https://corp.example/x/\"\n"), + None, + "@corp/w" + ), + some("https://corp.example/x/", Some("Bearer host-wide")) + ); + assert_eq!( + auth(Some(corp_npmrc), Some(bunfig), None, "@corp/w"), + some("https://corp.example/npm/", Some("Bearer from-env")) + ); + } + #[test] #[serial_test::serial] fn bun_writes_the_tarball_url_unless_it_is_on_npmjs() { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 592c20c8d..673352abf 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -168,7 +168,14 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. rolled back (v5.0 keeps no hosted ledger, and a rebuilt binary record is not byte-exact for every lock): rollback and remove refuse it with the `git checkout -- bun.lockb` remedy, while the hosted → vendored takeover rebuilds its npm registry record natively - and vendors over it. Bun's hoisted linker keeps an installed copy whose lock entry + and vendors over it. Each restore reads the package's version document from the + registry Bun resolves it against (`.npmrc` / `bunfig.toml` scope and default + registries, `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), sending the credentials + those settings give it — a bunfig `token` or `username` / `password` (`$VAR` + expanded), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + + `_password` covering the registry URL (`BUN_CONFIG_TOKEN` is not read); a registry + that still cannot be read falls back to the default registry's document with an + `upstream_registry_fallback` warning. Bun's hoisted linker keeps an installed copy whose lock entry returns to the registry record (a plain `bun install` reports no changes), so after `rollback`, `remove` or `vendor --revert` the patched bytes stay in `node_modules` until `bun install --force` (or deleting `node_modules`); `redirect_bun_reinstall_required` / From af97de7abc4825a35266699df2b0da08ae11d1d1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:56:49 -0400 Subject: [PATCH 33/55] Surface Bun's reinstall advisory from scan --prune reverts (#764) The #764 advisory (vendor_bun_reinstall_required) rides every reverting command's output except one: scan --prune's vendored GC called dispatch_revert_one and dropped the RevertOutcome's warnings. Its leg (a) reverts a `vendor`-tracked entry whose patch left the manifest, which puts the registry line back under the same hoisted node_modules copy, so Bun's plain `bun install` keeps the patched bytes and nothing said so (the vlt reinstall advisory was dropped the same way). VendorGcSummary now keeps the wet reverts' backend advisories and scan folds them into the additive gc.warnings[] (human: one `GC: .` line each), as it already does for failed rewrites. Also add the CLI-level coverage the advisory lacked, through the built binary: - vendor --revert --json of a bun.lock entry carries the per-entry event; - remove carries it for a vendored entry (manifest-backed and ledger-only) and carries redirect_bun_reinstall_required, naming only the restored package, for a hosted pin; - a non-migrated vendored bun.lockb revert carries exactly one advisory; - the pre-v5 path (ledger original = hosted line) restores upstream and advises once: the hosted unwind's run-level twin is deduplicated (verified the test fails with the dedupe disabled). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/commands/scan/gc.rs | 6 +- .../socket-patch-cli/src/commands/vendor.rs | 13 ++ .../tests/in_process_vendor_bun_takeover.rs | 179 ++++++++++++++++++ .../tests/vendor_eject_bun_lockb.rs | 22 +++ 5 files changed, 220 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index da28040d4..cce9be279 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -134,7 +134,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the vendored reverts' own backend advisories (e.g. `vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -1294,7 +1294,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | | `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | -| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]` | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | +| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` `gc.warnings[]` (human: `GC: …`) | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_artifact_gitignored` | `failed` | vendor (vlt and the npm-family tarball flavors: npm, pnpm, bun, yarn classic, yarn berry): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` or `vendor/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write. A file rule such as `*.tgz` is overridden by the `/.gitignore` vendoring writes; if the written artifact still reads as ignored, the run refuses and removes the uuid dir it created. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt and the npm-family tarball flavors): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index cd0bf9db5..9aa76a8a6 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -54,8 +54,9 @@ pub(super) struct GcSummary { /// finish (`cleanup_failed`: the pass aborted, or left orphans it could /// not unlink — the removed counts above are what it did reclaim). The /// mutations already happened on disk, so the stale record is reported, - /// not the pass failed. Serialized as additive `warnings[]` on the - /// apply shape only. + /// not the pass failed. Also the vendored reverts' backend advisories + /// (e.g. `vendor_bun_reinstall_required`). Serialized as additive + /// `warnings[]` on the apply shape only. warnings: Vec<(&'static str, String)>, } @@ -88,6 +89,7 @@ impl GcSummary { self.vendored_failed = v.failed; self.vendored_failed.sort(); self.warnings.extend(v.write_failures); + self.warnings.extend(v.advisories); self.vendor_orphan_dirs = v.orphan_dirs; } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index fa175dbd5..39a574f77 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -4183,6 +4183,17 @@ pub(crate) struct VendorGcSummary { /// "manifest_write_failed", )`. The reverts themselves already /// happened on disk; the stale record is what the caller must report. pub write_failures: Vec<(&'static str, String)>, + /// The wet reverts' backend advisories (`code`, `detail`), e.g. Bun's + /// `vendor_bun_reinstall_required` (#764): every other reverting + /// command surfaces these, and the GC must not drop them. + pub advisories: Vec<(&'static str, String)>, +} + +impl VendorGcSummary { + fn take_advisories(&mut self, outcome: &RevertOutcome) { + self.advisories + .extend(outcome.warnings.iter().map(|w| (w.code, w.detail.clone()))); + } } /// The vendored-state GC behind `scan --prune`: @@ -4248,6 +4259,7 @@ pub(crate) async fn run_vendor_gc( } let entry = state.entries.get(&purl).cloned().expect("listed above"); let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + out.take_advisories(&outcome); if !outcome.success { out.failed.push(purl); } else if outcome.kept_artifact { @@ -4284,6 +4296,7 @@ pub(crate) async fn run_vendor_gc( continue; } let outcome = dispatch_revert_one(&entry, &common.cwd, false).await; + out.take_advisories(&outcome); if !outcome.success { out.failed.push(purl); continue; diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index cce133678..28568979e 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -601,6 +601,9 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { "bun.lock must be restored byte-identical to the pristine registry lock; got:\n{}", read(root, "bun.lock") ); + // #764: the hoisted node_modules/left-pad keeps the vendored bytes + // through a plain `bun install`; the envelope names the forcing one. + assert_bun_reinstall_event(&env); assert!( !root.join(".socket/vendor").exists(), ".socket/vendor must be fully pruned after the revert" @@ -1054,6 +1057,16 @@ fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { "no top-level error expected: {env:#}" ); assert_only_left_pad_unwound(root, &pristine); + // #764: the restored pin's hoisted copy is kept by a plain `bun + // install`; the advisory names it, and not the still-hosted sibling. + let detail = run_warning(&env, "redirect_bun_reinstall_required") + .unwrap_or_else(|| panic!("remove must advise a forced reinstall: {env:#}")); + assert!( + detail.contains("left-pad@1.3.0") + && !detail.contains("other@") + && detail.contains("`bun install --force`"), + "{detail}" + ); } // ───────────────────────────────────────────────────────────────────── @@ -1295,3 +1308,169 @@ fn bun_vendor_over_hosted_v2_workspace_lock_still_takes_over() { ); assert!(lock.starts_with("{\n \"lockfileVersion\": 2,\n"), "{lock}"); } + +// ───────────────────────────────────────────────────────────────────── +// 6. Bun keeps the vendored copy after an unwind (#764) +// ───────────────────────────────────────────────────────────────────── +// Bun's hoisted linker does not re-extract a package whose lock entry moves +// back to the registry record of the same name@version, so every command +// that unwinds a vendored bun.lock entry must name `bun install --force`. + +/// The `detail` of the run-level `warnings[]` entry with `code`. +fn run_warning<'a>(env: &'a Value, code: &str) -> Option<&'a str> { + env["warnings"] + .as_array()? + .iter() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) +} + +/// The envelope carries the per-entry `vendor_bun_reinstall_required` +/// advisory for left-pad, naming the install that does reinstall. +fn assert_bun_reinstall_event(env: &Value) { + let event = find_event(env, "skipped", Some("vendor_bun_reinstall_required")); + assert_eq!(event["purl"], PURL, "{env:#}"); + let detail = event["reason"].as_str().unwrap_or_default(); + assert!( + detail.contains("left-pad@1.3.0") && detail.contains("`bun install --force`"), + "{env:#}" + ); +} + +/// A hoisted bun project with left-pad vendored over its (v5) hosted pin +/// by a plain `vendor` run; returns the pristine registry lock. +fn write_vendored_project(root: &Path) -> String { + let pristine = pristine_lock(); + write_bun_project(root, &pristine, &[(NAME, VERSION)]); + let hosted = pristine.replace( + LEFT_PAD_REGISTRY_LINE, + &hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), + ); + assert_ne!(hosted, pristine, "the hosted splice must hit"); + std::fs::write(root.join("bun.lock"), &hosted).unwrap(); + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root, &[]); + assert_eq!(code, 0, "vendor must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert_pure_vendored(root); + pristine +} + +/// `remove ` of a vendored bun.lock entry — manifest-backed and +/// ledger-only alike — restores the registry line and carries the +/// advisory in its envelope. +#[test] +fn bun_remove_of_a_vendored_entry_advises_a_forced_reinstall() { + for ledger_only in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + if ledger_only { + std::fs::remove_file(root.join(".socket/manifest.json")).unwrap(); + } + let (code, env) = run_json( + root, + &[ + "remove", + PURL, + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + ); + assert_eq!(code, 0, "ledger_only={ledger_only}: {env:#}"); + assert_eq!( + read(root, "bun.lock"), + pristine, + "ledger_only={ledger_only}" + ); + assert_bun_reinstall_event(&env); + } +} + +/// A pre-v5 ledger recorded the HOSTED line as left-pad's pre-vendor +/// original, so `vendor --revert` re-wires it to the patch server and then +/// restores its upstream registry entry. The vendored revert's per-entry +/// advisory already names left-pad; the hosted unwind's run-level twin for +/// the same package is dropped instead of repeating it. +#[test] +fn bun_pre_v5_revert_advises_a_forced_reinstall_once() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + let state_path = root.join(".socket/vendor/state.json"); + let mut state: Value = serde_json::from_str(&read(root, ".socket/vendor/state.json")).unwrap(); + let wiring = state["entries"][PURL]["wiring"].as_array_mut().unwrap(); + let record = wiring + .iter_mut() + .find(|w| w["kind"] == "bun_lock_package") + .unwrap(); + record["original"] = json!(hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512)); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + + let (code, env) = vendor_cli(root, &["--revert"]); + assert_eq!(code, 0, "revert must succeed: {env:#}"); + find_event(&env, "skipped", Some("vendor_revert_restored_upstream")); + assert_eq!( + read(root, "bun.lock"), + pristine, + "back to the registry line" + ); + assert_bun_reinstall_event(&env); + assert_eq!( + events(&env) + .iter() + .filter(|e| e["errorCode"] == "vendor_bun_reinstall_required") + .count(), + 1, + "{env:#}" + ); + assert!( + run_warning(&env, "redirect_bun_reinstall_required").is_none(), + "the run-level twin repeats the per-entry advisory: {env:#}" + ); +} + +/// `scan --prune` reverts a `vendor`-tracked entry whose patch left the +/// manifest (the GC's leg (a)). That revert restores left-pad's registry +/// line under the same hoisted copy, so the `gc` sub-object carries the +/// advisory too. +#[tokio::test(flavor = "multi_thread")] +async fn bun_scan_prune_revert_advises_a_forced_reinstall() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let pristine = write_vendored_project(root); + std::fs::write(root.join(".socket/manifest.json"), "{\"patches\": {}}\n").unwrap(); + + let (code, env) = scan_mode(root, &server.uri(), "vendored", &["--prune"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!( + env["gc"]["revertedVendoredEntries"], + json!([PURL]), + "{env:#}" + ); + assert_eq!( + read(root, "bun.lock"), + pristine, + "back to the registry line" + ); + let advised = env["gc"]["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "vendor_bun_reinstall_required" + && w["detail"].as_str().is_some_and(|d| { + d.contains("left-pad@1.3.0") && d.contains("`bun install --force`") + }) + }) + }); + assert!(advised, "{env:#}"); +} diff --git a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs index cb76c97a1..27bad0a4f 100644 --- a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs @@ -311,6 +311,7 @@ async fn takeover_vendors_over_a_hosted_bun_lockb_and_reverts_exactly() { p.lock() == p.pristine, "{writer}: the revert restores the pre-hosted bun.lockb byte for byte" ); + assert_reinstall_advised(&env); } } @@ -326,6 +327,27 @@ async fn eject_vendors_a_hosted_bun_lockb_and_reverts_exactly() { let (code, env) = p.run_json(&["vendor", "--revert"]); assert_eq!(code, 0, "revert: {env:#}"); assert!(p.lock() == p.pristine, "exact pre-hosted bytes"); + assert_reinstall_advised(&env); +} + +/// #764: the revert put minimist's registry record back while the hoisted +/// `node_modules/minimist` still holds the vendored bytes, which a plain +/// `bun install` keeps: the envelope carries exactly one per-entry advisory +/// naming `bun install --force`. +fn assert_reinstall_advised(env: &Value) { + let advisories: Vec<&Value> = env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["errorCode"] == "vendor_bun_reinstall_required") + .collect(); + assert_eq!(advisories.len(), 1, "{env:#}"); + assert_eq!(advisories[0]["purl"], PURL, "{env:#}"); + let detail = advisories[0]["reason"].as_str().unwrap_or_default(); + assert!( + detail.contains("minimist@1.2.2") && detail.contains("`bun install --force`"), + "{env:#}" + ); } /// A hosted lock whose workspace dependency behaviors the rewrite had to From f481803c80316f60766846ce06f6584efafd2ffe Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 14:02:19 -0400 Subject: [PATCH 34/55] Forward only reinstall advisories into scan --prune gc.warnings (#764) d60dd80d copied every RevertOutcome warning from the vendored GC's reverts into gc.warnings[]. The commonest leg-(b) prune (the dependency was uninstalled) warns vendor_lock_entry_removed, so a normal successful prune printed a 'GC: lock entry ... no longer exists' line and gained a gc.warnings key; a drift-kept revert repeated vendor_lock_entry_drifted and vendor_artifact_kept beside the existing keptVendoredEntries and 'GC: kept N drifted vendored entries' line; and failed reverts leaked their warnings too. take_advisories now keeps only vendor_bun_reinstall_required and vendor_vlt_reinstall_required, and only from a revert that succeeded and was not drift-kept (the only case that can leave a stale installed copy). The leg-(b) e2e and the drift-kept unit test now assert gc.warnings is absent; CLI_CONTRACT names only the reinstall advisories. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-cli/src/commands/scan/gc.rs | 13 ++++++-- .../socket-patch-cli/src/commands/vendor.rs | 33 ++++++++++++++++--- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 7 ++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index cce9be279..8ec70f229 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -134,7 +134,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the vendored reverts' own backend advisories (e.g. `vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated here). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 9aa76a8a6..da9dea1e8 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -54,8 +54,9 @@ pub(super) struct GcSummary { /// finish (`cleanup_failed`: the pass aborted, or left orphans it could /// not unlink — the removed counts above are what it did reclaim). The /// mutations already happened on disk, so the stale record is reported, - /// not the pass failed. Also the vendored reverts' backend advisories - /// (e.g. `vendor_bun_reinstall_required`). Serialized as additive + /// not the pass failed. Also the vendored reverts' reinstall advisories + /// (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`) + /// and no other revert warning. Serialized as additive /// `warnings[]` on the apply shape only. warnings: Vec<(&'static str, String)>, } @@ -1466,6 +1467,14 @@ mod tests { serde_json::json!([PURL]), "scan --prune --json must carry the keep" ); + // The revert's own drift warnings (`vendor_lock_entry_drifted`, + // `vendor_artifact_kept`) are already said by the keep above; they + // must not also land in `gc.warnings[]`. + assert!( + gc.to_apply_json().get("warnings").is_none(), + "a drift keep adds no gc warning: {}", + gc.to_apply_json() + ); // Nothing reclaimed: manifest record, blob, ledger entry, and // artifacts all survive (the drift-keep contract). assert_eq!(gc.blobs.blobs_removed, 0, "kept entry's blob is not swept"); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 39a574f77..1c20b0bcc 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -4183,16 +4183,39 @@ pub(crate) struct VendorGcSummary { /// "manifest_write_failed", )`. The reverts themselves already /// happened on disk; the stale record is what the caller must report. pub write_failures: Vec<(&'static str, String)>, - /// The wet reverts' backend advisories (`code`, `detail`), e.g. Bun's - /// `vendor_bun_reinstall_required` (#764): every other reverting - /// command surfaces these, and the GC must not drop them. + /// The wet reverts' reinstall advisories (`code`, `detail`): Bun's + /// `vendor_bun_reinstall_required` (#764) and vlt's + /// `vendor_vlt_reinstall_required`. Every other reverting command + /// surfaces these, and the GC must not drop them. pub advisories: Vec<(&'static str, String)>, } +/// The revert warnings `scan --prune` forwards into `gc.warnings[]`: only +/// the "the installed tree still holds the vendored copy" advisories. The +/// revert's other warnings are routine for a prune and stay out: +/// `vendor_lock_entry_removed` is the normal leg-(b) case (the dependency +/// was uninstalled), and a drift keep is already reported through +/// `keptVendoredEntries` and its own `GC: kept …` line. +const GC_FORWARDED_ADVISORIES: &[&str] = &[ + socket_patch_core::vendor::bun_lock::REINSTALL_REQUIRED, + socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED, +]; + impl VendorGcSummary { + /// Keep a revert's reinstall advisories. Only a revert that actually + /// restored the lock (succeeded, not drift-kept) can leave a stale + /// installed copy behind. fn take_advisories(&mut self, outcome: &RevertOutcome) { - self.advisories - .extend(outcome.warnings.iter().map(|w| (w.code, w.detail.clone()))); + if !outcome.success || outcome.kept_artifact { + return; + } + self.advisories.extend( + outcome + .warnings + .iter() + .filter(|w| GC_FORWARDED_ADVISORIES.contains(&w.code)) + .map(|w| (w.code, w.detail.clone())), + ); } } diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 2f0208055..5fe8ecee3 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1114,6 +1114,13 @@ async fn scan_prune_reverts_unused_vendored_entry() { serde_json::json!([]), "nothing resolves through the artifact, so nothing is kept: {v}" ); + // The revert warns `vendor_lock_entry_removed` (nothing to restore): + // routine for a prune of an uninstalled dependency, so it is not a + // gc warning. + assert!( + v["gc"].get("warnings").is_none(), + "a routine prune adds no gc warning: {v}" + ); // Ledger empty (an emptied state file is removed outright), artifact // gone. From d3f44d0c7db3021d899d364892d01da3b65125e4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:57:02 -0400 Subject: [PATCH 35/55] Judge orphaned Bun global store links by reachability (#599) The #599 orphan filter gave up (kept every .bun entry) whenever any entry linked into Bun's global store (#635), because the shared /links entries link their dependencies to sibling cache dirs, never back into the project's .bun. Bun prunes neither layout, so after an in-place `bun install` that re-resolves a package (a hosted or vendored rewire, a version bump) the old `.bun/@` link stays behind with nothing reaching it, and for a globalStore project vex again refused hosted patches as not_applied and raised false vendored_tree_out_of_sync warnings. The walk now judges liveness the way Bun's resolution does, whatever the store location: it maps each global store entry's real cache dir back to the .bun link naming it and follows the cache entries' own dependency links, so an entry is live exactly when some link chain from the importers, the hoist dir or a workspace member reaches it. A link into a cache entry this project does not link leaves reachability unknown, and then nothing is dropped. vex's copy filter also maps a copy whose real path lies in the cache back to the .bun entry its path names, so the orphan is dropped there too. When the root package.json workspaces cannot be read (unparseable, an odd shape, a ** walk past its budget), a bun.lock whose workspaces section parses now stands in as the member set (Bun rewrites it on every install) instead of keeping every entry; with no such lock nothing is dropped, as before. Verified with real Bun 1.3.14 and 1.4.2 (globalStore = true, is-number and left-pad rewired to tarball URLs in place, which re-points .bun/is-odd at a new cache dir and leaves the registry links behind): vex exits 1 (not_applied x2) before, 0 (verified x2) after, and an unpatched live copy is still not_applied. Tests: unit tests for the real global layout (a transitive version reached only through a cache link stays live, the orphan is dropped by the scan and the vex filter), an unknown cache entry, and lock members standing in for package.json; the hosted vex e2e now also runs the global layout, and a new vendored e2e covers the false vendored_tree_out_of_sync for both layouts with an unpatched-live-copy control. The #635 transitive-packages fixture now links its scoped package from the hoist dir, as Bun does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vex_redirect.rs | 161 ++++--- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 122 +++++ .../src/crawlers/npm_crawler.rs | 456 +++++++++++++----- 3 files changed, 565 insertions(+), 174 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 2fb430209..c22287262 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -2299,6 +2299,12 @@ fn bun_hosted_ref_is_judged_by_a_global_store_copy() { /// (real Bun 1.3.14 / 1.4.2 layout). Nothing can load the orphan, so it is /// no installed copy: the patched live copy attests, where vex used to /// refuse the patch as `not_applied` until `rm -rf node_modules`. +/// +/// With Bun's global store (#635, `globalStore = true`) every `.bun` entry +/// is instead an absolute link into `/links/-`, and +/// the dependency entry's cache dir links left-pad at its sibling cache +/// dir, never back into `.bun`; Bun leaves the orphaned link behind just +/// the same, and it is no copy either. #[cfg(unix)] #[test] fn bun_hosted_ref_ignores_orphaned_registry_store_entry() { @@ -2308,76 +2314,99 @@ fn bun_hosted_ref_ignores_orphaned_registry_store_entry() { ); let purl = "pkg:npm/left-pad@1.3.0"; let url = hosted_npm_url("left-pad", "1.3.0", UUID); - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - put( - cwd, - "package.json", - br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, - ); - put( - cwd, - "bun.lock", - format!( - "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ - \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ - }},\n }},\n }},\n \"packages\": {{\n \ - \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ - \"sha512-{dep}==\"],\n\n \ - \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", - dep = "D".repeat(86), - ) - .as_bytes(), - ); - let live_entry = format!("left-pad@{}", url.replace([':', '/'], "+")); - for (entry, bytes) in [("left-pad@1.3.0", pristine), (live_entry.as_str(), patched)] { - let store = format!("node_modules/.bun/{entry}/node_modules/left-pad"); + for global in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = &tmp.path().join("app"); put( cwd, - &format!("{store}/package.json"), - br#"{ "name": "left-pad", "version": "1.3.0" }"#, + "package.json", + br#"{ "name": "app", "version": "1.0.0", "dependencies": { "dep": "1.0.0" } }"#, ); - put(cwd, &format!("{store}/index.js"), bytes); - } - put( - cwd, - "node_modules/.bun/dep@1.0.0/node_modules/dep/package.json", - br#"{ "name": "dep", "version": "1.0.0" }"#, - ); - let link = |target: String, at: &str| { - let at = cwd.join(at); - std::fs::create_dir_all(at.parent().unwrap()).unwrap(); - std::os::unix::fs::symlink(target, at).unwrap(); - }; - link( - format!("../../{live_entry}/node_modules/left-pad"), - "node_modules/.bun/dep@1.0.0/node_modules/left-pad", - ); - link( - format!("../{live_entry}/node_modules/left-pad"), - "node_modules/.bun/node_modules/left-pad", - ); - link( - "../dep@1.0.0/node_modules/dep".to_string(), - "node_modules/.bun/node_modules/dep", - ); - link( - ".bun/dep@1.0.0/node_modules/dep".to_string(), - "node_modules/dep", - ); - let (_rt, server) = serve_patch_views(vec![( - UUID.to_string(), - one_file_view(UUID, purl, "package/index.js", pristine, patched), - )]); + put( + cwd, + "bun.lock", + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \ + \"name\": \"app\",\n \"dependencies\": {{\n \"dep\": \"1.0.0\",\n \ + }},\n }},\n }},\n \"packages\": {{\n \ + \"dep\": [\"dep@1.0.0\", \"\", {{ \"dependencies\": {{ \"left-pad\": \"1.3.0\" }} }}, \ + \"sha512-{dep}==\"],\n\n \ + \"left-pad\": [\"left-pad@{url}\", {{}}, \"{SRI}\"],\n }}\n}}\n", + dep = "D".repeat(86), + ) + .as_bytes(), + ); + let live_entry = format!("left-pad@{}", url.replace([':', '/'], "+")); + // Where each `.bun` entry's files live: the store itself, or a + // global store cache dir the entry links to. + let entry_dir = |entry: &str, hash: &str| { + if global { + let shared = tmp.path().join(format!("bun-cache/links/{entry}-{hash}")); + std::fs::create_dir_all(&shared).unwrap(); + std::fs::create_dir_all(cwd.join("node_modules/.bun")).unwrap(); + std::os::unix::fs::symlink(&shared, cwd.join("node_modules/.bun").join(entry)) + .unwrap(); + shared + } else { + cwd.join("node_modules/.bun").join(entry) + } + }; + for (entry, hash, bytes) in [ + ("left-pad@1.3.0", "6a490709ba3c5c8f", pristine), + (live_entry.as_str(), "70aa8f1dde99846b", patched), + ] { + let dir = entry_dir(entry, hash); + put( + &dir, + "node_modules/left-pad/package.json", + br#"{ "name": "left-pad", "version": "1.3.0" }"#, + ); + put(&dir, "node_modules/left-pad/index.js", bytes); + } + let dep = entry_dir("dep@1.0.0", "59cbc6610791e74a"); + put( + &dep, + "node_modules/dep/package.json", + br#"{ "name": "dep", "version": "1.0.0" }"#, + ); + let link = |target: String, at: &Path| { + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(target, at).unwrap(); + }; + link( + if global { + format!("../../{live_entry}-70aa8f1dde99846b/node_modules/left-pad") + } else { + format!("../../{live_entry}/node_modules/left-pad") + }, + &dep.join("node_modules/left-pad"), + ); + link( + format!("../{live_entry}/node_modules/left-pad"), + &cwd.join("node_modules/.bun/node_modules/left-pad"), + ); + link( + "../dep@1.0.0/node_modules/dep".to_string(), + &cwd.join("node_modules/.bun/node_modules/dep"), + ); + link( + ".bun/dep@1.0.0/node_modules/dep".to_string(), + &cwd.join("node_modules/dep"), + ); + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + one_file_view(UUID, purl, "package/index.js", pristine, patched), + )]); - let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); - assert_attested( - cwd, - code, - &env, - UUID, - "the orphaned registry entry is no copy", - ); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_attested( + cwd, + code, + &env, + UUID, + &format!("global store {global}: the orphaned registry entry is no copy"), + ); + } } /// The patch view for `name@version` (the [`left_pad_view`] shape). diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 0e5f46792..023c8fcc1 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -1343,6 +1343,128 @@ fn vendored_live_tree_out_of_sync_warns_but_attests() { ); } +/// REGRESSION (#599, with #635): Bun never prunes `node_modules/.bun`. +/// After the in-place `bun install` that consumes a vendored tarball, the +/// pre-vendor `lodash@4.17.21` registry entry stays on disk, pristine, while +/// the importer and the `.bun/node_modules` hoist link now point at the +/// tarball's entry (`lodash@.socket+vendor+npm++lodash-4.17.21.tgz`, +/// the name real Bun 1.4.2 gives a `file:` tarball). Nothing can load the +/// orphan, so it is no installed copy: no `vendored_tree_out_of_sync` +/// warning, which re-running the install could never clear. Both the +/// project-local store and the global store (`globalStore = true`: every +/// `.bun` entry an absolute link into `/links/-`) are +/// covered, each with a control whose live copy is unpatched and must +/// still warn. +#[cfg(unix)] +#[test] +fn vendored_bun_orphaned_store_entry_is_not_out_of_sync() { + let purl = "pkg:npm/lodash@4.17.21"; + let uuid = "0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a"; + let patched = b"patched npm bytes\n"; + let pristine = b"original unpatched bytes\n"; + for (global, live_patched) in [(false, true), (false, false), (true, true), (true, false)] { + let label = format!("global store {global}, live copy patched {live_patched}"); + let tmp = tempfile::tempdir().expect("create tempdir"); + let cwd = &tmp.path().join("app"); + std::fs::create_dir_all(cwd).unwrap(); + + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = format!(".socket/vendor/npm/{uuid}/lodash-4.17.21.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + let record = make_record( + uuid, + "package/index.js", + &after_hash, + "GHSA-sync-aaaa", + &["CVE-2026-10"], + ); + let wiring = write_matrix_wiring(cwd, "npm", uuid, &rel); + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + detached_matrix_entry("npm", purl, uuid, &rel, sha256, record, wiring), + ); + std::fs::write( + cwd.join(".socket/vendor/state.json"), + serde_json::to_string_pretty(&state).expect("serialize vendor state"), + ) + .expect("write vendor state.json"); + + let store = cwd.join("node_modules/.bun"); + let live_entry = format!("lodash@.socket+vendor+npm+{uuid}+lodash-4.17.21.tgz"); + for (entry, hash, bytes) in [ + ("lodash@4.17.21", "6a490709ba3c5c8f", &pristine[..]), + ( + live_entry.as_str(), + "2fdd36c28041169b", + if live_patched { + &patched[..] + } else { + &pristine[..] + }, + ), + ] { + let dir = if global { + tmp.path().join(format!("bun-cache/links/{entry}-{hash}")) + } else { + store.join(entry) + }; + let pkg = dir.join("node_modules/lodash"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{"name":"lodash","version":"4.17.21"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), bytes).unwrap(); + if global { + std::fs::create_dir_all(&store).unwrap(); + std::os::unix::fs::symlink(&dir, store.join(entry)).unwrap(); + } + } + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::os::unix::fs::symlink( + format!("../{live_entry}/node_modules/lodash"), + store.join("node_modules/lodash"), + ) + .unwrap(); + std::os::unix::fs::symlink( + format!(".bun/{live_entry}/node_modules/lodash"), + cwd.join("node_modules/lodash"), + ) + .unwrap(); + + let vex_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + assert!(out.status.success(), "{label}: {env}"); + assert_eq!(env["status"], "success", "{label}: {env}"); + let out_of_sync = env["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + w["code"] == "vendored_tree_out_of_sync" + && w["detail"].as_str().is_some_and(|d| d.contains(purl)) + }) + }); + assert_eq!(out_of_sync, !live_patched, "{label}: {env}"); + } +} + /// REGRESSION (#325): the lock rewires the hoisted `lodash@4.17.21` to the /// vendored tarball, but a parent package also BUNDLES `lodash@4.17.21` /// (`inBundle: true`). npm unpacks that copy from the parent's tarball, so diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index c34f22c32..da6b4ff17 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -627,22 +627,22 @@ pub fn bun_uses_global_store(project_root: &Path) -> bool { /// alias link can defeat one (`lp` linking `left-pad@…` while an `lp@…` /// entry exists), so when the quick walk leaves any entry unreached, the /// store is walked again reading every link before anything is dropped. +/// +/// With Bun's global store (#635) the entries are links into the shared +/// `/links`, whose entries link their dependencies to one another +/// there, never back into this `.bun`: the walk follows them through the +/// cache, mapping each cache entry back to the `.bun` link naming it (see +/// [`BunStoreDirs`]). Bun leaves an orphaned link behind exactly as it +/// leaves an orphaned dir, and judges liveness the same way. fn live_bun_store_entries_sync( store_path: &Path, candidates: &[ListedEntry], ) -> Option<(HashSet, HashMap)> { - let real_store = std::fs::canonicalize(store_path).ok()?; - let importer = store_path.parent()?; - // A global store entry (#635) is a link into the shared - // `/links`, whose entries link one another there, never back - // into this `.bun`, so the walk cannot see what reaches them. - if candidates.is_empty() - || candidates - .iter() - .any(|e| e.file_type.is_some_and(|ft| ft.is_symlink())) - { + if candidates.is_empty() { return None; } + let dirs = BunStoreDirs::new(store_path, candidates)?; + let importer = store_path.parent()?; let root = match importer.parent() { Some(root) if !root.as_os_str().is_empty() => root, _ => Path::new("."), @@ -655,12 +655,12 @@ fn live_bun_store_entries_sync( let mut live: HashSet = HashSet::new(); let mut listings = HashMap::new(); let seeds = [store_path.join("node_modules"), importer.to_path_buf()]; - reach_bun_store_entries_sync(&seeds, &real_store, unique, &mut live, &mut listings); + reach_bun_store_entries_sync(&seeds, &dirs, unique, &mut live, &mut listings)?; if unreached(&live) { let members = members .get_or_insert_with(|| bun_workspace_member_node_modules_sync(root)) .as_deref()?; - reach_bun_store_entries_sync(members, &real_store, unique, &mut live, &mut listings); + reach_bun_store_entries_sync(members, &dirs, unique, &mut live, &mut listings)?; } (!live.is_empty()).then_some((live, listings)) }; @@ -676,51 +676,40 @@ fn live_bun_store_entries_sync( /// every dir the root `package.json` `workspaces` patterns match (the only /// source for a binary `bun.lockb`). A pattern's `*` and `?` match any /// name, dot-names included, and `!` exclusions are ignored: an extra -/// member can only keep an entry, never drop one. `None` when the member -/// set cannot be known (an unreadable or unparseable `package.json`, a -/// `workspaces` field of another shape, a `**` walk past its budget), so -/// the caller keeps every entry. +/// member can only keep an entry, never drop one. When the patterns cannot +/// be read (an unreadable or unparseable `package.json`, a `workspaces` +/// field of another shape, a `**` walk past its budget), a `bun.lock` +/// whose `workspaces` section parses is the member set on its own: Bun +/// rewrites it on every install, so it names every member the install +/// linked. Without one the member set cannot be known: `None`, and the +/// caller keeps every entry. fn bun_workspace_member_node_modules_sync(root: &Path) -> Option> { use crate::vendor::bun_lock_text::{is_plain_member_dir, workspace_member_dirs}; - let mut members: Vec = Vec::new(); - match crate::utils::fs::read_regular_to_string_sync(&root.join("bun.lock")) { - Ok(text) => { - let lines: Vec = text.lines().map(str::to_string).collect(); - members.extend( - workspace_member_dirs(&lines) - .into_iter() - .filter(|dir| !dir.is_empty() && is_plain_member_dir(dir)) - .map(|dir| root.join(dir)), - ); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return None, - } - match crate::utils::fs::read_regular_to_string_sync(&root.join("package.json")) { - Ok(text) => { - let text = text.strip_prefix('\u{feff}').unwrap_or(&text); - let doc: serde_json::Value = serde_json::from_str(text).ok()?; - let patterns = match doc.get("workspaces") { - None | Some(serde_json::Value::Null) => Vec::new(), - Some(serde_json::Value::Array(list)) => list.clone(), - Some(serde_json::Value::Object(map)) => match map.get("packages") { - None => Vec::new(), - Some(packages) => packages.as_array()?.clone(), - }, - Some(_) => return None, - }; - for pattern in &patterns { - let pattern = pattern.as_str()?; - if pattern.starts_with('!') { - continue; - } - members.extend(expand_workspace_pattern_sync(root, pattern)?); + // `Some` once the lock's `workspaces` section parsed (it always lists + // the root, `""`). + let locked: Option> = + match crate::utils::fs::read_regular_to_string_sync(&root.join("bun.lock")) { + Ok(text) => { + let lines: Vec = text.lines().map(str::to_string).collect(); + let dirs = workspace_member_dirs(&lines); + (!dirs.is_empty()).then(|| { + dirs.into_iter() + .filter(|dir| !dir.is_empty() && is_plain_member_dir(dir)) + .map(|dir| root.join(dir)) + .collect() + }) } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return None, + }; + let members = match bun_workspace_pattern_members_sync(root) { + Some(mut members) => { + members.extend(locked.into_iter().flatten()); + members } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return None, - } + None => locked?, + }; let mut seen = HashSet::new(); Some( members @@ -731,6 +720,37 @@ fn bun_workspace_member_node_modules_sync(root: &Path) -> Option> { ) } +/// The dirs the root `package.json` `workspaces` patterns match (see +/// [`bun_workspace_member_node_modules_sync`]); `None` when they cannot be +/// read. No `package.json` has no members. +fn bun_workspace_pattern_members_sync(root: &Path) -> Option> { + let text = match crate::utils::fs::read_regular_to_string_sync(&root.join("package.json")) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Some(Vec::new()), + Err(_) => return None, + }; + let text = text.strip_prefix('\u{feff}').unwrap_or(&text); + let doc: serde_json::Value = serde_json::from_str(text).ok()?; + let patterns = match doc.get("workspaces") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(serde_json::Value::Array(list)) => list.clone(), + Some(serde_json::Value::Object(map)) => match map.get("packages") { + None => Vec::new(), + Some(packages) => packages.as_array()?.clone(), + }, + Some(_) => return None, + }; + let mut members = Vec::new(); + for pattern in &patterns { + let pattern = pattern.as_str()?; + if pattern.starts_with('!') { + continue; + } + members.extend(expand_workspace_pattern_sync(root, pattern)?); + } + Some(members) +} + /// The dirs below `root` a `workspaces` glob matches (`/`-separated; `*` /// and `?` within one component, `**` any number of them), never inside a /// `node_modules` and never through a link for `**`. `None` when a `**` @@ -788,7 +808,9 @@ const WORKSPACE_GLOB_DIR_BUDGET: usize = 20_000; /// Paths among `paths` that are copies inside an orphaned `.bun` store /// entry (see [`live_bun_store_entries_sync`]), judged by where each -/// canonicalizes; each store is walked once. +/// canonicalizes, or, when that is out of every store (a global store +/// entry's shared dir, #635), by the `.bun` entry the path itself names; +/// each store is walked once. fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { let mut stores: HashMap>> = HashMap::new(); let mut orphans = HashSet::new(); @@ -796,7 +818,10 @@ fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { let Ok(real) = std::fs::canonicalize(path) else { continue; }; - let Some((store, entry)) = bun_store_entry_of(&real) else { + let Some((store, entry)) = bun_store_entry_of(&real).or_else(|| { + let (store, entry) = bun_store_entry_of(&normalize_lexically(path))?; + Some((std::fs::canonicalize(store).ok()?, entry)) + }) else { continue; }; let live = stores.entry(store).or_insert_with_key(|store| { @@ -914,14 +939,16 @@ fn bun_store_entry_package(entry_name: &str) -> Option { /// the `seeds` dirs, following each reached entry's own `node_modules` /// links, and record each entry's listing in `listings`. Read one /// frontier at a time, each frontier's dirs in parallel. With `names`, -/// targets are guessed by name where they can be. +/// targets are guessed by name where they can be. `None` when a link +/// reaches a global store entry this store does not link (see +/// [`BunStoreDirs::entry_of`]): what lies past it cannot be told. fn reach_bun_store_entries_sync( seeds: &[PathBuf], - real_store: &Path, + dirs: &BunStoreDirs, names: Option<&BunStoreNames>, live: &mut HashSet, listings: &mut HashMap, -) { +) -> Option<()> { let mut frontier: Vec<(Option, PathBuf)> = seeds .iter() .filter_map(|nm| Some((None, std::fs::canonicalize(nm).ok()?))) @@ -930,10 +957,10 @@ fn reach_bun_store_entries_sync( let visited = par_map(frontier, |(entry, nm)| { let listing = read_dir_entries_sync(&nm) .map(|(entries, complete)| Listing::from_entries(entries, complete)); - let targets = listing - .as_ref() - .map(|listing| bun_store_link_targets_sync(&nm, listing, real_store, names)) - .unwrap_or_default(); + let targets = match &listing { + Some(listing) => bun_store_link_targets_sync(&nm, listing, dirs, names), + None => Some(Vec::new()), + }; (entry, listing, targets) }); frontier = Vec::new(); @@ -941,14 +968,90 @@ fn reach_bun_store_entries_sync( if let (Some(entry), Some(listing)) = (entry, listing) { listings.insert(entry, listing); } - for target in targets { + for target in targets? { if live.insert(target.clone()) { - let nm = real_store.join(&target).join("node_modules"); + let nm = dirs.entry_node_modules(&target); frontier.push((Some(target), nm)); } } } } + Some(()) +} + +/// Where a `.bun` store's entries really live, for the orphan walk: in the +/// (canonical) store dir itself, or, for each entry that is a link into +/// Bun's global store (#635), in its shared `/links/-` +/// dir, whose dependency links point at sibling cache dirs. +struct BunStoreDirs { + real_store: PathBuf, + /// The real dir of each global store entry, and the entry linking it. + global: HashMap, + /// The same, by entry. + global_dirs: HashMap, + /// The `links` dirs those real dirs sit in. + global_links: HashSet, +} + +impl BunStoreDirs { + /// `None` when the store or one of its global store links does not + /// resolve. + fn new(store_path: &Path, candidates: &[ListedEntry]) -> Option { + let real_store = std::fs::canonicalize(store_path).ok()?; + let mut global = HashMap::new(); + let mut global_dirs = HashMap::new(); + let mut global_links = HashSet::new(); + for entry in candidates { + if entry.file_type.is_some_and(|ft| ft.is_symlink()) { + let real = std::fs::canonicalize(store_path.join(&entry.name)).ok()?; + global_links.insert(real.parent()?.to_path_buf()); + global.insert(real.clone(), entry.name.clone()); + global_dirs.insert(entry.name.clone(), real); + } + } + Some(Self { + real_store, + global, + global_dirs, + global_links, + }) + } + + /// The real `node_modules` dir of the entry named `entry`. + fn entry_node_modules(&self, entry: &OsStr) -> PathBuf { + match self.global_dirs.get(entry) { + Some(dir) => dir.join("node_modules"), + None => self.real_store.join(entry).join("node_modules"), + } + } + + /// The entry a (lexical or real) path lies in: `Some(Some(name))` in + /// this store or one of its global store entries, `Some(None)` in some + /// other entry of the same global store (not linked from this store, + /// so its reach is unknown), `None` anywhere else. + fn entry_of(&self, path: &Path) -> Option> { + if let Ok(below) = path.strip_prefix(&self.real_store) { + return match below.components().next() { + Some(std::path::Component::Normal(name)) => Some(Some(name.to_os_string())), + _ => None, + }; + } + if self.global.is_empty() { + return None; + } + for dir in path.ancestors() { + if let Some(entry) = self.global.get(dir) { + return Some(Some(entry.clone())); + } + if dir + .parent() + .is_some_and(|links| self.global_links.contains(links)) + { + return Some(None); + } + } + None + } } /// The `.bun` entries the package links in `listing` (of the real dir @@ -957,14 +1060,15 @@ fn reach_bun_store_entries_sync( /// other link is read and /// resolved lexically first (Bun writes relative targets, and `nm` is /// real, so each `..` climbs a real dir), and one that lands outside the -/// store that way (an absolute Windows junction, a linked `node_modules`) -/// is canonicalized instead. +/// store's entries that way (an absolute Windows junction, a linked +/// `node_modules`) is canonicalized instead. `None` when a link reaches +/// an unknown global store entry (see [`BunStoreDirs::entry_of`]). fn bun_store_link_targets_sync( nm: &Path, listing: &Listing, - real_store: &Path, + dirs: &BunStoreDirs, names: Option<&BunStoreNames>, -) -> Vec { +) -> Option> { let mut targets = Vec::new(); // (link, the package its name spells) let mut links: Vec<(PathBuf, String)> = Vec::new(); @@ -1003,20 +1107,25 @@ fn bun_store_link_targets_sync( }, ) .collect(); - let entry_of = |path: &Path| match path.strip_prefix(real_store).ok()?.components().next() { - Some(std::path::Component::Normal(name)) => Some(name.to_os_string()), - _ => None, - }; - targets.extend(links.iter().filter_map(|link| { + for link in &links { let lexical = std::fs::read_link(link) .ok() - .and_then(|target| Some(normalize_lexically(&link.parent()?.join(target)))); - lexical - .as_deref() - .and_then(entry_of) - .or_else(|| entry_of(&std::fs::canonicalize(link).ok()?)) - })); - targets + .and_then(|target| Some(normalize_lexically(&link.parent()?.join(target)))) + .and_then(|path| dirs.entry_of(&path)); + if let Some(Some(entry)) = lexical { + targets.push(entry); + continue; + } + let real = std::fs::canonicalize(link) + .ok() + .and_then(|path| dirs.entry_of(&path)); + match real.or(lexical) { + Some(Some(entry)) => targets.push(entry), + Some(None) => return None, + None => {} + } + } + Some(targets) } /// The `node_modules` child that is npm's `install-strategy=linked` store, @@ -6242,6 +6351,11 @@ mod tests { let frame = link_entry("@babel+code-frame@7.0.0", "@babel/code-frame", "7.0.0"); link_dir(&number, &odd.parent().unwrap().join("is-number")); link_dir(&odd, &nm.join("is-odd")); + // Bun's hoist dir links every package (#599: an entry nothing + // links is an orphan). + let hoist_scope = store.join("node_modules/@babel"); + std::fs::create_dir_all(&hoist_scope).unwrap(); + link_dir(&frame, &hoist_scope.join("code-frame")); // A `.bun` link that is not into a global store entry. let elsewhere = tmp.join("elsewhere"); write_pkg( @@ -6273,13 +6387,34 @@ mod tests { .await; } - /// #599 with #635: a global store's shared `/links` entries link - /// their dependencies to one another, never back into a project's - /// `.bun`, so the orphan walk cannot tell which `.bun` links are live. - /// The scan keeps every global store entry rather than dropping the - /// transitive packages it only reaches through the cache. + /// A link at `link` to `target`, relative where the platform allows + /// (Bun writes its `node_modules` links relative); a junction to the + /// resolved target on Windows. + fn rel_link(target: &str, link: &Path) { + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + link_dir( + &normalize_lexically(&link.parent().unwrap().join(target)), + link, + ); + } + + /// #599 with #635: Bun's global store (`globalStore = true`) never + /// prunes `.bun` either. The layout real Bun 1.3.14 and 1.4.2 write + /// after member `a` goes from `{is-odd 3.0.1, left-pad 1.3.0}` to + /// `{is-odd 3.0.1, is-number 7.0.0, left-pad 1.2.0}` with an in-place + /// `bun install`: every `.bun` entry is an absolute link into + /// `/links/-`, the member and hoist links are + /// relative into `.bun`, and is-odd's cache entry links is-number + /// 6.0.0 at its sibling cache dir, never back into `.bun`. The hoist + /// names is-number 7.0.0, so 6.0.0 is reached only through the cache; + /// the `left-pad@1.3.0` link is left behind with nothing reaching it. + /// The walk follows the cache links: the transitive 6.0.0 stays live, + /// and the orphan is dropped by the scan and the live-copy filter. #[tokio::test] - async fn test_bun_global_store_entries_are_kept_by_the_orphan_walk() { + async fn test_bun_global_store_orphaned_entries_are_not_live_copies() { let dir = tempfile::tempdir().unwrap(); let tmp: PathBuf = dir.path().components().collect(); let root = tmp.join("proj"); @@ -6288,41 +6423,111 @@ mod tests { std::fs::create_dir_all(&store).unwrap(); std::fs::write( root.join("package.json"), - r#"{ "name": "proj", "dependencies": { "is-odd": "3.0.1" } }"#, + r#"{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}"#, ) .unwrap(); let links = tmp.join("bun-cache").join("links"); - let link_entry = |entry: &str, name: &str, version: &str| { - let shared = links.join(format!("{entry}-6a490709ba3c5c8f")); + let link_entry = |entry: &str, hash: &str, name: &str, version: &str| { + let shared = links.join(format!("{entry}-{hash}")); write_pkg(&shared.join("node_modules").join(name), name, version); link_dir(&shared, &store.join(entry)); - shared.join("node_modules") + store.join(entry).join("node_modules").join(name) }; - let odd = link_entry("is-odd@3.0.1", "is-odd", "3.0.1"); - let number = link_entry("is-number@6.0.0", "is-number", "6.0.0"); - // A second entry for the name, so links are read, not guessed. - link_entry("is-number@6.0.0+3c4e1d2a", "is-number", "6.0.0"); - link_dir(&number.join("is-number"), &odd.join("is-number")); - // Bun writes the importer's links relative, into `.bun`. - #[cfg(unix)] - std::os::unix::fs::symlink(".bun/is-odd@3.0.1/node_modules/is-odd", nm.join("is-odd")) - .unwrap(); - #[cfg(windows)] - link_dir( - &store.join("is-odd@3.0.1/node_modules/is-odd"), - &nm.join("is-odd"), + let odd = link_entry("is-odd@3.0.1", "630ebdaa4b425d00", "is-odd", "3.0.1"); + let number6 = link_entry("is-number@6.0.0", "fe514fa0667977a7", "is-number", "6.0.0"); + let number7 = link_entry("is-number@7.0.0", "d7644ee3a163df00", "is-number", "7.0.0"); + let pad12 = link_entry("left-pad@1.2.0", "4791bc564980741c", "left-pad", "1.2.0"); + let pad13 = link_entry("left-pad@1.3.0", "6a490709ba3c5c8f", "left-pad", "1.3.0"); + rel_link( + "../../is-number@6.0.0-fe514fa0667977a7/node_modules/is-number", + &links.join("is-odd@3.0.1-630ebdaa4b425d00/node_modules/is-number"), ); + let member = root.join("packages/a"); + write_pkg(&member, "a", "1.0.0"); + for (name, entry) in [ + ("is-odd", "is-odd@3.0.1"), + ("is-number", "is-number@7.0.0"), + ("left-pad", "left-pad@1.2.0"), + ] { + rel_link( + &format!("../{entry}/node_modules/{name}"), + &store.join("node_modules").join(name), + ); + rel_link( + &format!("../../../node_modules/.bun/{entry}/node_modules/{name}"), + &member.join("node_modules").join(name), + ); + } let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); - assert_eq!(candidates.len(), 3); - assert!(live_bun_store_entries_sync(&store, &candidates).is_none()); + assert_eq!(candidates.len(), 5); + let (live, _) = live_bun_store_entries_sync(&store, &candidates).unwrap(); + let mut live: Vec = live.into_iter().map(|e| e.into_string().unwrap()).collect(); + live.sort(); + assert_eq!( + live, + [ + "is-number@6.0.0", + "is-number@7.0.0", + "is-odd@3.0.1", + "left-pad@1.2.0" + ] + ); let scanned = scan_paths(&root).await; let purls: Vec<&str> = scanned.iter().map(|(p, _)| p.as_str()).collect(); - assert_eq!( - purls, - ["pkg:npm/is-number@6.0.0", "pkg:npm/is-odd@3.0.1"], - "{scanned:?}" + for purl in [ + "pkg:npm/is-number@6.0.0", + "pkg:npm/is-number@7.0.0", + "pkg:npm/is-odd@3.0.1", + "pkg:npm/left-pad@1.2.0", + ] { + assert!(purls.contains(&purl), "{purl}: {scanned:?}"); + } + assert!(!purls.contains(&"pkg:npm/left-pad@1.3.0"), "{scanned:?}"); + + // vex's filter judges a copy by the `.bun` entry its path names. + let mut copies = vec![ + number6.clone(), + number7.clone(), + pad13, + pad12.clone(), + odd.clone(), + ]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![number6, number7, pad12, odd]); + } + + /// #599 with #635: a cache entry linking a dependency at a global store + /// entry this project's `.bun` does not link cannot be followed (what + /// it reaches is unknown), so nothing is dropped. + #[tokio::test] + async fn test_bun_global_store_unknown_cache_entry_keeps_every_entry() { + let dir = tempfile::tempdir().unwrap(); + let tmp: PathBuf = dir.path().components().collect(); + let root = tmp.join("proj"); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(&store).unwrap(); + let links = tmp.join("bun-cache").join("links"); + for (entry, hash, name) in [ + ("is-odd@3.0.1", "630ebdaa4b425d00", "is-odd"), + ("is-number@6.0.0", "fe514fa0667977a7", "is-number"), + ] { + let shared = links.join(format!("{entry}-{hash}")); + write_pkg(&shared.join("node_modules").join(name), name, "1.0.0"); + link_dir(&shared, &store.join(entry)); + } + let foreign = links.join("x@1.0.0-0123456789abcdef/node_modules/x"); + write_pkg(&foreign, "x", "1.0.0"); + rel_link( + "../../x@1.0.0-0123456789abcdef/node_modules/x", + &links.join("is-odd@3.0.1-630ebdaa4b425d00/node_modules/x"), ); + rel_link(".bun/is-odd@3.0.1/node_modules/is-odd", &nm.join("is-odd")); + + let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); + assert_eq!(candidates.len(), 2); + assert!(live_bun_store_entries_sync(&store, &candidates).is_none()); } /// #599: Bun never prunes `.bun`. After an in-place `bun install` that @@ -6500,6 +6705,41 @@ mod tests { assert_eq!(copies, vec![pad, member_pad]); } + /// #599: a root `package.json` that does not parse leaves the + /// `bun.lock` `workspaces` section, which Bun rewrites on every + /// install, as the member set: an entry linked only from a member it + /// lists stays live, and an orphan is still dropped. + #[tokio::test] + async fn test_bun_isolated_store_lock_members_stand_in_for_package_json() { + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + let nm = root.join("node_modules"); + let store = nm.join(".bun"); + std::fs::create_dir_all(store.join("node_modules")).unwrap(); + std::fs::write(root.join("package.json"), "{ not json").unwrap(); + std::fs::write( + root.join("bun.lock"), + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \ + \"name\": \"app\",\n },\n \"weird/place\": {\n \"name\": \"w\",\n \ + },\n },\n \"packages\": {\n }\n}\n", + ) + .unwrap(); + let pad = store.join("left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&pad, "left-pad", "1.3.0"); + let member_pad = store.join("left-pad@1.2.0/node_modules/left-pad"); + write_pkg(&member_pad, "left-pad", "1.2.0"); + let churned = store.join("left-pad@1.1.0/node_modules/left-pad"); + write_pkg(&churned, "left-pad", "1.1.0"); + link_dir(&pad, &store.join("node_modules/left-pad")); + let member_nm = root.join("weird/place/node_modules"); + std::fs::create_dir_all(&member_nm).unwrap(); + link_dir(&member_pad, &member_nm.join("left-pad")); + + let mut copies = vec![pad.clone(), member_pad.clone(), churned]; + retain_live_store_copies([&mut copies]).await; + assert_eq!(copies, vec![pad, member_pad]); + } + /// #599: the orphan filter's quick walk takes a link named for a /// package only one entry holds to be that entry. An alias link /// (`node_modules/lp` -> `left-pad@1.3.0`) beside an unrelated `lp@…` From a43f05ad5a8e331b069636bb29923fa0265158f3 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 14:04:23 -0400 Subject: [PATCH 36/55] Fold late duplicate bun.lockb records that have dependencies (#861) The vendored re-run only folded a late dependent's second registry record of the patched name@version into the tarball record when the package had no dependencies of its own. A patched package WITH dependencies (mkdirp -> minimist) still got both records rewired to the same .socket/vendor tarball, with only a vendor_bun_lockb_duplicate_records warning: cold frozen installs on Bun's isolated linker then failed with EEXIST (6/6 on Bun 1.4.2, macOS). The fold now re-derives the hoisting trees with a port of Bun's own hoister (Lockfile.hoist(.resolvable): processSubtree / hoistDependency, DepSorter order, bundled hoist roots, folder placement, identical in Bun 1.3 and 1.4 for what it models) instead of the leaf-only tree rewrite, and drops the duplicate's own dependency edges from the dependency and resolution buffers, renumbering every package's dependency slices. It is done only where that is exact: - the duplicate's dependencies resolve to the same packages as the kept record's, so dropping its edges orphans nothing; - re-hoisting the input lock reproduces its own trees byte for byte (it does for every captured writer from 0.5.9 to 1.4.2; 0.1.x differs and is declined), and the merged lock needs no rule the port leaves out (an unresolved edge, sort ties, a peer meeting another version, which needs semver, a cyclic or peer folder edge, one package listing a name for two packages). Otherwise the records are rewired as before, with the warning. Verified with real Bun: the folded locks pass 6 cold frozen installs each and revert cleanly on 1.3.9, 1.3.14 and 1.4.2, and an unfrozen bun install by 1.3.9 and 1.4.2 leaves the folded lock byte-identical (Bun's own writer agrees). The new e2e test matches the existing workspace_late_dependent CI filter rows (1.3.14 and 1.4.2 legs); hermetic tests cover the captured 1.3.9/1.4.2 locks with mkdirp patched and the hoister against every fixture lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 131 +++- .../src/vendor/bun_binary.rs | 41 +- .../socket-patch-core/src/vendor/bun_lockb.rs | 580 +++++++++++++----- .../tests/fixtures/bun-lockb/README.md | 4 +- .../late-dependent/1.3.9-deps-adder.lockb | Bin 0 -> 3520 bytes .../late-dependent/1.3.9-deps-late.lockb | Bin 0 -> 3480 bytes .../late-dependent/1.4.2-deps-adder.lockb | Bin 0 -> 3496 bytes .../late-dependent/1.4.2-deps-late.lockb | Bin 0 -> 3456 bytes docs/testing/bun-compatibility.md | 20 +- 10 files changed, 570 insertions(+), 208 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8ec70f229..dd423a90c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -711,7 +711,7 @@ to **six flavors**. | npm / pnpm (lockfileVersion 9) | (same tarball) | root `package.json` `pnpm.overrides` (versioned selector) **+** `pnpm-lock.yaml` surgery (overrides / importer version / packages `resolution.integrity` / snapshots) | `pnpm install --frozen-lockfile --offline`, cold store (integrity-verified; byte-stable on pnpm 9 & 10). Other lockfileVersions: 5.4/6.0 route to the legacy backend below; anything else refused | | npm / pnpm LEGACY (lockfileVersion 5.4 = pnpm 7, 6.0 = pnpm 8; flavor `pnpm-legacy`) | (same tarball) | root `package.json` `pnpm.overrides` **+** legacy lock surgery (overrides / root dep + specifiers / packages rekey to a bare `file:` key with recomputed integrity / in-package dep refs). **No `pnpm-workspace.yaml` is written** (pnpm ≤ 8 reads overrides only from package.json). The lock's SPECIFIER is machine-ABSOLUTE — pnpm ≤ 8 absolutizes `file:` overrides itself — surfaced as `vendor_pnpm_legacy_absolute_specifier`. Legacy WORKSPACE locks (`importers:`) refused | same-path `pnpm install --frozen-lockfile --offline`, cold store (byte-stable on pnpm 7.33.5 / 8.15.9). A checkout at a DIFFERENT path fails the frozen check (path-bound specifier) and must run `pnpm install --offline --no-frozen-lockfile` once (the flag matters on CI, where pnpm defaults frozen on), which installs the vendored tarball and re-resolves only the specifier line | | npm / bun (`bun.lock`, lockfileVersion 0, 1 or 2 — `vendor_lockfile_version_unsupported` otherwise) | (same tarball) | `bun.lock` only: the packages entry's registry 4-tuple → local 3-tuple with recomputed `sha512`; the entry's `{deps}` meta, the lock's version line and its line endings are preserved. A lock holding `workspace:` packages is refused `vendor_bun_workspace_unsupported` unless lockfileVersion is 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the MEMBER (ENOENT on our root-relative path), 1.4 relative to the lockfile, and a committed version-2 lock is the only proof every consumer runs Bun ≥ 1.4 (a deliberate over-approximation: a package declared only by the workspace root would install on version 1 too). The gate fires only on a run that would WRITE a new local tuple, so in-sync re-runs, `already_vendored` skips and `repair` redownloads on such a lock pass. The detail names the version and the remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing lockfileVersion), or `--mode hosted`. Native binary support is described in the next row. `scan`/`get --mode vendored` apply all four refusals BEFORE downloading (see the `get --mode vendored` bullet). Bun 1.1.39–1.3.9 re-save the local tuple WITHOUT its `sha512` on any later lock re-save (`bun add`, `bun install` after a manifest change); the digest-less 2-tuple is recognised as the same wiring — an in-sync re-run stays `already_vendored` and re-pins the digest on disk (no new wiring record) when the committed artifact still holds the bytes the lock was written from — otherwise, as for any stale tuple of ours, the line is re-pinned and the fresh entry carries the new fingerprint — `repair` redownloads through it, and `vendor --revert` / `rollback` restore the registry line over it (a 2-tuple at ANOTHER uuid is still `vendor_lock_entry_drifted`) | `bun install --frozen-lockfile`, cold cache (the local tarball's sha512 is enforced by Bun ≥ 1.3.10; 1.1.39–1.3.9 install it unverified — the committed artifact is the protection there) | -| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record dropped and the hoisting trees re-derived — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where the re-hoist is not exactly predictable (a record with dependencies of its own) both are rewired with `vendor_bun_lockb_duplicate_records`. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | +| npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. A second record of the same `name@version` (Bun writes one for a dependent added after vendoring) is folded into the tarball record — its dependents re-pointed, the record and its own dependency edges dropped and the hoisting trees re-derived with Bun's hoister — so no two records share a tarball resolution, which Bun's isolated linker fails to install (`EEXIST`); where that is not exact (the records' dependencies resolve to different packages, or the lock's hoisting is not one the codec reproduces) both are rewired with `vendor_bun_lockb_duplicate_records`. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 69b9612f9..20b0e5569 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -214,14 +214,23 @@ fn copy_tree(source: &Path, target: &Path) { } fn find_installed_target(root: &Path) -> Option { - fn visit(dir: &Path, seen: &mut std::collections::HashSet) -> Option { + find_installed(root, ("minimist", "1.2.2")) +} + +/// The first installed copy of `(name, version)` under `root`. +fn find_installed(root: &Path, (name, version): (&str, &str)) -> Option { + fn visit( + dir: &Path, + target: (&str, &str), + seen: &mut std::collections::HashSet, + ) -> Option { let canonical = dir.canonicalize().ok()?; if !seen.insert(canonical) { return None; } if let Ok(bytes) = std::fs::read(dir.join("package.json")) { if let Ok(package) = serde_json::from_slice::(&bytes) { - if package["name"] == "minimist" && package["version"] == "1.2.2" { + if package["name"] == target.0 && package["version"] == target.1 { return Some(dir.to_path_buf()); } } @@ -229,14 +238,14 @@ fn find_installed_target(root: &Path) -> Option { for entry in std::fs::read_dir(dir).ok()?.flatten() { let path = entry.path(); if path.is_dir() { - if let Some(found) = visit(&path, seen) { + if let Some(found) = visit(&path, target, seen) { return Some(found); } } } None } - visit(root, &mut std::collections::HashSet::new()) + visit(root, (name, version), &mut std::collections::HashSet::new()) } fn installed_target(root: &Path) -> PathBuf { @@ -250,6 +259,9 @@ fn installed_target(root: &Path) -> PathBuf { struct Fixture { temp: tempfile::TempDir, + /// The patched package: minimist@1.2.2, or (`*-deps` shapes) + /// mkdirp@0.5.6, which has a dependency of its own. + target: (&'static str, &'static str), project: PathBuf, reader: PathBuf, legacy_reader: Option, @@ -321,7 +333,7 @@ impl Fixture { let dependencies = match shape { "alias" => json!({"alias":"npm:minimist@1.2.2", "is-number":"7.0.0"}), "transitive" => json!({"mkdirp":"0.5.3", "is-number":"7.0.0"}), - "workspace" | "workspace-adder" => { + "workspace" | "workspace-adder" | "workspace-deps" | "workspace-deps-adder" => { json!({"consumer":"workspace:*", "is-number":"7.0.0"}) } "workspace-nested" => { @@ -344,16 +356,23 @@ impl Fixture { package["scripts"] = json!({"preinstall":"echo root-pre", "postinstall":"echo root-post"}); } + let target = if shape.ends_with("-deps") || shape.ends_with("-deps-adder") { + ("mkdirp", "0.5.6") + } else { + ("minimist", "1.2.2") + }; if shape.starts_with("workspace") || shape == "extensions" { package["workspaces"] = json!(["packages/*"]); std::fs::create_dir_all(project.join("packages/consumer")).unwrap(); std::fs::write( project.join("packages/consumer/package.json"), - br#"{"name":"consumer","version":"1.0.0","dependencies":{"minimist":"1.2.2"}}"#, + serde_json::to_vec(&json!({"name":"consumer","version":"1.0.0", + "dependencies":{target.0:target.1}})) + .unwrap(), ) .unwrap(); } - if shape == "workspace-adder" { + if shape.ends_with("-adder") { std::fs::create_dir_all(project.join("packages/adder")).unwrap(); std::fs::write( project.join("packages/adder/package.json"), @@ -406,7 +425,9 @@ impl Fixture { "writer must not produce text" ); let original_lock = std::fs::read(project.join("bun.lockb")).unwrap(); - let original = std::fs::read(installed_target(&project).join("index.js")).unwrap(); + let installed = find_installed(&project, target) + .unwrap_or_else(|| panic!("installed {target:?} not found")); + let original = std::fs::read(installed.join("index.js")).unwrap(); let patched = [MARKER, original.as_slice()].concat(); let bystander = std::fs::read(project.join("node_modules/is-number/index.js")).unwrap(); eprintln!( @@ -416,6 +437,7 @@ impl Fixture { ); Some(Self { temp, + target, project, reader, legacy_reader: std::env::var_os("SOCKET_PATCH_BUN_LOCKB_LEGACY_READER") @@ -428,6 +450,15 @@ impl Fixture { }) } + fn purl(&self) -> String { + format!("pkg:npm/{}@{}", self.target.0, self.target.1) + } + + /// The patch's tarball file name. + fn tgz(&self) -> String { + format!("{}-{}.tgz", self.target.0, self.target.1) + } + fn lock(&self) -> Vec { assert!( !self.project.join("bun.lock").exists(), @@ -440,7 +471,7 @@ impl Fixture { let socket = self.project.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); let after = compute_git_sha256_from_bytes(&self.patched); - let manifest = json!({"patches":{PURL:{"uuid":UUID, + let manifest = json!({"patches":{self.purl():{"uuid":UUID, "exportedAt":"2026-01-01T00:00:00Z", "files":{"package/index.js":{ "beforeHash":compute_git_sha256_from_bytes(&self.original), "afterHash":after}}, "vulnerabilities":{GHSA:{"cves":[CVE],"summary":"binary lock vuln","severity":"high","description":"d"}}, @@ -505,7 +536,7 @@ impl Fixture { .output() .unwrap(); let output = require_success(output, label); - let target = find_installed_target(&checkout).unwrap_or_else(|| { + let target = find_installed(&checkout, self.target).unwrap_or_else(|| { panic!( "{label}: installed target absent under {}\nstdout: {}\nstderr: {}", checkout.display(), @@ -693,33 +724,40 @@ async fn mock_api(server: &MockServer, fixture: &Fixture, _target: &str) { mock_api_patch(server, fixture, UUID, &fixture.patched).await; } -/// [`mock_api`] serving patch `uuid`, which writes `patched` as minimist's -/// `index.js` (a superseding patch is a second server with a new uuid). +/// [`mock_api`] serving patch `uuid`, which writes `patched` as the fixture +/// target's `index.js` (a superseding patch is a second server with a new +/// uuid). async fn mock_api_patch(server: &MockServer, fixture: &Fixture, uuid: &str, patched: &[u8]) { - let tgz = make_tgz_from_installed(&installed_target(&fixture.project), patched); - prebuilt_common::mount_download(server, PURL, uuid, "minimist-1.2.2.tgz", &tgz).await; + let (name, version) = fixture.target; + let (purl, file) = (fixture.purl(), fixture.tgz()); + let installed = find_installed(&fixture.project, fixture.target).unwrap(); + let tgz = make_tgz_from_installed(&installed, patched); + prebuilt_common::mount_download(server, &purl, uuid, &file, &tgz).await; std::fs::write(fixture.temp.path().join("hosted.tgz"), &tgz).unwrap(); - let url = format!("{}/patch/npm/minimist/1.2.2/33333333-3333-4333-8333-333333333333/{uuid}/minimist-1.2.2.tgz", server.uri()); + let url = format!( + "{}/patch/npm/{name}/{version}/33333333-3333-4333-8333-333333333333/{uuid}/{file}", + server.uri() + ); let sri = format!( "sha512-{}", base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tgz)) ); Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/batch"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"packages":[{ - "purl":PURL,"patches":[{"uuid":uuid,"purl":PURL,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) + "purl":purl,"patches":[{"uuid":uuid,"purl":purl,"tier":"free","cveIds":[],"ghsaIds":[],"severity":"high","title":"binary lock patch"}]}],"canAccessPaidPatches":false}))) .mount(server).await; - Mock::given(method("GET")).and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.*minimist.*$"))) + Mock::given(method("GET")).and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.*{name}.*$"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"patches":[{ - "uuid":uuid,"purl":PURL,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) + "uuid":uuid,"purl":purl,"publishedAt":"2026-01-01T00:00:00Z","description":"binary lock patch","license":"MIT","tier":"free","vulnerabilities":{}}],"canAccessPaidPatches":false}))) .mount(server).await; Mock::given(method("POST")).and(path(format!("/v0/orgs/{ORG}/patches/package"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{uuid:{ - "status":"granted","url":url,"purl":PURL,"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":sri}}],"registryOverride":null}}}))) + "status":"granted","url":url,"purl":purl,"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":sri}}],"registryOverride":null}}}))) .mount(server).await; Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"uuid":uuid,"purl":PURL, + ResponseTemplate::new(200).set_body_json(json!({"uuid":uuid,"purl":purl, "publishedAt":"2026-01-01T00:00:00Z","files":{"package/index.js":{ "beforeHash":compute_git_sha256_from_bytes(&fixture.original), "afterHash":compute_git_sha256_from_bytes(patched), @@ -730,7 +768,7 @@ async fn mock_api_patch(server: &MockServer, fixture: &Fixture, uuid: &str, patc .mount(server) .await; Mock::given(method("GET")) - .and(path_regex("^/patch/npm/minimist/.*$")) + .and(path_regex(format!("^/patch/npm/{name}/.*$"))) .respond_with(ResponseTemplate::new(200).set_body_raw(tgz, "application/octet-stream")) .mount(server) .await; @@ -1532,7 +1570,26 @@ fn dumped_records(fixture: &Fixture) -> Vec { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn workspace_late_dependent_rerun_shares_the_tarball_record() { - for (shape, member) in [("workspace", "late"), ("workspace-adder", "adder")] { + late_dependent_matrix([("workspace", "late"), ("workspace-adder", "adder")]).await; +} + +/// #861, a patched package WITH dependencies of its own (mkdirp@0.5.6 -> +/// minimist): the late registry record resolves its dependencies to the +/// same packages as the tarball record, so it folds the same way — its +/// edges leave the lock with it and the trees are re-hoisted as Bun +/// hoists them — instead of being rewired to the same tarball (`EEXIST`). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn workspace_late_dependent_with_dependencies_rerun_shares_the_tarball_record() { + late_dependent_matrix([ + ("workspace-deps", "late"), + ("workspace-deps-adder", "adder"), + ]) + .await; +} + +async fn late_dependent_matrix(cases: [(&str, &str); 2]) { + for (shape, member) in cases { let Some(fixture) = Fixture::new(shape) else { return; }; @@ -1560,13 +1617,15 @@ async fn workspace_late_dependent_rerun_shares_the_tarball_record() { async fn late_dependent_rerun(fixture: &Fixture, member: &str) { let project = &fixture.project; + let (name, version) = fixture.target; + let file = fixture.tgz(); std::fs::write( project.join("bunfig.toml"), "[install]\nsaveTextLockfile = false\nlinker = \"isolated\"\n", ) .unwrap(); let server = MockServer::start().await; - mock_api(&server, fixture, "minimist").await; + mock_api(&server, fixture, name).await; fixture.stage(); let first = cli(project, &["vendor", "--offline"]); assert_eq!( @@ -1577,14 +1636,14 @@ async fn late_dependent_rerun(fixture: &Fixture, member: &str) { let dir = project.join("packages").join(member); let mut add = if dir.exists() { let mut add = command(&fixture.reader, &dir); - add.args(["add", "minimist@1.2.2", "--ignore-scripts"]); + add.args(["add", &format!("{name}@{version}"), "--ignore-scripts"]); add } else { std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("package.json"), format!( - r#"{{"name":"{member}","version":"1.0.0","dependencies":{{"minimist":"1.2.2"}}}}"# + r#"{{"name":"{member}","version":"1.0.0","dependencies":{{"{name}":"{version}"}}}}"# ), ) .unwrap(); @@ -1603,9 +1662,9 @@ async fn late_dependent_rerun(fixture: &Fixture, member: &str) { require_success(output, &format!("{member}: the late dependent")); let before = dumped_records(fixture); assert!( - before.iter().any( - |line| line.contains("minimist-1.2.2.tgz") && !line.contains(".socket/vendor/npm/") - ), + before + .iter() + .any(|line| line.contains(&file) && !line.contains(".socket/vendor/npm/")), "{member}: Bun writes a nested registry record: {before:?}" ); @@ -1614,15 +1673,19 @@ async fn late_dependent_rerun(fixture: &Fixture, member: &str) { rerun["summary"]["applied"], 1, "{member}: vendored re-run: {rerun}" ); + assert!( + !rerun + .to_string() + .contains("vendor_bun_lockb_duplicate_records"), + "{member}: the records fold, no fallback: {rerun}" + ); let after = dumped_records(fixture); assert_eq!( after .iter() - .filter(|line| line.contains("minimist-1.2.2.tgz")) + .filter(|line| line.contains(&file)) .collect::>(), - [&format!( - "resolved \".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz\"" - )], + [&format!("resolved \".socket/vendor/npm/{UUID}/{file}\"")], "{member}: one record, the tarball: {after:?}" ); // EEXIST was intermittent (about half the cold frozen installs). @@ -1638,7 +1701,9 @@ async fn late_dependent_rerun(fixture: &Fixture, member: &str) { checkout .join("packages") .join(member) - .join("node_modules/minimist/index.js") + .join("node_modules") + .join(name) + .join("index.js") ) .unwrap(), fixture.patched, diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index b855e9d0e..5bbff7854 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -48,9 +48,11 @@ fn is_target(package: &BinaryPackage, coords: &NpmCoords, leaf: &str) -> bool { /// and frozen installs then fail intermittently with `EEXIST` (#861). So /// such records are folded into ONE kept record (the one already at /// `target`, else one of ours, else the first), as Bun's own re-save -/// would. A record some bundled edge reaches is left to the rewrite: its -/// parent's tarball ships that copy. Where the lock's hoisting is not -/// exactly predictable ([`BunLockb::merge_packages`]) the records are all +/// would, whether or not the package has dependencies of its own. A record +/// some bundled edge reaches is left to the rewrite: its parent's tarball +/// ships that copy. Where the records cannot fold exactly +/// ([`BunLockb::merge_packages`]: their dependencies resolve differently, +/// or the lock's hoisting is not one this codec reproduces) they are all /// rewritten as before, with a warning. Returns the records left to /// rewrite, re-read after renumbering, and whether the lock changed. fn merge_duplicates( @@ -1655,12 +1657,12 @@ mod duplicate_tests { /// The uuid the fixtures were first vendored under. const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; - const PURL: &str = "pkg:npm/minimist@1.2.2"; const BEFORE: &[u8] = b"module.exports = 'original';\n"; const AFTER: &[u8] = b"module.exports = 'patched';\n"; /// REGRESSION (#861): the vendored re-run after Bun gave a late - /// dependent its own registry record of minimist@1.2.2 (see + /// dependent its own registry record of minimist@1.2.2, or of + /// mkdirp@0.5.6 with its own dependency on minimist (`deps`; see /// `bun_lockb::tests::LATE_DEPENDENT`) leaves ONE record, the tarball, /// that every dependency edge resolves to — never two records with one /// tarball resolution, which the isolated linker installs into the same @@ -1668,7 +1670,22 @@ mod duplicate_tests { /// test reverts it through the first run's ledger.) #[tokio::test] async fn rerun_folds_the_late_registry_copy_into_the_tarball_record() { - for name in ["1.3.9-late", "1.3.9-adder", "1.4.2-late", "1.4.2-adder"] { + for name in [ + "1.3.9-late", + "1.3.9-adder", + "1.4.2-late", + "1.4.2-adder", + "1.3.9-deps-late", + "1.3.9-deps-adder", + "1.4.2-deps-late", + "1.4.2-deps-adder", + ] { + let (package, version) = if name.contains("-deps-") { + ("mkdirp", "0.5.6") + } else { + ("minimist", "1.2.2") + }; + let purl = format!("pkg:npm/{package}@{version}"); let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let fixture = format!( @@ -1676,11 +1693,11 @@ mod duplicate_tests { env!("CARGO_MANIFEST_DIR") ); std::fs::copy(&fixture, root.join(LOCK)).unwrap(); - let installed = root.join("node_modules/minimist"); + let installed = root.join("node_modules").join(package); std::fs::create_dir_all(&installed).unwrap(); std::fs::write( installed.join("package.json"), - br#"{"name":"minimist","version":"1.2.2"}"#, + format!(r#"{{"name":"{package}","version":"{version}"}}"#), ) .unwrap(); std::fs::write(installed.join("index.js"), BEFORE).unwrap(); @@ -1696,7 +1713,7 @@ mod duplicate_tests { .unwrap(); let (result, entry, warnings) = ts::expect_done( ts::vendor_bun( - PURL, + &purl, &installed, root, &record, @@ -1716,14 +1733,14 @@ mod duplicate_tests { let entry = entry.expect("the lock changed"); let lock = BunLockb::parse(&std::fs::read(root.join(LOCK)).unwrap()).unwrap(); lock.validate_mutation().unwrap(); - let minimist: Vec<_> = lock + let copies: Vec<_> = lock .packages() .unwrap() .into_iter() - .filter(|p| p.name == "minimist") + .filter(|p| p.name == package) .collect(); assert_eq!( - minimist + copies .iter() .map(|p| p.resolution.as_str()) .collect::>(), diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index 999dcccb6..ffdc99b05 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -14,6 +14,7 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{json, Value}; use sha2::{Digest, Sha512_256}; use std::cmp::Ordering; +use std::collections::VecDeque; use std::ops::Range; const HEADER: &[u8] = b"#!/usr/bin/env bun\nbun-lockfile-format-v0\n"; @@ -25,8 +26,15 @@ const INTEGRITY_LEN: usize = 65; /// parent (verified against a real Bun 1.3.14 lock, fixture /// `bun-lockb-bundled`). const BEHAVIOR_BUNDLED: u8 = 0x40; -/// Bun's `Dependency.Behavior.peer` bit. +/// Bun's other `Dependency.Behavior` bits. +const BEHAVIOR_PROD: u8 = 0x02; +const BEHAVIOR_OPTIONAL: u8 = 0x04; +const BEHAVIOR_DEV: u8 = 0x08; const BEHAVIOR_PEER: u8 = 0x10; +const BEHAVIOR_WORKSPACE: u8 = 0x20; +/// Bun's `Tree.invalid_id` (no parent) and `Tree.root_dep_id`. +const INVALID_TREE: usize = u32::MAX as usize; +const ROOT_DEPENDENCY: usize = u32::MAX as usize - 1; /// Written by this codec in the last eight bytes of the root package's /// resolution (its value union, which a root resolution never reads — early /// writers leave uninitialized bytes there, and every supported reader @@ -362,17 +370,17 @@ impl BunLockb { Ok(found) } - /// Fold the leaf `duplicates` of package `kept` into it, the lock Bun - /// itself writes for one package that several dependents resolve to: - /// their dependency edges resolve to `kept`, the edges hoisting would - /// now deduplicate leave the trees (and a tree they leave empty goes - /// too), and their rows leave every package column, later IDs moving - /// down by one. Bun's frozen install re-hoists the lock and refuses one - /// whose trees differ, so this is only done where hoisting is exactly - /// predictable: every record involved has no dependencies of its own (a - /// leaf spawns no subtree), and no edge to it is a peer, nor any edge in - /// the lock bundled (a bundled edge starts a new hoisting root). - /// `Ok(false)` leaves the lock unchanged. + /// Fold the `duplicates` of package `kept` into it, the lock Bun itself + /// writes for one package that several dependents resolve to: their + /// dependency edges resolve to `kept`, their rows leave every package + /// column (later IDs moving down) and their own dependency edges leave + /// the dependency and resolution buffers, and the trees are re-hoisted + /// with Bun's hoister ([`hoist`]): Bun's frozen install re-hoists the + /// lock and refuses one whose trees differ. Done only where that is + /// exact: each duplicate's dependencies resolve to the same packages as + /// `kept`'s (so dropping them orphans nothing), and [`hoist`] models the + /// lock — it reproduces the lock's own trees and needs no rule it does + /// not model for the merged one. `Ok(false)` leaves the lock unchanged. pub(crate) fn merge_packages( &mut self, kept: usize, @@ -402,131 +410,100 @@ impl BunLockb { // The same normalizations as any record edit (`set_package`). self.promote_legacy_format()?; self.normalize_workspace_behaviors()?; - for &id in duplicates.iter().chain([&kept]) { - if !self.package_dependency_range(id)?.is_empty() { - return Ok(false); - } + let graph = self.hoist_graph()?; + let (trees, hoisted) = (self.buffer_array(0)?, self.buffer_array(1)?); + let own = ( + self.data[trees.data].to_vec(), + self.data[hoisted.data].to_vec(), + ); + if hoist(&graph).as_ref() != Some(&own) { + return Ok(false); } - let dependencies = self.dependency_array()?; - let resolutions = self.buffer_array(2)?; - let edges = resolutions.data.len() / 4; - let mut resolved = Vec::with_capacity(edges); - for index in 0..edges { - let behavior = self.data[dependencies.data.start + index * 26 + 16]; - let mut id = u32_at(&self.data, resolutions.data.start + index * 4)? as usize; - if duplicates.contains(&id) { - id = kept; - } - if behavior & BEHAVIOR_BUNDLED != 0 || (id == kept && behavior & BEHAVIOR_PEER != 0) { - return Ok(false); - } - resolved.push(id); - } - let name_hash = |edge: usize| u64_at(&self.data, dependencies.data.start + edge * 26 + 8); - - // Bun's hoister places an edge where it is declared unless the - // nearest ancestor tree holding the same name holds the same - // package, which deduplicates it. Merging only turns other packages - // into `kept`, so an edge to `kept` leaves its tree exactly when - // that nearest ancestor (parents precede children) now resolves to - // `kept` too. - let trees = self.buffer_array(0)?; - let hoisted = self.buffer_array(1)?; - if trees.data.len() % 20 != 0 { - return Err("bun.lockb: invalid tree buffer".into()); - } - let mut nodes = Vec::new(); - for at in trees.data.clone().step_by(20) { - let field = |i: usize| u32_at(&self.data, at + i * 4).map(|v| v as usize); - let (off, len) = (field(3)?, field(4)?); - if off + len > hoisted.data.len() / 4 { - return Err("bun.lockb: invalid tree dependency slice".into()); - } - let placed = (off..off + len) - .map(|i| u32_at(&self.data, hoisted.data.start + i * 4).map(|v| v as usize)) - .collect::, _>>()?; - nodes.push((field(0)?, field(1)?, field(2)?, placed)); - } - for index in 0..nodes.len() { - let parent = nodes[index].2; - if parent != u32::MAX as usize && parent >= index { - return Ok(false); - } - let mut placed = std::mem::take(&mut nodes[index].3); - let mut deduplicated = Vec::new(); - for &edge in &placed { - if resolved.get(edge) != Some(&kept) { - continue; - } - let hash = name_hash(edge)?; - let mut ancestor = parent; - while ancestor != u32::MAX as usize { - let mut same_name = nodes[ancestor] - .3 - .iter() - .filter(|&&e| e < edges && name_hash(e).is_ok_and(|h| h == hash)); - if let Some(&nearest) = same_name.next() { - if resolved[nearest] == kept { - deduplicated.push(edge); - } - break; - } - ancestor = nodes[ancestor].2; - } - } - placed.retain(|edge| !deduplicated.contains(edge)); - if placed.is_empty() && !deduplicated.is_empty() { - if nodes.iter().any(|node| node.2 == index) { - return Ok(false); - } - nodes[index].0 = usize::MAX; - } - nodes[index].3 = placed; - } - let mut renumbered = Vec::with_capacity(nodes.len()); - let mut next = 0; - for node in &nodes { - renumbered.push(next); - if node.0 != usize::MAX { - next += 1; - } + + let survivor = |id: usize| if duplicates.contains(&id) { kept } else { id }; + let targets = |id: usize| { + let mut targets: Vec<_> = graph.lists[id] + .clone() + .map(|edge| (graph.edges[edge].hash, survivor(graph.edges[edge].package))) + .collect(); + targets.sort_unstable(); + targets + }; + let kept_targets = targets(kept); + if duplicates.iter().any(|&id| targets(id) != kept_targets) { + return Ok(false); } - let (mut tree_bytes, mut hoisted_bytes) = (Vec::new(), Vec::new()); - for (index, (id, dependency, parent, placed)) in nodes.iter().enumerate() { - if *id == usize::MAX { - continue; - } - let id = if *id == index { renumbered[index] } else { *id }; - let parent = if *parent == u32::MAX as usize { - *parent - } else { - renumbered[*parent] - }; - for value in [ - id, - *dependency, - parent, - hoisted_bytes.len() / 4, - placed.len(), - ] { - tree_bytes.extend_from_slice(&(value as u32).to_le_bytes()); - } - for &edge in placed { - hoisted_bytes.extend_from_slice(&(edge as u32).to_le_bytes()); + let mut removed = vec![false; graph.edges.len()]; + for &id in duplicates { + for edge in graph.lists[id].clone() { + removed[edge] = true; } } - + let survivors: Vec = (0..self.count) + .filter(|id| !duplicates.contains(id)) + .collect(); + if survivors + .iter() + .any(|&id| graph.lists[id].clone().any(|edge| removed[edge])) + { + return Ok(false); + } let new_id = |id: usize| id - duplicates.iter().filter(|&&d| d < id).count(); - let mut resolution_bytes = Vec::with_capacity(edges * 4); - for (index, &id) in resolved.iter().enumerate() { - let raw = u32_at(&self.data, resolutions.data.start + index * 4)?; - let id = if id < self.count { - new_id(id) as u32 + // `new_edge[edge]`: the edge's index once the removed ones leave. + let new_edge: Vec = removed + .iter() + .scan(0, |next, &gone| { + let index = *next; + *next += usize::from(!gone); + Some(index) + }) + .chain([graph.edges.len() - removed.iter().filter(|&&r| r).count()]) + .collect(); + let merged = HoistGraph { + lists: survivors + .iter() + .map(|&id| { + let range = &graph.lists[id]; + new_edge[range.start]..new_edge[range.start] + range.len() + }) + .collect(), + folder: survivors.iter().map(|&id| graph.folder[id]).collect(), + edges: (0..graph.edges.len()) + .filter(|&edge| !removed[edge]) + .map(|edge| { + let mut edge = graph.edges[edge].clone(); + if edge.package < self.count { + edge.package = new_id(survivor(edge.package)); + } + edge + }) + .collect(), + }; + let Some((tree_bytes, hoisted_bytes)) = hoist(&merged) else { + return Ok(false); + }; + + let dependencies = self.dependency_array()?; + let resolutions = self.buffer_array(2)?; + let (mut dependency_bytes, mut resolution_bytes) = (Vec::new(), Vec::new()); + for edge in (0..graph.edges.len()).filter(|&edge| !removed[edge]) { + let at = dependencies.data.start + edge * 26; + dependency_bytes.extend_from_slice(&self.data[at..at + 26]); + let raw = u32_at(&self.data, resolutions.data.start + edge * 4)?; + let id = if (raw as usize) < self.count { + new_id(survivor(raw as usize)) as u32 } else { raw }; resolution_bytes.extend_from_slice(&id.to_le_bytes()); } + let slices = self.package_start + self.count * (16 + self.resolution_size); + for (&id, range) in survivors.iter().zip(&merged.lists) { + for column in [slices, slices + self.count * 8] { + let at = column + id * 8; + self.data[at..at + 4].copy_from_slice(&(range.start as u32).to_le_bytes()); + } + } let meta = self.package_start + self.count * (32 + self.resolution_size); for row in 0..self.count { let at = meta + row * 88 + 8; @@ -541,20 +518,20 @@ impl BunLockb { let mut columns = Vec::new(); let mut column = self.package_start; for width in widths { - for row in (0..self.count).filter(|row| !duplicates.contains(row)) { + for &row in &survivors { let at = column + row * width; columns.extend_from_slice(&self.data[at..at + width]); } column += self.count * width; } - let count = self.count - duplicates.len(); *self = self.relayout( - count, + survivors.len(), &columns, [ Some(tree_bytes), Some(hoisted_bytes), Some(resolution_bytes), + Some(dependency_bytes), ], )?; self.normalize_production_pool()?; @@ -562,6 +539,36 @@ impl BunLockb { Ok(true) } + /// The package graph [`hoist`] walks, as the lock records it. + fn hoist_graph(&self) -> Result { + let dependencies = self.dependency_array()?; + let resolutions = self.buffer_array(2)?; + let count = dependencies.data.len() / 26; + if resolutions.data.len() / 4 != count { + return Err("bun.lockb: dependency and resolution buffer lengths differ".into()); + } + let edges = (0..count) + .map(|edge| { + let at = dependencies.data.start + edge * 26; + Ok(HoistEdge { + name: self.string_at(at)?.into_bytes(), + hash: u64_at(&self.data, at + 8)?, + behavior: self.data[at + 16], + package: u32_at(&self.data, resolutions.data.start + edge * 4)? as usize, + }) + }) + .collect::>()?; + Ok(HoistGraph { + lists: (0..self.count) + .map(|id| self.package_dependency_range(id)) + .collect::>()?, + folder: (0..self.count) + .map(|id| self.data[self.resolution_at(id)] == 4) + .collect(), + edges, + }) + } + /// The lock re-laid with `count` packages in `columns` and the first /// buffers replaced, the way Bun's serializer writes one: each buffer /// keeps its descriptor and type prefix, then zero padding to an @@ -571,7 +578,7 @@ impl BunLockb { &self, count: usize, columns: &[u8], - replaced: [Option>; 3], + replaced: [Option>; 4], ) -> Result { let arrays = (0..6) .map(|index| self.buffer_array(index)) @@ -1886,6 +1893,187 @@ fn compare_prerelease(a: &str, b: &str) -> Ordering { } } +/// One dependency edge as Bun's hoister reads it. +#[derive(Clone)] +struct HoistEdge { + name: Vec, + hash: u64, + behavior: u8, + package: usize, +} + +/// What Bun's hoister walks: each package's dependency edges, whether it is +/// a folder (placed where declared, never hoisted), and the edges. +struct HoistGraph { + lists: Vec>, + folder: Vec, + edges: Vec, +} + +/// Where [`hoist_dependency`] puts an edge. +enum Hoisted { + /// An ancestor already holds the same package. + Deduplicated, + /// A different package of that name is in the way. + Conflict, + Placed(usize), +} + +/// Bun's `Dependency.Behavior.cmp`: workspace, dev, optional, prod, then +/// peer edges first. +fn behavior_order(l: u8, r: u8) -> Ordering { + if l == r { + return Ordering::Equal; + } + let optional = |b: u8| b & BEHAVIOR_OPTIONAL != 0 && b & BEHAVIOR_PEER == 0; + let tests: [&dyn Fn(u8) -> bool; 5] = [ + &|b| b & BEHAVIOR_WORKSPACE != 0, + &|b| b & BEHAVIOR_DEV != 0, + &optional, + &|b| b & BEHAVIOR_PROD != 0, + &|b| b & BEHAVIOR_PEER != 0, + ]; + for test in tests { + if test(l) != test(r) { + return if test(l) { + Ordering::Less + } else { + Ordering::Greater + }; + } + } + Ordering::Equal +} + +/// The `(trees, hoisted dependencies)` buffers Bun's hoister +/// (`Lockfile.hoist(.resolvable)`, `Tree.processSubtree` and +/// `hoistDependency` in Bun 1.3 and 1.4) writes for `graph`: breadth first +/// from the root, each package's edges in `DepSorter` order go to the +/// highest tree (up to a bundled edge's) with no same-name edge in the way, +/// deduplicated where one resolves to the same package, and a tree that +/// places nothing is dropped. `None` where the result depends on a rule the +/// releases differ on or this does not model: an unresolved edge, edges +/// tied in that order, a package listing one name for two packages, a peer +/// edge meeting another package of its name (a semver check), or a peer or +/// cyclic folder edge. +fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { + // (dependency, parent, placed edges) per tree. + let mut trees: Vec<(usize, usize, Vec)> = Vec::new(); + let mut queue = VecDeque::from([(INVALID_TREE, ROOT_DEPENDENCY, INVALID_TREE)]); + while let Some((parent, dependency, hoist_root)) = queue.pop_front() { + let package = match dependency { + ROOT_DEPENDENCY => 0, + edge => graph.edges[edge].package, + }; + let list = graph.lists[package].clone(); + if list.is_empty() { + continue; + } + let mut sorted: Vec = list.clone().collect(); + let order = |l: &usize, r: &usize| { + let (l, r) = (&graph.edges[*l], &graph.edges[*r]); + behavior_order(l.behavior, r.behavior).then_with(|| l.name.cmp(&r.name)) + }; + sorted.sort_by(order); + // Bun's sort is unstable: tied edges would hoist in either order. + if sorted + .windows(2) + .any(|pair| order(&pair[0], &pair[1]).is_eq()) + { + return None; + } + let next = trees.len(); + trees.push((dependency, parent, Vec::new())); + for edge in sorted { + let HoistEdge { + behavior, package, .. + } = graph.edges[edge]; + if package >= graph.lists.len() { + return None; + } + let bundled = behavior & BEHAVIOR_BUNDLED != 0; + let hoisted = if bundled { + Hoisted::Placed(next) + } else if graph.folder[package] { + let mut tree = next; + while tree != INVALID_TREE { + let (ancestor, parent, _) = trees[tree]; + if ancestor < graph.edges.len() && graph.edges[ancestor].package == package { + return None; + } + tree = parent; + } + if behavior & BEHAVIOR_PEER != 0 { + return None; + } + Hoisted::Placed(next) + } else { + hoist_dependency(graph, &trees, true, next, hoist_root, edge, &list)? + }; + if let Hoisted::Placed(tree) = hoisted { + trees[tree].2.push(edge); + if !graph.lists[package].is_empty() { + queue.push_back((tree, edge, if bundled { tree } else { hoist_root })); + } + } + } + if trees[next].2.is_empty() { + trees.pop(); + } + } + let (mut tree_bytes, mut hoisted_bytes) = (Vec::new(), Vec::new()); + for (id, (dependency, parent, placed)) in trees.iter().enumerate() { + for value in [ + id, + *dependency, + *parent, + hoisted_bytes.len() / 4, + placed.len(), + ] { + tree_bytes.extend_from_slice(&(value as u32).to_le_bytes()); + } + for &edge in placed { + hoisted_bytes.extend_from_slice(&(edge as u32).to_le_bytes()); + } + } + Some((tree_bytes, hoisted_bytes)) +} + +/// Bun's `hoistDependency` for `edge` (of the package whose edges are +/// `list`) from `tree` up: deduplicated against the same package, kept +/// below a different one, else placed in the highest tree reached. +fn hoist_dependency( + graph: &HoistGraph, + trees: &[(usize, usize, Vec)], + as_defined: bool, + tree: usize, + hoist_root: usize, + edge: usize, + list: &Range, +) -> Option { + let wanted = &graph.edges[edge]; + let (_, parent, placed) = &trees[tree]; + if let Some(&other) = placed + .iter() + .find(|&&other| graph.edges[other].hash == wanted.hash) + { + if graph.edges[other].package == wanted.package { + return Some(Hoisted::Deduplicated); + } + if list.contains(&other) || wanted.behavior & BEHAVIOR_PEER != 0 { + return None; + } + return Some(Hoisted::Conflict); + } + if *parent != INVALID_TREE && tree != hoist_root { + let hoisted = hoist_dependency(graph, trees, false, *parent, hoist_root, edge, list)?; + if !as_defined || !matches!(hoisted, Hoisted::Conflict) { + return Some(hoisted); + } + } + Some(Hoisted::Placed(tree)) +} + fn take(data: &[u8], at: usize, len: usize) -> Result<&[u8], String> { at.checked_add(len) .and_then(|end| data.get(at..end)) @@ -2145,7 +2333,10 @@ mod tests { lock.set_package(package.id, &repin, &digest()).unwrap(); assert_eq!(lock.bytes().len(), first.len(), "{version}"); assert!( - !lock.bytes().windows(token.len()).any(|w| w == token.as_bytes()), + !lock + .bytes() + .windows(token.len()) + .any(|w| w == token.as_bytes()), "{version}: the superseded URL is gone" ); // A remote tarball keeps the registry record's inactive bytes; a @@ -2188,7 +2379,9 @@ mod tests { .set_package(1, ".socket/vendor/npm/x/minimist-1.2.2.tgz", &digest()) .unwrap(); let at = local.resolution_at(1); - assert!(local.data[at + 16..at + local.resolution_size].iter().all(|b| *b == 0)); + assert!(local.data[at + 16..at + local.resolution_size] + .iter() + .all(|b| *b == 0)); } #[test] @@ -2663,11 +2856,13 @@ mod tests { } /// The `bun.lockb` each Bun wrote for a workspace vendored once (uuid - /// `80630680-…`) after a late dependent of minimist@1.2.2 was added: a - /// new member (`late`, hoisted after the vendored record, so it nests - /// its registry copy) or `bun add` in an existing one (`adder`, hoisted - /// first, so the vendored record nests instead). - const LATE_DEPENDENT: [(&str, &[u8]); 4] = [ + /// `80630680-…`) after a late dependent of the patched package was + /// added: a new member (`late`, hoisted after the vendored record, so it + /// nests its registry copy) or `bun add` in an existing one (`adder`, + /// hoisted first, so the vendored record nests instead). The patched + /// package is minimist@1.2.2, or (`deps`) mkdirp@0.5.6, whose own + /// dependency on minimist each record lists. + const LATE_DEPENDENT: [(&str, &[u8]); 8] = [ ( "1.3.9-late", include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb"), @@ -2684,6 +2879,22 @@ mod tests { "1.4.2-adder", include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-adder.lockb"), ), + ( + "1.3.9-deps-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb"), + ), + ( + "1.3.9-deps-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb"), + ), + ( + "1.4.2-deps-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb"), + ), + ( + "1.4.2-deps-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb"), + ), ]; /// Each hoisting tree as `(parent, names of the packages it places)`. @@ -2715,36 +2926,55 @@ mod tests { /// writes it, so the isolated linker gets one store directory — and the /// trees become what Bun's frozen install re-hoists: the nested copy is /// deduplicated against the hoisted one and its emptied tree dropped. - /// The re-laid buffers keep Bun's eight-byte data alignment. + /// A patched package with a dependency of its own (`deps`) folds too: + /// the duplicate's edge to minimist leaves with it, every other + /// package's dependency slice moves down past it. The re-laid buffers + /// keep Bun's eight-byte data alignment. #[test] fn late_duplicate_folds_into_the_tarball_record_as_bun_hoists_it() { for (label, bytes) in LATE_DEPENDENT { let member = label.rsplit('-').next().unwrap(); + let target = if label.contains("-deps-") { + "mkdirp" + } else { + "minimist" + }; let mut lock = BunLockb::parse(bytes).unwrap(); - let minimist: Vec<_> = lock + let edges = lock.dependency_array().unwrap().data.len() / 26; + let copies: Vec<_> = lock .packages() .unwrap() .into_iter() - .filter(|p| p.name == "minimist") + .filter(|p| p.name == target) .collect(); - assert_eq!(minimist.len(), 2, "{label}"); - let kept = minimist - .iter() - .find(|p| p.resolution.starts_with(".socket/")); + assert_eq!(copies.len(), 2, "{label}"); + let kept = copies.iter().find(|p| p.resolution.starts_with(".socket/")); let kept = kept.unwrap().id; - let duplicate = minimist.iter().find(|p| p.id != kept).unwrap().id; + let duplicate = copies.iter().find(|p| p.id != kept).unwrap().id; + let own = lock.package_dependency_range(duplicate).unwrap().len(); assert_eq!(tree_placements(&lock).len(), 2, "{label}: Bun nests a copy"); assert!(lock.merge_packages(kept, &[duplicate]).unwrap(), "{label}"); lock.validate_mutation().unwrap(); let merged = BunLockb::parse(&lock.bytes()).unwrap(); let packages = merged.packages().unwrap(); - assert_eq!(packages.len(), 5, "{label}"); - let minimist: Vec<_> = packages.iter().filter(|p| p.name == "minimist").collect(); - assert_eq!(minimist.len(), 1, "{label}: {packages:?}"); - assert!(minimist[0] + assert_eq!( + packages.len(), + if target == "mkdirp" { 6 } else { 5 }, + "{label}" + ); + let copies: Vec<_> = packages.iter().filter(|p| p.name == target).collect(); + assert_eq!(copies.len(), 1, "{label}: {packages:?}"); + assert!(copies[0] .resolution .starts_with(".socket/vendor/npm/80630680-")); + assert_eq!( + merged.dependency_array().unwrap().data.len() / 26, + edges - own, + "{label}: the duplicate's own edges leave" + ); + let graph = merged.hoist_graph().unwrap(); + assert!(graph.edges.iter().all(|e| e.package < merged.count)); let meta = merged.package_start + merged.count * (32 + merged.resolution_size); for row in 0..merged.count { assert_eq!( @@ -2752,7 +2982,10 @@ mod tests { row ); } - let mut expected = ["consumer", member, "is-number", "minimist"]; + let mut expected = vec!["consumer", member, "is-number", "minimist"]; + if target == "mkdirp" { + expected.push("mkdirp"); + } expected.sort(); assert_eq!( tree_placements(&merged), @@ -2769,10 +3002,11 @@ mod tests { } } - /// A record with dependencies of its own spawns a subtree whose - /// hoisting the merge does not reproduce: it declines, lock unchanged. + /// Records whose dependencies resolve to different packages cannot + /// fold (dropping one's edges would orphan what only it reaches): the + /// merge declines, lock unchanged. #[test] - fn merge_declines_records_with_dependencies() { + fn merge_declines_records_whose_dependencies_differ() { let bytes = include_bytes!("../../tests/fixtures/bun-lockb/0.8.1-production-complex/bun.lockb"); let mut lock = BunLockb::parse(bytes).unwrap(); @@ -2784,4 +3018,42 @@ mod tests { assert!(!lock.merge_packages(other, &[parent]).unwrap()); assert_eq!(lock.bytes(), bytes); } + + /// The merge's hoister is Bun's: re-hoisting the lock every captured + /// Bun release wrote reproduces its trees byte for byte. The 0.1.x + /// writers hoisted differently; the merge's check that the lock's own + /// trees come back makes it decline those locks. + #[test] + fn hoist_reproduces_every_captured_writers_trees() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut stack = vec![root.clone()]; + let mut checked = 0; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + stack.push(path); + continue; + } + let Ok(mut lock) = BunLockb::parse(&std::fs::read(&path).unwrap()) else { + continue; + }; + lock.promote_legacy_format().unwrap(); + lock.normalize_workspace_behaviors().unwrap(); + let own = ( + lock.data[lock.buffer_array(0).unwrap().data].to_vec(), + lock.data[lock.buffer_array(1).unwrap().data].to_vec(), + ); + let hoisted = hoist(&lock.hoist_graph().unwrap()); + let label = path.strip_prefix(&root).unwrap().display().to_string(); + if label.starts_with("bun-lockb/0.1.") { + assert_ne!(hoisted, Some(own), "{label}"); + } else { + assert_eq!(hoisted, Some(own), "{label}"); + checked += 1; + } + } + } + assert!(checked >= 30, "{checked}"); + } } diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index 965dbcea5..4a5d7e929 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -26,7 +26,9 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include wrote for the `e2e_bun_lockb` workspace (vendored once, uuid `80630680-…`) after a late dependent of minimist@1.2.2 — a new `late` member, or `bun add` in the existing `adder` member — gave it a second, - nested registry record (#861). + nested registry record (#861). `late-dependent/-deps-.lockb` + are the same flow with mkdirp@0.5.6 patched, a package with a dependency + (minimist) of its own. Other releases capture the stable major/minor eras. `two-versions` covers multiple package versions and scoped restoration. The earliest writers include diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-adder.lockb new file mode 100644 index 0000000000000000000000000000000000000000..0cbfa89611118ca57b75d025601a64cdf389b3ac GIT binary patch literal 3520 zcmcInYfKzf6doQfxJpY6NgLDZs8M0VJb+~#mhFZ{8n8`kE3~!}NM_lYWngy}XJ!{E z1+i%gYE5fXexS8#^@l|hQ%f7gAgjR_#+LqQq*h7|NK7M%MH|63;Jv$ZhJ{ed%acsb zeSY^l=iJAcM;>AVs=~M=nGxh5=?chZ^if`ad6g)61xoZQK3=1OtT~P#2rqT$-D~?x zg5sY$UKv;$Oyfo_SFEaEH&W3d?|826#qDeo8iXVvqKL#9x>2vc2LrR+2LMPT#{#f1h9T zZP!h)BlyMJAGY=%J-hwDFx&oA{V8GJ`W9%9(HSgN#p(e0NZS$bX$CC-yigzeAkx?m z3z#}>D0sRVwIa+#hxOo#RCjbCpEsG$$7$Slj4IMwY?Lz-#Zr34 zUPcvJI?4QJtt0Jq-TBV3pRRd4p`@-Y)~nr{2Q{-JXVZ-@KeiozO%RJsJHIJC`26uG zT!`~>^ToFZNc@q(p-pH0@(d-lO0I;a#Os#UbGdo{n%V|-H{4&JyW8veF;K%DY%Sr( zFRd?YvXp(&u3Z}J8aDl!e(dKzHvDFa;tTP_*zuRs8w+|~I^5savHIo8K-w<4xc}_< ziO^`McW(iE@u(wiUB_DD%`0sm-1z+bkz(iI>+Z3hn+fKYwv&~OUw3BVJqG@T{RNzt zq@!?QpN09ROrE@+yf>$EWB-8-=F{W*dgI=>7<&JKJodAR&R-hGZk;mQzdF6;u|toh zp8H|NKnvyl&A<5`L^m=J{rRict5S(NtORCZy!7rwhR+9Hh9@#IVm>M!M z84=t7_a4xJdY-<>E@QDkR;Cl-&4G+D@cG6(@icrUoAvo0%@YT&kM|V*01xhHd}eU@ zs*crz5(O+~M@gowG{7OtZ3e%L>6Sy@0C@muf)@!g4agiw6THDQdtyy2TJ0WmIv`7d ztRpt&37VyGFPtODGh$;{I-9oP9zBDBj3hRuEQ8L(UCWEMS`Rg7vqKepK|!H6zMu(; z%zKM8MUp);s!lRv%6FXMyFwjoKe8H&*@4}haXT*)gv@JFP@qsA$t$%KN+GNH@+#gV zs7yElMh$#h<5#Lw)GF95l1j+|pG#0GG_6{-GmIj5P{ya!(sH%0QleLeS`il$M9tI6W_26?WJq^<@09ax zRwv0B-k*vS0s9(u@tS)hXwAAdcEL_TQ6;}j>$+t`e`%(OdM|MMVqZP;BDJ_P#SkEm$a8P^5Q!Nd8gaTjbA3xdz6TNi8B KeEqiAasL4p=}Zm) literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-deps-late.lockb new file mode 100644 index 0000000000000000000000000000000000000000..362c5f1b3b0beae64d7f72d0e15194fe79288e2b GIT binary patch literal 3480 zcmcJReM}o=7{HGY3s%v&kT@4-P8Jz!xDTMzqw6Zj7RZ*(O~~9NpjUcV587+JyB67( zl|ROeGj7Y44Bh5}{=qQO=sprLNL|oHjAQ#_5mYu6B#Y6+p+s;KeBbt-wh&~{#y4r7 zdq1Av`#jJ4ym!yaM;X7WFb+v(1UW!D{IU`KD7Ux1QIy;QC3+PPuTcTkm_`u9liK?$ z%f$hCMN#gt6H71r^m5Q-yx?DvH2Vw@GA$+p`-}Tv1CC*ux|!3#2m-D zOV>6mh!R!>PIH@WT^^KWN&^{%r|eDjOT70T+_Wd5Od;ucm*9EcK zuE&cHD$cA%&xJI(tBu7ST=1o)Er8T908#=G=*t>ks znjLP}*ZwB%KwAwzIk>j2)l~PJeMoimr!=x2|<|jDOO9xY|B^$T`t>E5mrS^K?V- z^X{U|I4H>_f7YT*JzVuRpZ@m8ottWwIT?1vXCWJX{X>7^GP$w zyCs+G715bS+thTV;Q@H>fjQ7G)i>E5-OOK9Xh*zx$e4leH@=IQgL8bpuHUgh4$dd6 zi)B0yvoLo6c&JFg&1kDBwAA`J=Go=QY#T=yHK(WVNX zfS}OpU(y6c=G|4ABFU~l!D^CJO7?pkg7Si<`BW>zD1r+Wa!NBT`#cRQ?NwY% zbPYqro~dwbY0b40SEcnHCyiFCs2~+ICLqX8ufiY#!?Bhk*21xr*~wcdbFo-LIUGDk zIXI3JSW$EqbM;Il)KvIPFmWDR;JlD2@JQhUZC%I_YV+TYKkhhSfzw1iR-y6Y3&wrbssFGKvby?l)6Y~5P^J+U;JlD~~ zeFx(fDihT^sM~ugYBw_Oznn(K%=Y_+t~U|Y*YmzAoc?;&M?^d}?o|+W5pI9X8H)Tr%-c>v2gEZYr@G{rWpt);b2=&N@>ZtgvLX6ybzuJ!5qlj7brO)#EdFj%NcGyn>bb|Bu99GU}okzxA~ z(l`!tm^#T6B7FdjBFuT?fM4ARhetRNFBpe)CM`Zq2XTlB@`+fo2z*iz5nCM2T?W3z z(6dp=$O!+iV2yplI9LM?*4S6}$zUuWx(mFQTu5@nf*GXsIhU;H{YCrb6C>2|Fd{=t# zg=2BJkmtqbi*FB*>7^)JsIDR%Y0;Th?=kwP}LoTv7{nL$ z7jRyXj>Cn07Ui2Ue&SmCp1jI+z5Caar$+a7C%t(g^5KI8+!tf*zlKL{o+KULoZ9sG zp~qI8{c%ZO4VO0XZ{++t?ESSl<2^0sBTpdQg$T|y+@}qwO~#$C+u<%`E?_T^KETYN z@B9igEI$3Gxl{VXcw5qqyO(sz3VINIJ_cTCrdSn47nRLXn!H;KNSTz45`3~(2}mfX zri_ip1P{Qy2XvrcU@WrJTr8NA?Lv4nAY%-CzA;Zc4WG#a#{5raiG$b2dkX&m5AJAu zW^nt8fz^cy1uUeqB->sZi zi8dV#KBIeg=pU4vd`x+#|EK2fNP< zo)MF8@Rq*fIaB=|G3KQj|2#~8W4k>*YLjjTxCP8Jzgb!zdRE&!Uq8T~JEi{z#6v{9 literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-late.lockb new file mode 100644 index 0000000000000000000000000000000000000000..6bbfd55237f63dee52b28272545591f6cf477880 GIT binary patch literal 3456 zcmcImeM}o=7(YHNSViYT;#{0LS;X0JA3&)`*VQ3gAX_#!M<$bixzf9O&|dNGT4Y~V z{umf%bjy~2x@piq7$zFsM`@Z*E{V)?y6~-mWj35U|S3owSALaE|*NT!?phUmo<25SCno|gZcvAatb+I@g zugcGEIkx=l&oA|EZyY>V_ixckwd9EhT+jV+H`;_+T2v6}vt&pgzXuy4%J~_20wr2gVv!VXSN?0Wt+3tIi77?(FXHL5sQicCF>ByNFv`uGXh!!e5t+{<$%C> zScK|~t&r&^G>b5oj017?Asi0jK)zre?94De#{lBWLTefSgFk7=5o;XI9lA}^9JX&Z zm5LVd4>v4vY#0Yiz`+uaaXqobZE1Q__f7#o6ejV1gqK7_apR#=hqtxuFSYOK89QpJ zJZ-O{c38T|4bNDAKeeZ4gLC4$Up=01M)wx$m7dMRn%PmfY5a?iY{y>{#4^*)uPP6| za6Aqd>YTW<2q6v^zaG$14-b!QI{l|-B%@Vwr8T8rv$URHyZ#?j$K_r7SMFQ8%j@|z zP|qD~t>7mI*HtxHsy;cT4GwpYntsZ8`}^ODelf-2LOn5d>JK@glHQl!7#irj|CO4+ zW94+&(3#1$@L0HiZwY(xh$CfH=YzysmpaZ))@&R)P-H$i+1Q`*=Ed;)53OfEo$C5AG;!l&v;FMJ zE!nR>vgZ6Z_gt=LGe-UnUpU4rZ^)k>=(rGm6yYY)aIN7z-G}<-xchZ0+(dK%J3;pW zW*&L_EzDzl?xn@MbXEVhEYsg!vqO?qjrV$K_)61diXb@2JeE|Y-GX1tA+03ul{~Uv zL^U0Ijg%Y40{MAPgf|Z|#=!R*-$jgr&v>)0-@ZT`e4el_mhpHP z3*!y|9wG_2nH?2*w#op9qP7{n<;J209s#_7G{F}Zm;^8b(v*ep@Hc9DZQ#ImFo=KsQ*quQ6;}j>(WUU%vEuN%Yai0mGC|+bU*BC*8OZT y{0-bZSfn&N-hN&0ilMKc_vkr~Pg~rrk8tP+k2db~Rt`Oa2ExI6CtH literal 0 HcmV?d00001 diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 2587a3e1f..92ab854c9 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -255,13 +255,19 @@ runners) from the GitHub releases and verifies it against `SHASUMS256.txt`. Ever frozen installs fail intermittently with `EEXIST` (measured on 1.3.9 and 1.4.2; 1.2.23, the hoisted linker and text `bun.lock` were unaffected). The vendored re-run instead folds the duplicate into the tarball record: - its dependents resolve to that record, the record is dropped and later - package IDs renumbered, and the hoisting trees are rewritten the way Bun - re-hoists them (1.3.x refuses a frozen binary lock whose re-hoisted trees - differ). This is limited to records without dependencies of their own, - where the re-hoist is exactly predictable; otherwise every record is - rewired as before, with `vendor_bun_lockb_duplicate_records`. Covered by - `e2e_bun_lockb::workspace_late_dependent_rerun_shares_the_tarball_record` + its dependents resolve to that record, the record and its own dependency + edges are dropped and later package IDs and dependency slices renumbered, + and the hoisting trees are re-derived with Bun's hoister (1.3.x refuses a + frozen binary lock whose re-hoisted trees differ). The fold needs the + duplicate's dependencies to resolve to the same packages as the tarball + record's (so nothing is orphaned), and the codec's hoister to reproduce + the lock's own trees and need no rule it does not model (a peer meeting + another version, a cyclic folder); otherwise every record is rewired as + before, with `vendor_bun_lockb_duplicate_records`. A patched package with + dependencies of its own (mkdirp@0.5.6) folds the same way, and an + unfrozen install by 1.3.9 and 1.4.2 leaves the folded lock byte-identical. + Covered by `e2e_bun_lockb::workspace_late_dependent_rerun_shares_the_tarball_record` + and `workspace_late_dependent_with_dependencies_rerun_shares_the_tarball_record` on the 1.3.14 and 1.4.2 legs, and hermetically by the `bun-lockb/late-dependent/` fixtures. - **Workspace-member local tarballs.** Bun 1.2.x–1.3.x resolve a From e470b9b204b8c537dff0733042d1f6f3d6d1b491 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 14:25:37 -0400 Subject: [PATCH 37/55] Keep folding bun.lockb late duplicates when a lock has unresolved optional peers (#861) The re-hoist port returned None on any unresolved edge, and merge_packages first checks that re-hoisting the input lock reproduces its own trees. An optional peer nothing installs (ws@8's bufferutil and utf-8-validate) is recorded as an edge resolving to u32::MAX, which most real locks contain, so the fold declined for them all: the deps case fell back to the duplicate-records warning plus EEXIST, and the leaf (minimist) fold that 30bd202c shipped without hoisting regressed to the same fallback. Port what Bun's processSubtree does instead: an unresolved edge that is not an optional peer is skipped; an unresolved optional peer hoists like any edge and holds its place while hoisting, but Tree.Builder.clean drops it from the written buffer. Meeting another unresolved optional peer of its name is Bun's resolve_later (no placement); meeting a resolved edge of its name either way round re-resolves it (resolve / resolve_replace), which the port does not model, so hoist still returns None there and the merge declines. Real Bun 1.3.9 and 1.4.2 locks of the four late-dependent flows with ws@8.18.0 at the root are added as fixtures: the hoister reproduces their trees and the fold yields the expected single tree, with the old behaviour both unit tests fail. The e2e -adder and -deps shapes now depend on ws, and the late-dependent e2e tests pass on Bun 1.3.9, 1.3.14 and 1.4.2 (6 cold frozen isolated installs each) and fail with the old hoister. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/tests/e2e_bun_lockb.rs | 9 +- .../socket-patch-core/src/vendor/bun_lockb.rs | 130 +++++++++++++++--- .../tests/fixtures/bun-lockb/README.md | 5 +- .../late-dependent/1.3.9-ws-adder.lockb | Bin 0 -> 3552 bytes .../late-dependent/1.3.9-ws-deps-adder.lockb | Bin 0 -> 3928 bytes .../late-dependent/1.3.9-ws-deps-late.lockb | Bin 0 -> 3904 bytes .../late-dependent/1.3.9-ws-late.lockb | Bin 0 -> 3528 bytes .../late-dependent/1.4.2-ws-adder.lockb | Bin 0 -> 3528 bytes .../late-dependent/1.4.2-ws-deps-adder.lockb | Bin 0 -> 3904 bytes .../late-dependent/1.4.2-ws-deps-late.lockb | Bin 0 -> 3880 bytes .../late-dependent/1.4.2-ws-late.lockb | Bin 0 -> 3504 bytes docs/testing/bun-compatibility.md | 4 + 12 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 20b0e5569..0ac39ccf4 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -333,8 +333,13 @@ impl Fixture { let dependencies = match shape { "alias" => json!({"alias":"npm:minimist@1.2.2", "is-number":"7.0.0"}), "transitive" => json!({"mkdirp":"0.5.3", "is-number":"7.0.0"}), - "workspace" | "workspace-adder" | "workspace-deps" | "workspace-deps-adder" => { - json!({"consumer":"workspace:*", "is-number":"7.0.0"}) + "workspace" => json!({"consumer":"workspace:*", "is-number":"7.0.0"}), + // REGRESSION (#861): ws@8 has two optional peers nothing + // installs (bufferutil, utf-8-validate), so the lock holds + // unresolved edges, as most real locks do; the fold must still + // re-hoist it. + "workspace-adder" | "workspace-deps" | "workspace-deps-adder" => { + json!({"consumer":"workspace:*", "is-number":"7.0.0", "ws":"8.18.0"}) } "workspace-nested" => { json!({"consumer":"workspace:*", "minimist":"1.2.8", "is-number":"7.0.0"}) diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index ffdc99b05..c3ac6d918 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -1951,10 +1951,14 @@ fn behavior_order(l: u8, r: u8) -> Ordering { /// from the root, each package's edges in `DepSorter` order go to the /// highest tree (up to a bundled edge's) with no same-name edge in the way, /// deduplicated where one resolves to the same package, and a tree that -/// places nothing is dropped. `None` where the result depends on a rule the -/// releases differ on or this does not model: an unresolved edge, edges -/// tied in that order, a package listing one name for two packages, a peer -/// edge meeting another package of its name (a semver check), or a peer or +/// places nothing is dropped. An unresolved edge is skipped, except an +/// optional peer nothing installs (such as `ws`'s `bufferutil`), which +/// hoists like any edge but is left out of the written buffer, as Bun's +/// `Tree.Builder.clean` does. `None` where the result depends on a rule the +/// releases differ on or this does not model: an optional peer that would +/// resolve to (or be resolved by) a same-name edge it meets, edges tied in +/// that order, a package listing one name for two packages, a peer edge +/// meeting another package of its name (a semver check), or a peer or /// cyclic folder edge. fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { // (dependency, parent, placed edges) per tree. @@ -1988,12 +1992,18 @@ fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { let HoistEdge { behavior, package, .. } = graph.edges[edge]; - if package >= graph.lists.len() { - return None; - } + let resolved = package < graph.lists.len(); let bundled = behavior & BEHAVIOR_BUNDLED != 0; let hoisted = if bundled { Hoisted::Placed(next) + } else if !resolved { + if behavior & (BEHAVIOR_OPTIONAL | BEHAVIOR_PEER) + != BEHAVIOR_OPTIONAL | BEHAVIOR_PEER + { + // Bun skips an unresolvable edge that is no optional peer. + continue; + } + hoist_dependency(graph, &trees, true, next, hoist_root, edge, &list)? } else if graph.folder[package] { let mut tree = next; while tree != INVALID_TREE { @@ -2012,7 +2022,7 @@ fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { }; if let Hoisted::Placed(tree) = hoisted { trees[tree].2.push(edge); - if !graph.lists[package].is_empty() { + if resolved && !graph.lists[package].is_empty() { queue.push_back((tree, edge, if bundled { tree } else { hoist_root })); } } @@ -2023,6 +2033,13 @@ fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { } let (mut tree_bytes, mut hoisted_bytes) = (Vec::new(), Vec::new()); for (id, (dependency, parent, placed)) in trees.iter().enumerate() { + // An optional peer that never resolved holds its place while + // hoisting but is not written. + let placed: Vec = placed + .iter() + .copied() + .filter(|&edge| graph.edges[edge].package < graph.lists.len()) + .collect(); for value in [ id, *dependency, @@ -2032,7 +2049,7 @@ fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { ] { tree_bytes.extend_from_slice(&(value as u32).to_le_bytes()); } - for &edge in placed { + for edge in placed { hoisted_bytes.extend_from_slice(&(edge as u32).to_le_bytes()); } } @@ -2041,7 +2058,10 @@ fn hoist(graph: &HoistGraph) -> Option<(Vec, Vec)> { /// Bun's `hoistDependency` for `edge` (of the package whose edges are /// `list`) from `tree` up: deduplicated against the same package, kept -/// below a different one, else placed in the highest tree reached. +/// below a different one, else placed in the highest tree reached. An +/// unresolved optional peer meeting another one of its name is set aside +/// (Bun's `resolve_later`, which only a later resolution acts on); meeting +/// a resolved one either way round re-resolves it, which is `None`. fn hoist_dependency( graph: &HoistGraph, trees: &[(usize, usize, Vec)], @@ -2057,6 +2077,12 @@ fn hoist_dependency( .iter() .find(|&&other| graph.edges[other].hash == wanted.hash) { + let unresolved = |edge: &HoistEdge| edge.package >= graph.lists.len(); + match (unresolved(&graph.edges[other]), unresolved(wanted)) { + (true, true) => return Some(Hoisted::Deduplicated), + (true, false) | (false, true) => return None, + (false, false) => {} + } if graph.edges[other].package == wanted.package { return Some(Hoisted::Deduplicated); } @@ -2861,8 +2887,10 @@ mod tests { /// nests its registry copy) or `bun add` in an existing one (`adder`, /// hoisted first, so the vendored record nests instead). The patched /// package is minimist@1.2.2, or (`deps`) mkdirp@0.5.6, whose own - /// dependency on minimist each record lists. - const LATE_DEPENDENT: [(&str, &[u8]); 8] = [ + /// dependency on minimist each record lists. The `ws` locks also depend + /// on ws@8.18.0 at the root, whose two optional peers nothing installs: + /// unresolved edges, as most real locks have. + const LATE_DEPENDENT: [(&str, &[u8]); 16] = [ ( "1.3.9-late", include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-late.lockb"), @@ -2895,6 +2923,46 @@ mod tests { "1.4.2-deps-adder", include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-deps-adder.lockb"), ), + ( + "1.3.9-ws-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb"), + ), + ( + "1.3.9-ws-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb"), + ), + ( + "1.3.9-ws-deps-late", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb" + ), + ), + ( + "1.3.9-ws-deps-adder", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb" + ), + ), + ( + "1.4.2-ws-late", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb"), + ), + ( + "1.4.2-ws-adder", + include_bytes!("../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb"), + ), + ( + "1.4.2-ws-deps-late", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-late.lockb" + ), + ), + ( + "1.4.2-ws-deps-adder", + include_bytes!( + "../../tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb" + ), + ), ]; /// Each hoisting tree as `(parent, names of the packages it places)`. @@ -2958,9 +3026,10 @@ mod tests { lock.validate_mutation().unwrap(); let merged = BunLockb::parse(&lock.bytes()).unwrap(); let packages = merged.packages().unwrap(); + let ws = label.contains("-ws-"); assert_eq!( packages.len(), - if target == "mkdirp" { 6 } else { 5 }, + 5 + usize::from(target == "mkdirp") + usize::from(ws), "{label}" ); let copies: Vec<_> = packages.iter().filter(|p| p.name == target).collect(); @@ -2974,7 +3043,19 @@ mod tests { "{label}: the duplicate's own edges leave" ); let graph = merged.hoist_graph().unwrap(); - assert!(graph.edges.iter().all(|e| e.package < merged.count)); + let unresolved: Vec<_> = graph + .edges + .iter() + .filter(|e| e.package >= merged.count) + .map(|e| (e.name.as_slice(), e.package)) + .collect(); + let none = u32::MAX as usize; + let peers: &[(&[u8], usize)] = &[(b"bufferutil", none), (b"utf-8-validate", none)]; + assert_eq!( + unresolved, + if ws { peers } else { &[] }, + "{label}: only ws's optional peers stay unresolved" + ); let meta = merged.package_start + merged.count * (32 + merged.resolution_size); for row in 0..merged.count { assert_eq!( @@ -2986,6 +3067,9 @@ mod tests { if target == "mkdirp" { expected.push("mkdirp"); } + if ws { + expected.push("ws"); + } expected.sort(); assert_eq!( tree_placements(&merged), @@ -3020,14 +3104,15 @@ mod tests { } /// The merge's hoister is Bun's: re-hoisting the lock every captured - /// Bun release wrote reproduces its trees byte for byte. The 0.1.x - /// writers hoisted differently; the merge's check that the lock's own - /// trees come back makes it decline those locks. + /// Bun release wrote reproduces its trees byte for byte, unresolved + /// optional peers included. The 0.1.x writers hoisted differently; the + /// merge's check that the lock's own trees come back makes it decline + /// those locks. #[test] fn hoist_reproduces_every_captured_writers_trees() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); let mut stack = vec![root.clone()]; - let mut checked = 0; + let (mut checked, mut unresolved) = (0, 0); while let Some(dir) = stack.pop() { for entry in std::fs::read_dir(&dir).unwrap() { let path = entry.unwrap().path(); @@ -3044,16 +3129,23 @@ mod tests { lock.data[lock.buffer_array(0).unwrap().data].to_vec(), lock.data[lock.buffer_array(1).unwrap().data].to_vec(), ); - let hoisted = hoist(&lock.hoist_graph().unwrap()); + let graph = lock.hoist_graph().unwrap(); + let hoisted = hoist(&graph); let label = path.strip_prefix(&root).unwrap().display().to_string(); if label.starts_with("bun-lockb/0.1.") { assert_ne!(hoisted, Some(own), "{label}"); } else { assert_eq!(hoisted, Some(own), "{label}"); checked += 1; + if graph.edges.iter().any(|e| e.package >= graph.lists.len()) { + unresolved += 1; + } } } } assert!(checked >= 30, "{checked}"); + // REGRESSION (#861): locks with an optional peer nothing installs + // (the `late-dependent/*-ws-*` ones) hoist too. + assert!(unresolved >= 8, "{unresolved}"); } } diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index 4a5d7e929..ff3556d89 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -28,7 +28,10 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include member, or `bun add` in the existing `adder` member — gave it a second, nested registry record (#861). `late-dependent/-deps-.lockb` are the same flow with mkdirp@0.5.6 patched, a package with a dependency - (minimist) of its own. + (minimist) of its own. The `-ws-*` locks repeat both flows with + ws@8.18.0 also at the root, whose optional peers (bufferutil, + utf-8-validate) nothing installs: unresolved edges, as most real locks + hold. Other releases capture the stable major/minor eras. `two-versions` covers multiple package versions and scoped restoration. The earliest writers include diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-adder.lockb new file mode 100644 index 0000000000000000000000000000000000000000..d593d6be95c06b0b2013f68bf35f57b986e5dc03 GIT binary patch literal 3552 zcmdT{e@q)y9KT`(tOCw8nMTd({0DJ&*LH0^O4qT-B5c`)fJ{QlcBSviNqc2?*H$19 z#LNvh{1HYnosv!6VuI0)pm7N}rwCIdGf}b`P|)hKpvG-sW>EsZ*WN1!GQy9U=u6&x z``-6`KkxhFz3=<>!3XG|B+_nPpn(uFxPyWYcG4FpujF_iAi03(XJs;E)ZIc5MAxQ# zbJy9I?`zC${%qwvYyX>WRMci$`D9sJ<&=PnvM^Xg85#`Udutch*2>~DgCkOL_R^~ILnwMb-)(*))0 z5NC#C7J-$ktX&N$^5-H%XDr2bdwb%AsZOw4KZ!aSYGrA2qJ1oC-*7oE$ub&_DdOuD%z11?>vn3+y9GRQ1 zoq5mJ)(p6!#NuApf!%E?T$DX-T2tX_RiN6ovg4T#XEisj?A#MR-+OG$6GZO46kpOm zKxF9-ebhXWcP=ek(>^?Os@h-Q{Z>)KjsxRg+i_#HEq;efO0K$7$x%87FTe-U=cDi|O%;g%I1Of_ zLE>vbfLm*@7+4?g6#|?(k4;X+8agxRd?Qt`I(VF|Z1QA$v*0?j6XMN-j4;ssMt2du z-O=2-Pmw>dKpeC`!ayD6AcU!?JB4u39J)KB3f@m>BiM9~QnSq!WT5r7xQ?c2>v7$H z>i|O{evxp6fhz_>B7Qq(a}pAbaHreh>w~KkTx}$19-)jB;^O)R*CPoUhB7gTwHs*( zWhy%ptfA9jn)WCY_vkJK>=^V1YB~pUzG*Js zBeuZGd@+O` zFRTQ`YD%c`KP6EC(MyLVnuH%R=4?v#*0_Tl2gIPv`+_n@GGqu|1P?2Nc|hVDL9F<1 zG8$QgNJ+351~MH2f+rx-aC4e5+SVCujFGf>SQ}}va+##t%`&8$VHjZKIFFSnr*S~Y zXzUQuJUZaTPzC&RS`Il9weWUvi!U2Vpu~_}t@D&fF1$dRw9K zvrr5~i4O>rLW`=aKzh(-dB|z7&UFlM-iVROR+UPkxHx;nbHz69u~31uZK-xG+;dA?`8PJuO~(KL literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-adder.lockb new file mode 100644 index 0000000000000000000000000000000000000000..acc7eadf715b1cc1725b694cadc363234103a0e0 GIT binary patch literal 3928 zcmds4Yitx%6y7a$i)#uLN88ud70RhIXdaXgQt&cKMa9I&<4lIU9`#=uO$nj-%naBRpn}I zYAle);k!5_t9lSA!BU^3lvyl6NtS|=stGxL2T-|c^rKQ(SJbifoEpc!7n@{U&TG#G*Ye_rTFnPfav^aYm8Mns74)~p5AWQ&C zgZ{+8;eA9VIMu+H1vp84{TJZ30h>rD~UJm$dcXu}k*djcM^2Lt2p z8rnsGq7oj#3-GL#7_J#Qu_M2&pyOz8AJZ18^0}K$ zxcHQMWX*(|XaY@K6S*{H_h0@?$u*KUY3bmO)SBaC$KABmwG}LUcEQ+!fd80Y&M&W- zE!;SlU9>c{XlH|VF0#Mf_Uo99KVKRDo6Uqv;tj(mo*z?{b7aw~*0Xy@RF~+ZXEAxL zdv0uPzS?|peh$~N);;L?z0X-bxmZ_w{p%BJ@;s5%zRn}JlI-j1wv|+U*OW2Xgxi<+ zHJNtrt_hdKcU0e$?rj~z=4X~nY+W+mzU#)~lY=(2G=KiWIPR;irVCY_w|CgH4(`f* zb>%CgjvpP;R?a0~`ltESCU$s5M)%peQ_Zgd?xSuvuE^c+E%fcC-0n@*KzIrEe_#@U z?l1jcsC3!5Ach$SsogEfPz?kE3@KHZJOv>SmCjMBREa|37|KBj0m(0iL4fzKU$Mqp9+Y{z-B%)n@T2rMigW8eqJA=b-K zw;SV{?-$&^Y+zDY7&Nt!YoJp{8 zAPEFkkt&T;TM5?J8IHlTq>dx?Sb~*j(s(>I7i$T;bJQ=oZw{@OQmjP|U`n%C=D`fU z%TGE}@(M0oOG>eNvYjxM9?xV_ttD7F#>u!~J*fvtU6)`jN@LRT)ctiM=R#l5g2w(5 zYImzB7)A;+@dFJhvJjZ5DU$5J^>~4gdtto+_DnKZ0L|{k4okb2zhps^!ia{t zTu>@#D9Ou&;!?qnR5t3#_!sd}c}OW$VH9PhN-8buK`&C~Xj++?#j*bTs%iRe1eO1IK@ob>lJv~%kwRGtm){Ann+Ho_}Mf~%n84(PV28-54&&uUH30h0j$0N literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-deps-late.lockb new file mode 100644 index 0000000000000000000000000000000000000000..4d27e1c5329e879b0da5b83f590bf8d6edab01e4 GIT binary patch literal 3904 zcmds4drVVT7;mvFtPA)^6U?$l=cYr#eE@B}V(l<$WIB}%;)V>8TY7K3(B86pZ_B_( zIJo^mglO@U_h!iLKO%9)EfFI%Z7M1XVn4@|~6ka4`d4)4^|yPzCcA?bOnpMymV)J*5^eOc8?Das4HY||7Ue{4*Tv(V zztw-d$*`k)>GAl6DtqUtU~|otKThsuUWmfQen(>Kg4wm}vNBe;UE7{qa4@5g$VqNC zEqK*@wQ+UZ0{g%Zzq(v4@kf`Me{5S4R1CJ+i*FqJ%(~}Yj?dMv__kp48+)Q~(JeJ@ zEecl`1*+Q`>{)#9cUMn*wcv=YjO$OXK0PP%mcFh#Z{6c-=j6Fvr+j71=IVU*=J~mW zmC1z-jmr7p(O&&eDZ4IQnfJ3k3Kz#4g4bP42`oDC)|RewO^KU|eKTJpbGr`RtZlj0 z(y?X{-MP&+;mM{aG#_5B+jryenXS3@;QP*j6Srdx+w1lh2fk@ekBh<`P5efwcJFQ! zE{^YzyvCvZ{gc+rE?(HRVV>c@&Gj7B<3CUdLywjIFGT87 zKM<4Di_~BfL|I|oZW5O&WUj<!w)V!dG07Mg)6RimkZ6(}47 z9TsD5-lglYMy-9W1(wnxnT)Ho7%fd&NGt5e^&qb6Vzh;+WExt+**_Yd9MzJT8f-G> z@pBTn@J)r2MAn_7NP_6PYiKv64%?fF*gK$tAseD*up!r^-rF-Gs|bFMfcl%2$_XgF zOWC3l*2T#bwti#+IjYPnmB=u|Wh4tSA^JQHPFkfXrE&&ENt_D`WT~7KOFhLh>6Khm znM@JzM}&PqDy|BLkLNkbrwDGJ!V?VPhZJ!F@O?lc2LV>3PlOyELWHM7k;h5Gkutm* zIEC_aqSGr;AO^+I)^ys+(1h8^S_!j-pG`O%EJHXLhT&+QcUqVtD(o>LclpQ$=6d)Rqav)#l*yf^IU5G&3noIN{K zzBu(I6p5r2oFofgkyL4Jc`29VvzlkxO_qBF5x$qOd?Te}|NIsdaH!qE0H^B8NbN?io9&BdX3a>W8L=`-4URq literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.3.9-ws-late.lockb new file mode 100644 index 0000000000000000000000000000000000000000..fe3e869b5fb949286cb8076eb51b0df4588a8233 GIT binary patch literal 3528 zcmdT{e@q)y9KT`(?Z$Af1`=adF&9R(fX$4vAk z*YADb_kBO_`|G{?_KC;okSx(&L8Lh`Z1RRg13aWZSX#jgevagWQh-&+u-R}QK@ifi z&g{l*xs3~!57$demVW>H@pe)<@knb@^RnmwpEkOBGwGZ=w~LL# zN3W#s9=Vv-^X9jQK6&o->*o4p*{*Fn(^@gFi)}OS-vkw5El?8d#hyMqOJs~w4|?Ex zWKv*eft91Kg;)|Op0iM$v=sYs4rVvZ0|(oa4meO0kx84(g2C}w2#I3=2j>sZV>%Pa z`(Y7!kPePBq6icFjVR)w2DLFaz0s6WY^;Vf&lkYc5i#6&X#LH?p`x3;fzPO+Xbsc0 z$a=uDWlyTheD>D4vTr(S-dZZksodKGJ^jD79ou7Q9@^BC+_B%?-xuvX`tu)OzQe4F z!;O!pKAPKFm*cGM8a|xq`Pf-P=4W=A)@`-@azL)I9m2`d_iJix2NIdmN;B=d)%`ThpUeR)wh(qm$iK5zMV@>?y9=neIosNBIi*` zDC+GaRvMZ=-*;#I#gx^$w!wi@)q%RscMBq08%8cXb}aPj$gb_(-KSsU<8V>V@7ajM zO^gH8x4&`3*>U7IFW)m-ESK8WoM-d)Hh3~U{l>w=wYq4YhyEeZQa@CFsBZU1JDRqy zy0+%)4;R1K)MsqEaq6dms0(oa&*ML6{=aY1?05K4y{jHl?LxE}n}t?b-GDa@GQvRT z8=XaDD^jgj_1kBNgY=ONh@v|PVXEeiAzXBBuBuoU)XUl6Dz-X2AqG5mX!cnqdye}E z+!vS<@fQ+z6u3h$C8on3=vy^aCn1pr$pVnz?gV!m3A(2#Gi3%T?q6^}lAvQK3xha>;HiC>k6|GV0vfb`V*$E(dol2|5d9h53p8 z=kJnKHPx6JTrw93a}xE;D+(uxtUq6o1kpEzb(<^`v17E@<)~l{;58Zs7YeiGZc!!( zkyV5+M?xRTO4TH^kdu99gIe#lr%DaBXg4e>lDg%rUbQh1Uf!%**K06Yzdwh>@y@8oy_ z5h5i+&&w%vm=nu_5)GTvjM=fm>|o5Kt&DY$Hank1dc7<|dKre{%sgLaXG&?@k&@#n zLP*otfER-a_;o@r7HX)aEz(9gmtA{a4gQshBsgqt6QgBeB3}fqZ>^da#?TI^RI7$U zPLhS7NU5}-x{}KXIc!h5P4<5(1bjbW+h$7Vs&_`U_mXBe7ojK@1wOlVKe`1I8WX@SHET&x4EwS3k~Z)&Hw-a literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-adder.lockb new file mode 100644 index 0000000000000000000000000000000000000000..6305077d9b03137fbe34b306b44ac62a5f5752ef GIT binary patch literal 3528 zcmdT{e@q)y9KT`(tTLQyvKck2^B;n{AMM(Dl&;$%i?C%I0x}6H+m+sxllIE)uB|{I zh?yG>{1HYnof4*Q|6p_@Xj}r$DZ&)VmMED96tub+)VM9oEK0!l+Iz==gU~S(eaX9T z-}}Dr=Y4;?_kG_!^dK3MMAE|xBrAkXo{(Sw9rp*zD>>fJ;#^P+Fftyd47Xtz*0VL= z(sS>|sU=e63ae}4JqYcXVO|&33UWs81 z5h>2f2ILMfE)NP)D8PyVUf=_~Br{$wD*_%Ge|E`c{4V1Vm7>q9```uVU?!P6b-mPh zwd3n`2PV(28F=N}!|y--+$E|zukIAvxUmh5b@6SM{Y_F3asb7FzR1#h5=2HgO+d~8 zaOOBB0jykQ?OH&gKNAq0vlQ9!37Fls3>>5n&;?tu3>@U2G!Qr%;Gp=Sc|_+JvB2PV zFb_MFL4Jl5Vj{aCg?t0(sNJ00bow+>76C1n0z4fS$JIjXuN04!Tp0}X5M$9Ax^so) zCHJnT)lTZ{wU52ubk)4NR*+V+uZ;{0|K545$#7(B$3R-wi>~3JXm7`_e|+&ey-9+_>#dP zEZcD8!o2&uV zzy17CN7vEI9&TW|R4TV_ImZ+nYIbM3hmE6cwYq45oBS!zesHYfaQ%T(b*+0g{jlY$ zlPmYO4jEe~PX96zbpqV~bNdgf|L?1m_zpiSx$15uM;V;F03JlYpFpoPQ6#dg%VeQU z5?{jxx%DQiiShG3A;@X-*z`=i!7~HTH&g|wgT|YcO`cRY3vIBt0Nx_V5Ch(Cco)Ij z9m}l;75S4(#DV)m4A@}~f|!cBGY}Wf!MiiA;Qa_U0?ptowbo$ds$52{X3wUMNIgrEq>MfD4+N0M|jVWuH#AJh`a zR!Hy6{F&a#8+Bz?AY{g{TEKyC;{}_ssAP09#i(;_v;meyt#VM+lB6>e7BG)t3u}NX zqkqbnl|z82!6~tUFe?&UpO#rsVEl!$$P2!CLzl@s>rqaI9cheFk} zP|S)F9~20Mx=gkkMPJPqag?QhQJz`4M1181MbB|n-_Sy)#sucuyGHp(lA%!5-uRi< fI!%)`Vl{N^ZmKz9C-+#mK-zh;b|h+dqU-+#)T%{x literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-deps-adder.lockb new file mode 100644 index 0000000000000000000000000000000000000000..163e9f6af5ff9172bcdde8f04f2b7ae2d2b19703 GIT binary patch literal 3904 zcmds4Yitx%6y7a$Th|nj)D(@%s8OL|9&LBmY1yt2AuW&sO06{5CbQj{W!l}@GLNm4 zM+%8RBk~F)6ag#oM`;t~(Go-|rB)k3o=F1;rJ6-T48{~e%OkDl?#`V~c$HH4<4tDH zoqO;3&N=s-d+wRnUZFKbroEy>^HP}bYLXouG7u^$6T|>d3L!bjsbrY353^V-#Lm-l zZPWYQ>c-0jyOw5a2i$e1l2!`Mnd1^ZZasBO-8mc+< zkbc-Mu*m|!He`MBKoN%t=P6wQ+dzt#<`EBikS*9>h$0)2U5G+VM;oGO-81muJs28) z?}%Ou6qWE8UVvw{#Bj~f@!bVoh22Mj2dJ({CEGAOeQnXq>J&F~=z6Q~?C#3VV?02MXd}P-4CAnD(+pca*Eo#at zCTFKM6O%u1{Mop$ZL+85^dEkIOVYtS$FFVEBdXnbp2#|1&*57M_Ko#BN~?Zo&PX)j4kmt0 zrro=1!o~3&)i=3sNB77@nWa-Ymrt_qxv}iTu+1GUU%ot%`KGt|d{xiwUG}WjJ$Y}e zd424$Bg4BYn52vUww&Bbk6Mt?cc%Vi%bS4vxEqQqd^da#eY@#w4Rcpa;bv(o0vj z58;~c7u3J3V-iRhG`o9lx~oWIVFO%-u4(-nfa@k){}48OgW^gHS4@Nr*9>>g#Mu~- z1OlsYm4>UWIO|&!L!nt*$KiS`&dO40ESj2+v;^LH%2$0iM>L4Z)?x=RrCBTsVFumh zC!ERoh3Bs&BwIb%PMAtJPBO06;;aniq+GBb*MqpOi?bG|QR!&v{<`73Fc`F;zQ35- z-3lKJ^D;H%BbApWE-+h_Mah51r-w-E_X2BpVUrH_d=gjy&F)4HOS_l9Bu*8>JPCC< zCodqOBroSm$~Zr-&{0nsU)V<#A-PO}Q9dhGR7gn+dU<)Cs+KERG%fRfsGQ{mlvExp zRj82crz;eigclie4yF1ly_z8KvZjgwO%+I%48z&+aVq}+5JM1P8GSMu2~kA&rzi>f zC=jW@?*y;XVP5iuWE#|yCez-b|Aj!{{`ZTcKT(-JOlwfQ=YEO;@+8~`)ppn?_3TK!5Yr%cQaze z=M0~!I-PB_{)c45Q2{S2Vo0KN>LHx>iX0+!l=UC%j&zS@J#z02OU oI$y29ub-RPc`Ol`478nJ4v@rOdwroPf*Q6#jq;sbw>ii)&ZN^GNPu+oAW=()Rdht*nPk^bpT zX3l+`@1Dm!XU;tT9OaiK$|;BxCkD(;zi5Pq@c0UgdBMXGyifA73K5`4K$l zWGNHK<1jAtiL&3zN#0_YAeHEJUO^PRf~{107L)77c((FyUdto!N%dvhwa z;BIf8>E_;xlQ-QuKdI^D9lkbj;**-H_U*@B+t@{yPnl(3v0>6aJg<)&`^fJ&6=4og z0_4XEPKXg14N=*DAEz>g(%$Va6=T0jVeWxkH+6W zwjTkGW&=FEE`l2k9pAmYBe%QR`wiIm9gCZi4plf>&xY#uUjOsVZsz4ETrsf~bEFhMp z)|uzNVYzW+P2*fg?~lK^-StT)7h8U6Toh7__8AL%PJLn9^FGJt7?yvRzvb;cQMf2e zjarMsjgJD2-xlgzc=Qi{C)dr)^|;UaOPMWId2CweBTg!{N-uE`KRC6+J3Qi;^rd%OK*}n?Z^7| z)!(ddSv#L@-EJTAbnR2RkFQoA=sA9VTaF|2p{w`wodjcL^}(Xxw{;nbQMj?(-ze1{ z+>OG;_d6`FY2aY@xV1Bi=CyB}Z9Lq!p=Hd@*82U=WYJ&u*Ifzr-u=>;*>HIAl!}z8 z=bDo{O6jD|zv?gSrY5Y;7`Rw{q5eg{9qoqh75+9fKp)~PINUQHg%_XkAE<<(M@s(} zB3)BI5aZNDYP1WYtgs#riAxnSN8&h#Ih{7kLK)}dri6EJ{gwCAPU^8E}bZ`0&VyH|N!PvN@AY`~QPuBdQjWHw9&O!RHNpC`^h z!3rE2#8n!uw&L{DNt&c#71wdN9*fg6WEz8(poj}c<4XDSf!kwi1e3nN0!(Q-T^X!E z;TUYOnsRcl+)6O%9kZ;kl&(cGuGZr8G-)MmupifhxUP%S7o?HtXbDGuEIi9pOJZuY z%bYjBN#wj$3MYxIXPF`iqWiv~!<;r~Z$`6sPz6IaM9pYNu1R~aXGB&J0vrMLH!H0s zp!6XJbr~I7$N{6;sW4@fM^E+mewc24i6*3uS22NMZ%FX z{55b272rgdPoh8!ilJ>8w2h$&i;J}p7Arr4a5`Cra54a^@Z=h-X{GoyKL(9dC398WlU zwyJ#SLZhbLq~)9>3qFxlsl#l2(B0AUQG|Q=Z=A=5&k@;4t8!*(^7>=aca_RluJNmp g6N#8;%iWWaaQ4x1%@Tfo;B|S)NcD(S?^yT$4TtiWQvd(} literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/late-dependent/1.4.2-ws-late.lockb new file mode 100644 index 0000000000000000000000000000000000000000..5dfd31ad08e354542534e26fd64b8a3a6d68100e GIT binary patch literal 3504 zcmdT`e@v8R9Dk24s8dAq8f>lkMw@Hc`{T~L>*YL7owiV?5)y{&u=n77k2mh#qxZ)V zFxh1@&jf6u8)Y%Y>>pS^m^IxZ(=i~4*;Y&&6y`10tX2{>*Btab-21$|P%;qR>e=4+ z`##V2`~5uMU(fS?AAgKeWr^|&BE^Xj+^>oj@DRapX$3C?If4&MAyy$GwBiAhVB{qV=}c0xJvaBE8Qm4gSx%&+lOAOuEAf?y9!qfu>s90^R!mta`5 zN=~rcfIJB1rD0K4L!1;6L?I-|N|04JD1qi*Ty^^lV@MMO2?Mxcy{d0Xhm~VVw-utlQe{NfD*u77Y5YEU0{rZ<*0hK$vb=J^5eOjrWf2yMPzI9znSFZ3xn9Is*8 z7TMqMZQYmdq0inpSN3g3&0EVvIh}iZuxH@6wqyG&&BL2}QacWK2m0fkM}PVA%XgU7 z23%u2&GEd}x?Fc{*T~^4-^cC}Vp~=xzJ9yo*ORqf>%BKGUJC?1Pd&5Qak*<#T(Nkv zH{Lk=fvcr~;|r1tyM6U9wHR>W?Qz$J0oQB*HMf+!m%UAn0T<@{t_=fj zvH{fG{`wJj$C2Ote9u_1T6vcd*~B>GE}1ro=3 zaXXF6LJb$@pTr$F8x#U!n70@TWNbV!;1aBFXbLh1&DUs~Jg;x2uC#jr-VDeP1DzfP=ad)NLf0o+4?I23*v?pnfFD#E>=yvMK== zWFcBEzd!z0TAN@sl{kQs4Z~^xXA$6h<+K(Q4UMK)P2Ly!P@ zKRM}Isu8t#WG)opB=YH(6;2Y_;5J1P#K5$v7q?Buj?rV6tAW)6ui>zGV3=*Ui!z3Z ztRh4>0`!rrR84>uvXU*WU;~^?p=jz0Fz{7jsX_)ToI6X92~iFCIjLMxDrGlCNn8Ll zd{Q+jR)$`b$*>fls$_})FCr0BQVG=fRi5W0RS|-!!V?S;0rg%6fM)>FHv(AtH!+bw zh)Bwy=j9YC!ii;Ji2|Eb4DDJ;yBL~ql(889fV=`NB|rqiW7rm_UnZRIi3Y zPLhSNNNUuJJO3^J`n`Z`nk_~5$!z73+$qh@Ufo{xw!T80>Hfll3&&!O>i#CG-Wath ZHw3y3^s~Q(e Date: Wed, 7 Oct 2026 18:10:49 -0400 Subject: [PATCH 38/55] Name bun install --force in the Bun lock checkout remedy A refused hosted bun.lockb rollback/remove tells the user to restore the lock with `git checkout -- bun.lockb`. Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same name@version (#764), so the plain `bun install` that follows reports no changes and node_modules keeps the patched bytes. The native backtest measured this on Bun 1.1.38 and 1.4.2 (legacy-lockb): every hosted bun.lockb cell kept the patched index.js after the checkout and a plain install, and only `bun install --force` restored the upstream bytes. The remedy for a bun.lock / bun.lockb now appends ", then run `bun install --force` (a plain `bun install` keeps the patched copy)", matching the advisory the successful rollback path already emits. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/patch/redirect/upstream/mod.rs | 40 ++++++++++++++++++- docs/ecosystems.md | 2 +- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dd423a90c..82398ce7d 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -931,7 +931,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). -* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). ### JSON envelope (legacy shape + additive always-present keys) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 434d1a03b..9cde04dae 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -365,14 +365,28 @@ impl RestoreOutcome { } /// The remedy every refusal names: restore the file from version control. +/// For a Bun lock it also names `bun install --force`: Bun's hoisted linker +/// keeps the installed patched copy when the restored entry is the registry +/// copy of the same `name@version`, so a plain `bun install` after the +/// checkout reports no changes (#764). pub fn checkout_remedy(files: &[String]) -> String { if files.is_empty() { return "restore the lockfile from version control (`git checkout -- `)" .to_string(); } + use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; + let bun = files.iter().any(|f| { + let name = f.rsplit(['/', '\\']).next().unwrap_or(f); + name == BUN_LOCK || name == BUN_LOCKB + }); format!( - "restore it from version control instead (`git checkout -- {}`)", - files.join(" ") + "restore it from version control instead (`git checkout -- {}`){}", + files.join(" "), + if bun { + ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" + } else { + "" + } ) } @@ -739,3 +753,25 @@ async fn restore_pass(view: &mut View<'_>, active: &[&HostedPin], ctx: &Ctx<'_>) npm::cleanup_side_config(view, ctx, &mut out).await; out } + +#[cfg(test)] +mod tests { + use super::checkout_remedy; + + #[test] + fn bun_lock_remedies_name_the_forced_reinstall() { + for file in ["bun.lockb", "bun.lock", "packages/app/bun.lockb"] { + let remedy = checkout_remedy(&[file.to_string()]); + assert!(remedy.contains(&format!("`git checkout -- {file}`")), "{remedy}"); + assert!(remedy.ends_with( + ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" + ), "{remedy}"); + } + for file in ["yarn.lock", "package-lock.json", "bun.lock.bak"] { + assert_eq!( + checkout_remedy(&[file.to_string()]), + format!("restore it from version control instead (`git checkout -- {file}`)") + ); + } + } +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 673352abf..cec67f571 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -167,7 +167,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. hosted bun packages to its upstream registry entry. A hosted `bun.lockb` entry is not rolled back (v5.0 keeps no hosted ledger, and a rebuilt binary record is not byte-exact for every lock): rollback and remove refuse it with the `git checkout -- bun.lockb` - remedy, while the hosted → vendored takeover rebuilds its npm registry record natively + remedy (then `bun install --force`: a plain install keeps the patched copy), while the hosted → vendored takeover rebuilds its npm registry record natively and vendors over it. Each restore reads the package's version document from the registry Bun resolves it against (`.npmrc` / `bunfig.toml` scope and default registries, `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), sending the credentials From 0f0f3c2ab80ff286c210e6b14394ddd7db557111 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 18:10:49 -0400 Subject: [PATCH 39/55] Follow the Bun reinstall advisory in the native backtest rollback Every native Bun cell failed rollbackWarningsClean on e470b9b2: the rollback now emits vendor_bun_reinstall_required / redirect_bun_reinstall_required (#764), which the INFORMATIONAL allowlist did not know. They are advisories, so they join it. The other codes this PR introduced (*_bun_default_trust_lost, *_non_registry_entry_skipped, vendor_bun_lockb_duplicate_records) stay out on purpose: no fixture can legitimately trigger them, so seeing one is a misclassification. The reinstall step used to delete node_modules first, which hid #764 entirely. It now rolls back over a patched install (the corrupt-digest step had broken it), runs a plain `bun install` over the kept node_modules, and when patched bytes remain requires that the rollback advised `bun install --force` (the advisory, or the refused bun.lockb checkout remedy) before running it and checking the upstream bytes. That exposed a harness false negative on the isolated linker (and Bun 1.4 workspaces, which default to it): Bun links the registry store entry and leaves the superseded patched entry under node_modules/.bun unlinked, so the byte oracle now counts only store entries something links to. The custom-registry hosted expectation ("" slot) is still right after #992: the fixture configures no registry, and the injected URL is npmjs's own tarball, which Bun writes as "" on every release. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/testing/bun-compatibility.md | 28 +++++-- scripts/backtest-bun.py | 101 +++++++++++++++++++++-- scripts/tests/test_backtest_harnesses.py | 50 +++++++++++ 3 files changed, 164 insertions(+), 15 deletions(-) diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 655de47b8..2dd6cc233 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -386,7 +386,8 @@ workspace` (vendor a plain project, add a workspace member, `bun install`, re-run — must be `already_vendored`; then `repair` rebuilds a deleted tarball), `crlf` (CRLF manifest), `crlf-lock` (CRLF `bun.lock`), `space-unicode` (a path with spaces and Unicode), `custom-registry` (a -non-empty registry slot the rewrite must drop), `text` (`--save-text-lockfile` +non-empty registry slot the rewrite must drop; the project configures no +registry, so a hosted rollback restores Bun's `""` npmjs slot, #992), `text` (`--save-text-lockfile` opt-in, Bun ≥ 1.1.39 only — asserts `bun.lock` exists after the baseline), `isolated` / `hoisted` linkers, `lockfile-only` (no `node_modules`), `production`, `get-uuid`, `get-search`, `legacy-lockb` (the baseline is @@ -400,7 +401,12 @@ boundaries above — not the CLI's own output — and every cell asserts `supported` against it and the refusal codes EXACTLY, after removing an explicit informational allowlist (`vendor_prebuilt_downloaded`, `vendor_fetched_missing`, `reinstall_required`, -…); substring matching is never used. A configuration expected to be +`vendor_bun_reinstall_required` / `redirect_bun_reinstall_required`, +…); substring matching is never used. `*_bun_default_trust_lost`, +`*_non_registry_entry_skipped` and `vendor_bun_lockb_duplicate_records` are +deliberately not on it: no fixture rewires a default-trusted package, holds a +non-registry copy or a duplicate `bun.lockb` record, so each would be a +misclassification. A configuration expected to be supported FAILS on unexpected warnings, `redirect_bun_entry_not_found` or `redirect_revert_failed`. Exit codes are recorded for every invocation and asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` @@ -428,11 +434,19 @@ asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` - `rejectCorruptDigest`: a tampered sha512 on the PATCHED tuple is rejected on Bun ≥ 1.3.10; below that the observation is RECORDED (`legacyDigestBehavior`) rather than asserted; -- rollback restores the original manifest / lock bytes, removes the - `.socket/vendor` state, and a clean install reproduces the record's - `beforeHash` bytes; text projects retain `bun.lock`, and binary projects - retain `bun.lockb` without creating a text lock. The original lock presence - and SHA-256 are both checked. +- rollback (run over a patched install) restores the original manifest / + lock bytes, removes the `.socket/vendor` state, and the reinstall reproduces + the record's `beforeHash` bytes; text projects retain `bun.lock`, and binary + projects retain `bun.lockb` without creating a text lock. The original lock + presence and SHA-256 are both checked. The reinstall is a plain + `bun install` over the kept `node_modules`; Bun's hoisted linker keeps the + patched copy there (#764), so when it does the rollback must have emitted + `vendor_bun_reinstall_required` / `redirect_bun_reinstall_required` + (`rollbackReinstallAdvised`; for the refused hosted `bun.lockb`, the + refusal's checkout remedy names it) and the cell follows that advice with + `bun install --force`. The isolated linker links the registry entry and + leaves the superseded patched store entry under `node_modules/.bun` + unlinked; the byte oracle counts only store entries something links to. The runner captures the exact project manifests, lockfiles, the ledgers (and a `.socket/manifest.json` only where the `preexisting-manifest` shape seeded one), diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index d35b524aa..52c29b257 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -67,10 +67,14 @@ Every cell records the CLI exit codes (main, repeat, rollback, conversion), the exact refusal-code set, the repeat-run envelope semantics, digest enforcement, and after rollback the lockfile presence rules and byte identity. -A hosted rollback restores the DEFAULT upstream registry entry re-resolved -from the npm registry (a custom-registry slot comes back as bun's default +A hosted rollback restores the upstream entry of the project's registry +(#992: the full tarball URL for a non-default registry, bun's `""` for npmjs — +the custom-registry fixture configures none, so its injected slot comes back as `""`); a hosted bun.lockb is binary, so its rollback refuses with the -`git checkout -- bun.lockb` remedy and the cell applies that remedy. +`git checkout -- bun.lockb` remedy and the cell applies that remedy. Rollback +runs over a patched install; the reinstall is a plain `bun install` over the +kept node_modules, and where Bun keeps the patched copy (#764) the rollback must +have advised `bun install --force`, which the cell then runs. Provenance: `--cli-revision` is the branch-resolvable commit the row is about (PR head, or the pushed commit); `--cli-build-sha` (or the CLI_BUILD_SHA @@ -131,11 +135,23 @@ TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10) # URL/local tarball sha512 verified NO_PEER_OR_OVERRIDE = ('0.8.1', '1.0.0') # peers not installed, overrides ignored +# Bun's hoisted linker keeps the installed copy when a lock entry moves back to +# the registry copy of the same name@version, so `rollback` / `vendor --revert` +# name `bun install --force` whenever node_modules may still hold it (#764). +# The rollback step follows that advice (see `rollbackReinstallAdvised`). +BUN_REINSTALL_ADVISORIES = {'vendor_bun_reinstall_required', 'redirect_bun_reinstall_required'} # Advisory codes a SUPPORTED run may carry; everything else is a refusal. +# Deliberately NOT here: `*_bun_default_trust_lost` (#371 — minimist is not on +# Bun's default-trusted list), `*_non_registry_entry_skipped` (#497 — every +# fixture resolves minimist from the registry) and +# `vendor_bun_lockb_duplicate_records` (#861 — no fixture carries a nested +# duplicate record). On these fixtures each would be a misclassification, so it +# must fail the cell rather than be waved through. INFORMATIONAL = { 'vendor_prebuilt_downloaded', 'vendor_prebuilt_unavailable', 'vendor_prebuilt_pending', 'vendor_fetched_missing', 'reinstall_required', 'vendor_takeover_reverted_redirect', 'redirect_takeover_reverted_vendored', + *BUN_REINSTALL_ADVISORIES, } # Codes that mean the rewriter or the takeover broke on a supported configuration. REGRESSION_CODES = { @@ -499,11 +515,50 @@ def outcome(supported, codes=(), exit='zero', limitation=None, rerun=False): return outcome(True) +def store_entry(path): + """The isolated-linker store entry (`node_modules/.bun/`) holding + `path`, or None outside one (`.bun/node_modules` is Bun's hoist dir).""" + parts = path.parts + for i in range(len(parts) - 2): + if parts[i] == 'node_modules' and parts[i + 1] == '.bun' and parts[i + 2] != 'node_modules': + return Path(*parts[:i + 3]) + return None + + +def linked_store_entries(project): + """Every isolated store entry some symlink outside it resolves into. Bun's + isolated linker leaves the entry of a superseded resolution (the patched + `minimist@https+++patch.socket.dev+…`) on disk, unlinked, after the lock + moves back to the registry; nothing can `require` it (the #599 orphans).""" + live = set() + for directory, subdirs, files in os.walk(project): + if '.socket' in Path(directory).relative_to(project).parts: + subdirs[:] = [] + continue + for name in [*subdirs, *files]: + link = Path(directory) / name + # Bun links with junctions on Windows (Path.is_junction: 3.12+). + if not (link.is_symlink() or getattr(link, 'is_junction', lambda: False)()): + continue + entry = store_entry(link.resolve()) + if entry is not None and store_entry(Path(directory).resolve() / name) != entry: + live.add(entry) + return live + + def installed_targets(project): + """The installed minimist@1.2.2 copies node can load: every copy outside + an isolated store, and the store copies something links to.""" targets = [] + live = None for manifest in project.rglob('package.json'): if 'node_modules' not in manifest.parts or '.socket' in manifest.parts: continue + entry = store_entry(manifest) + if entry is not None: + live = linked_store_entries(project) if live is None else live + if entry.resolve() not in live: + continue data = json.loads(manifest.read_text(encoding='utf-8')) if data.get('name') == 'minimist' and data.get('version') == '1.2.2': targets.append(manifest.parent) @@ -1234,6 +1289,11 @@ def vex(label, *extra, via='vex'): version, 'rejected' if row['rejectsCorruptDigest'] else 'accepted', '.'.join(map(str, TARBALL_INTEGRITY_ENFORCED_FROM)))) lock.write_bytes(patched_lock) + # Roll back from what a user actually has: a patched install + # (the corrupt-digest install above removed or broke it), so the + # reinstall below judges whether Bun keeps the patched copy (#764). + code, _ = install(bun, 'pre-rollback', ['--frozen-lockfile'], cache='cache-pre-rollback') + row['preRollbackPatched'] = code == 0 and oracle(project, record, 'after')[0] code, output = run([cli, 'rollback', '--cwd', project, '--json', '--yes', '--no-telemetry'], project, env, case / 'rollback.log', False) exit_codes['rollback'] = code @@ -1241,6 +1301,10 @@ def vex(label, *extra, via='vex'): rollback_codes = [w.get('code') for w in rolled.get('warnings', [])] row['rollbackWarnings'] = rollback_codes expected_files = dict(original) + # Whether the rollback told the user a plain `bun install` is not + # enough (#764): the reinstall advisory, or — when it refused — + # the refusal's own remedy. + reinstall_advised = bool(set(rollback_codes) & BUN_REINSTALL_ADVISORIES) if main_mode == 'hosted' and lockb_origin: # bun.lockb is binary: the v5 upstream restore refuses it # with the version-control remedy and writes nothing; the @@ -1251,12 +1315,20 @@ def vex(label, *extra, via='vex'): 'git checkout -- bun.lockb' in (f.get('error') or '') for f in failed) and lock.read_bytes() == patched_lock) + reinstall_advised = any(f.get('purl') == PURL and + 'bun install --force' in (f.get('error') or '') + for f in failed) lock.write_bytes(original['bun.lockb']) else: checks['rollbackSucceeded'] = code == 0 and rolled.get('status') == 'success' if main_mode == 'hosted' and shape == 'custom-registry': - # The upstream restore writes the DEFAULT registry entry: - # bun's "" slot, i.e. the lock before the slot injection. + # The upstream restore writes the slot Bun itself would + # write for the PROJECT's registry (#992): the full + # tarball URL for a non-default registry, `""` for + # npmjs and its aliases — on every Bun release. This + # fixture configures no registry (the injected URL is + # npmjs's own tarball), so the restore is bun's `""` + # slot, i.e. the lock before the slot injection. expected_files['bun.lock'] = pre_injection checks['rollbackOriginalFiles'] = all( (project / n).exists() and (project / n).read_bytes() == b @@ -1270,9 +1342,22 @@ def vex(label, *extra, via='vex'): checks['rollbackWarningsClean'] = not set(rollback_codes) - INFORMATIONAL if shape == 'crlf-lock': checks['rollbackEolPreserved'] = crlf_only(lock.read_bytes()) - code, _ = install(bun, 'reinstall', cache='cache-rollback') - checks['rollbackOriginalBytes'], row['rollbackFiles'] = oracle(project, record, 'before') - checks['rollbackOriginalBytes'] = code == 0 and checks['rollbackOriginalBytes'] + # The user's next step: a plain `bun install` over the KEPT + # node_modules. Bun's hoisted linker does not re-extract a + # package whose entry returned to the registry copy of the same + # name@version (#764), so when the plain install leaves patched + # bytes the rollback must have said so (`*_bun_reinstall_required`) + # and the cell then follows that advice: `bun install --force`. + code, _ = run([bun, 'install', '--ignore-scripts'], project, + env_for(bun, 'cache-rollback'), case / 'reinstall.log', False) + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before') + row['rollbackPlainReinstallOriginal'] = code == 0 and plain_ok + if not row['rollbackPlainReinstallOriginal']: + checks['rollbackReinstallAdvised'] = reinstall_advised + code, _ = run([bun, 'install', '--ignore-scripts', '--force'], project, + env_for(bun, 'cache-rollback'), case / 'reinstall-force.log', False) + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before') + checks['rollbackOriginalBytes'] = code == 0 and plain_ok row['passed'] = all(checks.values()) except Exception as error: # noqa: BLE001 — every cell must produce a row row['error'] = str(error) diff --git a/scripts/tests/test_backtest_harnesses.py b/scripts/tests/test_backtest_harnesses.py index e0e380536..56a4e5073 100644 --- a/scripts/tests/test_backtest_harnesses.py +++ b/scripts/tests/test_backtest_harnesses.py @@ -93,6 +93,56 @@ def run_case(_job): self.assertEqual(len(row['networkRetryAttempts']), 2) +class BunInformationalCodeTests(unittest.TestCase): + def test_reinstall_advisories_are_informational(self): + # #764: rollback / vendor --revert name `bun install --force`; the + # advisory is not a refusal, and rerun_clean must accept it too. + for code in ('vendor_bun_reinstall_required', 'redirect_bun_reinstall_required'): + self.assertIn(code, bun.INFORMATIONAL) + self.assertIn(code, bun.BUN_REINSTALL_ADVISORIES) + envelope = {'status': 'success', 'redirect': {'redirected': 1, 'warnings': [ + {'code': 'redirect_bun_reinstall_required'}]}} + self.assertTrue(bun.rerun_clean(0, envelope, 'hosted')) + + def test_misclassification_codes_stay_refusals(self): + # No fixture rewires a default-trusted package (#371), holds a + # non-registry copy (#497) or a duplicate bun.lockb record (#861). + for code in ('redirect_bun_default_trust_lost', 'vendor_bun_default_trust_lost', + 'redirect_bun_non_registry_entry_skipped', 'vendor_non_registry_entry_skipped', + 'vendor_bun_lockb_duplicate_records'): + self.assertNotIn(code, bun.INFORMATIONAL) + + +class BunInstalledTargetsTests(unittest.TestCase): + def test_unlinked_isolated_store_entry_is_not_installed(self): + # After a rollback Bun's isolated linker links the registry entry and + # leaves the patched one on disk, unlinked: only the linked copy counts. + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + store = project / 'node_modules/.bun' + for key in ('minimist@1.2.2', 'minimist@https+++patch.socket.dev+x'): + pkg = store / key / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + (store / 'node_modules').mkdir() + try: + (store / 'node_modules/minimist').symlink_to('../minimist@1.2.2/node_modules/minimist') + (project / 'node_modules/minimist').symlink_to('.bun/minimist@1.2.2/node_modules/minimist') + except OSError: + self.skipTest('symlinks unavailable') + self.assertEqual(bun.installed_targets(project), + [store / 'minimist@1.2.2/node_modules/minimist']) + self.assertIsNone(bun.store_entry(store / 'node_modules/minimist')) + + def test_hoisted_copies_are_always_installed(self): + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + pkg = project / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + self.assertEqual(bun.installed_targets(project), [pkg]) + + class BunManifestlessVexHelperTests(unittest.TestCase): UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' VULNS = {'GHSA-xvch-5gv4-984h': ['CVE-2021-44906']} From 486075b35bcd890c064b9e7c7ff69625d2a78263 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 17:56:25 -0400 Subject: [PATCH 40/55] Build the hoist test's fixture label with `/` on every platform hoist_reproduces_every_captured_writers_trees picks out the Bun 0.1.x locks (whose writer did not hoist, so they must NOT reproduce) with label.starts_with("bun-lockb/0.1."). The label came from Path::display(), which on Windows yields "bun-lockb\0.1.7\bun.lockb", so the era check never matched there and the 0.1.x locks fell into the assert_eq branch, failing windows-latest only. Join the relative path's components with `/` so the check is separator-independent. The .lockb fixtures are classified binary by git (ls-files --eol shows -text) and the text bun.lock fixtures already carry -text, so no EOL conversion was involved and .gitattributes needs no change. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vendor/bun_lockb.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lockb.rs b/crates/socket-patch-core/src/vendor/bun_lockb.rs index c3ac6d918..7cb80debf 100644 --- a/crates/socket-patch-core/src/vendor/bun_lockb.rs +++ b/crates/socket-patch-core/src/vendor/bun_lockb.rs @@ -3131,7 +3131,15 @@ mod tests { ); let graph = lock.hoist_graph().unwrap(); let hoisted = hoist(&graph); - let label = path.strip_prefix(&root).unwrap().display().to_string(); + // Joined with `/` on every platform: `display()` would use `\` + // on Windows and miss the `bun-lockb/0.1.` era check below. + let label = path + .strip_prefix(&root) + .unwrap() + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); if label.starts_with("bun-lockb/0.1.") { assert_ne!(hoisted, Some(own), "{label}"); } else { From fc2d602a2f5ae76818543e26eac409829e0dac87 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 18:13:04 -0400 Subject: [PATCH 41/55] Document the Bun --force suffix on the takeover refusal detail checkout_remedy also builds the vendored-takeover redirect_revert_failed detail, so takeovers over a hosted bun.lock / bun.lockb pin now carry the same 'then run bun install --force' advice as rollback and remove. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 82398ce7d..883df6e45 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a workspace exact-pin override (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview does not model the takeover yet (it still lists such a purl `would_vendor`), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a workspace exact-pin override (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview does not model the takeover yet (it still lists such a purl `would_vendor`), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`) (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -1272,7 +1272,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_symlink_unsupported` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | -| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `errorCode` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from the nearest ancestor `pnpm-workspace.yaml`, which pnpm reads alone (a member's own file is ignored). When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. Disk runs only. | From d8c07dd4fbac1492c827be84a20baa9b53b4424f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 18:16:53 -0400 Subject: [PATCH 42/55] Keep the patch uuid out of the #497 vex test messages CodeQL's rust/cleartext-logging rule flags any uuid-derived value printed in an assert message. Label the hosted / vendored loops with a literal mode instead of the wired spec. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vex/discover/bun.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 070620f7f..d1e2c2061 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -1060,7 +1060,7 @@ mod tests { "text-2/git", ] { let text = std::fs::read_to_string(root.join(dir).join("bun.lock")).unwrap(); - for wired in [&hosted, &vendored] { + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { let lock: String = text .lines() .map( @@ -1080,7 +1080,7 @@ mod tests { assert_eq!( unversioned_contests(&out), 1, - "{dir} {wired}: {:#?}", + "{dir} {mode}: {:#?}", out.diagnostics ); @@ -1092,13 +1092,13 @@ mod tests { let p = Project::new(); p.write("bun.lock", &without_root); let out = run(&p).await; - assert_eq!(out.refs.len(), 1, "{dir} {wired} control: {:#?}", out); - assert_eq!(unversioned_contests(&out), 0, "{dir} {wired} control"); + assert_eq!(out.refs.len(), 1, "{dir} {mode} control: {:#?}", out); + assert_eq!(unversioned_contests(&out), 0, "{dir} {mode} control"); } } for shape in ["file", "url", "git"] { let bytes = std::fs::read(root.join("lockb").join(shape).join("bun.lockb")).unwrap(); - for wired in [&hosted, &vendored] { + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { let mut lock = crate::vendor::bun_lockb::BunLockb::parse(&bytes).unwrap(); let id = lock .packages() @@ -1115,7 +1115,7 @@ mod tests { assert_eq!( unversioned_contests(&out), 1, - "{shape} {wired}: {:#?}", + "{shape} {mode}: {:#?}", out.diagnostics ); } @@ -1162,7 +1162,7 @@ mod tests { let hosted = hosted_url("npm", "is-number", "6.0.0", UUID_A, "is-number-6.0.0.tgz"); let vendored = format!(".socket/vendor/npm/{UUID_A}/is-number-6.0.0.tgz"); for shape in ["url", "file"] { - for wired in [&hosted, &vendored] { + for (mode, wired) in [("hosted", &hosted), ("vendored", &vendored)] { let bytes = std::fs::read( fixture_path("bun-lockb-user-tarball") .join(shape) @@ -1185,7 +1185,7 @@ mod tests { assert_eq!( user_tarball_contests(&out), 1, - "{shape} {wired}: {:#?}", + "{shape} {mode}: {:#?}", out.diagnostics ); } From 101214b870b5cafef9221022faf172847a4bfdec Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 19:32:16 -0400 Subject: [PATCH 43/55] Record Bun 1.3.0's stale hidden hoist link as an upstream limitation Bun 1.3.0's isolated linker keeps node_modules/.bun/node_modules/minimist on the superseded patched store entry after a rollback, even under `bun install --force`, while every declared dependency links the restored registry entry. 1.3.1 repoints it (measured 1.3.0-1.3.14). The rollback cell now records that case under upstreamLimitations instead of failing rollbackOriginalBytes / rollbackReinstallAdvised. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/testing/bun-compatibility.md | 5 +++ scripts/backtest-bun.py | 39 ++++++++++++++++++++---- scripts/tests/test_backtest_harnesses.py | 26 ++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 2dd6cc233..26ef8e5d5 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -447,6 +447,11 @@ asserted: supported → 0; hosted refusals → 0 with `redirect.redirected == 0` `bun install --force`. The isolated linker links the registry entry and leaves the superseded patched store entry under `node_modules/.bun` unlinked; the byte oracle counts only store entries something links to. + Bun 1.3.0 (only; 1.3.1 fixed it, measured 1.3.0–1.3.14) also leaves its + hidden hoist link `node_modules/.bun/node_modules/minimist` on that + superseded entry, even under `--force`; when that link is the only patched + copy left, the cell records it under `upstreamLimitations` instead of + failing. The runner captures the exact project manifests, lockfiles, the ledgers (and a `.socket/manifest.json` only where the `preexisting-manifest` shape seeded one), diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 52c29b257..29997e12f 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -134,6 +134,10 @@ LINKER_FROM = (1, 3, 0) # bunfig [install] linker TARBALL_INTEGRITY_ENFORCED_FROM = (1, 3, 10) # URL/local tarball sha512 verified NO_PEER_OR_OVERRIDE = ('0.8.1', '1.0.0') # peers not installed, overrides ignored +# Bun 1.3.0's isolated linker leaves `node_modules/.bun/node_modules/` +# (the hidden hoist link) on a superseded store entry, even under +# `bun install --force`; 1.3.1 repoints it. Measured on 1.3.0-1.3.14. +STALE_HIDDEN_HOIST = ('1.3.0',) # Bun's hoisted linker keeps the installed copy when a lock entry moves back to # the registry copy of the same name@version, so `rollback` / `vendor --revert` @@ -525,16 +529,26 @@ def store_entry(path): return None -def linked_store_entries(project): +def hidden_hoist(directory): + """Whether `directory` is Bun's hidden hoist dir (`node_modules/.bun/node_modules`).""" + parts = directory.parts + return parts[-3:] == ('node_modules', '.bun', 'node_modules') + + +def linked_store_entries(project, skip_hidden_hoist=False): """Every isolated store entry some symlink outside it resolves into. Bun's isolated linker leaves the entry of a superseded resolution (the patched `minimist@https+++patch.socket.dev+…`) on disk, unlinked, after the lock - moves back to the registry; nothing can `require` it (the #599 orphans).""" + moves back to the registry; nothing can `require` it (the #599 orphans). + `skip_hidden_hoist` ignores the links in Bun's hidden hoist dir (see + STALE_HIDDEN_HOIST).""" live = set() for directory, subdirs, files in os.walk(project): if '.socket' in Path(directory).relative_to(project).parts: subdirs[:] = [] continue + if skip_hidden_hoist and hidden_hoist(Path(directory)): + continue for name in [*subdirs, *files]: link = Path(directory) / name # Bun links with junctions on Windows (Path.is_junction: 3.12+). @@ -546,7 +560,7 @@ def linked_store_entries(project): return live -def installed_targets(project): +def installed_targets(project, skip_hidden_hoist=False): """The installed minimist@1.2.2 copies node can load: every copy outside an isolated store, and the store copies something links to.""" targets = [] @@ -556,7 +570,7 @@ def installed_targets(project): continue entry = store_entry(manifest) if entry is not None: - live = linked_store_entries(project) if live is None else live + live = linked_store_entries(project, skip_hidden_hoist) if live is None else live if entry.resolve() not in live: continue data = json.loads(manifest.read_text(encoding='utf-8')) @@ -565,8 +579,8 @@ def installed_targets(project): return targets -def oracle(project, record, side): - targets = installed_targets(project) +def oracle(project, record, side, skip_hidden_hoist=False): + targets = installed_targets(project, skip_hidden_hoist) checks = {} for target in targets: for filename, hashes in record['files'].items(): @@ -1357,6 +1371,19 @@ def vex(label, *extra, via='vex'): code, _ = run([bun, 'install', '--ignore-scripts', '--force'], project, env_for(bun, 'cache-rollback'), case / 'reinstall-force.log', False) plain_ok, row['rollbackFiles'] = oracle(project, record, 'before') + if code == 0 and not plain_ok and version in STALE_HIDDEN_HOIST: + # Every copy a declared dependency reaches is original; + # only Bun's own stale hidden hoist link (which no install + # flag repoints on this release) still reaches the + # superseded patched entry. Recorded, not asserted. + plain_ok, row['rollbackFiles'] = oracle(project, record, 'before', + skip_hidden_hoist=True) + if plain_ok: + checks.pop('rollbackReinstallAdvised', None) + row.setdefault('upstreamLimitations', []).append( + 'Bun %s keeps node_modules/.bun/node_modules/minimist on the ' + 'superseded patched store entry after rollback, even under ' + '`bun install --force` (fixed in 1.3.1)' % version) checks['rollbackOriginalBytes'] = code == 0 and plain_ok row['passed'] = all(checks.values()) except Exception as error: # noqa: BLE001 — every cell must produce a row diff --git a/scripts/tests/test_backtest_harnesses.py b/scripts/tests/test_backtest_harnesses.py index 56a4e5073..156352a1a 100644 --- a/scripts/tests/test_backtest_harnesses.py +++ b/scripts/tests/test_backtest_harnesses.py @@ -134,6 +134,32 @@ def test_unlinked_isolated_store_entry_is_not_installed(self): [store / 'minimist@1.2.2/node_modules/minimist']) self.assertIsNone(bun.store_entry(store / 'node_modules/minimist')) + def test_stale_hidden_hoist_link_is_skipped_only_on_request(self): + # Bun 1.3.0 leaves `.bun/node_modules/minimist` on the superseded + # patched entry while the member links the registry one. + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp).resolve() + store = project / 'node_modules/.bun' + for key in ('minimist@1.2.2', 'minimist@https+++patch.socket.dev+x'): + pkg = store / key / 'node_modules/minimist' + pkg.mkdir(parents=True) + (pkg / 'package.json').write_text('{"name": "minimist", "version": "1.2.2"}') + (store / 'node_modules').mkdir() + member = project / 'packages/consumer/node_modules' + member.mkdir(parents=True) + try: + (store / 'node_modules/minimist').symlink_to( + '../minimist@https+++patch.socket.dev+x/node_modules/minimist') + (member / 'minimist').symlink_to( + '../../../node_modules/.bun/minimist@1.2.2/node_modules/minimist') + except OSError: + self.skipTest('symlinks unavailable') + registry = store / 'minimist@1.2.2/node_modules/minimist' + patched = store / 'minimist@https+++patch.socket.dev+x/node_modules/minimist' + self.assertEqual(sorted(bun.installed_targets(project)), sorted([registry, patched])) + self.assertEqual(bun.installed_targets(project, skip_hidden_hoist=True), [registry]) + self.assertEqual(bun.STALE_HIDDEN_HOIST, ('1.3.0',)) + def test_hoisted_copies_are_always_installed(self): with tempfile.TemporaryDirectory() as tmp: project = Path(tmp).resolve() From dcbfd9c8c5e97b7b1ed1d823109c0748a48267a2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 21:41:38 -0400 Subject: [PATCH 44/55] Retry Bun cells whose harness fetch was reset A connection reset on the harness's own fetch (published record, hosted tarball digest) surfaced as the cell error `` and failed the cell outright; it now earns the same fresh-cell retry as CLI and bun transport failures. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/backtest-bun.py | 8 +++++++- scripts/tests/test_backtest_harnesses.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 29997e12f..c7f10aa77 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -188,6 +188,11 @@ def save(path, data): r'^error: (?:Connection\w+|FailedToOpenSocket|Timeout|TLSHandshakeTimeout) downloading ' r'|^error: GET \S+ - 5\d\d\b', re.M) +# The harness's own fetches (published record, hosted tarball digest) that +# still fail after their in-place retries surface as the cell's `error`: +# ``. +HARNESS_TRANSPORT_FAILURE = re.compile(r'^'})) + self.assertTrue(bun.has_transport_failure({'error': '[Errno 54] Connection reset by peer'})) + self.assertFalse(bun.has_transport_failure({'error': 'KeyError: bun.lock'})) + def test_a_patch_api_5xx_is_a_transport_failure(self): self.assertTrue(bun.has_transport_failure({'repeat': {'error': ( 'failed to resolve patch references: API request failed with status 503: upstream ' From e26ccf409d9046baabc12c94710cc67121165855 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 8 Oct 2026 07:58:53 +0000 Subject: [PATCH 45/55] Fix build after merging main Main replaced npm_crawler's private normalize_lexically with the shared relpath helper and added a field to RewriteOptions. Point the bun store-link callers at the shared helper and fill in the new field in the remaining test initializer. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/crawlers/npm_crawler.rs | 14 +++++++++++--- crates/socket-patch-core/src/hosted/engine.rs | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 91aff4f38..e2cb39da5 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -810,7 +810,9 @@ fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { continue; }; let Some((store, entry)) = bun_store_entry_of(&real).or_else(|| { - let (store, entry) = bun_store_entry_of(&normalize_lexically(path))?; + let (store, entry) = bun_store_entry_of( + &crate::utils::relpath::normalize_lexically_keeping_escapes(path), + )?; Some((std::fs::canonicalize(store).ok()?, entry)) }) else { continue; @@ -1101,7 +1103,11 @@ fn bun_store_link_targets_sync( for link in &links { let lexical = std::fs::read_link(link) .ok() - .and_then(|target| Some(normalize_lexically(&link.parent()?.join(target)))) + .and_then(|target| { + Some(crate::utils::relpath::normalize_lexically_keeping_escapes( + &link.parent()?.join(target), + )) + }) .and_then(|path| dirs.entry_of(&path)); if let Some(Some(entry)) = lexical { targets.push(entry); @@ -6369,7 +6375,9 @@ mod tests { std::os::unix::fs::symlink(target, link).unwrap(); #[cfg(windows)] link_dir( - &normalize_lexically(&link.parent().unwrap().join(target)), + &crate::utils::relpath::normalize_lexically_keeping_escapes( + &link.parent().unwrap().join(target), + ), link, ); } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9e0234a50..eead38e45 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -2784,6 +2784,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; From 8ddcb3f1f827e1e5a7d10168008e0e54c561d580 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 8 Oct 2026 07:58:53 +0000 Subject: [PATCH 46/55] Name bun install --force in rollback dry runs A dry-run rollback of a Bun pin printed the generic note that the next package-manager install refreshes the tree, which is what Bun's hoisted linker does not do (#764). The Bun reinstall advisory only fired on the real run, so the preview gave the wrong advice. Emit the advisory on dry runs too, for both the hosted unwind and the vendored revert, so the preview's reinstall note names `bun install --force` just like the real run. The check only reads node_modules, so the preview still writes nothing. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/rollback.rs | 4 ++- .../tests/covgap_commands_rollback.rs | 29 +++++++++++++++++++ .../src/vendor/bun_binary.rs | 16 ++++++++-- .../socket-patch-core/src/vendor/bun_lock.rs | 21 ++++++++++---- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index a65f16729..ec2c78066 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -973,7 +973,9 @@ pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> H .extend(outcome.reverted_files.iter().cloned()); // Bun's hoisted linker keeps the patched copies of the pins it no // longer pins (#764): say so, with the install that does reinstall. - if !common.dry_run && outcome.flush_error.is_none() { + // A dry run says it too, so the preview's reinstall note names + // `bun install --force` like the real run's. + if outcome.flush_error.is_none() { use socket_patch_core::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; use socket_patch_core::vendor::bun_lock; let bun_purls = outcome diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 91b8b5338..68d3d5268 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -2143,6 +2143,35 @@ fn bun_lock_rollback_warns_that_a_hoisted_copy_is_kept() { "the human run names the forcing install; stderr=\n{stderr}" ); + // The preview warns the same way: its run-level `reinstall_required` + // note would otherwise promise that the next plain install refreshes + // the tree, which is the #764 failure. + let hoisted = project(true); + let (code, stdout, stderr) = run_hosted( + hoisted.path(), + &["rollback", "--dry-run", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let env = parse_envelope(&stdout, &stderr); + assert!(has_code(&env), "stdout=\n{stdout}"); + assert!( + env["warnings"] + .as_array() + .is_some_and(|ws| ws.iter().any(|w| { + w["code"] == "reinstall_required" + && w["detail"] + .as_str() + .is_some_and(|d| d.contains("`bun install --force`")) + })), + "the preview's reinstall note names the forcing install; stdout=\n{stdout}" + ); + assert_eq!( + std::fs::read_to_string(hoisted.path().join("bun.lock")).unwrap(), + bun_lock(&bun_redirected_line()), + "a dry run writes nothing" + ); + let fresh = project(false); let (code, stdout, stderr) = run_hosted( fresh.path(), diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 5bbff7854..30ce3df36 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -1451,7 +1451,13 @@ mod rebuild_tests { let dry = super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) .await; - assert!(dry.success && dry.warnings.is_empty(), "{bun}: {dry:?}"); + // The preview's only advisory is the reinstall one the real + // revert also gives for the fixture's hoisted copy (#764). + let dry_codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert!( + dry.success && dry_codes == [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {dry:?}" + ); assert_eq!( std::fs::read_to_string(fx.root().join(TEXT_LOCK)).unwrap(), vendored @@ -1585,7 +1591,13 @@ mod rebuild_tests { let dry = super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(true)) .await; - assert!(dry.success && dry.warnings.is_empty(), "{bun}: {dry:?}"); + // The preview's only advisory is the reinstall one the real + // revert also gives for the fixture's hoisted copy (#764). + let dry_codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert!( + dry.success && dry_codes == [super::super::bun_lock::REINSTALL_REQUIRED], + "{bun}: {dry:?}" + ); let outcome = super::super::bun_lock::revert_bun_opts(&entry, fx.root(), RevertOpts::new(false)) .await; diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4bdbb116c..3a417252e 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -890,9 +890,10 @@ pub(crate) async fn revert_bun( /// [`revert_bun`] with full [`RevertOpts`]: `keep_artifact` skips the /// artifact deletion — and the refusals that exist only to protect it — -/// while the wiring restore runs unchanged. A wet revert that restored the -/// wiring adds [`REINSTALL_REQUIRED`] when the installed tree may keep the -/// vendored copy. +/// while the wiring restore runs unchanged. A revert that restores (or, on +/// a dry run, would restore) the wiring adds [`REINSTALL_REQUIRED`] when +/// the installed tree may keep the vendored copy, so a preview names the +/// same `bun install --force` the real run will. pub(crate) async fn revert_bun_opts( entry: &VendorEntry, project_root: &Path, @@ -907,7 +908,7 @@ pub(crate) async fn revert_bun_opts( .warnings .iter() .any(|w| w.code == "vendor_lockfile_missing"); - if outcome.success && !outcome.kept_artifact && !opts.dry_run && !restored_nothing { + if outcome.success && !outcome.kept_artifact && !restored_nothing { let stale = stale_hoisted_copies(project_root, [entry.base_purl.as_str()]).await; if !stale.is_empty() { outcome.warnings.push(VendorWarning::new( @@ -3441,10 +3442,18 @@ mod tests { let (_, entry, _) = expect_done(fx.vendor(false).await); let entry = entry.unwrap(); + // The preview names the same install the real run will: the + // rollback's generic reinstall note otherwise promises that the + // next plain install refreshes the tree, which Bun's hoisted + // linker doesn't do. let dry = revert_bun(&entry, fx.root(), true).await; + assert!(dry.success, "{:?}", dry.error); + let codes: Vec<&str> = dry.warnings.iter().map(|w| w.code).collect(); + assert_eq!(codes, [REINSTALL_REQUIRED], "{:?}", dry.warnings); assert!( - dry.warnings.is_empty(), - "a preview changes nothing installed" + dry.warnings[0].detail.contains("`bun install --force`"), + "{}", + dry.warnings[0].detail ); let outcome = revert_bun(&entry, fx.root(), false).await; From 98f7e5e6b72c692c4daf1dd6719e47a1171604ce Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 8 Oct 2026 08:14:58 +0000 Subject: [PATCH 47/55] Label the setup-php pin with its real tag Upstream moved setup-php's v2 tag past the pinned commit, so the Audit GitHub Actions check (zizmor ref-version-mismatch) now fails on every PR. The pinned hash is tag 2.37.2. Ported from #1118 so this PR's audit goes green before that lands. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfd442c39..37d2380be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1402,7 +1402,7 @@ jobs: # The composer capstones shell out to a real composer; `composer:` # pins the release line (1, 2.2 LTS, 2) so the composer.lock grammar # the edits assert stays stable across runners. - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.2' tools: composer:${{ matrix.composer }} From 1305ecb6c121a5c289d1379005587a02af463941 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:51:35 +0000 Subject: [PATCH 48/55] Keep Bun registry URL credentials out of bun.lock bun_lookup_registry expanded `$VAR` / `${VAR}` inside a registry URL's userinfo (`https://u:${TOKEN}@host/`) and kept the result as the registry base. On the project-registry fallback, bun_tarball_url re-bases the restored tarball URL on that base, so an env-only token was written into bun.lock / bun.lockb and printed in the upstream_registry_fallback warning. Every base now goes through split_userinfo: the userinfo is removed from the base and sent only as the request's Basic Authorization (an explicit bunfig token still wins). Found by Cursor's security review on #1009. Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../src/patch/redirect/upstream/npm.rs | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 270bd1d18..ac80ecfdb 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -1087,8 +1087,13 @@ fn bun_lookup_registry( .and_then(|text| npmrc_top_level_value(text, key)) .map(|v| expand_env(&v, var, false)) }; + // Credentials in the URL itself (`https://user:${TOKEN}@host/`) go on + // the request only: the base is written into bun.lock and warnings. let with_npmrc_auth = |base: String, own: Option| -> ProjectRegistry { - let authorization = own.or_else(|| npmrc_registry_auth(&base, &npmrc_value)); + let (base, userinfo) = split_userinfo(&base); + let authorization = own + .or(userinfo) + .or_else(|| npmrc_registry_auth(&base, &npmrc_value)); ProjectRegistry { base, authorization, @@ -1170,6 +1175,30 @@ fn expand_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> out } +/// `base` without its URL userinfo, and the Basic `Authorization` that +/// userinfo stood for (percent-decoded, as a URL parser reads it). +fn split_userinfo(base: &str) -> (String, Option) { + use crate::utils::purl::percent_decode_purl_component as decode; + let Some((scheme, rest)) = base.split_once("://") else { + return (base.to_string(), None); + }; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let Some((userinfo, host)) = rest[..authority_end].rsplit_once('@') else { + return (base.to_string(), None); + }; + let stripped = format!("{scheme}://{host}{}", &rest[authority_end..]); + let (user, password) = userinfo.split_once(':').unwrap_or((userinfo, "")); + let (user, password) = (decode(user), decode(password)); + let authorization = (!user.is_empty() || !password.is_empty()).then(|| { + use base64::Engine as _; + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")) + ) + }); + (stripped, authorization) +} + /// The `Authorization` an `.npmrc` configures for the registry at `base`: /// the `//host[:port]/path/:`-keyed `_authToken` (Bearer), `_auth` (Basic) /// or `username` + base64 `_password` (Basic) of the longest path that @@ -1540,7 +1569,7 @@ mod tests { use super::{ berry_lookup_registry, berry_registry_locator, bun_env_registry, bun_lookup_registry, bun_registry_slot, bun_tarball_url, non_default_registry, registry_derives_tarball, - yaml_top_level_value, ProjectDist, + split_userinfo, yaml_top_level_value, ProjectDist, }; use crate::patch::redirect::upstream::client::NpmDist; @@ -1609,6 +1638,57 @@ mod tests { ); } + #[test] + fn bun_registry_userinfo_goes_on_the_request_not_the_base() { + let vars = |key: &str| (key == "TOKEN").then(|| "s3cret".to_string()); + let lookup = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { + bun_lookup_registry(npmrc, bunfig, env, &vars, name).map(|r| (r.base, r.authorization)) + }; + let basic = |pair: &str| { + use base64::Engine as _; + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(pair) + )) + }; + // bunfig `$VAR` / `${VAR}`, an .npmrc `${VAR}` and the environment's + // registry: the expanded secret never reaches the base that + // bun.lock, bun.lockb and upstream_registry_fallback print. + let bunfig = "[install]\nregistry = \"https://ci:$TOKEN@b.example/npm/\"\n\n\ + [install.scopes]\n\ + corp = { url = \"https://u:${TOKEN}@corp.example/\", token = \"own\" }\n"; + assert_eq!( + lookup(None, Some(bunfig), None, "a"), + Some(("https://b.example/npm/".to_string(), basic("ci:s3cret"))) + ); + // An explicit token still wins; the userinfo is dropped all the same. + assert_eq!( + lookup(None, Some(bunfig), None, "@corp/w"), + Some(( + "https://corp.example/".to_string(), + Some("Bearer own".to_string()) + )) + ); + assert_eq!( + lookup( + Some("@s:registry=https://x:${TOKEN}@s.example/\n"), + None, + None, + "@s/w" + ), + Some(("https://s.example/".to_string(), basic("x:s3cret"))) + ); + assert_eq!( + lookup(None, None, Some("https://e%40m:p%3Aw@e.example/"), "a"), + Some(("https://e.example/".to_string(), basic("e@m:p:w"))) + ); + // No userinfo: unchanged. + assert_eq!( + split_userinfo("https://h.example/a@b"), + ("https://h.example/a@b".to_string(), None) + ); + } + #[test] fn bun_sends_the_credentials_its_settings_give_each_registry() { let vars = |key: &str| match key { From f956e6eee75dee018e80acdc489570c0a4748cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:43:02 +0000 Subject: [PATCH 49/55] Send a token-only Bun scope's token to npmjs A bunfig `[install.scopes]` entry that sets a token but no URL uses the default registry. When no default registry was configured, the restore treated the scope as plain npmjs and dropped its token, so unwinding a hosted pin of a private npmjs-scoped package got a 401. Restores now send the scope's token to registry.npmjs.org in that case. The lock slot stays "" because the registry is still npmjs. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/upstream/npm.rs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index ac80ecfdb..1679e197f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -1127,15 +1127,21 @@ fn bun_lookup_registry( } // A scope entry with no URL (a token only) takes the configured // default registry, never the environment's, with its own - // credentials. + // credentials. With no default configured that is npmjs, which + // still gets the scope's token: a private npmjs scope 401s + // without it. if entry.is_table_like() && entry.get("url").is_none() { - return configured().map(|r| match toml_auth(entry) { - Some(own) => ProjectRegistry { - authorization: Some(own), + let own = toml_auth(entry); + return match configured() { + Some(r) => Some(ProjectRegistry { + authorization: own.or(r.authorization), ..r - }, - None => r, - }); + }), + None => own.map(|own| ProjectRegistry { + base: format!("{}/", crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY), + authorization: Some(own), + }), + }; } } } @@ -1736,6 +1742,19 @@ mod tests { auth(None, Some(bunfig), Some("https://e.example/"), "@own/w"), some("https://b.example/", Some("Bearer own")) ); + // ...and with no default registry configured, npmjs with that + // token (a private npmjs scope), still not the environment's. + let npmjs_scope = "[install.scopes]\nown = { token = \"$CORP_TOKEN\" }\n\ + none = { username = \"u\" }\n"; + for env in [None, Some("https://e.example/")] { + assert_eq!( + auth(None, Some(npmjs_scope), env, "@own/w"), + some("https://registry.npmjs.org/", Some("Bearer from-env")), + "{env:?}" + ); + } + // A token-less scope entry with nothing configured stays npmjs. + assert_eq!(auth(None, Some(npmjs_scope), None, "@none/w"), None); // The default registry's table token; the environment's registry // carries none of bunfig's. assert_eq!( From 55baf1ab0fb1212f2bd3bfc7e4acc0ee0f59f900 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:14:40 +0000 Subject: [PATCH 50/55] Keep bun.lockb references when bun.lock cannot be read scan_vendor_references skipped the binary lock whenever bun_text_lock_drives said the text lock is live, and that predicate counts an unreadable bun.lock (EACCES, ELOOP) as live. The text lock's own read then failed too, so neither lock's references were seen and the orphan sweep could delete a uuid dir bun.lockb still names. Before #735 the `exists()` gate scanned bun.lockb in that case. Read the binary lock unless the text lock drives Bun and is readable; a readable text lock still shadows a stale binary one. Found by Bugbot on #1009. Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../src/commands/vendored_backend/repair.rs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index ef53c0a10..bc6ca8362 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -42,9 +42,16 @@ struct Candidate { pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, String, String)> { let mut seen: HashSet<(String, String)> = HashSet::new(); let mut out = Vec::new(); - if !socket_patch_core::vendor::lock_inventory::bun_text_lock_drives( + // The binary lock is read unless a readable text lock shadows it: a + // `bun.lock` that drives Bun but cannot be read here (EACCES, ELOOP) + // hides its own references, so the binary lock's are kept rather than + // letting the orphan sweep delete a dir it still names. + let text_lock_read = socket_patch_core::vendor::lock_inventory::bun_text_lock_drives( &socket_patch_core::vendor::lock_inventory::ProjectView::Disk(project_root), - ) { + ) && read_regular_to_string(&project_root.join("bun.lock")) + .await + .is_ok(); + if !text_lock_read { if let Ok(paths) = socket_patch_core::vendor::bun_lock::binary_vendor_paths(project_root).await { @@ -861,6 +868,18 @@ mod tests { tokio::fs::remove_file(root.path().join("bun.lock")) .await .unwrap(); + + // A text lock Bun stops at but this scan cannot read (here a + // symlink loop, ELOOP) hides its own references, so the binary + // lock's are still kept from the orphan sweep. + #[cfg(unix)] + { + std::os::unix::fs::symlink("bun.lock", root.path().join("bun.lock")).unwrap(); + assert_eq!(scan_vendor_references(root.path()).await.len(), 1); + tokio::fs::remove_file(root.path().join("bun.lock")) + .await + .unwrap(); + } tokio::fs::write(root.path().join("bun.lockb"), b"malformed") .await .unwrap(); From 111b8492091415c4f0adc9e78e2669f9d2ece3ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:48:34 +0000 Subject: [PATCH 51/55] Replay bun.lock in a dry-run vendored revert revert_bun_wiring returned success on a dry run before reading bun.lock, so revert_bun_opts never saw vendor_lockfile_missing or vendor_lock_entry_removed and a preview advised `bun install --force` for a revert that restores nothing (the lock gone, or the entry removed by `bun remove`). The dry run now replays the lock in memory like the wet run, skipping only the write and the artifact deletion, so its warnings and the reinstall advisory match the wet run's. Found by Bugbot on #1009. Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../socket-patch-core/src/vendor/bun_lock.rs | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 9c54b2ad1..038d8ad4a 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1025,9 +1025,10 @@ async fn revert_bun_wiring( return blocked; } } - if dry_run { - return RevertOutcome::ok(); - } + // A dry run replays the lock in memory like the wet run, only without + // the write and the artifact deletion: its preview then sees the same + // `vendor_lockfile_missing` / `vendor_lock_entry_removed` the wet run + // would, and names the same reinstall advice. let mut outcome = RevertOutcome::ok(); // SECURITY: revert writes are restricted to the one file vendor edits — a @@ -1071,7 +1072,7 @@ async fn revert_bun_wiring( for rec in entry.wiring.iter().rev().filter(|r| r.file == BUN_LOCK) { revert_one_record(lines, rec, &entry.uuid, &mut dirty, &mut outcome.warnings); } - if dirty { + if dirty && !dry_run { if let Err(e) = atomic_write_bytes_preserving_mode( &project_root.join(BUN_LOCK), lines.join("\n").as_bytes(), @@ -1092,6 +1093,9 @@ async fn revert_bun_wiring( outcome.keep_artifact(&uuid_dir_rel); return outcome; } + if dry_run { + return outcome; + } // `--preserve-state` (`keep_artifact`): the wiring restore above already // ran; the artifact dir stays behind (and the caller keeps the ledger @@ -3470,6 +3474,45 @@ mod tests { assert!(w.detail.contains("`bun install --force`"), "{}", w.detail); } + /// A preview names no reinstall the wet run would not: with `bun.lock` + /// gone, or the vendored entry removed (`bun remove`), nothing is + /// restored and the dry run says so like the wet run, without writing. + #[tokio::test] + async fn dry_run_revert_skips_the_advisory_when_nothing_is_restored() { + for removed_entry in [false, true] { + let fx = fixture_with(BN3_BEFORE_LOCK, "node_modules/left-pad").await; + let (_, entry, _) = expect_done(fx.vendor(false).await); + let entry = entry.unwrap(); + let lock = fx.root().join(BUN_LOCK); + if removed_entry { + let text = tokio::fs::read_to_string(&lock).await.unwrap(); + let kept: Vec<&str> = text + .split('\n') + .filter(|l| !l.contains(&entry.uuid)) + .collect(); + tokio::fs::write(&lock, kept.join("\n")).await.unwrap(); + } else { + tokio::fs::remove_file(&lock).await.unwrap(); + } + let before = tokio::fs::read(&lock).await.ok(); + + let dry = revert_bun(&entry, fx.root(), true).await; + assert!(dry.success, "{:?}", dry.error); + assert!( + dry.warnings.iter().all(|w| w.code != REINSTALL_REQUIRED), + "removed_entry={removed_entry}: {:?}", + dry.warnings + ); + assert_eq!(tokio::fs::read(&lock).await.ok(), before, "dry run wrote"); + let wet = revert_bun(&entry, fx.root(), false).await; + assert!( + wet.warnings.iter().all(|w| w.code != REINSTALL_REQUIRED), + "removed_entry={removed_entry}: {:?}", + wet.warnings + ); + } + } + /// The isolated linker relinks `node_modules/` to the restored /// registry entry, and a project with nothing installed has nothing to /// keep: neither warns. From 3a45799f96109ee81583fcef69142b0e0c18dcb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:41:37 +0000 Subject: [PATCH 52/55] Trim per-entry work in the Bun scan paths The bench gate flagged bun-isolated/hosted and /rescan (+12-13% wall in CI, +15% instructions under callgrind against main). Cut the per-entry costs this branch added: - user_tarball_version (#497): run the cheap leaf checks before the vendor-path parse, and reject an entry of another package by length and '@' before any string work; the hosted rewriter asks this of every lock entry per patch. - loses_default_trust (#371): look the name up in a set built once instead of scanning the default trusted list per call (22k calls in the bench). - live_bun_store_entries_sync (#599): look a guessed link target up before building its path, and only clone a target that is newly reached. callgrind on the bun-isolated/hosted fixture: 768.7M -> 728.4M instructions (main: 669.5M). Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../src/crawlers/npm_crawler.rs | 33 +++++++++---------- .../src/patch/redirect/mod.rs | 2 +- .../src/vendor/bun_lock_text.rs | 21 +++++++++--- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index e2cb39da5..d79fc45af 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -962,7 +962,9 @@ fn reach_bun_store_entries_sync( listings.insert(entry, listing); } for target in targets? { - if live.insert(target.clone()) { + // Most targets are already live: check before cloning. + if !live.contains(&target) { + live.insert(target.clone()); let nm = dirs.entry_node_modules(&target); frontier.push((Some(target), nm)); } @@ -1063,8 +1065,11 @@ fn bun_store_link_targets_sync( names: Option<&BunStoreNames>, ) -> Option> { let mut targets = Vec::new(); - // (link, the package its name spells) - let mut links: Vec<(PathBuf, String)> = Vec::new(); + // A link whose package name guesses its entry is never read, and is + // looked up before any path is built: a quick walk visits every link + // of every live entry, so the allocations add up (#578). + let guess = |package: &str| names.and_then(|names| names.unique.get(package)); + let mut links: Vec = Vec::new(); for entry in &listing.entries { let Some(file_type) = entry.file_type else { continue; @@ -1073,7 +1078,10 @@ fn bun_store_link_targets_sync( continue; } if file_type.is_symlink() { - links.push((nm.join(&entry.name), entry.name_str.clone())); + match guess(&entry.name_str) { + Some(target) => targets.push(target.clone()), + None => links.push(nm.join(&entry.name)), + } } else if file_type.is_dir() && entry.name_str.starts_with('@') { if let Some(entries) = names.and_then(|names| names.scopes.get(&entry.name_str)) { targets.extend(entries.iter().cloned()); @@ -1083,23 +1091,14 @@ fn bun_store_link_targets_sync( for scoped in list_dir_sync(&scope).entries { if scoped.file_type.is_some_and(|ft| ft.is_symlink()) { let package = format!("{}/{}", entry.name_str, scoped.name_str); - links.push((scope.join(&scoped.name), package)); + match guess(&package) { + Some(target) => targets.push(target.clone()), + None => links.push(scope.join(&scoped.name)), + } } } } } - let links: Vec = links - .into_iter() - .filter_map( - |(link, package)| match names.and_then(|names| names.unique.get(&package)) { - Some(entry) => { - targets.push(entry.clone()); - None - } - None => Some(link), - }, - ) - .collect(); for link in &links { let lexical = std::fs::read_link(link) .ok() diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0db355619..bb44bfc28 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4916,7 +4916,7 @@ fn rewrite_bun_lock( // this very version is installed from that spec beside the // pinned copy and stays unpatched (#497, npm's #326): say so, // and keep the in-run VEX from assuming the uuid patched. - if spec != url_spec && is_user_tarball_spec(spec, &fname, &dep.version) { + if is_user_tarball_spec(spec, &fname, &dep.version) && spec != url_spec { user_tarball_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index c0a925c45..bff0d7ddc 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -83,7 +83,12 @@ pub(crate) fn loses_default_trust(manifest: Option<&str>, lock: Option<&str>, na // The list lookup is cheap and almost always false, so it runs first: // the hosted rewriter calls this per wired dep, and the manifest parse // plus whole-lock scan must not land in its per-dep loop (#578). - if !DEFAULT_TRUSTED.lines().any(|trusted| trusted == name) { + static TRUSTED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + if !TRUSTED + .get_or_init(|| DEFAULT_TRUSTED.lines().collect()) + .contains(name) + { return false; } let declared = manifest.and_then(parse_manifest).is_some_and(|value| { @@ -234,9 +239,8 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> { /// registry tarball and `npm pack` output carries. A leaf naming no version /// yields `None`, and so does our own vendored path. pub(crate) fn user_tarball_version<'t>(name: &str, target: &'t str) -> Option<&'t str> { - if crate::vendor::path::parse_vendor_path(target).is_some() { - return None; - } + // The leaf checks are cheap and reject a registry spec (`name@1.2.3`) + // at once; the vendor-path parse runs only for a tarball leaf (#578). let path = target.split(['?', '#']).next().unwrap_or(target); let leaf = path.rsplit(['/', '\\']).next()?; let bare = name.rsplit('/').next().unwrap_or(name); @@ -244,6 +248,9 @@ pub(crate) fn user_tarball_version<'t>(name: &str, target: &'t str) -> Option<&' let version = version .strip_suffix(".tgz") .or_else(|| version.strip_suffix(".tar.gz"))?; + if crate::vendor::path::parse_vendor_path(target).is_some() { + return None; + } semver::Version::parse(version).is_ok().then_some(version) } @@ -260,7 +267,13 @@ pub(crate) fn is_user_tarball_entry(entry: &BunEntry, name: &str, version: &str) /// [`is_user_tarball_entry`] on an already-decoded `name@` spec, /// for hot loops that decoded it once already. The name is matched by a /// prefix strip, so an entry of another package costs one compare. +#[inline] pub(crate) fn is_user_tarball_spec(spec: &str, name: &str, version: &str) -> bool { + // Length and separator first: the hosted rewriter asks this of every + // lock entry per patch, nearly all of another package (#578). + if spec.as_bytes().get(name.len()) != Some(&b'@') { + return false; + } spec.strip_prefix(name) .and_then(|rest| rest.strip_prefix('@')) .is_some_and(|target| user_tarball_version(name, target) == Some(version)) From fd75f30d6ecd4f0bde360c02b762a93e85f0d041 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:22:20 +0000 Subject: [PATCH 53/55] Speed up the Bun isolated orphan walk The scan-performance gate failed on bun-isolated/rescan (+10.2% wall in CI) and bun-isolated/hosted (+8.4%). The cause was the #599 orphan walk over node_modules/.bun, not its reads: the syscall counts match main. Two things cost the time: - The walk tracked entries by name. It hashed and cloned an OsString for each of the 3000 entries, several times over. It now tracks them by index into the store's entry list. - It read one parallel batch per link-graph frontier, plus another batch for the scan. Each batch wakes and parks the walk pool for little work, which cost about 12 ms of user and system time. The quick walk now reads every entry's listing in one parallel pass, the same reads the scan always made, and follows the links in memory. The scan reuses those listings without another pass. What counts as live is unchanged. The full re-walk that runs after a quick walk leaves entries unreached is still read as before. bun-isolated/rescan on the bench fixture, against the PR base: instructions 579M -> 556M (base 524M); paired local compare now +6.3% hosted and +8.7% rescan wall, +5% CPU (was +11-14%). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 327 +++++++++++++----- 1 file changed, 237 insertions(+), 90 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index d79fc45af..e87742c01 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -629,7 +629,7 @@ pub fn bun_uses_global_store(project_root: &Path) -> bool { fn live_bun_store_entries_sync( store_path: &Path, candidates: &[ListedEntry], -) -> Option<(HashSet, HashMap)> { +) -> Option { if candidates.is_empty() { return None; } @@ -640,27 +640,102 @@ fn live_bun_store_entries_sync( _ => Path::new("."), }; let names = BunStoreNames::new(candidates); - let unreached = |live: &HashSet| candidates.iter().any(|e| !live.contains(&e.name)); + // The quick walk reads every entry's listing (the scan reads them + // anyway) in one parallel pass up front, instead of one pass per + // frontier: each pass costs a round of the walk pool waking and + // parking for little work (#578). + let mut quick_reads: Vec> = + par_map((0..candidates.len()).collect::>(), |index| { + let nm = dirs.entry_node_modules(&Reached::Entry(index)); + Some(read_bun_store_node_modules_sync(&nm, &dirs, Some(&names))) + }); // Read at most once, shared by both walks. let mut members: Option>> = None; - let mut walk = |unique: Option<&BunStoreNames>| { - let mut live: HashSet = HashSet::new(); - let mut listings = HashMap::new(); + let mut walk = |unique: Option<&BunStoreNames>, + mut read: Option<&mut [Option]>| { + let mut live = LiveBunStore::new(candidates.len()); let seeds = [store_path.join("node_modules"), importer.to_path_buf()]; - reach_bun_store_entries_sync(&seeds, &dirs, unique, &mut live, &mut listings)?; - if unreached(&live) { + reach_bun_store_entries_sync(&seeds, &dirs, unique, &mut live, read.as_deref_mut())?; + if live.unreached() { let members = members .get_or_insert_with(|| bun_workspace_member_node_modules_sync(root)) .as_deref()?; - reach_bun_store_entries_sync(members, &dirs, unique, &mut live, &mut listings)?; + reach_bun_store_entries_sync(members, &dirs, unique, &mut live, read)?; } - (!live.is_empty()).then_some((live, listings)) + live.any().then_some(live) }; - let quick = walk(Some(&names))?; - if !unreached(&quick.0) { + let quick = walk(Some(&names), Some(&mut quick_reads))?; + if !quick.unreached() { return Some(quick); } - walk(None) + walk(None, None) +} + +/// What [`live_bun_store_entries_sync`] found, by index into the +/// candidates it was given: the scan walks thousands of entries, so they +/// are tracked by position instead of hashed and cloned by name (#578). +struct LiveBunStore { + /// Whether each candidate is live. + live: Vec, + /// How many of `live` are set. + count: usize, + /// Reached names that are no candidate (walked all the same). + other: HashSet, + /// The `node_modules` listing of each candidate the walk read. + listings: Vec>, +} + +impl LiveBunStore { + fn new(candidates: usize) -> Self { + Self { + live: vec![false; candidates], + count: 0, + other: HashSet::new(), + listings: std::iter::repeat_with(|| None).take(candidates).collect(), + } + } + + /// Mark `entry` live: whether it was not already. + fn insert(&mut self, entry: &Reached) -> bool { + match entry { + Reached::Entry(index) => { + let fresh = !std::mem::replace(&mut self.live[*index], true); + self.count += usize::from(fresh); + fresh + } + Reached::Other(name) => !self.other.contains(name) && self.other.insert(name.clone()), + } + } + + /// Some candidate is not live. + fn unreached(&self) -> bool { + self.count < self.live.len() + } + + /// Anything is live. + fn any(&self) -> bool { + self.count > 0 || !self.other.is_empty() + } + + /// The names of every live entry. + fn into_names(self, candidates: &[ListedEntry]) -> HashSet { + let mut names = self.other; + names.extend( + candidates + .iter() + .zip(self.live) + .filter(|(_, live)| *live) + .map(|(entry, _)| entry.name.clone()), + ); + names + } +} + +/// A `.bun` entry a link reaches: a candidate (by index) or some other +/// name in the store. +enum Reached { + Entry(usize), + Other(OsString), } /// The `node_modules` dirs of the workspace members a Bun install at @@ -819,7 +894,7 @@ fn orphaned_bun_store_copies_sync(paths: &[PathBuf]) -> HashSet { }; let live = stores.entry(store).or_insert_with_key(|store| { let candidates = pnpm_shaped_store_candidates_sync(store, StoreLayout::Bun); - live_bun_store_entries_sync(store, &candidates).map(|(live, _)| live) + live_bun_store_entries_sync(store, &candidates).map(|live| live.into_names(&candidates)) }); if live.as_ref().is_some_and(|live| !live.contains(&entry)) { orphans.insert(path.clone()); @@ -872,33 +947,34 @@ pub async fn retain_live_store_copies<'a>(lists: impl IntoIterator, - scopes: HashMap>, + /// Package name to the one candidate (by index) holding it. + unique: HashMap, + scopes: HashMap>, } impl BunStoreNames { fn new(candidates: &[ListedEntry]) -> Self { - let mut by_package: HashMap> = HashMap::new(); - for entry in candidates { + let mut by_package: HashMap> = HashMap::new(); + for (index, entry) in candidates.iter().enumerate() { if let Some(package) = bun_store_entry_package(&entry.name_str) { by_package .entry(package) .and_modify(|only| *only = None) - .or_insert(Some(&entry.name)); + .or_insert(Some(index)); } } let mut unique = HashMap::new(); - let mut scopes: HashMap>> = HashMap::new(); + let mut scopes: HashMap>> = HashMap::new(); for (package, only) in by_package { let scope = package.split_once('/').map(|(scope, _)| scope.to_string()); match only { Some(entry) => { if let Some(scope) = scope { if let Some(entries) = scopes.entry(scope).or_insert(Some(Vec::new())) { - entries.push(entry.clone()); + entries.push(entry); } } - unique.insert(package, entry.clone()); + unique.insert(package, entry); } None => { if let Some(scope) = scope { @@ -928,45 +1004,76 @@ fn bun_store_entry_package(entry_name: &str) -> Option { }) } -/// Add to `live` every `.bun` entry (by name) reachable through links from -/// the `seeds` dirs, following each reached entry's own `node_modules` -/// links, and record each entry's listing in `listings`. Read one -/// frontier at a time, each frontier's dirs in parallel. With `names`, -/// targets are guessed by name where they can be. `None` when a link -/// reaches a global store entry this store does not link (see -/// [`BunStoreDirs::entry_of`]): what lies past it cannot be told. +/// A `node_modules` dir's listing and the `.bun` entries its links reach +/// (see [`bun_store_link_targets_sync`]). +type BunStoreRead = (Option, Option>); + +/// Read the `node_modules` dir `nm` for the orphan walk. +fn read_bun_store_node_modules_sync( + nm: &Path, + dirs: &BunStoreDirs, + names: Option<&BunStoreNames>, +) -> BunStoreRead { + let listing = read_dir_entries_sync(nm) + .map(|(entries, complete)| Listing::from_entries(entries, complete)); + let targets = match &listing { + Some(listing) => bun_store_link_targets_sync(nm, listing, dirs, names), + None => Some(Vec::new()), + }; + (listing, targets) +} + +/// Add to `live` every `.bun` entry reachable through links from the +/// `seeds` dirs, following each reached entry's own `node_modules` links, +/// and record each candidate's listing. A candidate already read into +/// `read` is taken from there; the rest are read one frontier at a time, +/// each frontier's dirs in parallel. With `names`, targets are guessed by +/// name where they can be. `None` when a link reaches a global store +/// entry this store does not link (see [`BunStoreDirs::entry_of`]): what +/// lies past it cannot be told. fn reach_bun_store_entries_sync( seeds: &[PathBuf], dirs: &BunStoreDirs, names: Option<&BunStoreNames>, - live: &mut HashSet, - listings: &mut HashMap, + live: &mut LiveBunStore, + mut read: Option<&mut [Option]>, ) -> Option<()> { - let mut frontier: Vec<(Option, PathBuf)> = seeds + let mut frontier: Vec<(Option, Option)> = seeds .iter() - .filter_map(|nm| Some((None, std::fs::canonicalize(nm).ok()?))) + .filter_map(|nm| Some((None, Some(std::fs::canonicalize(nm).ok()?)))) .collect(); while !frontier.is_empty() { - let visited = par_map(frontier, |(entry, nm)| { - let listing = read_dir_entries_sync(&nm) - .map(|(entries, complete)| Listing::from_entries(entries, complete)); - let targets = match &listing { - Some(listing) => bun_store_link_targets_sync(&nm, listing, dirs, names), - None => Some(Vec::new()), + let mut visited = Vec::with_capacity(frontier.len()); + let mut unread = Vec::new(); + for (entry, nm) in frontier { + let cached = match (&entry, read.as_deref_mut()) { + (Some(Reached::Entry(index)), Some(read)) => read[*index].take(), + _ => None, }; + match cached { + Some((listing, targets)) => visited.push((entry, listing, targets)), + None => { + let nm = match (nm, &entry) { + (Some(nm), _) => nm, + (None, Some(entry)) => dirs.entry_node_modules(entry), + (None, None) => continue, + }; + unread.push((entry, nm)); + } + } + } + visited.extend(par_map(unread, |(entry, nm)| { + let (listing, targets) = read_bun_store_node_modules_sync(&nm, dirs, names); (entry, listing, targets) - }); + })); frontier = Vec::new(); for (entry, listing, targets) in visited { - if let (Some(entry), Some(listing)) = (entry, listing) { - listings.insert(entry, listing); + if let (Some(Reached::Entry(index)), Some(listing)) = (entry, listing) { + live.listings[index] = Some(listing); } for target in targets? { - // Most targets are already live: check before cloning. - if !live.contains(&target) { - live.insert(target.clone()); - let nm = dirs.entry_node_modules(&target); - frontier.push((Some(target), nm)); + if live.insert(&target) { + frontier.push((Some(target), None)); } } } @@ -978,56 +1085,87 @@ fn reach_bun_store_entries_sync( /// (canonical) store dir itself, or, for each entry that is a link into /// Bun's global store (#635), in its shared `/links/-` /// dir, whose dependency links point at sibling cache dirs. -struct BunStoreDirs { +struct BunStoreDirs<'a> { real_store: PathBuf, + /// The store's candidate entries, which [`Reached::Entry`] indexes. + candidates: &'a [ListedEntry], + /// Each candidate's index, by name. + index: HashMap<&'a OsStr, usize>, /// The real dir of each global store entry, and the entry linking it. - global: HashMap, + global: HashMap, /// The same, by entry. - global_dirs: HashMap, + global_dirs: HashMap, /// The `links` dirs those real dirs sit in. global_links: HashSet, } -impl BunStoreDirs { +impl<'a> BunStoreDirs<'a> { /// `None` when the store or one of its global store links does not /// resolve. - fn new(store_path: &Path, candidates: &[ListedEntry]) -> Option { + fn new(store_path: &Path, candidates: &'a [ListedEntry]) -> Option { let real_store = std::fs::canonicalize(store_path).ok()?; let mut global = HashMap::new(); let mut global_dirs = HashMap::new(); let mut global_links = HashSet::new(); - for entry in candidates { + for (index, entry) in candidates.iter().enumerate() { if entry.file_type.is_some_and(|ft| ft.is_symlink()) { let real = std::fs::canonicalize(store_path.join(&entry.name)).ok()?; global_links.insert(real.parent()?.to_path_buf()); - global.insert(real.clone(), entry.name.clone()); - global_dirs.insert(entry.name.clone(), real); + global.insert(real.clone(), index); + global_dirs.insert(index, real); } } + let index = candidates + .iter() + .enumerate() + .map(|(index, entry)| (entry.name.as_os_str(), index)) + .collect(); Some(Self { real_store, + candidates, + index, global, global_dirs, global_links, }) } - /// The real `node_modules` dir of the entry named `entry`. - fn entry_node_modules(&self, entry: &OsStr) -> PathBuf { - match self.global_dirs.get(entry) { - Some(dir) => dir.join("node_modules"), - None => self.real_store.join(entry).join("node_modules"), + /// The real `node_modules` dir of `entry`. + fn entry_node_modules(&self, entry: &Reached) -> PathBuf { + let name = match entry { + Reached::Entry(index) => { + if let Some(dir) = self.global_dirs.get(index) { + return dir.join("node_modules"); + } + self.candidates[*index].name.as_os_str() + } + Reached::Other(name) => name.as_os_str(), + }; + let mut nm = PathBuf::with_capacity( + self.real_store.as_os_str().len() + name.len() + "/node_modules".len() + 1, + ); + nm.push(&self.real_store); + nm.push(name); + nm.push("node_modules"); + nm + } + + /// The entry named `name` of this store. + fn reached(&self, name: &OsStr) -> Reached { + match self.index.get(name) { + Some(&index) => Reached::Entry(index), + None => Reached::Other(name.to_os_string()), } } - /// The entry a (lexical or real) path lies in: `Some(Some(name))` in + /// The entry a (lexical or real) path lies in: `Some(Some(entry))` in /// this store or one of its global store entries, `Some(None)` in some /// other entry of the same global store (not linked from this store, /// so its reach is unknown), `None` anywhere else. - fn entry_of(&self, path: &Path) -> Option> { + fn entry_of(&self, path: &Path) -> Option> { if let Ok(below) = path.strip_prefix(&self.real_store) { return match below.components().next() { - Some(std::path::Component::Normal(name)) => Some(Some(name.to_os_string())), + Some(std::path::Component::Normal(name)) => Some(Some(self.reached(name))), _ => None, }; } @@ -1035,8 +1173,8 @@ impl BunStoreDirs { return None; } for dir in path.ancestors() { - if let Some(entry) = self.global.get(dir) { - return Some(Some(entry.clone())); + if let Some(&entry) = self.global.get(dir) { + return Some(Some(Reached::Entry(entry))); } if dir .parent() @@ -1063,7 +1201,7 @@ fn bun_store_link_targets_sync( listing: &Listing, dirs: &BunStoreDirs, names: Option<&BunStoreNames>, -) -> Option> { +) -> Option> { let mut targets = Vec::new(); // A link whose package name guesses its entry is never read, and is // looked up before any path is built: a quick walk visits every link @@ -1079,12 +1217,12 @@ fn bun_store_link_targets_sync( } if file_type.is_symlink() { match guess(&entry.name_str) { - Some(target) => targets.push(target.clone()), + Some(&target) => targets.push(Reached::Entry(target)), None => links.push(nm.join(&entry.name)), } } else if file_type.is_dir() && entry.name_str.starts_with('@') { if let Some(entries) = names.and_then(|names| names.scopes.get(&entry.name_str)) { - targets.extend(entries.iter().cloned()); + targets.extend(entries.iter().map(|&entry| Reached::Entry(entry))); continue; } let scope = nm.join(&entry.name); @@ -1092,7 +1230,7 @@ fn bun_store_link_targets_sync( if scoped.file_type.is_some_and(|ft| ft.is_symlink()) { let package = format!("{}/{}", entry.name_str, scoped.name_str); match guess(&package) { - Some(target) => targets.push(target.clone()), + Some(&target) => targets.push(Reached::Entry(target)), None => links.push(scope.join(&scoped.name)), } } @@ -3213,25 +3351,24 @@ impl NpmCrawler { live_only: bool, ) -> Vec { let decode = |name: &str| layout.decode_pnpm_shaped(name); - let mut candidates = pnpm_shaped_store_candidates_sync(store_path, layout); + let candidates = pnpm_shaped_store_candidates_sync(store_path, layout); // pnpm prunes its store on install; Bun never does (#599), so the // scan, a judgement of the live install, skips its orphans. - let mut listings = HashMap::new(); - if layout == StoreLayout::Bun && live_only { - if let Some((live, walked)) = live_bun_store_entries_sync(store_path, &candidates) { - candidates.retain(|e| live.contains(&e.name)); - listings = walked; - } - } - let candidates: Vec<(ListedEntry, Option)> = candidates - .into_iter() - .map(|entry| { - let listing = listings.remove(&entry.name).filter(|_| read_listings); - (entry, listing) - }) - .collect(); + let live = (layout == StoreLayout::Bun && live_only) + .then(|| live_bun_store_entries_sync(store_path, &candidates)) + .flatten(); + let candidates: Vec<(ListedEntry, Option)> = match live { + Some(walked) => candidates + .into_iter() + .zip(walked.live) + .zip(walked.listings) + .filter(|((_, live), _)| *live) + .map(|((entry, _), listing)| (entry, listing.filter(|_| read_listings))) + .collect(), + None => candidates.into_iter().map(|entry| (entry, None)).collect(), + }; - par_map(candidates, |(entry, listing)| { + let entry_dirs = |(entry, listing): (ListedEntry, Option)| { let entry_path = store_path.join(&entry.name); let entry_nm = entry_path.join("node_modules"); if read_listings { @@ -3266,10 +3403,16 @@ impl NpmCrawler { }) .collect() } - }) - .into_iter() - .flatten() - .collect() + }; + // With every listing already read (a live Bun walk) nothing is + // left to read, and a parallel pass would only wake the pool. + if read_listings && candidates.iter().all(|(_, listing)| listing.is_some()) { + return candidates.into_iter().flat_map(entry_dirs).collect(); + } + par_map(candidates, entry_dirs) + .into_iter() + .flatten() + .collect() } /// Async `(name, node_modules)` view of @@ -6441,8 +6584,12 @@ mod tests { let candidates = pnpm_shaped_store_candidates_sync(&store, StoreLayout::Bun); assert_eq!(candidates.len(), 5); - let (live, _) = live_bun_store_entries_sync(&store, &candidates).unwrap(); - let mut live: Vec = live.into_iter().map(|e| e.into_string().unwrap()).collect(); + let live = live_bun_store_entries_sync(&store, &candidates).unwrap(); + let mut live: Vec = live + .into_names(&candidates) + .into_iter() + .map(|e| e.into_string().unwrap()) + .collect(); live.sort(); assert_eq!( live, From 55cf5b392d56e9723153af8e1ef9bd24fb1c576e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:08:29 +0000 Subject: [PATCH 54/55] Expand only npm token variables in Bun registry settings A Bun hosted restore reads the project's bunfig.toml and .npmrc and sends the registry credentials they configure. It expanded any $VAR / ${VAR} in them, so a project could name its own registry host and have socket-patch send it an unrelated secret such as $GITHUB_TOKEN. Bun itself would send the same on `bun install`, but socket-patch also runs where Bun does not, so it is an extra layer of defense: only NPM_TOKEN, NODE_AUTH_TOKEN and BUN_AUTH_TOKEN expand now, and any other variable expands to nothing (the read falls back to the anonymous read, as on main). The maintainer chose the allowlist on the review thread. Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../src/patch/redirect/upstream/npm.rs | 56 +++++++++++++++---- docs/ecosystems.md | 3 +- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 129705105..8887510a8 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -981,7 +981,11 @@ pub(crate) async fn restore_pnpm_locks( /// `.npmrc` `//host/path/:_authToken` / `:_auth` / `:username` + /// `:_password` whose path covers the registry URL. `$VAR` / `${VAR}` in a /// bunfig value and `${VAR}` in an `.npmrc` value read `var`, as Bun -/// expands them. A private scope registry answers 401 without them. +/// expands them, but only for the [`BUN_EXPANDED_VARS`] token variables: +/// these files come with the project, and any other reference (say +/// `$GITHUB_TOKEN`) would hand that secret to a host the project names. +/// It expands to nothing instead. A private scope registry answers 401 +/// without them. fn bun_lookup_registry( npmrc: Option<&str>, bunfig: Option<&str>, @@ -991,6 +995,7 @@ fn bun_lookup_registry( ) -> Option { use super::super::npmrc::npmrc_top_level_value; + let var = &|key: &str| BUN_EXPANDED_VARS.contains(&key).then(|| var(key)).flatten(); fn url(value: &str) -> Option { let value = value.trim().trim_matches(['"', '\'']); (value.starts_with("https://") || value.starts_with("http://")).then(|| value.to_string()) @@ -1090,6 +1095,11 @@ fn bun_lookup_registry( } } +/// The variables a project's bunfig.toml / .npmrc may expand into the +/// registry and credentials a Bun restore sends: the conventional npm +/// token variables, nothing else. +const BUN_EXPANDED_VARS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "BUN_AUTH_TOKEN"]; + /// `value` with each `${VAR}` (and, for a bunfig value, `$VAR`) replaced /// by `var(VAR)`, empty when unset. fn expand_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { @@ -1585,7 +1595,7 @@ mod tests { #[test] fn bun_registry_userinfo_goes_on_the_request_not_the_base() { - let vars = |key: &str| (key == "TOKEN").then(|| "s3cret".to_string()); + let vars = |key: &str| (key == "NPM_TOKEN").then(|| "s3cret".to_string()); let lookup = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { bun_lookup_registry(npmrc, bunfig, env, &vars, name).map(|r| (r.base, r.authorization)) }; @@ -1599,9 +1609,9 @@ mod tests { // bunfig `$VAR` / `${VAR}`, an .npmrc `${VAR}` and the environment's // registry: the expanded secret never reaches the base that // bun.lock, bun.lockb and upstream_registry_fallback print. - let bunfig = "[install]\nregistry = \"https://ci:$TOKEN@b.example/npm/\"\n\n\ + let bunfig = "[install]\nregistry = \"https://ci:$NPM_TOKEN@b.example/npm/\"\n\n\ [install.scopes]\n\ - corp = { url = \"https://u:${TOKEN}@corp.example/\", token = \"own\" }\n"; + corp = { url = \"https://u:${NPM_TOKEN}@corp.example/\", token = \"own\" }\n"; assert_eq!( lookup(None, Some(bunfig), None, "a"), Some(("https://b.example/npm/".to_string(), basic("ci:s3cret"))) @@ -1616,7 +1626,7 @@ mod tests { ); assert_eq!( lookup( - Some("@s:registry=https://x:${TOKEN}@s.example/\n"), + Some("@s:registry=https://x:${NPM_TOKEN}@s.example/\n"), None, None, "@s/w" @@ -1637,8 +1647,9 @@ mod tests { #[test] fn bun_sends_the_credentials_its_settings_give_each_registry() { let vars = |key: &str| match key { - "CORP_TOKEN" => Some("from-env".to_string()), - "NPMRC_TOKEN" => Some("npmrc-env".to_string()), + "NODE_AUTH_TOKEN" => Some("from-env".to_string()), + "BUN_AUTH_TOKEN" => Some("npmrc-env".to_string()), + "GITHUB_TOKEN" => Some("not-for-registries".to_string()), _ => None, }; let auth = |npmrc: Option<&str>, bunfig: Option<&str>, env: Option<&str>, name: &str| { @@ -1650,12 +1661,14 @@ mod tests { // from username + password. let bunfig = "[install]\nregistry = { url = \"https://b.example/\", token = \"bt\" }\n\n\ [install.scopes]\n\ - corp = { url = \"https://corp.example/npm/\", token = \"$CORP_TOKEN\" }\n\ - braced = { url = \"https://br.example/\", token = \"x${CORP_TOKEN}y\" }\n\ + corp = { url = \"https://corp.example/npm/\", token = \"$NODE_AUTH_TOKEN\" }\n\ + braced = { url = \"https://br.example/\", token = \"x${NODE_AUTH_TOKEN}y\" }\n\ basic = { url = \"https://ba.example/\", username = \"u\", password = \"p\" }\n\ bare = \"https://bare.example/\"\n\ own = { token = \"own\" }\n\ - unset = { url = \"https://un.example/\", token = \"$NOPE\" }\n"; + unset = { url = \"https://un.example/\", token = \"$NOPE\" }\n\ + other = { url = \"https://ot.example/\", token = \"${GITHUB_TOKEN}\" }\n\ + inurl = \"https://u:$GITHUB_TOKEN@iu.example/\"\n"; assert_eq!( auth(None, Some(bunfig), None, "@corp/w"), some("https://corp.example/npm/", Some("Bearer from-env")) @@ -1676,6 +1689,25 @@ mod tests { auth(None, Some(bunfig), None, "@unset/w"), some("https://un.example/", None) ); + // A variable that is not a registry token variable is never + // expanded: the project's files cannot send it anywhere. + assert_eq!( + auth(None, Some(bunfig), None, "@other/w"), + some("https://ot.example/", None) + ); + let (base, authorization) = auth(None, Some(bunfig), None, "@inurl/w").unwrap(); + assert_eq!(base, "https://iu.example/"); + let sent = authorization.and_then(|a| { + use base64::Engine as _; + let encoded = a.strip_prefix("Basic ")?.to_string(); + base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + }); + assert!( + !String::from_utf8_lossy(&sent.unwrap_or_default()).contains("not-for-registries"), + "the userinfo expanded $GITHUB_TOKEN" + ); // A token-only scope: the configured default registry, its own token. assert_eq!( auth(None, Some(bunfig), Some("https://e.example/"), "@own/w"), @@ -1683,7 +1715,7 @@ mod tests { ); // ...and with no default registry configured, npmjs with that // token (a private npmjs scope), still not the environment's. - let npmjs_scope = "[install.scopes]\nown = { token = \"$CORP_TOKEN\" }\n\ + let npmjs_scope = "[install.scopes]\nown = { token = \"$NODE_AUTH_TOKEN\" }\n\ none = { username = \"u\" }\n"; for env in [None, Some("https://e.example/")] { assert_eq!( @@ -1713,7 +1745,7 @@ mod tests { @legacy:registry=https://lg.example/r/\n\ registry=https://n.example/\n\ //corp.example/:_authToken=host-wide\n\ - //corp.example/npm/private/:_authToken=${NPMRC_TOKEN}\n\ + //corp.example/npm/private/:_authToken=${BUN_AUTH_TOKEN}\n\ //n.example/:_authToken=default\n\ //nb.example/:_auth=dTpw\n\ //lg.example/r/:username=u\n//lg.example/r/:_password=cA==\n"; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index f0484b1cb..182e8fcf5 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -214,7 +214,8 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. registry Bun resolves it against (`.npmrc` / `bunfig.toml` scope and default registries, `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), sending the credentials those settings give it — a bunfig `token` or `username` / `password` (`$VAR` - expanded), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + + expanded only for `NPM_TOKEN`, `NODE_AUTH_TOKEN` and `BUN_AUTH_TOKEN`; any other + variable expands to nothing, so a project's config cannot send other secrets), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + `_password` covering the registry URL (`BUN_CONFIG_TOKEN` is not read); a registry that still cannot be read falls back to the default registry's document with an `upstream_registry_fallback` warning. Bun's hoisted linker keeps an installed copy whose lock entry From 5b351e48331c23f9b886c9ff32a865126298726d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:30:21 +0000 Subject: [PATCH 55/55] Expand Bun registry URL variables only in their userinfo The env allowlist still let an allowed token reach a registry URL's host, path or query (`https://h/${NPM_TOKEN}/`). That part of the URL is requested, printed in upstream_registry_fallback and written into the restored bun.lock / bun.lockb, so the token leaked into all three (security review on 55cf5b3). A registry URL from bunfig.toml or .npmrc now expands variables only inside its `user:password@`, which split_userinfo moves onto the request's Authorization header; a reference anywhere else expands to nothing, and such a registry falls back like any other unreadable one. Assisted-by: Claude Code:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TSx4W4Qfb8hsBhw4NEvkv9 --- .../src/patch/redirect/upstream/npm.rs | 71 ++++++++++++++++--- docs/ecosystems.md | 4 +- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 8887510a8..ee0d1d9d4 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -1007,7 +1007,7 @@ fn bun_lookup_registry( let value = item .as_str() .or_else(|| item.get("url").and_then(toml_edit::Item::as_str))?; - url(&expand_env(value, var, true)) + url(&expand_url_env(value, var, true)) }; let toml_auth = |item: &toml_edit::Item| -> Option { let field = |key: &str| { @@ -1031,6 +1031,11 @@ fn bun_lookup_registry( .and_then(|text| npmrc_top_level_value(text, key)) .map(|v| expand_env(&v, var, false)) }; + let npmrc_url = |key: &str| { + npmrc + .and_then(|text| npmrc_top_level_value(text, key)) + .and_then(|v| url(&expand_url_env(&v, var, false))) + }; // Credentials in the URL itself (`https://user:${TOKEN}@host/`) go on // the request only: the base is written into bun.lock and warnings. let with_npmrc_auth = |base: String, own: Option| -> ProjectRegistry { @@ -1047,7 +1052,7 @@ fn bun_lookup_registry( let install = bunfig.as_ref().and_then(|doc| doc.get("install")); // The configured default registry before the environment applies. let configured = || -> Option { - if let Some(base) = npmrc_value("registry").and_then(|value| url(&value)) { + if let Some(base) = npmrc_url("registry") { return Some(with_npmrc_auth(base, None)); } let item = install?.get("registry")?; @@ -1055,9 +1060,7 @@ fn bun_lookup_registry( Some(with_npmrc_auth(base, toml_auth(item))) }; if let Some((scope, _)) = name.strip_prefix('@').and_then(|rest| rest.split_once('/')) { - if let Some(scoped) = - npmrc_value(&format!("@{scope}:registry")).and_then(|value| url(&value)) - { + if let Some(scoped) = npmrc_url(&format!("@{scope}:registry")) { return Some(with_npmrc_auth(scoped, None)); } let entry = install.and_then(|i| i.get("scopes")).and_then(|scopes| { @@ -1100,6 +1103,33 @@ fn bun_lookup_registry( /// token variables, nothing else. const BUN_EXPANDED_VARS: &[&str] = &["NPM_TOKEN", "NODE_AUTH_TOKEN", "BUN_AUTH_TOKEN"]; +/// A registry URL with its variables expanded only inside its userinfo +/// (`https://user:${NPM_TOKEN}@host/`), which goes on the request's +/// `Authorization` header (see [`split_userinfo`]). A reference anywhere +/// else expands to nothing: the rest of the URL is requested, printed in +/// `upstream_registry_fallback` and written into the lock, so a token +/// there would leak into all three. +fn expand_url_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { + let none = |_: &str| None; + let (scheme, rest) = value.split_once("://").unwrap_or(("", value)); + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (userinfo, after) = match rest[..authority_end].rsplit_once('@') { + Some((userinfo, host)) => (Some(userinfo), &rest[authority_end - host.len()..]), + None => (None, rest), + }; + let mut out = String::with_capacity(value.len()); + if value.contains("://") { + out.push_str(scheme); + out.push_str("://"); + } + if let Some(userinfo) = userinfo { + out.push_str(&expand_env(userinfo, var, bare)); + out.push('@'); + } + out.push_str(&expand_env(after, &none, bare)); + out +} + /// `value` with each `${VAR}` (and, for a bunfig value, `$VAR`) replaced /// by `var(VAR)`, empty when unset. fn expand_env(value: &str, var: &dyn Fn(&str) -> Option, bare: bool) -> String { @@ -1523,8 +1553,8 @@ pub(crate) async fn cleanup_side_config( mod tests { use super::{ berry_lookup_registry, berry_registry_locator, bun_env_registry, bun_lookup_registry, - bun_registry_slot, bun_tarball_url, non_default_registry, registry_derives_tarball, - split_userinfo, yaml_top_level_value, ProjectDist, + bun_registry_slot, bun_tarball_url, expand_url_env, non_default_registry, + registry_derives_tarball, split_userinfo, yaml_top_level_value, ProjectDist, }; use crate::patch::redirect::upstream::client::NpmDist; @@ -1668,7 +1698,8 @@ mod tests { own = { token = \"own\" }\n\ unset = { url = \"https://un.example/\", token = \"$NOPE\" }\n\ other = { url = \"https://ot.example/\", token = \"${GITHUB_TOKEN}\" }\n\ - inurl = \"https://u:$GITHUB_TOKEN@iu.example/\"\n"; + inurl = \"https://u:$GITHUB_TOKEN@iu.example/\"\n\ + inpath = \"https://ip.example/$NODE_AUTH_TOKEN/\"\n"; assert_eq!( auth(None, Some(bunfig), None, "@corp/w"), some("https://corp.example/npm/", Some("Bearer from-env")) @@ -1708,6 +1739,30 @@ mod tests { !String::from_utf8_lossy(&sent.unwrap_or_default()).contains("not-for-registries"), "the userinfo expanded $GITHUB_TOKEN" ); + // Even an allowed token stays out of the URL's host, path and + // query, which are requested, printed and written into the lock; + // only userinfo (moved onto the request header) expands. + assert_eq!( + auth(None, Some(bunfig), None, "@inpath/w"), + some("https://ip.example//", None) + ); + assert_eq!( + auth( + Some("@p:registry=https://h.example/${BUN_AUTH_TOKEN}/?t=${BUN_AUTH_TOKEN}\n"), + None, + None, + "@p/w" + ), + some("https://h.example//?t=", None) + ); + assert_eq!( + expand_url_env( + "https://u:$NODE_AUTH_TOKEN@h.example/$NODE_AUTH_TOKEN", + &vars, + true + ), + "https://u:from-env@h.example/" + ); // A token-only scope: the configured default registry, its own token. assert_eq!( auth(None, Some(bunfig), Some("https://e.example/"), "@own/w"), diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 182e8fcf5..ef46ec34d 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -215,7 +215,9 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. registries, `BUN_CONFIG_REGISTRY` / `NPM_CONFIG_REGISTRY`), sending the credentials those settings give it — a bunfig `token` or `username` / `password` (`$VAR` expanded only for `NPM_TOKEN`, `NODE_AUTH_TOKEN` and `BUN_AUTH_TOKEN`; any other - variable expands to nothing, so a project's config cannot send other secrets), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + + variable expands to nothing, so a project's config cannot send other secrets; in a + registry URL only its `user:password@` part expands, which is sent as the + `Authorization` header and never printed or written into the lock), else the `.npmrc` `//host/path/:_authToken` / `_auth` / `username` + `_password` covering the registry URL (`BUN_CONFIG_TOKEN` is not read); a registry that still cannot be read falls back to the default registry's document with an `upstream_registry_fallback` warning. Bun's hoisted linker keeps an installed copy whose lock entry