diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..ad36faa4a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1220,12 +1220,14 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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). | +| `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. | | `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. | | `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | | `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | | `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | +| `vendor_pnpm_settings_elsewhere` | `failed` | vendor / scan / get `--mode vendored` (pnpm, v9 lock): the project directory is a pnpm workspace member with its own `pnpm-lock.yaml` and no `pnpm-workspace.yaml` of its own; pnpm reads `overrides:` only from the nearest ancestor `pnpm-workspace.yaml`, so an override wired into the member (its `package.json` or a nested workspace file) would be ignored, failing frozen installs on pnpm >= 11 and silently reinstalling the unpatched package on a plain install. Refused before any write (the pre-download preflight and `--dry-run` included); the detail names the governing file; remedy: `--mode hosted`. | | `vendor_dir_symlink_unsupported` | `failed` | vendor / scan / get `--mode vendored` (every ecosystem): `.socket/vendor`, `.socket/vendor/` or the patch's `` dir is a symlink or junction. socket-patch creates those directories itself and never writes links, so a linked one is not ours; its target may be another project's vendor store. Refused before any write. The vendored revert (`vendor --revert`, `rollback`, `remove`, the vendored → hosted takeover) fails on the same check with the same detail before it edits a lock or deletes anything, so it never deletes another project's artifacts through the link. The detail names the linked path. Remedy: replace the link with a real directory and re-run. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | | `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. | diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e6e48a140..8eddfdec6 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -749,9 +749,15 @@ pub(crate) async fn run_redirect_selected( } // 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 + // read only the member, so refuse before any takeover or write. The + // same goes for a pnpm member whose `trustLockfile` setting lives in + // the workspace root's pnpm-workspace.yaml. + if let Some(refusal) = socket_patch_core::hosted::governing_root::refusal( + &view, + &candidates, + !common.no_trust_lockfile_config, + ) + .await { return refuse(common, scan_result.take(), &refusal); } 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 4519d1d4d..53c844367 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1329,3 +1329,131 @@ async fn hosted_scan_resolves_inherited_lockfile_dir_from_cwd() { async fn hosted_scan_workspace_yaml_overrides_member_npmrc() { assert_workspace_configured_lock_refused("root-yaml-precedence").await; } + +/// The workspace root as refusals and warnings name it: canonical (Windows +/// expands 8.3 names) without the verbatim `\\?\` prefix. +fn member_root(tmp: &tempfile::TempDir) -> std::path::PathBuf { + socket_patch_core::utils::pnpm_workspace::without_verbatim_prefix( + std::fs::canonicalize(tmp.path()).unwrap(), + ) +} + +/// Every redirect warning's text, unescaped: a JSON dump doubles each +/// Windows `\`, so a path would never match. +fn warning_texts(doc: &serde_json::Value) -> String { + let mut out = String::new(); + for warning in doc["redirect"]["warnings"].as_array().into_iter().flatten() { + for value in warning.as_object().into_iter().flat_map(|o| o.values()) { + if let Some(text) = value.as_str() { + out.push_str(text); + out.push('\n'); + } + } + if let Some(text) = warning.as_str() { + out.push_str(text); + out.push('\n'); + } + } + out +} + +/// #880: a workspace member with its own lock (`sharedWorkspaceLockfile: +/// false`) is pinned through its own lock, but pnpm reads `trustLockfile` +/// only from the workspace root's `pnpm-workspace.yaml`. Hosted mode used +/// to create a nested `packages: ['.']` + `trustLockfile: true` file in the +/// member, which pnpm ignores, so every root install failed on pnpm 11/12 +/// while the scan reported success. It now refuses before any write and +/// names the root file; once that file trusts the lock, the member pins +/// with no nested file. +#[tokio::test] +#[serial] +async fn hosted_scan_from_pnpm_member_with_own_lock_never_nests_trust_config() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = member_root(&tmp); + std::fs::write( + root.join("package.json"), + r#"{ "name": "root", "private": true }"#, + ) + .unwrap(); + let root_ws = root.join("pnpm-workspace.yaml"); + let ws_before = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + std::fs::write(&root_ws, ws_before).unwrap(); + let member = root.join("packages/a"); + std::fs::create_dir_all(&member).unwrap(); + write_pnpm_project(&member); + let lock = member.join("pnpm-lock.yaml"); + let lock_before = std::fs::read_to_string(&lock).unwrap(); + + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_eq!(code, Some(1), "{doc}"); + assert_eq!(doc["status"], "error", "{doc}"); + assert_eq!( + doc["errorCode"], "redirect_pnpm_settings_elsewhere", + "{doc}" + ); + let message = doc["error"].as_str().unwrap_or_default(); + assert!( + message.contains(&root_ws.display().to_string()) + && message.contains("trustLockfile: true") + && message.contains("nothing was written"), + "the error names the root file and the key to add: {message}" + ); + assert_eq!(std::fs::read_to_string(&lock).unwrap(), lock_before); + assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws_before); + assert!(!member.join("pnpm-workspace.yaml").exists()); + + // With the key in the root file (what pnpm reads), the member pins and + // no nested settings file is created. + let ws_trusted = format!("{ws_before}trustLockfile: true\n"); + std::fs::write(&root_ws, &ws_trusted).unwrap(); + let (code, doc) = run_hosted_json(&member, &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)); + assert!( + !member.join("pnpm-workspace.yaml").exists(), + "pnpm ignores a member's settings file; none is created" + ); + assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws_trusted); + let warnings = warning_texts(&doc); + assert!( + warnings.contains(&root_ws.display().to_string()) && warnings.contains("already carries"), + "the trust warning names the root file: {warnings}" + ); +} + +/// #880: an explicit `trustLockfile: false` in the root file is the user's +/// call, respected as in a single project: the member pins, nothing is +/// nested, and the warning names the root file. +#[tokio::test] +#[serial] +async fn hosted_scan_from_pnpm_member_respects_root_trust_opt_out() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = member_root(&tmp); + let root_ws = root.join("pnpm-workspace.yaml"); + let ws = "packages:\n - 'packages/*'\ntrustLockfile: false\n"; + std::fs::write(&root_ws, ws).unwrap(); + let member = root.join("packages/a"); + std::fs::create_dir_all(&member).unwrap(); + write_pnpm_project(&member); + + let (code, doc) = run_hosted_json(&member, &server.uri()); + assert_eq!(code, Some(0), "{doc}"); + assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert!(!member.join("pnpm-workspace.yaml").exists()); + assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws); + let warnings = warning_texts(&doc); + assert!( + warnings.contains(&root_ws.display().to_string()) + && warnings.contains("trustLockfile: false"), + "{warnings}" + ); +} diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f84f61b1a..15b3a86a4 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -37,6 +37,7 @@ use crate::patch::redirect::{ artifact_url_spellings, rewrite_registry_redirect_withholding_vlt, DepOverride, FileEdit, RewriteResult, RewriteWarning, }; +use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::purl::purl_parts; use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; @@ -1314,6 +1315,26 @@ fn pnpm_trust( pnpm_trust_legacy_detail(&server) } else if !root_lock_v9 || !options.trust_lockfile_config { pnpm_trust_manual_guidance(&server) + } else if let Some(root_file) = governing_workspace(view) { + // A workspace member with its own lock: pnpm reads `trustLockfile` + // only from the workspace root's file, and a nested one would be + // ignored (#880). The governing-root pre-check refused every root + // file but these two, so nothing is written here. + let root_file = root_file.display().to_string(); + match read_workspace_for_trust(std::path::Path::new(&root_file)) + .ok() + .flatten() + .map(|text| plan_workspace_trust(Some(&text))) + { + Some(TrustPlan::AlreadyTrue) => { + pnpm_rerun_only = spliced_pnpm_locks == 0; + pnpm_trust_already_true_detail(&server, &root_file) + } + Some(TrustPlan::UserSet(value)) => { + pnpm_trust_user_set_detail(&server, &root_file, &value) + } + _ => pnpm_trust_manual_guidance(&server), + } } else { let (workspace, symlinked) = read_workspace(view); workspace_symlinked = symlinked; @@ -1341,21 +1362,11 @@ fn pnpm_trust( } TrustPlan::AlreadyTrue => { pnpm_rerun_only = spliced_pnpm_locks == 0; - format!( - "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ - true` — keep it committed alongside the lock; installs need \ - no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ) + pnpm_trust_already_true_detail(&server, PNPM_WORKSPACE_REL) + } + TrustPlan::UserSet(value) => { + pnpm_trust_user_set_detail(&server, PNPM_WORKSPACE_REL, &value) } - TrustPlan::UserSet(value) => format!( - "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ - {value}`, which was respected and left untouched — install \ - with `pnpm install --trust-lockfile`, or set `trustLockfile: \ - true` yourself so every install accepts the patched \ - artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ), TrustPlan::Unsupported(why) => { pnpm_trust_workspace_unsupported_detail(&server, &why) } @@ -1393,6 +1404,42 @@ fn pnpm_trust( ) } +/// The trust detail for a settings file (`file`) that already carries +/// `trustLockfile: true`. +fn pnpm_trust_already_true_detail(server: &str, file: &str) -> String { + format!( + "{}, and {file} already carries `trustLockfile: true` — keep it committed \ + alongside the lock; installs need no extra flags. \ + {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +/// The trust detail for a settings file (`file`) whose explicit +/// `trustLockfile: ` was respected. +fn pnpm_trust_user_set_detail(server: &str, file: &str, value: &str) -> String { + format!( + "{}. {file} explicitly sets `trustLockfile: {value}`, which was respected \ + and left untouched — install with `pnpm install --trust-lockfile`, or set \ + `trustLockfile: true` yourself so every install accepts the patched \ + artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +/// The ancestor `pnpm-workspace.yaml` governing a disk project's pnpm +/// settings (see [`governing_workspace_file`]); an in-memory project has +/// no ancestors. +fn governing_workspace(view: &ProjectView<'_>) -> Option { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => governing_workspace_file(cwd), + ProjectView::Memory(_) => None, + } +} + /// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every /// tarball whose `resolved` origin is not the configured registry — which /// is exactly what a hosted redirect writes. npm <= 11 installs it diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index a45954310..6c7be3600 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -5,7 +5,10 @@ //! 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 +//! fails closed and names the directory to run from. It also refuses a +//! pnpm member that does have its own lock when the `trustLockfile: true` +//! hosted pins need lives in the workspace root's `pnpm-workspace.yaml`, +//! the only one pnpm reads (#880). Vendored mode refuses //! the same layouts (`vendor_lockfile_missing`, //! `cargo_manifest_not_workspace_root`); the cargo check is the vendored //! one, shared. @@ -18,18 +21,27 @@ 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::utils::fs::{read_regular_to_string, read_regular_to_string_sync}; +use crate::utils::pnpm_workspace::governing_workspace_file; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; use super::engine::{Candidate, Refusal}; +use super::guidance::{ + plan_workspace_trust, pnpm_lock_version_major, read_workspace_for_trust, TrustPlan, +}; /// 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"; +/// Refusal code for a pnpm workspace member with its own lock whose +/// settings (`trustLockfile`) live in an ancestor `pnpm-workspace.yaml` +/// that does not trust the lock yet. +pub const PNPM_SETTINGS_ELSEWHERE: &str = "redirect_pnpm_settings_elsewhere"; + const PNPM_LOCK: &str = "pnpm-lock.yaml"; const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; @@ -50,8 +62,14 @@ const HOSTED_CARGO_ROOT_HINT: &str = 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 { +/// directory that hosted mode would not read, or (with the trust +/// auto-config on) by pnpm settings in another directory that hosted mode +/// would not write (see the module doc). +pub async fn refusal( + view: &ProjectView<'_>, + candidates: &[Candidate], + trust_lockfile_config: bool, +) -> Option { let root: &Path = match view { ProjectView::Disk(root) => root, ProjectView::Snapshot(snap) => snap.root, @@ -77,10 +95,52 @@ pub async fn refusal(view: &ProjectView<'_>, candidates: &[Candidate]) -> Option ), }); } + if trust_lockfile_config { + if let Some(refusal) = pnpm_settings_elsewhere(root) { + return Some(refusal); + } + } } None } +/// A pnpm v9 project lock (the one the trust auto-config serves) in a +/// workspace member whose settings come from an ancestor +/// `pnpm-workspace.yaml` that neither trusts the lock nor explicitly opts +/// out. The auto-config used to create a nested file pnpm ignores (#880); +/// socket-patch writes only inside the project, so the user adds the key +/// to the root file. An explicit `trustLockfile: ` is respected, +/// as in a single project. +fn pnpm_settings_elsewhere(root: &Path) -> Option { + let lock = read_regular_to_string_sync(&root.join(PNPM_LOCK)).ok()?; + if pnpm_lock_version_major(&lock).is_none_or(|major| major < 9) { + return None; + } + let file = governing_workspace_file(root)?; + if let Ok(Some(text)) = read_workspace_for_trust(&file) { + if matches!( + plan_workspace_trust(Some(&text)), + TrustPlan::AlreadyTrue | TrustPlan::UserSet(_) + ) { + return None; + } + } + Some(Refusal { + code: PNPM_SETTINGS_ELSEWHERE.to_string(), + message: format!( + "{} is a project of the pnpm workspace whose settings live in {}: pnpm \ + reads `trustLockfile` only from that file, so pnpm >= 11 rejects the hosted \ + pins in this project's pnpm-lock.yaml (ERR_PNPM_TARBALL_URL_MISMATCH) until \ + it trusts the lock, and a pnpm-workspace.yaml created here would be ignored; \ + add `trustLockfile: true` to {} (pnpm <= 10 ignores it) and re-run, or pass \ + --no-trust-lockfile-config to pin without it; nothing was written", + root.display(), + file.display(), + file.display() + ), + }) +} + /// 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 { @@ -225,7 +285,7 @@ mod tests { } async fn code(dir: &Path, ecosystem: &str) -> Option { - refusal(&ProjectView::Disk(dir), &[candidate(ecosystem)]) + refusal(&ProjectView::Disk(dir), &[candidate(ecosystem)], true) .await .map(|r| r.code) } @@ -259,7 +319,7 @@ mod tests { write( tmp.path(), "pnpm-workspace.yaml", - "packages:\n - packages/*\n", + "packages:\n - packages/*\ntrustLockfile: true\n", ); write(tmp.path(), "packages/a/package.json", "{}"); let member = tmp.path().join("packages/a"); @@ -273,6 +333,72 @@ mod tests { assert_eq!(code(&member, "npm").await, None); } + /// #880: a member with its own v9 lock is pinned through that lock, but + /// pnpm reads `trustLockfile` only from the root `pnpm-workspace.yaml`. + /// Until that file trusts the lock (or opts out), the hosted run refuses + /// rather than nest a settings file pnpm ignores. + #[tokio::test] + async fn pnpm_member_with_own_lock_needs_the_root_to_trust_it() { + let tmp = tempfile::tempdir().unwrap(); + let ws = "packages:\n - packages/*\nsharedWorkspaceLockfile: false\n"; + write(tmp.path(), "pnpm-workspace.yaml", ws); + write(tmp.path(), "packages/a/package.json", "{}"); + write( + tmp.path(), + "packages/a/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + let member = tmp.path().join("packages/a"); + let refused = refusal(&ProjectView::Disk(&member), &[candidate("npm")], true) + .await + .unwrap(); + assert_eq!(refused.code, PNPM_SETTINGS_ELSEWHERE); + assert!( + refused.message.contains("trustLockfile: true") + && refused.message.contains("pnpm-workspace.yaml") + && refused.message.contains("nothing was written"), + "{}", + refused.message + ); + // `--no-trust-lockfile-config` plans no trust write: nothing to refuse. + let opted_out = refusal(&ProjectView::Disk(&member), &[candidate("npm")], false).await; + assert!(opted_out.is_none()); + // A non-npm run, and a pre-v9 lock (no trust policy), are untouched. + assert_eq!(code(&member, "pypi").await, None); + write( + tmp.path(), + "packages/a/pnpm-lock.yaml", + "lockfileVersion: '6.0'\n", + ); + assert_eq!(code(&member, "npm").await, None); + write( + tmp.path(), + "packages/a/pnpm-lock.yaml", + "lockfileVersion: '9.0'\n", + ); + // The root trusting the lock, or explicitly opting out, settles it. + write( + tmp.path(), + "pnpm-workspace.yaml", + &format!("{ws}trustLockfile: true\n"), + ); + assert_eq!(code(&member, "npm").await, None); + write( + tmp.path(), + "pnpm-workspace.yaml", + &format!("{ws}trustLockfile: false\n"), + ); + assert_eq!(code(&member, "npm").await, None); + // A member with its own settings file is its own workspace there. + write(tmp.path(), "pnpm-workspace.yaml", ws); + write( + tmp.path(), + "packages/a/pnpm-workspace.yaml", + "packages:\n - .\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() { diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 8c17de3bd..efcb02f9c 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -14,6 +14,7 @@ pub mod notice; pub mod pdm_lock; pub(crate) mod pep440; pub mod pipenv; +pub mod pnpm_workspace; pub mod poetry_lock; pub mod process; pub mod purl; diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs new file mode 100644 index 000000000..37b836018 --- /dev/null +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -0,0 +1,132 @@ +//! Which `pnpm-workspace.yaml` pnpm reads a project's settings from. +//! +//! pnpm finds its workspace root by walking up to the nearest +//! `pnpm-workspace.yaml` and reads settings (`overrides:`, +//! `trustLockfile:`, ...) only from that file. A workspace member with its +//! own lock (`sharedWorkspaceLockfile: false`) is still a project of that +//! workspace: a `pnpm-workspace.yaml` written into the member is ignored on +//! every install from the root ("The settings in packages/a/ +//! pnpm-workspace.yaml do not apply"), so hosted and vendored modes must not +//! treat a missing member file as "create one here" (#880, #881). + +use std::path::{Path, PathBuf}; + +/// pnpm's workspace and settings file. +pub const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; + +/// The `pnpm-workspace.yaml` that governs `project_root`'s pnpm settings +/// when it is not the project's own: the nearest regular file of that name +/// in a strict ancestor, for a project directory with none of its own. +/// +/// `None` when the project has its own entry of that name (of any kind: an +/// unreadable file or a link is the caller's to report) or no ancestor +/// has one. Reads only metadata, so a FIFO never blocks it. The path is +/// canonical, minus Windows' verbatim prefix (see +/// [`without_verbatim_prefix`]), because refusals and warnings show it to +/// the user. +pub fn governing_workspace_file(project_root: &Path) -> Option { + if std::fs::symlink_metadata(project_root.join(PNPM_WORKSPACE)).is_ok() { + return None; + } + let canonical = + std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + canonical + .ancestors() + .skip(1) + .map(|dir| dir.join(PNPM_WORKSPACE)) + .find(|file| file.is_file()) + .map(without_verbatim_prefix) +} + +/// `path` without the verbatim prefix `std::fs::canonicalize` adds on +/// Windows: `\\?\C:\dir` becomes `C:\dir` and `\\?\UNC\srv\share` becomes +/// `\\srv\share`, the spelling users type and pnpm prints. Separators are +/// left alone. Any other path, every Unix one included, is returned +/// unchanged. Pure string-level, so it is tested on every host. +pub fn without_verbatim_prefix(path: PathBuf) -> PathBuf { + let Some(text) = path.to_str() else { + return path; + }; + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{rest}")) + } else if let Some(rest) = text + .strip_prefix(r"\\?\") + .filter(|rest| rest.as_bytes().get(1) == Some(&b':')) + { + PathBuf::from(rest) + } else { + path + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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(); + } + + #[test] + fn the_verbatim_prefix_is_dropped_and_nothing_else_changes() { + let strip = |text: &str| without_verbatim_prefix(PathBuf::from(text)); + assert_eq!( + strip(r"\\?\C:\ws\pnpm-workspace.yaml"), + PathBuf::from(r"C:\ws\pnpm-workspace.yaml") + ); + assert_eq!( + strip(r"\\?\UNC\srv\share\ws"), + PathBuf::from(r"\\srv\share\ws") + ); + // Not a drive path after the prefix (a volume GUID): kept verbatim. + assert_eq!( + strip(r"\\?\Volume{1}\ws"), + PathBuf::from(r"\\?\Volume{1}\ws") + ); + assert_eq!( + strip("/tmp/ws/pnpm-workspace.yaml"), + PathBuf::from("/tmp/ws/pnpm-workspace.yaml") + ); + assert_eq!(strip(r"C:\ws"), PathBuf::from(r"C:\ws")); + } + + #[test] + fn a_member_without_its_own_file_is_governed_by_the_nearest_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let root = without_verbatim_prefix(std::fs::canonicalize(tmp.path()).unwrap()); + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + write(&root, "packages/a/package.json", "{}"); + assert_eq!( + governing_workspace_file(&root.join("packages/a")), + Some(root.join(PNPM_WORKSPACE)) + ); + // A nearer ancestor wins. + write(&root, "packages/pnpm-workspace.yaml", "packages:\n - a\n"); + assert_eq!( + governing_workspace_file(&root.join("packages/a")), + Some(root.join("packages").join(PNPM_WORKSPACE)) + ); + } + + #[test] + fn a_project_with_its_own_file_or_no_ancestor_has_none() { + let tmp = tempfile::tempdir().unwrap(); + let root = without_verbatim_prefix(std::fs::canonicalize(tmp.path()).unwrap()); + write(&root, "packages/a/package.json", "{}"); + // No workspace file anywhere above. + assert_eq!(governing_workspace_file(&root.join("packages/a")), None); + // The workspace root itself is its own settings root. + write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n"); + assert_eq!(governing_workspace_file(&root), None); + // A project with its own file is its own workspace when pnpm runs + // there; that file is the caller's to read. + write( + &root, + "packages/a/pnpm-workspace.yaml", + "packages:\n - .\n", + ); + assert_eq!(governing_workspace_file(&root.join("packages/a")), None); + } +} diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 88777f0c7..cd5ea5cf9 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -85,7 +85,11 @@ use crate::formats::pnpm::workspace; use crate::formats::pnpm::{check_v9_lock_version as check_lock_version, vendored_npm_uuids}; const PACKAGE_JSON: &str = "package.json"; -const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; +use crate::utils::pnpm_workspace::{governing_workspace_file, PNPM_WORKSPACE}; + +/// Refusal code for a workspace member whose pnpm settings live in an +/// ancestor `pnpm-workspace.yaml` (see [`read_project`]). +pub(crate) const VENDOR_PNPM_SETTINGS_ELSEWHERE: &str = "vendor_pnpm_settings_elsewhere"; /// The root-only workspace member list written into a freshly created /// `pnpm-workspace.yaml`. pnpm 9 refuses a workspace file whose `packages` @@ -579,6 +583,30 @@ async fn read_project( ))); } }; + // A workspace member with its own lock (`sharedWorkspaceLockfile: + // false`) and no workspace file of its own: pnpm reads `overrides:` + // only from the workspace root's file, so the create path below would + // nest a `packages: ['.']` scaffold pnpm ignores, and every root + // install then fails (frozen) or drops the override (#881). The + // override cannot go in the root file either: socket-patch writes only + // inside the project. Hosted mode pins such a member. + if ws_text.is_none() { + if let Some(file) = governing_workspace_file(project_root) { + return Err(Box::new(refused( + VENDOR_PNPM_SETTINGS_ELSEWHERE, + format!( + "{} is a project of the pnpm workspace whose settings live in {}: \ + pnpm reads `overrides:` only from that file, so an override wired \ + into this project would be ignored (pnpm >= 11 fails frozen \ + installs, and a plain `pnpm install` reinstalls the unpatched \ + package); vendored mode cannot wire a member with its own lockfile \ + — use `--mode hosted`; nothing was written", + project_root.display(), + file.display() + ), + ))); + } + } // Same CRLF posture as the lock above: the workspace splices match exact // LF lines, so a CRLF file dodges the conflict checks and the edit // appends a DUPLICATE `overrides:` section — a duplicated mapping key @@ -3812,6 +3840,89 @@ snapshots: } } + /// #881: a workspace member with its own lock (`sharedWorkspaceLockfile: + /// false`) has no `pnpm-workspace.yaml` of its own; pnpm reads + /// `overrides:` only from the workspace root's. Vendoring used to + /// create a nested `packages: ['.']` + `overrides:` file pnpm ignores, + /// so root installs failed (frozen) or silently dropped the patch. It + /// now refuses before any write, the dry run and the pre-flight + /// included. + #[tokio::test] + async fn workspace_member_with_its_own_lock_is_refused_before_any_write() { + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + let ws_root = fx.root(); + let member = ws_root.join("packages/a"); + tokio::fs::create_dir_all(&member).await.unwrap(); + for name in [PACKAGE_JSON, PNPM_LOCK, "node_modules"] { + tokio::fs::rename(ws_root.join(name), member.join(name)) + .await + .unwrap(); + } + let root_ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; + tokio::fs::write(ws_root.join(PNPM_WORKSPACE), root_ws) + .await + .unwrap(); + let blobs = ws_root.join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + // As the refusal spells it: canonical (Windows expands 8.3 names), + // without the verbatim prefix. + let governing = crate::utils::pnpm_workspace::without_verbatim_prefix( + std::fs::canonicalize(&ws_root) + .unwrap() + .join(PNPM_WORKSPACE), + ); + for dry_run in [true, false] { + let outcome = crate::vendor::test_support::vendor_pnpm( + "pkg:npm/left-pad@1.3.0", + &member.join("node_modules/left-pad"), + &member, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + None, + ) + .await; + let detail = expect_refused(outcome, VENDOR_PNPM_SETTINGS_ELSEWHERE); + assert!( + detail.contains(&governing.display().to_string()), + "the detail names the governing workspace file: {detail}" + ); + } + let preflight = + preflight_packages(&member, &[("pkg:npm/left-pad@1.3.0", &fx.record)]).await; + assert_eq!(preflight, vec![Err(VENDOR_PNPM_SETTINGS_ELSEWHERE)]); + assert!(!member.join(PNPM_WORKSPACE).exists(), "no nested file"); + assert!(!member.join(".socket").exists(), "nothing staged"); + assert_eq!( + tokio::fs::read_to_string(member.join(PACKAGE_JSON)) + .await + .unwrap(), + P1_BEFORE_PKG + ); + assert_eq!( + tokio::fs::read_to_string(member.join(PNPM_LOCK)) + .await + .unwrap(), + P1_BEFORE_LOCK + ); + assert_eq!( + tokio::fs::read_to_string(ws_root.join(PNPM_WORKSPACE)) + .await + .unwrap(), + root_ws + ); + + // The workspace root itself (its own settings file) still vendors. + let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await; + tokio::fs::write(fx.root().join(PNPM_WORKSPACE), "packages:\n - '.'\n") + .await + .unwrap(); + let (result, _, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + } + #[tokio::test] async fn p1_fixture_oracle_transform_is_byte_identical_for_both_files() { let fx = fixture_with(P1_BEFORE_PKG, P1_BEFORE_LOCK).await;