diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6959bc5ee..915cbf9ae 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1322,7 +1322,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 `error.code` (`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; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); 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 its `package.json` `workspaces` 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_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`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; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); 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 own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) 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 `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. 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. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `error.code` (`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 182a80299..68e1aaa12 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -2396,3 +2396,99 @@ async fn hosted_scan_from_npm_member_with_stray_lock_refuses() { } } } + +/// #1101 (Bun), #1134 (vlt): a workspace member holding a stray +/// `bun.lock` / `bun.lockb` / `vlt-lock.json` of its own (one its manager +/// never reads; members install from the root lock) used to skip the +/// #884 / #942 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_bun_or_vlt_member_with_stray_lock_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let bun_text = format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{ \"\": {{ \"name\": \"a\", \ + \"dependencies\": {{ \"{NAME}\": \"{VERSION}\" }} }} }},\n \"packages\": {{\n \ + \"{NAME}\": [\"{NAME}@{VERSION}\", \"\", {{}}, \"{UPSTREAM_SHA512}\"],\n }}\n}}\n" + ); + let vlt_text = format!( + "{{\"lockfileVersion\":1,\"options\":{{}},\"nodes\":{{\"~npm~{NAME}@{VERSION}\":\ + [0,\"{NAME}\",\"{UPSTREAM_SHA512}\"]}},\"edges\":{{\"file~_d {NAME}\":\"prod {VERSION} \ + ~npm~{NAME}@{VERSION}\"}}}}" + ); + for (root_lock, vlt_json, member_lock, member_text) in [ + ("bun.lock", None, "bun.lock", bun_text.as_str()), + ("bun.lock", None, "bun.lockb", "binary"), + ("bun.lockb", None, "bun.lock", bun_text.as_str()), + ("vlt-lock.json", None, "vlt-lock.json", vlt_text.as_str()), + ( + "vlt-lock.json", + Some(r#"{"workspaces":"packages/*"}"#), + "vlt-lock.json", + vlt_text.as_str(), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let member = write_package_json_workspace(tmp.path(), root_lock, false); + if let Some(text) = vlt_json { + std::fs::write(tmp.path().join("vlt.json"), text).unwrap(); + } + let stray = member.join(member_lock); + std::fs::write(&stray, member_text).unwrap(); + let lock = tmp.path().join(root_lock); + let before = std::fs::read_to_string(&lock).unwrap(); + let case = format!("root {root_lock} (vlt.json {vlt_json:?}), 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"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains(member_lock) && message.contains("ignores"), + "{case}: {message}" + ); + assert_eq!( + std::fs::read_to_string(&stray).unwrap(), + member_text, + "{case}: the stray member lock is untouched" + ); + } + } +} diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 40b8bb9e0..93f16b02f 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -87,7 +87,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, @@ -291,66 +291,168 @@ 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 files [`member_stray_lock`] reads; everything +/// else it reads is above the project. VEX discovery declares these to a +/// read recording so the check does not make it unusable. +pub(crate) fn member_stray_lock_own_files() -> impl Iterator { + npm_lock_files() + .chain(EXTRA_OWN_LOCKS) + .chain(["rush.json", VLT_JSON]) +} + +/// 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, + }) +} + +/// [`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 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?; +/// 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 { @@ -418,6 +520,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()); @@ -438,25 +563,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 } @@ -1303,11 +1413,146 @@ 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 + // The root is named canonical (`\\?\` on Windows, + // `/private` on macOS), as the walk resolves it. + .contains( + &std::fs::canonicalize(tmp.path()) + .unwrap() + .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/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 7a13741b0..2f1f33505 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -158,6 +158,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/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 68d5d6053..d53f82c1a 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -396,22 +396,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() ), )) } @@ -438,10 +441,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 { @@ -1849,6 +1850,88 @@ mod tests { ); } + /// #1101 (Bun), #1134 (vlt): a workspace member's own `bun.lock`, + /// `bun.lockb` or `vlt-lock.json` is a lock its manager never reads + /// (members install from the workspace root's lock), so vendoring into + /// it would wire nothing. The engine and the hosted→vendored takeover + /// preflights refuse the member as they do without the stray lock, and + /// nothing is written. + #[tokio::test] + async fn bun_and_vlt_members_with_stray_lock_are_refused() { + const BUN_TEXT: &str = "{\n \"lockfileVersion\": 1\n}\n"; + const VLT_TEXT: &str = r#"{"lockfileVersion":1,"options":{},"nodes":{},"edges":{}}"#; + let pj_workspace = r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#; + for (member_lock, member_text, root_files) in [ + ( + "bun.lock", + BUN_TEXT, + vec![("package.json", pj_workspace), ("bun.lock", BUN_TEXT)], + ), + ( + "bun.lockb", + "binary", + vec![("package.json", pj_workspace), ("bun.lock", BUN_TEXT)], + ), + ( + "vlt-lock.json", + VLT_TEXT, + vec![ + ("vlt.json", r#"{"workspaces":"packages/*"}"#), + ("vlt-lock.json", VLT_TEXT), + ], + ), + ] { + let (tmp, record) = npm_project().await; + tokio::fs::remove_file(tmp.path().join("package-lock.json")) + .await + .unwrap(); + touch(tmp.path(), member_lock, member_text).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(); + for (rel, text) in &root_files { + touch(ws.path(), rel, text).await; + } + let lock_before = tokio::fs::read(member.join(member_lock)).await.unwrap(); + + let preflight = if member_lock == "vlt-lock.json" { + crate::vendor::vlt_lock::vlt_vendor_preflight( + &member, + "pkg:npm/left-pad@1.3.0", + UUID, + ) + .await + .expect_err("the vlt takeover preflight refuses the member") + } else { + crate::vendor::bun_lock::preflight_vendor(&member) + .await + .expect_err("the Bun takeover preflight refuses the member") + }; + + let outcome = vendor_any(&member, &record).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("{member_lock}: expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "vendor_lockfile_missing", "{member_lock}"); + assert!( + detail.contains("workspace") + && detail.contains("ignores") + && detail.contains(member_lock), + "{member_lock}: {detail}" + ); + assert_eq!(preflight, (code, detail), "{member_lock}"); + assert!(!member.join(".socket/vendor").exists(), "{member_lock}"); + assert_eq!( + tokio::fs::read(member.join(member_lock)).await.unwrap(), + lock_before, + "{member_lock}" + ); + } + } + /// 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. diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index e3651b438..d4e40749f 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -398,7 +398,7 @@ impl NpmLockBackend for PackageLockBackend { /// 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 @@ -419,7 +419,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 5a6752302..f93aacd42 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -711,6 +711,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/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 65be03eeb..352a07cfc 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1145,6 +1145,7 @@ async fn discover_with_ctx(mut ctx: DiscoverCtx<'_>) -> Discovery { // Each extractor's reads are tagged with the vendor ecosystem it reads // for ([`Discovery::read`]). ctx.ecosystem = "npm"; + 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; @@ -1177,6 +1178,50 @@ async fn discover_with_ctx(mut 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) { + // Declares the project files the check reads; the rest are above the + // project, which no overlay of the project's files changes. + let Some(root) = + ctx.disk_root_reading(crate::hosted::governing_root::member_stray_lock_own_files()) + else { + return; + }; + let Some(stray) = crate::hosted::governing_root::member_stray_lock(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 (`