From ebf6a74efde7c902ca1f846d48244e71ca86b9c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:22:08 +0000 Subject: [PATCH 01/10] Start fix for #1094 Assisted-by: Claude Code:claude-opus-5-5 From 4e520c37ecde0c30744487ab503c391e0e0822ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:32:46 +0000 Subject: [PATCH 02/10] Refuse npm workspace members with a stray lock A workspace member that still held its own package-lock.json or npm-shrinkwrap.json skipped the workspace-member refusal. npm never reads a lock inside a member: it installs every member from the workspace root's lock. So hosted scan/get pinned the ignored member lock and vendored mode vendored into it. Both exited 0 while npm kept installing the unpatched package. Hosted scan/get from such a member now refuse with redirect_workspace_lockfile_elsewhere, naming the root lock and the ignored member lock. Vendored refuses with vendor_lockfile_missing, as it does for a member with no lock. A member that also holds a lock its own manager reads (pnpm, yarn, Bun, vlt, Rush), or a workspace root with no npm lock, keeps the own-lock shortcut. Fixes #1094 (hosted and vendored legs) Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/in_process_redirect_pnpm.rs | 112 ++++++++++- .../src/hosted/governing_root.rs | 181 ++++++++++++++++-- .../src/vendor/npm_flavor.rs | 57 ++++++ 4 files changed, 325 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b02fa44fa..9c0dbda81 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1274,7 +1274,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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`. | | `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_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`; a directory whose only locks are `package-lock.json` / `npm-shrinkwrap.json` is refused the same way when that root holds `package-lock.json` or `npm-shrinkwrap.json`, because npm never reads a lock inside a workspace member (#1094; vendored refuses it with `vendor_lockfile_missing`)) 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. | | `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 47937792d..626b28033 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -56,7 +56,10 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { }))) .mount(server) .await; - std::env::set_var("SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri())); + std::env::set_var( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ); let code = rollback::run(RollbackArgs { targets: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -431,7 +434,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock"); let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}"); - assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}"); + assert!( + lock.contains(HOSTED_URL), + "the BOM lock is redirected: {lock}" + ); let ws_path = tmp.path().join("pnpm-workspace.yaml"); assert_eq!( std::fs::read_to_string(&ws_path).ok().as_deref(), @@ -446,7 +452,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { pristine, "rollback restores the BOM lock byte for byte" ); - assert!(!ws_path.exists(), "the auto-created workspace file goes too"); + assert!( + !ws_path.exists(), + "the auto-created workspace file goes too" + ); // A BOM workspace file whose first key is the user's opt-out: left // byte-identical (no duplicate `trustLockfile`), lock still redirected. @@ -472,7 +481,11 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { "the lock is still redirected for {user_ws:?}" ); let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); - assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}"); + assert_eq!( + ws, + want.unwrap_or(user_ws), + "workspace file for {user_ws:?}" + ); assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}"); } } @@ -877,7 +890,11 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_legacy_ledger_and_api() { ..VexRun::offline() }, ); - assert_eq!(out.code, Some(0), "[{lock_name}] legacy ledger, offline: {out}"); + assert_eq!( + out.code, + Some(0), + "[{lock_name}] legacy ledger, offline: {out}" + ); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); assert_eq!(api.request_count(), seen); @@ -1669,3 +1686,88 @@ fn assert_refused_workspace_lock_elsewhere( "{case}: nothing written in the member" ); } + +/// #1094: an npm workspace member holding a stray `package-lock.json` / +/// `npm-shrinkwrap.json` of its own (one npm never reads; members install +/// from the root lock) used to skip the #884 refusal. `scan` and `get` +/// pinned the ignored member lock and exited 0. They now refuse, name the +/// root lock and the ignored member lock, and leave both untouched. +#[tokio::test] +#[serial] +async fn hosted_scan_from_npm_member_with_stray_lock_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + for (root_lock, member_lock) in [ + ("package-lock.json", "package-lock.json"), + ("package-lock.json", "npm-shrinkwrap.json"), + ("npm-shrinkwrap.json", "package-lock.json"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let member = write_package_json_workspace(tmp.path(), root_lock, false); + let stray = member.join(member_lock); + let stray_text = serde_json::json!({ + "name": "a", "version": "1.0.0", "lockfileVersion": 3, + "packages": { + "": { "name": "a", "version": "1.0.0", "dependencies": { NAME: VERSION } }, + format!("node_modules/{NAME}"): { + "version": VERSION, + "resolved": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": "sha512-orig==" + } + } + }) + .to_string(); + std::fs::write(&stray, &stray_text).unwrap(); + let lock = tmp.path().join(root_lock); + let before = std::fs::read_to_string(&lock).unwrap(); + let case = format!("root {root_lock}, member {member_lock}"); + + for args in [ + vec!["scan", "--mode", "hosted"], + vec!["get", UUID, "--mode", "hosted"], + ] { + let out = scrubbed_cli() + .args(&args) + .args([ + "--json", + "--yes", + "--cwd", + member.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{case} {args:?}: output is not JSON ({e}):\n{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + let case = format!("{case} {args:?}"); + assert_refused_workspace_lock_elsewhere( + &case, + out.status.code(), + &doc, + &lock, + &before, + &member, + ); + let message = doc["error"].as_str().unwrap_or_default(); + assert!(message.contains(member_lock), "{case}: {message}"); + assert_eq!( + std::fs::read_to_string(&stray).unwrap(), + stray_text, + "{case}: the stray member lock is untouched" + ); + assert!(!member.join(".npmrc").exists(), "{case}"); + } + } +} diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index c646adc4c..5179d72ed 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -88,7 +88,7 @@ pub async fn refusal( } if candidates.iter().any(|c| c.dep.ecosystem == "npm") { let workspace = if has_own_npm_family_lock(root) { - None + npm_member_stray_lock_refusal(root).await } else { package_json_workspace_refusal(root).await }; @@ -276,6 +276,95 @@ const WORKSPACE_ROOT_LOCKS: [&str; 5] = [ /// governing root with the refusal, so [`refusal`] can weigh it against /// the pnpm check (the nearer root wins; a tie goes to pnpm's message). async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let (ancestor, locks) = package_json_workspace_root(root).await?; + let refusal = Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{} is a workspace member with no lockfile of its own: the workspace \ + root {} lists it under \"workspaces\" and installs it from {}, which a \ + hosted run here cannot see; run socket-patch from {} (the workspace \ + root); nothing was written", + root.display(), + ancestor.display(), + join_paths(&ancestor, &locks), + ancestor.display() + ), + }; + Some((ancestor, refusal)) +} + +/// #1094: the project directory holds only npm locks (`package-lock.json`, +/// `npm-shrinkwrap.json`) and is a member of a `package.json` workspace +/// whose root holds an npm lock. npm installs every workspace member from +/// the root's lock and never reads a lock inside the member (a stray one, +/// typically left behind when the package moved into the monorepo), so a +/// run here would pin or vendor a lock npm ignores and report success. +/// +/// A member that also holds a lock its own manager reads (pnpm, yarn, Bun, +/// vlt, or a Rush repo) keeps the own-lock shortcut: pnpm and vlt ignore +/// `package.json` workspaces, and yarn berry treats a nested `yarn.lock` +/// as a separate project. A root with no npm lock is left alone too. +/// +/// Returns the workspace root with a one-line detail naming both locks, +/// `None` otherwise. +pub(crate) async fn npm_member_stray_lock(root: &Path) -> Option<(PathBuf, String)> { + let own: Vec<&str> = NPM_LOCKS + .iter() + .copied() + .filter(|name| root.join(name).exists()) + .collect(); + let other_own = OWN_LOCKS + .iter() + .chain(std::iter::once(&VLT_LOCK)) + .any(|name| root.join(name).exists()) + || root.join("rush.json").exists(); + if own.is_empty() || other_own { + return None; + } + let (ancestor, locks) = package_json_workspace_root(root).await?; + let npm_locks: Vec<&str> = locks + .into_iter() + .filter(|name| NPM_LOCKS.contains(name)) + .collect(); + if npm_locks.is_empty() { + return None; + } + let detail = format!( + "{} is a member of the npm workspace rooted at {}: npm installs it from {} and \ + ignores its own {}, so a lock rewritten here would never be installed", + root.display(), + ancestor.display(), + join_paths(&ancestor, &npm_locks), + join_paths(root, &own) + ); + Some((ancestor, detail)) +} + +/// The hosted refusal for [`npm_member_stray_lock`]. +async fn npm_member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let (ancestor, detail) = npm_member_stray_lock(root).await?; + let refusal = Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{detail}; run socket-patch from {} (the workspace root), or delete the \ + stray member lock; nothing was written", + ancestor.display() + ), + }; + Some((ancestor, refusal)) +} + +fn join_paths(dir: &Path, names: &[&str]) -> String { + names + .iter() + .map(|name| dir.join(name).display().to_string()) + .collect::>() + .join(", ") +} + +/// The governing `package.json` workspace root of a member and the +/// workspace locks it holds (see [`package_json_workspace_refusal`]). +async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'static str>)> { let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); @@ -297,7 +386,7 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal if !workspaces_include(&patterns, &rel) { continue; } - let locks: Vec<&str> = WORKSPACE_ROOT_LOCKS + let locks: Vec<&'static str> = WORKSPACE_ROOT_LOCKS .iter() .copied() .filter(|name| ancestor.join(name).is_file()) @@ -311,24 +400,7 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal member = ancestor; continue; } - let refusal = Refusal { - code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: format!( - "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"workspaces\" and installs it from {}, which a \ - hosted run here cannot see; run socket-patch from {} (the workspace \ - root); nothing was written", - root.display(), - ancestor.display(), - locks - .iter() - .map(|name| ancestor.join(name).display().to_string()) - .collect::>() - .join(", "), - ancestor.display() - ), - }; - return Some((ancestor.to_path_buf(), refusal)); + return Some((ancestor.to_path_buf(), locks)); } None } @@ -792,7 +864,8 @@ mod tests { code(&tmp.path().join("packages/excluded"), "npm").await, None ); - // A member with its own lock. + // A member with its own npm lock, under a root whose yarn.lock npm + // does not read (the npm-root case is #1094's test). write(tmp.path(), "packages/a/package-lock.json", "{}"); assert_eq!(code(&member, "npm").await, None); @@ -804,6 +877,72 @@ mod tests { assert_eq!(code(&tmp.path().join("sub"), "npm").await, None); } + /// #1094: npm installs every workspace member from the root's lock and + /// never reads a `package-lock.json` / `npm-shrinkwrap.json` inside the + /// member, so a stray member npm lock does not make the member its own + /// lock root when the workspace root holds an npm lock. Other own locks + /// (pnpm, yarn, Bun, vlt), and a root with no npm lock, keep the + /// member's own-lock shortcut. + #[tokio::test] + async fn npm_member_with_stray_npm_lock_is_refused() { + for (root_lock, member_lock) in [ + ("package-lock.json", "package-lock.json"), + ("package-lock.json", "npm-shrinkwrap.json"), + ("npm-shrinkwrap.json", "package-lock.json"), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), root_lock, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), &format!("packages/a/{member_lock}"), "{}"); + let member = tmp.path().join("packages/a"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{root_lock}/{member_lock}: member must be refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE); + assert!( + refused.message.contains(root_lock) + && refused.message.contains(member_lock) + && refused.message.contains("ignores") + && refused.message.contains("nothing was written"), + "{}", + refused.message + ); + assert_eq!(code(&member, "pypi").await, None); + assert_eq!(code(tmp.path(), "npm").await, None); + } + + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/a/package-lock.json", "{}"); + let member = tmp.path().join("packages/a"); + // No npm lock at the root (lockless, or another manager's lock). + assert_eq!(code(&member, "npm").await, None); + write(tmp.path(), "yarn.lock", ""); + assert_eq!(code(&member, "npm").await, None); + // A member that also holds a lock its manager does read keeps the + // shortcut (yarn berry treats a nested yarn.lock as its own project). + write(tmp.path(), "package-lock.json", "{}"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + for own in ["yarn.lock", "bun.lock", "vlt-lock.json"] { + write(tmp.path(), &format!("packages/a/{own}"), ""); + assert_eq!(code(&member, "npm").await, None, "{own}"); + std::fs::remove_file(member.join(own)).unwrap(); + } + } + /// The nearest ancestor that lists the member is its root, past an /// intermediate `package.json` that does not. #[tokio::test] diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 21fb4aa46..8e91742ec 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -356,6 +356,21 @@ pub async fn vendor_npm_any<'a>( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; + // #1094: a workspace member's own npm lock is one npm never reads; the + // member is refused as it is without that lock. + if flavor == NpmLockFlavor::PackageLock { + if let Some((_, detail)) = + crate::hosted::governing_root::npm_member_stray_lock(project_root).await + { + return VendorOutcome::Refused { + code: "vendor_lockfile_missing", + detail: format!( + "{detail}; vendor from the workspace root, or delete the stray member \ + lock" + ), + }; + } + } if let Some(detail) = flavor_change_refusal(project_root, purl, flavor).await { return VendorOutcome::Refused { code: "vendor_flavor_changed", @@ -1576,6 +1591,48 @@ mod tests { ))); } + /// #1094: a workspace member's own package-lock.json is a lock npm never + /// reads (members install from the workspace root's lock), so vendoring + /// into it would wire nothing. The member is refused as it is without + /// the stray lock, and nothing is written. + #[tokio::test] + async fn npm_member_with_stray_lock_is_refused() { + let (tmp, record) = npm_project().await; + let ws = tempfile::tempdir().unwrap(); + let member = ws.path().join("packages/a"); + tokio::fs::create_dir_all(member.parent().unwrap()) + .await + .unwrap(); + tokio::fs::rename(tmp.path(), &member).await.unwrap(); + touch( + ws.path(), + "package.json", + r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#, + ) + .await; + touch(ws.path(), "package-lock.json", "{}").await; + let lock_before = tokio::fs::read(member.join("package-lock.json")) + .await + .unwrap(); + + let outcome = vendor_any(&member, &record).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_lockfile_missing"); + assert!( + detail.contains("workspace") && detail.contains("ignores"), + "{detail}" + ); + assert!(!member.join(".socket/vendor").exists()); + assert_eq!( + tokio::fs::read(member.join("package-lock.json")) + .await + .unwrap(), + lock_before + ); + } + /// A yarn.lock ROUTES to the yarn-classic backend. With a header-only /// lock that has no matching block, the backend's own `vendor_lock_entry_not_found` /// proves the dispatch reached it — and nothing is written. From cddf38df40d424ab6892d059073c2cf6838f23af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:33:18 +0000 Subject: [PATCH 03/10] Keep the test file's existing formatting Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_redirect_pnpm.rs | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 626b28033..dd64db845 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -56,10 +56,7 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { }))) .mount(server) .await; - std::env::set_var( - "SOCKET_NPM_REGISTRY", - format!("{}/npm-registry", server.uri()), - ); + std::env::set_var("SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri())); let code = rollback::run(RollbackArgs { targets: Vec::new(), common: socket_patch_cli::args::GlobalArgs { @@ -434,10 +431,7 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock"); let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}"); - assert!( - lock.contains(HOSTED_URL), - "the BOM lock is redirected: {lock}" - ); + assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}"); let ws_path = tmp.path().join("pnpm-workspace.yaml"); assert_eq!( std::fs::read_to_string(&ws_path).ok().as_deref(), @@ -452,10 +446,7 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { pristine, "rollback restores the BOM lock byte for byte" ); - assert!( - !ws_path.exists(), - "the auto-created workspace file goes too" - ); + assert!(!ws_path.exists(), "the auto-created workspace file goes too"); // A BOM workspace file whose first key is the user's opt-out: left // byte-identical (no duplicate `trustLockfile`), lock still redirected. @@ -481,11 +472,7 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { "the lock is still redirected for {user_ws:?}" ); let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); - assert_eq!( - ws, - want.unwrap_or(user_ws), - "workspace file for {user_ws:?}" - ); + assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}"); assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}"); } } @@ -890,11 +877,7 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_legacy_ledger_and_api() { ..VexRun::offline() }, ); - assert_eq!( - out.code, - Some(0), - "[{lock_name}] legacy ledger, offline: {out}" - ); + assert_eq!(out.code, Some(0), "[{lock_name}] legacy ledger, offline: {out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); assert_eq!(api.request_count(), seen); From 57f5bbf16739671d55d426058172b8277bf02cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:40:03 +0000 Subject: [PATCH 04/10] Refuse stray-lock members before takeover A vendored run from an npm workspace member with a stray lock refused only inside the vendor backend, after the hosted-to-vendored takeover had already restored a leftover hosted pin. The takeover preflight now raises the same vendor_lockfile_missing refusal first, so nothing is reverted. The hosted and vendored messages no longer suggest deleting the stray lock: the directory is still a listed workspace member, so it would be refused again. Both name the workspace root to run from. Refs #1094 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 9 ++-- .../src/vendor/npm_flavor.rs | 43 +++++++++++++------ .../socket-patch-core/src/vendor/npm_lock.rs | 9 +++- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 319cd65d1..3f9cfe9a9 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -351,8 +351,8 @@ async fn npm_member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal) let refusal = Refusal { code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), message: format!( - "{detail}; run socket-patch from {} (the workspace root), or delete the \ - stray member lock; nothing was written", + "{detail}; run socket-patch from {} (the workspace root); nothing was \ + written", ancestor.display() ), }; @@ -1203,7 +1203,10 @@ mod tests { refused.message.contains(root_lock) && refused.message.contains(member_lock) && refused.message.contains("ignores") - && refused.message.contains("nothing was written"), + && refused.message.contains("nothing was written") + // The only convergent remedy: the directory stays a + // listed member whatever lock it holds (Bugbot on #1095). + && !refused.message.contains("delete"), "{}", refused.message ); diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 3a20ac6f1..d5f82fd28 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -389,6 +389,26 @@ async fn detect_vendorable_npm_flavor_with( )) } +/// #1094: a package-lock project that is a member of an npm workspace +/// holds a lock npm never reads (members install from the workspace +/// root's lock), so vendoring into it would wire nothing. Refused as a +/// member without that lock is (`vendor_lockfile_missing`). Shared by +/// [`vendor_npm_any`] and the hosted→vendored takeover preflight +/// ([`super::npm_lock::npm_lock_vendor_preflight`]), which must refuse +/// before the takeover restores the hosted pin. +pub(crate) async fn npm_member_stray_lock_refusal( + project_root: &Path, +) -> Option<(&'static str, String)> { + let (root, detail) = crate::hosted::governing_root::npm_member_stray_lock(project_root).await?; + Some(( + "vendor_lockfile_missing", + format!( + "{detail}; vendor from {} (the workspace root)", + root.display() + ), + )) +} + /// Vendor one npm package through whichever lockfile-flavor backend serves /// this project (package-lock / yarn classic / yarn berry node-modules / /// pnpm / pnpm legacy / bun / vlt). Probe refusals (PnP, unsupported lock @@ -411,19 +431,9 @@ pub async fn vendor_npm_any<'a>( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; - // #1094: a workspace member's own npm lock is one npm never reads; the - // member is refused as it is without that lock. if flavor == NpmLockFlavor::PackageLock { - if let Some((_, detail)) = - crate::hosted::governing_root::npm_member_stray_lock(project_root).await - { - return VendorOutcome::Refused { - code: "vendor_lockfile_missing", - detail: format!( - "{detail}; vendor from the workspace root, or delete the stray member \ - lock" - ), - }; + if let Some((code, detail)) = npm_member_stray_lock_refusal(project_root).await { + return VendorOutcome::Refused { code, detail }; } } if let Some(detail) = flavor_change_refusal(project_root, purl, flavor).await { @@ -1771,6 +1781,13 @@ mod tests { .await .unwrap(); + // The hosted→vendored takeover preflight raises the same refusal + // first, so a leftover hosted pin is never restored only to be + // refused (Bugbot on #1095). + let preflight = crate::vendor::npm_lock_vendor_preflight(&member) + .await + .expect("the takeover preflight refuses the member"); + let outcome = vendor_any(&member, &record).await; let VendorOutcome::Refused { code, detail } = outcome else { panic!("expected Refused, got {outcome:?}"); @@ -1780,6 +1797,8 @@ mod tests { detail.contains("workspace") && detail.contains("ignores"), "{detail}" ); + assert!(!detail.contains("delete"), "{detail}"); + assert_eq!(preflight, (code, detail)); assert!(!member.join(".socket/vendor").exists()); assert_eq!( tokio::fs::read(member.join("package-lock.json")) diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 7f14ddf8b..cf3ff48a8 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -417,8 +417,10 @@ pub async fn vendor_npm<'a>( } /// The project-level refusal [`vendor_npm`]'s step 2 raises whatever the -/// purl: the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) -/// is not parseable JSON or not a v2/v3 lock. `None` unless the project's +/// purl: the project is an npm workspace member whose own lock npm never +/// reads (#1094, [`super::npm_flavor::npm_member_stray_lock_refusal`]), or +/// the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) is +/// not parseable JSON or not a v2/v3 lock. `None` unless the project's /// npm flavor is package-lock (the probe `vendor_npm_any` routes on) and /// that lock fails the gate; a missing or unreadable lock is left to the /// backend's own refusal. @@ -437,6 +439,9 @@ pub async fn npm_lock_vendor_preflight(project_root: &Path) -> Option<(&'static ) { return None; } + if let Some(refusal) = super::npm_flavor::npm_member_stray_lock_refusal(project_root).await { + return Some(refusal); + } let (lock_name, lock_bytes, _) = select_lockfile(project_root).await.ok()??; let gate = match LOCK_MEMO.parse(&lock_bytes, || parse_json_manifest(&lock_bytes)) { Ok(lock) => lock_version_gate(&lock, &lock_name).err(), From 3faf081750fb33f205e4968c9da608a3bb2e8661 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:22:23 +0000 Subject: [PATCH 05/10] Label the setup-php pin with its real tag Ported from #1118. The upstream v2 tag moved off the pinned commit, so zizmor's ref-version-mismatch audit fails every PR on main. The pin itself is unchanged; only its comment now names 2.37.2. This no-ops once #1118 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 f4f8067ea..cce660612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1397,7 +1397,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 58834ec5b76a030167e13ce89cf170984e878c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:23:47 +0000 Subject: [PATCH 06/10] Start fix for #1101, #1134 Assisted-by: Claude Code:claude-opus-5-5 From 8e71db6e95cc3aaf8742e061067891cf26bb3bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:34:45 +0000 Subject: [PATCH 07/10] Refuse Bun and vlt members with a stray lock A Bun or vlt workspace member that still holds its own bun.lock, bun.lockb or vlt-lock.json (typically left behind when the package moved into the monorepo) skipped the workspace-member refusal. Bun and vlt never read that lock: they install the member from the root lock. Hosted and vendored scans then pinned or vendored a lock nothing installs from and reported success, and vex from the member attested not_affected. The npm-only check from #1094 now covers every manager that ignores a member lock. Hosted runs refuse with redirect_workspace_lockfile_elsewhere, vendored runs and the takeover preflights refuse with vendor_lockfile_missing, and vex discovery reads the ignored lock as absent, naming it in one diagnostic. Fixes #1101, #1134. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 3 +- .../tests/e2e_redirect_yarn_classic_build.rs | 16 +- .../tests/e2e_vendor_yarn_berry_build.rs | 47 ++- .../tests/in_process_redirect_pipenv.rs | 3 +- .../tests/in_process_redirect_pnpm.rs | 16 +- crates/socket-patch-core/src/formats/mod.rs | 2 +- .../src/hosted/governing_root.rs | 344 +++++++++++++++--- .../src/patch/redirect/mod.rs | 27 +- .../src/patch/redirect/poetry.rs | 12 +- .../src/patch/redirect/upstream/cargo.rs | 5 +- .../socket-patch-core/src/vendor/bun_lock.rs | 3 + .../src/vendor/lock_inventory/tests.rs | 5 +- crates/socket-patch-core/src/vendor/mod.rs | 2 +- .../src/vendor/npm_flavor.rs | 27 +- .../socket-patch-core/src/vendor/npm_lock.rs | 4 +- .../socket-patch-core/src/vendor/vlt_lock.rs | 3 + .../src/vendor/yarn_berry_lock.rs | 4 +- .../src/vendor/yarn_classic_lock.rs | 6 +- .../socket-patch-core/src/vex/discover/mod.rs | 64 +++- .../src/vex/discover/yarn.rs | 2 +- crates/socket-patch-core/src/vex/product.rs | 4 +- 21 files changed, 482 insertions(+), 117 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5413d6327..61fda0e7f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1234,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 diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index 181a7664d..7dc352b4b 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -900,7 +900,10 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { return; }; assert!( - fx.proj.join("mirror").join(format!("{DEP}-{DEP_VERSION}.tgz")).is_file(), + fx.proj + .join("mirror") + .join(format!("{DEP}-{DEP_VERSION}.tgz")) + .is_file(), "the fixture install must populate the offline mirror" ); let fresh = fx.tmp.path().join("fresh"); @@ -926,7 +929,11 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { String::from_utf8_lossy(&ci.stderr) ); assert!( - !fresh.join("node_modules").join(DEP).join("index.js").exists(), + !fresh + .join("node_modules") + .join(DEP) + .join("index.js") + .exists(), "yarn < 1.7 is expected to install nothing from the mirror" ); return; @@ -948,7 +955,10 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { ); let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); - assert_eq!(installed, fx.orig, "the untouched lock installs the upstream bytes"); + assert_eq!( + installed, fx.orig, + "the untouched lock installs the upstream bytes" + ); std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); } } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 5f43d035f..abf2ae267 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -737,7 +737,14 @@ async fn run_berry_capstone(driver: VendorDriver, yarnrc_extra: &str) { fn git(cwd: &Path, args: &[&str]) -> Output { let out = Command::new("git") - .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"]) + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "init.defaultBranch=main", + ]) .args(args) .current_dir(cwd) .output() @@ -818,16 +825,30 @@ fn yarn_berry_vendored_tarball_survives_a_tgz_gitignore_rule() { }; let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); git(&proj, &["add", "-A"]); git(&proj, &["commit", "-qm", "vendored"]); let fresh = tmp.path().join("fresh"); git( tmp.path(), - &["clone", "-q", proj.to_str().unwrap(), fresh.to_str().unwrap()], + &[ + "clone", + "-q", + proj.to_str().unwrap(), + fresh.to_str().unwrap(), + ], ); let fresh_global = tmp.path().join("fresh-yarn-global"); let ci = corepack( @@ -862,14 +883,26 @@ fn yarn_berry_vendor_refuses_a_gitignored_socket_dir() { let pkg_before = std::fs::read(proj.join("package.json")).unwrap(); let (code, stdout, stderr) = run_socket( &proj, - &["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()], + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 1, + "vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - assert_eq!(code, 1, "vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}"); assert!( stdout.contains("vendor_artifact_gitignored"), "refusal code expected:\n{stdout}" ); assert_eq!(std::fs::read(proj.join("yarn.lock")).unwrap(), lock_before); - assert_eq!(std::fs::read(proj.join("package.json")).unwrap(), pkg_before); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + pkg_before + ); assert!(!proj.join(format!(".socket/vendor/npm/{UUID}")).exists()); } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 051eaf6c5..7ede43a32 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -556,8 +556,7 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() { #[tokio::test] #[serial] async fn platform_wheel_is_not_pinned_into_the_lock() { - assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64") - .await; + assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64").await; } /// #1048: a pure wheel bound to one interpreter (`cp311-none-any`) fails diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 865ae4969..7ef834d1a 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -434,7 +434,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock"); let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}"); - assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}"); + assert!( + lock.contains(HOSTED_URL), + "the BOM lock is redirected: {lock}" + ); let ws_path = tmp.path().join("pnpm-workspace.yaml"); assert_eq!( std::fs::read_to_string(&ws_path).ok().as_deref(), @@ -449,7 +452,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { pristine, "rollback restores the BOM lock byte for byte" ); - assert!(!ws_path.exists(), "the auto-created workspace file goes too"); + assert!( + !ws_path.exists(), + "the auto-created workspace file goes too" + ); // A BOM workspace file whose first key is the user's opt-out: left // byte-identical (no duplicate `trustLockfile`), lock still redirected. @@ -475,7 +481,11 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() { "the lock is still redirected for {user_ws:?}" ); let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(); - assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}"); + assert_eq!( + ws, + want.unwrap_or(user_ws), + "workspace file for {user_ws:?}" + ); assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}"); } } diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 29dc4067f..069e33c6b 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -29,8 +29,8 @@ pub(crate) mod bun; pub mod cargo; pub mod composer; -pub mod governing_locks; pub mod gem; +pub mod governing_locks; pub(crate) mod maven; pub(crate) mod nuget; pub mod pnpm; diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 2700665ad..0e64c66b0 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -86,7 +86,7 @@ pub async fn refusal( } if candidates.iter().any(|c| c.dep.ecosystem == "npm") { let workspace = if has_own_npm_family_lock(root) { - npm_member_stray_lock_refusal(root).await + member_stray_lock_refusal(root).await } else { nearer_root( package_json_workspace_refusal(root).await, @@ -289,66 +289,159 @@ async fn package_json_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal Some((ancestor, refusal)) } -/// #1094: the project directory holds only npm locks (`package-lock.json`, -/// `npm-shrinkwrap.json`) and is a member of a `package.json` workspace -/// whose root holds an npm lock. npm installs every workspace member from -/// the root's lock and never reads a lock inside the member (a stray one, -/// typically left behind when the package moved into the monorepo), so a -/// run here would pin or vendor a lock npm ignores and report success. +/// A workspace member's own locks that its package manager never reads +/// (see [`member_stray_lock`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MemberStrayLock { + /// The workspace root whose lock installs the member. + pub(crate) root: PathBuf, + /// The member's own lock files (root-relative names), every one of + /// them ignored. + pub(crate) ignored: Vec<&'static str>, + /// A one-line detail naming the root's locks and the ignored ones. + pub(crate) detail: String, +} + +/// The package managers that never read a lock inside one of their +/// workspace members, with the name a refusal gives them. +const STRAY_LOCK_FAMILIES: [(NpmLockFamily, &str); 3] = [ + (NpmLockFamily::Vlt, "vlt"), + (NpmLockFamily::Bun, "Bun"), + (NpmLockFamily::Npm, "npm"), +]; + +/// The project directory is a workspace member whose own locks are all +/// ones its package manager never reads (a stray lock, typically left +/// behind when a standalone package moved into the monorepo), so a run +/// here would pin or vendor a lock nothing installs from and report +/// success: /// -/// A member that also holds a lock its own manager reads (pnpm, yarn, Bun, -/// vlt, or a Rush repo) keeps the own-lock shortcut: pnpm and vlt ignore -/// `package.json` workspaces, and yarn berry treats a nested `yarn.lock` -/// as a separate project. A root with no npm lock is left alone too. +/// - npm (#1094) and Bun (#1101) install every `package.json` workspace +/// member from the root's lock: a member `package-lock.json` / +/// `npm-shrinkwrap.json` is stray when the root holds an npm lock, a +/// member `bun.lock` / `bun.lockb` when the root holds a Bun lock. +/// - vlt (#1134) resolves a member with no `vlt.json` of its own to the +/// workspace root (its `vlt.json` `workspaces`, or `package.json` +/// `workspaces` when `vlt.json` declares none) and installs it from the +/// root's `vlt-lock.json`. /// -/// Returns the workspace root with a one-line detail naming both locks, -/// `None` otherwise. -pub(crate) async fn npm_member_stray_lock(root: &Path) -> Option<(PathBuf, String)> { - let npm_locks = NpmLockFamily::Npm.files(); - let own: Vec<&str> = npm_locks - .iter() - .copied() +/// A member that also holds a lock its own manager does read (pnpm, yarn, +/// or a family the root does not hold), or a Rush repo, keeps the +/// own-lock shortcut: pnpm ignores `package.json` workspaces, and yarn +/// berry treats a nested `yarn.lock` as a separate project. +/// +/// Shared by the hosted refusal, the vendored refusal +/// (`vendor_lockfile_missing`) and VEX discovery, which reads the ignored +/// locks as absent. +pub(crate) async fn member_stray_lock(root: &Path) -> Option { + let own: Vec<&'static str> = npm_lock_files() .filter(|name| root.join(name).exists()) .collect(); - let other_own = npm_lock_files() - .filter(|name| !npm_locks.contains(name)) - .chain(EXTRA_OWN_LOCKS) - .any(|name| root.join(name).exists()) + let other_own = EXTRA_OWN_LOCKS.iter().any(|name| root.join(name).exists()) || root.join("rush.json").exists(); - if own.is_empty() || other_own { + // Every own lock must belong to a manager that ignores it in a member. + let all_strayable = own.iter().all(|name| { + STRAY_LOCK_FAMILIES + .iter() + .any(|(family, _)| family.files().contains(name)) + }); + if own.is_empty() || other_own || !all_strayable { return None; } - let (ancestor, locks) = package_json_workspace_root(root).await?; - let root_npm_locks: Vec<&str> = locks - .into_iter() - .filter(|name| npm_locks.contains(name)) - .collect(); - if root_npm_locks.is_empty() { - return None; + let mut package_json_root = None; + let mut governing: Option<(PathBuf, Vec<&'static str>, &str)> = None; + for (family, manager) in STRAY_LOCK_FAMILIES { + let files = family.files(); + if !own.iter().any(|name| files.contains(name)) { + continue; + } + let found = match family { + NpmLockFamily::Vlt => { + if root.join(VLT_JSON).exists() { + return None; + } + vlt_lock_root(root, &mut package_json_root).await + } + _ => cached_package_json_root(root, &mut package_json_root) + .await + .clone(), + }; + let (ancestor, locks) = found?; + let root_locks: Vec<&'static str> = locks + .into_iter() + .filter(|name| files.contains(name)) + .collect(); + if root_locks.is_empty() { + return None; + } + governing.get_or_insert((ancestor, root_locks, manager)); } + let (ancestor, root_locks, manager) = governing?; let detail = format!( - "{} is a member of the npm workspace rooted at {}: npm installs it from {} and \ - ignores its own {}, so a lock rewritten here would never be installed", + "{} is a member of the {manager} workspace rooted at {}: {manager} installs it from {} \ + and ignores its own {}, so a lock rewritten here would never be installed", root.display(), ancestor.display(), - join_paths(&ancestor, &root_npm_locks), + join_paths(&ancestor, &root_locks), join_paths(root, &own) ); - Some((ancestor, detail)) + Some(MemberStrayLock { + root: ancestor, + ignored: own, + detail, + }) } -/// The hosted refusal for [`npm_member_stray_lock`]. -async fn npm_member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { - let (ancestor, detail) = npm_member_stray_lock(root).await?; +/// [`package_json_workspace_root`], walked at most once per check. +async fn cached_package_json_root<'a>( + root: &Path, + cache: &'a mut Option)>>, +) -> &'a Option<(PathBuf, Vec<&'static str>)> { + if cache.is_none() { + *cache = Some(package_json_workspace_root(root).await); + } + cache.as_ref().unwrap_or(&None) +} + +/// The vlt workspace root that installs a member and its locks: the nearer +/// of the `vlt.json` `workspaces` root and the `package.json` `workspaces` +/// root whose `vlt-lock.json` vlt reads (see [`package_json_workspace_root`]). +async fn vlt_lock_root( + root: &Path, + package_json_root: &mut Option)>>, +) -> Option<(PathBuf, Vec<&'static str>)> { + let from_vlt_json = vlt_workspace_root(root).await; + let from_package_json = cached_package_json_root(root, package_json_root) + .await + .clone() + .filter(|(_, locks)| locks.contains(&VLT_LOCK)) + .map(|(ancestor, _)| ancestor); + let ancestor = match (from_vlt_json, from_package_json) { + (Some(a), Some(b)) => { + if b.starts_with(&a) { + b + } else { + a + } + } + (a, b) => a.or(b)?, + }; + Some((ancestor, vec![VLT_LOCK])) +} + +/// The hosted refusal for [`member_stray_lock`]. +async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let stray = member_stray_lock(root).await?; let refusal = Refusal { code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), message: format!( - "{detail}; run socket-patch from {} (the workspace root); nothing was \ + "{}; run socket-patch from {} (the workspace root); nothing was \ written", - ancestor.display() + stray.detail, + stray.root.display() ), }; - Some((ancestor, refusal)) + Some((stray.root, refusal)) } fn join_paths(dir: &Path, names: &[&str]) -> String { @@ -416,6 +509,29 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat /// match is the root; one without a lock (never installed) refuses /// nothing. async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { + let ancestor = vlt_workspace_root(root).await?; + let lock = ancestor.join(VLT_LOCK); + let refusal = Refusal { + code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{} is a workspace member with no lockfile of its own: the workspace \ + root {} lists it under \"workspaces\" in {} and installs it from {}, \ + which a hosted run here cannot see; run socket-patch from {} (the \ + workspace root); nothing was written", + root.display(), + ancestor.display(), + ancestor.join(VLT_JSON).display(), + lock.display(), + ancestor.display() + ), + }; + Some((ancestor, refusal)) +} + +/// The nearest ancestor `vlt.json` whose `workspaces` patterns match the +/// project directory, when it holds `vlt-lock.json` (see +/// [`vlt_workspace_refusal`]). +async fn vlt_workspace_root(root: &Path) -> Option { let canonical = tokio::fs::canonicalize(root) .await .unwrap_or_else(|_| root.to_path_buf()); @@ -436,25 +552,10 @@ async fn vlt_workspace_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { if !workspaces_include(&patterns, &rel) { continue; } - let lock = ancestor.join(VLT_LOCK); - if !lock.is_file() { + if !ancestor.join(VLT_LOCK).is_file() { return None; } - let refusal = Refusal { - code: WORKSPACE_LOCKFILE_ELSEWHERE.to_string(), - message: format!( - "{} is a workspace member with no lockfile of its own: the workspace \ - root {} lists it under \"workspaces\" in {} and installs it from {}, \ - which a hosted run here cannot see; run socket-patch from {} (the \ - workspace root); nothing was written", - root.display(), - ancestor.display(), - ancestor.join(VLT_JSON).display(), - lock.display(), - ancestor.display() - ), - }; - return Some((ancestor.to_path_buf(), refusal)); + return Some(ancestor.to_path_buf()); } None } @@ -1566,11 +1667,138 @@ mod tests { assert_eq!(code(tmp.path(), "npm").await, None); assert_eq!(code(&member, "pypi").await, None); - // A member with its own lock is its own root. + // A member with its own `vlt.json` is its own vlt project, so its + // own lock governs it (#1134 covers the lock without the config). write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + write(tmp.path(), "packages/a/vlt.json", "{}"); assert_eq!(code(&member, "npm").await, None); } + /// #1134: vlt resolves a `vlt.json` workspace member with no `vlt.json` + /// of its own to the workspace root and installs it from the root's + /// `vlt-lock.json`, so a stray `vlt-lock.json` in the member (left + /// behind when a standalone package moved into the monorepo) is never + /// read. The member is refused as it is without the stray lock. + #[tokio::test] + async fn vlt_member_with_stray_vlt_lock_is_refused() { + for package_json_fallback in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + if package_json_fallback { + // vlt reads `package.json` `workspaces` when `vlt.json` + // declares none. + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), "vlt.json", r#"{"registries":{}}"#); + } else { + write(tmp.path(), "package.json", r#"{"private":true}"#); + write(tmp.path(), "vlt.json", r#"{"workspaces":"packages/*"}"#); + } + write(tmp.path(), VLT_LOCK, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/a/vlt-lock.json", "{}"); + let member = tmp.path().join("packages/a"); + let case = format!("package.json fallback: {package_json_fallback}"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{case}: member must be refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE, "{case}"); + assert!( + refused.message.contains(VLT_LOCK) + && refused.message.contains("ignores") + && refused.message.contains("nothing was written") + && refused + .message + .contains(&member.join(VLT_LOCK).display().to_string()), + "{case}: {}", + refused.message + ); + assert_eq!(code(tmp.path(), "npm").await, None, "{case}"); + + // A member with its own vlt.json is its own vlt project. + write(tmp.path(), "packages/a/vlt.json", "{}"); + assert_eq!(code(&member, "npm").await, None, "{case}"); + std::fs::remove_file(member.join("vlt.json")).unwrap(); + // A root that was never installed governs nothing. + std::fs::remove_file(tmp.path().join(VLT_LOCK)).unwrap(); + assert_eq!(code(&member, "npm").await, None, "{case}"); + } + } + + /// #1101: Bun installs every `package.json` workspace member from the + /// root's `bun.lock` / `bun.lockb` and never reads a lock inside the + /// member, so a stray member Bun lock does not make the member its own + /// lock root when the workspace root holds a Bun lock. Every mix of + /// text and binary locks, on either side, is refused. + #[tokio::test] + async fn bun_member_with_stray_bun_lock_is_refused() { + for (root_lock, member_lock) in [ + ("bun.lock", "bun.lock"), + ("bun.lock", "bun.lockb"), + ("bun.lockb", "bun.lock"), + ("bun.lockb", "bun.lockb"), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), root_lock, "{}"); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), &format!("packages/a/{member_lock}"), "{}"); + let member = tmp.path().join("packages/a"); + let case = format!("root {root_lock}, member {member_lock}"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap_or_else(|| panic!("{case}: member must be refused")); + assert_eq!(refused.code, WORKSPACE_LOCKFILE_ELSEWHERE, "{case}"); + assert!( + refused + .message + .contains(&tmp.path().join(root_lock).display().to_string()) + && refused + .message + .contains(&member.join(member_lock).display().to_string()) + && refused.message.contains("Bun") + && refused.message.contains("ignores") + && refused.message.contains("nothing was written"), + "{case}: {}", + refused.message + ); + assert_eq!(code(tmp.path(), "npm").await, None, "{case}"); + } + + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*"]}"#, + ); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/a/bun.lock", "{}"); + let member = tmp.path().join("packages/a"); + // No Bun lock at the root (never installed, or another manager's). + assert_eq!(code(&member, "npm").await, None); + write(tmp.path(), "yarn.lock", ""); + assert_eq!(code(&member, "npm").await, None); + write(tmp.path(), "bun.lock", "{}"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(WORKSPACE_LOCKFILE_ELSEWHERE) + ); + // A member that also holds a lock its own manager reads keeps the + // own-lock shortcut, and so does one with an npm lock the root + // does not back with its own npm lock. + for own in ["yarn.lock", "pnpm-lock.yaml", "package-lock.json"] { + write(tmp.path(), &format!("packages/a/{own}"), ""); + assert_eq!(code(&member, "npm").await, None, "{own}"); + std::fs::remove_file(member.join(own)).unwrap(); + } + } + /// Bugbot on #1073: vlt falls back to `package.json` `workspaces` when /// the root's `vlt.json` has no `workspaces` field, so its /// `vlt-lock.json` there governs the member. With the field present, diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 05d27b75d..e5041dbc4 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -77,9 +77,9 @@ use crate::formats::yarn::patterns::{ use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; #[cfg(test)] -mod pnpm_equivalence_tests; -#[cfg(test)] mod platform_wheel_tests; +#[cfg(test)] +mod pnpm_equivalence_tests; mod poetry; mod pypi_takeover; pub use pypi_takeover::preflight_pypi_takeover; @@ -565,7 +565,7 @@ pub fn rewrite_registry_redirect_with_pipenv_version( bun_lockb_present, &std::collections::BTreeSet::new(), &std::collections::BTreeSet::new(), - &yarnrc::OuterYarnMirror::default(), + &yarnrc::OuterYarnMirror::default(), ) } @@ -6804,8 +6804,10 @@ fn rewrite_maven_pom( // One pass over the pom's repositories: `(id, url)` of each, which also // answers the per-dep URL-refresh check below while the pom is still // unchanged (a no-op rescan then never re-scans the pom per dep). - let original_repos: Vec<(String, Option)> = - pom.as_deref().map(maven_repository_ids_and_urls).unwrap_or_default(); + let original_repos: Vec<(String, Option)> = pom + .as_deref() + .map(maven_repository_ids_and_urls) + .unwrap_or_default(); let hosted_repo_generations: std::collections::BTreeSet = original_repos .iter() .filter_map(|(id, _)| generation::pin_name_uuid(id, false).map(str::to_string)) @@ -10847,7 +10849,10 @@ mod tests { &[(YARNRC_REL, "yarn-offline-mirror: false\n")], &[(YARNRC_REL, "yarn-offline-mirror:\n")], &[(YARNRC_REL, "yarn-offline-mirror \"\"\n")], - &[(YARNRC_REL, "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n")], + &[( + YARNRC_REL, + "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n", + )], &[(npmrc::NPMRC_REL, "[scope]\nyarn-offline-mirror=./m\n")], &[ (YARNRC_REL, "yarn-offline-mirror false\n"), @@ -10863,7 +10868,10 @@ mod tests { let mut r = RewriteResult::default(); rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); assert!(r.warnings.is_empty(), "{rcs:?}: {:?}", r.warnings); - assert!(r.files["yarn.lock"].contains("http://p.test/lp.tgz"), "{rcs:?}"); + assert!( + r.files["yarn.lock"].contains("http://p.test/lp.tgz"), + "{rcs:?}" + ); assert!(r.refused_yarn_classic_uuids.is_empty(), "{rcs:?}"); } } @@ -10888,7 +10896,10 @@ mod tests { rewrite_yarn_classic(&files, std::slice::from_ref(&other), &mut r); assert!(r.refused_yarn_classic_uuids.is_empty()); assert_eq!( - r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), ["redirect_yarn_classic_entry_not_found"] ); } diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index 7a1b32d92..5ee901ff6 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -89,7 +89,9 @@ pub(super) fn rewrite_poetry( new: Some(Value::String(new)), }); } - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); if !stale_warned { if let Some(format) = *writer_format.get_or_insert_with(|| pre_1_4_writer(&content)) @@ -124,14 +126,18 @@ pub(super) fn rewrite_poetry( } // Already redirected to this artifact (idempotent re-scan). LockStep::Unchanged => { - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); } LockStep::NotFound => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), LockStep::Refused(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index 54d4c8030..2e9c01a90 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -489,6 +489,9 @@ mod tests { let other = format!("log = {{ version = \"0.4.20\", registry = \"socket-patch-{C}\" }}\n"); let (outcome, after) = restore_b(&manifest(&other), &config).await; assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins); - assert_eq!(after.as_deref().map(str::trim_start), Some(block(C).as_str())); + assert_eq!( + after.as_deref().map(str::trim_start), + Some(block(C).as_str()) + ); } } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4e20d5d52..90b7f257e 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -160,6 +160,9 @@ fn check_workspace_compatibility( /// project vendored before it grew a workspace member all reach the /// engine instead of dying here. pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> { + if let Some(refusal) = super::npm_flavor::member_stray_lock_refusal(project_root).await { + return Err(refusal); + } let path = project_root.join(BUN_LOCK); let text = match read_regular_to_string(&path).await { Ok(text) => text, 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 84c2f00ff..1bd0c8584 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -999,7 +999,10 @@ async fn headerless_yarn_classic_lock_is_inventoried_as_classic_by_the_fallback( .filter(|l| !l.starts_with('#')) .collect::>() .join("\n"); - assert!(!headerless.contains("lockfile v1"), "fixture drops the header"); + assert!( + !headerless.contains("lockfile v1"), + "fixture drops the header" + ); write(tmp.path(), "yarn.lock", &headerless).await; let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); assert_eq!(flavor, NpmLockFlavor::YarnClassic); diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 9129348d3..9de3d57bb 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -129,8 +129,8 @@ pub use verify::{ }; // The hosted→vendored takeover refuses a berry project the backend would // refuse BEFORE it reverts the hosted redirect. -pub use npm_lock::npm_lock_vendor_preflight; pub use npm_common::npm_tarball_gitignore_preflight; +pub use npm_lock::npm_lock_vendor_preflight; pub use yarn_berry_lock::{yarn_berry_vendor_preflight, yarn_berry_vendor_target_preflight}; use std::collections::{HashMap, HashSet}; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 480a91782..1b99e32bd 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -397,22 +397,25 @@ async fn detect_vendorable_npm_flavor_with( )) } -/// #1094: a package-lock project that is a member of an npm workspace -/// holds a lock npm never reads (members install from the workspace +/// A workspace member whose own lock its package manager never reads +/// (npm #1094, Bun #1101, vlt #1134: members install from the workspace /// root's lock), so vendoring into it would wire nothing. Refused as a /// member without that lock is (`vendor_lockfile_missing`). Shared by -/// [`vendor_npm_any`] and the hosted→vendored takeover preflight -/// ([`super::npm_lock::npm_lock_vendor_preflight`]), which must refuse -/// before the takeover restores the hosted pin. -pub(crate) async fn npm_member_stray_lock_refusal( +/// [`vendor_npm_any`] and the hosted→vendored takeover preflights +/// ([`super::npm_lock::npm_lock_vendor_preflight`], +/// [`super::bun_lock::preflight_vendor`], +/// [`super::vlt_lock::vlt_vendor_preflight`]), which must refuse before +/// the takeover restores the hosted pin. +pub(crate) async fn member_stray_lock_refusal( project_root: &Path, ) -> Option<(&'static str, String)> { - let (root, detail) = crate::hosted::governing_root::npm_member_stray_lock(project_root).await?; + let stray = crate::hosted::governing_root::member_stray_lock(project_root).await?; Some(( "vendor_lockfile_missing", format!( - "{detail}; vendor from {} (the workspace root)", - root.display() + "{}; vendor from {} (the workspace root)", + stray.detail, + stray.root.display() ), )) } @@ -439,10 +442,8 @@ pub async fn vendor_npm_any<'a>( Ok(found) => found, Err((code, detail)) => return VendorOutcome::Refused { code, detail }, }; - if flavor == NpmLockFlavor::PackageLock { - if let Some((code, detail)) = npm_member_stray_lock_refusal(project_root).await { - return VendorOutcome::Refused { code, detail }; - } + if let Some((code, detail)) = member_stray_lock_refusal(project_root).await { + return VendorOutcome::Refused { code, detail }; } if let Some(detail) = flavor_change_refusal(project_root, purl, flavor).await { return VendorOutcome::Refused { diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index cf3ff48a8..31499dcb9 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -418,7 +418,7 @@ pub async fn vendor_npm<'a>( /// The project-level refusal [`vendor_npm`]'s step 2 raises whatever the /// purl: the project is an npm workspace member whose own lock npm never -/// reads (#1094, [`super::npm_flavor::npm_member_stray_lock_refusal`]), or +/// reads (#1094, [`super::npm_flavor::member_stray_lock_refusal`]), or /// the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) is /// not parseable JSON or not a v2/v3 lock. `None` unless the project's /// npm flavor is package-lock (the probe `vendor_npm_any` routes on) and @@ -439,7 +439,7 @@ pub async fn npm_lock_vendor_preflight(project_root: &Path) -> Option<(&'static ) { return None; } - if let Some(refusal) = super::npm_flavor::npm_member_stray_lock_refusal(project_root).await { + if let Some(refusal) = super::npm_flavor::member_stray_lock_refusal(project_root).await { return Some(refusal); } let (lock_name, lock_bytes, _) = select_lockfile(project_root).await.ok()??; diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index 3939052bd..77aa7b2c9 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -714,6 +714,9 @@ pub async fn vlt_vendor_preflight( purl: &str, uuid: &str, ) -> Result<(), Refusal> { + if let Some(refusal) = super::npm_flavor::member_stray_lock_refusal(project_root).await { + return Err(refusal); + } let Some((name, version)) = super::npm_common::parse_npm_purl(purl) else { return Err(( "unsafe_coordinates", diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 5e36eef4a..db5adb221 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -46,9 +46,7 @@ use sha2::{Digest, Sha256, Sha512}; use crate::constants::SOCKET_DIR; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY}; -use crate::formats::yarn::blocks::{ - berry_field, block_eol, replace_block, scan_blocks, LockBlock, -}; +use crate::formats::yarn::blocks::{berry_field, block_eol, replace_block, scan_blocks, LockBlock}; use crate::formats::yarn::patterns::{pattern_real_name, split_berry_key_patterns, split_pattern}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 5e650c3d4..766186131 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -1512,11 +1512,7 @@ left-pad@^1.3.0: .filter(|&c| c != "vendor_prebuilt_downloaded") .collect(); assert_eq!(codes, ["vendor_yarn_classic_non_registry_legacy_wiring"]); - let detail = &warnings - .iter() - .find(|w| w.code == codes[0]) - .unwrap() - .detail; + let detail = &warnings.iter().find(|w| w.code == codes[0]).unwrap().detail; assert!( detail.contains("host.test/fork") && detail.contains("vendor --revert") diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 471e8344a..bcb8fd7e6 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -997,8 +997,9 @@ pub async fn discover_patched_refs_in( discover_with_ctx(ctx).await } -async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { +async fn discover_with_ctx(mut ctx: DiscoverCtx<'_>) -> Discovery { let mut out = Discovery::default(); + ignore_member_stray_locks(&mut ctx, &mut out).await; npm::extract(&ctx, &mut out).await; yarn::extract(&ctx, &mut out).await; bun::extract(&ctx, &mut out).await; @@ -1022,6 +1023,49 @@ async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { out } +/// A workspace member's own npm, Bun or vlt lock that its package manager +/// never reads (npm #1094, Bun #1101, vlt #1134: the member installs from +/// the workspace root's lock, see +/// [`crate::hosted::governing_root::member_stray_lock`]) is read as absent, +/// as it is by the install: nothing in it is wiring, so its pins attest +/// nothing. Its Socket identities are still recognized (rule 11), so a +/// ledger claim it alone mentions is dead, and one diagnostic says why. +/// Disk runs only: an in-memory project has no ancestors. +async fn ignore_member_stray_locks(ctx: &mut DiscoverCtx<'_>, out: &mut Discovery) { + if matches!( + ctx.view, + crate::vendor::lock_inventory::ProjectView::Memory(_) + ) { + return; + } + let Some(stray) = crate::hosted::governing_root::member_stray_lock(ctx.root).await else { + return; + }; + for rel in &stray.ignored { + let mentions = match ctx.view.read_bytes(rel).await { + Ok(bytes) => { + !socket_identities(&String::from_utf8_lossy(&bytes), ctx.patch_server_origins) + .is_empty() + } + Err(_) => false, + }; + ctx.recognize_ignored(rel).await; + if mentions { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + rel, + format!( + "{}, so the Socket references in {rel} are not attested; run socket-patch \ + from {} (the workspace root)", + stray.detail, + stray.root.display() + ), + ); + } + } + ctx.ignored = stray.ignored; +} + /// A PEP 723 script lock (`