From 3f7417402451e1881021be47062c5b8148361132 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 12 Aug 2026 16:36:31 -0700 Subject: [PATCH] fix(scan): warn to run pnpm install --trust-lockfile after hosted redirect When `scan --mode hosted` rewrites a pnpm-lock.yaml it repoints tarball URLs at patch.socket.dev. pnpm >=11's lockfile supply-chain policy then rejects the lock with ERR_PNPM_TARBALL_URL_MISMATCH until the user opts in with `pnpm install --trust-lockfile`. Push a `redirect_pnpm_trust_lockfile` warning (JSON warnings[] + stderr) whenever the rewrite lands in any pnpm-lock.yaml, mirroring the existing redirect_* warnings. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/scan/hosted.rs | 31 ++++++++ .../tests/in_process_redirect.rs | 70 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 8f917fa4..9bca8e63 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -343,6 +343,33 @@ pub(super) async fn run_redirect( })); } + // pnpm >=11 enforces a lockfile supply-chain policy: it compares each + // resolution's tarball URL against the registry's published metadata and + // REFUSES the lock when they differ + // (`ERR_PNPM_TARBALL_URL_MISMATCH … has a tarball URL (https://patch.socket.dev/…) + // that does not match the registry's published metadata`). The hosted + // rewrite deliberately repoints tarball URLs at patch.socket.dev, so a + // pnpm >=11 install rejects the rewritten lock until the user opts in with + // `pnpm install --trust-lockfile` (which installs the patched artifact + // cleanly). Warn whenever the rewrite actually landed in ANY pnpm-lock.yaml + // — the plain root lock or a Rush nested/subspace lock (basename check). + let mut pnpm_warnings: Vec = Vec::new(); + if rewrite.files.keys().any(|key| { + std::path::Path::new(key) + .file_name() + .and_then(|n| n.to_str()) + == Some("pnpm-lock.yaml") + }) { + pnpm_warnings.push(serde_json::json!({ + "code": "redirect_pnpm_trust_lockfile", + "detail": + "pnpm-lock.yaml was repointed at patch.socket.dev; pnpm >=11 rejects \ + the rewritten lock with ERR_PNPM_TARBALL_URL_MISMATCH (its tarball \ + URL no longer matches the registry's published metadata). Install \ + with `pnpm install --trust-lockfile` to accept the patched artifacts", + })); + } + // A dep counts as REDIRECTED only if its hosted-artifact URL (or its // per-dependency registry index URL) actually landed in the project's // files — either written by this run or already present from an earlier @@ -506,6 +533,7 @@ pub(super) async fn run_redirect( warnings.extend(record_warnings.iter().cloned()); warnings.extend(migration_warnings.iter().cloned()); warnings.extend(rush_warnings.iter().cloned()); + warnings.extend(pnpm_warnings.iter().cloned()); let mut result = serde_json::json!({ "status": "success", "redirect": { @@ -568,6 +596,9 @@ pub(super) async fn run_redirect( for w in &rush_warnings { eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } + for w in &pnpm_warnings { + eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); + } if let Some(statements) = vex_statements { eprintln!( "Wrote OpenVEX document with {} statement(s) to {} (redirected patches are \ diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 90f160bd..d302aa48 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1579,6 +1579,76 @@ async fn rush_stale_warning_requires_an_actual_lock_edit() { ); } +/// A plain (non-Rush) pnpm project whose only lockfile is a root +/// `pnpm-lock.yaml` (lockfileVersion 9.0) resolving the patched package, plus +/// the installed `node_modules/` copy the crawler discovers. +fn write_pnpm_project(root: &Path) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + write_installed(root, NAME, VERSION, b"unpatched installed bytes\n"); + std::fs::write(root.join("pnpm-lock.yaml"), rush_pnpm_lock(NAME)).unwrap(); +} + +/// A hosted redirect that rewrites a `pnpm-lock.yaml` must warn that pnpm >=11's +/// lockfile supply-chain policy will REJECT the rewritten lock +/// (`ERR_PNPM_TARBALL_URL_MISMATCH` — the repointed tarball URL no longer +/// matches the registry's published metadata) and name the documented +/// `pnpm install --trust-lockfile` opt-out — the same way the Rush repo-state +/// case surfaces its own post-rewrite install caveat. The npm twin +/// (package-lock.json, no pnpm lock) rewrites identically but emits no such +/// warning. Subprocess so the `--json` `warnings[]` array can be read back. +#[tokio::test] +#[serial] +async fn pnpm_lock_redirect_warns_to_trust_lockfile() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + // pnpm project: the root pnpm-lock.yaml is rewritten → the warning fires. + let pnpm = tempfile::tempdir().unwrap(); + write_pnpm_project(pnpm.path()); + let env = run_redirect_subprocess(pnpm.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert_eq!( + env["redirect"]["redirected"], 1, + "anchor: the pnpm lock must have been redirected: {env}" + ); + assert!( + warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "a rewritten pnpm-lock.yaml must warn about the pnpm >=11 policy; got warnings {:?}", + warning_codes(&env) + ); + // The warning must NAME the documented opt-out flag. + let detail = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_pnpm_trust_lockfile") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_default(); + assert!( + detail.contains("--trust-lockfile"), + "the warning must name `pnpm install --trust-lockfile`; got: {detail}" + ); + + // npm twin: only a package-lock.json is rewritten → no pnpm warning. + let npm = tempfile::tempdir().unwrap(); + write_project(npm.path()); + let env = run_redirect_subprocess(npm.path(), &server.uri()); + assert_eq!(env["status"], "success", "envelope: {env}"); + assert!( + !warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), + "an npm-only redirect must not emit the pnpm trust-lockfile warning; got warnings {:?}", + warning_codes(&env) + ); +} + /// Cargo's hosted redirect wires the managed sparse registry into /// `.cargo/config.toml` — but a project carrying the LEGACY extensionless /// `.cargo/config` is one cargo READS INSTEAD (it warns about the duplicate