Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> = 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
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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 \
Expand Down
70 changes: 70 additions & 0 deletions crates/socket-patch-cli/tests/in_process_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<NAME>` 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
Expand Down
Loading