From 157f32db32e0e7cf2b7255e798002f5323569e0c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 07:17:08 -0700 Subject: [PATCH 1/2] fix(redirect): make the cargo hosted redirect transactional and fail-closed A dep now counts as redirected (lock repoint, registry block, ledger record, confirmed count, VEX) ONLY if its Cargo.toml pin fully landed; any occurrence that cannot be pinned skips the dep entirely with one clear warning and zero writes. Fixes audit findings A1-A9: - A1: pin EVERY occurrence across [dependencies], [dev-dependencies], [build-dependencies], and target-specific tables, not the first regex match (partial pins gave one dep two sources - cargo refuses the manifest and every cargo command breaks). - A2: support the multi-line [dependencies.] table form (insert a registry line) instead of repointing the lock while the manifest still says crates.io. - A3/A3b: hosted confirmation keys off the rewriter's new confirmed_cargo_uuids set, never substring presence - the config block contains the index URL while pinning nothing, so config-only rewrites used to confirm, ledger-record, and VEX-attest patches no build would use. - A4: an existing registry = "socket-patch-" pin is OURS - supersede it in place instead of reporting not-found and splitting manifest (old uuid) from lock (new uuid). - A5: rename-aware matching - `package = "other"` under a matching key is NOT the patched crate; an alias key with `package = ""` is. - A6: the [registries] idempotence check ignores commented lines; a commented/degraded managed block is restored on re-run. - A7: line-scoped plain-version rewrite stops swallowing the trailing blank line (and preserves trailing comments). - A8: validate service-supplied inputs before TOML interpolation (canonical uuid grammar via path_safety, quote/control-free sparse index URL, 64-hex cksum). - A9: empty-string cargoCksumSha256 is missing (TS twin parity) - skip the dep instead of writing checksum = "" into Cargo.lock. New shared golden fixtures (two-sections, table-form, renamed, supersede, rerun, commented-config), 19 rewriter unit tests, and two CLI tests adapted from the audit repro probes (the cargo analogue of no_lockfile_redirect_is_not_attested, plus the table-form-without-lock scenario now landing a real pin). The depscan TS byte-twin is ported in a companion depscan PR; its submodule pin needs a bump after this merges. Co-Authored-By: Claude Fable 5 --- .../src/commands/scan/hosted.rs | 11 +- .../tests/in_process_redirect.rs | 267 +++ .../src/patch/redirect/mod.rs | 1509 +++++++++++++++-- .../commented-config/expected-edits.json | 9 + .../expected/.cargo/config.toml | 5 + .../commented-config/input/.cargo/config.toml | 2 + .../cargo/commented-config/input/Cargo.lock | 9 + .../cargo/commented-config/input/Cargo.toml | 7 + .../cargo/commented-config/overrides.json | 22 + .../cargo/cargo/renamed/expected-edits.json | 25 + .../cargo/renamed/expected/.cargo/config.toml | 2 + .../cargo/cargo/renamed/expected/Cargo.lock | 15 + .../cargo/cargo/renamed/expected/Cargo.toml | 8 + .../cargo/cargo/renamed/input/Cargo.lock | 15 + .../cargo/cargo/renamed/input/Cargo.toml | 8 + .../cargo/cargo/renamed/overrides.json | 22 + .../cargo/cargo/rerun/expected-edits.json | 1 + .../cargo/rerun/input/.cargo/config.toml | 2 + .../cargo/cargo/rerun/input/Cargo.lock | 9 + .../cargo/cargo/rerun/input/Cargo.toml | 7 + .../redirect/cargo/cargo/rerun/overrides.json | 22 + .../cargo/cargo/supersede/expected-edits.json | 25 + .../supersede/expected/.cargo/config.toml | 5 + .../cargo/cargo/supersede/expected/Cargo.lock | 9 + .../cargo/cargo/supersede/expected/Cargo.toml | 7 + .../cargo/supersede/input/.cargo/config.toml | 2 + .../cargo/cargo/supersede/input/Cargo.lock | 9 + .../cargo/cargo/supersede/input/Cargo.toml | 7 + .../cargo/cargo/supersede/overrides.json | 22 + .../cargo/table-form/expected-edits.json | 25 + .../table-form/expected/.cargo/config.toml | 2 + .../cargo/table-form/expected/Cargo.lock | 16 + .../cargo/table-form/expected/Cargo.toml | 9 + .../cargo/cargo/table-form/input/Cargo.lock | 16 + .../cargo/cargo/table-form/input/Cargo.toml | 8 + .../cargo/cargo/table-form/overrides.json | 22 + .../cargo/two-sections/expected-edits.json | 33 + .../two-sections/expected/.cargo/config.toml | 2 + .../cargo/two-sections/expected/Cargo.lock | 16 + .../cargo/two-sections/expected/Cargo.toml | 11 + .../cargo/cargo/two-sections/input/Cargo.lock | 16 + .../cargo/cargo/two-sections/input/Cargo.toml | 11 + .../cargo/cargo/two-sections/overrides.json | 22 + 43 files changed, 2141 insertions(+), 131 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/.cargo/config.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/input/Cargo.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/input/Cargo.toml create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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 340bddf3..d0dd25da 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -413,7 +413,16 @@ pub(super) async fn run_redirect( .collect(); let confirmed: Vec<(String, String)> = candidates .iter() - .filter(|(_, _, artifact_url, index_url, suffixed_version)| { + .filter(|(purl, uuid, artifact_url, index_url, suffixed_version)| { + // Cargo is transactional: the rewriter reports exactly which + // patch uuids FULLY landed (manifest pin + lock + registry + // block). Substring presence must never confirm a cargo dep — + // the `[registries.…]` config block contains the index URL while + // pinning nothing, so a config-block-only rewrite would be + // attested with zero enforcement in any build. + if purl.starts_with("pkg:cargo/") { + return rewrite.confirmed_cargo_uuids.contains(uuid); + } let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); final_texts.iter().any(|text| { text.contains(artifact_url.as_str()) diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d302aa48..04940299 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1970,3 +1970,270 @@ async fn redirect_json_mode_write_failures_emit_error_envelope() { let out = run_leg(tmp.path(), &server).await; assert_error_envelope(&out, "ledger-write failure"); } + +/// Mount the full cargo hosted-mock set (discovery + reference + view) for +/// one patch over `purl`. +async fn mock_cargo_patch( + server: &MockServer, + purl: &str, + uuid: &str, + name: &str, + version: &str, + index_url: &str, + cksum: &str, + ghsa: &str, +) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": uuid, "purl": purl, "tier": "free", + "cveIds": [], "ghsaIds": [ghsa], "severity": "high", + "title": "cargo redirect fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + uuid: { + "status": "granted", + "url": format!("http://patch.test/{name}-{version}.crate"), + "purl": purl, + "artifacts": [{ + "kind": "tarball", + "url": format!("http://patch.test/{name}-{version}.crate"), + "integrity": { "sha256": cksum } + }], + "registryOverride": { + "kind": "cargo-sparse", + "indexUrl": index_url, + "identifiers": { + "name": name, + "version": version, + "cargoCksumSha256": cksum, + } + } + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "src/lib.rs": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + ghsa: { + "cves": ["CVE-2024-99999"], + "summary": "cargo redirect vex fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// Write a vendored crate dir so the cargo crawler discovers `name@version` +/// without the project's manifest/lock referencing it. +fn write_vendored_crate(root: &Path, name: &str, version: &str) { + let dir = root.join("vendor").join(name); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"), + ) + .unwrap(); + std::fs::write(dir.join("src/lib.rs"), b"// unpatched\n").unwrap(); +} + +/// AUDIT A3/A3b — the cargo analogue of `no_lockfile_redirect_is_not_attested`: +/// a granted cargo patch whose crate the project does not declare (surfaced by +/// the crawler from a vendor dir) must confirm NOTHING. The old behavior wrote +/// an inert `[registries.…]` block to `.cargo/config.toml`, whose index URL +/// then satisfied the substring confirmed check: the run reported +/// `redirected: 1`, persisted a ledger record, and emitted an `assume_applied` +/// VEX statement while no build anywhere used the patched bytes. +#[tokio::test] +#[serial] +async fn cargo_granted_but_nothing_pinned_is_not_confirmed_or_attested() { + const CARGO_PURL: &str = "pkg:cargo/cfg-if@1.0.0"; + const CARGO_UUID: &str = "11111111-1111-4111-8111-111111111111"; + 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-aaaa-bbbb", + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + // Manifest + lock reference ONLY serde; cfg-if exists solely in vendor/. + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"consumer\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[dependencies]\nserde = \"1.0\"\n", + ) + .unwrap(); + std::fs::write( + tmp.path().join("Cargo.lock"), + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"ee\"\n", + ) + .unwrap(); + write_vendored_crate(tmp.path(), "cfg-if", "1.0.0"); + let toml_before = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + let lock_before = std::fs::read_to_string(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_to_string(tmp.path().join("Cargo.toml")).unwrap(), + toml_before, + "Cargo.toml must be untouched (no dep entry for cfg-if)" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Cargo.lock")).unwrap(), + lock_before, + "Cargo.lock must be untouched (no [[package]] for cfg-if)" + ); + assert!( + !tmp.path().join(".cargo/config.toml").exists() + && !tmp.path().join(".cargo/config").exists(), + "NO inert [registries] block may be written when nothing pins the patch" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "no ledger may be written when nothing was redirected" + ); + assert!( + !vex_path.exists(), + "NO OpenVEX document may exist for a tree where nothing pins the patch" + ); + assert_eq!( + code, 1, + "nothing was redirected, so the requested attestation must fail" + ); +} + +/// 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 + +/// attested (the manifest pin forces the next resolution through the managed +/// registry, which serves the patched checksum). +#[tokio::test] +#[serial] +async fn cargo_table_form_without_lock_is_pinned_and_attested() { + const CARGO_PURL: &str = "pkg:cargo/serde@1.0.190"; + const CARGO_UUID: &str = "55555555-5555-4555-8555-555555555555"; + let cksum = "11".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, + "serde", + "1.0.190", + &index_url, + &cksum, + "GHSA-carg-cccc-dddd", + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + // Table-form dependency; NO Cargo.lock. The crawler discovers the version + // from the vendored crate dir. + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies.serde]\nversion = \"1.0.190\"\n", + ) + .unwrap(); + write_vendored_crate(tmp.path(), "serde", "1.0.190"); + + 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/app@0.1.0".to_string()), + ..Default::default() + }; + let code = run(args).await; + assert_eq!(code, 0, "the table-form pin must land and attest"); + + let manifest = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + manifest.contains(&format!( + "[dependencies.serde]\nregistry = \"socket-patch-{CARGO_UUID}\"\nversion = \"1.0.190\"" + )), + "the table entry must gain the registry line: {manifest}" + ); + let cfg = std::fs::read_to_string(tmp.path().join(".cargo/config.toml")).unwrap(); + assert!( + cfg.contains(&index_url), + "the managed registry block must be wired in: {cfg}" + ); + assert!( + !tmp.path().join("Cargo.lock").exists(), + "no lockfile may be invented" + ); + let ledger = + std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger.contains(CARGO_UUID) && ledger.contains("GHSA-carg-cccc-dddd"), + "the ledger must record the landed redirect: {ledger}" + ); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "the landed redirect is attested: {doc}"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-carg-cccc-dddd"); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ff40eb5..eaa01584 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -119,6 +119,15 @@ pub struct RewriteResult { pub files: BTreeMap, pub edits: Vec, pub warnings: Vec, + /// Patch uuids whose cargo redirect FULLY landed — the Cargo.toml pin plus + /// (when a Cargo.lock is present) the lock repoint, with the registry + /// block wired in — whether written by this run or already in place from + /// an earlier one. The cargo rewrite is transactional per dependency: + /// a dep that is not in this set had NOTHING written for it. Hosted-mode + /// confirmation MUST key off this set for cargo deps, never off substring + /// presence in rewritten files (a `[registries.…]` config block alone + /// pins nothing). + pub confirmed_cargo_uuids: std::collections::BTreeSet, } /// Combined name as it appears in registry coordinates / lock keys. @@ -380,6 +389,17 @@ fn rewrite_pypi_requirements( } // ── cargo (Cargo.toml + .cargo/config.toml + Cargo.lock) ───────────────────── +// +// TRANSACTIONAL per dependency: a dep is redirected ONLY if its Cargo.toml pin +// fully lands across EVERY occurrence ([dependencies], [dev-dependencies], +// [build-dependencies], target-specific tables, [workspace.dependencies], and +// the multi-line `[dependencies.]` table form). If any occurrence cannot +// be rewritten — a foreign registry pin, a path/git dependency, an unsupported +// spelling — the dep is skipped ENTIRELY (no lock edit, no config block, no +// confirmation) with one clear warning. A partial edit set (lock repointed +// while the manifest still says crates.io, or an inert `[registries.…]` block +// with nothing referencing it) breaks `--locked` builds or silently drops the +// patch while attesting it — the exact failure mode this shape forbids. fn rewrite_cargo( files: &BTreeMap, overrides: &[DepOverride], @@ -417,13 +437,48 @@ fn rewrite_cargo( continue; }; if ov.kind != "cargo-sparse" { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_missing_override".into(), + detail: format!("{} has no cargo-sparse registry override", dep.name), + }); + continue; + } + // Service-supplied strings are interpolated into raw TOML (a section + // header, a quoted value) and into Cargo.lock — validate them against + // their exact expected grammars BEFORE any write, mirroring the + // vendored path's fail-closed uuid/path checks. A `]`+newline in a + // patch uuid or a quote in an index URL would otherwise inject + // arbitrary TOML (e.g. a `[source.crates-io]` replace-with hijacking + // every crate in the project). + if !crate::patch::path_safety::is_canonical_uuid(&dep.patch_uuid) { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_invalid_uuid".into(), + detail: format!( + "{} has a malformed patch uuid; dependency skipped", + dep.name + ), + }); continue; } + if !is_valid_cargo_index_url(&ov.index_url) { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_invalid_index_url".into(), + detail: format!( + "{} has a malformed sparse index URL; dependency skipped", + dep.name + ), + }); + continue; + } + // An empty-string cksum is MISSING (the TS twin's falsy check), not a + // value to write into Cargo.lock — `checksum = ""` hard-fails the next + // `cargo fetch --locked`. let Some(cksum) = ov .identifiers .cargo_cksum_sha256 .clone() - .or_else(|| dep.integrity.sha256.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| dep.integrity.sha256.clone().filter(|s| !s.is_empty())) else { result.warnings.push(RewriteWarning { code: "redirect_cargo_missing_cksum".into(), @@ -431,68 +486,116 @@ fn rewrite_cargo( }); continue; }; + if !is_hex64_lower(&cksum) { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_invalid_cksum".into(), + detail: format!( + "{} has a malformed sha256 cksum; dependency skipped", + dep.name + ), + }); + continue; + } let reg = format!("socket-patch-{}", dep.patch_uuid); let index_url = &ov.index_url; - // 1. .cargo/config.toml registry definition (idempotent). - if !cargo_config.contains(&format!("[registries.{reg}]")) { - let block = format!("[registries.{reg}]\nindex = \"{index_url}\"\n"); - let sep = if !cargo_config.is_empty() && !cargo_config.ends_with('\n') { - "\n" - } else { - "" - }; - let prefix = if cargo_config.is_empty() { "" } else { "\n" }; - cargo_config = format!("{cargo_config}{sep}{prefix}{block}"); - config_changed = true; - result.edits.push(FileEdit { - path: cargo_config_key.into(), - kind: "redirect_cargo_registry".into(), - action: "added".into(), - key: Some(reg.clone()), - original: None, - new: Some(Value::String(block)), + // 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 { + result.warnings.push(RewriteWarning { + code: "redirect_cargo_toml_dep_not_found".into(), + detail: format!( + "no Cargo.toml present to pin {}; dependency skipped (nothing rewritten)", + dep.name + ), }); - } - - // 2. Cargo.toml dep → add `registry = ""`. - if let Some(toml) = cargo_toml.as_mut() { - match add_cargo_toml_registry(toml, &dep.name, ®) { - CargoTomlRewrite::Rewritten(edit) => { - result.edits.push(*edit); - toml_changed = true; - } - // Re-run over an already-redirected Cargo.toml: not missing. - CargoTomlRewrite::AlreadyRedirected => {} - CargoTomlRewrite::NotFound => { - result.warnings.push(RewriteWarning { - code: "redirect_cargo_toml_dep_not_found".into(), - detail: format!("no [dependencies] entry for {} in Cargo.toml", dep.name), - }); - } + 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 \ + (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 \ + (nothing rewritten)", + dep.name + ), + }); + continue; + } + }; - // 3. Cargo.lock [[package]] → set source + checksum. - if let Some(lock) = cargo_lock.as_mut() { - match set_cargo_lock_source(lock, &dep.name, &dep.version, index_url, &cksum) { - CargoLockRewrite::Rewritten(edit) => { - result.edits.push(*edit); - lock_changed = true; - } - // Re-run over an already-redirected lock: nothing to record. - CargoLockRewrite::AlreadyRedirected => {} - CargoLockRewrite::NotFound => { + // 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. + enum LockCommit { + Write(String, Box), + InPlace, + Absent, + } + let lock_commit = if let Some(lock_text) = cargo_lock.as_ref() { + match plan_cargo_lock(lock_text, &dep.name, &dep.version, index_url, &cksum) { + CargoLockPlan::Rewritten { content, edit } => LockCommit::Write(content, edit), + CargoLockPlan::AlreadyRedirected => LockCommit::InPlace, + CargoLockPlan::NotFound => { result.warnings.push(RewriteWarning { code: "redirect_cargo_lock_pkg_not_found".into(), detail: format!( - "no [[package]] for {}@{} in Cargo.lock", + "no [[package]] for {}@{} in Cargo.lock; dependency skipped \ + (nothing rewritten)", dep.name, dep.version ), }); + continue; } } + } else { + LockCommit::Absent + }; + + // 3. Plan the managed `[registries.…]` block (never fails; `None` + // when a healthy block is already wired in). + let config_plan = plan_cargo_config(&cargo_config, cargo_config_key, ®, index_url); + + // COMMIT — everything planned, nothing can fail past this point, so + // the three files change together or not at all. Edit order matches + // the historical ledger order: config, manifest, lock. + if let Some(plan) = config_plan { + cargo_config = plan.content; + 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; + } + match lock_commit { + LockCommit::Write(content, edit) => { + cargo_lock = Some(content); + result.edits.push(*edit); + lock_changed = true; + } + LockCommit::InPlace | LockCommit::Absent => {} } + result.confirmed_cargo_uuids.insert(dep.patch_uuid.clone()); } if toml_changed { @@ -510,89 +613,542 @@ fn rewrite_cargo( } } -/// Outcome of the Cargo.toml dependency rewrite — a re-run over an entry that -/// already carries OUR `registry = "socket-patch-…"` is "already redirected" -/// (silent), not "dependency not found" (caller warns). -enum CargoTomlRewrite { - Rewritten(Box), - AlreadyRedirected, +/// 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 +/// a sparse+http(s) URL at all. +fn is_valid_cargo_index_url(url: &str) -> bool { + (url.starts_with("sparse+https://") || url.starts_with("sparse+http://")) + && !url.contains('"') + && !url.contains('\\') + && !url.chars().any(char::is_control) +} + +/// The exact shape `hex::encode(sha256)` / the TS `Buffer.toString('hex')` +/// produce: 64 lowercase hex chars. Anything else written as a Cargo.lock +/// `checksum` breaks the next fetch. +fn is_hex64_lower(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// A registry name THIS rewriter owns: `socket-patch-`. An +/// existing pin matching this grammar was written by a previous run and may be +/// superseded in place; any other registry pin is the user's and is refused. +fn is_socket_patch_registry_name(value: &str) -> bool { + value + .strip_prefix("socket-patch-") + .is_some_and(crate::patch::path_safety::is_canonical_uuid) +} + +/// Split a TOML table-header path into dot segments, respecting quoted +/// segments (`target.'cfg(unix)'.dependencies`). `None` on unbalanced quotes. +fn split_toml_header_segments(inner: &str) -> Option> { + let mut segs = Vec::new(); + let mut cur = String::new(); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + match c { + '.' => { + segs.push(cur.trim().to_string()); + cur = String::new(); + } + '"' | '\'' => { + cur.push(c); + let mut closed = false; + for c2 in chars.by_ref() { + cur.push(c2); + if c2 == c { + closed = true; + break; + } + } + if !closed { + return None; + } + } + _ => cur.push(c), + } + } + segs.push(cur.trim().to_string()); + Some(segs) +} + +fn strip_toml_key_quotes(s: &str) -> String { + let b = s.as_bytes(); + if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +#[derive(Debug, Clone, PartialEq)] +enum CargoTomlSection { + /// `[dependencies]` & friends (dev/build/target-specific) plus + /// `[workspace.dependencies]` — entries are `key = value` lines. + DepTable { workspace: bool }, + /// The multi-line table form `[dependencies.]` (all variants). + DepEntry { key: String, workspace: bool }, + Other, +} + +fn is_cargo_dep_kind(seg: &str) -> bool { + matches!( + seg, + "dependencies" | "dev-dependencies" | "build-dependencies" + ) +} + +fn classify_cargo_section(header_inner: &str) -> CargoTomlSection { + let Some(segs) = split_toml_header_segments(header_inner) else { + return CargoTomlSection::Other; + }; + let s: Vec<&str> = segs.iter().map(String::as_str).collect(); + match s.as_slice() { + [k] if is_cargo_dep_kind(k) => CargoTomlSection::DepTable { workspace: false }, + ["workspace", "dependencies"] => CargoTomlSection::DepTable { workspace: true }, + [k, key] if is_cargo_dep_kind(k) => CargoTomlSection::DepEntry { + key: strip_toml_key_quotes(key), + workspace: false, + }, + ["workspace", "dependencies", key] => CargoTomlSection::DepEntry { + key: strip_toml_key_quotes(key), + workspace: true, + }, + ["target", .., k] if is_cargo_dep_kind(k) => CargoTomlSection::DepTable { workspace: false }, + ["target", mid @ .., key] if mid.len() >= 2 && is_cargo_dep_kind(mid[mid.len() - 1]) => { + CargoTomlSection::DepEntry { + key: strip_toml_key_quotes(key), + workspace: false, + } + } + _ => CargoTomlSection::Other, + } +} + +/// Parse the key at the start of a table-entry line: bare (`[A-Za-z0-9_-]+`) +/// or single/double quoted. Returns `(key, rest-after-key)`. +fn parse_cargo_entry_key(line: &str) -> Option<(String, &str)> { + let b = line.as_bytes(); + match b.first()? { + b'"' | b'\'' => { + let quote = b[0] as char; + let end = line[1..].find(quote)? + 1; + Some((line[1..end].to_string(), &line[end + 1..])) + } + _ => { + let end = line + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or(line.len()); + if end == 0 { + return None; + } + Some((line[..end].to_string(), &line[end..])) + } + } +} + +struct CargoTomlPlan { + content: String, + edits: Vec, + /// `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), } -fn add_cargo_toml_registry(content: &mut String, crate_name: &str, reg: &str) -> CargoTomlRewrite { - let c = regex::escape(crate_name); - // Inline table: `crate = { version = "…", … }`. - let table_re = Regex::new(&format!(r"(?m)^({c}\s*=\s*\{{)([^}}\n]*)(\}})")).unwrap(); - if let Some(m) = table_re.captures(content) { - let inner = m.get(2).unwrap().as_str(); - if Regex::new(&format!(r#"\bregistry\s*=\s*"{}""#, regex::escape(reg))) - .unwrap() - .is_match(inner) - { - return CargoTomlRewrite::AlreadyRedirected; +/// How one occurrence of the dep will be handled. +enum CargoTomlAction { + /// `key = "1.0"` → `key = { version = "1.0", registry = "" }`. + ReplaceLine { idx: usize, new_text: String }, + /// `[dependencies.key]` table gains a `registry = ""` line after the + /// header (recorded as a rewrite of the header line so revert-by-string + /// replacement restores it). + InsertAfterHeader { idx: usize, inserted: String }, + /// Already pinned to our registry — nothing to write. + Already, + /// `key.workspace = true` / `{ workspace = true }`: satisfied by the + /// `[workspace.dependencies]` pin in this same manifest. + InheritsWorkspace, +} + +/// Plan the full-manifest pin: EVERY occurrence of `crate_name` across every +/// dependency table gains `registry = ""`, an existing +/// `socket-patch-` pin is superseded in place, and any occurrence that +/// cannot be handled refuses the whole dep. Nothing is applied unless every +/// occurrence resolves. +fn plan_cargo_toml( + content: &str, + crate_name: &str, + reg: &str, +) -> Result { + let lines: Vec<&str> = content.split('\n').collect(); + let header_re = Regex::new(r"^\[([^\]]+)\]\s*(?:#.*)?$").unwrap(); + let package_re = Regex::new(r#"\bpackage\s*=\s*"([^"]*)""#).unwrap(); + let registry_val_re = Regex::new(r#"\bregistry\s*=\s*"([^"]*)""#).unwrap(); + let registry_key_re = Regex::new(r"\bregistry\s*=").unwrap(); + let registry_index_re = Regex::new(r"\bregistry-index\s*=").unwrap(); + let workspace_key_re = Regex::new(r"\bworkspace\s*=").unwrap(); + let path_git_re = Regex::new(r"\b(?:path|git)\s*=").unwrap(); + + // 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, + 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; + + let mut section = CargoTomlSection::Other; + for (idx, raw) in lines.iter().enumerate() { + let trimmed = raw.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed.starts_with('[') && !trimmed.starts_with("[[") { + section = match header_re.captures(trimmed) { + Some(c) => classify_cargo_section(c.get(1).unwrap().as_str()), + None => CargoTomlSection::Other, + }; + if let CargoTomlSection::DepEntry { key, workspace } = section.clone() { + let ws = workspace; + // Table form: examine the whole block now. + let mut end = lines.len(); + for (j, l) in lines.iter().enumerate().skip(idx + 1) { + if l.trim_start().starts_with('[') { + end = j; + break; + } + } + let block: Vec<(usize, &str)> = (idx + 1..end) + .map(|j| (j, lines[j].trim_start())) + .filter(|(_, t)| !t.is_empty() && !t.starts_with('#')) + .collect(); + let find_value = |key_name: &str| -> Option<(usize, String)> { + for (j, t) in &block { + if let Some((k, rest)) = parse_cargo_entry_key(t) { + if k == key_name { + let rest = rest.trim_start(); + if let Some(v) = rest.strip_prefix('=') { + let v = v.trim(); + let v = v + .strip_prefix('"') + .and_then(|s| s.split('"').next()) + .unwrap_or(v); + return Some((*j, v.to_string())); + } + } + } + } + None + }; + let package_val = find_value("package").map(|(_, v)| v); + let is_ours = match &package_val { + Some(p) => p == crate_name, + None => key == crate_name, + }; + if !is_ours { + continue; + } + let has = |name: &str| { + block.iter().any(|(_, t)| { + parse_cargo_entry_key(t).is_some_and(|(k, rest)| { + k == name && rest.trim_start().starts_with('=') + }) + }) + }; + if has("workspace") { + pending.push(Pending::NeedsWorkspacePin); + } else if has("path") || has("git") { + pending.push(Pending::Refuse( + "declared as a path/git dependency".to_string(), + )); + } else if let Some((line_idx, value)) = find_value("registry") { + if value == reg { + pending.push(Pending::Action(CargoTomlAction::Already)); + if ws { + workspace_pinned = true; + } + } else if is_socket_patch_registry_name(&value) { + let old_line = lines[line_idx]; + let new_text = registry_val_re + .replace(old_line, format!("registry = \"{reg}\"").as_str()) + .into_owned(); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { + idx: line_idx, + new_text, + })); + if ws { + workspace_pinned = true; + } + } else { + pending.push(Pending::Refuse(format!( + "pinned to another registry (\"{value}\")" + ))); + } + } else { + let indent = &raw[..raw.len() - trimmed.len()]; + pending.push(Pending::Action(CargoTomlAction::InsertAfterHeader { + idx, + inserted: format!("{indent}registry = \"{reg}\""), + })); + if ws { + workspace_pinned = true; + } + } + } + continue; } - if Regex::new(r"\bregistry\s*=").unwrap().is_match(inner) { - // Pinned to some OTHER registry — leave it alone; the caller's - // warning surfaces that the redirect did not land. - return CargoTomlRewrite::NotFound; + let CargoTomlSection::DepTable { workspace } = section else { + continue; + }; + let Some((key, rest)) = parse_cargo_entry_key(trimmed) else { + continue; + }; + let rest_trim = rest.trim_start(); + if let Some(dotted) = rest_trim.strip_prefix('.') { + // Dotted entry (`serde.workspace = true`, `serde.version = "1"`, + // `alias.package = "serde"`, …). + let sub = parse_cargo_entry_key(dotted).map(|(k, _)| k); + if key == crate_name { + if sub.as_deref() == Some("workspace") { + pending.push(Pending::NeedsWorkspacePin); + } else { + pending.push(Pending::Refuse( + "declared with dotted keys this rewriter does not edit".to_string(), + )); + } + } else if sub.as_deref() == Some("package") + && package_re + .captures(trimmed) + .is_some_and(|c| &c[1] == crate_name) + { + pending.push(Pending::Refuse( + "declared with dotted keys this rewriter does not edit".to_string(), + )); + } + continue; } - let whole = m.get(0).unwrap().as_str().to_string(); - let inner_trim = inner.trim_end(); - let sep = if inner_trim.trim().ends_with(',') || inner_trim.trim().is_empty() { - "" - } else { - "," + let Some(value) = rest_trim.strip_prefix('=') else { + continue; }; - let rebuilt = format!( - "{}{inner_trim}{sep} registry = \"{reg}\" {}", - m.get(1).unwrap().as_str(), - m.get(3).unwrap().as_str() - ); - *content = content.replacen(&whole, &rebuilt, 1); - return CargoTomlRewrite::Rewritten(Box::new(FileEdit { - path: "Cargo.toml".into(), - kind: "redirect_cargo_toml_dep".into(), - action: "rewritten".into(), - key: Some(crate_name.into()), - original: Some(Value::String(whole)), - new: Some(Value::String(rebuilt)), - })); - } - // Plain version: `crate = "1.0"`. - let ver_re = Regex::new(&format!(r#"(?m)^({c}\s*=\s*)"([^"]+)"\s*$"#)).unwrap(); - if let Some(m) = ver_re.captures(content) { - let whole = m.get(0).unwrap().as_str().to_string(); - let rebuilt = format!( - "{}{{ version = \"{}\", registry = \"{reg}\" }}", - m.get(1).unwrap().as_str(), - m.get(2).unwrap().as_str() - ); - *content = content.replacen(&whole, &rebuilt, 1); - return CargoTomlRewrite::Rewritten(Box::new(FileEdit { - path: "Cargo.toml".into(), - kind: "redirect_cargo_toml_dep".into(), - action: "rewritten".into(), - key: Some(crate_name.into()), - original: Some(Value::String(whole)), - new: Some(Value::String(rebuilt)), - })); + let value = value.trim_start(); + if value.starts_with('{') { + // Inline table. Rename-aware: `package = ""` under our key + // means this entry is NOT the patched crate; `package = + // ""` under any key means it IS. + let Some(close) = value.find('}') else { + if key == crate_name { + pending.push(Pending::Refuse( + "inline table does not close on its line".to_string(), + )); + } + continue; + }; + let inner = &value[1..close]; + let package_val = package_re.captures(inner).map(|c| c[1].to_string()); + let is_ours = match &package_val { + Some(p) => p == crate_name, + None => key == crate_name, + }; + if !is_ours { + continue; + } + if workspace_key_re.is_match(inner) { + pending.push(Pending::NeedsWorkspacePin); + } else if path_git_re.is_match(inner) { + pending.push(Pending::Refuse( + "declared as a path/git dependency".to_string(), + )); + } else if let Some(c) = registry_val_re.captures(inner) { + let value = c[1].to_string(); + if value == reg { + pending.push(Pending::Action(CargoTomlAction::Already)); + if workspace { + workspace_pinned = true; + } + } else if is_socket_patch_registry_name(&value) { + let new_text = registry_val_re + .replace(raw, format!("registry = \"{reg}\"").as_str()) + .into_owned(); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { idx, new_text })); + if workspace { + workspace_pinned = true; + } + } else { + pending.push(Pending::Refuse(format!( + "pinned to another registry (\"{value}\")" + ))); + } + } else if registry_key_re.is_match(inner) || registry_index_re.is_match(inner) { + pending.push(Pending::Refuse( + "pinned to another registry".to_string(), + )); + } else { + // Rebuild the line: everything through `{`, the trimmed + // inner, the registry pin, then `}` + any trailing bytes + // (e.g. a comment). First `{`/`}` in the raw line are the + // inline table's — keys and indents cannot contain braces. + let inner_trim = inner.trim_end(); + let sep = if inner_trim.trim().ends_with(',') || inner_trim.trim().is_empty() { + "" + } else { + "," + }; + let brace = raw.find('{').unwrap_or_default(); + let close_raw = raw[brace..].find('}').unwrap_or_default() + brace; + let new_text = format!( + "{}{inner_trim}{sep} registry = \"{reg}\" {}", + &raw[..=brace], + &raw[close_raw..] + ); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { idx, new_text })); + if workspace { + workspace_pinned = true; + } + } + } else if value.starts_with('"') { + if key != crate_name { + continue; + } + // Plain version: `crate = "1.0"` (+ optional trailing comment). + // The rewrite is line-scoped, so the trailing newline / blank + // line after the entry is untouched (the old `\s*$` regex + // swallowed it). + let c = regex::escape(crate_name); + let line_re = + Regex::new(&format!(r#"^(\s*(?:{c}|"{c}")\s*=\s*)"([^"]+)"([ \t]*(?:#.*)?)$"#)) + .unwrap(); + let Some(m) = line_re.captures(raw) else { + pending.push(Pending::Refuse( + "unsupported version-entry spelling".to_string(), + )); + continue; + }; + let new_text = format!( + "{}{{ version = \"{}\", registry = \"{reg}\" }}{}", + m.get(1).unwrap().as_str(), + m.get(2).unwrap().as_str(), + m.get(3).unwrap().as_str() + ); + pending.push(Pending::Action(CargoTomlAction::ReplaceLine { idx, new_text })); + if workspace { + workspace_pinned = true; + } + } else if key == crate_name { + pending.push(Pending::Refuse( + "unsupported dependency-entry spelling".to_string(), + )); + } + } + + if pending.is_empty() { + return Err(CargoTomlPlanError::NotFound); } - CargoTomlRewrite::NotFound + // Resolve: any refusal (including an unsatisfiable `workspace = true` + // inheritor) refuses the WHOLE dep — no partial pin is ever applied. + let mut actions: Vec = Vec::new(); + for p in pending { + match p { + Pending::Action(a) => actions.push(a), + Pending::NeedsWorkspacePin => { + if workspace_pinned { + actions.push(CargoTomlAction::InheritsWorkspace); + } else { + 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)), + } + } + + // 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(); + let mut edits: Vec = Vec::new(); + let mut writes: Vec<(usize, &CargoTomlAction)> = actions + .iter() + .filter_map(|a| match a { + CargoTomlAction::ReplaceLine { idx, .. } => Some((*idx, a)), + CargoTomlAction::InsertAfterHeader { idx, .. } => Some((*idx, a)), + CargoTomlAction::Already | CargoTomlAction::InheritsWorkspace => None, + }) + .collect(); + writes.sort_by_key(|(idx, _)| *idx); + for (idx, action) in &writes { + match action { + CargoTomlAction::ReplaceLine { new_text, .. } => { + edits.push(FileEdit { + path: "Cargo.toml".into(), + kind: "redirect_cargo_toml_dep".into(), + action: "rewritten".into(), + key: Some(crate_name.into()), + original: Some(Value::String(lines[*idx].to_string())), + new: Some(Value::String(new_text.clone())), + }); + } + CargoTomlAction::InsertAfterHeader { inserted, .. } => { + edits.push(FileEdit { + path: "Cargo.toml".into(), + kind: "redirect_cargo_toml_dep".into(), + action: "rewritten".into(), + key: Some(crate_name.into()), + original: Some(Value::String(lines[*idx].to_string())), + new: Some(Value::String(format!("{}\n{inserted}", lines[*idx]))), + }); + } + CargoTomlAction::Already | CargoTomlAction::InheritsWorkspace => {} + } + } + for (idx, action) in writes.iter().rev() { + match action { + CargoTomlAction::ReplaceLine { new_text, .. } => { + new_lines[*idx] = new_text.clone(); + } + CargoTomlAction::InsertAfterHeader { inserted, .. } => { + new_lines.insert(idx + 1, inserted.clone()); + } + CargoTomlAction::Already | CargoTomlAction::InheritsWorkspace => {} + } + } + let changed = !edits.is_empty(); + Ok(CargoTomlPlan { + content: new_lines.join("\n"), + edits, + changed, + }) } -fn set_cargo_lock_source( - content: &mut String, +fn plan_cargo_lock( + content: &str, crate_name: &str, version: &str, index_url: &str, cksum: &str, -) -> CargoLockRewrite { +) -> CargoLockPlan { // Rust's regex has NO lookahead, so bound the [[package]] block by string // search: from its header to the next `\n[[package]]` (or EOF), so the // trailing bytes after the block (incl. the final newline) are preserved. let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); let Some(block_start) = content.find(&head) else { - return CargoLockRewrite::NotFound; + return CargoLockPlan::NotFound; }; let body_start = block_start + head.len(); let mut block_end = match content[body_start..].find("\n[[package]]") { @@ -631,28 +1187,107 @@ fn set_cargo_lock_source( // Already redirected (re-run): the block is at the target values; a // recorded edit would have original == new and grow the ledger forever. if rebuilt == original { - return CargoLockRewrite::AlreadyRedirected; + return CargoLockPlan::AlreadyRedirected; + } + let new_content = content.replacen(&original, &rebuilt, 1); + CargoLockPlan::Rewritten { + content: new_content, + edit: Box::new(FileEdit { + path: "Cargo.lock".into(), + kind: "redirect_cargo_lock_entry".into(), + action: "rewritten".into(), + key: Some(format!("{crate_name}@{version}")), + original: Some(Value::String(original)), + new: Some(Value::String(rebuilt)), + }), } - *content = content.replacen(&original, &rebuilt, 1); - CargoLockRewrite::Rewritten(Box::new(FileEdit { - path: "Cargo.lock".into(), - kind: "redirect_cargo_lock_entry".into(), - action: "rewritten".into(), - key: Some(format!("{crate_name}@{version}")), - original: Some(Value::String(original)), - new: Some(Value::String(rebuilt)), - })) } -/// Outcome of the Cargo.lock `[[package]]` rewrite — distinguishes a re-run +/// Outcome of the Cargo.lock `[[package]]` plan — distinguishes a re-run /// over an already-redirected block (no edit, no warning) from a genuinely -/// missing package (caller warns). -enum CargoLockRewrite { - Rewritten(Box), +/// missing package (the caller warns AND skips the dep entirely). +enum CargoLockPlan { + Rewritten { content: String, edit: Box }, AlreadyRedirected, NotFound, } +struct CargoConfigPlan { + content: String, + edit: FileEdit, +} + +/// Plan the managed `[registries.socket-patch-]` block. `None` when a +/// HEALTHY block is already wired in — an uncommented header with an +/// uncommented `index = ""` line. Comments never satisfy the +/// check: a user who commented the managed block out gets it restored on the +/// next run (the old substring test matched the commented text, reported +/// success, and left `registry = "socket-patch-…"` in Cargo.toml naming an +/// undefined registry). A degraded block (missing/stale index line) is +/// regenerated in place — it is ours, the header grammar proves it. +fn plan_cargo_config( + config: &str, + config_key: &str, + reg: &str, + index_url: &str, +) -> Option { + let header = format!("[registries.{reg}]"); + let index_line = format!("index = \"{index_url}\""); + let lines: Vec<&str> = config.split('\n').collect(); + let header_idx = lines.iter().position(|l| l.trim() == header); + if let Some(i) = header_idx { + let mut end = lines.len(); + for (j, l) in lines.iter().enumerate().skip(i + 1) { + if l.trim_start().starts_with('[') { + end = j; + break; + } + } + // Keep trailing blank separator lines out of the managed region. + while end > i + 1 && lines[end - 1].trim().is_empty() { + end -= 1; + } + let healthy = lines[i + 1..end].iter().any(|l| l.trim() == index_line); + if healthy { + return None; + } + let original_region = lines[i..end].join("\n"); + let replacement = format!("{header}\n{index_line}"); + let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); + new_lines.splice(i..end, [header.clone(), index_line.clone()]); + return Some(CargoConfigPlan { + content: new_lines.join("\n"), + edit: FileEdit { + path: config_key.into(), + kind: "redirect_cargo_registry".into(), + action: "rewritten".into(), + key: Some(reg.to_string()), + original: Some(Value::String(original_region)), + new: Some(Value::String(replacement)), + }, + }); + } + // Absent (or surviving only in comments): append a fresh block. + let block = format!("{header}\n{index_line}\n"); + let sep = if !config.is_empty() && !config.ends_with('\n') { + "\n" + } else { + "" + }; + let prefix = if config.is_empty() { "" } else { "\n" }; + Some(CargoConfigPlan { + content: format!("{config}{sep}{prefix}{block}"), + edit: FileEdit { + path: config_key.into(), + kind: "redirect_cargo_registry".into(), + action: "added".into(), + key: Some(reg.to_string()), + original: None, + new: Some(Value::String(block)), + }, + }) +} + // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── fn rewrite_pnpm_lock( files: &BTreeMap, @@ -3408,6 +4043,18 @@ mod tests { ); } + /// Canonical lowercase patch uuid — the rewriter validates the uuid + /// grammar fail-closed before interpolating it into TOML. + const CARGO_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn cargo_reg() -> String { + format!("socket-patch-{CARGO_UUID}") + } + + fn cargo_index_url() -> String { + format!("sparse+https://patch.test/cargo/{CARGO_UUID}/index/") + } + fn cargo_sparse_override() -> DepOverride { DepOverride { ecosystem: "cargo".into(), @@ -3415,12 +4062,12 @@ mod tests { namespace: None, version: "1.0.190".into(), token: "tok".into(), - patch_uuid: "uuid".into(), + patch_uuid: CARGO_UUID.into(), artifact_url: "https://patch.test/serde-1.0.190.crate".into(), berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "cargo-sparse".into(), - index_url: "sparse+https://patch.test/cargo/uuid/".into(), + index_url: cargo_index_url(), identifiers: RegistryOverrideIdentifiers { name: "serde".into(), version: "1.0.190".into(), @@ -3507,7 +4154,7 @@ mod tests { ) }); assert!( - written.contains("[registries.socket-patch-uuid]"), + written.contains(&format!("[registries.{}]", cargo_reg())), "registry definition must land in the legacy config: {written}" ); assert!( @@ -3529,6 +4176,9 @@ mod tests { } /// The default (no legacy file) shape is unchanged: `.cargo/config.toml`. + /// With no Cargo.lock at all the manifest pin alone forces the next + /// resolution through the managed registry, so the dep still counts as + /// fully landed. #[test] fn cargo_config_toml_is_the_default_target() { let mut files = BTreeMap::new(); @@ -3540,6 +4190,605 @@ mod tests { let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); assert!(r.files.contains_key(".cargo/config.toml")); assert!(!r.files.contains_key(".cargo/config")); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + fn cargo_lock_with(name: &str, version: &str) -> String { + format!( + "# This file is automatically @generated by Cargo.\n\ + version = 3\n\ + \n\ + [[package]]\n\ + name = \"{name}\"\n\ + version = \"{version}\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"91f70896d6720bc714a4a57d22fc91f1db634680e65c8efe13323f1fa38d53f5\"\n" + ) + } + + fn cargo_files(toml: &str) -> BTreeMap { + let mut files = BTreeMap::new(); + files.insert("Cargo.toml".to_string(), toml.to_string()); + files.insert( + "Cargo.lock".to_string(), + cargo_lock_with("serde", "1.0.190"), + ); + files + } + + /// AUDIT A1+A7: a crate declared in BOTH [dev-dependencies] and + /// [dependencies] must gain the registry pin in BOTH sections — a + /// first-match-only rewrite gives the two sections different sources for + /// the same dep, which cargo rejects at manifest-parse time, bricking + /// every cargo command. The blank separator line after each entry must + /// survive (the old `\s*$` regex swallowed it). + #[test] + fn cargo_two_sections_rewrites_all_occurrences_and_preserves_blank_lines() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dev-dependencies]\nserde = \"1.0.190\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("Cargo.toml rewritten"); + let pinned = format!("serde = {{ version = \"1.0.190\", registry = \"{}\" }}", cargo_reg()); + assert_eq!( + toml.matches(&pinned).count(), + 2, + "BOTH sections must be pinned: {toml}" + ); + assert!( + toml.contains(&format!("{pinned}\n\n[dependencies]")), + "the blank line before [dependencies] must be preserved: {toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + // One manifest edit per occurrence. + assert_eq!( + r.edits + .iter() + .filter(|e| e.kind == "redirect_cargo_toml_dep") + .count(), + 2 + ); + } + + /// AUDIT A2: the multi-line `[dependencies.]` table form is a + /// completely standard manifest shape — it gains a `registry = "…"` line + /// instead of being reported not-found (which used to leave the lock + /// repointed while the manifest still said crates.io: `--locked` builds + /// broke, unlocked builds silently dropped the patch). + #[test] + fn cargo_table_form_dep_gains_registry_line() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies.serde]\nversion = \"1.0.190\"\nfeatures = [\"derive\"]\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("Cargo.toml rewritten"); + assert!( + toml.contains(&format!( + "[dependencies.serde]\nregistry = \"{}\"\nversion = \"1.0.190\"", + cargo_reg() + )), + "the table must gain a registry line: {toml}" + ); + assert!(r.files.contains_key("Cargo.lock")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + + // Idempotent re-run over the rewritten output: silent no-op. + let mut again = files.clone(); + for (name, content) in &r.files { + again.insert(name.clone(), content.clone()); + } + let second = rewrite_registry_redirect(&again, &[cargo_sparse_override()]); + assert!( + second.files.is_empty() && second.edits.is_empty() && second.warnings.is_empty(), + "re-run must be a silent no-op: files={:?} warnings={:?}", + second.files.keys(), + second.warnings + ); + assert!(second.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// AUDIT A5: rename-aware matching. An entry whose KEY matches the + /// patched crate but whose `package = ""` names a different crate + /// is NOT the patched crate (pinning it would point a foreign package at + /// the single-crate socket registry — resolution hard-fails); the patched + /// crate consumed under an ALIAS key (`iffy = { package = "serde" }`) IS. + #[test] + fn cargo_rename_aware_matching() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\n\ + serde = { package = \"leftpad\", version = \"1.0.0\" }\n\ + iffy = { package = \"serde\", version = \"1.0.190\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("Cargo.toml rewritten"); + assert!( + toml.contains("serde = { package = \"leftpad\", version = \"1.0.0\" }"), + "the key-colliding entry for a DIFFERENT crate must be untouched: {toml}" + ); + assert!( + toml.contains(&format!( + "iffy = {{ package = \"serde\", version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + )), + "the aliased entry for the patched crate must be pinned: {toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// AUDIT A5(a) alone: when the ONLY key match renames a different crate, + /// the dep is genuinely not declared → not-found, and NOTHING is written + /// (no config block, no lock repoint). + #[test] + fn cargo_key_collision_only_is_not_found_and_writes_nothing() { + let mut files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = { package = \"leftpad\", version = \"1.0.0\" }\n", + ); + files.insert( + "Cargo.lock".to_string(), + cargo_lock_with("leftpad", "1.0.0"), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "nothing may be written: {:?}", + r.files.keys() + ); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_not_found")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// AUDIT A4: a re-scan that selects a NEWER patch uuid over an existing + /// redirect must supersede the old `registry = "socket-patch-"` pin + /// in place — the old code classified it as a foreign registry and left + /// the manifest on the OLD uuid while moving the lock to the NEW one + /// (broken `--locked` builds, unlocked builds resolving the superseded + /// patch, VEX attesting the new one). + #[test] + fn cargo_supersede_replaces_previous_socket_registry_pin() { + const OLD_UUID: &str = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; + let old_reg = format!("socket-patch-{OLD_UUID}"); + let old_index = format!("sparse+https://patch.test/cargo/{OLD_UUID}/index/"); + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = {{ version = \"1.0.190\", registry = \"{old_reg}\" }}\n" + ), + ); + files.insert( + "Cargo.lock".to_string(), + cargo_lock_with("serde", "1.0.190").replace( + "registry+https://github.com/rust-lang/crates.io-index", + &old_index, + ), + ); + files.insert( + ".cargo/config.toml".to_string(), + format!("[registries.{old_reg}]\nindex = \"{old_index}\"\n"), + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("manifest re-pinned"); + assert!( + toml.contains(&format!("registry = \"{}\"", cargo_reg())) && !toml.contains(&old_reg), + "the manifest must move to the NEW registry: {toml}" + ); + let lock = r.files.get("Cargo.lock").expect("lock re-pinned"); + assert!(lock.contains(&cargo_index_url()), "{lock}"); + let cfg = r.files.get(".cargo/config.toml").expect("config updated"); + assert!(cfg.contains(&format!("[registries.{}]", cargo_reg())), "{cfg}"); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_not_found"), + "supersession is not 'dependency missing': {:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// A pin to a registry this rewriter does NOT own is the user's — refuse + /// the whole dep (no lock edit, no config block) with one clear warning. + #[test] + fn cargo_foreign_registry_pin_refuses_whole_dep() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = { version = \"1.0.190\", registry = \"corp\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "nothing may be written: {:?}", + r.files.keys() + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_unrewritable"), + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// AUDIT A2/A3 (transactionality): when ONE occurrence is rewritable but + /// ANOTHER is not, the dep is skipped ENTIRELY — a partial pin (one + /// section redirected, one not) gives the dep two different sources and + /// cargo refuses the manifest. + #[test] + fn cargo_unrewritable_occurrence_skips_dep_entirely() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\"\n\n\ + [dev-dependencies]\nserde.version = \"1.0.190\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "no partial pin may be written: {:?}", + r.files.keys() + ); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_unrewritable")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// AUDIT A3 (the cargo analogue of npm's + /// `no_lockfile_redirect_is_not_attested`): a granted dep the project + /// does not declare at all (e.g. surfaced by the machine-wide + /// $CARGO_HOME crawl) must produce NO writes — the old code still wrote + /// the inert `[registries.…]` block, whose index URL then satisfied the + /// hosted confirmed check and produced a false VEX attestation. + #[test] + fn cargo_undeclared_dep_writes_nothing_not_even_the_config_block() { + let mut files = BTreeMap::new(); + files.insert( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nanyhow = \"1.0\"\n" + .to_string(), + ); + files.insert("Cargo.lock".to_string(), cargo_lock_with("anyhow", "1.0.0")); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "no file (config block included) may be written: {:?}", + r.files.keys() + ); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_not_found")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A Cargo.lock that exists but has no [[package]] for the dep means the + /// project does not resolve it — pinning the manifest anyway desyncs + /// manifest and lock. Skip the dep entirely. + #[test] + fn cargo_missing_lock_entry_skips_dep_entirely() { + let mut files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + files.insert("Cargo.lock".to_string(), cargo_lock_with("anyhow", "1.0.0")); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "no partial edit set may be written: {:?}", + r.files.keys() + ); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_lock_pkg_not_found")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// AUDIT A6: a user who commented the managed [registries] block out (to + /// debug an install) and re-runs the scan gets the block RESTORED. The + /// old substring idempotence check matched the commented text, wrote + /// nothing, and the run still reported the dep redirected while every + /// cargo command failed on the undefined registry. + #[test] + fn cargo_commented_config_block_is_restored() { + // First run to produce the redirected state. + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let first = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let mut redirected = files.clone(); + for (name, content) in &first.files { + redirected.insert(name.clone(), content.clone()); + } + // Comment out every line of the managed config block. + let commented = redirected[".cargo/config.toml"] + .lines() + .map(|l| { + if l.is_empty() { + l.to_string() + } else { + format!("#{l}") + } + }) + .collect::>() + .join("\n") + + "\n"; + redirected.insert(".cargo/config.toml".to_string(), commented.clone()); + + let second = rewrite_registry_redirect(&redirected, &[cargo_sparse_override()]); + let cfg = second + .files + .get(".cargo/config.toml") + .expect("the managed block must be restored"); + assert!( + cfg.contains(&format!( + "[registries.{}]\nindex = \"{}\"", + cargo_reg(), + cargo_index_url() + )), + "an UNCOMMENTED block must exist after the re-run: {cfg}" + ); + assert!( + cfg.contains(&format!("#[registries.{}]", cargo_reg())), + "the user's commented lines are preserved: {cfg}" + ); + assert!(second.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// A degraded managed block (header intact, index line commented or + /// stale) is regenerated in place rather than trusted. + #[test] + fn cargo_degraded_config_block_is_regenerated() { + let mut files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = { version = \"1.0.190\", registry = \"socket-patch-9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f\" }\n", + ); + files.insert( + "Cargo.lock".to_string(), + cargo_lock_with("serde", "1.0.190").replace( + "registry+https://github.com/rust-lang/crates.io-index", + &cargo_index_url(), + ), + ); + files.insert( + ".cargo/config.toml".to_string(), + format!( + "[registries.{}]\n#index = \"{}\"\n", + cargo_reg(), + cargo_index_url() + ), + ); + // The lock checksum still differs from the override's — rewritten. + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let cfg = r + .files + .get(".cargo/config.toml") + .expect("degraded block regenerated"); + assert!( + cfg.contains(&format!("\nindex = \"{}\"", cargo_index_url())), + "an uncommented index line must exist: {cfg}" + ); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// AUDIT A9: an empty-string `cargoCksumSha256` is MISSING (the TS twin's + /// falsy check), never written as `checksum = ""` into Cargo.lock — that + /// hard-fails the next `cargo fetch --locked`. + #[test] + fn cargo_empty_string_cksum_skips_dep() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let mut dep = cargo_sparse_override(); + if let Some(ov) = dep.registry_override.as_mut() { + ov.identifiers.cargo_cksum_sha256 = Some(String::new()); + } + dep.integrity = Integrity::default(); + let r = rewrite_registry_redirect(&files, &[dep]); + assert!( + r.files.is_empty(), + "nothing may be written: {:?}", + r.files.keys() + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_cargo_missing_cksum"), + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// AUDIT A8: service-supplied strings are validated against their exact + /// grammars before interpolation into raw TOML — a hostile patch uuid, + /// index URL, or cksum must be refused, never written (TOML injection: + /// a `]`+newline uuid can define `[source.crates-io] replace-with = …` + /// redirecting EVERY crate). + #[test] + fn cargo_hostile_service_inputs_are_refused() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + // Hostile uuid. + let mut dep = cargo_sparse_override(); + dep.patch_uuid = "x]\n[source.crates-io]\nreplace-with = \"evil\"\n[registries.y".into(); + let r = rewrite_registry_redirect(&files, &[dep]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_invalid_uuid")); + assert!(r.confirmed_cargo_uuids.is_empty()); + + // Hostile index URL (quote breaks out of the TOML string). + let mut dep = cargo_sparse_override(); + if let Some(ov) = dep.registry_override.as_mut() { + ov.index_url = "sparse+https://x/\"\nreplace-with = \"evil\"".into(); + } + let r = rewrite_registry_redirect(&files, &[dep]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_invalid_index_url")); + + // Non-sparse index URL. + let mut dep = cargo_sparse_override(); + if let Some(ov) = dep.registry_override.as_mut() { + ov.index_url = "https://patch.test/cargo/index/".into(); + } + let r = rewrite_registry_redirect(&files, &[dep]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_invalid_index_url")); + + // Malformed cksum (not 64 lowercase hex). + let mut dep = cargo_sparse_override(); + if let Some(ov) = dep.registry_override.as_mut() { + ov.identifiers.cargo_cksum_sha256 = Some("\"\nevil = 1\n".into()); + } + let r = rewrite_registry_redirect(&files, &[dep]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_invalid_cksum")); + } + + /// Workspace inheritance: the pin lands on the [workspace.dependencies] + /// entry (which member `workspace = true` entries inherit), and the + /// inheriting occurrences are then satisfied. + #[test] + fn cargo_workspace_inheritance_pins_the_workspace_table() { + let files = cargo_files( + "[workspace]\nmembers = [\"member\"]\n\n\ + [workspace.dependencies]\nserde = \"1.0.190\"\n\n\ + [dependencies]\nserde.workspace = true\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("workspace table pinned"); + assert!( + toml.contains(&format!( + "[workspace.dependencies]\nserde = {{ version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + )), + "{toml}" + ); + assert!( + toml.contains("serde.workspace = true"), + "the inheriting entry is untouched: {toml}" + ); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// `workspace = true` with NO [workspace.dependencies] entry in this + /// manifest (deps declared in member manifests the rewriter cannot see) + /// must refuse the whole dep — fail closed, nothing written. + #[test] + fn cargo_workspace_inheritance_without_entry_refuses() { + let files = cargo_files( + "[package]\nname = \"member\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = { workspace = true }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_unrewritable")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A plain-version entry with a trailing comment keeps the comment. + #[test] + fn cargo_plain_version_trailing_comment_preserved() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = \"1.0.190\" # pinned for CVE-2024-XXXX\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("rewritten"); + assert!( + toml.contains(&format!( + "serde = {{ version = \"1.0.190\", registry = \"{}\" }} # pinned for CVE-2024-XXXX", + cargo_reg() + )), + "{toml}" + ); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); + } + + /// A path/git dependency never resolves through a registry — pinning it + /// would be a lie; refuse the whole dep. + #[test] + fn cargo_path_dep_is_refused() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies]\nserde = { path = \"../serde\" }\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_unrewritable")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A cargo dep whose override kind is not `cargo-sparse` warns (the TS + /// twin's behavior) instead of vanishing silently. + #[test] + fn cargo_kind_mismatch_warns() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0.190\"\n", + ); + let mut dep = cargo_sparse_override(); + if let Some(ov) = dep.registry_override.as_mut() { + ov.kind = "goproxy".into(); + } + let r = rewrite_registry_redirect(&files, &[dep]); + assert!(r.files.is_empty(), "{:?}", r.files.keys()); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_cargo_missing_override")); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + + /// A target-specific dependency table is a rewrite target like the plain + /// sections. + #[test] + fn cargo_target_specific_table_is_rewritten() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [target.'cfg(unix)'.dependencies]\nserde = \"1.0.190\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + let toml = r.files.get("Cargo.toml").expect("rewritten"); + assert!( + toml.contains(&format!( + "[target.'cfg(unix)'.dependencies]\nserde = {{ version = \"1.0.190\", registry = \"{}\" }}", + cargo_reg() + )), + "{toml}" + ); + assert!(r.confirmed_cargo_uuids.contains(CARGO_UUID)); } fn gem_override(name: &str, version: &str) -> DepOverride { diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected-edits.json new file mode 100644 index 00000000..b320cbed --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected-edits.json @@ -0,0 +1,9 @@ +[ + { + "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" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/expected/.cargo/config.toml new file mode 100644 index 00000000..6df734f9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/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-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/commented-config/input/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/.cargo/config.toml new file mode 100644 index 00000000..bffedda7 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/.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/commented-config/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.lock new file mode 100644 index 00000000..0222bc9b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.lock @@ -0,0 +1,9 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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/commented-config/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.toml new file mode 100644 index 00000000..3fd8caca --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +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/commented-config/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/commented-config/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/renamed/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected-edits.json new file mode 100644 index 00000000..6988a142 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected-edits.json @@ -0,0 +1,25 @@ +[ + { + "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-alias = { package = \"serde\", version = \"1.0.190\" }", + "new": "serde-alias = { package = \"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/renamed/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/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/renamed/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.lock new file mode 100644 index 00000000..9b200d27 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.lock @@ -0,0 +1,15 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "leftpad" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaaac334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9caaaa" + +[[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/renamed/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.toml new file mode 100644 index 00000000..0ef0b887 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/expected/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { package = "leftpad", version = "1.0.0" } +serde-alias = { package = "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/renamed/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.lock new file mode 100644 index 00000000..579f3fda --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.lock @@ -0,0 +1,15 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "leftpad" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaaac334ca1ee894a2c6f6ad7bf058a4d9a3b30e9e0d5a9d1f3e8f0c2c9caaaa" + +[[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/renamed/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.toml new file mode 100644 index 00000000..75ff8acd --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { package = "leftpad", version = "1.0.0" } +serde-alias = { package = "serde", version = "1.0.190" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/renamed/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/rerun/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/.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/rerun/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.lock new file mode 100644 index 00000000..0222bc9b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.lock @@ -0,0 +1,9 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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/rerun/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.toml new file mode 100644 index 00000000..3fd8caca --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +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/rerun/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/rerun/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/supersede/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected-edits.json new file mode 100644 index 00000000..5e606955 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected-edits.json @@ -0,0 +1,25 @@ +[ + { + "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 = { version = \"1.0.190\", registry = \"socket-patch-00000000-0000-0000-0000-000000000000\" }", + "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 = \"sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000/index/\"\nchecksum = \"1111beefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdead1111\"", + "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/supersede/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/.cargo/config.toml new file mode 100644 index 00000000..6c84a472 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/.cargo/config.toml @@ -0,0 +1,5 @@ +[registries.socket-patch-00000000-0000-0000-0000-000000000000] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000/index/" + +[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/supersede/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.lock new file mode 100644 index 00000000..0222bc9b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.lock @@ -0,0 +1,9 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[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/supersede/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.toml new file mode 100644 index 00000000..3fd8caca --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/expected/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +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/supersede/input/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/.cargo/config.toml new file mode 100644 index 00000000..e6b611c1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/.cargo/config.toml @@ -0,0 +1,2 @@ +[registries.socket-patch-00000000-0000-0000-0000-000000000000] +index = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000/index/" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.lock new file mode 100644 index 00000000..53ae1042 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.lock @@ -0,0 +1,9 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "serde" +version = "1.0.190" +source = "sparse+https://patch.socket.dev/patch-registry/cargo/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000/index/" +checksum = "1111beefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdead1111" diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.toml new file mode 100644 index 00000000..8d0dad61 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/input/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.190", registry = "socket-patch-00000000-0000-0000-0000-000000000000" } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/supersede/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/table-form/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected-edits.json new file mode 100644 index 00000000..b8283353 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected-edits.json @@ -0,0 +1,25 @@ +[ + { + "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": "[dependencies.serde]", + "new": "[dependencies.serde]\nregistry = \"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/table-form/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/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/table-form/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/Cargo.lock new file mode 100644 index 00000000..e737470e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/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/table-form/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/Cargo.toml new file mode 100644 index 00000000..5c116c80 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/expected/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies.serde] +registry = "socket-patch-55555555-5555-5555-5555-555555555555" +version = "1.0.190" +features = ["derive"] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.lock new file mode 100644 index 00000000..bf6fa585 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/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/table-form/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.toml new file mode 100644 index 00000000..81a9b951 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/input/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dependencies.serde] +version = "1.0.190" +features = ["derive"] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/table-form/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/two-sections/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected-edits.json new file mode 100644 index 00000000..a6ec2123 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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": "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/two-sections/expected/.cargo/config.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/.cargo/config.toml new file mode 100644 index 00000000..743fa5dc --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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/two-sections/expected/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/Cargo.lock new file mode 100644 index 00000000..e737470e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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/two-sections/expected/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/Cargo.toml new file mode 100644 index 00000000..0d3454c8 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/expected/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +serde = { version = "1.0.190", registry = "socket-patch-55555555-5555-5555-5555-555555555555" } + +[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/two-sections/input/Cargo.lock b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/input/Cargo.lock new file mode 100644 index 00000000..bf6fa585 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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/two-sections/input/Cargo.toml b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/input/Cargo.toml new file mode 100644 index 00000000..ea5fee45 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/input/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "myapp" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +serde = "1.0.190" + +[dependencies] +serde = "1.0.190" +anyhow = { version = "1.0", features = ["backtrace"] } diff --git a/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/overrides.json new file mode 100644 index 00000000..5fc4f53a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/cargo/cargo/two-sections/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" + } + } +] From 358efb4b2fb980e3e7840128786b78083b818f27 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 14 Aug 2026 07:55:13 -0700 Subject: [PATCH 2/2] fix(redirect): refuse table-form deps carrying registry-index The table-form branch of plan_cargo_toml checked workspace/path/git and an existing registry value but never registry-index, so a block like [dependencies.anyhow] version = "1.0.0" registry-index = "sparse+https://index.crates.io/" gained an inserted registry = "socket-patch-" line next to the existing registry-index line. Cargo rejects a dependency naming both keys as ambiguous at manifest-parse time, so every cargo command broke - while the dep still landed in confirmed_cargo_uuids with zero warnings, printing "Redirected 1 package(s)", persisting a ledger record, and emitting an assume_applied VEX statement for a bricked project. Refuse the whole dep (redirect_cargo_toml_dep_unrewritable, zero writes, no confirmation) when a table-form block carries a registry-index key, mirroring the inline-table branch, and cover it with a unit test. Co-Authored-By: Claude Fable 5 --- .../src/patch/redirect/mod.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index eaa01584..f0af08ff 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -877,6 +877,13 @@ fn plan_cargo_toml( pending.push(Pending::Refuse( "declared as a path/git dependency".to_string(), )); + } else if has("registry-index") { + // Inserting `registry = …` next to `registry-index` makes + // cargo reject the manifest as ambiguous — refuse, like + // the inline-table branch does. + pending.push(Pending::Refuse( + "pinned to another registry".to_string(), + )); } else if let Some((line_idx, value)) = find_value("registry") { if value == reg { pending.push(Pending::Action(CargoTomlAction::Already)); @@ -4422,6 +4429,34 @@ mod tests { assert!(r.confirmed_cargo_uuids.is_empty()); } + /// A table-form block that carries `registry-index` cannot take a + /// `registry` pin — cargo rejects a dependency naming both keys as + /// ambiguous, so inserting the pin bricks every cargo command. Refuse + /// the whole dep (zero writes, no confirmation), like the inline-table + /// branch already does. + #[test] + fn cargo_table_form_registry_index_refuses_whole_dep() { + let files = cargo_files( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n\ + [dependencies.serde]\nversion = \"1.0.190\"\n\ + registry-index = \"sparse+https://index.crates.io/\"\n", + ); + let r = rewrite_registry_redirect(&files, &[cargo_sparse_override()]); + assert!( + r.files.is_empty(), + "nothing may be written: {:?}", + r.files.keys() + ); + assert!( + r.warnings + .iter() + .any(|w| w.code == "redirect_cargo_toml_dep_unrewritable"), + "{:?}", + r.warnings + ); + assert!(r.confirmed_cargo_uuids.is_empty()); + } + /// AUDIT A2/A3 (transactionality): when ONE occurrence is rewritable but /// ANOTHER is not, the dep is skipped ENTIRELY — a partial pin (one /// section redirected, one not) gives the dep two different sources and