diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e7e7da..d8685b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,15 @@ limits, and required install commands. ### Fixed +- Hosted `scan` / `get` run from a workspace member no longer reports + success while pinning nothing or the wrong files. From a pnpm workspace + member, or a pnpm project whose `lockfile-dir` puts `pnpm-lock.yaml` in + another directory, the run now refuses with + `redirect_pnpm_lockfile_elsewhere` (#590). From a cargo workspace member, + it refuses with `cargo_manifest_not_workspace_root`, as vendored mode + does, instead of rewriting the member's manifest and breaking the + workspace build (#417). Both exit 1 and write nothing; run from the + directory holding the lock. - Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on Windows, where they install as `.cmd` / `.bat` shims, instead of reporting an empty scan. The yarn and npm-family global lookups no longer run from the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df..a294cae2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1194,6 +1194,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. | | `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` / `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) 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). | | `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. | | `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. | diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 97e6866c..be19c002 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -731,6 +731,14 @@ pub(crate) async fn run_redirect_selected( &engine::bun_lockb_symlink_refusal(), ); } + // A workspace member whose lock lives in an ancestor directory (pnpm + // workspace / `lockfile-dir`, cargo workspace): the rewriters would + // read only the member, so refuse before any takeover or write. + if let Some(refusal) = + socket_patch_core::hosted::governing_root::refusal(&view, &candidates).await + { + return refuse(common, scan_result.take(), &refusal); + } // vlt artifact preflight: before any takeover or rewrite (dry runs // included), each in-scope artifact is fetched as vlt fetches it. A diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7ae65080..3f4c04bf 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -4327,3 +4327,111 @@ async fn in_process_hosted_scan_state_attests_manifest_less() { .expect("manifest-less VEX tail panicked"); }); } + +/// #417: hosted `scan` from a cargo workspace MEMBER treated it as a +/// lockless project, wrote `registry = …` into the member's Cargo.toml and +/// a `[registries]` block into the member's `.cargo/config.toml`, left the +/// root Cargo.lock alone, and exited 0, breaking every build of the +/// workspace. It now refuses with vendored mode's +/// `cargo_manifest_not_workspace_root` and writes nothing. +#[tokio::test] +#[serial] +async fn cargo_hosted_scan_from_workspace_member_refuses() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.4"; + const CARGO_UUID: &str = "33333333-3333-4333-8333-333333333333"; + let cksum = "cd".repeat(32); + let index_url = format!("sparse+http://patch.test/registry/cargo/{CARGO_UUID}/index/"); + let server = MockServer::start().await; + mock_cargo_patch( + &server, + CARGO_PURL, + CARGO_UUID, + "cfg-if", + "1.0.4", + &index_url, + &cksum, + "GHSA-carg-wsmb-wsmb", + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"inherits\", \"direct\"]\n\n\ + [workspace.dependencies]\ncfg-if = \"1.0.4\"\n", + ) + .unwrap(); + std::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"ee\"\n\n[[package]]\nname = \"direct\"\nversion = \"0.1.0\"\n\ + dependencies = [\n \"cfg-if\",\n]\n\n[[package]]\nname = \"inherits\"\n\ + version = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n", + ) + .unwrap(); + for (member, dep) in [ + ("inherits", "cfg-if = { workspace = true }"), + ("direct", "cfg-if = \"1.0.4\""), + ] { + std::fs::create_dir_all(root.join(member).join("src")).unwrap(); + std::fs::write( + root.join(member).join("Cargo.toml"), + format!( + "[package]\nname = \"{member}\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n\ + [dependencies]\n{dep}\n" + ), + ) + .unwrap(); + std::fs::write(root.join(member).join("src/lib.rs"), "").unwrap(); + } + let member = root.join("direct"); + write_vendored_crate(&member, "cfg-if", "1.0.4"); + let manifest_before = std::fs::read(member.join("Cargo.toml")).unwrap(); + let lock_before = std::fs::read(root.join("Cargo.lock")).unwrap(); + + let out = scrubbed_cli() + .args([ + "scan", + "--mode=hosted", + "--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!( + "scan --json output is not JSON ({e}):\n{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + assert_eq!(out.status.code(), Some(1), "{doc}"); + assert_eq!(doc["status"], "error", "{doc}"); + assert_eq!( + doc["errorCode"], "cargo_manifest_not_workspace_root", + "{doc}" + ); + assert!( + doc["error"] + .as_str() + .is_some_and(|m| m.contains("workspace root") && m.contains("nothing was written")), + "{doc}" + ); + assert_eq!( + std::fs::read(member.join("Cargo.toml")).unwrap(), + manifest_before + ); + assert_eq!(std::fs::read(root.join("Cargo.lock")).unwrap(), lock_before); + assert!(!member.join(".cargo").exists(), "no member registry block"); + assert!(!member.join(".socket").exists()); +} 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 3c7338d2..4519d1d4 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1097,3 +1097,235 @@ async fn hosted_partial_pnpm_redirect_is_not_confirmed_by_url_presence() { .join(".socket/vendor/redirect-state.json") .exists()); } + +/// A pnpm workspace: the root holds `pnpm-workspace.yaml` and the only +/// `pnpm-lock.yaml` (importer `packages/a`); the member `packages/a` holds +/// its manifest and the installed copy, as pnpm lays it out. +fn write_pnpm_workspace(root: &Path) -> std::path::PathBuf { + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "private": true }"#, + ) + .unwrap(); + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages:\n - packages/*\n", + ) + .unwrap(); + std::fs::write( + root.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0' + +importers: + .: {{}} + packages/a: + dependencies: + {NAME}: + specifier: {VERSION} + version: {VERSION} + +packages: + {NAME}@{VERSION}: + resolution: {{integrity: {UPSTREAM_SHA512}}} + +snapshots: + {NAME}@{VERSION}: {{}} +" + ), + ) + .unwrap(); + let member = root.join("packages/a"); + let pkg = member.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + member.join("package.json"), + format!( + r#"{{ "name": "a", "version": "1.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + member +} + +/// Assert the run refused with `redirect_pnpm_lockfile_elsewhere`, naming +/// the governing lock, and wrote nothing anywhere. +fn assert_refused_lock_elsewhere( + code: Option, + doc: &serde_json::Value, + lock: &Path, + lock_before: &str, + cwd: &Path, +) { + assert_eq!( + code, + Some(1), + "a found-but-unpinnable patch is not success: {doc}" + ); + assert_eq!(doc["status"], "error", "{doc}"); + assert_eq!( + doc["errorCode"], "redirect_pnpm_lockfile_elsewhere", + "{doc}" + ); + let message = doc["error"].as_str().unwrap_or_default(); + assert!( + message.contains("pnpm-lock.yaml") && message.contains("nothing was written"), + "the error names the governing lock: {message}" + ); + assert_eq!(std::fs::read_to_string(lock).unwrap(), lock_before); + assert!( + !cwd.join(".socket").exists(), + "nothing written in the member" + ); + assert!(!cwd.join("pnpm-workspace.yaml").exists()); +} + +/// #590: `scan --mode hosted` from a pnpm workspace member saw no lock in +/// the member, pinned nothing, and exited 0 with `success` and an npm +/// "no package-lock.json" warning while pnpm installs the unpatched copy +/// from the root lock. It now refuses and names the root. +#[tokio::test] +#[serial] +async fn hosted_scan_from_pnpm_workspace_member_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let member = write_pnpm_workspace(tmp.path()); + let lock = tmp.path().join("pnpm-lock.yaml"); + let before = std::fs::read_to_string(&lock).unwrap(); + + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_refused_lock_elsewhere(code, &doc, &lock, &before, &member); + + // `get --mode hosted` takes the same path. + let out = scrubbed_cli() + .args([ + "get", + UUID, + "--mode", + "hosted", + "--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!( + "get --json output is not JSON ({e}):\n{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + assert_refused_lock_elsewhere(out.status.code(), &doc, &lock, &before, &member); + + // From the workspace root the same patch is pinned. + let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert!(std::fs::read_to_string(&lock).unwrap().contains(HOSTED_URL)); +} + +/// #590, `lockfile-dir=..` variant: pnpm writes the project's lock to the +/// parent directory, so the project directory has none. +#[tokio::test] +#[serial] +async fn hosted_scan_with_pnpm_lockfile_dir_elsewhere_refuses() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_pnpm_project(&proj); + std::fs::rename( + proj.join("pnpm-lock.yaml"), + tmp.path().join("pnpm-lock.yaml"), + ) + .unwrap(); + std::fs::write(proj.join(".npmrc"), "lockfile-dir=..\n").unwrap(); + let lock = tmp.path().join("pnpm-lock.yaml"); + let before = std::fs::read_to_string(&lock).unwrap(); + + let (code, doc) = run_hosted_json(&proj, &server.uri()); + assert_refused_lock_elsewhere(code, &doc, &lock, &before, &proj); +} + +/// pnpm inherits workspace-root config even when +/// invoked from a member. Absolute npmrc paths isolate inheritance; relative +/// YAML paths are resolved from the member cwd by pnpm 10.34.5 itself. +async fn assert_workspace_configured_lock_refused(case: &str) { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let member = write_pnpm_workspace(&root); + let lock_dir = if case == "root-npmrc-absolute" { + let dir = tmp.path().join("locks"); + std::fs::write( + root.join(".npmrc"), + format!("lockfile-dir={}\n", dir.display()), + ) + .unwrap(); + dir + } else if case == "root-yaml-relative" { + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages:\n - packages/*\nlockfileDir: ../locks\n", + ) + .unwrap(); + root.join("packages/locks") + } else { + let dir = tmp.path().join("yaml-locks"); + std::fs::write( + root.join("pnpm-workspace.yaml"), + format!( + "packages:\n - packages/*\nlockfileDir: {}\n", + dir.display() + ), + ) + .unwrap(); + std::fs::write(member.join(".npmrc"), "lockfile-dir=../unused-locks\n").unwrap(); + dir + }; + std::fs::create_dir_all(&lock_dir).unwrap(); + let lock = lock_dir.join("pnpm-lock.yaml"); + std::fs::rename(root.join("pnpm-lock.yaml"), &lock).unwrap(); + let before = std::fs::read_to_string(&lock).unwrap(); + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_refused_lock_elsewhere(code, &doc, &lock, &before, &member); +} + +#[tokio::test] +#[serial] +async fn hosted_scan_inherits_workspace_npmrc_lockfile_dir() { + assert_workspace_configured_lock_refused("root-npmrc-absolute").await; +} + +#[tokio::test] +#[serial] +async fn hosted_scan_resolves_inherited_lockfile_dir_from_cwd() { + assert_workspace_configured_lock_refused("root-yaml-relative").await; +} + +#[tokio::test] +#[serial] +async fn hosted_scan_workspace_yaml_overrides_member_npmrc() { + assert_workspace_configured_lock_refused("root-yaml-precedence").await; +} diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs new file mode 100644 index 00000000..195a914a --- /dev/null +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -0,0 +1,448 @@ +//! The governing-root pre-check of the hosted flow: a run whose `--cwd` is +//! a workspace member reads the member's directory only, while the package +//! manager installs from a lock in an ancestor directory. Hosted mode then +//! either pins nothing and reports success (pnpm, #590) or rewrites the +//! member as a lockless project and breaks the workspace (cargo, #417). +//! +//! [`refusal`] spots both layouts before any takeover or write, so the run +//! fails closed and names the directory to run from. Vendored mode refuses +//! the same layouts (`vendor_lockfile_missing`, +//! `cargo_manifest_not_workspace_root`); the cargo check is the vendored +//! one, shared. +//! +//! Unlike the rest of [`super::engine`], this looks outside the project +//! directory (its ancestors), so it only runs over the disk; an in-memory +//! project is the host's whole file set and has no ancestors. + +use std::path::{Path, PathBuf}; + +use crate::constants::npm_family::{NPM_LOCKS, VLT_LOCK}; +use crate::patch::redirect::npmrc::npmrc_top_level_value; +use crate::utils::fs::read_regular_to_string; +use crate::vendor::cargo::NOT_WORKSPACE_ROOT; +use crate::vendor::cargo_manifest; +use crate::vendor::lock_inventory::ProjectView; + +use super::engine::{Candidate, Refusal}; + +/// Refusal code for a pnpm project whose `pnpm-lock.yaml` lives in another +/// directory: the nearest ancestor `pnpm-workspace.yaml` (a workspace +/// member) or a configured `lockfile-dir`. +pub const PNPM_LOCKFILE_ELSEWHERE: &str = "redirect_pnpm_lockfile_elsewhere"; + +const PNPM_LOCK: &str = "pnpm-lock.yaml"; +const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; + +/// npm-family locks that, present in the project directory, make it its +/// own lock root: the existing rewriters handle it. +const OWN_LOCKS: [&str; 5] = [ + PNPM_LOCK, + "shrinkwrap.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", +]; + +const HOSTED_CARGO_ROOT_HINT: &str = + "hosted mode pins the crate in the workspace's Cargo.lock and in every member \ + manifest, which only a run from the workspace root can reach; run socket-patch \ + from the workspace root (the directory holding its Cargo.toml and Cargo.lock); \ + nothing was written"; + +/// `Some` when the project directory is governed by a lock in another +/// directory that hosted mode would not read (see the module doc). +pub async fn refusal(view: &ProjectView<'_>, candidates: &[Candidate]) -> Option { + let root: &Path = match view { + ProjectView::Disk(root) => root, + ProjectView::Snapshot(snap) => snap.root, + ProjectView::Memory(_) => return None, + }; + if candidates.iter().any(|c| c.dep.ecosystem == "cargo") { + if let Some(refusal) = cargo_member_refusal(root).await { + return Some(refusal); + } + } + if candidates.iter().any(|c| c.dep.ecosystem == "npm") { + if let Some(lock) = pnpm_lock_elsewhere(root).await { + let dir = lock.parent().unwrap_or(&lock); + return Some(Refusal { + code: PNPM_LOCKFILE_ELSEWHERE.to_string(), + message: format!( + "{} has no lockfile of its own: pnpm installs it from {}, which a \ + hosted run here cannot see; run socket-patch from {} (the directory \ + holding pnpm-lock.yaml); nothing was written", + root.display(), + lock.display(), + dir.display() + ), + }); + } + } + None +} + +/// The vendored workspace-root check over `/Cargo.toml`; an absent or +/// unreadable manifest is left to the rewriter. +async fn cargo_member_refusal(root: &Path) -> Option { + let text = read_regular_to_string(&root.join(cargo_manifest::CARGO_TOML)) + .await + .ok()?; + let doc = cargo_manifest::parse_manifest(&text).ok()?; + let detail = + crate::vendor::cargo::workspace_root_refusal(root, &doc, HOSTED_CARGO_ROOT_HINT).await?; + Some(Refusal { + code: NOT_WORKSPACE_ROOT.to_string(), + message: detail, + }) +} + +/// The `pnpm-lock.yaml` pnpm reads for a project directory that holds no +/// npm-family lock of its own, when it lives elsewhere and exists: +/// +/// The nearest `pnpm-workspace.yaml` supplies `lockfileDir`, ahead of the +/// project's `.npmrc` and then the workspace root's `.npmrc`. A configured +/// relative directory is resolved from the invocation cwd, as pnpm does; +/// without an override, the workspace's lock lives at its root. +async fn pnpm_lock_elsewhere(root: &Path) -> Option { + let has_own_lock = OWN_LOCKS + .iter() + .chain(NPM_LOCKS.iter()) + .chain(std::iter::once(&VLT_LOCK)) + .any(|name| root.join(name).exists()); + // Rush keeps its locks under common/config, read by the rewriter. + if has_own_lock || root.join("rush.json").exists() { + return None; + } + let canonical = tokio::fs::canonicalize(root) + .await + .unwrap_or_else(|_| root.to_path_buf()); + + let mut workspace = None; + for ancestor in canonical.ancestors() { + let path = ancestor.join(PNPM_WORKSPACE); + if let Ok(yaml) = read_regular_to_string(&path).await { + workspace = Some((ancestor.to_path_buf(), yaml)); + break; + } + if ancestor == canonical && path.exists() { + // An unreadable local workspace file still bounds the project. + break; + } + } + + // Native pnpm 10: workspace YAML beats both npmrc files; the member's + // npmrc beats the workspace root's. A member inherits root npmrc settings + // even when its own directory has no pnpm-workspace.yaml. + let mut configured = workspace + .as_ref() + .and_then(|(_, yaml)| workspace_lockfile_dir(yaml)); + if configured.is_none() { + configured = npmrc_lockfile_dir(&canonical).await; + } + if configured.is_none() { + if let Some((workspace_root, _)) = &workspace { + if workspace_root != &canonical { + configured = npmrc_lockfile_dir(workspace_root).await; + } + } + } + if let Some(dir) = configured { + // Even an inherited relative override is based on the invocation + // directory, not on the directory containing the setting. + return lock_elsewhere(&canonical, &canonical, &dir).await; + } + if let Some((workspace_root, _)) = workspace { + return lock_elsewhere(&canonical, &workspace_root, ".").await; + } + None +} + +async fn npmrc_lockfile_dir(root: &Path) -> Option { + let npmrc = read_regular_to_string(&root.join(".npmrc")).await.ok()?; + npmrc_top_level_value(&npmrc, "lockfile-dir") +} + +/// `//pnpm-lock.yaml` when it exists and `/` is not +/// the project directory itself. +async fn lock_elsewhere(project: &Path, base: &Path, dir: &str) -> Option { + let dir = dir.trim(); + if dir.is_empty() { + return None; + } + let dir = base.join(dir); + let lock = dir.join(PNPM_LOCK); + let same_dir = tokio::fs::canonicalize(&dir) + .await + .is_ok_and(|d| d == project); + (!same_dir && lock.is_file()).then_some(lock) +} + +/// The top-level `lockfileDir:` scalar of a `pnpm-workspace.yaml`, read +/// line-wise (a block key at column 0, bare or quoted, an optional quoted +/// value, an optional trailing comment). +fn workspace_lockfile_dir(yaml: &str) -> Option { + yaml.lines().find_map(|line| { + let rest = ["lockfileDir", "\"lockfileDir\"", "'lockfileDir'"] + .iter() + .find_map(|key| line.strip_prefix(key))? + .trim_start(); + let value = rest.strip_prefix(':')?.trim(); + let value = match value.chars().next() { + Some(q @ ('"' | '\'')) => value[1..].split(q).next().unwrap_or(""), + _ => value.split(" #").next().unwrap_or("").trim(), + }; + (!value.is_empty()).then(|| value.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(ecosystem: &str) -> Candidate { + Candidate { + purl: format!("pkg:{ecosystem}/x@1.0.0"), + dep: serde_json::from_value(serde_json::json!({ + "ecosystem": ecosystem, + "name": "x", + "version": "1.0.0", + "token": "t", + "patchUuid": "11111111-1111-4111-8111-111111111111", + "artifactUrl": "https://patch.socket.dev/x.tgz", + "integrity": {}, + })) + .unwrap(), + } + } + + fn write(root: &Path, rel: &str, text: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + async fn code(dir: &Path, ecosystem: &str) -> Option { + refusal(&ProjectView::Disk(dir), &[candidate(ecosystem)]) + .await + .map(|r| r.code) + } + + /// #590: a pnpm workspace member has no lock; the root's lock governs it. + #[tokio::test] + async fn pnpm_workspace_member_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "packages:\n - packages/*\n", + ); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n"); + write(tmp.path(), "packages/a/package.json", "{}"); + let member = tmp.path().join("packages/a"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(PNPM_LOCKFILE_ELSEWHERE) + ); + // The root itself is fine, and so is a non-npm run from the member. + assert_eq!(code(tmp.path(), "npm").await, None); + assert_eq!(code(&member, "pypi").await, None); + } + + /// A member with its own lock (`sharedWorkspaceLockfile: false`) is its + /// own lock root; a workspace whose root has no lock refuses nothing. + #[tokio::test] + async fn pnpm_member_with_own_lock_or_lockless_root_is_left_alone() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "pnpm-workspace.yaml", + "packages:\n - packages/*\n", + ); + write(tmp.path(), "packages/a/package.json", "{}"); + let member = tmp.path().join("packages/a"); + assert_eq!(code(&member, "npm").await, None); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n"); + write( + tmp.path(), + "packages/a/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + assert_eq!(code(&member, "npm").await, None); + } + + /// #590 `lockfile-dir=..` variant, from `.npmrc` or `pnpm-workspace.yaml`. + #[tokio::test] + async fn pnpm_lockfile_dir_elsewhere_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n"); + write(tmp.path(), "proj/package.json", "{}"); + write(tmp.path(), "proj/.npmrc", "lockfile-dir=..\n"); + let proj = tmp.path().join("proj"); + assert_eq!( + code(&proj, "npm").await.as_deref(), + Some(PNPM_LOCKFILE_ELSEWHERE) + ); + + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n"); + write(tmp.path(), "proj/package.json", "{}"); + write( + tmp.path(), + "proj/pnpm-workspace.yaml", + "lockfileDir: '..' # shared\n", + ); + let proj = tmp.path().join("proj"); + assert_eq!( + code(&proj, "npm").await.as_deref(), + Some(PNPM_LOCKFILE_ELSEWHERE) + ); + + // `lockfile-dir=.` names the project itself. + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "package.json", "{}"); + write(tmp.path(), ".npmrc", "lockfile-dir=.\n"); + assert_eq!(code(tmp.path(), "npm").await, None); + } + + /// An inherited relative `lockfileDir` is resolved from the member cwd, + /// verified with native pnpm, rather than from the workspace root. + #[tokio::test] + async fn pnpm_member_of_workspace_with_relocated_lock_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "ws/packages/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + write( + tmp.path(), + "ws/pnpm-workspace.yaml", + "packages:\n - packages/*\nlockfileDir: ..\n", + ); + write(tmp.path(), "ws/packages/a/package.json", "{}"); + let member = tmp.path().join("ws/packages/a"); + assert_eq!( + code(&member, "npm").await.as_deref(), + Some(PNPM_LOCKFILE_ELSEWHERE) + ); + } + + #[tokio::test] + async fn pnpm_config_precedence_matches_native_workspace_install() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("ws"); + let member = root.join("packages/a"); + write(&root, "packages/a/package.json", "{}"); + write(&root, PNPM_LOCK, "lockfileVersion: '9.0'\n"); + for dir in ["yaml-locks", "root-rc-locks", "member-rc-locks"] { + write( + tmp.path(), + &format!("{dir}/{PNPM_LOCK}"), + "lockfileVersion: '9.0'\n", + ); + } + let yaml_lock = tmp.path().join("yaml-locks"); + let root_rc_lock = tmp.path().join("root-rc-locks"); + let member_rc_lock = tmp.path().join("member-rc-locks"); + write( + &root, + PNPM_WORKSPACE, + &format!( + "packages:\n - packages/*\nlockfileDir: '{}'\n", + yaml_lock.display() + ), + ); + write( + &root, + ".npmrc", + &format!("lockfile-dir={}\n", root_rc_lock.display()), + ); + write( + &member, + ".npmrc", + &format!("lockfile-dir={}\n", member_rc_lock.display()), + ); + for expected in [&yaml_lock, &member_rc_lock, &root_rc_lock, &root] { + let found = pnpm_lock_elsewhere(&member).await.expect("governing lock"); + assert_eq!( + std::fs::canonicalize(found).unwrap(), + std::fs::canonicalize(expected.join(PNPM_LOCK)).unwrap() + ); + if expected == &yaml_lock { + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + } else if expected == &member_rc_lock { + std::fs::remove_file(member.join(".npmrc")).unwrap(); + } else if expected == &root_rc_lock { + std::fs::remove_file(root.join(".npmrc")).unwrap(); + } + } + } + + #[tokio::test] + async fn pnpm_member_own_workspace_bounds_ancestor_lookup() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), PNPM_WORKSPACE, "packages:\n - packages/*\n"); + write(tmp.path(), PNPM_LOCK, "lockfileVersion: '9.0'\n"); + write(tmp.path(), "packages/a/package.json", "{}"); + write( + tmp.path(), + "packages/a/pnpm-workspace.yaml", + "packages: []\n", + ); + assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None); + } + + /// #417: a cargo workspace member is refused with the vendored code; the + /// root and a standalone crate are not. + #[tokio::test] + async fn cargo_workspace_member_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Cargo.toml", + "[workspace]\nmembers = [\"direct\"]\n", + ); + write( + tmp.path(), + "direct/Cargo.toml", + "[package]\nname = \"direct\"\nversion = \"0.1.0\"\n", + ); + let member = tmp.path().join("direct"); + assert_eq!( + code(&member, "cargo").await.as_deref(), + Some(NOT_WORKSPACE_ROOT) + ); + assert_eq!(code(tmp.path(), "cargo").await, None); + assert_eq!(code(&member, "npm").await, None); + + let standalone = tempfile::tempdir().unwrap(); + write( + standalone.path(), + "Cargo.toml", + "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n", + ); + assert_eq!(code(standalone.path(), "cargo").await, None); + } + + #[test] + fn workspace_lockfile_dir_reads_the_top_level_key() { + assert_eq!( + workspace_lockfile_dir("lockfileDir: ..\n").as_deref(), + Some("..") + ); + assert_eq!( + workspace_lockfile_dir("packages: []\nlockfileDir: \"../x\"\n").as_deref(), + Some("../x") + ); + assert_eq!( + workspace_lockfile_dir("\"lockfileDir\": \"..\"\n").as_deref(), + Some("..") + ); + assert_eq!( + workspace_lockfile_dir("'lockfileDir': ../x\n").as_deref(), + Some("../x") + ); + assert_eq!(workspace_lockfile_dir(" lockfileDir: ..\n"), None); + assert_eq!(workspace_lockfile_dir("lockfileDirX: ..\n"), None); + } +} diff --git a/crates/socket-patch-core/src/hosted/mod.rs b/crates/socket-patch-core/src/hosted/mod.rs index 1a195bb9..d8c90b6d 100644 --- a/crates/socket-patch-core/src/hosted/mod.rs +++ b/crates/socket-patch-core/src/hosted/mod.rs @@ -5,6 +5,8 @@ //! [`ProjectView`](crate::vendor::lock_inventory::ProjectView), shared by //! the disk flow (`scan`/`get --mode hosted` in the CLI) and the //! in-memory engine. +//! - [`governing_root`] — the workspace-member pre-check (a lock in an +//! ancestor directory governs the project). //! - [`guidance`] — the pnpm `trustLockfile` / npm `allow-remote` //! auto-config planners and their warning texts. //! - [`vlt`] — the vlt artifact preflight. @@ -13,6 +15,7 @@ //! (`socket-patch-node`) and the CLI's hidden `hosted-bundle` harness. pub mod engine; +pub mod governing_root; pub mod guidance; pub mod memory; pub mod render; diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 0b3dbba5..86d4311e 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -566,7 +566,9 @@ async fn cargo_prelude( if let Err(e) = cargo_manifest::check_source_alias(&manifest_doc) { return Err(refused(e.code(), e.detail().to_string())); } - if let Some(detail) = workspace_root_refusal(project_root, &manifest_doc).await { + if let Some(detail) = + workspace_root_refusal(project_root, &manifest_doc, VENDORED_ROOT_HINT).await + { return Err(refused(NOT_WORKSPACE_ROOT, detail)); } let manifest_entries = cargo_manifest::crates_io_patch_entries(&manifest_doc); @@ -1505,15 +1507,22 @@ async fn unwind_manifest( /// workspace root (cargo ignores `[patch]` in member manifests). pub const NOT_WORKSPACE_ROOT: &str = "cargo_manifest_not_workspace_root"; +const VENDORED_ROOT_HINT: &str = "cargo ignores `[patch]` outside the workspace-root manifest; \ + run socket-patch from the workspace root (the directory \ + holding its Cargo.toml and Cargo.lock)"; + /// `Some(detail)` when `/Cargo.toml` is not the workspace /// root cargo reads `[patch]` from: it names another root /// (`package.workspace`), or — having no `[workspace]` table of its own — /// an ancestor directory's `[workspace]` claims it (cargo's own /// `find_root`: the nearest ancestor workspace that does not `exclude` it; -/// an unparseable ancestor manifest is skipped). -async fn workspace_root_refusal( +/// an unparseable ancestor manifest is skipped). `hint` closes the +/// detail: why the mode needs the root, and what to run instead. Hosted +/// mode shares the check (`hosted::governing_root::refusal`). +pub(crate) async fn workspace_root_refusal( project_root: &Path, doc: &toml_edit::DocumentMut, + hint: &str, ) -> Option { if doc .get("workspace") @@ -1521,8 +1530,6 @@ async fn workspace_root_refusal( { return None; } - let hint = "cargo ignores `[patch]` outside the workspace-root manifest; run socket-patch \ - from the workspace root (the directory holding its Cargo.toml and Cargo.lock)"; if let Some(ws) = doc .get("package") .and_then(toml_edit::Item::as_table_like) @@ -1838,7 +1845,7 @@ async fn move_wiring_into_manifest( .map_err(|e| e.to_string())?; let doc = cargo_manifest::parse_manifest(&text).map_err(|e| e.to_string())?; cargo_manifest::check_source_alias(&doc).map_err(|e| e.to_string())?; - if let Some(detail) = workspace_root_refusal(project_root, &doc).await { + if let Some(detail) = workspace_root_refusal(project_root, &doc, VENDORED_ROOT_HINT).await { return Err(detail); } let manifest_entries = cargo_manifest::crates_io_patch_entries(&doc);