diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index fbc0cd5e0..a0f484214 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -844,7 +844,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 @@ -899,7 +904,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; PyPI names compare by their PEP 503 canonical form, so `pkg:pypi/typing_extensions@4.7.1` selects `pkg:pypi/typing-extensions@4.7.1`), 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; PyPI names compare by their PEP 503 canonical form, so `pkg:pypi/typing_extensions@4.7.1` selects `pkg:pypi/typing-extensions@4.7.1`), 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.** @@ -934,7 +939,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/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..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; @@ -1248,16 +1247,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() @@ -2817,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-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 50bfe7b42..75b5baeea 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1569,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:#}" + ); + } +} 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 582ca464f..00a86caa5 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -27,9 +27,11 @@ 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::purl::{normalize_purl, patch_matches, strip_purl_qualifiers}; use crate::vendor::{VendorEntry, VendorState}; +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)] @@ -129,6 +131,33 @@ impl Matches { } } +/// 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(&canonical_base_purl(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 +325,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 +344,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)); @@ -575,4 +619,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()); + } } 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..a1889db08 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/generation.rs @@ -0,0 +1,150 @@ +//! 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 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 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; + +/// 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. +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 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}\""); + 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 075f630b4..e14ac1610 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 @@ -286,6 +286,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 efbac91ba..21ecc2b37 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; @@ -1517,6 +1518,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 { @@ -1579,7 +1583,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 @@ -1799,6 +1803,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") @@ -2186,27 +2230,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 @@ -5591,7 +5619,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 @@ -6812,10 +6840,28 @@ 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. + // + // 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. 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 @@ -6861,7 +6907,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 @@ -6984,33 +7030,50 @@ 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, 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, 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. + 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!( @@ -7062,6 +7125,75 @@ 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(); + // 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. 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 hex8_still_pinned || 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. + // 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; + 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, @@ -7130,16 +7262,31 @@ 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(); + // 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" } else { "added" }; + let kept: String = existing_checksums + .split('\n') + .filter(|l| !names_dropped_path(l)) + .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(), @@ -7182,6 +7329,72 @@ 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)> { + 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())) + .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 @@ -7587,6 +7800,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 { @@ -12271,6 +12514,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() @@ -22270,3 +22529,424 @@ 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}"); + } + + /// 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}" + ); + // 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-` + /// 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] + 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 70ca86a6d..54d4c8030 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, @@ -218,29 +216,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 { @@ -251,7 +261,6 @@ pub(crate) async fn restore( } } } - let _ = by_uuid; result } @@ -312,7 +321,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}]"); @@ -418,4 +427,68 @@ mod tests { 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())); + } } 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..99e1dc4b2 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/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 4056ea30b..9f4d9d387 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 c880b5afa..b9200e64f 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -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 ba855e20b..ef3a3cf03 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -576,7 +576,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` @@ -783,7 +783,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) }) }) @@ -797,7 +797,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) }) } @@ -806,9 +806,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) { @@ -1091,7 +1091,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 @@ -1647,12 +1647,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: @@ -1685,7 +1686,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 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 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", diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 0c1217836..7566271d8 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -436,7 +436,13 @@ 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 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. 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 — @@ -971,6 +977,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.