From 6f381843463b7f3f96324f7b6925a81e8585fd7f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:26:24 -0400 Subject: [PATCH 01/21] fix(redirect): pin and revert each cargo version separately A project that locks two versions of one crate (cfg-if 1.0.4 beside a renamed cfg-if-legacy at 0.1.10) had every same-named declaration pinned to the last patch's registry: the manifest planner matched the crate name only, so `^0.1.10` pointed at a registry that serves 1.0.4 and `cargo fetch --locked` failed. `get ` for one version broke it the same way. The planner now pins a declaration only when its version requirement selects the patched version. A requirement that also matches another locked version refuses the dep (nothing written); `workspace = true` inheritors of an entry naming another version are skipped. `remove` had the matching defect: manifest edits are keyed by crate name, so removing one version also reverted the other version's pin (leaving its lock entry hosted) and dropped its registry edit from the ledger, so the created `.cargo/config.toml` survived the second removal. Manifest edits whose pin names a sibling version's registry lineage are no longer claimed. Golden: cargo/cargo/multi-version (the depscan TS twin lags; list it in TS_LAGGING on the next submodule bump). Real-cargo regression: e2e_redirect_cargo_shapes multi-version (fresh `cargo fetch --locked` + offline build links both patched copies; removing both purls restores every byte). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 574 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 407 +++++++++++-- .../src/patch/redirect/takeover.rs | 190 +++++- .../cargo/multi-version/expected-edits.json | 48 ++ .../multi-version/expected-warnings.json | 1 + .../multi-version/expected/.cargo/config.toml | 5 + .../cargo/multi-version/expected/Cargo.lock | 23 + .../cargo/multi-version/expected/Cargo.toml | 8 + .../cargo/multi-version/input/Cargo.lock | 23 + .../cargo/multi-version/input/Cargo.toml | 8 + .../cargo/cargo/multi-version/overrides.json | 42 ++ .../vex-discover-golden/redirect-cargo.json | 91 +++ 12 files changed, 1377 insertions(+), 43 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs new file mode 100644 index 00000000..88a34274 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -0,0 +1,574 @@ +//! Real-cargo hosted-mode regressions for project SHAPES beyond the single +//! root dependency `e2e_redirect_cargo_build` covers — each one a defect a +//! real-cargo capture matrix found in the hosted rewriter / revert: +//! +//! * `multi_version` — two patched versions of one crate (`cfg-if` 1.0.4 and +//! a renamed `cfg-if-legacy` at 0.1.10): each declaration is pinned to its +//! own version's registry, and removing both purls restores every byte. +//! +//! Every shape runs the same chain against the real cargo: a baseline build +//! with a private CARGO_HOME (network to crates.io for fixture setup only), +//! patched `.crate`s rebuilt from the ACTUAL crates.io bytes and served by a +//! wiremock sparse registry per patch, `scan --mode hosted`, then a FRESH +//! checkout (only the committed files travel) where `cargo fetch --locked` +//! and an offline `cargo build --locked` must link each patched-only symbol, +//! and finally `remove ` for every patch, which must leave the project +//! byte-identical to its pre-scan state. +//! +//! Skips (with a println) when `cargo` is missing or crates.io is +//! unreachable (a failure instead under `SOCKET_PATCH_CARGO_E2E_REQUIRED=1`). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +#[path = "cargo_e2e_matrix/mod.rs"] +mod cargo_e2e_matrix; + +const ORG: &str = "test-org"; +/// Appended to each patched crate's `src/lib.rs`: the oracle links it. +const PATCH_SUFFIX: &str = + "\n/// Socket-patch shape marker (added by the hosted patch).\npub fn socket_patched() -> u32 { 1 }\n"; + +/// One patched crate version the fixture serves. +#[derive(Clone, Copy)] +struct Patch { + name: &'static str, + version: &'static str, + uuid: &'static str, + token: &'static str, +} + +impl Patch { + fn purl(&self) -> String { + format!("pkg:cargo/{}@{}", self.name, self.version) + } +} + +const CFG_IF_1: Patch = Patch { + name: "cfg-if", + version: "1.0.4", + uuid: "c1f90104-5a0c-4e7a-9c0d-1a2b3c4d5e01", + token: "70ce0104-1111-4111-8111-111111111101", +}; +const CFG_IF_0: Patch = Patch { + name: "cfg-if", + version: "0.1.10", + uuid: "c1f90010-5a0c-4e7a-9c0d-1a2b3c4d5e02", + token: "70ce0010-1111-4111-8111-111111111102", +}; + +/// A project shape: its files before the lock exists, the patches, and the +/// oracle sources that reference each patched crate's marker. +struct Shape { + tag: &'static str, + files: Vec<(&'static str, String)>, + patches: Vec, + /// Written into the fresh checkout before the offline build. + oracle: Vec<(&'static str, String)>, +} + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env("SOCKET_NO_CONFIG", "1"); + cmd.env("CARGO_HOME", cargo_home); + let out = cmd.output().expect("failed to run socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn cargo(cwd: &Path, args: &[&str], cargo_home: &Path) -> Output { + cargo_e2e_matrix::cargo_command(cwd, cargo_home) + .args(args) + .output() + .expect("failed to run cargo") +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn find_registry_crate(cargo_home: &Path, leaf: &str) -> Option { + let src = cargo_home.join("registry").join("src"); + std::fs::read_dir(src) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path().join(leaf)) + .find(|p| p.is_dir()) +} + +fn sparse_index_rel(name: &str) -> String { + match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + } +} + +fn build_crate(stage: &Path, crate_dir: &Path, leaf: &str, patched: &[u8]) -> Vec { + let pkg = stage.join(leaf); + copy_tree(crate_dir, &pkg); + let _ = std::fs::remove_file(pkg.join(".cargo-checksum.json")); + std::fs::write(pkg.join("src/lib.rs"), patched).unwrap(); + let mut bytes = Vec::new(); + { + let enc = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::new(6)); + let mut builder = tar::Builder::new(enc); + builder.append_dir_all(leaf, &pkg).unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + bytes +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } +} + +/// Every project file except build output and the `.socket/` ledger dir. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel == "target" || rel == ".socket" || rel.ends_with("/target") { + continue; + } + if path.is_dir() { + walk(root, &path, out); + } else { + out.insert(rel, std::fs::read(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +struct Served { + patch: Patch, + orig: Vec, + patched: Vec, + crate_bytes: Vec, +} + +/// Route every patch-API, sparse-index and download request the CLI and +/// cargo make, for any number of patches. +fn router(origin: String, served: Vec) -> impl Fn(&Request) -> ResponseTemplate { + move |req: &Request| { + let path = req.url.path().to_string(); + let find = |uuid: &str| served.iter().find(|s| s.patch.uuid == uuid); + let index_url = |p: &Patch| { + format!( + "sparse+{origin}/patch-registry/cargo/{}/{}/index/", + p.token, p.uuid + ) + }; + let hosted_url = |p: &Patch| { + format!( + "{origin}/patch/cargo/{0}/{1}/{2}/{3}/{0}-{1}.crate", + p.name, p.version, p.token, p.uuid + ) + }; + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let api = format!("/v0/orgs/{ORG}/patches/"); + if path == format!("{api}batch") { + let packages: Vec = served + .iter() + .map(|s| { + serde_json::json!({ + "purl": s.patch.purl(), + "patches": [{ + "uuid": s.patch.uuid, "purl": s.patch.purl(), "tier": "free", + "cveIds": [], "ghsaIds": [format!("GHSA-shape-{}", &s.patch.uuid[..4])], + "severity": "high", "title": "cargo shape fixture" + }] + }) + }) + .collect(); + return ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "packages": packages, "canAccessPaidPatches": false }), + ); + } + if let Some(rest) = path.strip_prefix(&format!("{api}by-package/")) { + let purl = urlencoding_decode(rest); + let patches: Vec = served + .iter() + .filter(|s| s.patch.purl() == purl) + .map(|s| { + serde_json::json!({ + "uuid": s.patch.uuid, "purl": s.patch.purl(), + "publishedAt": "2026-01-01T00:00:00Z", "description": "x", + "license": "MIT", "tier": "free", "vulnerabilities": {} + }) + }) + .collect(); + return ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "patches": patches, "canAccessPaidPatches": false }), + ); + } + if path == format!("{api}package") { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default(); + let mut results = serde_json::Map::new(); + for uuid in body["uuids"].as_array().into_iter().flatten() { + let uuid = uuid.as_str().unwrap_or_default(); + let value = match find(uuid) { + Some(s) => { + let cksum = hex::encode(Sha256::digest(&s.crate_bytes)); + serde_json::json!({ + "status": "granted", + "url": hosted_url(&s.patch), + "purl": s.patch.purl(), + "artifacts": [{ + "kind": "tarball", "url": hosted_url(&s.patch), + "integrity": { "sha256": cksum } + }], + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": index_url(&s.patch), + "identifiers": { + "name": s.patch.name, "version": s.patch.version, + "cargoCksumSha256": cksum, + } + } + }) + } + None => serde_json::json!({ "status": "not_found" }), + }; + results.insert(uuid.to_string(), value); + } + return ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "results": results })); + } + if let Some(uuid) = path.strip_prefix(&format!("{api}view/")) { + if let Some(s) = find(uuid) { + return ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": s.patch.purl(), + "publishedAt": "2026-01-01T00:00:00Z", + "files": { "src/lib.rs": { + "beforeHash": compute_git_sha256_from_bytes(&s.orig), + "afterHash": compute_git_sha256_from_bytes(&s.patched), + }}, + "vulnerabilities": { format!("GHSA-shape-{}", &uuid[..4]): { + "cves": [], "summary": "s", "severity": "high", "description": "d" + }}, + "description": "x", "license": "MIT", "tier": "free" + })); + } + } + // /patch-registry/cargo///index/... + if segs.len() >= 6 && segs[..2] == ["patch-registry", "cargo"] && segs[4] == "index" { + if let Some(s) = find(segs[3]) { + let rest = segs[5..].join("/"); + if rest == "config.json" { + return ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dl": format!("{origin}/dl/{}", s.patch.uuid), + })); + } + if rest == sparse_index_rel(s.patch.name) { + let line = serde_json::json!({ + "name": s.patch.name, "vers": s.patch.version, "deps": [], + "cksum": hex::encode(Sha256::digest(&s.crate_bytes)), + "features": {}, "yanked": false, + }); + return ResponseTemplate::new(200).set_body_string(line.to_string()); + } + } + } + // /dl////download + if segs.len() == 5 && segs[0] == "dl" && segs[4] == "download" { + if let Some(s) = find(segs[1]) { + return ResponseTemplate::new(200).set_body_bytes(s.crate_bytes.clone()); + } + } + ResponseTemplate::new(404) + } +} + +fn urlencoding_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Run one shape through scan → fresh-checkout fetch + offline build → +/// remove. Returns `None` when the fixture could not be built (skipped). +async fn run_shape(shape: Shape) -> Option<()> { + let suite = format!("e2e_redirect_cargo_shapes ({})", shape.tag); + if !cargo_e2e_matrix::cargo_available(&suite) { + return None; + } + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let home = tmp.path().join("cargo-home"); + std::fs::create_dir_all(&home).unwrap(); + for (rel, content) in &shape.files { + let path = proj.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + let generate = cargo(&proj, &["generate-lockfile"], &home); + if !generate.status.success() { + let _ = cargo_e2e_matrix::skip( + &suite, + &format!( + "`cargo generate-lockfile` failed (crates.io unreachable?):\n{}", + stderr(&generate) + ), + ); + return None; + } + pin_patched_versions(&proj, &home, &shape.patches); + let build = cargo(&proj, &["build", "-q", "--locked"], &home); + if !build.status.success() { + let _ = cargo_e2e_matrix::skip( + &suite, + &format!( + "baseline `cargo build` failed (crates.io unreachable?):\n{}", + stderr(&build) + ), + ); + return None; + } + let _ = std::fs::remove_dir_all(proj.join("target")); + let before = snapshot(&proj); + + let mut served = Vec::new(); + for patch in &shape.patches { + let leaf = format!("{}-{}", patch.name, patch.version); + let dir = find_registry_crate(&home, &leaf) + .unwrap_or_else(|| panic!("{leaf} must be extracted by the baseline build")); + let orig = std::fs::read(dir.join("src/lib.rs")).unwrap(); + let patched = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + let crate_bytes = build_crate(&tmp.path().join("stage"), &dir, &leaf, &patched); + served.push(Served { + patch: *patch, + orig, + patched, + crate_bytes, + }); + } + let server = MockServer::start().await; + Mock::given(wiremock::matchers::any()) + .respond_with(router(server.uri(), served)) + .mount(&server) + .await; + + let uri = server.uri(); + let proj_s = proj.to_str().unwrap().to_string(); + let (code, stdout, err) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--no-telemetry", + "--cwd", + &proj_s, + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake", + ], + &home, + ); + assert_eq!( + code, 0, + "{}: scan failed\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + env["redirect"]["redirected"], + shape.patches.len(), + "{}: every patch redirected: {env}", + shape.tag + ); + assert_eq!( + env["redirect"]["warnings"], + serde_json::json!([]), + "{}: {env}", + shape.tag + ); + + // Fresh checkout: only committed files travel; an EMPTY CARGO_HOME. + let fresh = tmp.path().join("fresh"); + for (rel, _) in snapshot(&proj) { + let to = fresh.join(&rel); + std::fs::create_dir_all(to.parent().unwrap()).unwrap(); + std::fs::copy(proj.join(&rel), to).unwrap(); + } + let fresh_home = tmp.path().join("fresh-home"); + std::fs::create_dir_all(&fresh_home).unwrap(); + let fetch = cargo(&fresh, &["fetch", "--locked"], &fresh_home); + assert!( + fetch.status.success(), + "{}: fresh `cargo fetch --locked` failed:\n{}", + shape.tag, + stderr(&fetch) + ); + for (rel, content) in &shape.oracle { + std::fs::write(fresh.join(rel), content).unwrap(); + } + let build = cargo(&fresh, &["build", "--locked", "--offline"], &fresh_home); + assert!( + build.status.success(), + "{}: offline `cargo build --locked` must link every patched marker:\n{}", + shape.tag, + stderr(&build) + ); + + // Rollback: removing every purl restores the pre-scan project exactly. + for patch in &shape.patches { + let purl = patch.purl(); + let (code, stdout, err) = run_socket( + &proj, + &[ + "remove", + &purl, + "--cwd", + &proj_s, + "--json", + "--yes", + "--no-telemetry", + ], + &home, + ); + assert_eq!( + code, 0, + "{}: remove {purl}\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + } + let after = snapshot(&proj); + for (rel, bytes) in &before { + assert_eq!( + after + .get(rel) + .map(|b| String::from_utf8_lossy(b).into_owned()), + Some(String::from_utf8_lossy(bytes).into_owned()), + "{}: {rel} not restored byte-for-byte by remove", + shape.tag + ); + } + let extra: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); + assert!( + extra.is_empty(), + "{}: remove left files behind: {extra:?}", + shape.tag + ); + Some(()) +} + +/// Pin each patched version: a caret requirement locks the newest +/// compatible release, which moves as crates.io publishes. +fn pin_patched_versions(proj: &Path, home: &Path, patches: &[Patch]) { + for patch in patches { + let lock = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); + let locked = cargo_e2e_matrix::parse_lock(&lock); + if locked + .iter() + .any(|p| p.name == patch.name && p.version == patch.version) + { + continue; + } + let pinned = locked + .iter() + .filter(|p| p.name == patch.name) + .filter(|p| { + !patches + .iter() + .any(|q| q.name == p.name && q.version == p.version) + }) + .any(|p| { + let spec = format!("{}@{}", p.name, p.version); + cargo( + proj, + &["update", "-p", &spec, "--precise", patch.version], + home, + ) + .status + .success() + }); + assert!( + pinned, + "cannot lock {}@{}:\n{lock}", + patch.name, patch.version + ); + } +} + +fn consumer_manifest(deps: &str) -> String { + format!("[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n[dependencies]\n{deps}") +} + +/// Bugs B + I: two patched versions of one crate. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() { + let shape = Shape { + tag: "multi-version", + files: vec![ + ( + "Cargo.toml", + consumer_manifest( + "cfg-if = \"1.0.4\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n", + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1, CFG_IF_0], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched() + cfg_if_legacy::socket_patched()); }\n" + .to_string(), + )], + }; + let _ = run_shape(shape).await; +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8c885688..d24d60c2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -826,31 +826,34 @@ fn rewrite_cargo( }); continue; }; - let toml_plan = match plan_cargo_toml(toml_text, &dep.name, ®) { - Ok(plan) => plan, - Err(CargoTomlPlanError::NotFound) => { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_not_found".into(), - detail: format!( - "no [dependencies] entry for {} in Cargo.toml; dependency skipped \ + let other_versions = + cargo_lock_other_versions(cargo_lock.as_deref(), &dep.name, &dep.version); + let toml_plan = + match plan_cargo_toml(toml_text, &dep.name, &dep.version, &other_versions, ®) { + Ok(plan) => plan, + Err(CargoTomlPlanError::NotFound) => { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_not_found".into(), + detail: format!( + "no [dependencies] entry for {} in Cargo.toml; dependency skipped \ (nothing rewritten)", - dep.name - ), - }); - continue; - } - Err(CargoTomlPlanError::Refused(reason)) => { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_unrewritable".into(), - detail: format!( - "{} in Cargo.toml cannot be pinned ({reason}); dependency skipped \ + dep.name + ), + }); + continue; + } + Err(CargoTomlPlanError::Refused(reason)) => { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: format!( + "{} in Cargo.toml cannot be pinned ({reason}); dependency skipped \ (nothing rewritten)", - dep.name - ), - }); - continue; - } - }; + dep.name + ), + }); + continue; + } + }; // 2. Plan the Cargo.lock repoint. A lock that exists but has no // [[package]] for the dep means the project does not actually resolve @@ -1274,9 +1277,87 @@ static CARGO_TOML_PATH_GIT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"\b(?:path|git)\s*=").expect("static path/git probe regex is valid") }); +static CARGO_TOML_VERSION_VAL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bversion\s*=\s*"([^"]*)""#).expect("static version-value regex is valid") +}); + +/// Whether one declaration's version requirement selects the patched +/// version. Cargo resolves a declaration to ONE version, so a project that +/// locks several versions of a crate (`cfg-if = "1"` beside a renamed +/// `cfg-if-legacy = { package = "cfg-if", version = "0.1" }`) must pin +/// only the declaration whose requirement matches the patched version — +/// pinning every same-named declaration to one registry leaves the other +/// requirement unsatisfiable there. +#[derive(Debug, Clone, Copy, PartialEq)] +enum CargoReqMatch { + Ours, + NotOurs, + /// The requirement also matches another locked version (or cannot be + /// read while another version is locked): which one cargo picked for + /// this declaration cannot be told from the manifest. + Ambiguous, +} + +fn cargo_req_selects(req: Option<&str>, version: &str, other_versions: &[String]) -> CargoReqMatch { + let unknown = if other_versions.is_empty() { + CargoReqMatch::Ours + } else { + CargoReqMatch::Ambiguous + }; + let (Some(req), Ok(patched)) = (req, semver::Version::parse(version)) else { + return unknown; + }; + let Ok(req) = semver::VersionReq::parse(req.trim()) else { + return unknown; + }; + if !req.matches(&patched) { + return CargoReqMatch::NotOurs; + } + let also_other = other_versions + .iter() + .any(|v| semver::Version::parse(v).is_ok_and(|v| req.matches(&v))); + if also_other { + CargoReqMatch::Ambiguous + } else { + CargoReqMatch::Ours + } +} + +/// What a `[workspace.dependencies]` entry means for the patched version — +/// resolved per entry KEY, since `workspace = true` inherits by key. +#[derive(Debug, Clone, Copy, PartialEq)] +enum CargoWorkspaceEntry { + /// The entry lands (or already carries) the pin. + Pinned, + /// The entry names the crate at another version. + OtherVersion, +} + +/// Every version of `crate_name` a Cargo.lock holds other than `version`. +fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str) -> Vec { + let Some(lock) = lock else { + return Vec::new(); + }; + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \""); + let mut versions: Vec = lock + .match_indices(head.as_str()) + .filter(|&(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + .filter_map(|(at, _)| { + let rest = &lock[at + head.len()..]; + rest.split_once('"').map(|(v, _)| v.to_string()) + }) + .filter(|v| v != version) + .collect(); + versions.sort(); + versions.dedup(); + versions +} + fn plan_cargo_toml( content: &str, crate_name: &str, + version: &str, + other_versions: &[String], reg: &str, ) -> Result { let lines: Vec<&str> = content.split('\n').collect(); @@ -1287,18 +1368,23 @@ fn plan_cargo_toml( let registry_index_re: &Regex = &CARGO_TOML_REGISTRY_INDEX_RE; let workspace_key_re: &Regex = &CARGO_TOML_WORKSPACE_KEY_RE; let path_git_re: &Regex = &CARGO_TOML_PATH_GIT_RE; + let version_val_re: &Regex = &CARGO_TOML_VERSION_VAL_RE; + let ambiguous = + || format!("its version requirement also matches another locked version of {crate_name}"); // A pending occurrence: what was found, resolved to an action in pass 2 // (workspace-inheriting entries need the whole file scanned first). enum Pending { Action(CargoTomlAction), - NeedsWorkspacePin, + /// `workspace = true` under this key. + NeedsWorkspacePin(String), Refuse(String), } let mut pending: Vec = Vec::new(); - // Whether the `[workspace.dependencies]` entry for the crate lands (or - // already carries) the pin — satisfies `workspace = true` inheritors. - let mut workspace_pinned = false; + // Per `[workspace.dependencies]` key naming the crate: whether that + // entry lands (or already carries) the pin — satisfies `workspace = + // true` inheritors of the same key — or names another version. + let mut ws_entries: BTreeMap = BTreeMap::new(); let mut section = CargoTomlSection::Other; for (idx, raw) in lines.iter().enumerate() { @@ -1362,8 +1448,22 @@ fn plan_cargo_toml( }) }) }; + let selects = if has("workspace") { + CargoReqMatch::Ours + } else { + let req = find_value("version").map(|(_, v)| v); + cargo_req_selects(req.as_deref(), version, other_versions) + }; + if selects == CargoReqMatch::NotOurs { + if ws { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } if has("workspace") { - pending.push(Pending::NeedsWorkspacePin); + pending.push(Pending::NeedsWorkspacePin(key.clone())); + } else if selects == CargoReqMatch::Ambiguous { + pending.push(Pending::Refuse(ambiguous())); } else if has("path") || has("git") { pending.push(Pending::Refuse( "declared as a path/git dependency".to_string(), @@ -1377,7 +1477,7 @@ fn plan_cargo_toml( if value == reg { pending.push(Pending::Action(CargoTomlAction::Already)); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if is_socket_patch_registry_name(&value) { let old_line = lines[line_idx]; @@ -1389,7 +1489,7 @@ fn plan_cargo_toml( new_text, })); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else { pending.push(Pending::Refuse(format!( @@ -1403,7 +1503,7 @@ fn plan_cargo_toml( inserted: format!("{indent}registry = \"{reg}\""), })); if ws { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } } @@ -1422,7 +1522,7 @@ fn plan_cargo_toml( let sub = parse_cargo_entry_key(dotted).map(|(k, _)| k); if key == crate_name { if sub.as_deref() == Some("workspace") { - pending.push(Pending::NeedsWorkspacePin); + pending.push(Pending::NeedsWorkspacePin(key.clone())); } else { pending.push(Pending::Refuse( "declared with dotted keys this rewriter does not edit".to_string(), @@ -1465,8 +1565,24 @@ fn plan_cargo_toml( continue; } if workspace_key_re.is_match(inner) { - pending.push(Pending::NeedsWorkspacePin); - } else if path_git_re.is_match(inner) { + pending.push(Pending::NeedsWorkspacePin(key.clone())); + continue; + } + let req = version_val_re.captures(inner).map(|c| c[1].to_string()); + match cargo_req_selects(req.as_deref(), version, other_versions) { + CargoReqMatch::NotOurs => { + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } + CargoReqMatch::Ambiguous => { + pending.push(Pending::Refuse(ambiguous())); + continue; + } + CargoReqMatch::Ours => {} + } + if path_git_re.is_match(inner) { pending.push(Pending::Refuse( "declared as a path/git dependency".to_string(), )); @@ -1475,7 +1591,7 @@ fn plan_cargo_toml( if value == reg { pending.push(Pending::Action(CargoTomlAction::Already)); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if is_socket_patch_registry_name(&value) { let new_text = registry_val_re @@ -1486,7 +1602,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else { pending.push(Pending::Refuse(format!( @@ -1518,7 +1634,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } } else if value.starts_with('"') { @@ -1540,6 +1656,23 @@ fn plan_cargo_toml( )); continue; }; + let req = m + .get(2) + .expect("line_re always captures group 2 (version)") + .as_str(); + match cargo_req_selects(Some(req), version, other_versions) { + CargoReqMatch::NotOurs => { + if workspace { + ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); + } + continue; + } + CargoReqMatch::Ambiguous => { + pending.push(Pending::Refuse(ambiguous())); + continue; + } + CargoReqMatch::Ours => {} + } let new_text = format!( "{}{{ version = \"{}\", registry = \"{reg}\" }}{}", m.get(1) @@ -1557,7 +1690,7 @@ fn plan_cargo_toml( new_text, })); if workspace { - workspace_pinned = true; + ws_entries.insert(key.clone(), CargoWorkspaceEntry::Pinned); } } else if key == crate_name { pending.push(Pending::Refuse( @@ -1575,20 +1708,27 @@ fn plan_cargo_toml( for p in pending { match p { Pending::Action(a) => actions.push(a), - Pending::NeedsWorkspacePin => { - if workspace_pinned { + Pending::NeedsWorkspacePin(key) => match ws_entries.get(&key) { + Some(CargoWorkspaceEntry::Pinned) => { actions.push(CargoTomlAction::InheritsWorkspace); - } else { + } + // Inherits another version of the crate: not this dep. + Some(CargoWorkspaceEntry::OtherVersion) => {} + None => { return Err(CargoTomlPlanError::Refused( "inherits from [workspace.dependencies] with no rewritable entry \ in this manifest" .to_string(), )); } - } + }, Pending::Refuse(reason) => return Err(CargoTomlPlanError::Refused(reason)), } } + // Every occurrence named another version (inheritors included). + if actions.is_empty() { + return Err(CargoTomlPlanError::NotFound); + } // Apply bottom-up so line indices stay valid; record edits top-down. let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); @@ -8664,6 +8804,189 @@ mod tests { assert!(r.confirmed_cargo_uuids.is_empty()); } + /// A second patched version of the crate, uuid distinct from + /// [`CARGO_UUID`]. + const CARGO_UUID_2: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + + fn cfg_if_override(version: &str, uuid: &str) -> DepOverride { + let mut dep = cargo_sparse_override(); + dep.name = "cfg-if".into(); + dep.version = version.into(); + dep.patch_uuid = uuid.into(); + let ov = dep.registry_override.as_mut().expect("fixture override"); + ov.index_url = format!("sparse+https://patch.test/cargo/{uuid}/index/"); + ov.identifiers.name = "cfg-if".into(); + ov.identifiers.version = version.into(); + dep + } + + /// Both cfg-if versions locked from crates.io (the `multi-version` shape). + fn cfg_if_multi_files(manifest_deps: &str) -> BTreeMap { + let block = |v: &str| { + format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"{v}\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"{}\"\n", + "1".repeat(64) + ) + }; + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n{manifest_deps}"), + ); + files.insert( + "Cargo.lock".to_string(), + format!("version = 3\n\n{}\n{}", block("0.1.10"), block("1.0.4")), + ); + files + } + + const CFG_IF_MULTI_DEPS: &str = "[dependencies]\ncfg-if = \"1.0.4\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + + /// Bug B: the manifest pin matched the crate NAME only, so every + /// same-named declaration — `cfg-if-legacy = { package = "cfg-if", + /// version = "0.1.10" }` too — was pinned to the one patched version's + /// registry, where `^0.1.10` cannot resolve. Each declaration is pinned + /// only by the patch its version requirement selects. + #[test] + fn cargo_multi_version_pins_only_the_declaration_the_version_selects() { + let files = cfg_if_multi_files(CFG_IF_MULTI_DEPS); + let reg1 = format!("socket-patch-{CARGO_UUID}"); + let reg2 = format!("socket-patch-{CARGO_UUID_2}"); + + let r = rewrite_registry_redirect(&files, &[cfg_if_override("1.0.4", CARGO_UUID)]); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!( + toml.contains(&format!( + "cfg-if = {{ version = \"1.0.4\", registry = \"{reg1}\" }}\n" + )), + "{toml}" + ); + assert!( + toml.contains("cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"), + "the 0.1.10 declaration is not the patched version's: {toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + + let r = rewrite_registry_redirect(&files, &[cfg_if_override("0.1.10", CARGO_UUID_2)]); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!(toml.contains("cfg-if = \"1.0.4\"\n"), "{toml}"); + assert!( + toml.contains(&format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{reg2}\" }}" + )), + "{toml}" + ); + let lock = r.files.get("Cargo.lock").expect("lock repointed"); + assert!( + lock.contains(&format!( + "version = \"0.1.10\"\nsource = \"sparse+https://patch.test/cargo/{CARGO_UUID_2}/index/\"" + )) && lock.contains( + "version = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"" + ), + "only the 0.1.10 entry moves: {lock}" + ); + + let r = rewrite_registry_redirect( + &files, + &[ + cfg_if_override("1.0.4", CARGO_UUID), + cfg_if_override("0.1.10", CARGO_UUID_2), + ], + ); + let toml = r.files.get("Cargo.toml").expect("manifest pinned"); + assert!( + toml.contains(&format!("version = \"1.0.4\", registry = \"{reg1}\"")) + && toml.contains(&format!("version = \"0.1.10\", registry = \"{reg2}\"")), + "{toml}" + ); + assert_eq!(r.confirmed_cargo_uuids.len(), 2); + } + + /// A requirement that also matches another locked version cannot be + /// attributed to the patched one — refuse the dep, write nothing. + #[test] + fn cargo_requirement_matching_several_locked_versions_refuses() { + let files = cfg_if_multi_files( + "[dependencies]\ncfg-if = \">=0.1\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cfg_if_override("1.0.4", CARGO_UUID)]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A declaration whose requirement excludes the patched version is not + /// the patched crate: nothing to pin. + #[test] + fn cargo_requirement_excluding_the_patched_version_is_not_found() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"2\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files); + assert_eq!(warning_codes(&r), vec!["redirect_cargo_toml_dep_not_found"]); + } + + /// A `workspace = true` inheritor of the entry that names ANOTHER + /// version is not this dep (and does not refuse it). + #[test] + fn cargo_workspace_inheritor_of_another_version_is_skipped() { + let files = cfg_if_multi_files( + "[workspace.dependencies]\ncfg-if = \"1.0.4\"\n\ + cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n\n\ + [dependencies]\ncfg-if = { workspace = true }\n\ + cfg-if-legacy = { workspace = true }\n", + ); + let r = rewrite_registry_redirect(&files, &[cfg_if_override("0.1.10", CARGO_UUID_2)]); + let toml = r.files.get("Cargo.toml").expect("workspace entry pinned"); + assert!( + toml.contains(&format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-{CARGO_UUID_2}\" }}" + )) && toml.contains("[workspace.dependencies]\ncfg-if = \"1.0.4\"\n"), + "{toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID_2)); + } + + /// A project the name-only matcher already damaged (the 0.1.10 + /// declaration pinned to the 1.0.4 patch's registry) is repaired: the + /// 0.1.10 patch supersedes its own declaration's socket pin, and the + /// 1.0.4 patch leaves it alone. + #[test] + fn cargo_mispinned_other_version_declaration_is_repaired() { + let reg1 = format!("socket-patch-{CARGO_UUID}"); + let reg2 = format!("socket-patch-{CARGO_UUID_2}"); + let files = cfg_if_multi_files(&format!( + "[dependencies]\ncfg-if = {{ version = \"1.0.4\", registry = \"{reg1}\" }}\n\ + cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{reg1}\" }}\n" + )); + let r = rewrite_registry_redirect( + &files, + &[ + cfg_if_override("1.0.4", CARGO_UUID), + cfg_if_override("0.1.10", CARGO_UUID_2), + ], + ); + let toml = r.files.get("Cargo.toml").expect("legacy pin superseded"); + assert!( + toml.contains(&format!("version = \"1.0.4\", registry = \"{reg1}\"")) + && toml.contains(&format!("version = \"0.1.10\", registry = \"{reg2}\"")), + "{toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 6c7d6dd4..03e57408 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -148,6 +148,55 @@ static SOCKET_REGISTRY_UUID: LazyLock = LazyLock::new(|| { .expect("static registry-uuid regex is valid") }); +/// The `socket-patch-` registry uuids a recorded fragment names. +fn registry_uuids(fragment: Option<&Value>) -> impl Iterator + '_ { + fragment + .and_then(Value::as_str) + .into_iter() + .flat_map(|s| SOCKET_REGISTRY_UUID.captures_iter(s)) + .map(|c| c[1].to_string()) +} + +/// Every patch uuid `name@version` was redirected at: its record's, plus +/// each managed registry whose index URL one of its (version-keyed) +/// Cargo.lock edits names — the older links of a re-redirect chain. +fn cargo_lineage(state: &RedirectState, name: &str, version: &str, uuid: &str) -> HashSet { + let lock_key = format!("{name}@{version}"); + let lock_fragments: Vec<&str> = state + .edits + .iter() + .filter(|e| { + e.kind == "redirect_cargo_lock_entry" && e.key.as_deref() == Some(lock_key.as_str()) + }) + .flat_map(|e| [e.original.as_ref(), e.new.as_ref()]) + .flatten() + .filter_map(Value::as_str) + .collect(); + let mut lineage: HashSet = HashSet::from([uuid.to_string()]); + for e in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_registry") + { + let Some(u) = e + .key + .as_deref() + .and_then(|k| k.strip_prefix("socket-patch-")) + else { + continue; + }; + let index = e + .new + .as_ref() + .and_then(Value::as_str) + .and_then(|block| block.split('"').nth(1)); + if index.is_some_and(|index| lock_fragments.iter().any(|f| f.contains(index))) { + lineage.insert(u.to_string()); + } + } + lineage +} + /// Revert every hosted-redirect edit the ledger records for `purl` (a cargo /// package), then drop that purl's record and edits from `state`. The caller /// persists the mutated ledger (see `persist_redirect_state`). @@ -172,8 +221,25 @@ pub async fn revert_cargo_redirect_purl( let (name, version) = (name.into_owned(), version.into_owned()); let lock_key = format!("{name}@{version}"); + // Manifest edits are keyed by crate NAME (the shared golden ledger + // shape), so when another version of the crate is redirected too, its + // pins carry the same key: skip every manifest edit whose pin names a + // registry of a sibling version's lineage. Without a sibling the claim + // stays name-wide, as before. + let sibling_uuids: HashSet = state + .records + .iter() + .filter(|(key, _)| **key != record_key) + .filter_map(|(key, rec)| { + let (n, v) = parse_cargo_purl(strip_purl_qualifiers(key))?; + (n == name && v != version).then(|| cargo_lineage(state, &n, &v, &rec.uuid)) + }) + .flatten() + .collect(); let is_wiring_edit = |e: &FileEdit| { - (e.kind == "redirect_cargo_toml_dep" && e.key.as_deref() == Some(name.as_str())) + (e.kind == "redirect_cargo_toml_dep" + && e.key.as_deref() == Some(name.as_str()) + && !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u))) || (e.kind == "redirect_cargo_lock_entry" && e.key.as_deref() == Some(lock_key.as_str())) }; @@ -1229,6 +1295,128 @@ mod tests { (tmp, state) } + /// Bug I: two redirected versions of one crate share the manifest edit + /// key (the crate name). Removing one version must revert ONLY its own + /// declaration + lock entry + registry block — claiming the sibling's + /// manifest edit reverted the other pin while its lock entry stayed + /// hosted (a broken build), and dropped the sibling's registry edit, so + /// the second removal left the created `.cargo/config.toml` behind. + #[tokio::test] + async fn multi_version_removes_each_version_independently() { + const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + const PURL_OLD: &str = "pkg:cargo/cfg-if@0.1.10"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ + source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", + "8".repeat(64), + pristine_lock_block() + ); + let dep = |version: &str, uuid: &str| -> crate::patch::redirect::DepOverride { + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": version, "token": "tok", + "patchUuid": uuid, + "artifactUrl": format!("http://127.0.0.1:5555/cfg-if-{version}.crate"), + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), + "identifiers": { + "name": "cfg-if", "version": version, + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap() + }; + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.clone()); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[dep("1.0.4", UUID), dep("0.1.10", UUID_OLD)], + ); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + 2, + "{:?}", + rewrite.warnings + ); + tokio::fs::write(root.join("Cargo.toml"), toml) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), &lock) + .await + .unwrap(); + for (rel, content) in &rewrite.files { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_OLD.to_string(); + state.records.insert(PURL_OLD.to_string(), old); + + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("1.0.4 reverts"); + let t = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + assert!( + t.contains("cfg-if = \"1.0\"\n") + && t.contains(&format!("registry = \"socket-patch-{UUID_OLD}\"")), + "only the 1.0.4 pin is reverted: {t}" + ); + let l = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + assert!( + l.contains(&format!("sparse+http://127.0.0.1:5555/{UUID_OLD}/index/")), + "{l}" + ); + let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + assert!( + !cfg.contains(&format!("socket-patch-{UUID}]")) && cfg.contains(UUID_OLD), + "{cfg}" + ); + + revert_cargo_redirect_purl(root, &mut state, PURL_OLD, false) + .await + .expect("0.1.10 reverts"); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + assert!( + !root.join(".cargo/config.toml").exists(), + "the created config goes with the last block" + ); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{:?}", + state.edits + ); + } + #[tokio::test] async fn reverts_toml_lock_and_registry_block_and_drops_ledger_entries() { let (tmp, mut state) = redirected_fixture().await; diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json new file mode 100644 index 00000000..4abcfd73 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-edits.json @@ -0,0 +1,48 @@ +[ + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "cfg-if", + "original": "cfg-if = \"1.0.4\"", + "new": "cfg-if = { version = \"1.0.4\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "cfg-if@1.0.4", + "original": "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"", + "new": "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\nchecksum = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + }, + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-66666666-6666-6666-6666-666666666666", + "new": "[registries.socket-patch-66666666-6666-6666-6666-666666666666]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "cfg-if", + "original": "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }", + "new": "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-66666666-6666-6666-6666-666666666666\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "cfg-if@0.1.10", + "original": "[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822\"", + "new": "[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/\"\nchecksum = \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml new file mode 100644 index 00000000..1ef2fc0c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/.cargo/config.toml @@ -0,0 +1,5 @@ +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" + +[registries.socket-patch-66666666-6666-6666-6666-666666666666] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock new file mode 100644 index 00000000..306a322d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/" +checksum = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "cfg-if 0.1.10", + "cfg-if 1.0.4", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml new file mode 100644 index 00000000..cd0456b4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/expected/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2018" + +[dependencies] +cfg-if = { version = "1.0.4", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } +cfg-if-legacy = { package = "cfg-if", version = "0.1.10", registry = "socket-patch-66666666-6666-6666-6666-666666666666" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock new file mode 100644 index 00000000..cfcf0991 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "cfg-if 0.1.10", + "cfg-if 1.0.4", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml new file mode 100644 index 00000000..a2f02505 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2018" + +[dependencies] +cfg-if = "1.0.4" +cfg-if-legacy = { package = "cfg-if", version = "0.1.10" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json new file mode 100644 index 00000000..e24a3142 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/multi-version/overrides.json @@ -0,0 +1,42 @@ +[ + { + "ecosystem": "cargo", + "name": "cfg-if", + "version": "1.0.4", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/cfg-if/1.0.4/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "cfg-if", + "version": "1.0.4", + "cargoCksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "integrity": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "ecosystem": "cargo", + "name": "cfg-if", + "version": "0.1.10", + "token": "22222222-2222-2222-2222-222222222222", + "patchUuid": "66666666-6666-6666-6666-666666666666", + "artifactUrl": "https://patch.socket.dev/patch/cargo/cfg-if/0.1.10/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/cfg-if-0.1.10.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/", + "identifiers": { + "name": "cfg-if", + "version": "0.1.10", + "cargoCksumSha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + "integrity": { + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 9f82f122..b5606443 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -120,6 +120,97 @@ } ] }, + "redirect/cargo/cargo/multi-version/expected": { + "refs": [ + { + "purl": "pkg:cargo/cfg-if@0.1.10", + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/22222222-2222-2222-2222-222222222222/66666666-6666-6666-6666-666666666666/index/", + "lockfile_basis_ok": true + }, + { + "purl": "pkg:cargo/cfg-if@1.0.4", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "66666666-6666-6666-6666-666666666666", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/cfg-if@1.0.4" + }, + { + "mode": "hosted", + "uuid": "66666666-6666-6666-6666-666666666666", + "purl": "pkg:cargo/cfg-if@0.1.10" + } + ] + }, + "redirect/cargo/cargo/multi-version/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/renamed/expected": { "refs": [ { From f1042c3f2626c8a0d9e3ab27b0a3546ef70f28db Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:30:03 -0400 Subject: [PATCH 02/21] fix(redirect): restore a cargo config's bytes on hosted remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remove` of a hosted cargo patch deleted the `[registries.socket-patch-…]` block it had appended to an existing config but kept the blank separator line it inserted before it, so a legacy `.cargo/config` holding `[net]\nretry = 2\n` came back as `[net]\nretry = 2\n\n`. It also collapsed every run of three newlines anywhere in the file, rewriting the user's own spacing. The block now leaves through the replay path's fragment removal, which takes the block's own separator and nothing else. Real-cargo regression: e2e_redirect_cargo_shapes legacy-config. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 22 +++++ .../src/patch/redirect/replay.rs | 2 +- .../src/patch/redirect/takeover.rs | 80 +++++++++++++++++-- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 88a34274..84772372 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -5,6 +5,8 @@ //! * `multi_version` — two patched versions of one crate (`cfg-if` 1.0.4 and //! a renamed `cfg-if-legacy` at 0.1.10): each declaration is pinned to its //! own version's registry, and removing both purls restores every byte. +//! * `legacy_config` — an existing legacy `.cargo/config`: the registry +//! block lands there, and `remove` restores the file byte-for-byte. //! //! Every shape runs the same chain against the real cargo: a baseline build //! with a private CARGO_HOME (network to crates.io for fixture setup only), @@ -572,3 +574,23 @@ async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() }; let _ = run_shape(shape).await; } + +/// Bug H: an existing legacy `.cargo/config` gets the registry block +/// appended; removing the purl must restore its exact bytes. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { + let shape = Shape { + tag: "legacy-config", + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + (".cargo/config", "[net]\nretry = 2\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + }; + let _ = run_shape(shape).await; +} diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 9c603b16..0890e4b8 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -250,7 +250,7 @@ fn safe_rel_path(path: &str) -> bool { /// fragment + newline) is byte-AMBIGUOUS to invert — `"m\n\n" + "F\n"` /// and `"m\n" + "\nF\n"` produce identical files — so the tidy form (the /// one `go mod tidy` itself emits) is chosen. -fn remove_fragment_once(content: &str, fragment: &str) -> String { +pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { let Some(pos) = content.find(fragment) else { return content.to_string(); }; diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 03e57408..5dc70d61 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -360,12 +360,12 @@ pub async fn revert_cargo_redirect_purl( out.reverted_files.push(edit.path.clone()); continue; } - let mut trimmed = content.replacen(block, "", 1); - // Collapse the blank separator the rewrite inserted. - while trimmed.contains("\n\n\n") { - trimmed = trimmed.replace("\n\n\n", "\n\n"); - } - let trimmed = trimmed.trim_start_matches('\n').to_string(); + // The block leaves with the blank separator the rewrite put + // before it, so an appended block leaves the user's config + // ending exactly as it did — and no other spacing of the + // user's is touched (the old triple-newline collapse + // rewrote any blank run anywhere in the file). + let trimmed = super::replay::remove_fragment_once(&content, block); if trimmed.trim().is_empty() { staged.insert(edit.path.clone(), None); } else { @@ -3990,6 +3990,74 @@ mod tests { assert!(state.edits.is_empty(), "edits dropped"); } + /// Bug H: removing the block the redirect APPENDED to an existing + /// config (the legacy `.cargo/config` here) restores the user's bytes — + /// it left a trailing blank line (`[net]\nretry = 2\n\n`) — and never + /// touches blank runs of the user's own elsewhere in the file. + #[tokio::test] + async fn appended_registry_block_revert_restores_the_config_bytes() { + for user_cfg in [ + "[net]\nretry = 2\n", + "[net]\n\n\n\nretry = 2\n", + "# a comment\n\n[http]\ntimeout = 5\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lock = format!("version = 4\n\n{}\n", pristine_lock_block()); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), pristine_toml()); + files.insert("Cargo.lock".into(), lock.clone()); + files.insert(".cargo/config".into(), user_cfg.to_string()); + let dep: crate::patch::redirect::DepOverride = + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", + "token": "tok", "patchUuid": UUID, + "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", "indexUrl": INDEX, + "identifiers": { + "name": "cfg-if", "version": "1.0.4", + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("revert succeeds"); + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config")) + .await + .unwrap(), + user_cfg + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + pristine_toml() + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + } + } + /// The socket block was already hand-removed (the config now holds only /// user content): skip it, byte-untouched, and still succeed. #[tokio::test] From 113a894e0738163af37199b9addf1ed63d6a65cc Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:38:00 -0400 Subject: [PATCH 03/21] fix(redirect): pin cargo workspace members' own declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted mode pinned only the root Cargo.toml. In a workspace whose member declares the patched crate itself (`cfg-if = "1.0.4"` beside siblings that inherit `[workspace.dependencies]`), the member stayed on crates.io while Cargo.lock was repointed at the patch registry, so `cargo fetch --locked` failed — and the dep was still reported redirected and attested. `scan`/`get --mode hosted` now read every workspace member manifest (`[workspace] members` globs minus `exclude`, plus in-root path dependencies) and the rewriter plans them all in the dep's single transaction: each direct declaration is pinned, `workspace = true` inheritors resolve against the root's entry, and a member that cannot be pinned refuses the dep everywhere. `remove` already reverts edits by path; its registry-block reference probe now covers member manifests too. Golden: cargo/cargo/workspace-member (the depscan TS twin is handed no member manifests and lags; list it in TS_LAGGING on the next submodule bump). Real-cargo regression: e2e_redirect_cargo_shapes workspace-direct-member. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 13 + .../tests/e2e_redirect_cargo_shapes.rs | 39 +++ .../src/patch/redirect/mod.rs | 309 ++++++++++++++--- .../src/patch/redirect/takeover.rs | 15 +- .../src/utils/cargo_workspace.rs | 321 ++++++++++++++++++ crates/socket-patch-core/src/utils/mod.rs | 1 + .../workspace-member/expected-edits.json | 33 ++ .../workspace-member/expected-warnings.json | 1 + .../expected/.cargo/config.toml | 2 + .../workspace-member/expected/Cargo.lock | 23 ++ .../workspace-member/expected/Cargo.toml | 5 + .../workspace-member/expected/b/Cargo.toml | 7 + .../cargo/workspace-member/input/Cargo.lock | 23 ++ .../cargo/workspace-member/input/Cargo.toml | 5 + .../cargo/workspace-member/input/a/Cargo.toml | 7 + .../cargo/workspace-member/input/b/Cargo.toml | 7 + .../cargo/workspace-member/overrides.json | 22 ++ .../vex-discover-golden/redirect-cargo.json | 55 +++ 18 files changed, 831 insertions(+), 57 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/cargo_workspace.rs create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index d0e3a1df..4c36a192 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1709,6 +1709,19 @@ pub(crate) async fn run_redirect_selected( } } + // Cargo workspace members (and in-root path dependencies) declare + // dependencies of their own: a member's direct `cfg-if = "1"` must + // be pinned alongside the root's, or the redirected lock entry is + // unsatisfiable. Keyed `/Cargo.toml` for the cargo rewriter. + if files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") + { + for rel in socket_patch_core::utils::cargo_workspace::member_manifests(&common.cwd) { + if let Ok(content) = read_regular_to_string(&common.cwd.join(&rel)).await { + files.insert(rel, content); + } + } + } + if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { for path in paths { if let Some(script_path) = diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 84772372..259fba07 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -7,6 +7,9 @@ //! own version's registry, and removing both purls restores every byte. //! * `legacy_config` — an existing legacy `.cargo/config`: the registry //! block lands there, and `remove` restores the file byte-for-byte. +//! * `workspace_direct_member` — a virtual workspace whose root pins +//! `[workspace.dependencies]`, one member inheriting and one declaring the +//! crate itself: both members build against the patched copy. //! //! Every shape runs the same chain against the real cargo: a baseline build //! with a private CARGO_HOME (network to crates.io for fixture setup only), @@ -594,3 +597,39 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { }; let _ = run_shape(shape).await; } + +/// Bug F: a workspace member's own declaration must be pinned too. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_workspace_member_declaration_is_pinned() { + let member = |name: &str, dep: &str| { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2018\"\n\n\ + [dependencies]\n{dep}\n" + ) + }; + let oracle = "pub fn marker() -> u32 { cfg_if::socket_patched() }\n".to_string(); + let shape = Shape { + tag: "workspace-direct-member", + files: vec![ + ( + "Cargo.toml", + "[workspace]\nmembers = [\"inherits\", \"direct\"]\n\n\ + [workspace.dependencies]\ncfg-if = \"1.0.4\"\n" + .to_string(), + ), + ( + "inherits/Cargo.toml", + member("inherits", "cfg-if = { workspace = true }"), + ), + ("inherits/src/lib.rs", String::new()), + ("direct/Cargo.toml", member("direct", "cfg-if = \"1.0.4\"")), + ("direct/src/lib.rs", String::new()), + ], + patches: vec![CFG_IF_1], + oracle: vec![ + ("inherits/src/lib.rs", oracle.clone()), + ("direct/src/lib.rs", oracle), + ], + }; + let _ = run_shape(shape).await; +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d24d60c2..f107952c 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -733,7 +733,23 @@ fn rewrite_cargo( if cargo.is_empty() { return; } - let mut cargo_toml = files.get("Cargo.toml").cloned(); + // The root manifest first, then every workspace-member manifest the + // caller supplied (`/Cargo.toml`): a member's own declaration of the + // crate resolves exactly like the root's, so it must be pinned too, or + // the lock's repointed entry is unsatisfiable (`--locked` fails) while + // the dep is reported redirected. + let mut manifests: Vec<(String, String)> = files + .iter() + .filter(|(k, _)| k.as_str() == "Cargo.toml") + .chain( + files + .iter() + .filter(|(k, _)| is_cargo_member_manifest_key(k)), + ) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let mut changed_manifests: std::collections::BTreeSet = + std::collections::BTreeSet::new(); let mut cargo_lock = files.get("Cargo.lock").cloned(); // Cargo reads the LEGACY extensionless `.cargo/config` in preference to // `config.toml` when both exist (it warns about the duplicate), so a @@ -747,7 +763,7 @@ fn rewrite_cargo( ".cargo/config.toml" }; let mut cargo_config = files.get(cargo_config_key).cloned().unwrap_or_default(); - let (mut toml_changed, mut lock_changed, mut config_changed) = (false, false, false); + let (mut lock_changed, mut config_changed) = (false, false); for dep in &cargo { let Some(ov) = registry_override_of_kind(dep, "cargo-sparse") else { @@ -816,7 +832,7 @@ fn rewrite_cargo( // 1. Plan the Cargo.toml pin FIRST — it is the gate for everything // else. Without a manifest pin nothing forces resolution through the // managed registry, so no other file may be touched for this dep. - let Some(toml_text) = cargo_toml.as_ref() else { + if !manifests.iter().any(|(k, _)| k == "Cargo.toml") { result.warnings.push(RewriteWarning { code: "redirect_cargo_toml_dep_not_found".into(), detail: format!( @@ -825,35 +841,56 @@ fn rewrite_cargo( ), }); continue; - }; + } let other_versions = cargo_lock_other_versions(cargo_lock.as_deref(), &dep.name, &dep.version); - let toml_plan = - match plan_cargo_toml(toml_text, &dep.name, &dep.version, &other_versions, ®) { - Ok(plan) => plan, - Err(CargoTomlPlanError::NotFound) => { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_not_found".into(), - detail: format!( - "no [dependencies] entry for {} in Cargo.toml; dependency skipped \ - (nothing rewritten)", - dep.name - ), - }); - continue; + // The root manifest's `[workspace.dependencies]` verdicts feed its + // members' `workspace = true` inheritors. + let mut root_workspace: BTreeMap = BTreeMap::new(); + let mut toml_plans: Vec<(usize, CargoTomlPlan)> = Vec::new(); + let mut refused: Option<(String, String)> = None; + for (i, (path, text)) in manifests.iter().enumerate() { + match plan_cargo_toml( + text, + path, + &dep.name, + &dep.version, + &other_versions, + ®, + &root_workspace, + ) { + Ok(plan) => { + if path == "Cargo.toml" { + root_workspace = plan.workspace.clone(); + } + if plan.found { + toml_plans.push((i, plan)); + } } - Err(CargoTomlPlanError::Refused(reason)) => { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_unrewritable".into(), - detail: format!( - "{} in Cargo.toml cannot be pinned ({reason}); dependency skipped \ - (nothing rewritten)", - dep.name - ), - }); - continue; + Err(reason) => { + refused = Some((path.clone(), reason)); + break; } - }; + } + } + if let Some((path, reason)) = refused { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: format!( + "{} in {path} cannot be pinned ({reason}); dependency skipped \ + (nothing rewritten)", + dep.name + ), + }); + continue; + } + if toml_plans.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_not_found".into(), + detail: cargo_not_declared_detail(&dep.name, manifests.len()), + }); + continue; + } // 2. Plan the Cargo.lock repoint. A lock that exists but has no // [[package]] for the dep means the project does not actually resolve @@ -911,10 +948,12 @@ fn rewrite_cargo( result.edits.push(plan.edit); config_changed = true; } - if toml_plan.changed { - cargo_toml = Some(toml_plan.content); - result.edits.extend(toml_plan.edits); - toml_changed = true; + for (i, plan) in toml_plans { + if plan.changed { + changed_manifests.insert(manifests[i].0.clone()); + manifests[i].1 = plan.content; + result.edits.extend(plan.edits); + } } match lock_commit { LockCommit::Write(content, edits) => { @@ -927,9 +966,9 @@ fn rewrite_cargo( result.confirmed_cargo_uuids.insert(dep.patch_uuid.clone()); } - if toml_changed { - if let Some(t) = cargo_toml { - result.files.insert("Cargo.toml".into(), t); + for (path, text) in manifests { + if changed_manifests.contains(&path) { + result.files.insert(path, text); } } if lock_changed { @@ -942,6 +981,36 @@ fn rewrite_cargo( } } +/// A workspace-member manifest key the caller supplied: `/Cargo.toml`, +/// a plain repo-relative path (never absolute, never `..`, never under the +/// ledger's `.socket/` or a build `target/`). +fn is_cargo_member_manifest_key(key: &str) -> bool { + let Some(dir) = key.strip_suffix("/Cargo.toml") else { + return false; + }; + !dir.is_empty() + && !key.starts_with('/') + && !key.contains('\\') + && !key.contains(':') + && dir + .split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && seg != ".socket") +} + +/// The not-declared warning for a crate no manifest names at the patched +/// version. +fn cargo_not_declared_detail(crate_name: &str, manifests: usize) -> String { + let scope = if manifests > 1 { + format!("any of the {manifests} workspace manifests") + } else { + "Cargo.toml".to_string() + }; + format!( + "no [dependencies] entry for {crate_name} in {scope}; dependency skipped \ + (nothing rewritten)" + ) +} + /// Sparse index URLs land verbatim inside quoted TOML strings in both /// `.cargo/config.toml` and `Cargo.lock` — refuse anything that could break /// out of the string (quote, backslash escape, control chars) or that is not @@ -1221,15 +1290,13 @@ struct CargoTomlPlan { /// `false` when every occurrence already carried our registry (idempotent /// re-run) — the pin is in place, nothing to write. changed: bool, -} - -enum CargoTomlPlanError { - /// The crate is not declared anywhere in this manifest (rename-aware: - /// a key that matches but has `package = ""` is NOT the crate). - NotFound, - /// At least one occurrence exists that cannot be pinned to the managed - /// registry — the whole dep must be skipped. - Refused(String), + /// Whether this manifest declares the crate at the patched version at + /// all (rename-aware: a key that matches but has `package = ""` + /// is NOT the crate). `false` plans nothing. + found: bool, + /// This manifest's `[workspace.dependencies]` verdicts, per key — what + /// its members' `workspace = true` inheritors resolve against. + workspace: BTreeMap, } /// How one occurrence of the dep will be handled. @@ -1353,13 +1420,18 @@ fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str versions } +/// `Err` carries the refusal reason: an occurrence exists that cannot be +/// pinned to the managed registry, so the whole dep must be skipped. +/// `inherited` is the workspace root's verdicts when planning a member. fn plan_cargo_toml( content: &str, + path: &str, crate_name: &str, version: &str, other_versions: &[String], reg: &str, -) -> Result { + inherited: &BTreeMap, +) -> Result { let lines: Vec<&str> = content.split('\n').collect(); let header_re: &Regex = &CARGO_TOML_HEADER_RE; let package_re: &Regex = &CARGO_TOML_PACKAGE_RE; @@ -1699,8 +1771,15 @@ fn plan_cargo_toml( } } + let not_found = |ws_entries: BTreeMap| CargoTomlPlan { + content: content.to_string(), + edits: Vec::new(), + changed: false, + found: false, + workspace: ws_entries, + }; if pending.is_empty() { - return Err(CargoTomlPlanError::NotFound); + return Ok(not_found(ws_entries)); } // Resolve: any refusal (including an unsatisfiable `workspace = true` // inheritor) refuses the WHOLE dep — no partial pin is ever applied. @@ -1708,26 +1787,24 @@ fn plan_cargo_toml( for p in pending { match p { Pending::Action(a) => actions.push(a), - Pending::NeedsWorkspacePin(key) => match ws_entries.get(&key) { + Pending::NeedsWorkspacePin(key) => match ws_entries.get(&key).or(inherited.get(&key)) { Some(CargoWorkspaceEntry::Pinned) => { actions.push(CargoTomlAction::InheritsWorkspace); } // Inherits another version of the crate: not this dep. Some(CargoWorkspaceEntry::OtherVersion) => {} None => { - return Err(CargoTomlPlanError::Refused( - "inherits from [workspace.dependencies] with no rewritable entry \ - in this manifest" - .to_string(), - )); + return Err("inherits from [workspace.dependencies] with no rewritable \ + entry for it" + .to_string()); } }, - Pending::Refuse(reason) => return Err(CargoTomlPlanError::Refused(reason)), + Pending::Refuse(reason) => return Err(reason), } } // Every occurrence named another version (inheritors included). if actions.is_empty() { - return Err(CargoTomlPlanError::NotFound); + return Ok(not_found(ws_entries)); } // Apply bottom-up so line indices stay valid; record edits top-down. @@ -1746,7 +1823,7 @@ fn plan_cargo_toml( match action { CargoTomlAction::ReplaceLine { new_text, .. } => { edits.push(FileEdit { - path: "Cargo.toml".into(), + path: path.into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), @@ -1756,7 +1833,7 @@ fn plan_cargo_toml( } CargoTomlAction::InsertAfterHeader { inserted, .. } => { edits.push(FileEdit { - path: "Cargo.toml".into(), + path: path.into(), kind: "redirect_cargo_toml_dep".into(), action: "rewritten".into(), key: Some(crate_name.into()), @@ -1783,6 +1860,8 @@ fn plan_cargo_toml( content: new_lines.join("\n"), edits, changed, + found: true, + workspace: ws_entries, }) } @@ -8987,6 +9066,124 @@ mod tests { assert!(r.warnings.is_empty(), "{:?}", r.warnings); } + /// A virtual workspace: the root pins `[workspace.dependencies]`, member + /// `a` inherits, member `b` declares serde itself. + fn cargo_workspace_files(b_manifest: &str) -> BTreeMap { + let mut files = cargo_files( + "[workspace]\nmembers = [\"a\", \"b\"]\n\n\ + [workspace.dependencies]\nserde = \"1.0.190\"\n", + ); + files.insert( + "a/Cargo.toml".to_string(), + "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde.workspace = true\n" + .to_string(), + ); + files.insert("b/Cargo.toml".to_string(), b_manifest.to_string()); + files + } + + /// Bug F: only the root's `[workspace.dependencies]` was pinned; member + /// `b`'s own `serde = "1.0.190"` stayed on crates.io, so `--locked` + /// failed against the repointed lock while the dep was reported + /// redirected. Every member manifest the caller supplies is planned in + /// the same transaction; inheritors are satisfied by the root's pin. + #[test] + fn cargo_workspace_member_direct_declaration_is_pinned() { + let files = cargo_workspace_files( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let pin = format!( + "serde = {{ version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + ); + assert!(r.files["Cargo.toml"].contains(&pin), "{:?}", r.files); + assert!(r.files["b/Cargo.toml"].contains(&pin), "{:?}", r.files); + assert!( + !r.files.contains_key("a/Cargo.toml"), + "the inheriting member needs no edit" + ); + assert!(r + .edits + .iter() + .any(|e| e.path == "b/Cargo.toml" && e.kind == "redirect_cargo_toml_dep")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// A member that cannot be pinned (a path dependency here) refuses the + /// WHOLE dep: the root stays untouched too. + #[test] + fn cargo_workspace_member_refusal_refuses_every_manifest() { + let files = cargo_workspace_files( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = { path = \"../serde\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!( + r.warnings[0].detail.contains("b/Cargo.toml"), + "{:?}", + r.warnings + ); + } + + /// Only a member declares the crate (the root has no workspace entry): + /// that member is pinned, and a member inheriting an entry the root + /// does not have refuses. + #[test] + fn cargo_member_only_declaration_and_unsatisfied_inheritor() { + let mut files = cargo_files("[workspace]\nmembers = [\"b\"]\n"); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert_eq!( + r.files.keys().map(String::as_str).collect::>(), + vec![".cargo/config.toml", "Cargo.lock", "b/Cargo.toml"] + ); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = { workspace = true }\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + } + + /// Manifest keys outside a plain repo-relative `/Cargo.toml` are + /// never treated as members. + #[test] + fn cargo_member_manifest_keys() { + for ok in ["a/Cargo.toml", "crates/x-y/Cargo.toml"] { + assert!(is_cargo_member_manifest_key(ok), "{ok}"); + } + for bad in [ + "Cargo.toml", + "/abs/Cargo.toml", + "../up/Cargo.toml", + "a/../b/Cargo.toml", + "./a/Cargo.toml", + ".socket/vendor/cargo/x/Cargo.toml", + "a//Cargo.toml", + "a/Cargo.toml.orig", + ] { + assert!(!is_cargo_member_manifest_key(bad), "{bad}"); + } + } + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 5dc70d61..da6098de 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -273,6 +273,19 @@ pub async fn revert_cargo_redirect_purl( .map(|(i, _)| i) .collect(); + // Every manifest the ledger ever pinned (workspace members included), + // plus the lock: where a registry block can still be referenced from. + let mut probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; + for e in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_toml_dep") + { + if !probes.contains(&e.path) { + probes.push(e.path.clone()); + } + } + let mut out = RedirectRevert::default(); let mut staged: Staged = Staged::new(); // Newest-first: the hosted flow appends edits, so reverse index order @@ -337,7 +350,7 @@ pub async fn revert_cargo_redirect_purl( .map(str::to_string) .unwrap_or_default(); let mut referenced = false; - for probe in ["Cargo.toml", "Cargo.lock"] { + for probe in &probes { if let Some(text) = staged_read(&staged, project_root, probe).await? { if (!reg.is_empty() && text.contains(reg)) || (!index.is_empty() && text.contains(&index)) diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs new file mode 100644 index 00000000..4d6b6919 --- /dev/null +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -0,0 +1,321 @@ +//! The manifests of a cargo workspace beside its root `Cargo.toml`: every +//! `[workspace] members` glob (minus `exclude`) plus every path dependency +//! reachable from them, as repo-relative `/Cargo.toml` keys. +//! +//! The hosted cargo rewriter pins a patched crate in EVERY manifest that +//! declares it — a member's own `cfg-if = "1"` resolves exactly like the +//! root's, so leaving it unpinned makes the repointed Cargo.lock entry +//! unsatisfiable. Only manifests inside the project root are returned: a +//! file outside it is not the project's to rewrite. + +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +use toml_edit::{DocumentMut, Item, Table}; + +/// Upper bound on discovered manifests — a runaway glob (or a hostile tree) +/// must not turn one scan into an unbounded walk. +const MAX_MANIFESTS: usize = 4096; + +/// Repo-relative `/Cargo.toml` keys of the workspace members and +/// in-root path dependencies of the project at `root`, sorted. Empty when +/// `root/Cargo.toml` is absent or unparseable. +pub fn member_manifests(root: &Path) -> Vec { + let Some(doc) = read_manifest(&root.join("Cargo.toml")) else { + return Vec::new(); + }; + let mut dirs: BTreeSet = BTreeSet::new(); + let mut queue: Vec<(String, DocumentMut)> = Vec::new(); + + if let Some(ws) = doc.get("workspace").and_then(Item::as_table_like) { + let patterns = |key: &str| -> Vec { + ws.get(key) + .and_then(Item::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let excluded: BTreeSet = patterns("exclude") + .iter() + .flat_map(|p| expand_glob(root, p)) + .collect(); + for pattern in patterns("members") { + for dir in expand_glob(root, &pattern) { + if !excluded.contains(&dir) { + enqueue(root, dir, &mut dirs, &mut queue); + } + } + } + } + for dep_dir in path_dependencies(&doc) { + if let Some(dir) = normalize_rel("", &dep_dir) { + enqueue(root, dir, &mut dirs, &mut queue); + } + } + while let Some((dir, doc)) = queue.pop() { + for dep_dir in path_dependencies(&doc) { + if let Some(dep) = normalize_rel(&dir, &dep_dir) { + enqueue(root, dep, &mut dirs, &mut queue); + } + } + } + dirs.into_iter() + .map(|dir| format!("{dir}/Cargo.toml")) + .collect() +} + +fn read_manifest(path: &Path) -> Option { + if !path.is_file() { + return None; + } + std::fs::read_to_string(path).ok()?.parse().ok() +} + +fn enqueue( + root: &Path, + dir: String, + dirs: &mut BTreeSet, + queue: &mut Vec<(String, DocumentMut)>, +) { + if dir.is_empty() || dirs.len() >= MAX_MANIFESTS || dirs.contains(&dir) { + return; + } + let Some(doc) = read_manifest(&root.join(&dir).join("Cargo.toml")) else { + return; + }; + dirs.insert(dir.clone()); + queue.push((dir, doc)); +} + +/// Every `path = "…"` of a dependency declaration: the dependency tables +/// (plain and per-target), `[workspace.dependencies]` and `[patch.*]`. +fn path_dependencies(doc: &DocumentMut) -> Vec { + fn dep_paths(table: Option<&Item>, out: &mut Vec) { + let Some(table) = table.and_then(Item::as_table_like) else { + return; + }; + for (_, entry) in table.iter() { + let path = match entry { + Item::Table(t) => t.get("path").and_then(Item::as_str), + Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get("path")) + .and_then(|p| p.as_str()), + _ => None, + }; + if let Some(path) = path { + out.push(path.to_string()); + } + } + } + fn dep_tables(table: &Table, out: &mut Vec) { + for kind in ["dependencies", "dev-dependencies", "build-dependencies"] { + dep_paths(table.get(kind), out); + } + } + let mut out = Vec::new(); + dep_tables(doc.as_table(), &mut out); + if let Some(targets) = doc.get("target").and_then(Item::as_table) { + for (_, target) in targets.iter() { + if let Some(target) = target.as_table() { + dep_tables(target, &mut out); + } + } + } + if let Some(ws) = doc.get("workspace").and_then(Item::as_table) { + dep_paths(ws.get("dependencies"), &mut out); + } + if let Some(patch) = doc.get("patch").and_then(Item::as_table) { + for (_, source) in patch.iter() { + dep_paths(Some(source), &mut out); + } + } + out +} + +/// `base/rel` lexically normalized to a repo-relative slash path; `None` +/// when it is absolute or climbs out of the root. +fn normalize_rel(base: &str, rel: &str) -> Option { + let rel = rel.replace('\\', "/"); + if rel.starts_with('/') || Path::new(&rel).is_absolute() { + return None; + } + let mut parts: Vec = base + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + for component in Path::new(&rel).components() { + match component { + Component::Normal(seg) => parts.push(seg.to_str()?.to_string()), + Component::CurDir => {} + Component::ParentDir => { + parts.pop()?; + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(parts.join("/")) +} + +/// Expand a cargo `members` / `exclude` glob (`*`, `?`, `**`) to the +/// repo-relative directories it names. +fn expand_glob(root: &Path, pattern: &str) -> Vec { + let Some(normalized) = normalize_rel("", pattern.trim_end_matches('/')) else { + return Vec::new(); + }; + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + let mut out = Vec::new(); + expand_from(root, PathBuf::new(), &segments, &mut out); + out.sort(); + out.dedup(); + out +} + +fn expand_from(root: &Path, at: PathBuf, rest: &[&str], out: &mut Vec) { + if out.len() >= MAX_MANIFESTS { + return; + } + let Some((seg, tail)) = rest.split_first() else { + out.push(at.to_string_lossy().replace('\\', "/")); + return; + }; + if !seg.contains(['*', '?']) { + let next = at.join(seg); + if root.join(&next).is_dir() { + expand_from(root, next, tail, out); + } + return; + } + let Ok(entries) = std::fs::read_dir(root.join(&at)) else { + return; + }; + let mut children: Vec = entries + .filter_map(Result::ok) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().to_str().map(str::to_string)) + .filter(|name| !name.starts_with('.') && name != "target") + .collect(); + children.sort(); + if *seg == "**" { + expand_from(root, at.clone(), tail, out); + for child in children { + expand_from(root, at.join(child), rest, out); + } + return; + } + for child in children { + if wildcard_match(seg.as_bytes(), child.as_bytes()) { + expand_from(root, at.join(child), tail, out); + } + } +} + +fn wildcard_match(pattern: &[u8], name: &[u8]) -> bool { + match (pattern.first(), name.first()) { + (None, None) => true, + (Some(b'*'), _) => { + wildcard_match(&pattern[1..], name) + || (!name.is_empty() && wildcard_match(pattern, &name[1..])) + } + (Some(b'?'), Some(_)) => wildcard_match(&pattern[1..], &name[1..]), + (Some(p), Some(n)) if p == n => wildcard_match(&pattern[1..], &name[1..]), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + fn pkg(name: &str) -> String { + format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n") + } + + #[test] + fn members_globs_excludes_and_path_dependencies() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + "[workspace]\nmembers = [\"app\", \"crates/*\"]\nexclude = [\"crates/skip\"]\n", + ); + write( + root, + "app/Cargo.toml", + &format!( + "{}[dependencies]\nhelper = {{ path = \"../libs/helper\" }}\n", + pkg("app") + ), + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + write(root, "crates/skip/Cargo.toml", &pkg("skip")); + write(root, "crates/no-manifest/README", ""); + write( + root, + "libs/helper/Cargo.toml", + &format!( + "{}[target.'cfg(unix)'.dependencies]\nleaf = {{ path = \"../leaf\" }}\n\ + outside = {{ path = \"../../../elsewhere\" }}\n", + pkg("helper") + ), + ); + write(root, "libs/leaf/Cargo.toml", &pkg("leaf")); + assert_eq!( + member_manifests(root), + vec![ + "app/Cargo.toml", + "crates/a/Cargo.toml", + "libs/helper/Cargo.toml", + "libs/leaf/Cargo.toml", + ] + ); + } + + #[test] + fn a_package_without_workspace_still_reaches_its_path_dependencies() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + &format!("{}[dependencies.inner]\npath = \"inner\"\n", pkg("root")), + ); + write(root, "inner/Cargo.toml", &pkg("inner")); + assert_eq!(member_manifests(root), vec!["inner/Cargo.toml"]); + } + + #[test] + fn recursive_globs_and_missing_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + assert!(member_manifests(root).is_empty()); + write(root, "Cargo.toml", "[workspace]\nmembers = [\"**/m?\"]\n"); + write(root, "x/y/m1/Cargo.toml", &pkg("m1")); + write(root, "m2/Cargo.toml", &pkg("m2")); + write(root, "target/m3/Cargo.toml", &pkg("m3")); + assert_eq!( + member_manifests(root), + vec!["m2/Cargo.toml", "x/y/m1/Cargo.toml"] + ); + } + + #[test] + fn wildcard_matching() { + assert!(wildcard_match(b"a*c", b"abbc")); + assert!(wildcard_match(b"*", b"")); + assert!(wildcard_match(b"a?c", b"abc")); + assert!(!wildcard_match(b"a?c", b"ac")); + assert!(!wildcard_match(b"a*d", b"abc")); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 840cdbf2..9c57a536 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod cargo_workspace; pub(crate) mod digest; pub mod env_compat; pub mod fs; diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json new file mode 100644 index 00000000..b6f367d7 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-edits.json @@ -0,0 +1,33 @@ +[ + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "b/Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "serde@1.0.190", + "original": "[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000\"", + "new": "[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\nchecksum = \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock new file mode 100644 index 00000000..1532947c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "a" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "b" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml new file mode 100644 index 00000000..a4faf226 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = ["a", "b"] + +[workspace.dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml new file mode 100644 index 00000000..c90450fa --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/expected/b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock new file mode 100644 index 00000000..fa5ee8fd --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "a" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "b" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml new file mode 100644 index 00000000..2cfb1173 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = ["a", "b"] + +[workspace.dependencies] +serde = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml new file mode 100644 index 00000000..0dee9c7e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/a/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "a" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml new file mode 100644 index 00000000..9aedd9ed --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/input/b/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "b" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/workspace-member/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index b5606443..a4633b70 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -521,5 +521,60 @@ "unlocked_pins": [], "elsewhere": [], "live_claims": [] + }, + "redirect/cargo/cargo/workspace-member/expected": { + "refs": [ + { + "purl": "pkg:cargo/serde@1.0.190", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config.toml" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/serde@1.0.190" + } + ] + }, + "redirect/cargo/cargo/workspace-member/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] } } From 11273e4a03cb18e81e751e7e6afe24fb837d558d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:39:27 -0400 Subject: [PATCH 04/21] test(redirect): attest every cargo shape post-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-cargo shape suite now also runs `socket-patch vex` over each fresh checkout (ledger kept, patch server admitted) and requires exactly the shape's patches to be attested — multi-version and workspace-member redirects included. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 259fba07..c17c328d 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -16,8 +16,8 @@ //! patched `.crate`s rebuilt from the ACTUAL crates.io bytes and served by a //! wiremock sparse registry per patch, `scan --mode hosted`, then a FRESH //! checkout (only the committed files travel) where `cargo fetch --locked` -//! and an offline `cargo build --locked` must link each patched-only symbol, -//! and finally `remove ` for every patch, which must leave the project +//! and an offline `cargo build --locked` must link each patched-only symbol +//! and a post-install `vex` must attest exactly the patches, and finally `remove ` for every patch, which must leave the project //! byte-identical to its pre-scan state. //! //! Skips (with a println) when `cargo` is missing or crates.io is @@ -216,7 +216,7 @@ fn router(origin: String, served: Vec) -> impl Fn(&Request) -> ResponseT "purl": s.patch.purl(), "patches": [{ "uuid": s.patch.uuid, "purl": s.patch.purl(), "tier": "free", - "cveIds": [], "ghsaIds": [format!("GHSA-shape-{}", &s.patch.uuid[..4])], + "cveIds": [], "ghsaIds": [format!("GHSA-shape-{}", &s.patch.uuid[..8])], "severity": "high", "title": "cargo shape fixture" }] }) @@ -285,7 +285,7 @@ fn router(origin: String, served: Vec) -> impl Fn(&Request) -> ResponseT "beforeHash": compute_git_sha256_from_bytes(&s.orig), "afterHash": compute_git_sha256_from_bytes(&s.patched), }}, - "vulnerabilities": { format!("GHSA-shape-{}", &uuid[..4]): { + "vulnerabilities": { format!("GHSA-shape-{}", &uuid[..8]): { "cves": [], "summary": "s", "severity": "high", "description": "d" }}, "description": "x", "license": "MIT", "tier": "free" @@ -450,6 +450,9 @@ async fn run_shape(shape: Shape) -> Option<()> { std::fs::create_dir_all(to.parent().unwrap()).unwrap(); std::fs::copy(proj.join(&rel), to).unwrap(); } + if proj.join(".socket").is_dir() { + copy_tree(&proj.join(".socket"), &fresh.join(".socket")); + } let fresh_home = tmp.path().join("fresh-home"); std::fs::create_dir_all(&fresh_home).unwrap(); let fetch = cargo(&fresh, &["fetch", "--locked"], &fresh_home); @@ -470,6 +473,46 @@ async fn run_shape(shape: Shape) -> Option<()> { stderr(&build) ); + // Post-install VEX over the fresh checkout: every patch is attested, + // hash-verified against the extracted (patched) registry sources. + let doc_path = fresh.join("doc.vex.json"); + let fresh_s = fresh.to_str().unwrap().to_string(); + let (code, stdout, err) = run_socket( + &fresh, + &[ + "vex", + "--output", + doc_path.to_str().unwrap(), + "--product", + "pkg:cargo/consumer@0.1.0", + "--patch-server-url", + &uri, + "--cwd", + &fresh_s, + ], + &fresh_home, + ); + assert_eq!( + code, 0, + "{}: vex\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + let mut attested: Vec = doc["statements"] + .as_array() + .unwrap() + .iter() + .flat_map(|st| st["products"].as_array().unwrap().clone()) + .flat_map(|p| p["subcomponents"].as_array().unwrap().clone()) + .map(|c| c["@id"].as_str().unwrap().to_string()) + .collect(); + attested.sort(); + attested.dedup(); + let mut expected: Vec = shape.patches.iter().map(Patch::purl).collect(); + expected.sort(); + assert_eq!(attested, expected, "{}: attested purls: {doc}", shape.tag); + // Rollback: removing every purl restores the pre-scan project exactly. for patch in &shape.patches { let purl = patch.purl(); From 582a5f0069447fb50e26de5efd2ace2b52ad82fd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:45:38 -0400 Subject: [PATCH 05/21] fix(redirect): redirect CRLF cargo projects in hosted mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hosted cargo planner matched LF text, so a Windows checkout with CRLF Cargo.toml / Cargo.lock was refused outright (redirect_cargo_toml_dep_unrewritable / redirect_cargo_lock_pkg_not_found). A manifest, lock or config whose every line break is CRLF is now planned as LF and written back as CRLF, and its recorded edit fragments are stored CRLF so `remove` and rollback find them in the file. Files with mixed endings are left alone and keep refusing where the grammar does not match. The shared fragment remover (used for the appended registry block) inverts CRLF files as LF so it no longer strands a `\r` line. Golden: cargo/cargo/crlf (CRLF manifest, lock and legacy config; the depscan TS twin lags — list it in TS_LAGGING on the next submodule bump). Real-cargo regression: e2e_redirect_cargo_shapes crlf (endings kept, fresh `cargo fetch --locked` + offline build, byte-identical remove). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 59 ++++++++ .../src/patch/redirect/mod.rs | 133 +++++++++++++++++- .../src/patch/redirect/replay.rs | 23 +++ .../src/patch/redirect/takeover.rs | 52 +++++++ .../cargo/cargo/crlf/expected-edits.json | 25 ++++ .../cargo/cargo/crlf/expected-warnings.json | 1 + .../cargo/cargo/crlf/expected/.cargo/config | 5 + .../cargo/cargo/crlf/expected/Cargo.lock | 16 +++ .../cargo/cargo/crlf/expected/Cargo.toml | 8 ++ .../cargo/cargo/crlf/input/.cargo/config | 2 + .../cargo/cargo/crlf/input/Cargo.lock | 16 +++ .../cargo/cargo/crlf/input/Cargo.toml | 8 ++ .../redirect/cargo/cargo/crlf/overrides.json | 22 +++ .../vex-discover-golden/redirect-cargo.json | 55 ++++++++ 14 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index c17c328d..579e5aae 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -7,6 +7,8 @@ //! own version's registry, and removing both purls restores every byte. //! * `legacy_config` — an existing legacy `.cargo/config`: the registry //! block lands there, and `remove` restores the file byte-for-byte. +//! * `crlf` — CRLF `Cargo.toml` + `Cargo.lock`: rewritten with CRLF kept, +//! and restored byte-for-byte. //! * `workspace_direct_member` — a virtual workspace whose root pins //! `[workspace.dependencies]`, one member inheriting and one declaring the //! crate itself: both members build against the patched copy. @@ -75,6 +77,9 @@ struct Shape { patches: Vec, /// Written into the fresh checkout before the offline build. oracle: Vec<(&'static str, String)>, + /// Re-encode every `Cargo.toml` and the generated `Cargo.lock` with CRLF + /// line endings (a Windows checkout) before the scan. + crlf: bool, } fn binary() -> PathBuf { @@ -379,6 +384,23 @@ async fn run_shape(shape: Shape) -> Option<()> { return None; } let _ = std::fs::remove_dir_all(proj.join("target")); + if shape.crlf { + for rel in snapshot(&proj).keys() { + if rel.ends_with("Cargo.toml") || rel == "Cargo.lock" { + let path = proj.join(rel); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replace('\n', "\r\n")).unwrap(); + } + } + let rebuilt = cargo(&proj, &["build", "-q", "--locked"], &home); + assert!( + rebuilt.status.success(), + "{}: the CRLF baseline must build:\n{}", + shape.tag, + stderr(&rebuilt) + ); + let _ = std::fs::remove_dir_all(proj.join("target")); + } let before = snapshot(&proj); let mut served = Vec::new(); @@ -443,6 +465,20 @@ async fn run_shape(shape: Shape) -> Option<()> { shape.tag ); + if shape.crlf { + for (rel, bytes) in snapshot(&proj) { + if rel.ends_with("Cargo.toml") || rel == "Cargo.lock" { + let text = String::from_utf8(bytes).unwrap(); + assert_eq!( + text.matches("\r\n").count(), + text.matches('\n').count(), + "{}: {rel} must keep CRLF endings:\n{text}", + shape.tag + ); + } + } + } + // Fresh checkout: only committed files travel; an EMPTY CARGO_HOME. let fresh = tmp.path().join("fresh"); for (rel, _) in snapshot(&proj) { @@ -617,6 +653,7 @@ async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() "fn main() { println!(\"{}\", cfg_if::socket_patched() + cfg_if_legacy::socket_patched()); }\n" .to_string(), )], + crlf: false, }; let _ = run_shape(shape).await; } @@ -637,6 +674,7 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { "src/main.rs", "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], + crlf: false, }; let _ = run_shape(shape).await; } @@ -673,6 +711,27 @@ async fn cargo_hosted_workspace_member_declaration_is_pinned() { ("inherits/src/lib.rs", oracle.clone()), ("direct/src/lib.rs", oracle), ], + crlf: false, + }; + let _ = run_shape(shape).await; +} + +/// Bug K: a CRLF checkout is redirected (it was refused) with its line +/// endings kept, and removed byte-for-byte. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_crlf_project_keeps_its_line_endings() { + let shape = Shape { + tag: "crlf", + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: true, }; let _ = run_shape(shape).await; } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f107952c..97bad030 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -748,9 +748,30 @@ fn rewrite_cargo( ) .map(|(k, v)| (k.clone(), v.clone())) .collect(); + // Every planner below matches LF text. A file whose every line ends in + // CRLF (a Windows checkout) is planned as LF and written back — edit + // fragments included, so `remove` finds them — as CRLF. Mixed endings + // stay as they are (and refuse where the LF grammar does not match). + let mut crlf_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut to_lf = |path: &str, text: String| -> String { + match crlf_to_lf(&text) { + Some(lf) => { + crlf_paths.insert(path.to_string()); + lf + } + None => text, + } + }; + for (path, text) in manifests.iter_mut() { + *text = to_lf(path, std::mem::take(text)); + } + let edits_before = result.edits.len(); let mut changed_manifests: std::collections::BTreeSet = std::collections::BTreeSet::new(); - let mut cargo_lock = files.get("Cargo.lock").cloned(); + let mut cargo_lock = files + .get("Cargo.lock") + .cloned() + .map(|t| to_lf("Cargo.lock", t)); // Cargo reads the LEGACY extensionless `.cargo/config` in preference to // `config.toml` when both exist (it warns about the duplicate), so a // managed `[registries.…]` block written to `config.toml` there is @@ -762,7 +783,11 @@ fn rewrite_cargo( } else { ".cargo/config.toml" }; - let mut cargo_config = files.get(cargo_config_key).cloned().unwrap_or_default(); + let mut cargo_config = files + .get(cargo_config_key) + .cloned() + .map(|t| to_lf(cargo_config_key, t)) + .unwrap_or_default(); let (mut lock_changed, mut config_changed) = (false, false); for dep in &cargo { @@ -966,21 +991,50 @@ fn rewrite_cargo( result.confirmed_cargo_uuids.insert(dep.patch_uuid.clone()); } + let restore = |path: &str, text: String| -> String { + if crlf_paths.contains(path) { + text.replace('\n', "\r\n") + } else { + text + } + }; + for edit in &mut result.edits[edits_before..] { + if crlf_paths.contains(&edit.path) { + for fragment in [&mut edit.original, &mut edit.new] { + if let Some(Value::String(text)) = fragment { + *text = text.replace('\n', "\r\n"); + } + } + } + } for (path, text) in manifests { if changed_manifests.contains(&path) { + let text = restore(&path, text); result.files.insert(path, text); } } if lock_changed { if let Some(l) = cargo_lock { - result.files.insert("Cargo.lock".into(), l); + result + .files + .insert("Cargo.lock".into(), restore("Cargo.lock", l)); } } if config_changed { - result.files.insert(cargo_config_key.into(), cargo_config); + result.files.insert( + cargo_config_key.into(), + restore(cargo_config_key, cargo_config), + ); } } +/// `text` with every CRLF turned into LF, when every line break in it is a +/// CRLF (and there is at least one); `None` for LF-only or mixed text. +fn crlf_to_lf(text: &str) -> Option { + let crlf = text.matches("\r\n").count(); + (crlf > 0 && crlf == text.matches('\n').count()).then(|| text.replace("\r\n", "\n")) +} + /// A workspace-member manifest key the caller supplied: `/Cargo.toml`, /// a plain repo-relative path (never absolute, never `..`, never under the /// ledger's `.socket/` or a build `target/`). @@ -9184,6 +9238,77 @@ mod tests { } } + /// Bug K: CRLF manifests and locks (Windows checkouts) were refused — + /// every planner matched LF text only. A CRLF-only file is now planned + /// as LF and written back CRLF, recorded fragments included, and a + /// re-run over the output is a silent no-op. + #[test] + fn cargo_crlf_files_are_rewritten_with_crlf_kept() { + let lf = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let crlf: BTreeMap = lf + .iter() + .map(|(k, v)| (k.clone(), v.replace('\n', "\r\n"))) + .collect(); + let want = rewrite_registry_redirect(&lf, &[cargo_sparse_override()]); + let got = rewrite_registry_redirect(&crlf, &[cargo_sparse_override()]); + assert!(got.warnings.is_empty(), "{:?}", got.warnings); + assert!(got.confirmed_cargo_uuids.contains(CARGO_UUID)); + for key in ["Cargo.toml", "Cargo.lock"] { + assert_eq!( + got.files[key], + want.files[key].replace('\n', "\r\n"), + "{key}" + ); + } + assert_eq!( + got.files[".cargo/config.toml"], want.files[".cargo/config.toml"], + "a created config stays LF" + ); + let lock_edit = got + .edits + .iter() + .find(|e| e.kind == "redirect_cargo_lock_entry") + .unwrap(); + let (Some(Value::String(orig)), Some(Value::String(new))) = + (&lock_edit.original, &lock_edit.new) + else { + panic!("lock edit fragments"); + }; + assert!(crlf["Cargo.lock"].contains(orig.as_str())); + assert!(got.files["Cargo.lock"].contains(new.as_str())); + + let mut again = crlf.clone(); + again.extend(got.files.clone()); + let rerun = rewrite_registry_redirect(&again, &[cargo_sparse_override()]); + assert!( + rerun.files.is_empty() && rerun.edits.is_empty() && rerun.warnings.is_empty(), + "{:?} {:?}", + rerun.files.keys(), + rerun.warnings + ); + } + + /// Mixed line endings are not normalized: the LF grammar still refuses + /// what it cannot match, writing nothing. + #[test] + fn cargo_mixed_line_endings_still_refuse() { + let mut files = cargo_files( + "[package]\r\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\r\nserde = \"1.0.190\"\r\n", + ); + files.insert( + "Cargo.lock".into(), + files["Cargo.lock"].replace('\n', "\r\n"), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + } + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 0890e4b8..84aa5320 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -251,6 +251,17 @@ fn safe_rel_path(path: &str) -> bool { /// and `"m\n" + "\nF\n"` produce identical files — so the tidy form (the /// one `go mod tidy` itself emits) is chosen. pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { + // A CRLF file (its fragments recorded CRLF too) is inverted as LF and + // written back CRLF, so the separator bookkeeping below sees real line + // breaks instead of stranding a `\r` line. + let crlf = content.matches("\r\n").count(); + if crlf > 0 && crlf == content.matches('\n').count() && content.contains(fragment) { + return remove_fragment_once( + &content.replace("\r\n", "\n"), + &fragment.replace("\r\n", "\n"), + ) + .replace('\n', "\r\n"); + } let Some(pos) = content.find(fragment) else { return content.to_string(); }; @@ -2838,6 +2849,18 @@ mod tests { // ---------- remove_fragment_once unit pins ---------- + #[test] + fn remove_fragment_once_keeps_crlf_separators_straight() { + assert_eq!( + remove_fragment_once("[net]\r\nretry = 2\r\n\r\nF\r\nG\r\n", "F\r\nG\r\n"), + "[net]\r\nretry = 2\r\n" + ); + assert_eq!( + remove_fragment_once("a\r\n\r\nF\r\n\r\nb\r\n", "F\r\n"), + "a\r\n\r\nb\r\n" + ); + } + #[test] fn remove_fragment_once_absent_fragment_is_identity() { // Defensive edge: callers check contains() first, so the not-found diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index da6098de..586b9605 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -4013,6 +4013,7 @@ mod tests { "[net]\nretry = 2\n", "[net]\n\n\n\nretry = 2\n", "# a comment\n\n[http]\ntimeout = 5\n", + "[net]\r\nretry = 2\r\n", ] { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); @@ -4071,6 +4072,57 @@ mod tests { } } + /// Bug K: a CRLF project (manifest, lock and legacy config) is + /// redirected with CRLF kept and `remove` restores every byte. + #[tokio::test] + async fn crlf_project_reverts_byte_for_byte() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let crlf = |s: &str| s.replace('\n', "\r\n"); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), crlf(&pristine_toml())); + files.insert( + "Cargo.lock".into(), + crlf(&format!("version = 4\n\n{}\n", pristine_lock_block())), + ); + files.insert(".cargo/config".into(), crlf("[net]\nretry = 2\n")); + let dep: crate::patch::redirect::DepOverride = serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", + "token": "tok", "patchUuid": UUID, + "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", + "registryOverride": { + "kind": "cargo-sparse", "indexUrl": INDEX, + "identifiers": { + "name": "cfg-if", "version": "1.0.4", + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); + assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("revert succeeds"); + for (rel, content) in &files { + assert_eq!( + &tokio::fs::read_to_string(root.join(rel)).await.unwrap(), + content, + "{rel}" + ); + } + } + /// The socket block was already hand-removed (the config now holds only /// user content): skip it, byte-untouched, and still succeed. #[tokio::test] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json new file mode 100644 index 00000000..7679392d --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-edits.json @@ -0,0 +1,25 @@ +[ + { + "path": ".cargo/config", + "kind": "redirect_cargo_registry", + "action": "added", + "key": "socket-patch-55555555-5555-5555-5555-555555555555", + "new": "[registries.socket-patch-55555555-5555-5555-5555-555555555555]\r\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\r\n" + }, + { + "path": "Cargo.toml", + "kind": "redirect_cargo_toml_dep", + "action": "rewritten", + "key": "serde", + "original": "serde = \"1.0.190\"", + "new": "serde = { version = \"1.0.190\", registry = \"socket-patch-55555555-5555-5555-5555-555555555555\" }" + }, + { + "path": "Cargo.lock", + "kind": "redirect_cargo_lock_entry", + "action": "rewritten", + "key": "serde@1.0.190", + "original": "[[package]]\r\nname = \"serde\"\r\nversion = \"1.0.190\"\r\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\r\nchecksum = \"91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000\"", + "new": "[[package]]\r\nname = \"serde\"\r\nversion = \"1.0.190\"\r\nsource = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/\"\r\nchecksum = \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config new file mode 100644 index 00000000..51cf6dc4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/.cargo/config @@ -0,0 +1,5 @@ +[net] +retry = 2 + +[registries.socket-patch-55555555-5555-5555-5555-555555555555] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock new file mode 100644 index 00000000..e77698b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/" +checksum = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml new file mode 100644 index 00000000..bcb9a444 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/expected/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } +anyhow = { version = "1.0", features = ["backtrace"] } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config new file mode 100644 index 00000000..206b4886 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/.cargo/config @@ -0,0 +1,2 @@ +[net] +retry = 2 diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock new file mode 100644 index 00000000..7b725829 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml new file mode 100644 index 00000000..af0b8b13 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +anyhow = { version = "1.0", features = ["backtrace"] } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/crlf/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index a4633b70..3f1b7493 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -120,6 +120,61 @@ } ] }, + "redirect/cargo/cargo/crlf/expected": { + "refs": [ + { + "purl": "pkg:cargo/serde@1.0.190", + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "source_file": "Cargo.lock", + "artifact_rel": null, + "locked_integrity": "Sha256Hex(\"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\")", + "integrity_required": true, + "url": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": ".cargo/config" + }, + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "Cargo.lock" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": ".cargo/config" + }, + { + "uuid": "55555555-5555-5555-5555-555555555555", + "mode": "hosted", + "file": "Cargo.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "55555555-5555-5555-5555-555555555555", + "purl": "pkg:cargo/serde@1.0.190" + } + ] + }, + "redirect/cargo/cargo/crlf/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/multi-version/expected": { "refs": [ { From 082d4223620707521365ac3130340b3bce76d4b9 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 14:49:59 -0400 Subject: [PATCH 06/21] fix(redirect): say why a transitive-only crate is not redirected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted cargo redirect pins a crate with `registry = "…"` on its manifest declaration, which reaches only that declaration: a crate the project gets purely through another dependency cannot be redirected in hosted mode. That stays a refusal — nothing is written, recorded or attested, and a requested VEX fails the run — but the warning said only "no [dependencies] entry for X in Cargo.toml", which reads like a discovery bug. When Cargo.lock resolves the crate at the patched version the warning now says it is a transitive-only dependency, that it was NOT redirected and stays unpatched, and that `scan --mode vendored` (whose `[patch.crates-io]` covers the whole graph) or a direct declaration can patch it. The code (`redirect_cargo_toml_dep_not_found`) is unchanged. Golden: cargo/cargo/transitive-refusal (pins the refusal and its warning code). In-process: the refusal writes nothing, is not attested, and the envelope carries the transitive-only warning. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_redirect.rs | 95 +++++++++++++++++++ .../src/patch/redirect/mod.rs | 74 +++++++++++++-- .../transitive-refusal/expected-edits.json | 1 + .../transitive-refusal/expected-warnings.json | 3 + .../cargo/transitive-refusal/input/Cargo.lock | 25 +++++ .../cargo/transitive-refusal/input/Cargo.toml | 7 ++ .../cargo/transitive-refusal/overrides.json | 22 +++++ .../vex-discover-golden/redirect-cargo.json | 8 ++ 8 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7338c7d4..6dcb1edf 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -3964,6 +3964,101 @@ async fn cargo_granted_but_nothing_pinned_is_not_confirmed_or_attested() { ); } +/// A granted cargo patch for a crate the project reaches only +/// TRANSITIVELY (in Cargo.lock, declared by no manifest) stays a refusal — +/// a manifest `registry` pin cannot reach it — but a loud one: nothing is +/// written, recorded or attested, the requested VEX fails the run, and the +/// warning names the crate transitive-only and points at vendored mode. +#[tokio::test] +#[serial] +async fn cargo_transitive_only_crate_is_refused_loudly_and_not_attested() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.0"; + const CARGO_UUID: &str = "22222222-2222-4222-8222-222222222222"; + 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.0", + &index_url, + &cksum, + "GHSA-carg-tttt-tttt", + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\nlog = \"0.4\"\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("Cargo.lock"), + "version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ee\"\n\n[[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ff\"\ndependencies = [\n \"cfg-if\",\n]\n", + ) + .unwrap(); + write_vendored_crate(tmp.path(), "cfg-if", "1.0.0"); + let toml_before = std::fs::read(tmp.path().join("Cargo.toml")).unwrap(); + let lock_before = std::fs::read(tmp.path().join("Cargo.lock")).unwrap(); + + let vex_path = tmp.path().join("out.vex.json"); + let mut args = redirect_args(tmp.path(), server.uri()); + args.vex = socket_patch_cli::commands::vex::VexEmbedArgs { + vex: Some(vex_path.clone()), + vex_product: Some("pkg:cargo/consumer@0.0.0".to_string()), + ..Default::default() + }; + let code = run(args).await; + + assert_eq!( + std::fs::read(tmp.path().join("Cargo.toml")).unwrap(), + toml_before + ); + assert_eq!( + std::fs::read(tmp.path().join("Cargo.lock")).unwrap(), + lock_before + ); + assert!( + !tmp.path().join(".cargo").exists(), + "no registry block may be written for an unpinnable crate" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "nothing redirected, nothing recorded" + ); + assert!( + !vex_path.exists(), + "a transitive-only crate is never attested" + ); + assert_eq!(code, 1, "the requested attestation must fail the run"); + + // Without --vex the run succeeds, reporting nothing redirected and the + // transitive-only warning in the envelope. + let env = run_redirect_subprocess(tmp.path(), &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{env}"); + let warning = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_cargo_toml_dep_not_found") + .unwrap_or_else(|| panic!("{env}")); + let detail = warning["detail"].as_str().unwrap(); + assert!( + detail.contains("cfg-if@1.0.0 is a transitive-only dependency") + && detail.contains("--mode vendored"), + "{detail}" + ); + assert_eq!( + std::fs::read(tmp.path().join("Cargo.lock")).unwrap(), + lock_before + ); +} + /// AUDIT A2 (green side): the multi-line `[dependencies.]` table form — /// with NO Cargo.lock — is fully pinned: the manifest entry gains a registry /// line, the managed registry block is wired in, and the patch is recorded + diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 97bad030..6012b299 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -912,7 +912,12 @@ fn rewrite_cargo( if toml_plans.is_empty() { result.warnings.push(RewriteWarning { code: "redirect_cargo_toml_dep_not_found".into(), - detail: cargo_not_declared_detail(&dep.name, manifests.len()), + detail: cargo_not_declared_detail( + &dep.name, + &dep.version, + manifests.len(), + cargo_lock.as_deref(), + ), }); continue; } @@ -1052,17 +1057,40 @@ fn is_cargo_member_manifest_key(key: &str) -> bool { } /// The not-declared warning for a crate no manifest names at the patched -/// version. -fn cargo_not_declared_detail(crate_name: &str, manifests: usize) -> String { +/// version. A crate Cargo.lock nonetheless resolves is a TRANSITIVE-only +/// dependency: a `registry = "…"` pin reaches only the declaration it sits +/// on, so hosted mode cannot redirect it at all — say so, and name the mode +/// that can (vendored `[patch.crates-io]` applies to the whole graph). +fn cargo_not_declared_detail( + crate_name: &str, + version: &str, + manifests: usize, + lock: Option<&str>, +) -> String { let scope = if manifests > 1 { format!("any of the {manifests} workspace manifests") } else { "Cargo.toml".to_string() }; - format!( - "no [dependencies] entry for {crate_name} in {scope}; dependency skipped \ - (nothing rewritten)" - ) + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); + let transitive = lock.is_some_and(|lock| { + lock.match_indices(head.as_str()) + .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + }); + if transitive { + format!( + "{crate_name}@{version} is a transitive-only dependency (Cargo.lock resolves it, \ + but no [dependencies] entry in {scope} declares it); hosted mode can pin only \ + direct dependencies, so it was NOT redirected and stays unpatched — patch it with \ + `socket-patch scan --mode vendored`, or declare it directly and re-run \ + (nothing rewritten)" + ) + } else { + format!( + "no [dependencies] entry for {crate_name} in {scope}; dependency skipped \ + (nothing rewritten)" + ) + } } /// Sparse index URLs land verbatim inside quoted TOML strings in both @@ -9309,6 +9337,38 @@ mod tests { ); } + /// Bug J (kept a refusal): a crate only reached transitively cannot be + /// pinned by a manifest `registry` key. Nothing is written or + /// confirmed, and the warning says it is transitive-only, unpatched, and + /// which mode can patch it. + #[test] + fn cargo_transitive_only_crate_is_refused_loudly() { + let mut files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nother = \"1\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty() && r.edits.is_empty(), "{:?}", r.files); + assert!(r.confirmed_cargo_uuids.is_empty()); + assert_eq!(warning_codes(&r), vec!["redirect_cargo_toml_dep_not_found"]); + let detail = &r.warnings[0].detail; + assert!( + detail.contains("transitive-only") + && detail.contains("NOT redirected") + && detail.contains("--mode vendored"), + "{detail}" + ); + // Not in the lock either: the plain not-declared wording. + files.remove("Cargo.lock"); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.warnings[0] + .detail + .starts_with("no [dependencies] entry for serde"), + "{:?}", + r.warnings + ); + } + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS /// twin's behavior) instead of vanishing silently. #[test] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json new file mode 100644 index 00000000..acfb99e8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_not_found" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock new file mode 100644 index 00000000..157d89ad --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml new file mode 100644 index 00000000..df66812e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-refusal/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 3f1b7493..118055a4 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -522,6 +522,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/transitive-refusal/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/two-sections/expected": { "refs": [ { From 8cb05faa9343ab42a1efbf6de5f9dfeebd6e69a0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 17:00:53 -0400 Subject: [PATCH 07/21] test(redirect): run the cargo shape suite in every lock format The multi-version, workspace-member, legacy-config and CRLF shapes ran only against the toolchain's own v4 lock, so nothing in-repo checked them against a committed v1 lock (full-id dependency edges, checksums in `[metadata]`) or v2/v3. The shape suite now honours SOCKET_PATCH_CARGO_E2E_LOCK_VERSION like the other real-cargo suites and joins the cargo-vex-matrix CI job. The shared lock re-encoder kept only dependency names, which cannot tell two locked versions of one crate apart; it now keeps each edge's package id and writes the shortest form cargo writes for that format (`name`, `name version`, or the full id; always the full id in v1). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 2 +- .../tests/cargo_e2e_matrix/mod.rs | 81 +++++++++++++++---- .../tests/e2e_redirect_cargo_shapes.rs | 5 ++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fa1954..fea73c77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1455,7 +1455,7 @@ jobs: SOCKET_PATCH_CARGO_E2E_LOCK_VERSION: ${{ matrix.lock }} run: | set -euo pipefail - cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_vendor_cargo_build --test mode_migration_cargo + cargo test -p socket-patch-cli --test e2e_redirect_cargo_build --test e2e_redirect_cargo_shapes --test e2e_vendor_cargo_build --test mode_migration_cargo cargo test -p socket-patch-cli --test e2e_safety_cargo_build -- --ignored # Manifest `[patch]` + the tagged detached lock (the v5 vendored cargo diff --git a/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs b/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs index 863f33ef..f58f8fcf 100644 --- a/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs +++ b/crates/socket-patch-cli/tests/cargo_e2e_matrix/mod.rs @@ -1,6 +1,7 @@ //! Toolchain / Cargo.lock-format knobs shared by the real-cargo e2e suites -//! (`e2e_redirect_cargo_build`, `e2e_vendor_cargo_build`, -//! `mode_migration_cargo`, `e2e_safety_cargo_build`), so one local loop (or +//! (`e2e_redirect_cargo_build`, `e2e_redirect_cargo_shapes`, +//! `e2e_vendor_cargo_build`, `mode_migration_cargo`, +//! `e2e_safety_cargo_build`), so one local loop (or //! one CI matrix leg per cell) drives every hosted + vendored flow through a //! given cargo release and lock format: //! @@ -28,6 +29,7 @@ //! SOCKET_PATCH_CARGO_E2E_REQUIRED=1 SOCKET_PATCH_CARGO_E2E_TOOLCHAIN=$tc \ //! SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=$lv \ //! cargo test -p socket-patch-cli --test e2e_redirect_cargo_build \ +//! --test e2e_redirect_cargo_shapes \ //! --test e2e_vendor_cargo_build --test mode_migration_cargo //! done; done //! ``` @@ -145,10 +147,21 @@ pub struct LockPackage { pub version: String, pub source: Option, pub checksum: Option, - /// Dependency NAMES (the fixtures never lock two versions of a crate). + /// Dependency package ids (`name version`, plus ` (source)` for a + /// sourced package), resolved from whichever short form the lock used — + /// so two locked versions of one crate stay distinct across formats. pub dependencies: Vec, } +impl LockPackage { + fn id(&self) -> String { + match &self.source { + Some(src) => format!("{} {} ({src})", self.name, self.version), + None => format!("{} {}", self.name, self.version), + } + } +} + /// The lock format of `text`: `version = N` (3 / 4), else v1 when it has /// a `[metadata]` table or `"name version (source)"` references, else v2. pub fn lock_format(text: &str) -> u8 { @@ -208,8 +221,7 @@ pub fn parse_lock(text: &str) -> Vec { if t == "]" { in_deps = false; } else if let Some(dep) = quoted(t.trim_end_matches(',')) { - let name = dep.split(' ').next().unwrap_or_default().to_string(); - pkg.dependencies.push(name); + pkg.dependencies.push(dep); } continue; } @@ -235,6 +247,38 @@ pub fn parse_lock(text: &str) -> Vec { _ => {} } } + // A dependency is written as `name`, `name version` or the full + // `name version (source)`, whichever is unambiguous in its lock. + let ids: Vec<(String, String, Option, String)> = pkgs + .iter() + .map(|p| (p.name.clone(), p.version.clone(), p.source.clone(), p.id())) + .collect(); + for pkg in &mut pkgs { + for dep in &mut pkg.dependencies { + let (head, source) = match dep.split_once(" (") { + Some((head, rest)) => (head, rest.strip_suffix(')').map(str::to_string)), + None => (dep.as_str(), None), + }; + let mut parts = head.split(' '); + let name = parts.next().unwrap_or_default(); + let version = parts.next(); + let matches: Vec<&String> = ids + .iter() + .filter(|(n, v, s, _)| { + n == name + && version.is_none_or(|want| want == v) + && (source.is_none() || *s == source) + }) + .map(|(_, _, _, id)| id) + .collect(); + assert_eq!( + matches.len(), + 1, + "lock dependency {dep:?} must name exactly one locked package" + ); + *dep = matches[0].clone(); + } + } for pkg in &mut pkgs { if pkg.checksum.is_none() { if let Some(src) = &pkg.source { @@ -265,17 +309,26 @@ pub fn write_lock(pkgs: &[LockPackage], version: u8) -> String { if version >= 3 { out.push_str(&format!("version = {version}\n\n")); } - let dep_ref = |name: &str| -> String { - if version >= 2 { - return name.to_string(); - } + // v1 always writes the full id; v2+ the shortest unambiguous form. + let dep_ref = |id: &str| -> String { let dep = sorted .iter() - .find(|p| p.name == name) - .unwrap_or_else(|| panic!("lock dependency {name} is not a locked package")); - match &dep.source { - Some(src) => format!("{} {} ({src})", dep.name, dep.version), - None => format!("{} {}", dep.name, dep.version), + .find(|p| p.id() == id) + .unwrap_or_else(|| panic!("lock dependency {id} is not a locked package")); + if version == 1 { + return dep.id(); + } + let same_name = sorted.iter().filter(|p| p.name == dep.name).count(); + let same_version = sorted + .iter() + .filter(|p| p.name == dep.name && p.version == dep.version) + .count(); + if same_name == 1 { + dep.name.clone() + } else if same_version == 1 { + format!("{} {}", dep.name, dep.version) + } else { + dep.id() } }; let blocks: Vec = sorted diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 579e5aae..1cc9ec5b 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -22,6 +22,10 @@ //! and a post-install `vex` must attest exactly the patches, and finally `remove ` for every patch, which must leave the project //! byte-identical to its pre-scan state. //! +//! `SOCKET_PATCH_CARGO_E2E_LOCK_VERSION` / `_TOOLCHAIN` (see +//! `cargo_e2e_matrix`) re-encode the baseline lock, so a v1 lock's full-id +//! dependency edges and `[metadata]` checksums meet every shape too. +//! //! Skips (with a println) when `cargo` is missing or crates.io is //! unreachable (a failure instead under `SOCKET_PATCH_CARGO_E2E_REQUIRED=1`). @@ -372,6 +376,7 @@ async fn run_shape(shape: Shape) -> Option<()> { return None; } pin_patched_versions(&proj, &home, &shape.patches); + cargo_e2e_matrix::apply_lock_version(&proj); let build = cargo(&proj, &["build", "-q", "--locked"], &home); if !build.status.success() { let _ = cargo_e2e_matrix::skip( From c97d77c9d785e9468d8154b5b402ef36c737f58d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 17:57:07 -0400 Subject: [PATCH 08/21] fix(redirect): refuse a cargo crate other crates depend on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted cargo redirect pins a crate with `registry = "…"` on each manifest declaration and repoints its one Cargo.lock entry. When a crates.io (or git) crate in the lock also depends on it — cfg-if, libc and serde usually are both direct and transitive — that edge keeps resolving from crates.io. The repointed lock then fails `cargo fetch --locked`, a plain build adds the crates.io copy back beside the patched one, and the dependent crate compiles the unpatched code, while scan reported the crate redirected and VEX attested it. The same happened for a path package whose manifest the rewriter never saw (outside the project root, or behind a symlink). Before committing a dep, every Cargo.lock package that depends on it (any edge spelling: `name`, `name version`, or the full v1 id) must be a source-less package whose manifest was planned and pinned. Otherwise the dep is skipped with the new additive warning `redirect_cargo_transitive_dependents`, which names the dependents, says the crate was NOT redirected and stays unpatched, and points to `scan --mode vendored`. Nothing is written, recorded or attested. Golden: cargo/cargo/shared-dependency (the depscan TS twin lags — list it in TS_LAGGING on the next submodule bump). Unit: registry, git, v1 full-id and unplanned-path dependents refuse; another version's edge and a planned member do not. Real cargo: e2e_redirect_cargo_shapes direct-and-transitive (cfg-if beside crc32fast) is refused and the untouched project still fetches `--locked`; before this change the same shape reported redirected: 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 63 +++++ .../src/patch/redirect/mod.rs | 240 +++++++++++++++++- .../shared-dependency/expected-edits.json | 1 + .../shared-dependency/expected-warnings.json | 3 + .../cargo/shared-dependency/input/Cargo.lock | 26 ++ .../cargo/shared-dependency/input/Cargo.toml | 8 + .../cargo/shared-dependency/overrides.json | 22 ++ .../vex-discover-golden/redirect-cargo.json | 8 + 8 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 1cc9ec5b..06db6fdf 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -12,6 +12,8 @@ //! * `workspace_direct_member` — a virtual workspace whose root pins //! `[workspace.dependencies]`, one member inheriting and one declaring the //! crate itself: both members build against the patched copy. +//! * `direct_and_transitive` — the crate is also a dependency of another +//! crates.io crate: hosted mode refuses it loudly and rewrites nothing. //! //! Every shape runs the same chain against the real cargo: a baseline build //! with a private CARGO_HOME (network to crates.io for fixture setup only), @@ -84,6 +86,9 @@ struct Shape { /// Re-encode every `Cargo.toml` and the generated `Cargo.lock` with CRLF /// line endings (a Windows checkout) before the scan. crlf: bool, + /// A shape hosted mode must REFUSE: the rewriter warning code every + /// patch is skipped with. The scan must leave every file untouched. + refused: Option<&'static str>, } fn binary() -> PathBuf { @@ -457,6 +462,36 @@ async fn run_shape(shape: Shape) -> Option<()> { shape.tag ); let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + if let Some(code) = shape.refused { + assert_eq!(env["redirect"]["redirected"], 0, "{}: {env}", shape.tag); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert_eq!( + codes, + vec![code; shape.patches.len()], + "{}: every patch refused loudly: {env}", + shape.tag + ); + assert_eq!( + snapshot(&proj), + before, + "{}: a refused redirect rewrites nothing", + shape.tag + ); + assert!(!proj.join(".socket").exists(), "{}", shape.tag); + let fetch = cargo(&proj, &["fetch", "--locked"], &home); + assert!( + fetch.status.success(), + "{}: the untouched project still fetches --locked:\n{}", + shape.tag, + stderr(&fetch) + ); + return Some(()); + } assert_eq!( env["redirect"]["redirected"], shape.patches.len(), @@ -659,6 +694,7 @@ async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() .to_string(), )], crlf: false, + refused: None, }; let _ = run_shape(shape).await; } @@ -680,6 +716,7 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: false, + refused: None, }; let _ = run_shape(shape).await; } @@ -717,6 +754,7 @@ async fn cargo_hosted_workspace_member_declaration_is_pinned() { ("direct/src/lib.rs", oracle), ], crlf: false, + refused: None, }; let _ = run_shape(shape).await; } @@ -737,6 +775,31 @@ async fn cargo_hosted_crlf_project_keeps_its_line_endings() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: true, + refused: None, + }; + let _ = run_shape(shape).await; +} + +/// A crate that is both a direct dependency and a dependency of another +/// crates.io crate (`crc32fast` depends on `cfg-if ^1`): a hosted pin cannot +/// reach crc32fast's edge, so the redirect is refused loudly instead of +/// repointing the lock (`--locked` then fails, and crc32fast compiled the +/// unpatched crates.io copy while scan reported the crate redirected). +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_crate_another_crate_depends_on() { + let shape = Shape { + tag: "direct-and-transitive", + files: vec![ + ( + "Cargo.toml", + consumer_manifest("cfg-if = \"1.0.4\"\ncrc32fast = \"=1.5.0\"\n"), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + refused: Some("redirect_cargo_transitive_dependents"), }; let _ = run_shape(shape).await; } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 6012b299..cf7bcf58 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -765,6 +765,12 @@ fn rewrite_cargo( for (path, text) in manifests.iter_mut() { *text = to_lf(path, std::mem::take(text)); } + // Each manifest's own `[package] name` — how Cargo.lock names the + // source-less (workspace / path) package it declares. + let manifest_packages: Vec> = manifests + .iter() + .map(|(_, text)| cargo_manifest_package_name(text)) + .collect(); let edits_before = result.edits.len(); let mut changed_manifests: std::collections::BTreeSet = std::collections::BTreeSet::new(); @@ -921,6 +927,26 @@ fn rewrite_cargo( }); continue; } + // A pin reaches only the declarations it sits on: every OTHER lock + // package depending on the crate — a registry/git crate, or a path + // package whose manifest was not planned (outside the project, behind + // a symlink) — keeps resolving it from crates.io, so the repointed + // lock is unsatisfiable and that consumer compiles the unpatched copy. + if let Some(lock_text) = cargo_lock.as_deref() { + let pinned_packages: std::collections::BTreeSet<&str> = toml_plans + .iter() + .filter_map(|(i, _)| manifest_packages[*i].as_deref()) + .collect(); + let blocking = + cargo_unpinnable_dependents(lock_text, &dep.name, &dep.version, &pinned_packages); + if !blocking.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_transitive_dependents".into(), + detail: cargo_transitive_dependents_detail(&dep.name, &dep.version, &blocking), + }); + continue; + } + } // 2. Plan the Cargo.lock repoint. A lock that exists but has no // [[package]] for the dep means the project does not actually resolve @@ -1093,6 +1119,102 @@ fn cargo_not_declared_detail( } } +/// The `[package] name` a manifest declares (`None` for a virtual workspace +/// root or an unparseable file). +fn cargo_manifest_package_name(text: &str) -> Option { + let doc = text.parse::().ok()?; + doc.get("package")? + .get("name")? + .as_str() + .map(str::to_string) +} + +/// The Cargo.lock packages that depend on `crate_name@version` and that a +/// manifest pin cannot reach: any package with a `source` (a registry or +/// git crate), and any source-less (workspace / path) package whose +/// manifest is not among `pinned_packages`. Dependency edges are matched +/// in every spelling — `"name"`, `"name version"` and the full +/// `"name version (source)"` id — so a v1 lock and a twin's full id are +/// covered alike. A lock that does not parse yields one entry saying so. +fn cargo_unpinnable_dependents( + lock: &str, + crate_name: &str, + version: &str, + pinned_packages: &std::collections::BTreeSet<&str>, +) -> Vec { + let Ok(doc) = lock.parse::() else { + return vec!["Cargo.lock (it does not parse as TOML)".to_string()]; + }; + let Some(packages) = doc + .get("package") + .and_then(toml_edit::Item::as_array_of_tables) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + for package in packages.iter() { + let field = |key: &str| package.get(key).and_then(toml_edit::Item::as_str); + let (Some(name), Some(pkg_version)) = (field("name"), field("version")) else { + continue; + }; + let depends = package + .get("dependencies") + .and_then(toml_edit::Item::as_array) + .is_some_and(|deps| { + deps.iter().filter_map(|d| d.as_str()).any(|d| { + let mut parts = d.splitn(3, ' '); + parts.next() == Some(crate_name) && parts.next().is_none_or(|v| v == version) + }) + }); + if !depends { + continue; + } + match field("source") { + Some(source) => { + let kind = if source.starts_with("git+") { + "git" + } else { + "registry" + }; + out.push(format!("{name} {pkg_version} ({kind})")); + } + None if !pinned_packages.contains(name) => { + out.push(format!( + "{name} {pkg_version} (a path package whose Cargo.toml is outside the \ + project or not rewritable)" + )); + } + None => {} + } + } + out +} + +/// The refusal for a crate other lock packages also depend on. +fn cargo_transitive_dependents_detail( + crate_name: &str, + version: &str, + blocking: &[String], +) -> String { + const SHOWN: usize = 5; + let mut names = blocking + .iter() + .take(SHOWN) + .cloned() + .collect::>() + .join(", "); + if blocking.len() > SHOWN { + names.push_str(&format!(" and {} more", blocking.len() - SHOWN)); + } + format!( + "{crate_name}@{version} is also a dependency of {names} in Cargo.lock; a `registry = …` \ + pin reaches only the declarations it sits on, so those would keep resolving \ + {crate_name} from crates.io (a `--locked` build fails, and the unpatched copy is \ + compiled) — it was NOT redirected and stays unpatched; patch it with \ + `socket-patch scan --mode vendored` (nothing rewritten)" + ) +} + /// Sparse index URLs land verbatim inside quoted TOML strings in both /// `.cargo/config.toml` and `Cargo.lock` — refuse anything that could break /// out of the string (quote, backslash escape, control chars) or that is not @@ -14289,8 +14411,7 @@ packages: let lock = format!( "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ - dependencies = [\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ \"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", @@ -14306,8 +14427,7 @@ packages: let want = format!( "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({idx})\",\n]\n\n\ - [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\ - dependencies = [\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{idx}\"\n\n\ [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ \"checksum serde 1.0.190 ({idx})\" = \"{cksum}\"\n", @@ -14316,15 +14436,16 @@ packages: assert_eq!(out, &want, "v1 lock stays v1, fully repointed"); assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); - // Four fragment edits (entry, metadata line, two dependents), each - // unique in the rewritten file, and reverting them newest-first (the - // replay order) restores the original byte-for-byte. + // Three fragment edits (entry, metadata line, the dependent's + // reference), each unique in the rewritten file, and reverting them + // newest-first (the replay order) restores the original + // byte-for-byte. let edits: Vec<&FileEdit> = r .edits .iter() .filter(|e| e.kind == "redirect_cargo_lock_entry") .collect(); - assert_eq!(edits.len(), 4, "{edits:#?}"); + assert_eq!(edits.len(), 3, "{edits:#?}"); let mut reverted = out.clone(); for e in edits.iter().rev() { assert_eq!(e.key.as_deref(), Some("serde@1.0.190")); @@ -14356,6 +14477,109 @@ packages: ); } + /// A lock of `app` (source-less, declares serde + `extra`) where `extra` + /// resolves from `extra_source` and depends on serde via `edge`. + fn cargo_shared_dependency_files( + extra: &str, + extra_source: Option<&str>, + edge: &str, + ) -> BTreeMap { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let source = extra_source.map_or(String::new(), |s| format!("source = \"{s}\"\n")); + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + serde = \"1.0.190\"\n{extra} = {{ path = \"../{extra}\" }}\n" + ), + ); + files.insert( + "Cargo.lock".to_string(), + format!( + "version = 3\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\ + dependencies = [\n \"{extra}\",\n \"serde\",\n]\n\n\ + [[package]]\nname = \"{extra}\"\nversion = \"0.2.0\"\n{source}\ + dependencies = [\n \"{edge}\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\ + checksum = \"{}\"\n", + "1".repeat(64) + ), + ); + files + } + + /// A crate that is BOTH a direct dependency and a dependency of another + /// crate (cfg-if, libc, serde…) cannot be hosted-redirected: the pin + /// reaches only the root's declaration, the other crate keeps resolving + /// it from crates.io, so the repointed lock fails `--locked` and the + /// unpatched copy is compiled. REGRESSION: it was pinned, repointed and + /// confirmed (reported redirected, attested by VEX). + #[test] + fn cargo_crate_another_lock_package_depends_on_is_refused() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let git = "git+https://example.test/extra#0123456789abcdef"; + for (source, edge, kind) in [ + (Some(CRATES_IO), "serde".to_string(), "registry"), + (Some(CRATES_IO), "serde 1.0.190".to_string(), "registry"), + ( + Some(CRATES_IO), + format!("serde 1.0.190 ({CRATES_IO})"), + "registry", + ), + (Some(git), "serde".to_string(), "git"), + // A source-less path package whose manifest was never supplied + // (outside the project root, or behind a symlink). + (None, "serde".to_string(), "a path package"), + ] { + let files = cargo_shared_dependency_files("extra", source, &edge); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{edge}: {:?}", r.files.keys()); + assert!(r.edits.is_empty(), "{edge}: {:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "{edge}"); + let [w] = r.warnings.as_slice() else { + panic!("{edge}: one warning: {:?}", r.warnings); + }; + assert_eq!(w.code, "redirect_cargo_transitive_dependents", "{edge}"); + assert!( + w.detail.contains(&format!("extra 0.2.0 ({kind}")), + "{edge}: {}", + w.detail + ); + assert!(w.detail.contains("--mode vendored"), "{}", w.detail); + } + } + + /// The dependent check is edge-exact: another VERSION of the crate is + /// not ours, and a source-less dependent whose manifest is planned (and + /// pinned) resolves through the pin. + #[test] + fn cargo_dependents_that_the_pin_reaches_or_another_version_do_not_refuse() { + let files = cargo_shared_dependency_files( + "extra", + Some("registry+https://github.com/rust-lang/crates.io-index"), + "serde 1.0.100", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + let mut files = cargo_shared_dependency_files("extra", None, "serde"); + files.insert( + "extra/Cargo.toml".to_string(), + "[package]\nname = \"extra\"\nversion = \"0.2.0\"\n\n[dependencies]\nserde = \"1\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + assert!( + r.files["extra/Cargo.toml"].contains(&format!("registry = \"{}\"", cargo_reg())), + "{:?}", + r.files + ); + } + /// A checksum-less entry whose `source` line ends the block (the /// trailing newline sits outside the block region) still gets its pin. #[test] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json new file mode 100644 index 00000000..97c5d3b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_transitive_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock new file mode 100644 index 00000000..182db3ae --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.lock @@ -0,0 +1,26 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde_json" +version = "1.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml new file mode 100644 index 00000000..adaf565e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/shared-dependency/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 118055a4..fb76d036 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -368,6 +368,14 @@ } ] }, + "redirect/cargo/cargo/shared-dependency/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/supersede/expected": { "refs": [ { From 2d02c852d1505bc682f967215d0adeff1135ee48 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 17:59:51 -0400 Subject: [PATCH 09/21] fix(redirect): never follow a symlink to a cargo member Workspace member discovery checked the project root only lexically. A literal `members = ["linked"]` entry or a path dependency through a symlinked directory was returned as a rewrite target, and `scan --mode hosted` then wrote the registry pin into a Cargo.toml outside the project (the whole-run symlink guard checks only the file itself, not its parent directories). The same link matched by a `crates/*` glob was skipped instead, because the glob does not follow links. Discovery now returns only manifests reached without crossing a symbolic link: a symlinked member directory (literal or glob), a symlinked path dependency, a symlinked intermediate directory and a symlinked Cargo.toml are all left out. Cargo still reads those manifests, so a crate one of them depends on is refused by the Cargo.lock dependents check (redirect_cargo_transitive_dependents), exactly like a member or path dependency outside the root. Unit: symlinked literal/glob/nested members, a symlinked path dependency and manifest, and out-of-root members/path dependencies are not returned. In-process: an out-of-root path dependency and a glob or literal symlinked member each refuse the crate loudly and leave every file, the one outside the project included, untouched (the literal case reported redirected: 1 and wrote outside the project before). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_redirect.rs | 92 +++++++++++++++++++ .../src/utils/cargo_workspace.rs | 89 +++++++++++++++++- 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 6dcb1edf..abdb014e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -4059,6 +4059,98 @@ async fn cargo_transitive_only_crate_is_refused_loudly_and_not_attested() { ); } +/// A workspace member or path dependency the rewriter must not write — +/// outside the project root, or reached through a symbolic link — still +/// declares the patched crate to cargo. Pinning only the root would leave +/// that package resolving crates.io (`--locked` fails, the unpatched copy is +/// compiled) while the crate is reported redirected. REGRESSION: the root +/// was pinned and the dep confirmed; a literal symlinked member was even +/// written through the link, outside the project. Now the Cargo.lock +/// dependents check refuses the crate and nothing is written anywhere. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn cargo_member_outside_the_project_or_behind_a_symlink_refuses_the_crate() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.0"; + 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.0", + &index_url, + &cksum, + "GHSA-carg-ssss-ssss", + ) + .await; + + let lib_manifest = + "[package]\nname = \"lib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1\"\n"; + let lock = "version = 3\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ee\"\n\n[[package]]\nname = \"consumer\"\nversion = \"0.0.0\"\ndependencies = [\n \"cfg-if\",\n \"lib\",\n]\n\n[[package]]\nname = \"lib\"\nversion = \"0.1.0\"\ndependencies = [\n \"cfg-if\",\n]\n"; + for (shape, root_manifest, link) in [ + ( + "out-of-root path dependency", + "[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\nlib = { path = \"../lib\" }\n", + None, + ), + ( + "glob-matched symlinked member", + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\n", + Some("crates/lib"), + ), + ( + "literal symlinked member", + "[workspace]\nmembers = [\"lib\"]\n\n[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\ncfg-if = \"1.0.0\"\n", + Some("lib"), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("lib"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("Cargo.toml"), lib_manifest).unwrap(); + let app = tmp.path().join("app"); + std::fs::create_dir_all(&app).unwrap(); + std::fs::write(app.join("Cargo.toml"), root_manifest).unwrap(); + std::fs::write(app.join("Cargo.lock"), lock).unwrap(); + if let Some(link) = link { + let at = app.join(link); + std::fs::create_dir_all(at.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&outside, at).unwrap(); + } + write_vendored_crate(&app, "cfg-if", "1.0.0"); + + let env = run_redirect_subprocess(&app, &server.uri()); + assert_eq!(env["redirect"]["redirected"], 0, "{shape}: {env}"); + let warning = env["redirect"]["warnings"] + .as_array() + .unwrap() + .iter() + .find(|w| w["code"] == "redirect_cargo_transitive_dependents") + .unwrap_or_else(|| panic!("{shape}: {env}")); + let detail = warning["detail"].as_str().unwrap(); + assert!( + detail.contains("lib 0.1.0 (a path package") && detail.contains("--mode vendored"), + "{shape}: {detail}" + ); + assert_eq!( + std::fs::read_to_string(outside.join("Cargo.toml")).unwrap(), + lib_manifest, + "{shape}: a manifest outside the project is never written" + ); + assert_eq!( + std::fs::read_to_string(app.join("Cargo.toml")).unwrap(), + root_manifest, + "{shape}" + ); + assert_eq!(std::fs::read_to_string(app.join("Cargo.lock")).unwrap(), lock, "{shape}"); + assert!(!app.join(".cargo").exists(), "{shape}"); + } +} + /// AUDIT A2 (green side): the multi-line `[dependencies.]` table form — /// with NO Cargo.lock — is fully pinned: the manifest entry gains a registry /// line, the managed registry block is wired in, and the patch is recorded + diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index 4d6b6919..e435dda7 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -5,8 +5,11 @@ //! The hosted cargo rewriter pins a patched crate in EVERY manifest that //! declares it — a member's own `cfg-if = "1"` resolves exactly like the //! root's, so leaving it unpinned makes the repointed Cargo.lock entry -//! unsatisfiable. Only manifests inside the project root are returned: a -//! file outside it is not the project's to rewrite. +//! unsatisfiable. Only manifests inside the project root, reached without +//! crossing a symbolic link, are returned: a file outside the root is not +//! the project's to rewrite, and a link may lead outside it (the writer +//! would follow it). Cargo still reads those manifests; the rewriter's +//! Cargo.lock dependents check refuses a crate one of them depends on. use std::collections::BTreeSet; use std::path::{Component, Path, PathBuf}; @@ -68,19 +71,38 @@ pub fn member_manifests(root: &Path) -> Vec { } fn read_manifest(path: &Path) -> Option { - if !path.is_file() { + if !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_file()) { return None; } std::fs::read_to_string(path).ok()?.parse().ok() } +/// A directory that is not itself a symbolic link. +fn is_real_dir(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Every component of repo-relative `dir` is a real directory under +/// `root` — none is a symbolic link (which may lead outside the root). +fn is_real_dir_path(root: &Path, dir: &str) -> bool { + let mut at = root.to_path_buf(); + dir.split('/').all(|seg| { + at.push(seg); + is_real_dir(&at) + }) +} + fn enqueue( root: &Path, dir: String, dirs: &mut BTreeSet, queue: &mut Vec<(String, DocumentMut)>, ) { - if dir.is_empty() || dirs.len() >= MAX_MANIFESTS || dirs.contains(&dir) { + if dir.is_empty() + || dirs.len() >= MAX_MANIFESTS + || dirs.contains(&dir) + || !is_real_dir_path(root, &dir) + { return; } let Some(doc) = read_manifest(&root.join(&dir).join("Cargo.toml")) else { @@ -185,7 +207,7 @@ fn expand_from(root: &Path, at: PathBuf, rest: &[&str], out: &mut Vec) { }; if !seg.contains(['*', '?']) { let next = at.join(seg); - if root.join(&next).is_dir() { + if is_real_dir(&root.join(&next)) { expand_from(root, next, tail, out); } return; @@ -310,6 +332,63 @@ mod tests { ); } + /// Members and path dependencies behind a symbolic link — whether the + /// link is a literal member, matched by a glob, a path dependency, an + /// intermediate directory or the manifest itself — are never returned: + /// the writer would follow the link, possibly out of the project. + #[cfg(unix)] + #[test] + fn symlinked_members_and_path_dependencies_are_not_returned() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let outside = tmp.path().join("outside"); + write(&outside, "shared/Cargo.toml", &pkg("shared")); + write(&outside, "lone/Cargo.toml", &pkg("lone")); + let root = tmp.path().join("proj"); + write( + &root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"crates/*\", \"linked\", \"nested/inner\"]\n\n\ + {}[dependencies]\nvia = {{ path = \"via\" }}\nreal = {{ path = \"real\" }}\n", + pkg("root") + ), + ); + std::fs::create_dir_all(root.join("crates")).unwrap(); + symlink(outside.join("shared"), root.join("crates/one")).unwrap(); + write(&root, "crates/two/Cargo.toml", &pkg("two")); + symlink(outside.join("shared"), root.join("linked")).unwrap(); + symlink(&outside, root.join("nested")).unwrap(); + std::fs::create_dir_all(outside.join("inner")).unwrap(); + std::fs::write(outside.join("inner/Cargo.toml"), pkg("inner")).unwrap(); + symlink(outside.join("lone"), root.join("via")).unwrap(); + std::fs::create_dir_all(root.join("real")).unwrap(); + symlink( + outside.join("lone/Cargo.toml"), + root.join("real/Cargo.toml"), + ) + .unwrap(); + assert_eq!(member_manifests(&root), vec!["crates/two/Cargo.toml"]); + } + + #[test] + fn members_and_path_dependencies_outside_the_root_are_not_returned() { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "lib/Cargo.toml", &pkg("lib")); + write(tmp.path(), "shared/Cargo.toml", &pkg("shared")); + let root = tmp.path().join("app"); + write( + &root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"../shared\"]\n\n{}[dependencies]\n\ + lib = {{ path = \"../lib\" }}\n", + pkg("app") + ), + ); + assert!(member_manifests(&root).is_empty()); + } + #[test] fn wildcard_matching() { assert!(wildcard_match(b"a*c", b"abbc")); From c6a2c23b2133cee3bf63ddd72b91c93f70da7c71 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:11:24 -0400 Subject: [PATCH 10/21] fix(redirect): remove v1-lock cargo patches in any order A v1 Cargo.lock names every dependency by its full id, so repointing a crate also rewrites its dependents' references. Each dependent's whole `[[package]]` block was recorded as one edit. When one block referenced two patched packages (the root, for two cfg-if versions or any two patched crates it declares) the second edit's `original` was the first edit's `new`, and removing the first-applied purl alone found neither fragment: `remove`, scoped rollback and the hosted-to-vendored takeover refused as "drifted" unless purls went in exact reverse apply order. The dependents' references are now one `redirect_cargo_lock_reference` edit holding just the quoted full id `"name version (source)"`. It names that package exactly, so its inverse puts back every occurrence, independently of any other package's edits; the per-purl revert and the whole-ledger replay both handle it. The v1 whole-block edits were never released, so no existing ledger carries them. Unit: two cfg-if versions and two crates sharing a dependent block each remove in both orders byte-for-byte (both refused as drifted before); a full id named by two members replays clean. Real cargo: e2e_redirect_cargo_shapes now removes every multi-patch shape in apply order and in reverse, from the same post-scan state, and passes with SOCKET_PATCH_CARGO_E2E_LOCK_VERSION=1, 2 and 4. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 88 +++--- .../src/patch/redirect/mod.rs | 43 ++- .../src/patch/redirect/replay.rs | 14 +- .../src/patch/redirect/takeover.rs | 255 +++++++++++++++++- 4 files changed, 343 insertions(+), 57 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 06db6fdf..ac253e14 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -21,7 +21,9 @@ //! wiremock sparse registry per patch, `scan --mode hosted`, then a FRESH //! checkout (only the committed files travel) where `cargo fetch --locked` //! and an offline `cargo build --locked` must link each patched-only symbol -//! and a post-install `vex` must attest exactly the patches, and finally `remove ` for every patch, which must leave the project +//! and a post-install `vex` must attest exactly the patches, and finally +//! `remove ` for every patch — in apply order and, from the same +//! post-scan state, in reverse — which must leave the project //! byte-identical to its pre-scan state. //! //! `SOCKET_PATCH_CARGO_E2E_LOCK_VERSION` / `_TOOLCHAIN` (see @@ -589,45 +591,59 @@ async fn run_shape(shape: Shape) -> Option<()> { expected.sort(); assert_eq!(attested, expected, "{}: attested purls: {doc}", shape.tag); - // Rollback: removing every purl restores the pre-scan project exactly. - for patch in &shape.patches { - let purl = patch.purl(); - let (code, stdout, err) = run_socket( - &proj, - &[ - "remove", - &purl, - "--cwd", - &proj_s, - "--json", - "--yes", - "--no-telemetry", - ], - &home, - ); - assert_eq!( - code, 0, - "{}: remove {purl}\nstdout:\n{stdout}\nstderr:\n{err}", - shape.tag - ); + // Rollback: removing every purl restores the pre-scan project exactly — + // in apply order AND in reverse (a v1 lock's shared dependent blocks + // once made the first-applied purl unremovable before its sibling). + let post_scan = tmp.path().join("post-scan"); + copy_tree(&proj, &post_scan); + let mut orders = vec![shape.patches.clone()]; + if shape.patches.len() > 1 { + orders.push(shape.patches.iter().rev().copied().collect()); } - let after = snapshot(&proj); - for (rel, bytes) in &before { - assert_eq!( - after - .get(rel) - .map(|b| String::from_utf8_lossy(b).into_owned()), - Some(String::from_utf8_lossy(bytes).into_owned()), - "{}: {rel} not restored byte-for-byte by remove", + for (n, order) in orders.iter().enumerate() { + if n > 0 { + std::fs::remove_dir_all(&proj).unwrap(); + copy_tree(&post_scan, &proj); + } + for patch in order { + let purl = patch.purl(); + let (code, stdout, err) = run_socket( + &proj, + &[ + "remove", + &purl, + "--cwd", + &proj_s, + "--json", + "--yes", + "--no-telemetry", + ], + &home, + ); + assert_eq!( + code, 0, + "{} (removal order {n}): remove {purl}\nstdout:\n{stdout}\nstderr:\n{err}", + shape.tag + ); + } + let after = snapshot(&proj); + for (rel, bytes) in &before { + assert_eq!( + after + .get(rel) + .map(|b| String::from_utf8_lossy(b).into_owned()), + Some(String::from_utf8_lossy(bytes).into_owned()), + "{} (removal order {n}): {rel} not restored byte-for-byte by remove", + shape.tag + ); + } + let extra: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); + assert!( + extra.is_empty(), + "{} (removal order {n}): remove left files behind: {extra:?}", shape.tag ); } - let extra: Vec<&String> = after.keys().filter(|k| !before.contains_key(*k)).collect(); - assert!( - extra.is_empty(), - "{}: remove left files behind: {extra:?}", - shape.tag - ); Some(()) } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index cf7bcf58..6c13e99d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2069,6 +2069,11 @@ fn plan_cargo_toml( }) } +/// The Cargo.lock edit kind for dependents' full-id references: `original` +/// / `new` are the quoted `" ()"` ids, keyed +/// `@`, and the inverse replaces EVERY occurrence of `new`. +pub(crate) const CARGO_LOCK_REFERENCE_KIND: &str = "redirect_cargo_lock_reference"; + static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") }); @@ -2096,8 +2101,9 @@ static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { /// Full-id references are rewritten in any format (v2+ spells them that way /// when a name + version is ambiguous). Each changed fragment is its own /// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is -/// unambiguous): the entry, the `[metadata]` line, and each dependent's -/// whole `[[package]]` block. +/// unambiguous) — the entry and the `[metadata]` line — and the dependents' +/// references are one `redirect_cargo_lock_reference` edit holding the +/// quoted full id, reverted at every occurrence. fn plan_cargo_lock( content: &str, crate_name: &str, @@ -2201,22 +2207,35 @@ fn plan_cargo_lock( edits.push(edit(&line, &pinned)); } } - // Dependents' full-id references to the OLD source. + // Dependents' full-id references to the OLD source, recorded as ONE + // `redirect_cargo_lock_reference` edit holding just the quoted id — + // never a dependent's whole block: a block referencing two patched + // packages (the root of a v1 lock) would hold two overlapping block + // edits, and reverting the first-applied one alone found neither of its + // fragments. The id names this name + version + source exactly, so its + // inverse puts back EVERY occurrence, independently of any other + // package's edits and in any removal order. if let Some(old) = old_source.filter(|old| old != index_url) { let from = format!("\"{crate_name} {version} ({old})\""); let to = format!("\"{crate_name} {version} ({index_url})\""); let mut cursor = 0; + let mut repointed_any = false; while let Some((start, end)) = next_lock_block(&new_content, cursor) { - let block = new_content[start..end].to_string(); - if block.contains(&from) { - let repointed = block.replace(&from, &to); + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); new_content.replace_range(start..end, &repointed); - edits.push(edit(&block, &repointed)); + repointed_any = true; cursor = start + repointed.len(); } else { cursor = end; } } + if repointed_any { + edits.push(FileEdit { + kind: CARGO_LOCK_REFERENCE_KIND.into(), + ..edit(&from, &to) + }); + } } // Already redirected (re-run): every fragment is at the target values; a // recorded edit would have original == new and grow the ledger forever. @@ -14443,9 +14462,12 @@ packages: let edits: Vec<&FileEdit> = r .edits .iter() - .filter(|e| e.kind == "redirect_cargo_lock_entry") + .filter(|e| { + e.kind == "redirect_cargo_lock_entry" || e.kind == CARGO_LOCK_REFERENCE_KIND + }) .collect(); assert_eq!(edits.len(), 3, "{edits:#?}"); + assert_eq!(edits[2].kind, CARGO_LOCK_REFERENCE_KIND, "{edits:#?}"); let mut reverted = out.clone(); for e in edits.iter().rev() { assert_eq!(e.key.as_deref(), Some("serde@1.0.190")); @@ -14468,10 +14490,7 @@ packages: ); let again = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!( - !again - .edits - .iter() - .any(|e| e.kind == "redirect_cargo_lock_entry"), + !again.edits.iter().any(|e| e.path == "Cargo.lock"), "{:?}", again.edits ); diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 84aa5320..73a0878f 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -54,6 +54,10 @@ enum Inverse { /// writers record an `original` that is a substring of `new` (the /// Cargo.toml insert variant, the maven version suffix). ReplaceFragment, + /// Like [`Inverse::ReplaceFragment`], but `new` legitimately occurs + /// several times and stands for every occurrence (a cargo v1 lock's + /// full-id dependency reference, named by several dependents). + ReplaceEveryFragment, PipenvEntry, HatchDocument, /// action `added` with only `new` recorded: the redirect inserted the @@ -106,6 +110,7 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { ("cargo", Inverse::ReplaceFragment) } + super::CARGO_LOCK_REFERENCE_KIND => ("cargo", Inverse::ReplaceEveryFragment), "redirect_cargo_registry" => ( "cargo", if action == "added" { @@ -450,7 +455,9 @@ pub async fn revert_remaining_redirect_edits( } } } - Inverse::ReplaceFragment | Inverse::HatchDocument => { + Inverse::ReplaceFragment + | Inverse::ReplaceEveryFragment + | Inverse::HatchDocument => { let (Some(original), Some(new)) = (str_payload(&edit.original), str_payload(&edit.new)) else { @@ -516,7 +523,10 @@ pub async fn revert_remaining_redirect_edits( }; // `new` before `original`: original may be a substring // of new (Cargo.toml insert, maven version suffix). - if content.contains(new) { + if inverse == Inverse::ReplaceEveryFragment && content.contains(new) { + staged.insert(edit.path.clone(), Some(content.replace(new, original))); + group_drops.insert(idx); + } else if content.contains(new) { if content.matches(new).count() > 1 { refuse( format!( diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 586b9605..fe2ebb70 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -157,6 +157,12 @@ fn registry_uuids(fragment: Option<&Value>) -> impl Iterator + '_ .map(|c| c[1].to_string()) } +/// A Cargo.lock edit (keyed `@`): the entry / `[metadata]` +/// fragments, or the dependents' full-id reference. +fn is_cargo_lock_edit(e: &FileEdit) -> bool { + e.kind == "redirect_cargo_lock_entry" || e.kind == super::CARGO_LOCK_REFERENCE_KIND +} + /// Every patch uuid `name@version` was redirected at: its record's, plus /// each managed registry whose index URL one of its (version-keyed) /// Cargo.lock edits names — the older links of a re-redirect chain. @@ -165,9 +171,7 @@ fn cargo_lineage(state: &RedirectState, name: &str, version: &str, uuid: &str) - let lock_fragments: Vec<&str> = state .edits .iter() - .filter(|e| { - e.kind == "redirect_cargo_lock_entry" && e.key.as_deref() == Some(lock_key.as_str()) - }) + .filter(|e| is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) .flat_map(|e| [e.original.as_ref(), e.new.as_ref()]) .flatten() .filter_map(Value::as_str) @@ -240,8 +244,7 @@ pub async fn revert_cargo_redirect_purl( (e.kind == "redirect_cargo_toml_dep" && e.key.as_deref() == Some(name.as_str()) && !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u))) - || (e.kind == "redirect_cargo_lock_entry" - && e.key.as_deref() == Some(lock_key.as_str())) + || (is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) }; // Registry blocks tie to this purl via the `socket-patch-` names in // its record + wiring edits (a patch uuid is per purl, so this cannot @@ -295,7 +298,9 @@ pub async fn revert_cargo_redirect_purl( for &i in mine.iter().rev() { let edit = &state.edits[i]; match edit.kind.as_str() { - "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { + "redirect_cargo_toml_dep" + | "redirect_cargo_lock_entry" + | super::CARGO_LOCK_REFERENCE_KIND => { let (Some(new), Some(orig)) = ( edit.new.as_ref().and_then(Value::as_str), edit.original.as_ref().and_then(Value::as_str), @@ -314,7 +319,13 @@ pub async fn revert_cargo_redirect_purl( )); }; if content.contains(new) { - let reverted = content.replacen(new, orig, 1); + // A full-id reference edit stands for every dependent's + // occurrence of that exact id. + let reverted = if edit.kind == super::CARGO_LOCK_REFERENCE_KIND { + content.replace(new, orig) + } else { + content.replacen(new, orig, 1) + }; staged.insert(edit.path.clone(), Some(reverted)); out.reverted_files.push(edit.path.clone()); } else if content.contains(orig) { @@ -1430,6 +1441,236 @@ mod tests { ); } + /// A hosted override for `name@version` at patch `uuid`. + fn cargo_dep(name: &str, version: &str, uuid: &str) -> crate::patch::redirect::DepOverride { + serde_json::from_value(serde_json::json!({ + "ecosystem": "cargo", "name": name, "version": version, "token": "tok", + "patchUuid": uuid, + "artifactUrl": format!("http://127.0.0.1:5555/{name}-{version}.crate"), + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), + "identifiers": { + "name": name, "version": version, + "cargoCksumSha256": "a".repeat(64), + }, + }, + "integrity": { "sha256": "a".repeat(64) }, + })) + .unwrap() + } + + /// Redirect `deps` (applied in order) over a pristine project with the + /// real rewriter, write the output to a tempdir, and return the ledger + /// with one record per purl. + async fn redirect_on_disk( + toml: &str, + lock: &str, + deps: &[(&str, &str, &str)], + ) -> (tempfile::TempDir, RedirectState) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.to_string()); + let overrides: Vec<_> = deps + .iter() + .map(|(name, version, uuid)| cargo_dep(name, version, uuid)) + .collect(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + deps.len(), + "{:?}", + rewrite.warnings + ); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + for (name, version, uuid) in deps { + let mut rec = record(); + rec.uuid = uuid.to_string(); + state + .records + .insert(format!("pkg:cargo/{name}@{version}"), rec); + } + (tmp, state) + } + + /// Redirect `deps`, then remove the purls in EVERY order: each order + /// must succeed and restore the manifest and lock byte-for-byte, with no + /// config and no ledger left. + async fn assert_removes_in_every_order(toml: &str, lock: &str, deps: &[(&str, &str, &str)]) { + let purls: Vec = deps + .iter() + .map(|(name, version, _)| format!("pkg:cargo/{name}@{version}")) + .collect(); + for order in [purls.clone(), purls.iter().rev().cloned().collect()] { + let (tmp, mut state) = redirect_on_disk(toml, lock, deps).await; + let root = tmp.path(); + for purl in &order { + revert_cargo_redirect_purl(root, &mut state, purl, false) + .await + .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); + } + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml, + "{order:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock, + "{order:?}" + ); + assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{order:?}: {:?}", + state.edits + ); + } + } + + /// A v1 lock names every dependency by its full id, so the root block + /// references BOTH patched cfg-if versions. REGRESSION: each dependent + /// block was one whole-block edit, the second version's edit recorded + /// the first one's output as its `original`, and removing the + /// first-applied version alone found neither fragment — `remove` + /// refused as drifted unless purls went in exact reverse apply order. + #[tokio::test] + async fn v1_lock_multi_version_removes_in_any_order() { + const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 0.1.10 ({CRATES_IO})\",\n \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 0.1.10 ({CRATES_IO})\" = \"{}\"\n\ + \"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", + "8".repeat(64), + "9".repeat(64) + ); + assert_removes_in_every_order( + toml, + &lock, + &[("cfg-if", "1.0.4", UUID), ("cfg-if", "0.1.10", UUID_OLD)], + ) + .await; + } + + /// The whole-ledger replay (rollback) inverts a v1 full-id reference + /// named by several dependents at every occurrence. + #[tokio::test] + async fn v1_lock_reference_named_by_several_dependents_replays_clean() { + let toml = "[workspace]\nmembers = [\"a\", \"b\"]\n\n\ + [workspace.dependencies]\ncfg-if = \"1.0\"\n"; + let member = |name: &str| { + format!( + "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\ncfg-if = {{ workspace = true }}\n" + ) + }; + let lock = format!( + "[[package]]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"b\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", + "9".repeat(64) + ); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.to_string()); + files.insert("Cargo.lock".into(), lock.clone()); + files.insert("a/Cargo.toml".into(), member("a")); + files.insert("b/Cargo.toml".into(), member("b")); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + assert_eq!( + rewrite.confirmed_cargo_uuids.len(), + 1, + "{:?}", + rewrite.warnings + ); + let references: Vec<&FileEdit> = rewrite + .edits + .iter() + .filter(|e| e.kind == super::super::CARGO_LOCK_REFERENCE_KIND) + .collect(); + assert_eq!( + references.len(), + 1, + "one edit per full id: {:?}", + rewrite.edits + ); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = rewrite.edits; + state.records.insert(PURL.to_string(), record()); + let outcome = + crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml + ); + } + + /// Two different patched crates with one shared v1 dependent block. + #[tokio::test] + async fn v1_lock_two_crates_sharing_a_dependent_remove_in_any_order() { + const UUID_ITOA: &str = "4d6e8f0a-3b5c-4d7e-9f1a-2b4c6d8e0f2a"; + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\nitoa = \"1.0.11\"\n"; + let lock = format!( + "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"cfg-if 1.0.4 ({CRATES_IO})\",\n \"itoa 1.0.11 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"itoa\"\nversion = \"1.0.11\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n\ + \"checksum itoa 1.0.11 ({CRATES_IO})\" = \"{}\"\n", + "8".repeat(64), + "9".repeat(64) + ); + assert_removes_in_every_order( + toml, + &lock, + &[("cfg-if", "1.0.4", UUID), ("itoa", "1.0.11", UUID_ITOA)], + ) + .await; + } + #[tokio::test] async fn reverts_toml_lock_and_registry_block_and_drops_ledger_entries() { let (tmp, mut state) = redirected_fixture().await; From 33dbd8811cccd64ef43dddfaa9097e202428027a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:21:36 -0400 Subject: [PATCH 11/21] fix(redirect): restore a cargo config's exact trailing bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier bug-H fix removed the appended `[registries.…]` block with the shared fragment remover, which collapses the whole trailing newline run at EOF to one newline. That fixed a config ending in exactly one newline but lost a config's trailing blank line (restored byte-for-byte before that change) and still added a newline to a config without one. The block removal now inverts exactly what the rewriter appended: the recorded fragment plus the single blank separator before it. The newline a config without a final one needs rides in the recorded fragment, so that case is exact too; content the user appended after the block is kept; a config the rewrite created still ends empty and is deleted. `remove` and the whole-ledger replay share the helper (replay no longer leaves an emptied created config behind). All-CRLF configs are inverted as LF, and a fragment recorded with the other line endings still matches. Existing ledgers keep reverting as before. Unit: the bug-H revert test covers no final newline (LF and CRLF), trailing blank lines (LF and CRLF) and a whitespace-only config through both `remove` and the replay; helper pins for every append shape. Real cargo: e2e_redirect_cargo_shapes config-unterminated and config-trailing-blank restore the config byte-for-byte. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_redirect_cargo_shapes.rs | 41 ++++++- .../src/patch/redirect/mod.rs | 6 +- .../src/patch/redirect/replay.rs | 109 +++++++++++++++++- .../src/patch/redirect/takeover.rs | 53 ++++++--- 4 files changed, 189 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index ac253e14..c7222674 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -6,7 +6,8 @@ //! a renamed `cfg-if-legacy` at 0.1.10): each declaration is pinned to its //! own version's registry, and removing both purls restores every byte. //! * `legacy_config` — an existing legacy `.cargo/config`: the registry -//! block lands there, and `remove` restores the file byte-for-byte. +//! block lands there, and `remove` restores the file byte-for-byte — +//! also when it lacks a final newline or ends in a blank line. //! * `crlf` — CRLF `Cargo.toml` + `Cargo.lock`: rewritten with CRLF kept, //! and restored byte-for-byte. //! * `workspace_direct_member` — a virtual workspace whose root pins @@ -737,6 +738,44 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { let _ = run_shape(shape).await; } +/// Bug H, exactly: a config without a final newline, and one ending in a +/// blank line, both come back byte-for-byte (the appended block's removal +/// once normalized the trailing newline run). +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_config_trailing_bytes_are_restored() { + for (tag, rel, config) in [ + ( + "config-unterminated", + ".cargo/config.toml", + "[net]\nretry = 2", + ), + ( + "config-trailing-blank", + ".cargo/config", + "[net]\nretry = 2\n\n", + ), + ] { + let shape = Shape { + tag, + files: vec![ + ("Cargo.toml", consumer_manifest("cfg-if = \"1.0.4\"\n")), + ("src/main.rs", "fn main() {}\n".to_string()), + (rel, config.to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: false, + refused: None, + }; + if run_shape(shape).await.is_none() { + return; + } + } +} + /// Bug F: a workspace member's own declaration must be pinned too. #[tokio::test(flavor = "multi_thread")] async fn cargo_hosted_workspace_member_declaration_is_pinned() { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 6c13e99d..8db4a93e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2364,6 +2364,10 @@ fn plan_cargo_config( "" }; let prefix = if config.is_empty() { "" } else { "\n" }; + // The newline a config without a final one needed rides in the recorded + // fragment, so the revert (which also drops the one blank separator + // before the fragment) restores the config's exact bytes. + let recorded = format!("{sep}{block}"); Some(CargoConfigPlan { content: format!("{config}{sep}{prefix}{block}"), edit: FileEdit { @@ -2372,7 +2376,7 @@ fn plan_cargo_config( action: "added".into(), key: Some(reg.to_string()), original: None, - new: Some(Value::String(block)), + new: Some(Value::String(recorded)), }, }) } diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 73a0878f..145327b4 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -72,6 +72,11 @@ enum Inverse { /// go.sum lines the redirect added (`new`, `\n`-joined): each is removed /// as a whole line, whatever the file's line endings. RemoveAddedLines, + /// The appended cargo `[registries.…]` block (`redirect_cargo_registry`, + /// action `added`): removed together with exactly the one blank + /// separator the rewriter put before it — see + /// [`remove_appended_cargo_block`]. + RemoveAppendedCargoBlock, /// Cleanup of PRIOR socket wiring performed during a redirect refresh /// (`redirect_golang_stale_*`). The removal already moved the file /// toward pristine; restoring it would re-create socket wiring, so @@ -114,7 +119,7 @@ fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { "redirect_cargo_registry" => ( "cargo", if action == "added" { - Inverse::RemoveAddedFragment + Inverse::RemoveAppendedCargoBlock } else { Inverse::ReplaceFragment }, @@ -305,6 +310,38 @@ pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { format!("{}{}", &content[..start], &content[end..]) } +/// Invert the cargo rewriter's append of a `[registries.…]` block: it wrote +/// `config + "\n" + block` (just `block` into an empty config) and records +/// `block` — or `"\n" + block` when the config lacked a final newline (the +/// extra newline it had to add first). Removing the recorded fragment plus +/// the one newline before it therefore restores the config's exact bytes: +/// a missing final newline or trailing blank lines included, and anything +/// the user appended after the block kept. An all-CRLF file is inverted as +/// LF and written back CRLF; a fragment recorded with the other line +/// endings (a checkout converted them) still matches. `None` when the +/// fragment is not in the file. +pub(super) fn remove_appended_cargo_block(content: &str, fragment: &str) -> Option { + let crlf = content.matches("\r\n").count(); + if crlf > 0 && crlf == content.matches('\n').count() { + return remove_appended_cargo_block( + &content.replace("\r\n", "\n"), + &fragment.replace("\r\n", "\n"), + ) + .map(|lf| lf.replace('\n', "\r\n")); + } + let lf_fragment = fragment.replace("\r\n", "\n"); + let (pos, len) = match content.find(fragment) { + Some(pos) => (pos, fragment.len()), + None => (content.find(&lf_fragment)?, lf_fragment.len()), + }; + let before = &content[..pos]; + let before = before + .strip_suffix("\r\n") + .or_else(|| before.strip_suffix('\n')) + .unwrap_or(before); + Some(format!("{before}{}", &content[pos + len..])) +} + /// The string payloads of an edit, or `None` when a payload is missing or /// not a string (a shape the inverse table said must be there). fn str_payload(v: &Option) -> Option<&str> { @@ -592,6 +629,37 @@ pub async fn revert_remaining_redirect_edits( } } } + Inverse::RemoveAppendedCargoBlock => { + let Some(new) = str_payload(&edit.new) else { + refuse( + format!("{} edit is missing its recorded fragment", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + match staged_read(&staged, project_root, &edit.path).await { + Ok(Some(content)) => { + // Absent fragment == already clean. A config the + // rewrite created ends empty and goes with it. + if let Some(restored) = remove_appended_cargo_block(&content, new) { + staged.insert( + edit.path.clone(), + (!restored.is_empty()).then_some(restored), + ); + } + group_drops.insert(idx); + } + Ok(None) => { + group_drops.insert(idx); + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + } Inverse::RemoveAddedFragment => { let Some(new) = str_payload(&edit.new) else { refuse( @@ -2871,6 +2939,45 @@ mod tests { ); } + #[test] + fn remove_appended_cargo_block_inverts_exactly_what_was_appended() { + let block = "[registries.r]\nindex = \"i\"\n"; + for (written, fragment, want) in [ + // Empty config: the block alone (a created file ends empty). + (block.to_string(), block.to_string(), ""), + // One separator after a config ending in newline(s). + (format!("a\n\n{block}"), block.to_string(), "a\n"), + (format!("a\n\n\n{block}"), block.to_string(), "a\n\n"), + // No final newline: the added newline rides in the fragment. + (format!("a\n\n{block}"), format!("\n{block}"), "a"), + // The user appended after the block: kept. + ( + format!("a\n\n{block}b = 1\n"), + block.to_string(), + "a\nb = 1\n", + ), + // CRLF file, and a CRLF-recorded fragment against an LF file. + ( + format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), + block.replace('\n', "\r\n"), + "a\r\n", + ), + (format!("a\n\n{block}"), block.replace('\n', "\r\n"), "a\n"), + ( + format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), + block.to_string(), + "a\r\n", + ), + ] { + assert_eq!( + remove_appended_cargo_block(&written, &fragment).as_deref(), + Some(want), + "{written:?}" + ); + } + assert_eq!(remove_appended_cargo_block("a\n", block), None); + } + #[test] fn remove_fragment_once_absent_fragment_is_identity() { // Defensive edge: callers check contains() first, so the not-found diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index fe2ebb70..66e80f51 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -384,17 +384,19 @@ pub async fn revert_cargo_redirect_purl( out.reverted_files.push(edit.path.clone()); continue; } - // The block leaves with the blank separator the rewrite put - // before it, so an appended block leaves the user's config - // ending exactly as it did — and no other spacing of the - // user's is touched (the old triple-newline collapse - // rewrote any blank run anywhere in the file). - let trimmed = super::replay::remove_fragment_once(&content, block); - if trimmed.trim().is_empty() { - staged.insert(edit.path.clone(), None); - } else { - staged.insert(edit.path.clone(), Some(trimmed)); - } + // The block leaves with exactly the blank separator the + // rewrite put before it, so the user's config comes back + // byte-for-byte — its trailing newlines (or missing final + // newline) included. A config the rewrite created ends + // empty and is deleted. + let Some(restored) = super::replay::remove_appended_cargo_block(&content, block) + else { + continue; + }; + staged.insert( + edit.path.clone(), + (!restored.is_empty()).then_some(restored), + ); out.reverted_files.push(edit.path.clone()); } _ => {} @@ -4250,12 +4252,20 @@ mod tests { /// touches blank runs of the user's own elsewhere in the file. #[tokio::test] async fn appended_registry_block_revert_restores_the_config_bytes() { - for user_cfg in [ + let cases = [ "[net]\nretry = 2\n", "[net]\n\n\n\nretry = 2\n", "# a comment\n\n[http]\ntimeout = 5\n", "[net]\r\nretry = 2\r\n", - ] { + // No final newline, trailing blank lines, whitespace only. + "[net]\nretry = 2", + "[net]\r\nretry = 2", + "[net]\nretry = 2\n\n", + "[net]\r\nretry = 2\r\n\r\n", + "\n", + ]; + // Both unwind paths: `remove ` and the whole-ledger replay. + for (user_cfg, replay) in cases.iter().flat_map(|cfg| [(*cfg, false), (*cfg, true)]) { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let lock = format!("version = 4\n\n{}\n", pristine_lock_block()); @@ -4289,14 +4299,23 @@ mod tests { state.edits = rewrite.edits; state.records.insert(PURL.to_string(), record()); - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); + if replay { + let outcome = crate::patch::redirect::revert_remaining_redirect_edits( + root, &mut state, false, + ) + .await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + } else { + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .expect("revert succeeds"); + } assert_eq!( tokio::fs::read_to_string(root.join(".cargo/config")) .await .unwrap(), - user_cfg + user_cfg, + "replay: {replay}" ); assert_eq!( tokio::fs::read_to_string(root.join("Cargo.toml")) From 57267d54fe972fee203ea189707f98aea263e536 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:25:04 -0400 Subject: [PATCH 12/21] fix(redirect): remove cargo patches across line-ending changes The CRLF support records a CRLF project's edit fragments with CRLF, and the ledger is JSON, so git line-ending normalization never touches them while it does rewrite the committed Cargo.toml / Cargo.lock / .cargo/config. A hosted redirect scanned on Windows (core.autocrlf) therefore could not be removed on a Linux checkout, nor an LF scan on a CRLF checkout: `remove`, rollback and the vendored takeover matched fragments byte-for-byte and refused as "drifted", and the suggested re-scan was a no-op that kept the same fragments. The cargo per-purl revert and the whole-ledger replay now match fragments regardless of CRLF/LF: an all-CRLF file is matched as LF and written back CRLF, any other file is tried with the recorded fragments and then with their LF forms. The file keeps its current line endings. The registry-block checks and removals use the same matching. Drift of the text itself still refuses exactly as before. Unit: a ledger recorded CRLF reverts LF files and a ledger recorded LF reverts CRLF files, each through `remove` and through the replay, with the ledger round-tripped through JSON (all four refused before). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/replay.rs | 118 +++++++++++++++- .../src/patch/redirect/takeover.rs | 129 ++++++++++++++---- 2 files changed, 217 insertions(+), 30 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 145327b4..bb80dc9f 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -310,6 +310,73 @@ pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { format!("{}{}", &content[..start], &content[end..]) } +/// Every line break in `text` is a CRLF (and there is at least one). +fn is_all_crlf(text: &str) -> bool { + let crlf = text.matches("\r\n").count(); + crlf > 0 && crlf == text.matches('\n').count() +} + +/// One fragment-edit inverse, tolerant of a line-ending conversion between +/// the scan and the revert (git `core.autocrlf` rewrites the committed +/// files but never the JSON-escaped fragments in the ledger). +#[derive(Debug, PartialEq)] +pub(super) enum FragmentRevert { + /// `new` was found and put back to `original` (the file's own line + /// endings kept). + Reverted(String), + /// `new` is gone but `original` is present: already unwound. + AlreadyOriginal, + /// Neither fragment is present. + Drifted, +} + +/// Replace `new` with `original` in `content` — once, or at `every` +/// occurrence — matching regardless of CRLF/LF: an all-CRLF file is +/// matched as LF and written back CRLF; any other file is matched with the +/// recorded fragments, then with their LF forms. `new` is looked for +/// before `original` (an `original` may be a substring of `new`). +pub(super) fn revert_fragment_eol( + content: &str, + new: &str, + original: &str, + every: bool, +) -> FragmentRevert { + if is_all_crlf(content) { + return match revert_fragment_eol( + &content.replace("\r\n", "\n"), + &new.replace("\r\n", "\n"), + &original.replace("\r\n", "\n"), + every, + ) { + FragmentRevert::Reverted(lf) => FragmentRevert::Reverted(lf.replace('\n', "\r\n")), + other => other, + }; + } + let (lf_new, lf_original) = (new.replace("\r\n", "\n"), original.replace("\r\n", "\n")); + for (n, o) in [(new, original), (lf_new.as_str(), lf_original.as_str())] { + if content.contains(n) { + return FragmentRevert::Reverted(if every { + content.replace(n, o) + } else { + content.replacen(n, o, 1) + }); + } + } + if content.contains(original) || content.contains(&lf_original) { + FragmentRevert::AlreadyOriginal + } else { + FragmentRevert::Drifted + } +} + +/// Whether `content` holds `fragment`, ignoring CRLF/LF differences. +pub(super) fn contains_eol(content: &str, fragment: &str) -> bool { + content.contains(fragment) + || content + .replace("\r\n", "\n") + .contains(&fragment.replace("\r\n", "\n")) +} + /// Invert the cargo rewriter's append of a `[registries.…]` block: it wrote /// `config + "\n" + block` (just `block` into an empty config) and records /// `block` — or `"\n" + block` when the config lacked a final newline (the @@ -321,8 +388,7 @@ pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { /// endings (a checkout converted them) still matches. `None` when the /// fragment is not in the file. pub(super) fn remove_appended_cargo_block(content: &str, fragment: &str) -> Option { - let crlf = content.matches("\r\n").count(); - if crlf > 0 && crlf == content.matches('\n').count() { + if is_all_crlf(content) { return remove_appended_cargo_block( &content.replace("\r\n", "\n"), &fragment.replace("\r\n", "\n"), @@ -560,10 +626,50 @@ pub async fn revert_remaining_redirect_edits( }; // `new` before `original`: original may be a substring // of new (Cargo.toml insert, maven version suffix). - if inverse == Inverse::ReplaceEveryFragment && content.contains(new) { - staged.insert(edit.path.clone(), Some(content.replace(new, original))); - group_drops.insert(idx); - } else if content.contains(new) { + // cargo: matched regardless of a CRLF/LF conversion since + // the scan (a checkout's `core.autocrlf` rewrites the + // files, never the ledger's escaped fragments). + if *group == "cargo" { + let every = inverse == Inverse::ReplaceEveryFragment; + let lf = |t: &str| t.replace("\r\n", "\n"); + if !every && lf(&content).matches(&lf(new)).count() > 1 { + refuse( + format!( + "{}: the redirected fragment appears more than once — \ + ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + match revert_fragment_eol(&content, new, original, every) { + FragmentRevert::Reverted(restored) => { + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + // Same substring guard as below. + FragmentRevert::AlreadyOriginal if !lf(new).contains(&lf(original)) => { + group_drops.insert(idx); + } + _ => { + refuse( + format!( + "{}: content matches neither the redirected nor the \ + original fragment for {} — the file drifted; re-run \ + `scan --mode hosted` to normalize", + edit.path, edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + } + continue; + } + if content.contains(new) { if content.matches(new).count() > 1 { refuse( format!( diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 66e80f51..d448b346 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -58,6 +58,7 @@ use crate::vendor::go_mod_edit::{ is_hosted_module_path, parse_replace_entries, HOSTED_GO_MODULE_PREFIX, }; +use super::replay::FragmentRevert; use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; use super::state::RedirectState; use super::FileEdit; @@ -318,27 +319,28 @@ pub async fn revert_cargo_redirect_purl( edit.path )); }; - if content.contains(new) { - // A full-id reference edit stands for every dependent's - // occurrence of that exact id. - let reverted = if edit.kind == super::CARGO_LOCK_REFERENCE_KIND { - content.replace(new, orig) - } else { - content.replacen(new, orig, 1) - }; - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); - } else if content.contains(orig) { + // A full-id reference edit stands for every dependent's + // occurrence of that exact id. Matching ignores a CRLF/LF + // conversion since the scan (a Windows checkout's CRLF + // fragments against an LF checkout, and vice versa). + let every = edit.kind == super::CARGO_LOCK_REFERENCE_KIND; + match super::replay::revert_fragment_eol(&content, new, orig, every) { + FragmentRevert::Reverted(reverted) => { + staged.insert(edit.path.clone(), Some(reverted)); + out.reverted_files.push(edit.path.clone()); + } // Already at (or unwound to) the pre-redirect fragment. - } else { - return Err(format!( - "the {} entry for {name}@{version} has drifted from the \ - recorded hosted redirect (neither the redirected nor the \ - original fragment is present); refusing to touch it — \ - re-run `scan --mode hosted` to normalize the redirect, \ - or restore the crates.io wiring manually, then re-run", - edit.path - )); + FragmentRevert::AlreadyOriginal => {} + FragmentRevert::Drifted => { + return Err(format!( + "the {} entry for {name}@{version} has drifted from the \ + recorded hosted redirect (neither the redirected nor the \ + original fragment is present); refusing to touch it — \ + re-run `scan --mode hosted` to normalize the redirect, \ + or restore the crates.io wiring manually, then re-run", + edit.path + )); + } } } "redirect_cargo_registry" => { @@ -348,7 +350,7 @@ pub async fn revert_cargo_redirect_purl( let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { continue; // config already gone }; - if !content.contains(block) { + if !super::replay::contains_eol(&content, block) { continue; // block already removed } // Keep the block while anything still references its registry @@ -379,9 +381,12 @@ pub async fn revert_cargo_redirect_purl( // it as `original`) restores that pre-existing region instead // of deleting it: the original bytes are the user's. if let Some(orig) = edit.original.as_ref().and_then(Value::as_str) { - let reverted = content.replacen(block, orig, 1); - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); + if let FragmentRevert::Reverted(reverted) = + super::replay::revert_fragment_eol(&content, block, orig, false) + { + staged.insert(edit.path.clone(), Some(reverted)); + out.reverted_files.push(edit.path.clone()); + } continue; } // The block leaves with exactly the blank separator the @@ -4383,6 +4388,82 @@ mod tests { } } + /// A ledger recorded on one side of a line-ending conversion reverts + /// files checked out on the other: a Windows scan (CRLF fragments, + /// JSON-escaped, so git never converts them) removed on an LF checkout, + /// and an LF scan removed on a CRLF (`core.autocrlf`) checkout — through + /// `remove` and through the whole-ledger replay. REGRESSION: both + /// refused as "drifted" (neither fragment found byte-for-byte). + #[tokio::test] + async fn revert_survives_a_checkout_line_ending_conversion() { + let crlf = |s: &str| s.replace('\n', "\r\n"); + let lf = |s: &str| s.replace("\r\n", "\n"); + let pristine: Vec<(&str, String)> = vec![ + ("Cargo.toml", pristine_toml()), + ( + "Cargo.lock", + format!("version = 4\n\n{}\n", pristine_lock_block()), + ), + (".cargo/config", "[net]\nretry = 2\n".to_string()), + ]; + for (scan_crlf, replay) in [(true, false), (false, false), (true, true), (false, true)] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let scanned = |s: &str| if scan_crlf { crlf(s) } else { s.to_string() }; + let checked_out = |s: &str| if scan_crlf { lf(s) } else { crlf(s) }; + let files: BTreeMap = pristine + .iter() + .map(|(rel, text)| (rel.to_string(), scanned(text))) + .collect(); + let rewrite = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + for (rel, content) in files.iter().chain(rewrite.files.iter()) { + tokio::fs::write(root.join(rel), checked_out(content)) + .await + .unwrap(); + } + // The ledger round-trips through its JSON file unchanged. + let mut state: RedirectState = serde_json::from_str( + &serde_json::to_string(&{ + let mut state = RedirectState::new(); + state.edits = rewrite.edits.clone(); + state.records.insert(PURL.to_string(), record()); + state + }) + .unwrap(), + ) + .unwrap(); + if replay { + let outcome = crate::patch::redirect::revert_remaining_redirect_edits( + root, &mut state, false, + ) + .await; + assert!( + outcome.fully_reverted(), + "scan crlf {scan_crlf}: {:?}", + outcome.refusals + ); + } else { + revert_cargo_redirect_purl(root, &mut state, PURL, false) + .await + .unwrap_or_else(|e| panic!("scan crlf {scan_crlf}: {e}")); + } + for (rel, text) in &pristine { + assert_eq!( + tokio::fs::read_to_string(root.join(rel)).await.unwrap(), + checked_out(text), + "{rel} (scan crlf {scan_crlf}, replay {replay})" + ); + } + } + } + /// The socket block was already hand-removed (the config now holds only /// user content): skip it, byte-untouched, and still succeed. #[tokio::test] From 1ca32269e64e26e0e64b571ed1d3e77c0ea429a2 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:31:50 -0400 Subject: [PATCH 13/21] fix(redirect): remove each cargo version from older ledgers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest edits are keyed by crate name, so the per-version revert told two redirected versions' edits apart by the registry each pin names. Ledgers an older CLI wrote with its name-only matcher break that rule: each version's run pinned BOTH declarations to its own registry, and a project it mis-pinned is later repaired by superseding that pin. On such a ledger `remove pkg:cargo/cfg-if@1.0.4` claimed half of a declaration's re-pin chain (or the superseded mis-pin) and refused as "drifted" — for exactly the users bug B had already hurt — and the other removal order dropped a still-pinned registry edit, leaving the created .cargo/config.toml behind. With another version of the crate still redirected, each manifest edit is now attributed to the version its declaration's requirement selects (the planner's own rule); a line without a readable requirement falls back to the registry it pins. A sibling version's registry is never claimed, and a still-referenced block keeps its ledger edit when another recorded wiring edit still pins to it, so the removal that retires that pin removes the block. A block kept for a hand pin still leaves the ledger, and the whole-ledger replay now keeps such a block too instead of deleting it from under the hand pin. Unit: a name-only two-version ledger and a repaired mis-pin ledger each remove in both orders back to the pristine project with no config and an empty ledger (removing 1.0.4 first refused before); the replay keeps a hand-pinned block. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 8 +- .../src/patch/redirect/replay.rs | 34 ++ .../src/patch/redirect/takeover.rs | 363 +++++++++++++++++- 3 files changed, 394 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8db4a93e..c9978c15 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1560,7 +1560,7 @@ static CARGO_TOML_VERSION_VAL_RE: LazyLock = LazyLock::new(|| { /// pinning every same-named declaration to one registry leaves the other /// requirement unsatisfiable there. #[derive(Debug, Clone, Copy, PartialEq)] -enum CargoReqMatch { +pub(crate) enum CargoReqMatch { Ours, NotOurs, /// The requirement also matches another locked version (or cannot be @@ -1569,7 +1569,11 @@ enum CargoReqMatch { Ambiguous, } -fn cargo_req_selects(req: Option<&str>, version: &str, other_versions: &[String]) -> CargoReqMatch { +pub(crate) fn cargo_req_selects( + req: Option<&str>, + version: &str, + other_versions: &[String], +) -> CargoReqMatch { let unknown = if other_versions.is_empty() { CargoReqMatch::Ours } else { diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index bb80dc9f..40b8f275 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -435,6 +435,19 @@ pub async fn revert_remaining_redirect_edits( groups.entry(group).or_default().push(idx); } + // Where a cargo `[registries.…]` block can still be referenced from: + // the root manifest and lock, plus every manifest the ledger pinned. + let mut cargo_probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; + for edit in state + .edits + .iter() + .filter(|e| e.kind == "redirect_cargo_toml_dep") + { + if !cargo_probes.contains(&edit.path) { + cargo_probes.push(edit.path.clone()); + } + } + let mut drop_indices: BTreeSet = BTreeSet::new(); let mut refused_groups: BTreeSet<&'static str> = BTreeSet::new(); let mut pending_warnings: Vec<(String, String)> = Vec::new(); @@ -744,6 +757,27 @@ pub async fn revert_remaining_redirect_edits( refused_groups.insert(group); continue 'group; }; + // A block something still references (a hand-pinned dep) + // stays: removing it would leave that pin naming an + // undefined registry. The reverse walk has already + // unwound this ledger's own references. + let reg = edit.key.as_deref().unwrap_or_default(); + let index = new.split('"').nth(1).unwrap_or_default(); + let mut referenced = false; + for probe in &cargo_probes { + if let Ok(Some(text)) = staged_read(&staged, project_root, probe).await { + if (!reg.is_empty() && text.contains(reg)) + || (!index.is_empty() && text.contains(index)) + { + referenced = true; + break; + } + } + } + if referenced { + group_drops.insert(idx); + continue; + } match staged_read(&staged, project_root, &edit.path).await { Ok(Some(content)) => { // Absent fragment == already clean. A config the diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index d448b346..5280cf91 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -228,35 +228,56 @@ pub async fn revert_cargo_redirect_purl( // Manifest edits are keyed by crate NAME (the shared golden ledger // shape), so when another version of the crate is redirected too, its - // pins carry the same key: skip every manifest edit whose pin names a - // registry of a sibling version's lineage. Without a sibling the claim - // stays name-wide, as before. - let sibling_uuids: HashSet = state + // pins carry the same key. Attribute each such edit to the version its + // declaration's requirement selects — the planner's own rule, and the + // only one that also holds for ledgers an older, name-only CLI wrote + // (a declaration of one version pinned to, then re-pinned from, the + // other version's registry). An edit whose line carries no readable + // requirement (a table-form header / registry line) falls back to the + // registry it pins: skipped when that is a sibling lineage's. Without a + // sibling the claim stays name-wide, as before. + let siblings: Vec<(String, HashSet)> = state .records .iter() .filter(|(key, _)| **key != record_key) .filter_map(|(key, rec)| { let (n, v) = parse_cargo_purl(strip_purl_qualifiers(key))?; - (n == name && v != version).then(|| cargo_lineage(state, &n, &v, &rec.uuid)) + (n == name && v != version) + .then(|| (v.to_string(), cargo_lineage(state, &n, &v, &rec.uuid))) }) - .flatten() .collect(); + let sibling_versions: Vec = siblings.iter().map(|(v, _)| v.clone()).collect(); + let sibling_uuids: HashSet = siblings.into_iter().flat_map(|(_, u)| u).collect(); + let is_my_manifest_edit = |e: &FileEdit| { + if sibling_versions.is_empty() { + return true; + } + let req = cargo_declared_req(e.original.as_ref()); + match super::cargo_req_selects(req.as_deref(), &version, &sibling_versions) { + super::CargoReqMatch::Ours if req.is_some() => true, + super::CargoReqMatch::NotOurs => false, + _ => !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u)), + } + }; let is_wiring_edit = |e: &FileEdit| { (e.kind == "redirect_cargo_toml_dep" && e.key.as_deref() == Some(name.as_str()) - && !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u))) + && is_my_manifest_edit(e)) || (is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) }; // Registry blocks tie to this purl via the `socket-patch-` names in // its record + wiring edits (a patch uuid is per purl, so this cannot - // claim another package's block). + // claim another package's block) — never a sibling version's, which a + // re-pinned legacy declaration also names. let mut uuids: HashSet = HashSet::new(); uuids.insert(state.records[&record_key].uuid.clone()); for e in state.edits.iter().filter(|e| is_wiring_edit(e)) { for v in [&e.original, &e.new] { if let Some(s) = v.as_ref().and_then(Value::as_str) { for c in SOCKET_REGISTRY_UUID.captures_iter(s) { - uuids.insert(c[1].to_string()); + if !sibling_uuids.contains(&c[1]) { + uuids.insert(c[1].to_string()); + } } } } @@ -292,6 +313,19 @@ pub async fn revert_cargo_redirect_purl( let mut out = RedirectRevert::default(); let mut staged: Staged = Staged::new(); + // A block kept because it is still referenced normally leaves the + // ledger (it now belongs to whatever hand pin references it). The + // exception is a block another, still-recorded wiring edit pins to (an + // older CLI's cross-version pin): that edit stays in the ledger so the + // removal that retires the last such pin also removes the block. + let still_pinned: HashSet = state + .edits + .iter() + .enumerate() + .filter(|(i, e)| e.kind == "redirect_cargo_toml_dep" && !mine.contains(i)) + .flat_map(|(_, e)| registry_uuids(e.new.as_ref()).collect::>()) + .collect(); + let mut kept: HashSet = HashSet::new(); // Newest-first: the hosted flow appends edits, so reverse index order // unwinds re-redirect chains correctly (each step's `original` is the // previous step's `new`), and the registry-block removals — recorded @@ -374,6 +408,12 @@ pub async fn revert_cargo_redirect_purl( } } if referenced { + if reg + .strip_prefix("socket-patch-") + .is_some_and(|u| still_pinned.contains(u)) + { + kept.insert(i); + } continue; } // A REGENERATED block (`action: "rewritten"` — the rewriter @@ -419,6 +459,7 @@ pub async fn revert_cargo_redirect_purl( flush_staged(project_root, &staged, &StagedBytes::new()).await?; } + let mine: Vec = mine.into_iter().filter(|i| !kept.contains(i)).collect(); drop_claimed(state, mine, &record_key); Ok(out) } @@ -534,6 +575,29 @@ pub async fn revert_golang_redirect_purl( }) } +/// The version requirement a recorded Cargo.toml declaration line carries: +/// the `version = "…"` of an inline table, or the value of a plain +/// `name = "…"` entry. `None` for a table-form header or `registry` line. +fn cargo_declared_req(fragment: Option<&Value>) -> Option { + static PLAIN_ENTRY: LazyLock = LazyLock::new(|| { + Regex::new(r#"^\s*(?:"[^"]+"|'[^']+'|[A-Za-z0-9_-]+)\s*=\s*"([^"]+)""#) + .expect("static plain-entry regex is valid") + }); + static INLINE_VERSION: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bversion\s*=\s*"([^"]*)""#).expect("static inline-version regex is valid") + }); + let line = fragment.and_then(Value::as_str)?; + if line.contains('\n') || line.trim_start().starts_with('[') { + return None; + } + let req = if line.contains('{') { + INLINE_VERSION.captures(line)?[1].to_string() + } else { + PLAIN_ENTRY.captures(line)?[1].to_string() + }; + semver::VersionReq::parse(req.trim()).is_ok().then_some(req) +} + /// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` /// hold the whole lock fragment as a string, the edit's `key` embeds /// `@`, and the revert is a `replacen(new, original)`. @@ -1578,6 +1642,253 @@ mod tests { .await; } + const UUID_LEGACY: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; + + /// The two-declaration cfg-if project (1.0.4 as `cfg-if`, 0.1.10 as a + /// renamed `cfg-if-legacy`) and its crates.io lock. + fn multi_version_project() -> (String, String) { + let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; + let lock = format!( + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ + source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", + "8".repeat(64), + pristine_lock_block() + ); + (toml.to_string(), lock) + } + + /// Remove `order` from `state` over `root`, then require the pristine + /// project back with no config and an empty ledger. + async fn remove_all_and_expect_pristine( + root: &Path, + mut state: RedirectState, + order: &[&str], + toml: &str, + lock: &str, + ) { + for purl in order { + revert_cargo_redirect_purl(root, &mut state, purl, false) + .await + .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); + } + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml, + "{order:?}" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock, + "{order:?}" + ); + assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); + assert!( + state.edits.is_empty() && state.records.is_empty(), + "{order:?}: {:?}", + state.edits + ); + } + + /// A ledger an older CLI wrote with its name-only manifest matcher for + /// both cfg-if versions: each run pinned BOTH declarations to its own + /// registry, so the file ends with both on the 0.1.10 registry and the + /// ledger holds plain→1.0.4 then 1.0.4→0.1.10 edits for both lines. + /// REGRESSION: removing 1.0.4 first claimed the plain→1.0.4 edit of the + /// 0.1.10 declaration but not its 1.0.4→0.1.10 successor, found neither + /// fragment and refused as drifted; removing 0.1.10 first dropped the + /// still-referenced 0.1.10 registry edit and left the config behind. + #[tokio::test] + async fn legacy_name_only_multi_version_ledger_removes_in_any_order() { + let (toml, lock) = multi_version_project(); + let reg = |uuid: &str| format!("socket-patch-{uuid}"); + let index = |uuid: &str| format!("sparse+http://127.0.0.1:5555/{uuid}/index/"); + let line1 = |r: Option<&str>| match r { + None => "cfg-if = \"1.0\"".to_string(), + Some(r) => format!("cfg-if = {{ version = \"1.0\", registry = \"{r}\" }}"), + }; + let line2 = |r: Option<&str>| { + match r { + None => "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }".to_string(), + Some(r) => format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{r}\" }}" + ), + } + }; + let block = |version: &str, source: &str, cksum: &str| { + format!( + "[[package]]\nname = \"cfg-if\"\nversion = \"{version}\"\nsource = \"{source}\"\n\ + checksum = \"{cksum}\"" + ) + }; + let (a, b) = (reg(UUID), reg(UUID_LEGACY)); + let edit = + |path: &str, kind: &str, key: &str, orig: Option, new: String| FileEdit { + path: path.to_string(), + kind: kind.to_string(), + action: if orig.is_some() { "rewritten" } else { "added" }.to_string(), + key: Some(key.to_string()), + original: orig.map(Value::String), + new: Some(Value::String(new)), + }; + let registry = |uuid: &str| { + edit( + ".cargo/config.toml", + "redirect_cargo_registry", + ®(uuid), + None, + format!("[registries.{}]\nindex = \"{}\"\n", reg(uuid), index(uuid)), + ) + }; + let lock_edit = |version: &str, uuid: &str, crates_cksum: &str| { + edit( + "Cargo.lock", + "redirect_cargo_lock_entry", + &format!("cfg-if@{version}"), + Some(block(version, CRATES_IO, crates_cksum)), + block(version, &index(uuid), &"a".repeat(64)), + ) + }; + let toml_edit = |orig: String, new: String| { + edit( + "Cargo.toml", + "redirect_cargo_toml_dep", + "cfg-if", + Some(orig), + new, + ) + }; + let edits = vec![ + registry(UUID), + toml_edit(line1(None), line1(Some(&a))), + toml_edit(line2(None), line2(Some(&a))), + lock_edit("1.0.4", UUID, &"9".repeat(64)), + registry(UUID_LEGACY), + toml_edit(line1(Some(&a)), line1(Some(&b))), + toml_edit(line2(Some(&a)), line2(Some(&b))), + lock_edit("0.1.10", UUID_LEGACY, &"8".repeat(64)), + ]; + let live_toml = toml + .replace(&line1(None), &line1(Some(&b))) + .replace(&line2(None), &line2(Some(&b))); + let live_lock = lock + .replace( + &block("1.0.4", CRATES_IO, &"9".repeat(64)), + &block("1.0.4", &index(UUID), &"a".repeat(64)), + ) + .replace( + &block("0.1.10", CRATES_IO, &"8".repeat(64)), + &block("0.1.10", &index(UUID_LEGACY), &"a".repeat(64)), + ); + let live_config = format!( + "{}\n{}", + edits[0].new.as_ref().and_then(Value::as_str).unwrap(), + edits[4].new.as_ref().and_then(Value::as_str).unwrap() + ); + for order in [ + ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], + ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.toml"), &live_toml) + .await + .unwrap(); + tokio::fs::write(root.join("Cargo.lock"), &live_lock) + .await + .unwrap(); + tokio::fs::write(root.join(".cargo/config.toml"), &live_config) + .await + .unwrap(); + let mut state = RedirectState::new(); + state.edits = edits.clone(); + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_LEGACY.to_string(); + state + .records + .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); + remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; + } + } + + /// An older CLI redirected only 1.0.4 and its name-only matcher also + /// pinned the 0.1.10 declaration to 1.0.4's registry; the current + /// planner then repaired it (superseding that pin with 0.1.10's own + /// registry) while redirecting 0.1.10. REGRESSION: removing 1.0.4 + /// claimed the old mis-pin edit, whose fragments were both gone, and + /// refused as drifted. + #[tokio::test] + async fn repaired_legacy_mispin_removes_in_any_order() { + let (toml, lock) = multi_version_project(); + let legacy = "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }"; + let mispinned = format!( + "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-{UUID}\" }}" + ); + // The old CLI's run: 1.0.4 only, plus its mis-pin of the legacy line. + let mut files: BTreeMap = BTreeMap::new(); + files.insert("Cargo.toml".into(), toml.clone()); + files.insert("Cargo.lock".into(), lock.clone()); + let old_run = crate::patch::redirect::rewrite_registry_redirect( + &files, + &[cargo_dep("cfg-if", "1.0.4", UUID)], + ); + let mut edits = old_run.edits.clone(); + let at = edits + .iter() + .position(|e| e.kind == "redirect_cargo_toml_dep") + .unwrap(); + let mut mispin = edits[at].clone(); + mispin.original = Some(Value::String(legacy.to_string())); + mispin.new = Some(Value::String(mispinned.clone())); + edits.insert(at + 1, mispin); + let mut live = files.clone(); + live.extend(old_run.files.clone()); + let t = live["Cargo.toml"].replace(legacy, &mispinned); + live.insert("Cargo.toml".into(), t); + // The current CLI's run with both patches repairs the mis-pin. + let new_run = crate::patch::redirect::rewrite_registry_redirect( + &live, + &[ + cargo_dep("cfg-if", "1.0.4", UUID), + cargo_dep("cfg-if", "0.1.10", UUID_LEGACY), + ], + ); + assert!(new_run.warnings.is_empty(), "{:?}", new_run.warnings); + edits.extend(new_run.edits.clone()); + live.extend(new_run.files.clone()); + for order in [ + ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], + ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (rel, content) in &live { + let path = root.join(rel); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, content).await.unwrap(); + } + let mut state = RedirectState::new(); + state.edits = edits.clone(); + state.records.insert(PURL.to_string(), record()); + let mut old = record(); + old.uuid = UUID_LEGACY.to_string(); + state + .records + .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); + remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; + } + } + /// The whole-ledger replay (rollback) inverts a v1 full-id reference /// named by several dependents at every occurrence. #[tokio::test] @@ -4500,6 +4811,40 @@ mod tests { assert!(state.edits.is_empty(), "edits dropped"); } + /// The whole-ledger replay keeps a hand-pinned block too (it removed + /// it unconditionally, leaving the hand pin naming an undefined + /// registry), while still unwinding the wiring it owns. + #[tokio::test] + async fn replay_keeps_a_registry_block_still_referenced() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + let reg = format!("socket-patch-{UUID}"); + let pinned_line = format!("other = {{ version = \"1.0\", registry = \"{reg}\" }}\n"); + let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + tokio::fs::write( + root.join("Cargo.toml"), + format!("{wired_toml}{pinned_line}"), + ) + .await + .unwrap(); + let outcome = + crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; + assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); + let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + assert!(cfg.contains(®), "block kept while referenced: {cfg}"); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + format!("{}{pinned_line}", pristine_toml()) + ); + assert!(state.edits.is_empty(), "{:?}", state.edits); + } + /// A user hand-pinned a SECOND dep to the socket registry: the block is /// kept while anything still references it (the documented defensive /// keep), and the takeover still reverts the wiring it owns. From 033a595fd79f8c63a715df0782ee771e6435c3c5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:35:05 -0400 Subject: [PATCH 14/21] fix(redirect): keep identical hosted edits from one scan The hosted flow appends a run's edits to the redirect ledger, skipping any edit already in it. The check also ran against the edits appended earlier in the same run, so identical edits from one rewrite collapsed into one. The cargo rewriter records one edit per occurrence, and a Cargo.toml that declares the crate with the same line in two sections (`[dependencies]` and `[dev-dependencies]`) yields two identical edits. With only one in the ledger, `remove` reverted one pin, kept the other and the registry block it references, and still reported success. Edits are now deduplicated only against the ledger as the run found it, so a re-run still records nothing twice. Real cargo: e2e_redirect_cargo_shapes two-sections (scan, fresh `--locked` build, VEX, byte-identical `remove`); it failed on the leftover pin before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 9 +++++- .../tests/e2e_redirect_cargo_shapes.rs | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 4c36a192..2c218c53 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -2592,6 +2592,13 @@ pub(crate) async fn run_redirect_selected( } } } + // Dedup against the ledger as this run found it, never within + // this run: one run legitimately records identical edits (a + // Cargo.toml declaring the crate with the same line in two + // sections), and each one reverts one occurrence — collapsing + // them made `remove` leave the second pin (and its registry + // block) in place while reporting success. + let recorded = ledger.edits.len(); for edit in &rewrite.edits { let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) && rebased.iter().any(|&t| { @@ -2601,7 +2608,7 @@ pub(crate) async fn run_redirect_selected( && old.key == edit.key && old.new == edit.new }); - if !is_rebased && !ledger.edits.contains(edit) { + if !is_rebased && !ledger.edits[..recorded].contains(edit) { ledger.edits.push(edit.clone()); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index c7222674..443842ae 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -13,6 +13,8 @@ //! * `workspace_direct_member` — a virtual workspace whose root pins //! `[workspace.dependencies]`, one member inheriting and one declaring the //! crate itself: both members build against the patched copy. +//! * `two_sections` — the same declaration line in `[dependencies]` and +//! `[dev-dependencies]`: both pins revert on `remove`. //! * `direct_and_transitive` — the crate is also a dependency of another //! crates.io crate: hosted mode refuses it loudly and rewrites nothing. //! @@ -776,6 +778,35 @@ async fn cargo_hosted_config_trailing_bytes_are_restored() { } } +/// The crate declared with the SAME line in two dependency sections: the +/// rewrite records two identical manifest edits, and the ledger must keep +/// both — it collapsed them, so `remove` reverted one pin, kept the other +/// (and the registry block it references) and still reported success. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_same_line_in_two_sections_removes_cleanly() { + let shape = Shape { + tag: "two-sections", + files: vec![ + ( + "Cargo.toml", + format!( + "{}\n[dev-dependencies]\ncfg-if = \"1.0.4\"\n", + consumer_manifest("cfg-if = \"1.0.4\"\n") + ), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: vec![( + "src/main.rs", + "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), + )], + crlf: false, + refused: None, + }; + let _ = run_shape(shape).await; +} + /// Bug F: a workspace member's own declaration must be pinned too. #[tokio::test(flavor = "multi_thread")] async fn cargo_hosted_workspace_member_declaration_is_pinned() { From 5949248409815900f912d84a97d06d85cb2b6519 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 18:35:56 -0400 Subject: [PATCH 15/21] docs: describe the hosted cargo redirect fixes CLI_CONTRACT.md still said the hosted rewriter reads only root candidate files, but cargo now also reads and pins workspace-member and in-root path-dependency manifests, which can show up in `rewrittenFiles`. The contract now covers that (members globs minus `exclude`, no symlinks, never `.socket/`), the new `redirect_cargo_transitive_dependents` refusal, the transitive-only and multi-version refusals, and CRLF handling across a checkout conversion. CHANGELOG [Unreleased] gains Fixed entries for this branch's user-visible hosted cargo changes, and docs/ecosystems.md notes that hosted cargo reaches direct dependencies only. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 24 ++++++++++++++++++++++++ crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- docs/ecosystems.md | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1f2d5a..0527a11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -967,6 +967,30 @@ into the new version's section — see docs/releasing.md. vendored rewrites now follow the `[metadata]` checksum table and rewrite dependents' full-id references, so `cargo --locked` accepts the lock (and the revert stays byte-identical). +- **Hosted cargo pins every declaration of the patched version.** Each + version of a multi-version crate is pinned only in the declarations whose + requirement selects it (a requirement matching several locked versions is + refused `redirect_cargo_toml_dep_unrewritable`), and workspace-member and + in-root path-dependency manifests are pinned beside the root, so + `cargo --locked` accepts the redirected lock. Member discovery never + follows a symbolic link, so nothing outside the project is rewritten. +- **Hosted cargo refuses crates a pin cannot reach.** A crate another + `Cargo.lock` package also depends on (a crates.io or git crate, or a path + package outside the project) now warns + `redirect_cargo_transitive_dependents` and is skipped instead of being + reported redirected while that package compiled the unpatched copy; a + transitive-only crate's `redirect_cargo_toml_dep_not_found` detail now + says so and points to `--mode vendored`. +- **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`, + `Cargo.lock` and cargo configs are rewritten with their endings kept + (they were refused), and `remove` / rollback still find the recorded + edits after a checkout converts the line endings. +- **Hosted cargo `remove` restores every byte, in any order.** An appended + registry block leaves the user's config exactly as it was (trailing blank + lines or a missing final newline included) and a created config is + deleted with the last block; v1-lock and multi-version patches, ledgers + written by older CLIs included, can be removed in any order; and a crate + declared with the same line in two sections gets both pins reverted. - **yarn 4.0.x checksums keep the lock's own spelling.** Vendored and hosted berry rewrites write bare-hex `cacheKey: 10c0` checksums when the lock does, so `yarn install --immutable` no longer fails with YN0028. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4c4595fc..1491727f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -125,7 +125,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on; without a `Cargo.lock` this check cannot run), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, and a requirement that also matches another locked version of the crate is refused `redirect_cargo_toml_dep_unrewritable` — each a transactional skip, never recorded or attested. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index d070106f..1ca1b71a 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -16,7 +16,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. |-----------|------------------------|------------------------------|--------------------------| | npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | -| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | +| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored` | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | From 920767cac1a469222d7e1868ddfed4c4314dcfb0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 19:02:47 -0400 Subject: [PATCH 16/21] fix(redirect): refuse cargo requirements the patch misses A crate declared only with version requirements the patched version does not satisfy resolves those declarations to another version, so a pin cannot reach the locked crate. Hosted cargo now refuses it with redirect_cargo_toml_dep_unrewritable, the code the Socket backend uses for the same shape, instead of reporting it not found. Shared golden fixtures pin both refusals the backend and CLI must agree on: requirement-excludes-patched-version, path-dependency and transitive-dependents-git (a git crate also depending on the patched crate). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/patch/redirect/mod.rs | 76 ++++++++++++++++++- .../cargo/path-dependency/expected-edits.json | 1 + .../path-dependency/expected-warnings.json | 3 + .../cargo/path-dependency/input/Cargo.lock | 14 ++++ .../cargo/path-dependency/input/Cargo.toml | 7 ++ .../cargo/path-dependency/overrides.json | 22 ++++++ .../expected-edits.json | 1 + .../expected-warnings.json | 3 + .../input/Cargo.lock | 16 ++++ .../input/Cargo.toml | 7 ++ .../overrides.json | 22 ++++++ .../expected-edits.json | 1 + .../expected-warnings.json | 3 + .../input/Cargo.lock | 25 ++++++ .../input/Cargo.toml | 8 ++ .../transitive-dependents-git/overrides.json | 22 ++++++ .../vex-discover-golden/redirect-cargo.json | 24 ++++++ 19 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0527a11d..bb5bfba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -980,7 +980,11 @@ into the new version's section — see docs/releasing.md. `redirect_cargo_transitive_dependents` and is skipped instead of being reported redirected while that package compiled the unpatched copy; a transitive-only crate's `redirect_cargo_toml_dep_not_found` detail now - says so and points to `--mode vendored`. + says so and points to `--mode vendored`. A crate declared only with + requirements the patched version does not satisfy (cargo resolves those + declarations to another version) is refused + `redirect_cargo_toml_dep_unrewritable`, the Socket backend's code for the + same shape, instead of `redirect_cargo_toml_dep_not_found`. - **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`, `Cargo.lock` and cargo configs are rewritten with their endings kept (they were refused), and `remove` / rollback still find the recorded diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 1491727f..07f27bc7 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -125,7 +125,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on; without a `Cargo.lock` this check cannot run), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, and a requirement that also matches another locked version of the crate is refused `redirect_cargo_toml_dep_unrewritable` — each a transactional skip, never recorded or attested. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on; without a `Cargo.lock` this check cannot run), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c9978c15..e9cfde5d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -879,6 +879,7 @@ fn rewrite_cargo( // members' `workspace = true` inheritors. let mut root_workspace: BTreeMap = BTreeMap::new(); let mut toml_plans: Vec<(usize, CargoTomlPlan)> = Vec::new(); + let mut excluded: Vec<(String, String)> = Vec::new(); let mut refused: Option<(String, String)> = None; for (i, (path, text)) in manifests.iter().enumerate() { match plan_cargo_toml( @@ -894,6 +895,7 @@ fn rewrite_cargo( if path == "Cargo.toml" { root_workspace = plan.workspace.clone(); } + excluded.extend(plan.excluded.iter().map(|req| (path.clone(), req.clone()))); if plan.found { toml_plans.push((i, plan)); } @@ -915,6 +917,21 @@ fn rewrite_cargo( }); continue; } + // Declared, but no declaration's requirement accepts the patched + // version: cargo resolves each to another version, so a pin cannot + // reach the locked one (the TS twin's requirement-coverage refusal). + if toml_plans.is_empty() && !excluded.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_unrewritable".into(), + detail: cargo_requirement_excludes_detail( + &dep.name, + &dep.version, + &excluded, + cargo_lock.as_deref(), + ), + }); + continue; + } if toml_plans.is_empty() { result.warnings.push(RewriteWarning { code: "redirect_cargo_toml_dep_not_found".into(), @@ -1119,6 +1136,37 @@ fn cargo_not_declared_detail( } } +/// The refusal for a crate every declaration of which requires another +/// version (`excluded`: each declaring manifest and its requirement). +fn cargo_requirement_excludes_detail( + crate_name: &str, + version: &str, + excluded: &[(String, String)], + lock: Option<&str>, +) -> String { + let declared = excluded + .iter() + .map(|(path, req)| format!("\"{req}\" in {path}")) + .collect::>() + .join(", "); + let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); + let locked = lock.is_some_and(|lock| { + lock.match_indices(head.as_str()) + .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') + }); + let remedy = if locked { + "; Cargo.lock resolves it for another package, which a pin cannot reach — patch it \ + with `socket-patch scan --mode vendored`" + } else { + "" + }; + format!( + "{crate_name} is declared as {declared}, which {version} does not satisfy (cargo \ + resolves that declaration to another version){remedy}; dependency skipped (nothing \ + rewritten)" + ) +} + /// The `[package] name` a manifest declares (`None` for a virtual workspace /// root or an unparseable file). fn cargo_manifest_package_name(text: &str) -> Option { @@ -1501,6 +1549,10 @@ struct CargoTomlPlan { /// This manifest's `[workspace.dependencies]` verdicts, per key — what /// its members' `workspace = true` inheritors resolve against. workspace: BTreeMap, + /// The requirements of this manifest's declarations of the crate that + /// do NOT accept the patched version (cargo resolves each to another + /// version). + excluded: Vec, } /// How one occurrence of the dep will be handled. @@ -1665,6 +1717,7 @@ fn plan_cargo_toml( // entry lands (or already carries) the pin — satisfies `workspace = // true` inheritors of the same key — or names another version. let mut ws_entries: BTreeMap = BTreeMap::new(); + let mut excluded: Vec = Vec::new(); let mut section = CargoTomlSection::Other; for (idx, raw) in lines.iter().enumerate() { @@ -1728,13 +1781,14 @@ fn plan_cargo_toml( }) }) }; + let req = find_value("version").map(|(_, v)| v); let selects = if has("workspace") { CargoReqMatch::Ours } else { - let req = find_value("version").map(|(_, v)| v); cargo_req_selects(req.as_deref(), version, other_versions) }; if selects == CargoReqMatch::NotOurs { + excluded.extend(req); if ws { ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); } @@ -1851,6 +1905,7 @@ fn plan_cargo_toml( let req = version_val_re.captures(inner).map(|c| c[1].to_string()); match cargo_req_selects(req.as_deref(), version, other_versions) { CargoReqMatch::NotOurs => { + excluded.extend(req); if workspace { ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); } @@ -1942,6 +1997,7 @@ fn plan_cargo_toml( .as_str(); match cargo_req_selects(Some(req), version, other_versions) { CargoReqMatch::NotOurs => { + excluded.push(req.to_string()); if workspace { ws_entries.insert(key.clone(), CargoWorkspaceEntry::OtherVersion); } @@ -1985,6 +2041,7 @@ fn plan_cargo_toml( changed: false, found: false, workspace: ws_entries, + excluded: excluded.clone(), }; if pending.is_empty() { return Ok(not_found(ws_entries)); @@ -2070,6 +2127,7 @@ fn plan_cargo_toml( changed, found: true, workspace: ws_entries, + excluded, }) } @@ -9233,9 +9291,10 @@ mod tests { } /// A declaration whose requirement excludes the patched version is not - /// the patched crate: nothing to pin. + /// the patched crate, and a pin there cannot reach the locked one: the + /// requirement-coverage refusal (the TS twin's code), nothing written. #[test] - fn cargo_requirement_excluding_the_patched_version_is_not_found() { + fn cargo_requirement_excluding_the_patched_version_is_unrewritable() { let mut files = BTreeMap::new(); files.insert( "Cargo.toml".to_string(), @@ -9244,7 +9303,16 @@ mod tests { ); let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!(r.files.is_empty(), "{:?}", r.files); - assert_eq!(warning_codes(&r), vec!["redirect_cargo_toml_dep_not_found"]); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_toml_dep_unrewritable"] + ); + assert!( + r.warnings[0].detail.contains("\"2\" in Cargo.toml"), + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); } /// A `workspace = true` inheritor of the entry that names ANOTHER diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json new file mode 100644 index 00000000..5259ffdc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_unrewritable" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock new file mode 100644 index 00000000..2c0622f2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml new file mode 100644 index 00000000..cf1d2a6b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { path = "third_party/serde", version = "1.0.190" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/path-dependency/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json new file mode 100644 index 00000000..5259ffdc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_toml_dep_unrewritable" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock new file mode 100644 index 00000000..bf6fa585 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml new file mode 100644 index 00000000..88553e52 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "=1.0.180" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/requirement-excludes-patched-version/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json new file mode 100644 index 00000000..97c5d3b0 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_transitive_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock new file mode 100644 index 00000000..621622b6 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "myapp" +version = "0.1.0" +dependencies = [ + "serde", + "serde-helper", +] + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d3c334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9c0000" + +[[package]] +name = "serde-helper" +version = "0.3.1" +source = "git+https://github.com/example/serde-helper#0123456789abcdef0123456789abcdef01234567" +dependencies = [ + "serde", +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml new file mode 100644 index 00000000..b5bc2c4b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde-helper = { git = "https://github.com/example/serde-helper" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/transitive-dependents-git/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index fb76d036..fe741248 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -266,6 +266,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/path-dependency/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/renamed/expected": { "refs": [ { @@ -321,6 +329,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/requirement-excludes-patched-version/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/rerun/input": { "refs": [ { @@ -530,6 +546,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/transitive-dependents-git/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/transitive-refusal/input": { "refs": [], "diagnostics": [], From 8e0410b91a8603651e61933ade0b2a414135d055 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 23:38:42 -0400 Subject: [PATCH 17/21] fix(vendor): see a hosted pin in a workspace member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted rewriter pins the patched crate in every manifest that declares it, workspace members included, but the guard that refuses to vendor on top of a live hosted redirect read the root Cargo.toml alone. A member-only pin was invisible, so with the redirect ledger gone vendor wired [patch.crates-io] beside a member still resolving the crate from the hosted registry — where [patch.crates-io] does not reach — and reported success. The guard now reads the root manifest and every member manifest the rewriter itself discovers, and the refusal names the file it found. Real cargo: mode_migration_cargo's takeover refusal gains the shape the guard exists for — the lock restored from version control while a table-form manifest pin survives. Co-Authored-By: Claude Opus 5 --- .../tests/mode_migration_cargo.rs | 48 ++++++++++++ crates/socket-patch-core/src/vendor/cargo.rs | 74 +++++++++++++++++-- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index d8cd2fb6..de3d8197 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -1087,6 +1087,9 @@ async fn vendor_over_hosted_without_ledger_is_refused() { let purl = format!("pkg:cargo/{DEP}@{version}"); let orig = std::fs::read(crate_dir.join("src/lib.rs")).unwrap(); let patched: Vec = [orig.as_slice(), PATCH_SUFFIX.as_bytes()].concat(); + // Kept for the second arm: the pristine crates.io lock a checkout + // restores over the redirected one. + let pristine_lock = std::fs::read_to_string(proj.join("Cargo.lock")).unwrap(); let server = MockServer::start().await; let crate_bytes = @@ -1142,4 +1145,49 @@ async fn vendor_over_hosted_without_ledger_is_refused() { assert!(!read(&proj, ".cargo/config.toml").contains("[patch.crates-io]")); assert!(!read(&proj, "Cargo.toml").contains("[patch.crates-io]")); assert!(!vendor_ledger_claims(&proj, &purl)); + + // The half-reverted state this guard really exists for: the lock is + // back on crates.io (restored from version control, or re-resolved) + // while the manifest pin survives — here in the TABLE form the hosted + // rewriter writes for a `[dependencies.]` declaration, which a + // ` = { … }` probe reads as "not redirected". + let registry = toml_before + .split("registry = \"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("the hosted scan pinned a socket-patch registry") + .to_string(); + assert!(registry.starts_with("socket-patch-"), "{registry}"); + std::fs::write(proj.join("Cargo.lock"), &pristine_lock).unwrap(); + std::fs::write( + proj.join("Cargo.toml"), + format!( + "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ + [dependencies.{DEP}]\nversion = \"1.0\"\nregistry = \"{registry}\"\n" + ), + ) + .unwrap(); + let table_toml = read(&proj, "Cargo.toml"); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + &cargo_home, + ); + assert_eq!( + code, 1, + "the table-form pin must fail closed too: {stdout}\n{stderr}" + ); + assert!( + stdout.contains("hosted_redirect_live"), + "actionable refusal code missing: {stdout}" + ); + assert_eq!(read(&proj, "Cargo.toml"), table_toml); + assert_eq!(read(&proj, "Cargo.lock"), pristine_lock); + assert!(!vendor_ledger_claims(&proj, &purl)); } diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index bb783c5b..3a8143dd 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -232,8 +232,9 @@ pub async fn vendored_entry_in_use(entry: &VendorEntry, project_root: &Path) -> } /// A LIVE hosted-redirect wiring for `name`+`version`: the lock resolves it -/// from a Socket hosted patch registry, or Cargo.toml pins it to a -/// `socket-patch-` registry (the shapes `scan --mode hosted` writes). +/// from a Socket hosted patch registry, or a manifest — the root or any +/// workspace member — pins it to a `socket-patch-` registry in any +/// declaration shape (the shapes `scan --mode hosted` writes). /// Registry indexes are matched against the config-declared /// `[registries.socket-patch-*]` URLs, not a hardcoded host, so test /// registries are recognised too. `Some(description)` when residue is found. @@ -250,11 +251,23 @@ async fn hosted_redirect_residue(project_root: &Path, name: &str, version: &str) )); } } - // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file check) — - // a FIFO planted as `Cargo.toml` would otherwise wedge every wet vendor - // run in an open(2) that waits for a writer; an unreadable manifest has - // no readable residue, matching the read_to_string Err arm this guards. - if let Ok(toml) = read_regular_to_string(&project_root.join("Cargo.toml")).await { + // The root manifest AND every workspace-member manifest: the hosted + // rewriter pins the crate in each one that declares it, so a member's + // surviving pin is residue just as much as the root's. + let root = project_root.to_path_buf(); + let members = + tokio::task::spawn_blocking(move || crate::utils::cargo_workspace::member_manifests(&root)) + .await + .unwrap_or_default(); + for rel in std::iter::once("Cargo.toml".to_string()).chain(members) { + // Guarded read (`open_regular_file`: O_NONBLOCK + regular-file + // check) — a FIFO planted as `Cargo.toml` would otherwise wedge + // every wet vendor run in an open(2) that waits for a writer; an + // unreadable manifest has no readable residue, matching the + // read_to_string Err arm this guards. + let Ok(toml) = read_regular_to_string(&project_root.join(&rel)).await else { + continue; + }; // The rewriter's own reader, not a single-line regex: the hosted pin // is just as often a standalone `registry = …` line under a // `[dependencies.]` header, or sits under a renamed key @@ -263,7 +276,7 @@ async fn hosted_redirect_residue(project_root: &Path, name: &str, version: &str) // the half-reverted state this guard exists for. if let Some(reg) = crate::patch::redirect::cargo_socket_registry_pin(&toml, name) { return Some(format!( - "Cargo.toml pins `{name}` to the socket-patch hosted registry `{reg}`" + "{rel} pins `{name}` to the socket-patch hosted registry `{reg}`" )); } } @@ -3971,6 +3984,51 @@ mod tests { } } + /// A pin that survives only in a WORKSPACE MEMBER's manifest is residue + /// too: the hosted rewriter pins every member that declares the crate, + /// so a root-only probe misses exactly the projects it was extended to + /// cover. + #[tokio::test] + async fn test_refuses_live_hosted_redirect_pinned_in_a_member() { + let (dir, blobs, pristine, record) = fixture().await; + let root = dir.path(); + tokio::fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"b\"]\n\n\ + [package]\nname = \"app\"\nversion = \"0.1.0\"\n", + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join("b")).await.unwrap(); + tokio::fs::write( + root.join("b/Cargo.toml"), + format!( + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies.cfg-if]\nversion = \"1\"\n\ + registry = \"socket-patch-{UUID}\"\n" + ), + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".cargo")) + .await + .unwrap(); + tokio::fs::write( + root.join(".cargo/config.toml"), + format!( + "[registries.socket-patch-{UUID}]\nindex = \"sparse+http://127.0.0.1:5555/index/\"\n" + ), + ) + .await + .unwrap(); + let detail = expect_refused( + run_vendor(PURL, root, &blobs, &pristine, &record, false).await, + "hosted_redirect_live", + ); + assert!(detail.contains("b/Cargo.toml"), "{detail}"); + assert!(!root.join(format!(".socket/vendor/cargo/{UUID}")).exists()); + } + /// A dependency pinned to a registry that is NOT ours, and an unpinned /// one, are not hosted residue: vendoring proceeds. #[tokio::test] From 43551405b49774a36b90bc4f1b7887a5859659fd Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 23:38:52 -0400 Subject: [PATCH 18/21] fix(redirect): repoint a v1 lock's [root] table A v1 Cargo.lock written before cargo dropped [root] keeps the root package in that table, whose dependencies spell full package ids. The reference rewrite walked the [[package]] array only, so a redirected crate left [root] naming the crates.io id of a package the lock no longer held: cargo build --locked then fails, an unlocked build discards the lock and re-resolves, and the scan reported the crate redirected either way. The walk now covers that table too, recorded by the same every-occurrence reference edit, so remove still restores the lock byte for byte in any order. The vendored backend has read [root] since it learned v1 locks. Co-Authored-By: Claude Opus 5 --- .../src/patch/redirect/mod.rs | 124 +++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index e9cfde5d..bbbde1f2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2280,8 +2280,21 @@ fn plan_cargo_lock( if let Some(old) = old_source.filter(|old| old != index_url) { let from = format!("\"{crate_name} {version} ({old})\""); let to = format!("\"{crate_name} {version} ({index_url})\""); - let mut cursor = 0; let mut repointed_any = false; + // The oldest v1 locks keep the ROOT package in a standalone `[root]` + // table instead of the `[[package]]` array, with its own full-id + // `dependencies`. It precedes the array, so the block walk below + // never reaches it and the lock would keep naming a package it no + // longer contains (`--locked` fails; an unlocked build silently + // re-resolves). + if let Some((start, end)) = lock_root_table(&new_content) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + } + } + let mut cursor = 0; while let Some((start, end)) = next_lock_block(&new_content, cursor) { if new_content[start..end].contains(&from) { let repointed = new_content[start..end].replace(&from, &to); @@ -2310,6 +2323,19 @@ fn plan_cargo_lock( } } +/// The v1 `[root]` table's span, when the lock has one: cargo before the +/// `[root]` removal recorded the root package there rather than in the +/// `[[package]]` array, and its `dependencies` spell full package ids the +/// same way. Bounded by [`lock_block_end`], like a package block. +fn lock_root_table(content: &str) -> Option<(usize, usize)> { + const HEADER: &str = "[root]\n"; + let at = content + .match_indices(HEADER) + .map(|(at, _)| at) + .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; + Some((at, lock_block_end(content, at + HEADER.len()))) +} + /// The next `[[package]]` block starting at or after `from`, as /// [`lock_block_end`] bounds it. fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { @@ -14572,6 +14598,102 @@ packages: ); } + /// The OLDEST v1 locks (cargo before the `[root]` removal) record the + /// root package in a standalone `[root]` table — not in the + /// `[[package]]` array — and its `dependencies` spell full package ids + /// the same way. REGRESSION: the reference walk searched `[[package]]` + /// blocks only, so `[root]` kept naming the crates.io id of a package + /// the repointed lock no longer contained: `cargo build --locked` fails + /// and an unlocked build silently discards the lock, while the scan + /// reports the crate redirected. The vendored twin has handled this + /// table since `dependency_tables_mut`. + #[test] + fn cargo_lock_v1_root_table_references_are_repointed() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\nlog = \"0.4\"\n"; + let cksum = "e".repeat(64); + let lock = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", + a = "a".repeat(64), + b = "b".repeat(64), + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let out = r.files.get("Cargo.lock").expect("lock rewritten"); + let idx = cargo_index_url(); + let want = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"log 0.4.20 ({CRATES_IO})\",\n \"serde 1.0.190 ({idx})\",\n]\n\n\ + [[package]]\nname = \"log\"\nversion = \"0.4.20\"\nsource = \"{CRATES_IO}\"\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{idx}\"\n\n\ + [metadata]\n\"checksum log 0.4.20 ({CRATES_IO})\" = \"{a}\"\n\ + \"checksum serde 1.0.190 ({idx})\" = \"{cksum}\"\n", + a = "a".repeat(64), + ); + assert_eq!(out, &want, "the [root] table is repointed with the rest"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + // The reference edit's inverse puts back EVERY occurrence, so the + // recorded fragments restore the lock byte for byte whether the id + // sat in `[root]`, in a package block, or in both. + let mut reverted = out.clone(); + for e in r + .edits + .iter() + .filter(|e| { + e.kind == "redirect_cargo_lock_entry" || e.kind == CARGO_LOCK_REFERENCE_KIND + }) + .rev() + { + let new = e.new.as_ref().and_then(Value::as_str).unwrap(); + let orig = e.original.as_ref().and_then(Value::as_str).unwrap(); + reverted = if e.kind == CARGO_LOCK_REFERENCE_KIND { + reverted.replace(new, orig) + } else { + reverted.replacen(new, orig, 1) + }; + } + assert_eq!(reverted, lock); + } + + /// Both places at once: a `[root]` table AND a package block reference + /// the patched crate by full id, and one reference edit repoints both. + #[test] + fn cargo_lock_v1_root_and_package_references_share_one_edit() { + const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; + let manifest = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n"; + let lock = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"helper\"\nversion = \"0.1.0\"\ndependencies = [\n \ + \"serde 1.0.190 ({CRATES_IO})\",\n]\n\n\ + [[package]]\nname = \"serde\"\nversion = \"1.0.190\"\nsource = \"{CRATES_IO}\"\n\n\ + [metadata]\n\"checksum serde 1.0.190 ({CRATES_IO})\" = \"{b}\"\n", + b = "b".repeat(64), + ); + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), manifest.to_string()); + files.insert("Cargo.lock".to_string(), lock.clone()); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + // `helper` is a source-less path package that no manifest pins, so + // the dependents refusal owns this shape: nothing is rewritten. + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_transitive_dependents"] + ); + assert!(r.files.is_empty(), "{:?}", r.files); + } + /// A lock of `app` (source-less, declares serde + `extra`) where `extra` /// resolves from `extra_source` and depends on serde via `edge`. fn cargo_shared_dependency_files( From 722cf8ead88d59377bcc672e0686763824f23ac7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 23:39:08 -0400 Subject: [PATCH 19/21] fix(redirect): spend one cargo edit per occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One scan records one cargo edit per occurrence, so a manifest that declares the crate with the same line in two sections leaves two identical edits in the ledger, and the per-purl revert unwinds one occurrence per edit. The whole-ledger replay — the path a run whose patch-record fetch failed leaves behind, records empty — instead refused as soon as the fragment appeared more than once, and that refusal dropped every other cargo edit with it: the project stayed fully redirected, with a suggested remedy (re-run the scan) that cannot change a duplication the manifest itself carries. The replay now allows as many occurrences as there are identical edits left to spend on them, and still refuses a surplus no edit accounts for. Co-Authored-By: Claude Opus 5 --- .../src/patch/redirect/replay.rs | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs index 40b8f275..324aa583 100644 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -645,7 +645,29 @@ pub async fn revert_remaining_redirect_edits( if *group == "cargo" { let every = inverse == Inverse::ReplaceEveryFragment; let lf = |t: &str| t.replace("\r\n", "\n"); - if !every && lf(&content).matches(&lf(new)).count() > 1 { + // ONE scan legitimately records identical cargo + // edits (a manifest declaring the crate with the + // same line in two sections), and each one unwinds + // one occurrence — what `revert_cargo_redirect_purl` + // does over this same ledger. So the file may hold + // as many occurrences as there are identical edits + // left to spend on them; only a surplus is + // ambiguous. + let twins = indices + .iter() + .filter(|&&i| { + let other = &state.edits[i]; + !group_drops.contains(&i) + && other.path == edit.path + && other.kind == edit.kind + && other.action == edit.action + && other.key == edit.key + && other.original == edit.original + && other.new == edit.new + }) + .count() + .max(1); + if !every && lf(&content).matches(&lf(new)).count() > twins { refuse( format!( "{}: the redirected fragment appears more than once — \ @@ -1847,6 +1869,52 @@ mod tests { assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); } + /// ONE scan records one cargo edit per OCCURRENCE, so a manifest that + /// declares the crate with the same line in two sections leaves two + /// IDENTICAL edits in the ledger. The whole-ledger replay — the + /// records-empty path a degraded (record-fetch-failed) run leaves + /// behind — must spend one edit per occurrence, exactly as the per-purl + /// `revert_cargo_redirect_purl` does over the same ledger. An + /// occurrence no edit accounts for is still ambiguous and refuses. + #[tokio::test] + async fn identical_cargo_edits_each_unwind_one_occurrence() { + let plain = "cfg-if = \"1\""; + let pinned = "cfg-if = { version = \"1\", registry = \"socket-patch-u\" }"; + let two = |line: &str| format!("[dependencies]\n{line}\n\n[dev-dependencies]\n{line}\n"); + let dir = TempDir::new().unwrap(); + write(dir.path(), "Cargo.toml", &two(pinned)).await; + let cargo_edit = || { + edit( + "Cargo.toml", + "redirect_cargo_toml_dep", + "rewritten", + Some(plain), + Some(pinned), + ) + }; + let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), "Cargo.toml").await, two(plain)); + assert!(state.edits.is_empty(), "{:?}", state.edits); + + // A THIRD occurrence with only two edits to spend: nothing says + // which one the ledger owns, so the group refuses byte-untouched. + let dir = TempDir::new().unwrap(); + let surplus = format!("{}\n[build-dependencies]\n{pinned}\n", two(pinned)); + write(dir.path(), "Cargo.toml", &surplus).await; + let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert!( + out.refusals[0].reason.contains("more than once"), + "{:?}", + out.refusals[0] + ); + assert_eq!(read(dir.path(), "Cargo.toml").await, surplus); + assert_eq!(state.edits.len(), 2); + } + #[tokio::test] async fn gem_section_move_record_fails_closed() { // redirect_gemfile_lock_gem_source records only the bare URLs of a From 827aa2acbede355be3b88bd0533b211cb8b93734 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 23:39:08 -0400 Subject: [PATCH 20/21] fix(redirect): never pin a manifest under target/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member discovery skipped cargo's build directory only while expanding a glob: a literal `members = ["target/gen"]` entry, or a path dependency pointing into target/, was returned and pinned — which the key predicate's own doc comment said could not happen. The next `cargo clean` deletes that manifest, and a recorded hosted edit whose file no longer exists refuses the rollback of every other cargo edit. A `target` segment is now excluded wherever a member path is accepted, in discovery and in the key predicate both. Co-Authored-By: Claude Opus 5 --- .../src/patch/redirect/mod.rs | 8 +++-- .../src/utils/cargo_workspace.rs | 34 ++++++++++++++++++- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index bbbde1f2..4496f112 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1094,9 +1094,9 @@ fn is_cargo_member_manifest_key(key: &str) -> bool { && !key.starts_with('/') && !key.contains('\\') && !key.contains(':') - && dir - .split('/') - .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && seg != ".socket") + && dir.split('/').all(|seg| { + !seg.is_empty() && seg != "." && seg != ".." && seg != ".socket" && seg != "target" + }) } /// The not-declared warning for a crate no manifest names at the patched @@ -9502,6 +9502,8 @@ mod tests { "a/../b/Cargo.toml", "./a/Cargo.toml", ".socket/vendor/cargo/x/Cargo.toml", + "target/generated/Cargo.toml", + "crates/a/target/gen/Cargo.toml", "a//Cargo.toml", "a/Cargo.toml.orig", ] { diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index e435dda7..5f30771a 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -98,9 +98,15 @@ fn enqueue( dirs: &mut BTreeSet, queue: &mut Vec<(String, DocumentMut)>, ) { + // `target` is cargo's build directory at every level: the glob walk + // already skips it, and a literal `members = ["target/gen"]` or a path + // dependency into it names a manifest the next `cargo clean` deletes — + // pinning it would record a hosted edit whose file can vanish, blocking + // the rollback of every other cargo edit. if dir.is_empty() || dirs.len() >= MAX_MANIFESTS || dirs.contains(&dir) + || dir.split('/').any(|seg| seg == "target") || !is_real_dir_path(root, &dir) { return; @@ -160,7 +166,7 @@ fn path_dependencies(doc: &DocumentMut) -> Vec { /// `base/rel` lexically normalized to a repo-relative slash path; `None` /// when it is absolute or climbs out of the root. -fn normalize_rel(base: &str, rel: &str) -> Option { +pub(crate) fn normalize_rel(base: &str, rel: &str) -> Option { let rel = rel.replace('\\', "/"); if rel.starts_with('/') || Path::new(&rel).is_absolute() { return None; @@ -389,6 +395,32 @@ mod tests { assert!(member_manifests(&root).is_empty()); } + /// Cargo's build directory is never a member, whichever way it is + /// named: the glob walk skips it, and so do a literal `members` entry + /// and a path dependency pointing into it. `cargo clean` deletes those + /// manifests, and a recorded hosted edit for a file that no longer + /// exists blocks the rollback of every other cargo edit. + #[test] + fn build_directory_manifests_are_never_members() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "Cargo.toml", + &format!( + "[workspace]\nmembers = [\"crates/*\", \"target/generated\"]\n\n\ + {}[dependencies]\ngen2 = {{ path = \"target/gen2\" }}\n\ + nested = {{ path = \"crates/a/target/gen3\" }}\n", + pkg("root") + ), + ); + write(root, "crates/a/Cargo.toml", &pkg("a")); + write(root, "crates/a/target/gen3/Cargo.toml", &pkg("gen3")); + write(root, "target/generated/Cargo.toml", &pkg("generated")); + write(root, "target/gen2/Cargo.toml", &pkg("gen2")); + assert_eq!(member_manifests(root), vec!["crates/a/Cargo.toml"]); + } + #[test] fn wildcard_matching() { assert!(wildcard_match(b"a*c", b"abbc")); From 2677e9919826652ee2b3c069bf4bf65ccf00daac Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 24 Sep 2026 23:39:21 -0400 Subject: [PATCH 21/21] fix(redirect): refuse a lockless cargo pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `registry = …` pin reaches only the declarations it sits on, so a crate another package also pulls in is refused — but that check reads Cargo.lock, and a project without one was pinned unconditionally. Rust libraries routinely gitignore their lock, so the silent shape was the common one: the scan reported the crate redirected, vex attested it, and the next build resolved the patched copy for the root and an unpatched crates.io copy for whatever else depends on the crate. Without a lock the dependents question is now answered from the manifests instead: a crate declared beside any other dependency — or beside a workspace member this run could not read (a members glob, or a member outside the project or behind a symbolic link) — is refused `redirect_cargo_lockless_dependents`, whose detail names both remedies. A path dependency on a manifest the same run pins is not company, and a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. Real cargo: e2e_redirect_cargo_shapes lockless-other-dependencies, the direct-and-transitive project with its lock deleted. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_cargo_shapes.rs | 58 +++- .../src/patch/redirect/mod.rs | 279 +++++++++++++++++- .../lockless-dependents/expected-edits.json | 1 + .../expected-warnings.json | 3 + .../lockless-dependents/input/Cargo.toml | 8 + .../cargo/lockless-dependents/overrides.json | 22 ++ .../vex-discover-golden/redirect-cargo.json | 8 + docs/ecosystems.md | 2 +- 10 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5bfba9..21874c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -984,7 +984,14 @@ into the new version's section — see docs/releasing.md. requirements the patched version does not satisfy (cargo resolves those declarations to another version) is refused `redirect_cargo_toml_dep_unrewritable`, the Socket backend's code for the - same shape, instead of `redirect_cargo_toml_dep_not_found`. + same shape, instead of `redirect_cargo_toml_dep_not_found`. A project with + NO `Cargo.lock` has no resolved graph to ask, so a crate declared beside + any other dependency — anything but a path dependency on a manifest the + same run pins — or beside a workspace member this run did not read (a + glob, a member outside the project or behind a symbolic link) is refused + `redirect_cargo_lockless_dependents` (commit a lockfile, or use `--mode + vendored`); a project whose only dependency is the patched crate has + nothing that could pull it in and still redirects. - **CRLF cargo projects redirect in hosted mode.** All-CRLF `Cargo.toml`, `Cargo.lock` and cargo configs are rewritten with their endings kept (they were refused), and `remove` / rollback still find the recorded diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 07f27bc7..f6991b70 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -125,7 +125,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). -The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on; without a `Cargo.lock` this check cannot run), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index 443842ae..b5abf52e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -17,6 +17,9 @@ //! `[dev-dependencies]`: both pins revert on `remove`. //! * `direct_and_transitive` — the crate is also a dependency of another //! crates.io crate: hosted mode refuses it loudly and rewrites nothing. +//! * `lockless_other_dependencies` — the same project with no committed +//! `Cargo.lock`: with no resolved graph to read, a crate declared beside +//! any other dependency is refused just as loudly. //! //! Every shape runs the same chain against the real cargo: a baseline build //! with a private CARGO_HOME (network to crates.io for fixture setup only), @@ -91,6 +94,9 @@ struct Shape { /// Re-encode every `Cargo.toml` and the generated `Cargo.lock` with CRLF /// line endings (a Windows checkout) before the scan. crlf: bool, + /// Delete `Cargo.lock` after the baseline build, before the scan: the + /// shape a Rust library that gitignores its lock presents. + lockless: bool, /// A shape hosted mode must REFUSE: the rewriter warning code every /// patch is skipped with. The scan must leave every file untouched. refused: Option<&'static str>, @@ -416,6 +422,9 @@ async fn run_shape(shape: Shape) -> Option<()> { ); let _ = std::fs::remove_dir_all(proj.join("target")); } + if shape.lockless { + std::fs::remove_file(proj.join("Cargo.lock")).unwrap(); + } let before = snapshot(&proj); let mut served = Vec::new(); @@ -488,13 +497,15 @@ async fn run_shape(shape: Shape) -> Option<()> { shape.tag ); assert!(!proj.join(".socket").exists(), "{}", shape.tag); - let fetch = cargo(&proj, &["fetch", "--locked"], &home); - assert!( - fetch.status.success(), - "{}: the untouched project still fetches --locked:\n{}", - shape.tag, - stderr(&fetch) - ); + if !shape.lockless { + let fetch = cargo(&proj, &["fetch", "--locked"], &home); + assert!( + fetch.status.success(), + "{}: the untouched project still fetches --locked:\n{}", + shape.tag, + stderr(&fetch) + ); + } return Some(()); } assert_eq!( @@ -713,6 +724,7 @@ async fn cargo_hosted_multi_version_pins_each_declaration_and_removes_cleanly() .to_string(), )], crlf: false, + lockless: false, refused: None, }; let _ = run_shape(shape).await; @@ -735,6 +747,7 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: false, + lockless: false, refused: None, }; let _ = run_shape(shape).await; @@ -770,6 +783,7 @@ async fn cargo_hosted_config_trailing_bytes_are_restored() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: false, + lockless: false, refused: None, }; if run_shape(shape).await.is_none() { @@ -802,6 +816,7 @@ async fn cargo_hosted_same_line_in_two_sections_removes_cleanly() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: false, + lockless: false, refused: None, }; let _ = run_shape(shape).await; @@ -840,6 +855,7 @@ async fn cargo_hosted_workspace_member_declaration_is_pinned() { ("direct/src/lib.rs", oracle), ], crlf: false, + lockless: false, refused: None, }; let _ = run_shape(shape).await; @@ -861,6 +877,7 @@ async fn cargo_hosted_crlf_project_keeps_its_line_endings() { "fn main() { println!(\"{}\", cfg_if::socket_patched()); }\n".to_string(), )], crlf: true, + lockless: false, refused: None, }; let _ = run_shape(shape).await; @@ -885,7 +902,34 @@ async fn cargo_hosted_refuses_a_crate_another_crate_depends_on() { patches: vec![CFG_IF_1], oracle: Vec::new(), crlf: false, + lockless: false, refused: Some("redirect_cargo_transitive_dependents"), }; let _ = run_shape(shape).await; } + +/// The SAME project with its `Cargo.lock` gitignored (the common shape for +/// a Rust library): with no resolved graph the dependents check above has +/// nothing to read, so the manifests answer instead — cfg-if is declared +/// beside crc32fast, which may well pull it in, and the redirect is refused +/// rather than reported as one while the build links an unpatched copy +/// through crc32fast. +#[tokio::test(flavor = "multi_thread")] +async fn cargo_hosted_refuses_a_lockless_project_with_other_dependencies() { + let shape = Shape { + tag: "lockless-other-dependencies", + files: vec![ + ( + "Cargo.toml", + consumer_manifest("cfg-if = \"1.0.4\"\ncrc32fast = \"=1.5.0\"\n"), + ), + ("src/main.rs", "fn main() {}\n".to_string()), + ], + patches: vec![CFG_IF_1], + oracle: Vec::new(), + crlf: false, + lockless: true, + refused: Some("redirect_cargo_lockless_dependents"), + }; + let _ = run_shape(shape).await; +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4496f112..ebcd2fab 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -963,15 +963,35 @@ fn rewrite_cargo( }); continue; } + } else { + // NO Cargo.lock: the resolved graph the check above reads does + // not exist, so nothing here can say whether some other + // dependency's own graph also pulls in the crate. Any other + // declared dependency might, and the pin reaches only the + // declarations it sits on — that consumer would compile the + // unpatched crates.io copy while the scan reports the crate + // redirected and VEX attests it. Fail closed, exactly as the + // locked path does for a dependent it CAN see; a project whose + // only dependency is the patched crate has nothing that could + // pull it in, and still redirects. + let others = cargo_lockless_other_dependencies(&manifests, &dep.name); + if !others.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_lockless_dependents".into(), + detail: cargo_lockless_dependents_detail(&dep.name, &dep.version, &others), + }); + continue; + } } // 2. Plan the Cargo.lock repoint. A lock that exists but has no // [[package]] for the dep means the project does not actually resolve // it — rewriting the manifest anyway would desync manifest and lock. // Skip the dep entirely (discarding the manifest plan). A project - // with NO lockfile is fine: the manifest pin alone forces the next - // resolution through the managed registry, which serves the patched - // checksum. + // with NO lockfile reaches here only when the patched crate is its + // one declared dependency (the dependents gate above): the manifest + // pin alone then forces the next resolution through the managed + // registry, which serves the patched checksum. enum LockCommit { Write(String, Vec), InPlace, @@ -1238,6 +1258,145 @@ fn cargo_unpinnable_dependents( out } +/// Dependencies OTHER than `crate_name` declared across the manifests this +/// rewriter can pin, as ` (in )`. This is the question a +/// Cargo.lock answers outright; without one, every such dependency is a +/// possible second consumer of the patched crate. A path dependency on a +/// manifest in this same list is NOT one of them — that package's own +/// declarations are listed here too — and a `workspace = true` inheritor +/// resolves to the root's `[workspace.dependencies]` entry, which is. +/// A manifest that does not parse is itself blocking (fail closed). +fn cargo_lockless_other_dependencies( + manifests: &[(String, String)], + crate_name: &str, +) -> Vec { + fn field<'a>(entry: &'a toml_edit::Item, key: &str) -> Option<&'a str> { + match entry { + toml_edit::Item::Table(t) => t.get(key).and_then(toml_edit::Item::as_str), + toml_edit::Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get(key)) + .and_then(toml_edit::Value::as_str), + _ => None, + } + } + fn flag(entry: &toml_edit::Item, key: &str) -> bool { + match entry { + toml_edit::Item::Table(t) => t.get(key).and_then(toml_edit::Item::as_bool), + toml_edit::Item::Value(v) => v + .as_inline_table() + .and_then(|t| t.get(key)) + .and_then(toml_edit::Value::as_bool), + _ => None, + } + .unwrap_or(false) + } + const KINDS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"]; + let known: std::collections::BTreeSet<&str> = + manifests.iter().map(|(k, _)| k.as_str()).collect(); + let mut out: Vec = Vec::new(); + for (path, text) in manifests { + let dir = path.strip_suffix("/Cargo.toml").unwrap_or(""); + let Ok(doc) = text.parse::() else { + out.push(format!("{path} (it does not parse as TOML)")); + continue; + }; + let scan = |item: Option<&toml_edit::Item>, out: &mut Vec| { + let Some(table) = item.and_then(toml_edit::Item::as_table_like) else { + return; + }; + for (key, entry) in table.iter() { + let name = field(entry, "package").unwrap_or(key); + if name == crate_name || flag(entry, "workspace") { + continue; + } + if let Some(rel) = field(entry, "path") { + let inside = crate::utils::cargo_workspace::normalize_rel(dir, rel) + .is_some_and(|d| { + d.is_empty() || known.contains(format!("{d}/Cargo.toml").as_str()) + }); + if inside { + continue; + } + } + let named = if path == "Cargo.toml" { + name.to_string() + } else { + format!("{name} (in {path})") + }; + if !out.contains(&named) { + out.push(named); + } + } + }; + for kind in KINDS { + scan(doc.get(kind), &mut out); + } + if let Some(targets) = doc.get("target").and_then(toml_edit::Item::as_table) { + for (_, target) in targets.iter() { + let Some(target) = target.as_table() else { + continue; + }; + for kind in KINDS { + scan(target.get(kind), &mut out); + } + } + } + if let Some(ws) = doc.get("workspace").and_then(toml_edit::Item::as_table) { + scan(ws.get("dependencies"), &mut out); + // A member manifest this run did not read is a second consumer + // nothing can rule out: it may declare the crate itself (a pin + // never reaches it) or a dependency that pulls it in. Member + // discovery drops what it must not follow — a symbolic link, a + // path outside the project — and a glob's expansion is not + // visible here at all, so only a literal member whose manifest + // IS in this run's set is accounted for. + let members = ws + .get("members") + .and_then(toml_edit::Item::as_array) + .into_iter() + .flat_map(|a| a.iter().filter_map(toml_edit::Value::as_str)); + for member in members { + let named = if member.contains(['*', '?']) { + format!("the workspace members pattern `{member}`") + } else if crate::utils::cargo_workspace::normalize_rel(dir, member) + .is_some_and(|d| known.contains(format!("{d}/Cargo.toml").as_str())) + { + continue; + } else { + format!("the workspace member `{member}`") + }; + if !out.contains(&named) { + out.push(named); + } + } + } + } + out +} + +/// The refusal for a lockless project that declares other dependencies. +fn cargo_lockless_dependents_detail(crate_name: &str, version: &str, others: &[String]) -> String { + const SHOWN: usize = 5; + let mut names = others + .iter() + .take(SHOWN) + .cloned() + .collect::>() + .join(", "); + if others.len() > SHOWN { + names.push_str(&format!(" and {} more", others.len() - SHOWN)); + } + format!( + "this project has no Cargo.lock, so nothing says whether {names} also pull in \ + {crate_name}@{version}; a `registry = …` pin reaches only the declarations it sits \ + on, so such a consumer would compile the unpatched crates.io copy while \ + {crate_name} is reported redirected — commit a lockfile (`cargo generate-lockfile`) \ + and re-run, or patch it with `socket-patch scan --mode vendored`, whose \ + `[patch.crates-io]` covers the whole graph (nothing rewritten)" + ) +} + /// The refusal for a crate other lock packages also depend on. fn cargo_transitive_dependents_detail( crate_name: &str, @@ -8606,6 +8765,120 @@ mod tests { assert_eq!(cargo_socket_registry_pin(&renamed_other, "serde"), None); } + /// NO Cargo.lock: the transitive-dependents refusal reads the resolved + /// graph, and without one nothing says whether another dependency also + /// pulls in the patched crate — a pin reaches only the declarations it + /// sits on, so that consumer would compile the unpatched crates.io copy + /// while the scan reported the crate redirected and VEX attested it. + /// Every OTHER declared dependency is therefore blocking; a path + /// dependency on a manifest this run pins is not (its own declarations + /// are pinned too), and neither is a `workspace = true` inheritor of the + /// root table this run scans. + #[test] + fn cargo_lockless_other_dependencies_are_refused() { + let head = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n"; + let refused = |files: BTreeMap| { + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "nothing rewritten: {:?}", r.files); + assert!(r.edits.is_empty(), "{:?}", r.edits); + assert!(r.confirmed_cargo_uuids.is_empty(), "never confirmed"); + assert_eq!( + warning_codes(&r), + vec!["redirect_cargo_lockless_dependents"] + ); + r.warnings[0].detail.clone() + }; + let one = |toml: &str| { + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), toml.to_string()); + files + }; + + // A registry dependency beside the patched crate. + let detail = refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\ntokio = \"1\"\n" + ))); + assert!(detail.contains("tokio"), "{detail}"); + assert!(detail.contains("cargo generate-lockfile"), "{detail}"); + assert!(detail.contains("--mode vendored"), "{detail}"); + // A dev-dependency counts (it is linked into the test build too). + refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\n\n\ + [dev-dependencies]\ntokio = \"1\"\n" + ))); + // A path dependency this run cannot pin (outside the project). + let detail = refused(one(&format!( + "{head}[dependencies]\nserde = \"1.0.190\"\n\ + shared = {{ path = \"../shared\" }}\n" + ))); + assert!(detail.contains("shared"), "{detail}"); + // A member's own other dependency blocks as well. + let mut files = one(&format!( + "[workspace]\nmembers = [\"b\"]\n\n{head}\ + [dependencies]\nserde = \"1.0.190\"\n" + )); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nrand = \"0.8\"\n" + .to_string(), + ); + let detail = refused(files); + assert!(detail.contains("rand (in b/Cargo.toml)"), "{detail}"); + + // A member manifest this run did NOT read — dropped by member + // discovery (a symbolic link, a path outside the project) or hidden + // behind a glob it cannot expand — may declare the crate itself or + // pull it in, and nothing here can tell. + let detail = refused(one( + "[workspace]\nmembers = [\"b\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + )); + assert!(detail.contains("the workspace member `b`"), "{detail}"); + let detail = refused(one( + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"app\"\n\ + version = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + )); + assert!( + detail.contains("the workspace members pattern `crates/*`"), + "{detail}" + ); + } + + /// The lockless shapes that stay redirectable: the patched crate alone, + /// the same crate declared again by a member this run pins, and a path + /// dependency on that member (whose own declarations are pinned too). + #[test] + fn cargo_lockless_self_contained_workspace_still_redirects() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[workspace]\nmembers = [\"b\"]\n\n\ + [package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\nb = { path = \"b\" }\n" + .to_string(), + ); + files.insert( + "b/Cargo.toml".to_string(), + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n" + .to_string(), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + for key in ["Cargo.toml", "b/Cargo.toml"] { + assert!( + r.files + .get(key) + .is_some_and(|t| t.contains(&format!("registry = \"{}\"", cargo_reg()))), + "{key} must be pinned: {:?}", + r.files.get(key) + ); + } + assert!(!r.files.contains_key("Cargo.lock")); + } + fn cargo_lock_with(name: &str, version: &str) -> String { format!( "# This file is automatically @generated by Cargo.\n\ diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json new file mode 100644 index 00000000..5e643ea8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_cargo_lockless_dependents" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml new file mode 100644 index 00000000..adaf565e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0.190" +serde_json = "1.0.108" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/lockless-dependents/overrides.json @@ -0,0 +1,22 @@ +[ + { + "ecosystem": "cargo", + "name": "serde", + "version": "1.0.190", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "55555555-5555-5555-5555-555555555555", + "artifactUrl": "https://patch.socket.dev/patch/cargo/serde/1.0.190/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/serde-1.0.190.crate", + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/55555555-5555-5555-5555-555555555555/index/", + "identifiers": { + "name": "serde", + "version": "1.0.190", + "cargoCksumSha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "integrity": { + "sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index fe741248..3acf08ca 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -175,6 +175,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/cargo/cargo/lockless-dependents/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/cargo/cargo/multi-version/expected": { "refs": [ { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1ca1b71a..4038cf42 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -16,7 +16,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. |-----------|------------------------|------------------------------|--------------------------| | npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | -| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored` | +| Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | | RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) |