From fc933c3cb7cdbc351e3b0af83af3dc09f57ecff0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:56:18 -0400 Subject: [PATCH 01/11] Match every patch generation of an owned pin in remove and rollback remove and rollback matched the vendor ledger and the hosted pins by the raw identifier only, never by the manifest entries the identifier selected. When the manifest recorded a superseding patch B while the ledger still held the vendored generation A, `remove ` dropped the manifest record and left A's vendoring wired, reporting success (#999). Ledgers::matching now also selects the vendored entries a matched manifest key claims, and the new ledgers::hosted_pins_matching selects the hosted pins wiring the same release as a matched manifest key. remove and rollback both route through them; the duplicate pin matcher in remove.rs and the inline loop in rollback.rs are deleted. Un-ignores remove_by_uuid_reverts_vendoring_when_ledger_generation_is_older and adds its rollback twin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/remove.rs | 38 +++--- .../socket-patch-cli/src/commands/rollback.rs | 14 +-- .../tests/remove/remove_invariants.rs | 34 ++++- crates/socket-patch-core/src/ledgers.rs | 118 +++++++++++++++++- 4 files changed, 172 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index d69b1183e..6bbb80ed6 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,5 +1,6 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::ledgers::hosted_pins_matching; use socket_patch_core::manifest::cleanup_blobs::{format_bytes, ArtifactReferences}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; @@ -24,9 +25,16 @@ use crate::ui::plural; /// Vendor-ledger entries matching a remove identifier /// ([`socket_patch_core::ledgers::Ledgers::matching`]), sorted by key for -/// deterministic event order. -fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { +/// deterministic event order. With the manifest, an entry the matched +/// manifest keys claim matches whatever patch generation it recorded +/// (#999); the ledger-only path passes `None`. +fn vendor_entries_matching( + state: &VendorState, + manifest: Option<&PatchManifest>, + identifier: &str, +) -> Vec<(String, VendorEntry)> { socket_patch_core::ledgers::Ledgers { + manifest, vendor: Some(state), ..Default::default() } @@ -34,18 +42,6 @@ fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String .vendor } -/// The lockfiles' hosted pins matching a remove identifier (by purl or -/// patch uuid), sorted by purl. -fn hosted_pins_matching(pins: &[HostedPin], identifier: &str) -> Vec { - let mut matches: Vec = pins - .iter() - .filter(|pin| patch_matches(&pin.purl, &pin.uuid, identifier)) - .cloned() - .collect(); - matches.sort_by(|a, b| a.purl.cmp(&b.purl)); - matches -} - /// Drop every manifest entry matching `identifier` except `exclusions` /// (drift-kept vendored purls, whose record must survive with their /// vendored state). Returns the removed purls, sorted. @@ -444,6 +440,7 @@ pub async fn run(args: RemoveArgs) -> i32 { .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier)) .collect(); matching.sort_by(|a, b| a.0.cmp(b.0)); + let matched_keys: Vec = matching.iter().map(|(purl, _)| (*purl).clone()).collect(); // The vendor ledger, loaded ONCE under the lock: it scopes the nested // rollback (vendor-owned purls are not restored in place) and drives @@ -462,7 +459,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // --revert`'s all-at-once). An unreadable ledger falls through to // `not_found`: nothing is mutated on that path. if let Ok(state) = vendor_state_result { - let ledger_matches = vendor_entries_matching(&state, &args.identifier); + let ledger_matches = vendor_entries_matching(&state, None, &args.identifier); if !ledger_matches.is_empty() { return remove_ledger_only( &args, @@ -478,7 +475,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // Hosted-only patches likewise have no manifest entry — their // lockfile pins are their only persistence, and `remove` is their // per-purl exit path (restoring the upstream entry IS the removal). - let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &[]); if !hosted_matches.is_empty() { return remove_hosted_only( &args, @@ -541,9 +538,9 @@ pub async fn run(args: RemoveArgs) -> i32 { } else { let vendored = vendor_state_result .as_ref() - .map(|st| vendor_entries_matching(st, &args.identifier).len()) + .map(|st| vendor_entries_matching(st, Some(&manifest), &args.identifier).len()) .unwrap_or(0); - let hosted = hosted_pins_matching(&hosted_pins, &args.identifier).len(); + let hosted = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys).len(); (vendored, hosted) }; let prompt = remove_prompt( @@ -723,7 +720,8 @@ pub async fn run(args: RemoveArgs) -> i32 { return 1; } }; - let vendored_matches = vendor_entries_matching(&vendor_state, &args.identifier); + let vendored_matches = + vendor_entries_matching(&vendor_state, Some(&manifest), &args.identifier); let mut vendor_leg = RemoveVendorLeg::default(); if !vendored_matches.is_empty() { if args.skip_rollback { @@ -772,7 +770,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // carried into the success envelope's `warnings[]`. let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new(); if !args.skip_rollback { - let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier, &matched_keys); if !hosted_matches.is_empty() { let leg = match unwind_hosted(&args.common, &hosted_matches).await { Ok(leg) => { diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 950bf5af9..40b3c5fcb 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1248,16 +1248,14 @@ pub async fn run(args: RollbackArgs) -> i32 { }; for id in &identifiers { let found = ledgers.matching(id); - let mut matched = !found.is_empty(); + // Hosted pins live in the lockfiles, not in a store: matched by the + // identifier, or as another generation of a matched manifest key. + let pins = + socket_patch_core::ledgers::hosted_pins_matching(&hosted_pins, id, &found.manifest); + let matched = !found.is_empty() || !pins.is_empty(); manifest_scope.extend(found.manifest); vendor_scope.extend(found.vendor.into_iter().map(|(k, _)| k)); - // Hosted pins live in the lockfiles, not in a store. - for (purl, uuid) in &redirect_records { - if patch_matches(purl, uuid, id) { - hosted_scope.insert(purl.clone()); - matched = true; - } - } + hosted_scope.extend(pins.into_iter().map(|pin| pin.purl)); if !matched { let hint = if id.starts_with("pkg:") || looks_like_uuid(id) { String::new() diff --git a/crates/socket-patch-cli/tests/remove/remove_invariants.rs b/crates/socket-patch-cli/tests/remove/remove_invariants.rs index 2a92ba270..abde1d328 100644 --- a/crates/socket-patch-cli/tests/remove/remove_invariants.rs +++ b/crates/socket-patch-cli/tests/remove/remove_invariants.rs @@ -547,8 +547,6 @@ fn write_vendored_ledger(root: &Path, ledger_uuid: &str) -> PathBuf { /// Fully offline: no files in the record, vendor-owned purl (so the /// rollback returns before the before-blob gate), empty wiring. #[test] -#[ignore = "RED: pins a ledger-generation matching fix in remove.rs that was not \ - part of this change."] fn remove_by_uuid_reverts_vendoring_when_ledger_generation_is_older() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = write_vendored_manifest(tmp.path(), MANIFEST_UUID); @@ -589,6 +587,38 @@ fn remove_by_uuid_reverts_vendoring_when_ledger_generation_is_older() { ); } +/// The rollback twin of the test above (#999): `rollback ` +/// resolves through the same ledger matching, so the vendored generation +/// one behind the manifest must be reverted too, not left wired while the +/// run reports success. +#[test] +fn rollback_by_uuid_reverts_vendoring_when_ledger_generation_is_older() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_vendored_manifest(tmp.path(), MANIFEST_UUID); + let artifact_dir = write_vendored_ledger(tmp.path(), LEDGER_UUID); + + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &["rollback", MANIFEST_UUID, "--json", "--yes"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert!( + !tmp.path().join(".socket/vendor/state.json").exists(), + "rollback must revert the older vendored generation; envelope={v}" + ); + assert!( + !artifact_dir.exists(), + "the vendored artifact must be deleted; envelope={v}" + ); + assert_eq!( + v["vendoredReverted"], + serde_json::json!([VENDORED_PURL]), + "envelope={v}" + ); +} + /// Control for the test above: removing the SAME fixture by PURL already /// reverted the vendoring, so the by-uuid failure was a matching hole /// rather than a broken fixture (no artifact, unrevertable wiring, ...). diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs index 9bcf56623..65dc752d4 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -27,9 +27,12 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use crate::manifest::schema::{PatchManifest, PatchRecord}; +use crate::patch::redirect::upstream::HostedPin; use crate::patch::redirect::{CorruptRedirectState, RedirectState}; +use crate::utils::composer_version::composer_purls_equivalent; use crate::utils::purl::{normalize_purl, patch_matches, strip_purl_qualifiers}; use crate::vendor::{VendorEntry, VendorState}; +use crate::vex::discover::canonical_base_purl; /// A patch store, in owner-precedence order (a lower store wins a key). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -129,6 +132,39 @@ impl Matches { } } +/// Whether a hosted pin's purl (canonical base, as lockfile discovery spells +/// it) wires the same package release as the store key `key`: the same +/// owned pin, whichever patch generation (uuid) each side recorded. +pub fn same_release(key: &str, pin_purl: &str) -> bool { + let base = canonical_base_purl(key); + base == pin_purl || composer_purls_equivalent(&base, pin_purl) +} + +/// The lockfiles' hosted pins a remove/rollback `identifier` selects, +/// sorted by purl: every pin the identifier names by purl or uuid, plus +/// every pin wiring the same release as one of `manifest_keys` (the +/// manifest entries the identifier matched). The second half is the hosted +/// twin of [`Ledgers::matching`]'s claim groups: `remove ` of a +/// manifest record whose package a later hosted scan re-pinned to another +/// patch generation unwinds that pin too, instead of leaving it live with +/// no record. +pub fn hosted_pins_matching( + pins: &[HostedPin], + identifier: &str, + manifest_keys: &[String], +) -> Vec { + let mut matches: Vec = pins + .iter() + .filter(|pin| { + patch_matches(&pin.purl, &pin.uuid, identifier) + || manifest_keys.iter().any(|key| same_release(key, &pin.purl)) + }) + .cloned() + .collect(); + matches.sort_by(|a, b| a.purl.cmp(&b.purl).then(a.uuid.cmp(&b.uuid))); + matches +} + fn sorted_keys(map: &HashMap) -> Vec<&String> { let mut keys: Vec<&String> = map.keys().collect(); keys.sort(); @@ -296,7 +332,16 @@ impl<'a> Ledgers<'a> { /// Every entry a remove/rollback `identifier` (purl or uuid) matches: /// manifest and hosted records by [`patch_matches`] on their key, /// vendored entries by [`VendorEntry::matches_identifier`] (key or base - /// purl). + /// purl) or by belonging to a matched manifest key's claim group (rule 1 + /// of the module docs). + /// + /// The claim group is what makes a uuid identifier generation-blind: a + /// manifest key and the vendored entry it claims are ONE owned pin, + /// whichever patch generation each store recorded. When `get`/`scan` + /// recorded a superseding patch B in the manifest while the ledger still + /// holds the vendored generation A, `remove ` must revert A's + /// vendoring too (#999), or the lockfile keeps consuming A's artifact + /// with no record left. pub fn matching(&self, identifier: &str) -> Matches { let mut manifest: Vec = self .manifest @@ -306,11 +351,17 @@ impl<'a> Ledgers<'a> { .map(|(key, _)| key.clone()) .collect(); manifest.sort(); + let claimed_by_match = |key: &str, entry: &VendorEntry| { + self.claimant(key, entry) + .is_some_and(|owner| manifest.binary_search_by(|m| m.as_str().cmp(owner)).is_ok()) + }; let mut vendor: Vec<(String, VendorEntry)> = self .vendor .into_iter() .flat_map(|s| s.entries.iter()) - .filter(|(key, entry)| entry.matches_identifier(key, identifier)) + .filter(|(key, entry)| { + entry.matches_identifier(key, identifier) || claimed_by_match(key, entry) + }) .map(|(k, e)| (k.clone(), e.clone())) .collect(); vendor.sort_by(|a, b| a.0.cmp(&b.0)); @@ -576,4 +627,67 @@ mod tests { assert!(l.matching("nope").is_empty()); assert_eq!(l.matching("hb").hosted, vec!["pkg:npm/b@1"]); } + /// #999: a uuid identifier that matches the manifest's generation also + /// selects the vendored entry the matched key claims, even when the + /// ledger recorded an older generation; an unclaimed entry for another + /// package does not ride along. + #[test] + fn matching_by_uuid_spans_the_claimed_vendored_generation() { + let m = manifest(&[("pkg:npm/a@1", "new")]); + let v = vendor(vec![ + ("pkg:npm/a@1", entry("old", "pkg:npm/a@1", false, None)), + ("pkg:npm/b@1", entry("other", "pkg:npm/b@1", false, None)), + ]); + let l = Ledgers { + manifest: Some(&m), + vendor: Some(&v), + redirect: None, + }; + let found = l.matching("new"); + assert_eq!(found.manifest, vec!["pkg:npm/a@1"]); + let keys: Vec<&str> = found.vendor.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["pkg:npm/a@1"]); + // Without the manifest, the old generation only matches its own uuid. + let ledger_only = Ledgers { + vendor: Some(&v), + ..Default::default() + }; + assert!(ledger_only.matching("new").vendor.is_empty()); + assert_eq!(ledger_only.matching("old").vendor.len(), 1); + } + + fn pin(purl: &str, uuid: &str) -> HostedPin { + HostedPin { + purl: purl.into(), + uuid: uuid.into(), + files: vec![], + } + } + + #[test] + fn hosted_pins_matching_spans_generations_of_matched_keys() { + let pins = vec![ + pin("pkg:npm/a@1", "hosted-b"), + pin("pkg:npm/c@1", "hosted-c"), + pin("pkg:pypi/six@1.16.0", "hosted-six"), + ]; + // By identifier alone. + let by_uuid = hosted_pins_matching(&pins, "hosted-c", &[]); + assert_eq!(by_uuid, vec![pin("pkg:npm/c@1", "hosted-c")]); + // A matched manifest key (another generation, qualifiers and all) + // selects the pin wiring its release. + let keys = vec![ + "pkg:npm/a@1".to_string(), + "pkg:pypi/six@1.16.0?artifact_id=x".to_string(), + ]; + let got = hosted_pins_matching(&pins, "agent-a", &keys); + assert_eq!( + got, + vec![ + pin("pkg:npm/a@1", "hosted-b"), + pin("pkg:pypi/six@1.16.0", "hosted-six") + ] + ); + assert!(hosted_pins_matching(&pins, "nope", &[]).is_empty()); + } } From d548f08f1b2bf5b2d0134594229255f424ef4175 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:56:18 -0400 Subject: [PATCH 02/11] Drop a superseded hosted generation's wiring on re-pin and restore A hosted re-pin to a superseding patch moved the pin but left the old generation's wiring behind: - cargo kept [registries.socket-patch-] in the cargo config, and remove never swept it (#864); - Go kept the old gopatch module's go.sum pair, so `go mod tidy -diff` failed after every patch update (#682); - Maven treated an earlier `-socket.` literal as a user mismatch and never re-pinned, and a rotated grant token left the stale repository URL in place (#266). Add patch::redirect::generation, the shared owned-pin generation policy: one live socket-owned pin per release, a re-pin replaces the previous generation and drops the wiring only it used, and a restore sweeps every unreferenced generation. The cargo, Go and Maven rewriters and the cargo restore use it; cargo, Maven and NuGet pin names now come from its single hosted_pin_name, and the upstream Maven restore reuses the rewriter's repository lookup/removal instead of its own copy. Adds an owned_pin_generation_matrix test module (cargo, Go new-uuid and republish, Maven supersede and token rotation) asserting that no file names the superseded generation and a re-run is a no-op. The Go equivalence golden is re-blessed: its re-run cases feed the rewriter's own output back in, which now carries no superseded sum lines. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/generation.rs | 106 ++++ .../redirect/golang_equivalence_tests.rs | 1 + .../src/patch/redirect/mod.rs | 561 +++++++++++++++++- .../src/patch/redirect/upstream/cargo.rs | 43 +- .../src/patch/redirect/upstream/maven.rs | 35 +- .../src/patch/redirect/upstream/mod.rs | 2 +- .../src/patch/redirect/upstream/nuget.rs | 2 +- .../tests/equivalence/golang_rewrite.golden | 142 ++--- 8 files changed, 757 insertions(+), 135 deletions(-) create mode 100644 crates/socket-patch-core/src/patch/redirect/generation.rs diff --git a/crates/socket-patch-core/src/patch/redirect/generation.rs b/crates/socket-patch-core/src/patch/redirect/generation.rs new file mode 100644 index 000000000..0d9954627 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/generation.rs @@ -0,0 +1,106 @@ +//! Owned-pin generations: the one supersede policy every hosted writer and +//! restore shares (audit B07). +//! +//! A package release (ecosystem, name, version) has at most ONE live +//! socket-owned pin. The patch uuid it carries is that pin's generation: a +//! superseding patch, or the same patch republished, is a new generation of +//! the same pin, never a second pin beside it. So: +//! +//! - a re-pin replaces the previous generation in place, and drops every +//! piece of wiring only that generation used (a cargo `[registries.…]` +//! block, a Go module's go.sum pair, a Maven ``), the way a +//! fresh pin would never have written it; +//! - a restore (`remove` / `rollback`) unwinds every generation still wired, +//! including residue an older CLI left behind, not only the generation the +//! lockfile names today; +//! - matching a remove/rollback identifier across the stores treats a +//! manifest record, the vendored entry it claims and the hosted pin of the +//! same release as one owned pin ([`crate::ledgers::Ledgers::matching`], +//! [`crate::ledgers::hosted_pins_matching`]). +//! +//! Hosted writers name a generation's wiring `socket-patch-` (a cargo +//! registry, a Maven repository id, a NuGet source key); vendored mode's +//! `socket-patch-vendor-` names are a different owner and never match. + +use std::collections::BTreeSet; + +use crate::patch::path_safety::is_canonical_uuid; + +const PIN_NAME_PREFIX: &str = "socket-patch-"; + +/// The name hosted mode gives a generation's wiring: `socket-patch-`. +pub(crate) fn hosted_pin_name(uuid: &str) -> String { + format!("{PIN_NAME_PREFIX}{uuid}") +} + +/// Every generation (patch uuid) `text` names as `socket-patch-`, in +/// any position (a `registry = "…"` value, an ``, a section header). A +/// comment that names one counts too, which only ever keeps wiring alive. +pub(crate) fn named_generations(text: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut rest = text; + while let Some(at) = rest.find(PIN_NAME_PREFIX) { + let tail = &rest[at + PIN_NAME_PREFIX.len()..]; + if let Some(uuid) = tail.get(..36).filter(|u| is_canonical_uuid(u)) { + // A longer token (`…-x`) is not this grammar. + let boundary = tail[36..] + .chars() + .next() + .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')); + if boundary { + out.insert(uuid.to_string()); + } + } + rest = tail; + } + out +} + +/// The generations the `before` texts name that the `after` texts no longer +/// do: this run moved their pins to another generation, so the wiring only +/// they used is the re-pin's to drop. +pub(crate) fn superseded_generations<'a>( + before: impl IntoIterator, + after: impl IntoIterator, +) -> BTreeSet { + let after: BTreeSet = after.into_iter().flat_map(named_generations).collect(); + before + .into_iter() + .flat_map(named_generations) + .filter(|uuid| !after.contains(uuid)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aaaaaaaa-0000-4000-8000-000000000001"; + const B: &str = "bbbbbbbb-0000-4000-8000-000000000002"; + + #[test] + fn names_only_canonical_hosted_generations() { + let text = format!( + "x = {{ registry = \"socket-patch-{A}\" }}\n\ + [registries.socket-patch-vendor-{B}]\n\ + socket-patch-{B}x\n\ + socket-patch-not-a-uuid\n" + ); + assert_eq!(named_generations(&text), BTreeSet::from([A.to_string()])); + assert_eq!(hosted_pin_name(A), format!("socket-patch-{A}")); + } + + #[test] + fn superseded_is_named_before_and_not_after() { + let before = format!("registry = \"socket-patch-{A}\""); + let after = format!("registry = \"socket-patch-{B}\""); + assert_eq!( + superseded_generations([before.as_str()], [after.as_str()]), + BTreeSet::from([A.to_string()]) + ); + // Still named by any after-text: not superseded. + assert!( + superseded_generations([before.as_str()], [after.as_str(), before.as_str()]).is_empty() + ); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs index 3a8ebf5c2..7593e747b 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs @@ -282,6 +282,7 @@ fn single_walk_golang_rewrite_matches_golden() { "redirect_golang_gosum_prune", "redirect_golang_stale_replace_removed", "redirect_golang_stale_gosum_removed", + "redirect_golang_superseded_gosum_removed", ] { assert!(kinds.contains(kind), "no case reached {kind}: {kinds:?}"); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index a2b825411..ad132b9a8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -36,6 +36,7 @@ pub use bun_binary::{preflight_bun_binary, rewrite_bun_binary}; mod cargo_lock_equivalence_tests; #[cfg(test)] mod composer_equivalence_tests; +pub(crate) mod generation; #[cfg(test)] mod golang_equivalence_tests; pub mod golang_local; @@ -1513,6 +1514,9 @@ fn rewrite_cargo( .map(|t| to_lf(cargo_config_key, t)) .unwrap_or_default(); let (mut lock_changed, mut config_changed) = (false, false); + // The manifests as they arrived: a generation they pin that the final + // manifests no longer do was superseded by this run (see below). + let manifests_before: Vec = manifests.iter().map(|(_, t)| t.clone()).collect(); for dep in &cargo { let Some(ov) = registry_override_of_kind(dep, "cargo-sparse") else { @@ -1575,7 +1579,7 @@ fn rewrite_cargo( }); continue; } - let reg = format!("socket-patch-{}", dep.patch_uuid); + let reg = generation::hosted_pin_name(&dep.patch_uuid); let index_url = &ov.index_url; // 1. Plan the Cargo.toml pin FIRST — it is the gate for everything @@ -1795,6 +1799,46 @@ fn rewrite_cargo( result.confirmed_cargo_uuids.insert(dep.patch_uuid.clone()); } + // A re-pin to a superseding patch moved every declaration off the old + // generation's registry: its `[registries.socket-patch-]` block is + // now referenced by nothing, so it goes with the re-pin (#864) — left in + // place it outlives even `remove`, token-bearing index URL and all. + let superseded = generation::superseded_generations( + manifests_before.iter().map(String::as_str), + manifests.iter().map(|(_, t)| t.as_str()), + ); + for uuid in superseded { + if cargo_lock + .as_deref() + .is_some_and(|lock| lock.contains(&uuid)) + { + continue; + } + let reg = generation::hosted_pin_name(&uuid); + let Some(removed) = upstream::cargo::remove_registry_block(&cargo_config, ®) else { + continue; + }; + // The block opened the file: don't leave a blank first line behind. + let opened_file = cargo_config + .lines() + .next() + .is_some_and(|l| l.trim() == format!("[registries.{reg}]")); + let removed = match removed.strip_prefix('\n') { + Some(rest) if opened_file => rest.to_string(), + _ => removed, + }; + result.edits.push(FileEdit { + path: cargo_config_key.into(), + kind: "redirect_cargo_superseded_registry_removed".into(), + action: "removed".into(), + key: Some(reg), + original: None, + new: None, + }); + cargo_config = removed; + config_changed = true; + } + let restore = |path: &str, text: String| -> String { if crlf_paths.contains(path) { text.replace('\n', "\r\n") @@ -5605,7 +5649,7 @@ fn rewrite_nuget( .strip_prefix("sha512-") .unwrap_or(&sha512_sri) .to_string(); - let reg = format!("socket-patch-{}", dep.patch_uuid); + let reg = generation::hosted_pin_name(&dep.patch_uuid); let id_lower = ov .identifiers .nuget_id_lower @@ -6830,6 +6874,9 @@ fn rewrite_maven_pom( let mut mvn_config_changed = false; // (local-repo-relative path, bare sha256 hex) entries to merge in. let mut checksum_entries: Vec<(String, String)> = vec![]; + // Trusted-checksum paths of superseded generations' suffixed artifacts: + // their pins moved to the new generation this run, so the entries go. + let mut checksum_drops: Vec = vec![]; let gradle_build_present = gradle::gradle_build_present(files); let mut warned_no_pom = false; // Local-repo paths of the suffixed jars this run's Trusted Checksums pin @@ -6875,7 +6922,7 @@ fn rewrite_maven_pom( continue; }; // Unique-per-patch repository id (valid chars: alnum, `-`, `_`, `.`). - let repo_id = format!("socket-patch-{}", dep.patch_uuid); + let repo_id = generation::hosted_pin_name(&dep.patch_uuid); // LEGACY same-GAV fallback: no suffixed version means the patched jar is // served under its original GAV. Add the repository (transport checksum @@ -6998,33 +7045,45 @@ fn rewrite_maven_pom( .map(|((s, e), v)| (s, e, v)) }) .collect(); - // Rewrite base → suffixed. Descending offset order so earlier edits - // don't shift later matches' offsets. - let mut to_rewrite: Vec<(usize, usize)> = versioned + // A `-socket.` literal for this release that is not the + // applied suffix is an earlier generation of OUR pin (a superseding + // patch uuid): re-pin it like the base (#266). + let prior_generation = |v: &str| { + v != suffixed_version + && crate::formats::maven::split_socket_version(v) + .is_some_and(|(base, _)| base == dep.version) + }; + // Rewrite base (or a prior generation) → suffixed. Descending offset + // order so earlier edits don't shift later matches' offsets. + let mut to_rewrite: Vec<(usize, usize, String)> = versioned .iter() - .filter(|(_, _, v)| *v == dep.version) - .map(|(s, e, _)| (*s, *e)) + .filter(|(_, _, v)| *v == dep.version || prior_generation(v)) + .map(|(s, e, v)| (*s, *e, v.clone())) .collect(); to_rewrite.sort_by(|a, b| b.0.cmp(&a.0)); - for (start, end) in &to_rewrite { + let mut superseded_versions: Vec = vec![]; + for (start, end, was) in &to_rewrite { pom_text.replace_range(*start..*end, &suffixed_version); pom_changed = true; pin_landed = true; + if *was != dep.version && !superseded_versions.contains(was) { + superseded_versions.push(was.clone()); + } result.edits.push(FileEdit { path: "pom.xml".into(), kind: "redirect_maven_dep_version".into(), action: "rewritten".into(), key: Some(format!("{group_id}:{artifact_id}")), - original: Some(Value::String(dep.version.clone())), + original: Some(Value::String(was.clone())), new: Some(Value::String(suffixed_version.clone())), }); } - // A literal version that is neither base nor the applied suffixed - // version disagrees with the row — skip it (don't guess). A dep whose - // only match is a mismatch adds no pin (versioned is non-empty, so the - // depMgmt branch below is skipped). + // A literal version that is neither base, the applied suffixed + // version nor an earlier generation disagrees with the row — skip it + // (don't guess). A dep whose only match is a mismatch adds no pin + // (versioned is non-empty, so the depMgmt branch below is skipped). for (_, _, v) in &versioned { - if *v != dep.version && *v != suffixed_version { + if *v != dep.version && *v != suffixed_version && !prior_generation(v) { result.warnings.push(RewriteWarning { code: "redirect_maven_dep_version_mismatch".into(), detail: format!( @@ -7076,6 +7135,57 @@ fn rewrite_maven_pom( )); } + // The superseded generations' wiring: once no literal names an old + // suffix any more, its `socket-patch-` repository and trusted + // checksums are referenced by nothing. + for old in &superseded_versions { + if pom_text.contains(old.as_str()) { + continue; + } + let hex8 = crate::formats::maven::split_socket_version(old) + .map(|(_, h)| h) + .unwrap_or_default(); + for old_uuid in generation::named_generations(pom_text) { + if old_uuid == dep.patch_uuid || !old_uuid.starts_with(hex8) { + continue; + } + let old_id = generation::hosted_pin_name(&old_uuid); + if let Some(next) = remove_maven_repository(pom_text, &old_id) { + *pom_text = next; + pom_changed = true; + result.edits.push(FileEdit { + path: "pom.xml".into(), + kind: "redirect_maven_superseded_repository_removed".into(), + action: "removed".into(), + key: Some(old_id), + original: None, + new: None, + }); + } + } + for ext in ["jar", "pom"] { + checksum_drops.push(local_repo_artifact_path(&group_id, &artifact_id, old, ext)); + } + } + + // A rotated grant token keeps the uuid but changes the repository + // URL: refresh the existing `socket-patch-` repository in place + // so the pin keeps resolving through the current grant. + if pin_landed || versioned.iter().any(|(_, _, v)| *v == suffixed_version) { + if let Some(next) = refresh_maven_repository_url(pom_text, &repo_id, &ov.index_url) { + *pom_text = next; + pom_changed = true; + result.edits.push(FileEdit { + path: "pom.xml".into(), + kind: "redirect_maven_repository".into(), + action: "rewritten".into(), + key: Some(repo_id.clone()), + original: None, + new: Some(json!({ "id": repo_id, "url": ov.index_url })), + }); + } + } + // A pin landed this run: inject the repository (idempotent via the // guard) and emit trusted checksums. When the pin was already present // from a prior run, `pin_landed` stays false and both are skipped, @@ -7144,16 +7254,29 @@ fn rewrite_maven_pom( if mvn_config_changed { result.files.insert(MVN_CONFIG.into(), mvn_config); } - if !checksum_entries.is_empty() { - let existing = files.get(MVN_CHECKSUMS).cloned().unwrap_or_default(); + let existing_checksums = files.get(MVN_CHECKSUMS).cloned().unwrap_or_default(); + let drops_checksums = checksum_drops.iter().any(|path| { + existing_checksums + .lines() + .any(|l| l.split_once(" ").is_some_and(|(_, p)| p == path)) + }); + if !checksum_entries.is_empty() || drops_checksums { let action = if files.contains_key(MVN_CHECKSUMS) { "rewritten" } else { "added" }; + let kept: String = existing_checksums + .split('\n') + .filter(|l| { + !l.split_once(" ") + .is_some_and(|(_, p)| checksum_drops.iter().any(|d| d == p)) + }) + .collect::>() + .join("\n"); result.files.insert( MVN_CHECKSUMS.into(), - merge_checksums(&existing, &checksum_entries), + merge_checksums(&kept, &checksum_entries), ); result.edits.push(FileEdit { path: MVN_CHECKSUMS.into(), @@ -7196,6 +7319,56 @@ fn rewrite_maven_pom( } } +/// The `` elements of `pom` whose `` is `id`, as byte spans. +pub(crate) fn maven_repositories_with_id(pom: &str, id: &str) -> Vec<(usize, usize)> { + static REPOSITORY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s).*?").expect("static repository regex is valid") + }); + REPOSITORY_RE + .find_iter(pom) + .filter(|m| maven_tag_text_in(pom, "id", m.start(), m.end()).as_deref() == Some(id)) + .map(|m| (m.start(), m.end())) + .collect() +} + +/// `pom` without the one `` whose `` is `id` and the line +/// break [`insert_maven_repository`] put before it; `None` unless exactly +/// one such element sits on lines of its own (anything else is not the +/// rewriter's insertion, and stays). +pub(crate) fn remove_maven_repository(pom: &str, id: &str) -> Option { + let [(start, end)] = maven_repositories_with_id(pom, id)[..] else { + return None; + }; + let line_start = pom[..start].rfind('\n')?; + let own_lines = pom[line_start + 1..start].trim().is_empty() + && (pom[end..].starts_with('\n') || pom[end..].starts_with("\r\n")); + if !own_lines { + return None; + } + let cut = if pom[..line_start].ends_with('\r') { + line_start - 1 + } else { + line_start + }; + Some(format!("{}{}", &pom[..cut], &pom[end..])) +} + +/// `pom` with the `` of its one `` whose `` is `id` set +/// to `url`; `None` when there is no such single element or it already +/// points there. +fn refresh_maven_repository_url(pom: &str, id: &str, url: &str) -> Option { + let [(start, end)] = maven_repositories_with_id(pom, id)[..] else { + return None; + }; + let (s, e) = maven_tag_inner_range(pom, "url", start, end)?; + if pom[s..e].trim() == url { + return None; + } + let mut out = pom.to_string(); + out.replace_range(s..e, url); + Some(out) +} + /// Insert the socket-patch `` block: releases enabled with /// `fail` (the transport-level check against /// the served `.jar.sha1`); snapshots disabled (patched artifacts are always @@ -7601,6 +7774,36 @@ fn rewrite_golang( }); } } + // A refresh that moved the pin to another generation (a superseding + // patch uuid, or the same uuid republished at a new version) owns the + // previous target's go.sum pair too: nothing references it any more, + // so `go mod tidy` would prune it, and leaving it makes the first + // day-2 tidy rewrite go.sum (#682). + if let Some((old_module, old_version)) = prior + .filter(|e| e.owner == Some(go_mod_edit::ReplaceOwner::Hosted)) + .and_then(|e| e.rhs_module.as_deref().zip(e.rhs_version.as_deref())) + .filter(|&(m, v)| (m, v) != (rhs_module.as_str(), rhs_version.as_str())) + { + let still_targeted = go_mod_edit::parse_replace_entries(&go_mod).iter().any(|e| { + e.rhs_module.as_deref() == Some(old_module) + && e.rhs_version.as_deref() == Some(old_version) + }); + if !still_targeted { + if let Some(removed) = + go_sum.remove_exact_module_version_lines(old_module, old_version) + { + sum_changed = true; + result.edits.push(FileEdit { + path: "go.sum".into(), + kind: "redirect_golang_superseded_gosum_removed".into(), + action: "removed".into(), + key: Some(format!("{old_module}@{old_version}")), + original: Some(Value::String(removed.join("\n"))), + new: None, + }); + } + } + } if go_sum.upsert_module_lines(rhs_module, rhs_version, zip_h1, gomod_h1) { sum_changed = true; result.edits.push(FileEdit { @@ -12136,6 +12339,22 @@ mod tests { cfg.contains(&format!("[registries.{}]", cargo_reg())), "{cfg}" ); + // #864: the superseded generation's block is referenced by nothing + // any more and goes with the re-pin (no blank first line left). + assert!(!cfg.contains(&old_reg), "{cfg}"); + assert_eq!( + cfg, + &format!( + "[registries.{}]\nindex = \"{}\"\n", + cargo_reg(), + cargo_index_url() + ) + ); + assert!(r + .edits + .iter() + .any(|e| e.kind == "redirect_cargo_superseded_registry_removed" + && e.key.as_deref() == Some(old_reg.as_str()))); assert!( !r.warnings .iter() @@ -22107,3 +22326,309 @@ mod superseding_repin_tests { assert!(again.files.is_empty() && again.warnings.is_empty()); } } + +/// B07 owned-pin generations: one supersede matrix across the hosted +/// writers whose re-pin left the previous generation's wiring behind +/// (#864 cargo, #682 Go, #266 Maven). Each case pins generation A through +/// the full redirect planner, re-pins to generation B, and asserts the +/// shared invariant: no file names A any more, B is confirmed, and a +/// re-run over the result is a no-op. +#[cfg(test)] +mod owned_pin_generation_matrix { + use super::*; + + const A: &str = "aaaaaaaa-0000-4000-8000-00000000000a"; + const B: &str = "bbbbbbbb-0000-4000-8000-00000000000b"; + + fn plan( + files: &BTreeMap, + dep: &DepOverride, + ) -> (BTreeMap, RewriteResult) { + let result = rewrite_registry_redirect(files, std::slice::from_ref(dep)); + let mut out = files.clone(); + out.extend(result.files.clone()); + (out, result) + } + + /// Pin `first`, re-pin `second`, and check the generation invariant. + /// `gone` is the text only the first generation's wiring carried. + fn supersede( + files: BTreeMap, + first: &DepOverride, + second: &DepOverride, + gone: &str, + ) -> (BTreeMap, RewriteResult) { + let (wired, result) = plan(&files, first); + assert!( + result.warnings.is_empty(), + "first pin: {:?}", + result.warnings + ); + assert!( + wired.values().any(|t| t.contains(gone)), + "fixture: the first generation must be wired" + ); + let (repinned, result) = plan(&wired, second); + assert!(result.warnings.is_empty(), "re-pin: {:?}", result.warnings); + for (path, text) in &repinned { + assert!( + !text.contains(gone), + "{path} kept the superseded generation `{gone}`:\n{text}" + ); + } + let (_, again) = plan(&repinned, second); + assert!( + again.files.is_empty() && again.warnings.is_empty(), + "the re-pinned project must be settled: {:?} {:?}", + again.files.keys(), + again.warnings + ); + (repinned, result) + } + + fn cargo(uuid: &str) -> DepOverride { + DepOverride { + ecosystem: "cargo".into(), + name: "cfg-if".into(), + namespace: None, + version: "1.0.4".into(), + token: "tok".into(), + patch_uuid: uuid.into(), + artifact_url: format!("https://patch.test/{uuid}/cfg-if-1.0.4.crate"), + registry_override: Some(RegistryOverride { + kind: "cargo-sparse".into(), + index_url: format!("sparse+https://patch.test/cargo/tok-{uuid}/{uuid}/index/"), + identifiers: RegistryOverrideIdentifiers { + name: "cfg-if".into(), + version: "1.0.4".into(), + cargo_cksum_sha256: Some(uuid[..1].repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity::default(), + } + } + + fn cargo_project(config: Option<&str>) -> BTreeMap { + let mut files = BTreeMap::from([ + ( + "Cargo.toml".to_string(), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ + [dependencies]\ncfg-if = \"1.0.4\"\n" + .to_string(), + ), + ( + "Cargo.lock".to_string(), + "version = 3\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\ + dependencies = [\n \"cfg-if\",\n]\n\n[[package]]\nname = \"cfg-if\"\n\ + version = \"1.0.4\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n\ + checksum = \"0000000000000000000000000000000000000000000000000000000000000000\"\n" + .to_string(), + ), + ]); + if let Some((key, text)) = config.map(|c| (".cargo/config", c)) { + files.insert(key.to_string(), text.to_string()); + } + files + } + + /// #864: the re-pin drops the old `[registries.socket-patch-A]` block, + /// with no prior config (the file holds only B's block afterwards) and + /// with a legacy `.cargo/config` the user owns (its bytes survive). + #[test] + fn cargo_repin_drops_the_superseded_registry_block() { + let (repinned, result) = supersede(cargo_project(None), &cargo(A), &cargo(B), A); + assert!(result.confirmed_cargo_uuids.contains(B)); + assert_eq!( + repinned[".cargo/config.toml"], + format!( + "[registries.socket-patch-{B}]\nindex = \"sparse+https://patch.test/cargo/tok-{B}/{B}/index/\"\n" + ) + ); + + let user = "[net]\nretry = 2\n"; + let (repinned, _) = supersede(cargo_project(Some(user)), &cargo(A), &cargo(B), A); + let config = &repinned[".cargo/config"]; + assert!(config.starts_with(user), "{config}"); + assert!(config.contains(&format!("[registries.socket-patch-{B}]"))); + assert!(!repinned.contains_key(".cargo/config.toml")); + } + + fn h1(c: char) -> String { + format!("h1:{}=", c.to_string().repeat(43)) + } + + fn golang(uuid: &str, version: &str, c: char) -> DepOverride { + let module = format!("patch.socket.dev/gopatch/{uuid}"); + DepOverride { + ecosystem: "golang".into(), + name: "example.com/up".into(), + namespace: None, + version: "v1.0.0".into(), + token: String::new(), + patch_uuid: uuid.into(), + artifact_url: format!( + "https://patch.socket.dev/patch-registry/golang/{module}/@v/{version}.zip" + ), + registry_override: Some(RegistryOverride { + kind: "goproxy".into(), + index_url: "https://patch.socket.dev/patch-registry/golang".into(), + identifiers: RegistryOverrideIdentifiers { + name: "example.com/up".into(), + version: "v1.0.0".into(), + go_module_path: Some(module), + go_module_version: Some(version.into()), + ..Default::default() + }, + }), + integrity: Integrity { + dirhash_h1: Some(h1(c)), + go_mod_h1: Some(h1('G')), + ..Default::default() + }, + } + } + + fn go_project() -> BTreeMap { + BTreeMap::from([ + ( + "go.mod".to_string(), + "module example.com/app\n\ngo 1.21\n\nrequire example.com/up v1.0.0\n".to_string(), + ), + ( + "go.sum".to_string(), + format!( + "example.com/up v1.0.0 {}\nexample.com/up v1.0.0/go.mod {}\n", + h1('U'), + h1('M') + ), + ), + ]) + } + + /// #682: a re-pin to a superseding uuid, and the same uuid republished + /// at a new version, both drop the previous target's go.sum pair, so + /// go.sum is left exactly as `go mod tidy` would leave it. + #[test] + fn golang_repin_drops_the_superseded_gosum_pair() { + let first = golang(A, "v1.0.0-socketpatch.1", 'A'); + let (repinned, result) = supersede( + go_project(), + &first, + &golang(B, "v1.0.0-socketpatch.2", 'B'), + A, + ); + assert!(result.confirmed_golang_uuids.contains(B)); + let module_b = format!("patch.socket.dev/gopatch/{B}"); + assert_eq!( + repinned["go.sum"], + format!( + "{module_b} v1.0.0-socketpatch.2 {}\n{module_b} v1.0.0-socketpatch.2/go.mod {}\n", + h1('B'), + h1('G') + ) + ); + assert!(result + .edits + .iter() + .any(|e| e.kind == "redirect_golang_superseded_gosum_removed")); + + // Same uuid, republished: the `.1` lines go, the `.2` lines stay. + let (repinned, _) = supersede( + go_project(), + &first, + &golang(A, "v1.0.0-socketpatch.2", 'B'), + "socketpatch.1", + ); + assert_eq!( + repinned["go.sum"].lines().count(), + 2, + "{}", + repinned["go.sum"] + ); + } + + fn maven(uuid: &str, token: &str) -> DepOverride { + DepOverride { + ecosystem: "maven".into(), + name: "commons-lang3".into(), + namespace: Some("org.apache.commons".into()), + version: "3.12.0".into(), + token: token.into(), + patch_uuid: uuid.into(), + artifact_url: format!( + "https://patch.socket.dev/patch/maven/org.apache.commons/commons-lang3/3.12.0/{token}/{uuid}/commons-lang3-3.12.0.jar" + ), + registry_override: Some(RegistryOverride { + kind: "maven2".into(), + index_url: format!("https://patch.socket.dev/patch-registry/maven/{token}/{uuid}/maven2"), + identifiers: RegistryOverrideIdentifiers { + name: "org.apache.commons/commons-lang3".into(), + version: "3.12.0".into(), + maven_group_id: Some("org.apache.commons".into()), + maven_artifact_id: Some("commons-lang3".into()), + maven_suffixed_version: Some(format!("3.12.0-socket.{}", &uuid[..8])), + maven_pom_sha256: Some(uuid[..1].repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity { + sha256: Some(uuid[..1].repeat(64)), + ..Default::default() + }, + } + } + + fn pom_project() -> BTreeMap { + BTreeMap::from([( + "pom.xml".to_string(), + "\n\n 4.0.0\n \ + dev.socket.test\n consumer\n 1.0.0\n \ + \n \n org.apache.commons\n \ + commons-lang3\n 3.12.0\n \n \ + \n\n" + .to_string(), + )]) + } + + /// #266: a superseding uuid re-pins the `-socket.` literal to B's + /// suffix and replaces A's repository and trusted checksums. + #[test] + fn maven_repin_moves_the_suffix_and_drops_the_superseded_repository() { + let tok = "22222222-3333-4444-8555-666666666666"; + let (repinned, result) = supersede(pom_project(), &maven(A, tok), &maven(B, tok), A); + let pom = &repinned["pom.xml"]; + assert!( + pom.contains("3.12.0-socket.bbbbbbbb"), + "{pom}" + ); + assert!(!pom.contains("socket.aaaaaaaa"), "{pom}"); + assert_eq!(pom.matches("").count(), 1, "{pom}"); + assert!(!result + .warnings + .iter() + .any(|w| w.code == "redirect_maven_dep_version_mismatch")); + let checksums = &repinned[MVN_CHECKSUMS]; + assert!(!checksums.contains("aaaaaaaa"), "{checksums}"); + assert_eq!(checksums.lines().count(), 2, "{checksums}"); + } + + /// #266: a rotated grant token (same uuid, new token path segment) + /// refreshes the repository URL in place. + #[test] + fn maven_rotated_grant_token_refreshes_the_repository_url() { + let old = "22222222-3333-4444-8555-666666666666"; + let new = "77777777-3333-4444-8555-666666666666"; + let (wired, _) = plan(&pom_project(), &maven(A, old)); + let (rotated, result) = plan(&wired, &maven(A, new)); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + let pom = &rotated["pom.xml"]; + assert!(!pom.contains(old), "{pom}"); + assert!( + pom.contains(&format!("/maven/{new}/{A}/maven2")), + "{pom}" + ); + let (_, again) = plan(&rotated, &maven(A, new)); + assert!(again.files.is_empty(), "{:?}", again.files.keys()); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index c44d7919e..ee6356c97 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -25,9 +25,7 @@ const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io- /// The project cargo configs, in cargo's read preference. const CARGO_CONFIGS: [&str; 2] = [".cargo/config", ".cargo/config.toml"]; -fn registry_name(uuid: &str) -> String { - format!("socket-patch-{uuid}") -} +use crate::patch::redirect::generation::{self, hosted_pin_name as registry_name}; struct LockHit { uuid: String, @@ -206,29 +204,41 @@ pub(crate) async fn restore( } // ── cargo config registry blocks ── + // Every hosted generation a config defines is a candidate, not only the + // pins found in the lock: a superseded generation's block an older CLI + // left behind on re-pin is referenced by nothing and would otherwise + // outlive the restore (#864). A block any manifest or the lock still + // names stays. + let mut configs: Vec<(&str, String)> = Vec::new(); + for rel in CARGO_CONFIGS { + if let Ok(Some(config)) = view.read(rel).await { + configs.push((rel, config)); + } + } let mut referenced: BTreeSet = BTreeSet::new(); for rel in manifests.iter().map(String::as_str).chain(["Cargo.lock"]) { if let Ok(Some(text)) = view.read(rel).await { - for pin in pins { - let reg = registry_name(&pin.uuid); - if text.contains(®) || lock_names_index(&text, &pin.uuid) { - referenced.insert(pin.uuid.clone()); + referenced.extend(generation::named_generations(&text)); + for (_, config) in &configs { + for uuid in generation::named_generations(config) { + if lock_names_index(&text, &uuid) { + referenced.insert(uuid); + } } } } } - for rel in CARGO_CONFIGS { - let Ok(Some(config)) = view.read(rel).await else { - continue; - }; + for (rel, config) in configs { let mut next = config.clone(); - for pin in pins { - if result.refused.contains_key(&pin.uuid) || referenced.contains(&pin.uuid) { + for uuid in generation::named_generations(&config) { + if result.refused.contains_key(&uuid) || referenced.contains(&uuid) { continue; } - if let Some(removed) = remove_registry_block(&next, ®istry_name(&pin.uuid)) { + if let Some(removed) = remove_registry_block(&next, ®istry_name(&uuid)) { next = removed; - result.handled.insert(pin.uuid.clone()); + if by_uuid.contains_key(uuid.as_str()) { + result.handled.insert(uuid); + } } } if next != config { @@ -239,7 +249,6 @@ pub(crate) async fn restore( } } } - let _ = by_uuid; result } @@ -300,7 +309,7 @@ fn unpin_line(line: &str, reg: &str) -> Option> { /// The config with its `[registries.]` block (and the blank separator /// the rewriter put before it) removed; `None` when absent. -fn remove_registry_block(config: &str, reg: &str) -> Option { +pub(crate) fn remove_registry_block(config: &str, reg: &str) -> Option { let crlf = config.contains("\r\n"); let lf = config.replace("\r\n", "\n"); let header = format!("[registries.{reg}]"); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index 7b90cbdee..b12fa673d 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -26,8 +26,8 @@ use regex::Regex; use super::npm::{by_uuid, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::patch::redirect::{ - maven_tag_inner_range, maven_tag_text_in, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, - MVN_CONFIG_ARGS, + generation, maven_repositories_with_id, maven_tag_inner_range, maven_tag_text_in, + remove_maven_repository, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, MVN_CONFIG_ARGS, }; /// The line break and indent `insert_maven_dependency_management` writes @@ -111,16 +111,9 @@ fn dm_sections(pom: &str) -> Vec<(usize, usize)> { /// the line break before it (the rewriter's own insertion), when it sits /// on lines of its own. fn remove_repository(pom: &str, uuid: &str, ctx: &Ctx<'_>) -> Result { - let re = - Regex::new(r"(?s).*?").expect("static repository regex is valid"); - let id = format!("socket-patch-{uuid}"); - let found: Vec<(usize, usize)> = re - .find_iter(pom) - .filter(|m| maven_tag_text_in(pom, "id", m.start(), m.end()).as_deref() == Some(&id)) - .map(|m| (m.start(), m.end())) - .collect(); - let (start, end) = match found.as_slice() { - [one] => *one, + let id = generation::hosted_pin_name(uuid); + let (start, end) = match maven_repositories_with_id(pom, &id)[..] { + [one] => one, [] => return Err(format!("pom.xml has no with id {id}")), _ => { return Err(format!( @@ -134,20 +127,8 @@ fn remove_repository(pom: &str, uuid: &str, ctx: &Ctx<'_>) -> Result Option { /// Remove one pin's source definition and mapping, each with the line /// break before it (the rewriter's own insertion). fn remove_source(config: &str, uuid: &str, id: &str, ctx: &Ctx<'_>) -> Result { - let key = format!("socket-patch-{uuid}"); + let key = crate::patch::redirect::generation::hosted_pin_name(uuid); let quoted = format!(r#"(?:"{k}"|'{k}')"#, k = regex::escape(&key)); let add_re = Regex::new(&format!( r"(?:\r?\n[ \t]*)?]*?key\s*=\s*{quoted}[^>]*?/>" diff --git a/crates/socket-patch-core/tests/equivalence/golang_rewrite.golden b/crates/socket-patch-core/tests/equivalence/golang_rewrite.golden index d1755043b..e007c85f8 100644 --- a/crates/socket-patch-core/tests/equivalence/golang_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/golang_rewrite.golden @@ -8,54 +8,54 @@ 100-119 6b67a79750e24dd3 c39342753206b8de 120-139 fa13c96c073cae08 dd5fc59277757625 140-159 f31cbe6d9e2f48af f7751f4d46e49cfb -160-179 71f6d03c2d4375d6 b85826ee6629769d +160-179 f583a6f780923f23 a2d4bb49b1b0e577 180-199 3c086f161e773a7f d90b6bb988306cf5 200-219 2dbfc36f5c11dad9 f2ca76820e5ee929 220-239 0317760fdd547229 57a51f50cada0d57 240-259 47ca04a94948041d c504bff314072199 260-279 1223bab190d4ad17 bd88df7cc9813075 -280-299 c1f042c26aaccd3c 4d2aefec23d08238 +280-299 b796240976dea352 8691a5e2d1d8c8eb 300-319 4b16f76f77f2864d 9cd531d6442ce036 -320-339 62f6c190d61afbbe 0a42a4908dd35ea2 -340-359 52b489087348cdfb 488145b72e8dd83f +320-339 03cf18892339dd6e ad4f7aa3cfe8904e +340-359 8d92d4e883cca301 645b187b434db848 360-379 fe9855f9bb69c430 190a72101e00926b 380-399 c644cd41879f5d4d b83abf8dd29041d0 400-419 8af5995cf2f76dea cb4c682266cf703c 420-439 9cb2e5ab58f12f52 6ec27ddf7b2f6160 440-459 c7e8bdeca795eb4a b84ebc45ba9646ad 460-479 69b8c8c81644d846 2a05b275244b30a1 -480-499 dc92532b559ba578 3f1068fc323b5e76 +480-499 b17ebc32790282b2 d37c1b933a02e03b 500-519 cddbdc3dd3a98b37 89126477ab4ebf0f 520-539 14ced69da5c9ca03 fa216aa853527650 540-559 8259451bf283159e 7195bb2ad5f771ce -560-579 7ce17480c6d397c0 fdbfb7b4a2a3e3af -580-599 1b9b508f4b2c2a25 9b2980e26bdc2fdb +560-579 46ecdb84ca2d828f f3280c333a062264 +580-599 9c8383546fc3321a f7fdeb78b1f6cf2a 600-619 8b5d18e8c42cd6ea 1b16dd536a428b9c 620-639 e4a8f29f5416d660 746299aae8afa586 -640-659 e6cf9e271701b7bb 7e1d46b3d7ab21bc -660-679 3fa4966313a17c0d 17c1a7a497ea71fa +640-659 428d179c205d7a1c 5d18ef0820bb7698 +660-679 033e653bac2b897f 8d98f02b8b7ae256 680-699 8d63e77bd076294e 6f7c47208f18010e 700-719 513cc219e8a8e920 591d9ae3eb3ae09b 720-739 61b704425a73b56d 2cdefa768429a2e9 -740-759 f7360fc5b441de83 782aecf2092a65a5 +740-759 3e94f4557b9ca391 6c4feec91dcb67b6 760-779 02556046100081a7 0fb6925b98463b01 780-799 6a0983ce6589757e b373be106f8aa16a 800-819 0116b8f40912ff10 af22460213179a03 820-839 7436226b7c79ccfd c7cad0d8d57681f4 -840-859 09bf17f22d9b5e20 f9442f31e1e0acc5 +840-859 22ad5131200df40a 264ef50084e152ec 860-879 3ffec215a7e32e97 6a53242431c223cf 880-899 ac8e35ba6f2607c9 c088350169e4f2be -900-919 0892f5515e4006dc 46a87f861db17c1f +900-919 1deaf15626d0a873 6cc5208e0f281496 920-939 19206f8cab68c222 1b9795ad8797f53f 940-959 b1abc86bef6b3063 95036d70702b674d 960-979 91a059a20d7961f4 44f9de049d21e73d 980-999 6642fbe828ba9cd2 d43c047c8e275f33 -1000-1019 c26c202915b06bba e0a6ec5c97a8592d +1000-1019 72451429b9ee5a6a 236aba70c1e21aae 1020-1039 6170e2fe8d9c912f 88aee1438b50c94e -1040-1059 fd983dc3174b36b8 24684edd7716877f +1040-1059 cd6447eb587ccd54 8e390b25aae8f2e9 1060-1079 1fcb1a9b156d83b4 e1b7424ba9ef6ec9 -1080-1099 38a227b69e3f6ae7 df222dc1d2d54d6b -1100-1119 aadb6db6a158c49c a2bf3a2413253bc6 +1080-1099 38a227b69e3f6ae7 ade9923b97f6afb6 +1100-1119 90a6432b92c39df0 9281e32b09c7bdbe 1120-1139 1d5c7a423f733e7f e88efac8a1d8fe97 1140-1159 92d9937cfda2ee21 bde79afca1477a5c 1160-1179 9e28ff14a9791a74 4a9903bbe7d70032 @@ -69,19 +69,19 @@ 1320-1339 86094c70406465d1 1a46b7ca1c33bec4 1340-1359 c190dc1962aadda7 50314a84cb4c34a7 1360-1379 aba0c23280634c9d 1470099826b24678 -1380-1399 0c86117c7a8f6528 b170fa1fafb09bd8 +1380-1399 54720be2701ce3aa 3fb75bdd4d0105b0 1400-1419 aa4e979509698703 0d5cd8b252c101d9 -1420-1439 ac96380695cc241d 143b770f5efad8d6 +1420-1439 ac96380695cc241d c6de97323446b570 1440-1459 0b118514502af233 e461b52e9bb976ca -1460-1479 71574ed1de3c6fb8 1caf8de27c216844 -1480-1499 2a9f6c089e179107 8e05bc21621b9686 +1460-1479 57dbbd67346b42b7 440496b2b33338b1 +1480-1499 575473f377b94d69 6bcbb9ef301fcd4b 1500-1519 51e0c4b25e9a8741 929207ed9c42ecf6 1520-1539 3f07f8546c8a88b8 acf0aed035f2ced9 -1540-1559 65c47245e00e3248 40565518df5f1041 +1540-1559 4874a2ac9f5077ce e535b07601b6fdc8 1560-1579 262f06c164e1cdd9 0c152f1b70a666fe 1580-1599 ceb59f6639170e68 28e462491ec7c339 1600-1619 eccdec2aeb7ef8a6 7c95e2375ca33fe8 -1620-1639 7ae7ef0cae7beecd f605a841239d7e61 +1620-1639 4741075bff354e72 34ed6e1d6e2034c3 1640-1659 64596243610d935c 2cffea8ddd9d7acb 1660-1679 f3f85177ebff3596 fee585d8cc18b5e2 1680-1699 f4d3a33eda46bd7d e4ceba8b3767815a @@ -96,9 +96,9 @@ 1860-1879 37945653458c9406 cc2403b2a07e596f 1880-1899 cb37ec60958d9bf6 10bc42e84df79ea9 1900-1919 15f5f89601876636 fba3fbc9ad42d9c5 -1920-1939 28e570f13772f19b d4bf15f5512dc58a +1920-1939 930635a46da369ea 06b96935d8bd7361 1940-1959 d773904bc29c8b01 179a50c2636720b2 -1960-1979 40f4e598f5ab04c9 69460bff89154908 +1960-1979 423397c8b1fa8ccc a19291063f33f7a0 1980-1999 8c645c3cdd0dd7d4 c68fe56d117a4016 2000-2019 325f5116c5754a2d b9656e64977d389e 2020-2039 bfc90eb7a13c4cce bd4ce7c411b8bd60 @@ -107,44 +107,44 @@ 2080-2099 c9a673de487d54f9 60438646a0cebd36 2100-2119 ab3bbcf1e7e21465 b8bade9d59bf7a0b 2120-2139 2f3e4728c4f25b1f b9e7fa744da8b777 -2140-2159 6b1cb1ef5cabfebf a582bd3588b38d67 +2140-2159 b03159bc959cb2d5 9a5ec5654d1f62f5 2160-2179 11542938b082a1aa 38c6a7f76dcd064d 2180-2199 0d02756f72dd9a52 6cec852148533d75 2200-2219 547f3442128a0271 6683626fb2c64dc2 2220-2239 70bc1f23691f0ff0 adf628baf7f12376 2240-2259 99ecc1a8a93d23e9 b17d44d8ee4648f3 2260-2279 8b2db3ee961a62bf 86ff90b276dfc3ee -2280-2299 80dd7fdb60bc437f 4568fe441859ef5c +2280-2299 0ce8c8dab6f96caa 028c9d2ea6470df3 2300-2319 8bef847fa812bd59 96e9d3b30a8b7e88 2320-2339 da479c323a75e58b 64dd928266b63e3c 2340-2359 e1ebf237fca7e63f 3a95d5999b55a01e 2360-2379 713533f28a620f7b 1dc359eccc56047b -2380-2399 af9ffc0b144883a4 8633d001257d352b -2400-2419 77254c03fa59c43f 5cbc35f671c19101 +2380-2399 8938fc53015b2578 20cb98c556868233 +2400-2419 97bd972c076b1c8c 2179cfc2af5bfb9b 2420-2439 9596efaa79f07823 6be4ab4f8c9f82fd -2440-2459 756ee3683a4a145c d803d2c6cd8e0a04 +2440-2459 6f8712b563d282ea be0cff28f497b70f 2460-2479 ec09dd2e975c53e6 a9442198de3b5f40 -2480-2499 58132e884769dddf 7e7711e2b7dc57d9 +2480-2499 27d230fab24e610c 9e9023f06b2f44ff 2500-2519 652f64025db8cb2b 542bcb404f620d7b 2520-2539 8247444203a95467 8516b1a55a0d0dce -2540-2559 2b65764d411b1c6c fa12f0b58a30358a +2540-2559 2b65764d411b1c6c ea68672904f81dc6 2560-2579 df7c8b11820cffb0 421256d8fa63cdfe 2580-2599 9f129af92ef3ad33 9dd64e42d2fe0ba9 -2600-2619 11d9d5bbc94c5985 0cab1dda6ad3b656 +2600-2619 d23a1fa14b5f5d86 d7790a76b2b8f94f 2620-2639 c8a360230ffa3fc3 7047b7c6b153a1fd 2640-2659 fea790e433ad8bbb df8d1366b3cb659a 2660-2679 5410dfc3052a398b 20873d49e81a98c8 -2680-2699 2004c847fc860283 6a666541861aa02f +2680-2699 af7ba2cff846df8f 744361d702a88ff4 2700-2719 a2c1a218bedd74dd 7d7cf2c84854b1d5 2720-2739 2a2c351e51e23298 08d273563119e9e9 -2740-2759 1f09e83451e3c831 0250a58a991b9804 +2740-2759 a0beeb47b1195243 b7512b1e391aa5ad 2760-2779 f4099378b1fe076e 9bc0b3c900776430 2780-2799 ce182a92235c7631 7aad80adb6b342fb 2800-2819 131487c221ccf2bc 7284f342246b1cf4 2820-2839 712799bcc42c6179 e6c631afd83c28a2 -2840-2859 6d7c6697a0645359 7b0ae492af2ee4ec +2840-2859 187acc4a9c67d193 8fc5797dcf5ae716 2860-2879 5057d868f431b7d7 e35226f8ec2e1394 -2880-2899 7a8842e5b6591dc8 241193774e2e8d15 +2880-2899 02773cc3a08ffe9a abe3909b1d4cc1da 2900-2919 8a80ad707c1a33cc b70124fa7e4107d0 2920-2939 8338b88e435ce264 8c6e765cc702d976 2940-2959 4b1d3c03c9038dd6 0c7c8a3b9ac74e62 @@ -152,31 +152,31 @@ 2980-2999 8c827f840a5b9ed2 38c6ba7bb6d403f4 3000-3019 3c030faf6bf52d5e 2a4a7d42c174f71e 3020-3039 bf3d5b73c5f6456b 64df250943e3e19e -3040-3059 e749881155dcd0bc 74cb08f1de3dbeb6 +3040-3059 0045ccfa7e83245a 10dfad3cf6ec887d 3060-3079 b7c0e27f547a64ee a079b9988ed26451 -3080-3099 edf834bfcf47cd6b 652436ac09baa89a +3080-3099 060469eb247f653d 88d6ca8d5df89789 3100-3119 e1cd525268dc583b 9b036621cbdca3b1 3120-3139 82a81da2ed139600 ea48dd2f42153a4d 3140-3159 f7e8513ae78b6059 5f155bfb024ddf42 3160-3179 f0313ed29b76b2df 39f4de3896ff0436 -3180-3199 96d2c3b6df44a45b 57ce774acbe547fb +3180-3199 83a53f4bb2760c17 08ef1986be169ea8 3200-3219 59c6694b87aab4fc 7900dfb72932b73d 3220-3239 7274408356878e0e 8bdee012561d7a6e -3240-3259 b980673459f2ff10 1e0f907dfd49a19f +3240-3259 95c095d68a24685f 836d03bbbcc4dce3 3260-3279 f68c7e5a05d0945e ebff3bef140c0052 3280-3299 9b654554e18c8577 e491dcbc31da78ab 3300-3319 81f3c893279db890 8cd83afe6c778aab 3320-3339 e4c5ed75090fb3ee e8537c168b72c2b5 -3340-3359 5f80b158db41fc45 36160f09d10dc03b +3340-3359 2fa8afe034449e5e 5c0fcada1013612a 3360-3379 59e7c20847d90a98 4b68b91e4d36d229 3380-3399 c339d408ad1817ee 111bc05fa7f1df1e -3400-3419 5e68af54fcad599b 0bc85b065f2bc9d9 +3400-3419 e358bd1a1ef815de e2375323a76c3320 3420-3439 f0ebf2a3d9bafa14 ccebf5900bb58a01 3440-3459 d5df1f88ed55c0c4 02068ee2de04a1d6 3460-3479 bd67c9e387a4e062 70bddf1ba00095a1 3480-3499 52f54b11e3386e5d fd6f5e5c577a9dde 3500-3519 896f05102f69aca2 84e0dfe27c056050 -3520-3539 8bfac158e47a218f dfcfdd2918a22f8d +3520-3539 c57b970212c839f3 19e913a0851e0abf 3540-3559 46a3bdc6544b19af d74b9d10156600a7 3560-3579 e7e06fc8e9261029 9ae65ece43e91f48 3580-3599 0e791f2e1cc88b5a 0f58e22fc5107c38 @@ -184,16 +184,16 @@ 3620-3639 445425d851190334 e2a4c24df2d038ec 3640-3659 b2b08ad8d0acffbd 446d748f1a2b50fd 3660-3679 146755ee55b5ef28 20333853750491b4 -3680-3699 38d47a253f4a909f 1e14742b592c70bd -3700-3719 d41cfab6800120cc a5fb611a8bedc31f +3680-3699 b37e30a6c8ff11bc 5d1061ee95ddbf4b +3700-3719 d41cfab6800120cc 99a98f52435572e3 3720-3739 e1608a68de7441bf b1ab2a8ab9262785 -3740-3759 f4dbc964f3c4abcf 72cd75867e028f90 -3760-3779 d14cf9f38e2133fb 62300bafbe0a48d5 +3740-3759 a6326f6d3a80274d e9319c1ce4a50517 +3760-3779 017969a555beeb90 3329edeb3630cfd8 3780-3799 d63c1379cb42b31b 902ae86fe793b654 3800-3819 9835bc7593be9cbf 785946aaba05529b 3820-3839 ca1fee48b41c2890 bf05d1c5efbc63c4 3840-3859 0f32ea8f73ee7cd0 9c28b46e6e357643 -3860-3879 f8f366c01d82162e 044edc635d100ab6 +3860-3879 b57402235f5df321 d90fca0643a1b71b 3880-3899 ea682a566eaf0791 a058991c988c5c3e 3900-3919 d157be480d78eb3c f27e8a606a70435d 3920-3939 47cc145383d3fdf8 c0ab2e1c88502cdb @@ -203,35 +203,35 @@ 4000-4019 39470cd0c337018c 3772314334a0dd8b 4020-4039 bf486b2c6e1e5b22 a4ade9bddf4666cf 4040-4059 f8bdafdae580eec7 1272e8590eda2076 -4060-4079 e46d9ec764ea5c89 c3034badee686879 -4080-4099 ca68f60cc2248338 2b99403759b0e59e +4060-4079 c9ee920c23f065ac 4d495252d8000dcd +4080-4099 9450c20a8051747d 3fe91cb408e0def5 4100-4119 030ddcea61d13f54 103b69b11098e942 -4120-4139 c2194fbf8ba83cf9 d2ce37345e5e7206 +4120-4139 ec664d5ba07551ab 1c3d8057bcd60f90 4140-4159 bd59f9bbaece8982 286cfa254cec65b3 -4160-4179 8e75aebb357d6e3c a88799c32f30cf02 -4180-4199 171a4c5201072c5a 2798f9af5548b61b -4200-4219 1d1865413a196205 e5d1105796f197a4 +4160-4179 73e8d8ab3f73b391 1f83869709ac481c +4180-4199 6a4a68c17691983a 99298225d97a86a9 +4200-4219 6ff01c6d4ed47ac1 e7ef1e26527c230c 4220-4239 21e9315592104765 49a59fb15998115f -4240-4259 8681c1c1be57d7e8 fa96ea6a21fd03b9 +4240-4259 c4a0f64d81da0b34 ae39816fa944c0c3 4260-4279 2a1b522ddc623168 2016cd5cb2906b17 4280-4299 3da724e31c6521e9 23e7900cd270556c 4300-4319 00654f50f2a1ede0 70da790feb208091 -4320-4339 0a13de4f5a403c07 23d77cc04689af6f +4320-4339 981cdcc383ab90ae 9dc9c136d4eb89df 4340-4359 f546fd1859a3093e ea810be1578a3f96 4360-4379 9e7c48ef22caeb5c dd6a284459c761c8 4380-4399 b77557bc30305080 34402c13ef42a82b 4400-4419 910b759e4947506f 99913ba0c1e61b8c -4420-4439 99cd2b9d7b54c993 ac361f4103c273ad +4420-4439 289080cffd2ad052 962c981a00dd274b 4440-4459 e34ffcb1b2142279 53c9c7d2d5da0a15 -4460-4479 d94e53c9b74143dd 140440d60ae8008b +4460-4479 3af2e7c770eea065 a5b33a94a2e2f878 4480-4499 be655bbf1a4d09fe 92656a99dddc903a 4500-4519 9d814ba00e980505 6919b6dc30c06829 4520-4539 68c9a78410c0ace7 5cec0a9cb7c89c91 -4540-4559 bab8c797a71050db 12a75d6d13a6343b +4540-4559 89447fa8f1be792f 6770096360b33d91 4560-4579 a59c8c8cecd95029 1bdaa52fea5d55eb 4580-4599 f2704adbd3b4eab9 ac7349b08398f51f 4600-4619 6e7629e24839e3de 8fc6a9dee9d05961 -4620-4639 05ddb6a8460e9786 9faf9bbb31aed006 +4620-4639 bb3222906b020a64 b8af970c27ad78ef 4640-4659 86995563c98fdd85 995fefdb0f324ec4 4660-4679 2c3bc42638b1821e e84a1574bb036af4 4680-4699 fa6df29ba8ad2ea4 7652fcf1a56176d7 @@ -239,7 +239,7 @@ 4720-4739 6841e2f5a6f5d22a 2e390e437a522b3e 4740-4759 d53a1fa7602a0513 c6ccdef36502466f 4760-4779 8ce786d22930e70b c0b37c7a143a3edf -4780-4799 aae811e20ed8d7a9 64fae785627df52d +4780-4799 aae811e20ed8d7a9 ce38ddd7481a35a8 4800-4819 367cbd238adc9c04 1886f56ed621c61b 4820-4839 14a41d8f4aad31cc dd0a5e5a935c3e6d 4840-4859 a69502e36a96e998 4675b6ca088c79d4 @@ -250,21 +250,21 @@ 4940-4959 cc4a10ad9cc4ed1b f2404cf60e7f6340 4960-4979 b4e4dd8234f05604 c29823e3c367decc 4980-4999 9a740f223cd59942 8d97a2509cb25486 -5000-5019 a3ba3103e8b572e2 54524839b1534d54 +5000-5019 6ff4f67c8cc48731 c8d59360be850d1f 5020-5039 fcce96eb5e5d5224 d1bd7294f2ecdd70 -5040-5059 6c4c9b0240c57867 e560f903417c68ee +5040-5059 0aea8aa01af2ffa3 def192df0ce68b97 5060-5079 9d08fa6d4ae2edb4 266455f3fdc5c57c 5080-5099 65c3eff34c7e9d55 6c6292e0cfc6a5a3 5100-5119 36a28822cd75dd54 a397491371232aeb -5120-5139 a3b940ec544f8a8f 0f3b5a2ee6e4659a +5120-5139 380423e7a758df71 2a9226eff6be7a49 5140-5159 3617eca48adcb41f 95a6c1648e5ea937 5160-5179 b165ad021192aa97 a4ff4baf5f86214a 5180-5199 787f7b8e06932ea5 54e539dd5249876d 5200-5219 d9c93081624bf463 d48214b6ddff9501 5220-5239 adf844cadc992c23 c97d8febf6e11114 -5240-5259 fd54880a9ce740b0 a7450e8e95b433ce +5240-5259 3ad400fbcfb0c73c 7d0d16b5b5f81bd7 5260-5279 db9c9f65f5dfb9ad ae6e154761851200 -5280-5299 bb79fd88e738c46f 2ab48dcfac880f9e +5280-5299 bb79fd88e738c46f 821771ceac353f58 5300-5319 7fddda4649deea19 1278ddca31068fe0 5320-5339 3ff30573ca06f507 a7274e347681ab0c 5340-5359 4e213dfece0d3540 c85136fdb7f1035e @@ -279,13 +279,13 @@ 5520-5539 56d2d0154a578a5d 750fd9254014aead 5540-5559 dbc46201eb322ef3 d6c563b37eca9ebe 5560-5579 8e20d233bc032828 4fd246d9bccde25f -5580-5599 f30fdc065a509772 a22cc2cb6cb0eb7c -5600-5619 3aedaffc3ee5b52d 67e38fd702f3dce0 +5580-5599 9c5f56f846b76b6a b5c6264e4b4d63cc +5600-5619 3b28bdc009f4c967 96dd530592c20611 5620-5639 9fe5c0a391af04c0 ae2e13021e3303ad 5640-5659 93330ccfd77962b5 a8f270df47c51c46 5660-5679 4190093bf17da99f 45473bae2e0554f0 5680-5699 3fc3b812649bd56e f489ab55e8959013 -5700-5719 c1781c0b8636a549 6d5bcbf2baa65fc2 +5700-5719 062ac098b8450ac3 044b6b33dddcc9cc 5720-5739 9c5885f35c9a31a8 63d53c5bfbfe00f4 5740-5759 f57706c708958d95 1bd8934657451ed5 5760-5779 4c0011a64cbefc67 14246ff41137c94e @@ -297,6 +297,6 @@ 5880-5899 9cf04174c0f52c82 d0ebdde22c764214 5900-5919 27bd9015ce0266c0 bba95be7aebb7f90 5920-5939 ff4e6413e26667bf e22a156355b80a01 -5940-5959 e5ae669220e6c62a 88d2d62e74f3eaf5 -5960-5979 f86a9bd60d59bfcf 1969aeb02e50e4eb +5940-5959 5343ec7763ca4275 4c416edc2525cbee +5960-5979 1e0b4e5484cd0abe f506c3e220430252 5980-5999 92856edee0554d49 9f0110999cfb357f From 0888704190e7cbb82978a8268590d4b7a027fe77 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:56:18 -0400 Subject: [PATCH 03/11] Document the hosted supersede cleanup for cargo, Go and Maven Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- docs/ecosystems.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b64de3807..18a71f050 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -923,7 +923,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`); a yarn berry lock whose `.yarnrc.yml` names another `npmRegistryServer` reads that registry's document instead, so a mirror's off-path `dist.tarball` keeps its `::__archiveUrl=` binding (falling back to the default registry, with `upstream_registry_fallback`, when the mirror can't be read). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. - * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. + * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); every `[registries.socket-patch-]` block no manifest or lock still references leaves the project cargo config — including a superseded patch generation's block an earlier re-pin left behind (#864). A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). A restored `requirements.txt` line gets `--hash` options only when the file is in pip's hash-checking mode. The mode is read off the file's other requirement lines (an `-e` / `--editable` line means unhashed). When every requirement is a hosted pin, it is read off the hosted line itself (`--hash` vs a `#sha256=` url fragment) (#410). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index bc9c2a266..d83f5824e 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -419,7 +419,10 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: (`redirect_maven_dep_unpinned`) — a literal edit would break the property reference and a depMgmt pin could strand sibling artifacts sharing the property. A literal version that matches neither the base nor the suffixed value is skipped - (`redirect_maven_dep_version_mismatch`). + (`redirect_maven_dep_version_mismatch`). A `-socket.` literal left by an + earlier patch for the same release is re-pinned to the new suffix: the superseded + `socket-patch-` repository and its Trusted Checksums entries are removed, and a + repository whose grant URL changed (a rotated token) is refreshed in place. * **Trusted Checksums reinforcement (hosted Maven, 3.9.4+).** When the patch server supplies both the jar and pom sha256, the rewriter also emits Maven [Trusted Checksums](https://maven.apache.org/resolver/expected-checksums.html) files — @@ -954,6 +957,9 @@ replacement. Go uses committed `go.sum` entries without consulting the checksum database for those entries, so a fresh checkout needs no per-machine checksum exemption. A mismatched checksum still fails the build. The rewriter removes the replaced version's original sum lines to keep the result stable under `go mod tidy`. +A re-pin to a newer patch (a superseding patch uuid, or the same patch republished +at a new `-socketpatch.` version) also removes the previous Socket module's sum +lines, so `go mod tidy -diff` stays clean after a patch update. This requires a free, publicly retrievable patch reference carrying a `goproxy` override. CLI support does not imply a patch is published for a particular module. From f80f9afe45b1d3e9c212195fd8d1b183bb2bab31 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:23:01 -0400 Subject: [PATCH 04/11] Keep one copy of the pin-name grammar and the same-release check The `socket-patch-` grammar now lives only in patch::redirect::generation: PIN_NAME_PREFIX, hosted_pin_name and pin_name_uuid (the exact parser moved there from redirect/mod.rs as socket_patch_name_uuid_exact). vendor::cargo_config::SOCKET_REGISTRY_PREFIX is deleted, and the hand-built names in vendor/nuget_feed.rs, vex/discover/cargo.rs, vex/discover/maven.rs and upstream/maven.rs go through the generation helpers. The same-release predicate (canonical base equal, or composer equivalent) had four copies: ledgers::same_release, same_package in vex_sources.rs and in vex/discover/mod.rs, and the inline filter in rollback::superseded_by_hosted. vex::discover::same_release is the one left; the others are deleted and their call sites routed through it. The generation module doc now says what is implemented: the Go and Maven restores do not sweep residue an older CLI left, and vendored NuGet uses the same socket-patch- source key. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/rollback.rs | 5 +- .../src/commands/vex_sources.rs | 23 +++---- crates/socket-patch-core/src/ledgers.rs | 16 ++--- .../src/patch/redirect/generation.rs | 68 +++++++++++++++---- .../src/patch/redirect/mod.rs | 18 +---- .../src/patch/redirect/upstream/maven.rs | 7 +- .../src/vendor/cargo_config.rs | 5 +- .../src/vendor/nuget_feed.rs | 2 +- .../src/vex/discover/cargo.rs | 7 +- .../src/vex/discover/maven.rs | 10 +-- .../socket-patch-core/src/vex/discover/mod.rs | 29 ++++---- 11 files changed, 101 insertions(+), 89 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 40b3c5fcb..b51a058f4 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -14,10 +14,9 @@ use socket_patch_core::patch::rollback::{ VerifyRollbackResult, VerifyRollbackStatus, }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; -use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; use socket_patch_core::vendor::{purl_keys_cover, RevertOpts, VendorState}; -use socket_patch_core::vex::discover::canonical_base_purl; +use socket_patch_core::vex::discover::{canonical_base_purl, same_release}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -2815,7 +2814,7 @@ pub(crate) fn superseded_by_hosted( let pkg = canonical_base_purl(purl); let same: Vec<&HostedPin> = pins .iter() - .filter(|pin| pin.purl == pkg || composer_purls_equivalent(&pin.purl, &pkg)) + .filter(|pin| same_release(&pin.purl, &pkg)) .collect(); if same.iter().any(|pin| pin.uuid == record.uuid) { return None; diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 370dc1cee..5e17cdddb 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -77,12 +77,12 @@ use socket_patch_core::api::client::{ }; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::RedirectState; -use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::concurrent::{api_concurrency, ordered_concurrent}; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; use socket_patch_core::vex::discover::{ - canonical_base_purl, vendor_ref, Discovery, LedgerLiveness, PatchedRef, WiringMode, + canonical_base_purl, same_release, vendor_ref, Discovery, LedgerLiveness, PatchedRef, + WiringMode, }; use socket_patch_core::vex::FailedPatch; @@ -249,7 +249,7 @@ impl Cand { let vendor_entry = vendor .entries .values() - .find(|e| e.uuid == r.uuid && same_package(&canonical_base_purl(&e.base_purl), &r.purl)) + .find(|e| e.uuid == r.uuid && same_release(&canonical_base_purl(&e.base_purl), &r.purl)) .cloned(); Cand { key: r.purl.clone(), @@ -297,7 +297,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let mut conflicted_keys: BTreeMap<&str, Vec> = BTreeMap::new(); cands.retain(|c| { let pkg = canonical_base_purl(&c.key); - match conflicts.iter().find(|(k, _)| same_package(k, &pkg)) { + match conflicts.iter().find(|(k, _)| same_release(k, &pkg)) { Some((k, _)) => { conflicted_keys .entry(k.as_str()) @@ -337,7 +337,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let Some(u) = discovery .unattested .iter() - .find(|u| u.uuid == c.uuid && same_package(&u.purl, &pkg)) + .find(|u| u.uuid == c.uuid && same_release(&u.purl, &pkg)) else { return true; }; @@ -466,7 +466,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let expected_pkg = expected_package(cand); match local_record_by_uuid(&cand.uuid, &manifest, &redirect_records, &vendor) { Some((found_key, record)) - if same_package(&canonical_base_purl(&found_key), &expected_pkg) => + if same_release(&canonical_base_purl(&found_key), &expected_pkg) => { if cand.lockfile_only { cand.key = found_key; @@ -492,7 +492,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S match fetched.get(&cand.uuid) { Some((api_purl, record)) if record.uuid == cand.uuid - && same_package(&canonical_base_purl(api_purl), &expected_package(cand)) => + && same_release(&canonical_base_purl(api_purl), &expected_package(cand)) => { if cand.lockfile_only { cand.key = api_purl.clone(); @@ -590,13 +590,6 @@ fn failed(purl: &str, reason: &str) -> FailedPatch { } } -/// Whether two [`canonical_base_purl`] spellings name one package release: -/// equal, or composer spellings of the same release (a lock's `@3.0.2`, a -/// patch purl's padded `@3.0.2.0`). -fn same_package(a: &str, b: &str) -> bool { - a == b || composer_purls_equivalent(a, b) -} - /// The package a candidate's record must name (canonical form). fn expected_package(cand: &Cand) -> String { match (&cand.vendor_entry, cand.lockfile_only) { @@ -689,7 +682,7 @@ fn attach_discovered( let mut superseded = Vec::new(); for (pkg, refs) in groups { let idxs: Vec = (0..cands.len()) - .filter(|&i| same_package(&canonical_base_purl(&cands[i].key), pkg)) + .filter(|&i| same_release(&canonical_base_purl(&cands[i].key), pkg)) .collect(); // Pass 1: the candidate already attests the wired uuid. let mut unmatched: Vec<&PatchedRef> = Vec::new(); diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs index 65dc752d4..00a86caa5 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -29,10 +29,9 @@ use std::path::Path; use crate::manifest::schema::{PatchManifest, PatchRecord}; use crate::patch::redirect::upstream::HostedPin; use crate::patch::redirect::{CorruptRedirectState, RedirectState}; -use crate::utils::composer_version::composer_purls_equivalent; use crate::utils::purl::{normalize_purl, patch_matches, strip_purl_qualifiers}; use crate::vendor::{VendorEntry, VendorState}; -use crate::vex::discover::canonical_base_purl; +use crate::vex::discover::{canonical_base_purl, same_release}; /// A patch store, in owner-precedence order (a lower store wins a key). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -132,14 +131,6 @@ impl Matches { } } -/// Whether a hosted pin's purl (canonical base, as lockfile discovery spells -/// it) wires the same package release as the store key `key`: the same -/// owned pin, whichever patch generation (uuid) each side recorded. -pub fn same_release(key: &str, pin_purl: &str) -> bool { - let base = canonical_base_purl(key); - base == pin_purl || composer_purls_equivalent(&base, pin_purl) -} - /// The lockfiles' hosted pins a remove/rollback `identifier` selects, /// sorted by purl: every pin the identifier names by purl or uuid, plus /// every pin wiring the same release as one of `manifest_keys` (the @@ -157,7 +148,9 @@ pub fn hosted_pins_matching( .iter() .filter(|pin| { patch_matches(&pin.purl, &pin.uuid, identifier) - || manifest_keys.iter().any(|key| same_release(key, &pin.purl)) + || manifest_keys + .iter() + .any(|key| same_release(&canonical_base_purl(key), &pin.purl)) }) .cloned() .collect(); @@ -422,7 +415,6 @@ pub fn uuid_only_record(uuid: &str) -> PatchRecord { } } - /// Fold the hosted pins and the vendor ledger's patch records into the /// manifest view update detection consults. Hosted mode records purl→uuid /// ONLY in the lockfiles (`hosted_pins`, uuid only; v5 keeps no hosted diff --git a/crates/socket-patch-core/src/patch/redirect/generation.rs b/crates/socket-patch-core/src/patch/redirect/generation.rs index 0d9954627..a1889db08 100644 --- a/crates/socket-patch-core/src/patch/redirect/generation.rs +++ b/crates/socket-patch-core/src/patch/redirect/generation.rs @@ -1,38 +1,67 @@ -//! Owned-pin generations: the one supersede policy every hosted writer and -//! restore shares (audit B07). +//! Owned-pin generations: the supersede policy the hosted writers and +//! restores share (audit B07), and the one home of the `socket-patch-` +//! name grammar. //! //! A package release (ecosystem, name, version) has at most ONE live //! socket-owned pin. The patch uuid it carries is that pin's generation: a //! superseding patch, or the same patch republished, is a new generation of //! the same pin, never a second pin beside it. So: //! -//! - a re-pin replaces the previous generation in place, and drops every -//! piece of wiring only that generation used (a cargo `[registries.…]` -//! block, a Go module's go.sum pair, a Maven ``), the way a -//! fresh pin would never have written it; -//! - a restore (`remove` / `rollback`) unwinds every generation still wired, -//! including residue an older CLI left behind, not only the generation the -//! lockfile names today; +//! - a hosted re-pin (cargo, Go, Maven) replaces the previous generation in +//! place, and drops every piece of wiring only that generation used (a +//! cargo `[registries.…]` block, a Go module's go.sum pair, a Maven +//! `` and its trusted checksums), the way a fresh pin would +//! never have written it; +//! - the cargo restore (`remove` / `rollback`) also unwinds every +//! `[registries.socket-patch-]` block nothing references any more, +//! including residue an older CLI left behind on re-pin. The Go and Maven +//! restores remove only the selected pins' wiring: residue an older CLI +//! left there (an old socket module's go.sum pair, a superseded +//! repository) is not swept yet; //! - matching a remove/rollback identifier across the stores treats a //! manifest record, the vendored entry it claims and the hosted pin of the //! same release as one owned pin ([`crate::ledgers::Ledgers::matching`], //! [`crate::ledgers::hosted_pins_matching`]). //! //! Hosted writers name a generation's wiring `socket-patch-` (a cargo -//! registry, a Maven repository id, a NuGet source key); vendored mode's -//! `socket-patch-vendor-` names are a different owner and never match. +//! registry, a Maven repository id, a NuGet source key). Vendored NuGet uses +//! the same `socket-patch-` source key for its local feed; vendored +//! Maven's `socket-patch-vendor-` repository id is a different grammar +//! ([`pin_name_uuid`] with `vendored`), which [`named_generations`] never +//! matches. use std::collections::BTreeSet; use crate::patch::path_safety::is_canonical_uuid; -const PIN_NAME_PREFIX: &str = "socket-patch-"; +/// The prefix every Socket-owned registry / repository / source name +/// carries: `socket-patch-`. +pub(crate) const PIN_NAME_PREFIX: &str = "socket-patch-"; + +/// The prefix of vendored Maven's repository id: `socket-patch-vendor-`. +const VENDOR_PIN_NAME_PREFIX: &str = "socket-patch-vendor-"; /// The name hosted mode gives a generation's wiring: `socket-patch-`. pub(crate) fn hosted_pin_name(uuid: &str) -> String { format!("{PIN_NAME_PREFIX}{uuid}") } +/// The uuid of a Socket-owned registry / repository / source NAME in its +/// EXACT grammar: `socket-patch-`, or with `vendored` +/// `socket-patch-vendor-` (maven's vendored repository id). +/// No trimming: the rewriter must never treat a user's padded pin as its +/// own, while lockfile discovery trims at its call site +/// (`vex::discover::socket_patch_name_uuid`). +pub(crate) fn pin_name_uuid(name: &str, vendored: bool) -> Option<&str> { + let prefix = if vendored { + VENDOR_PIN_NAME_PREFIX + } else { + PIN_NAME_PREFIX + }; + name.strip_prefix(prefix) + .filter(|uuid| is_canonical_uuid(uuid)) +} + /// Every generation (patch uuid) `text` names as `socket-patch-`, in /// any position (a `registry = "…"` value, an ``, a section header). A /// comment that names one counts too, which only ever keeps wiring alive. @@ -90,6 +119,21 @@ mod tests { assert_eq!(hosted_pin_name(A), format!("socket-patch-{A}")); } + #[test] + fn pin_name_uuid_is_exact() { + assert_eq!(pin_name_uuid(&hosted_pin_name(A), false), Some(A)); + assert_eq!( + pin_name_uuid(&format!("socket-patch-vendor-{A}"), true), + Some(A) + ); + assert_eq!( + pin_name_uuid(&format!("socket-patch-vendor-{A}"), false), + None + ); + assert_eq!(pin_name_uuid(&format!(" socket-patch-{A}"), false), None); + assert_eq!(pin_name_uuid(&format!("socket-patch-{A}x"), false), None); + } + #[test] fn superseded_is_named_before_and_not_after() { let before = format!("registry = \"socket-patch-{A}\""); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ad132b9a8..b5042b6db 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -2226,27 +2226,11 @@ fn is_valid_gem_index_url(url: &str) -> bool { && !url.chars().any(|c| c.is_control() || c == ' ') } -/// The uuid of a Socket-owned registry / repository / source NAME in its -/// EXACT grammar: `socket-patch-`, or with `vendored` -/// `socket-patch-vendor-` (maven's vendored repository id). -/// No trimming: the rewriter must never treat a user's padded pin as its -/// own, while lockfile discovery trims at its call site -/// (`vex::discover::socket_patch_name_uuid`). -pub(crate) fn socket_patch_name_uuid_exact(name: &str, vendored: bool) -> Option<&str> { - let prefix = if vendored { - "socket-patch-vendor-" - } else { - "socket-patch-" - }; - name.strip_prefix(prefix) - .filter(|uuid| crate::patch::path_safety::is_canonical_uuid(uuid)) -} - /// 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 { - socket_patch_name_uuid_exact(value, false).is_some() + generation::pin_name_uuid(value, false).is_some() } /// The `socket-patch-` registry name pinning `crate_name` in this diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index b12fa673d..99e1dc4b2 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -192,8 +192,9 @@ fn restore_pin( plugin configuration socket-patch did not write)" )); } - if text.contains(&generation::hosted_pin_name(uuid)) { - return Err(format!("pom.xml still names socket-patch-{uuid}")); + let name = generation::hosted_pin_name(uuid); + if text.contains(&name) { + return Err(format!("pom.xml still names {name}")); } Ok(text) } @@ -261,7 +262,7 @@ pub(crate) async fn restore( for module in &module_poms { if let Ok(Some(child)) = view.read(module).await { if child.contains(&suffixed) - || child.contains(&format!("socket-patch-{}", pin.uuid)) + || child.contains(&generation::hosted_pin_name(&pin.uuid)) { in_module = Some(module.clone()); break; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index 6740038f7..4e487712d 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -61,9 +61,6 @@ const CARGO_VENDOR_DIR: &str = ".socket/vendor/cargo"; pub(crate) const CONFIG_LEGACY: &str = ".cargo/config"; /// The project config cargo reads when [`CONFIG_LEGACY`] does not exist. pub(crate) const CONFIG_TOML: &str = ".cargo/config.toml"; -/// The prefix every Socket-owned registry name carries -/// (`socket-patch-`, as the hosted rewriter defines and pins it). -pub(crate) const SOCKET_REGISTRY_PREFIX: &str = "socket-patch-"; /// Info about one `[patch.crates-io]` entry, for vendor pre-flight / verify. #[derive(Debug, Clone)] @@ -266,7 +263,7 @@ pub(crate) fn registry_definitions(doc: &DocumentMut) -> Vec<(String, String)> { }; registries .iter() - .filter(|(name, _)| name.starts_with(SOCKET_REGISTRY_PREFIX)) + .filter(|(name, _)| name.starts_with(crate::patch::redirect::generation::PIN_NAME_PREFIX)) .filter_map(|(name, item)| { let index = item.as_table_like()?.get("index")?.as_str()?; Some((name.to_string(), index.to_string())) diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 1e8f51d79..37ec656d1 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -216,7 +216,7 @@ async fn nuget_prelude( let copy_rel = format!("{uuid_dir_rel}/{leaf}"); let uuid_dir = project_root.join(&uuid_dir_rel); let nupkg_path = project_root.join(©_rel); - let source_key = format!("socket-patch-{}", record.uuid); + let source_key = crate::patch::redirect::generation::hosted_pin_name(&record.uuid); // A patch with no files is meaningless to vendor: no-op success, no edits. if record.files.is_empty() { diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 86aab22de..55b9e2ce6 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -119,10 +119,11 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::formats::cargo::{CargoLock, CopyClaim, LockedPackage}; +use crate::patch::redirect::generation::{hosted_pin_name, PIN_NAME_PREFIX}; use crate::utils::digest::is_hex64_lower; use crate::vendor::cargo_config::{ effective_config_rel, patch_entries, registry_definitions, CargoPatchEntry, CONFIG_LEGACY, - CONFIG_TOML, SOCKET_REGISTRY_PREFIX, + CONFIG_TOML, }; use crate::vendor::cargo_manifest::{crates_io_url_alias_tables, is_crates_io_source}; use crate::vendor::cargo_tag; @@ -457,7 +458,7 @@ fn hosted_from_lock( ); continue; }; - let reg = format!("{SOCKET_REGISTRY_PREFIX}{uuid}"); + let reg = hosted_pin_name(&uuid); if let Some(index) = definitions.get(®) { if source_uuid(ctx, index).as_deref() != Some(uuid.as_str()) { out.diag( @@ -555,7 +556,7 @@ fn unresolved_manifest_pins( let deps = dependency_entries(manifest); for (name, regs) in decls { for reg in regs.iter().flatten() { - if !reg.trim().starts_with(SOCKET_REGISTRY_PREFIX) { + if !reg.trim().starts_with(PIN_NAME_PREFIX) { continue; } let Some(uuid) = socket_patch_name_uuid(reg, false) else { diff --git a/crates/socket-patch-core/src/vex/discover/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index c73e1f978..b9200e64f 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -88,15 +88,15 @@ use super::{ Discovery, PatchedRef, WiringMode, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::maven::{ + is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, + PomRepo, +}; use crate::patch::redirect::{ local_repo_artifact_path, MVN_CHECKSUMS, MVN_CONFIG, TRUSTED_CHECKSUMS_ON, }; use crate::utils::digest::sha256_hex; use crate::vendor::lock_inventory::LockIntegrity; -use crate::formats::maven::{ - is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, - PomRepo, -}; use crate::vendor::maven_repo::{sha1_sidecar_matches, VENDOR_REPO_URL_PREFIX}; use crate::vendor::path::{sweep_vendor_dirs, VENDOR_DIR}; @@ -490,7 +490,7 @@ enum RepoKind { fn classify_repo(ctx: &DiscoverCtx<'_>, repo: &PomRepo, out: &mut Discovery) -> RepoKind { let id = repo.id.as_str(); let url = repo.url.as_str(); - let id_socket = id.starts_with("socket-patch-"); + let id_socket = id.starts_with(crate::patch::redirect::generation::PIN_NAME_PREFIX); let id_hosted = socket_patch_name_uuid(id, false); let id_vendored = socket_patch_name_uuid(id, true); let url_hosted = ctx.hosted_uuid(url); diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index bc6df58e5..73bab6981 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -556,7 +556,7 @@ impl Discovery { let key = canonical_base_purl(purl); self.refs .iter() - .any(|r| r.uuid == uuid && r.mode == mode && same_package(&r.purl, &key)) + .any(|r| r.uuid == uuid && r.mode == mode && same_release(&r.purl, &key)) } /// Whether some file discovery read mentions patch `uuid` as a `mode` @@ -700,7 +700,7 @@ impl Discovery { self.refs.iter().any(|r| { r.uuid == uuid && r.mode == WiringMode::Vendored - && same_package(&r.purl, &key) + && same_release(&r.purl, &key) && r.artifact_rel.as_deref() == Some(artifact) }) }) @@ -714,7 +714,7 @@ impl Discovery { pub fn vendored_contest(&self, purl: &str, uuid: &str) -> Option<&ContestedRef> { let key = canonical_base_purl(purl); self.contested.iter().find(|c| { - c.uuid == uuid && c.mode == WiringMode::Vendored && same_package(&c.purl, &key) + c.uuid == uuid && c.mode == WiringMode::Vendored && same_release(&c.purl, &key) }) } @@ -723,9 +723,9 @@ impl Discovery { /// ([`Discovery::resolved_elsewhere`]). pub fn resolves_package(&self, purl: &str) -> bool { let key = canonical_base_purl(purl); - self.refs.iter().any(|r| same_package(&r.purl, &key)) - || self.contested.iter().any(|c| same_package(&c.purl, &key)) - || self.elsewhere.iter().any(|e| same_package(&e.purl, &key)) + self.refs.iter().any(|r| same_release(&r.purl, &key)) + || self.contested.iter().any(|c| same_release(&c.purl, &key)) + || self.elsewhere.iter().any(|e| same_release(&e.purl, &key)) } fn recognize(&mut self, uuid: &str, mode: WiringMode, file: &str) { @@ -999,7 +999,7 @@ impl<'a> DiscoverCtx<'a> { /// `vendored = true`, `socket-patch-vendor-` (maven's vendored repo /// id). Exact grammar only — a suffix or a non-canonical uuid is not ours. pub(crate) fn socket_patch_name_uuid(name: &str, vendored: bool) -> Option { - crate::patch::redirect::socket_patch_name_uuid_exact(name.trim(), vendored).map(str::to_string) + crate::patch::redirect::generation::pin_name_uuid(name.trim(), vendored).map(str::to_string) } /// Every Socket patch identity `text` MENTIONS, by wiring mode — the sweep @@ -1555,12 +1555,13 @@ pub fn canonical_base_purl(purl: &str) -> String { } } -/// Whether a ref's [`canonical_base_purl`] and `key` (another canonical -/// base) name the same package release: equal, or for composer the same -/// release in another version spelling (a ledger's `@3.0.2.0` is the lock's -/// `@3.0.2`). -fn same_package(ref_purl: &str, key: &str) -> bool { - ref_purl == key || crate::utils::composer_version::composer_purls_equivalent(ref_purl, key) +/// Whether two [`canonical_base_purl`] spellings name the same package +/// release, whichever patch generation each side recorded: equal, or for +/// composer the same release in another version spelling (a ledger's +/// `@3.0.2.0` is the lock's `@3.0.2`). The one same-release predicate the +/// ledgers, `vex` and `remove` / `rollback` share. +pub fn same_release(a: &str, b: &str) -> bool { + a == b || crate::utils::composer_version::composer_purls_equivalent(a, b) } /// [`canonical_base_purl`] for a ref about to be pushed, plus shape checks: @@ -1593,7 +1594,7 @@ impl Discovery { let key = canonical_base_purl(purl); self.refs .iter() - .any(|r| r.mode == mode && same_package(&r.purl, &key)) + .any(|r| r.mode == mode && same_release(&r.purl, &key)) } /// Liveness of a VENDOR-ledger entry — the ONE rule every reader of the From 8b055ebb5acacce6d58d9c3b14b6d3b43f8627fe Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:23:10 -0400 Subject: [PATCH 05/11] Re-pin only hosted Maven suffix literals and drop CRLF checksums A `-socket.` literal counted as an earlier hosted generation whenever its base matched, so a vendored reactor / sbt pin (the same suffix under a socket-patch-vendor- repository) was silently re-pinned when the vendor ledger was missing. It is now a prior generation only when the pom declared a hosted socket-patch- repository whose uuid starts with that hex8; otherwise the redirect_maven_dep_version_mismatch skip stays. The trusted-checksums drop split a CRLF file one way for the check and another for the filter, so superseded entries survived and every re-run rewrote the file. The file is LF-normalized first and both use the same line test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 104 +++++++++++++++--- docs/ecosystems.md | 7 +- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index b5042b6db..4218bb732 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6854,6 +6854,23 @@ fn rewrite_maven_pom( } let mut pom = files.get("pom.xml").cloned(); let mut pom_changed = false; + // The hosted generations whose `socket-patch-` repository the pom + // declared before this run: only a suffixed literal one of these minted + // is an earlier generation of OUR pin. A vendored reactor / sbt pin + // writes the same `-socket.` suffix under a + // `socket-patch-vendor-` repository, which stays a mismatch. + let hosted_repo_generations: std::collections::BTreeSet = pom + .as_deref() + .map(|text| { + generation::named_generations(text) + .into_iter() + .filter(|uuid| { + !maven_repositories_with_id(text, &generation::hosted_pin_name(uuid)) + .is_empty() + }) + .collect() + }) + .unwrap_or_default(); let mut mvn_config = files.get(MVN_CONFIG).cloned().unwrap_or_default(); let mut mvn_config_changed = false; // (local-repo-relative path, bare sha256 hex) entries to merge in. @@ -7030,12 +7047,17 @@ fn rewrite_maven_pom( }) .collect(); // A `-socket.` literal for this release that is not the - // applied suffix is an earlier generation of OUR pin (a superseding - // patch uuid): re-pin it like the base (#266). + // applied suffix, minted by a hosted `socket-patch-` repository + // the pom declares, is an earlier generation of OUR pin (a + // superseding patch uuid): re-pin it like the base (#266). let prior_generation = |v: &str| { v != suffixed_version - && crate::formats::maven::split_socket_version(v) - .is_some_and(|(base, _)| base == dep.version) + && crate::formats::maven::split_socket_version(v).is_some_and(|(base, hex8)| { + base == dep.version + && hosted_repo_generations + .iter() + .any(|uuid| uuid.starts_with(hex8)) + }) }; // Rewrite base (or a prior generation) → suffixed. Descending offset // order so earlier edits don't shift later matches' offsets. @@ -7238,12 +7260,17 @@ fn rewrite_maven_pom( if mvn_config_changed { result.files.insert(MVN_CONFIG.into(), mvn_config); } - let existing_checksums = files.get(MVN_CHECKSUMS).cloned().unwrap_or_default(); - let drops_checksums = checksum_drops.iter().any(|path| { - existing_checksums - .lines() - .any(|l| l.split_once(" ").is_some_and(|(_, p)| p == path)) - }); + // LF-normalized, so the drop check and the filter below split lines the + // same way (a CRLF file's paths would otherwise keep a trailing `\r`). + let existing_checksums = files + .get(MVN_CHECKSUMS) + .map(|text| text.replace("\r\n", "\n")) + .unwrap_or_default(); + let names_dropped_path = |line: &str| { + line.split_once(" ") + .is_some_and(|(_, p)| checksum_drops.iter().any(|d| d == p)) + }; + let drops_checksums = existing_checksums.split('\n').any(names_dropped_path); if !checksum_entries.is_empty() || drops_checksums { let action = if files.contains_key(MVN_CHECKSUMS) { "rewritten" @@ -7252,10 +7279,7 @@ fn rewrite_maven_pom( }; let kept: String = existing_checksums .split('\n') - .filter(|l| { - !l.split_once(" ") - .is_some_and(|(_, p)| checksum_drops.iter().any(|d| d == p)) - }) + .filter(|l| !names_dropped_path(l)) .collect::>() .join("\n"); result.files.insert( @@ -22597,6 +22621,58 @@ mod owned_pin_generation_matrix { assert_eq!(checksums.lines().count(), 2, "{checksums}"); } + /// A `-socket.` literal that no hosted `socket-patch-` + /// repository minted (a vendored reactor / sbt pin, served from a + /// `socket-patch-vendor-` repository) is not an earlier hosted + /// generation: the mismatch is reported and the pom left untouched. + #[test] + fn maven_vendored_suffix_literal_is_not_a_prior_generation() { + let pom = pom_project()["pom.xml"].replace( + "3.12.0", + "3.12.0-socket.aaaaaaaa", + ); + let pom = pom.replace( + "\n", + &format!( + "\n \n \n \ + socket-patch-vendor-{A}\n \ + file://${{project.basedir}}/.socket/vendor/maven/repo\n \ + \n \n" + ), + ); + let files = BTreeMap::from([("pom.xml".to_string(), pom)]); + let tok = "22222222-3333-4444-8555-666666666666"; + let (_, result) = plan(&files, &maven(B, tok)); + assert!( + result + .warnings + .iter() + .any(|w| w.code == "redirect_maven_dep_version_mismatch"), + "{:?}", + result.warnings + ); + assert!( + !result.files.contains_key("pom.xml"), + "{:?}", + result.files.get("pom.xml") + ); + } + + /// A CRLF trusted-checksums file drops the superseded generation's + /// entries like an LF one, and the re-pinned project is settled. + #[test] + fn maven_repin_drops_superseded_checksums_from_a_crlf_file() { + let tok = "22222222-3333-4444-8555-666666666666"; + let (mut wired, _) = plan(&pom_project(), &maven(A, tok)); + let crlf = wired[MVN_CHECKSUMS].replace('\n', "\r\n"); + wired.insert(MVN_CHECKSUMS.to_string(), crlf); + let (repinned, _) = plan(&wired, &maven(B, tok)); + let checksums = &repinned[MVN_CHECKSUMS]; + assert!(!checksums.contains("aaaaaaaa"), "{checksums}"); + let (_, again) = plan(&repinned, &maven(B, tok)); + assert!(again.files.is_empty(), "{:?}", again.files.keys()); + } + /// #266: a rotated grant token (same uuid, new token path segment) /// refreshes the repository URL in place. #[test] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index d83f5824e..a491e6507 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -420,9 +420,12 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: a depMgmt pin could strand sibling artifacts sharing the property. A literal version that matches neither the base nor the suffixed value is skipped (`redirect_maven_dep_version_mismatch`). A `-socket.` literal left by an - earlier patch for the same release is re-pinned to the new suffix: the superseded + earlier hosted patch for the same release (the pom declares that patch's + `socket-patch-` repository) is re-pinned to the new suffix: the superseded `socket-patch-` repository and its Trusted Checksums entries are removed, and a - repository whose grant URL changed (a rotated token) is refreshed in place. + repository whose grant URL changed (a rotated token) is refreshed in place. A suffixed + literal no hosted repository in the pom minted (a vendored `socket-patch-vendor-` + pin, say) is still a mismatch and is skipped. * **Trusted Checksums reinforcement (hosted Maven, 3.9.4+).** When the patch server supplies both the jar and pom sha256, the rewriter also emits Maven [Trusted Checksums](https://maven.apache.org/resolver/expected-checksums.html) files — From 29e30b195ce1a3c9b5eb31b59eabfd3749ac1d39 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:23:11 -0400 Subject: [PATCH 06/11] Test the cargo restore's sweep of superseded registry blocks A leftover socket-patch-A block beside the live pin B is swept when B is restored, and the emptied config is deleted (#864). A generation the restore did not select that a manifest still pins keeps its block. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/upstream/cargo.rs | 103 ++++++++++++++++-- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index ee6356c97..54d4c8030 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -95,7 +95,10 @@ pub(crate) async fn restore( ) }); let cksums: BTreeMap> = - futures_util::future::join_all(lookups).await.into_iter().collect(); + futures_util::future::join_all(lookups) + .await + .into_iter() + .collect(); let mut changed = false; let mut restored: Vec<(&LockHit, String)> = Vec::new(); for hit in &hits { @@ -114,7 +117,9 @@ pub(crate) async fn restore( } // The entries' own source + checksum values, spliced at the parse's // spans (every hit is a distinct block: its source names its uuid). - let spans = model.spans().expect("a lock parsed from text carries spans"); + let spans = model + .spans() + .expect("a lock parsed from text carries spans"); let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); for (hit, cksum) in &restored { let at = &spans.packages[hit.index]; @@ -131,7 +136,10 @@ pub(crate) async fn restore( } for (hit, cksum) in &restored { // Dependents' full-id references and the v1 `[metadata]` key. - lock = lock.replace(&format!("({})", hit.source), &format!("({CRATES_IO_SOURCE})")); + lock = lock.replace( + &format!("({})", hit.source), + &format!("({CRATES_IO_SOURCE})"), + ); let metadata_key = format!( "\"checksum {} {} ({CRATES_IO_SOURCE})\" = \"", hit.name, hit.version @@ -150,7 +158,11 @@ pub(crate) async fn restore( if changed { view.write( "Cargo.lock", - if crlf { lock.replace('\n', "\r\n") } else { lock }, + if crlf { + lock.replace('\n', "\r\n") + } else { + lock + }, ); } } @@ -326,11 +338,10 @@ pub(crate) fn remove_registry_block(config: &str, reg: &str) -> Option { end -= 1; } let fragment = format!("{}\n", lines[i..end].join("\n")); - let removed = remove_appended_cargo_block(&lf, &fragment) - .or_else(|| { - // The block ends the file with no final newline. - remove_appended_cargo_block(&lf, fragment.trim_end_matches('\n')) - })?; + let removed = remove_appended_cargo_block(&lf, &fragment).or_else(|| { + // The block ends the file with no final newline. + remove_appended_cargo_block(&lf, fragment.trim_end_matches('\n')) + })?; Some(if crlf { removed.replace('\n', "\r\n") } else { @@ -397,7 +408,10 @@ mod tests { #[test] fn table_form_line_is_dropped() { - assert_eq!(unpin_line(&format!("registry = \"{REG}\""), REG), Some(None)); + assert_eq!( + unpin_line(&format!("registry = \"{REG}\""), REG), + Some(None) + ); } #[test] @@ -406,8 +420,75 @@ mod tests { let hosted = format!( "{original}\n[registries.{REG}]\nindex = \"sparse+https://patch.socket.dev/x/index/\"\n" ); - assert_eq!(remove_registry_block(&hosted, REG).as_deref(), Some(original)); + assert_eq!( + remove_registry_block(&hosted, REG).as_deref(), + Some(original) + ); let created = format!("[registries.{REG}]\nindex = \"sparse+https://x/\"\n"); assert_eq!(remove_registry_block(&created, REG).as_deref(), Some("")); } + + const A: &str = "aaaaaaaa-0000-4000-8000-00000000000a"; + const B: &str = "bbbbbbbb-0000-4000-8000-00000000000b"; + const C: &str = "cccccccc-0000-4000-8000-00000000000c"; + + fn block(uuid: &str) -> String { + format!( + "[registries.socket-patch-{uuid}]\nindex = \"sparse+https://patch.socket.dev/patch-registry/cargo/tok/{uuid}/index/\"\n" + ) + } + + /// Restore cfg-if's hosted pin `B` (lock-free, so offline) over + /// `manifest` and `config`; the outcome and the config after (`None` + /// once deleted). + async fn restore_b( + manifest: &str, + config: &str, + ) -> (super::super::RestoreOutcome, Option) { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), manifest).unwrap(); + std::fs::create_dir_all(tmp.path().join(".cargo")).unwrap(); + std::fs::write(tmp.path().join(".cargo/config.toml"), config).unwrap(); + let pins = [super::super::HostedPin { + purl: "pkg:cargo/cfg-if@1.0.4".into(), + uuid: B.into(), + files: vec!["Cargo.toml".into(), ".cargo/config.toml".into()], + }]; + let opts = super::super::RestoreOptions { + offline: true, + ..Default::default() + }; + let outcome = super::super::restore_upstream(tmp.path(), &pins, &opts).await; + let config = std::fs::read_to_string(tmp.path().join(".cargo/config.toml")).ok(); + (outcome, config) + } + + fn manifest(extra: &str) -> String { + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ + cfg-if = {{ version = \"1.0.4\", registry = \"socket-patch-{B}\" }}\n{extra}" + ) + } + + /// #864: a superseded generation's block an older CLI left beside the + /// live pin's is referenced by nothing; restoring the live pin sweeps + /// it too, and the config the rewriter created is deleted once empty. + #[tokio::test] + async fn restore_sweeps_a_leftover_superseded_registry_block() { + let config = format!("{}\n{}", block(A), block(B)); + let (outcome, after) = restore_b(&manifest(""), &config).await; + assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins); + assert_eq!(after, None, "the emptied config must be deleted"); + } + + /// A generation the restore did not select that a manifest still pins + /// stays wired: only unreferenced blocks are swept. + #[tokio::test] + async fn restore_keeps_an_unselected_generation_still_referenced() { + let config = format!("{}\n{}", block(B), block(C)); + let other = format!("log = {{ version = \"0.4.20\", registry = \"socket-patch-{C}\" }}\n"); + let (outcome, after) = restore_b(&manifest(&other), &config).await; + assert_eq!(outcome.restored().count(), 1, "{:?}", outcome.pins); + assert_eq!(after.as_deref().map(str::trim_start), Some(block(C).as_str())); + } } From da19b8007d62af9c7085fafbe8da3bce53d1e218 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:23:12 -0400 Subject: [PATCH 07/11] Document and test remove/rollback of a superseded generation by uuid `remove ` and `rollback ` of a manifest record that a hosted scan superseded with patch B now unwind B's lockfile pin too (one owned pin per release). CLI_CONTRACT says so for both commands, and that the hosted restore can then refuse offline. The in-process test reuses the #933 fixture and drives both commands by the superseded uuid. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 9 +++-- .../tests/in_process_rollback_hosted.rs | 35 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 18a71f050..cd2eb2201 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -833,7 +833,12 @@ worse, lets a warm cache silently serve unpatched bytes): (purl or patch uuid; v5 keeps no hosted ledger) restores each matched pin to its default upstream registry entry — the same restore as `rollback` (see "Hosted unwind coverage"), for every ecosystem. A hosted-only match works with no manifest at all (mirroring the manifest-less - vendored escape). A pin the restore refuses (`--offline`, a registry that does not answer, + vendored escape). **One owned pin per release**: an identifier that matches a manifest entry + also selects the vendored ledger entry that entry claims and every hosted pin wiring the same + package release, whatever patch uuid (generation) each recorded — so `remove ` of a + record a later scan superseded with patch B (vendored or hosted) unwinds B's wiring too. That + hosted restore needs the registry lookups below, so such a remove can refuse offline where the + manifest entry alone would not. A pin the restore refuses (`--offline`, a registry that does not answer, `bun.lockb`, …) or a failed write is the top-level `hosted_revert_failed` error BEFORE the manifest mutation (exit 1, manifest not modified; message `could not restore to its upstream registry entry: … (`git checkout -- `)`). v4's `hosted_revert_unsupported` is no @@ -888,7 +893,7 @@ worse, lets a warm cache silently serve unpatched bytes): ### Targets -`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan): +`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); an identifier that matches a manifest entry also selects the vendored ledger entry that entry claims and every hosted pin of the same package release under any patch uuid (one owned pin per release — `remove` parity); an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan): * **A target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0).** Each rollback path pattern must select at least one patched package; the error names the pattern and the reachability rule. * **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.** diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5091adb59..75b5baeea 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1144,8 +1144,9 @@ async fn a_git_pattern_hosted_pin_is_refused_not_restored_to_the_registry() { assert!( envelope["hosted"]["failed"][0]["error"] .as_str() - .is_some_and(|e| e.contains("installs from git") - && e.contains("`git checkout -- yarn.lock`")), + .is_some_and( + |e| e.contains("installs from git") && e.contains("`git checkout -- yarn.lock`") + ), "{envelope}" ); assert_eq!( @@ -1568,3 +1569,33 @@ async fn remove_unhosts_a_package_whose_agent_record_is_superseded() { "the superseded record is reported:\n{envelope:#}" ); } + +/// B07: a remove/rollback identifier that names the superseded record's +/// uuid (A) selects the whole owned pin, so the hosted pin of the same +/// release under the superseding uuid (B) is unwound too, instead of being +/// left live with no record. +#[tokio::test] +#[serial] +async fn remove_and_rollback_by_superseded_record_uuid_unhost_the_release() { + for command in ["remove", "rollback"] { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_superseded_agent_fixture(tmp.path(), &server, B_PATCHED_INDEX).await; + + let (code, envelope) = if command == "remove" { + run_remove_subprocess_online(tmp.path(), &server, SUPERSEDED_UUID) + } else { + run_rollback_subprocess_online(tmp.path(), &server, &[SUPERSEDED_UUID]) + }; + assert_eq!(code, 0, "{command}:\n{envelope:#}"); + let restored = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert_eq!( + restored, pristine, + "{command} {SUPERSEDED_UUID} must unwind the hosted pin of the same release:\n{envelope:#}" + ); + assert!( + manifest_patch_keys(tmp.path()).is_empty(), + "{command}:\n{envelope:#}" + ); + } +} From c1b01529a293aae79c6999f9d493a7041b5a6200 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 17:29:20 +0000 Subject: [PATCH 08/11] Update the cargo supersede goldens for the dropped superseded registry This branch now removes a superseded generation's [registries.*] block on re-pin (d548f08), but the cargo/cargo/supersede redirect fixture and the VEX discovery golden still expected the old socket-patch-0000... block to survive, so redirect_golden and committed_fixture_corpus_matches_golden failed. Record the new config, the redirect_cargo_superseded_registry_removed edit, and regenerate the discovery golden with SOCKET_PATCH_UPDATE_GOLDEN. Co-Authored-By: Claude --- .../redirect/cargo/cargo/supersede/expected-edits.json | 6 ++++++ .../cargo/cargo/supersede/expected/.cargo/config.toml | 3 --- .../tests/fixtures/vex-discover-golden/redirect-cargo.json | 5 ----- 3 files changed, 6 insertions(+), 8 deletions(-) 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 index 5e6069559..ba3450561 100644 --- 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 @@ -21,5 +21,11 @@ "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\"" + }, + { + "path": ".cargo/config.toml", + "kind": "redirect_cargo_superseded_registry_removed", + "action": "removed", + "key": "socket-patch-00000000-0000-0000-0000-000000000000" } ] 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 index 6c84a472d..743fa5dcc 100644 --- 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 @@ -1,5 +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/" - [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/vex-discover-golden/redirect-cargo.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json index 3acf08caf..99be573f0 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-cargo.json @@ -416,11 +416,6 @@ ], "diagnostics": [], "recognized": [ - { - "uuid": "00000000-0000-0000-0000-000000000000", - "mode": "hosted", - "file": ".cargo/config.toml" - }, { "uuid": "11111111-1111-1111-1111-111111111111", "mode": "hosted", From 49c8d33321ffd6164afe9e85446dcb9b43786224 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 17:01:19 -0400 Subject: [PATCH 09/11] Index the pom's repositories once per Maven rewrite The Benchmarks job flagged maven/rescan at +9-11% wall and CPU on three consecutive heads of this branch. The supersede work added two per-pom lookups that each re-scanned the whole pom with the repository regex: the hosted-generation set (one scan per named generation) and the rotated-token URL refresh (one scan per patched dep, even on a no-op rescan). Read every 's id and url in one pass up front. The hosted generations come from that index (an exact `socket-patch-` id is the same set the per-uuid lookup produced), and while the pom is still unchanged the refresh is skipped unless the index shows exactly one repository with that id whose url differs, which is exactly when refresh_maven_repository_url would have rewritten it. Once the pom has changed this run, the refresh runs as before. Local compare against main (perf profile, 15 paired runs): maven/rescan +3.8% -> +0.6%; maven/hosted unchanged (+1.7%). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/mod.rs | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d29386141..f0d801896 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6817,18 +6817,16 @@ fn rewrite_maven_pom( // is an earlier generation of OUR pin. A vendored reactor / sbt pin // writes the same `-socket.` suffix under a // `socket-patch-vendor-` repository, which stays a mismatch. - let hosted_repo_generations: std::collections::BTreeSet = pom - .as_deref() - .map(|text| { - generation::named_generations(text) - .into_iter() - .filter(|uuid| { - !maven_repositories_with_id(text, &generation::hosted_pin_name(uuid)) - .is_empty() - }) - .collect() - }) - .unwrap_or_default(); + // + // One pass over the pom's repositories: `(id, url)` of each, which also + // answers the per-dep URL-refresh check below while the pom is still + // unchanged (a no-op rescan then never re-scans the pom per dep). + let original_repos: Vec<(String, Option)> = + pom.as_deref().map(maven_repository_ids_and_urls).unwrap_or_default(); + let hosted_repo_generations: std::collections::BTreeSet = original_repos + .iter() + .filter_map(|(id, _)| generation::pin_name_uuid(id, false).map(str::to_string)) + .collect(); let mut mvn_config = files.get(MVN_CONFIG).cloned().unwrap_or_default(); let mut mvn_config_changed = false; // (local-repo-relative path, bare sha256 hex) entries to merge in. @@ -7135,7 +7133,19 @@ fn rewrite_maven_pom( // A rotated grant token keeps the uuid but changes the repository // URL: refresh the existing `socket-patch-` repository in place // so the pin keeps resolving through the current grant. - if pin_landed || versioned.iter().any(|(_, _, v)| *v == suffixed_version) { + // While the pom is unchanged, `original_repos` already says whether + // the refresh would change anything (exactly one such repository, + // with a `` that differs); skip the scan when it would not. + let refresh_may_apply = pom_changed || { + let mut ours = original_repos.iter().filter(|(id, _)| *id == repo_id); + match (ours.next(), ours.next()) { + (Some((_, Some(url))), None) => *url != ov.index_url, + _ => false, + } + }; + if refresh_may_apply + && (pin_landed || versioned.iter().any(|(_, _, v)| *v == suffixed_version)) + { if let Some(next) = refresh_maven_repository_url(pom_text, &repo_id, &ov.index_url) { *pom_text = next; pom_changed = true; @@ -7285,12 +7295,28 @@ fn rewrite_maven_pom( } } +static MAVEN_REPOSITORY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s).*?").expect("static repository regex is valid") +}); + +/// The trimmed `` and `` texts of every `` element of +/// `pom` that has an ``, in document order: the same elements, ids and +/// URLs [`maven_repositories_with_id`] and [`refresh_maven_repository_url`] +/// read, in one pass. +fn maven_repository_ids_and_urls(pom: &str) -> Vec<(String, Option)> { + MAVEN_REPOSITORY_RE + .find_iter(pom) + .filter_map(|m| { + let id = maven_tag_text_in(pom, "id", m.start(), m.end())?; + let url = maven_tag_text_in(pom, "url", m.start(), m.end()); + Some((id, url)) + }) + .collect() +} + /// The `` elements of `pom` whose `` is `id`, as byte spans. pub(crate) fn maven_repositories_with_id(pom: &str, id: &str) -> Vec<(usize, usize)> { - static REPOSITORY_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?s).*?").expect("static repository regex is valid") - }); - REPOSITORY_RE + MAVEN_REPOSITORY_RE .find_iter(pom) .filter(|m| maven_tag_text_in(pom, "id", m.start(), m.end()).as_deref() == Some(id)) .map(|m| (m.start(), m.end())) From 0a9ae86b65f4e77784dfdcadeacf2b612c3701c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 21:07:04 +0000 Subject: [PATCH 10/11] Keep a live Maven pin's repository when its uuid shares the hex8 The superseded-repository cleanup removed every socket-patch- repository whose uuid starts with the old suffix's eight hex digits. The suffix carries only those eight digits, so another artifact's live `-socket.` pin from a different uuid with the same prefix lost its repository and stopped resolving. Skip the removal while any `-socket.` literal remains in the pom, and pin it with a two-artifact re-pin test. Co-Authored-By: Claude --- .../src/patch/redirect/mod.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f0d801896..0a1098f63 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7107,6 +7107,13 @@ fn rewrite_maven_pom( let hex8 = crate::formats::maven::split_socket_version(old) .map(|(_, h)| h) .unwrap_or_default(); + // The suffix carries only the uuid's first eight hex digits: a + // `-socket.` literal of another artifact still in the pom + // may be a different live pin whose uuid shares them, so its + // repository stays. + if pom_text.contains(&format!("-socket.{hex8}")) { + continue; + } for old_uuid in generation::named_generations(pom_text) { if old_uuid == dep.patch_uuid || !old_uuid.starts_with(hex8) { continue; @@ -22645,6 +22652,63 @@ mod owned_pin_generation_matrix { assert_eq!(checksums.lines().count(), 2, "{checksums}"); } + /// Another artifact's live pin whose uuid shares the superseded + /// generation's eight hex digits keeps its repository: the re-pin of + /// `commons-lang3` must not strand `commons-text`'s `-socket.`. + #[test] + fn maven_repin_keeps_a_live_pin_sharing_the_hex8() { + let tok = "22222222-3333-4444-8555-666666666666"; + let twin = "aaaaaaaa-1111-4000-8000-00000000001a"; + let mut text = maven(twin, tok); + let to_text = |s: &str| { + s.replace("commons-lang3", "commons-text") + .replace("3.12.0", "1.10.0") + }; + text.name = "commons-text".into(); + text.version = "1.10.0".into(); + text.artifact_url = to_text(&text.artifact_url); + let ro = text.registry_override.as_mut().unwrap(); + ro.identifiers.name = to_text(&ro.identifiers.name); + ro.identifiers.version = "1.10.0".into(); + ro.identifiers.maven_artifact_id = Some("commons-text".into()); + ro.identifiers.maven_suffixed_version = Some("1.10.0-socket.aaaaaaaa".into()); + + let mut files = pom_project(); + let pom = files["pom.xml"].replace( + " \n", + " \n org.apache.commons\n \ + commons-text\n 1.10.0\n \ + \n \n", + ); + files.insert("pom.xml".into(), pom); + let (wired, _) = plan(&files, &text); + let (wired, _) = plan(&wired, &maven(A, tok)); + let wired_pom = &wired["pom.xml"]; + assert!( + wired_pom.contains(&format!("socket-patch-{twin}")), + "{wired_pom}" + ); + assert!( + wired_pom.contains(&format!("socket-patch-{A}")), + "{wired_pom}" + ); + + let (repinned, _) = plan(&wired, &maven(B, tok)); + let pom = &repinned["pom.xml"]; + assert!( + pom.contains("3.12.0-socket.bbbbbbbb"), + "{pom}" + ); + assert!( + pom.contains("1.10.0-socket.aaaaaaaa"), + "{pom}" + ); + assert!( + pom.contains(&format!("socket-patch-{twin}")), + "the live twin pin lost its repository:\n{pom}" + ); + } + /// A `-socket.` literal that no hosted `socket-patch-` /// repository minted (a vendored reactor / sbt pin, served from a /// `socket-patch-vendor-` repository) is not an earlier hosted From ddedc7c49d9dc32c0e096f79cf7dd5db4f72bcca Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 21:51:24 -0400 Subject: [PATCH 11/11] Drop a superseded Maven generation's checksums when its hex8 is still pinned The hex8-sharing guard skipped the whole superseded-version cleanup, so a re-pin that kept another artifact's -socket. literal also left the abandoned generation's jar and pom entries in the trusted checksums file. Those entries are keyed by the artifact's own GAV and cannot belong to the twin pin, so only the repository removal is now guarded. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-core/src/patch/redirect/mod.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 808b5ab06..2cbbe03ab 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -7136,12 +7136,11 @@ fn rewrite_maven_pom( // The suffix carries only the uuid's first eight hex digits: a // `-socket.` literal of another artifact still in the pom // may be a different live pin whose uuid shares them, so its - // repository stays. - if pom_text.contains(&format!("-socket.{hex8}")) { - continue; - } + // repository stays. The trusted checksums below are keyed by this + // artifact's own GAV, so they go either way. + let hex8_still_pinned = pom_text.contains(&format!("-socket.{hex8}")); for old_uuid in generation::named_generations(pom_text) { - if old_uuid == dep.patch_uuid || !old_uuid.starts_with(hex8) { + if hex8_still_pinned || old_uuid == dep.patch_uuid || !old_uuid.starts_with(hex8) { continue; } let old_id = generation::hosted_pin_name(&old_uuid); @@ -22870,6 +22869,12 @@ mod owned_pin_generation_matrix { pom.contains(&format!("socket-patch-{twin}")), "the live twin pin lost its repository:\n{pom}" ); + // The superseded generation's own trusted checksums are keyed by its + // GAV, so they go even though the hex8-sharing repository stays. + let checksums = &repinned[MVN_CHECKSUMS]; + assert!(!checksums.contains("3.12.0-socket.aaaaaaaa"), "{checksums}"); + assert!(checksums.contains("3.12.0-socket.bbbbbbbb"), "{checksums}"); + assert!(checksums.contains("1.10.0-socket.aaaaaaaa"), "{checksums}"); } /// A `-socket.` literal that no hosted `socket-patch-`