diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0289bf946..58308c5a0 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -34,9 +34,9 @@ use socket_patch_core::utils::group_commit::{CommittedFile, GroupCommit}; use socket_patch_core::utils::purl::{canonical_purl, normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::socket_dir::remove_tree_and_prune; use socket_patch_core::vendor::{ - self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, save_state, - save_state_shared, PackageSource, RevertOpts, RevertOutcome, VendorEntry, VendorOutcome, - VendorServiceConfig, VendorState, VendorWarning, + self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, lookup_entry_kv, + save_state, save_state_shared, PackageSource, RevertOpts, RevertOutcome, VendorEntry, + VendorOutcome, VendorServiceConfig, VendorState, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; use std::collections::{HashMap, HashSet}; @@ -2010,6 +2010,24 @@ impl StagedSource { } } +/// Whether an installed copy that fails `candidate`'s variant probe is +/// still `candidate` itself, superseded: the ledger vendored exactly this +/// package at an OLDER patch uuid (#769), so the venv most likely holds +/// that patch's bytes (`pipenv sync` from the vendored wheel), which are +/// neither the pristine release nor this patch's output. The re-vendor +/// then takes the pristine artifact from the lock / registry / service, as +/// a lock-only checkout does, instead of reporting it not installed. +fn superseded_install( + ledger: &VendorState, + candidate: &str, + record: &PatchRecord, + sole_candidate: bool, +) -> bool { + lookup_entry_kv(&ledger.entries, candidate).is_some_and(|(key, entry)| { + entry.uuid != record.uuid && (sole_candidate || key == candidate) + }) +} + #[allow(clippy::too_many_arguments)] async fn plan_service_downloads( cwd: &Path, @@ -2026,6 +2044,8 @@ async fn plan_service_downloads( &vendor::pypi::InstalledSiteListings, ), ) -> Vec { + // The loop's stand-in for a superseded install (see there). + let uninstalled = cwd.join(".socket/vendor/.uninstalled"); // Each loop candidate that reaches its backend, in loop order. let mut reaching: Vec<(&str, &PatchRecord, &Path)> = Vec::new(); let mut handled_bases: HashSet = HashSet::new(); @@ -2057,6 +2077,7 @@ async fn plan_service_downloads( && !matches!(Ecosystem::from_purl(candidate), Some(Ecosystem::Maven)); let ledger_answers_probe = lookup_entry(&ledger.entries, candidate).is_some_and(|e| e.uuid == record.uuid); + let mut source_path = source.path(); if probe_applicable && !force && !ledger_answers_probe { if matches!(staged, StagedSource::Installed(_)) { if let Some((file, info)) = representative_file(&record.files) { @@ -2064,7 +2085,11 @@ async fn plan_service_downloads( if !variant_matches_installed(Some( &verify_file_patch(dir, file, info).await.status, )) { - continue; + if !superseded_install(ledger, candidate, record, candidates.len() == 1) + { + continue; + } + source_path = &uninstalled; } } } else if candidates.len() > 1 && lookup_entry(&ledger.entries, candidate).is_none() @@ -2102,7 +2127,7 @@ async fn plan_service_downloads( if lookup_entry(&ledger.entries, candidate).is_some_and(|e| e.uuid == record.uuid) { continue; } - reaching.push((candidate.as_str(), record, source.path())); + reaching.push((candidate.as_str(), record, source_path)); } } @@ -2459,6 +2484,8 @@ pub(crate) async fn vendor_records_reusing( && !socket_patch_core::utils::failpoint::switched_off("group_commit")) .then(|| GroupCommit::begin(&common.cwd)); let mut stale_artifacts: Vec = Vec::new(); + // The source of a superseded install (see [`superseded_install`]). + let uninstalled = common.cwd.join(".socket/vendor/.uninstalled"); for (index, (purl, staged)) in all_packages.iter().enumerate() { let pkg_source = staged.as_source(); let is_variant_eco = @@ -2500,6 +2527,7 @@ pub(crate) async fn vendor_records_reusing( // without downloading the pristine tree just to read one file. let ledger_answers_probe = lookup_entry(&state.entries, candidate).is_some_and(|e| e.uuid == record.uuid); + let mut candidate_source = pkg_source; if probe_applicable && !force && !ledger_answers_probe { if matches!(staged, StagedSource::Installed(_)) { if let Some((file, info)) = representative_file(&record.files) { @@ -2508,7 +2536,11 @@ pub(crate) async fn vendor_records_reusing( .await .status, )) { - continue; + if !superseded_install(&state, candidate, record, candidates.len() == 1) + { + continue; + } + candidate_source = PackageSource::Installed(&uninstalled); } } } else if candidates.len() > 1 && lookup_entry(&state.entries, candidate).is_none() @@ -2815,7 +2847,7 @@ pub(crate) async fn vendor_records_reusing( )); let outcome = dispatch_vendor_one( candidate, - pkg_source, + candidate_source, &common.cwd, record, sources, diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b8c92adbe..505fae033 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -456,6 +456,94 @@ async fn pipenv_vendored_to_hosted() { assert_vendored_to_hosted(&root, files).await; } +/// A superseding patch for the same release (a fixed patch, or one +/// covering more CVEs). +const UUID_B: &str = "6d4f2b3c-8e5a-4f7b-9c9d-2e3f4a5b6c7d"; +const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; + +/// A virtualenv whose six was installed from the vendored wheel of patch +/// A (`pipenv sync` after the first vendor): its files are A's patched +/// bytes, not the pristine release patch B is diffed against. +fn venv_installed_from_patch_a(venv: &Path) { + let site = venv.join("lib/python3.11/site-packages"); + let dist = site.join("six-1.16.0.dist-info"); + std::fs::create_dir_all(&dist).unwrap(); + std::fs::write(site.join("six.py"), PATCHED).unwrap(); + std::fs::write( + dist.join("METADATA"), + "Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\n", + ) + .unwrap(); + std::fs::write( + dist.join("WHEEL"), + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n", + ) + .unwrap(); + std::fs::write(dist.join("INSTALLER"), "pip\n").unwrap(); + std::fs::write( + dist.join("RECORD"), + "six.py,,\nsix-1.16.0.dist-info/METADATA,,\nsix-1.16.0.dist-info/WHEEL,,\nsix-1.16.0.dist-info/INSTALLER,,\nsix-1.16.0.dist-info/RECORD,,\n", + ) + .unwrap(); +} + +/// #769: a Pipenv project vendored at patch A moves to the superseding +/// patch B when the manifest offers it, as the `would_revendor` preview +/// and the CLI contract promise, whether the checkout is lock-only or its +/// venv was installed from A's vendored wheel. Pipfile.lock is rewired to +/// B, A's artifact is swept, and reverting B restores the registry pin. +#[tokio::test] +async fn pipenv_revendors_to_a_superseding_patch() { + for venv_present in [false, true] { + let lane = if venv_present { + "venv from A" + } else { + "lock-only" + }; + let (_tmp, root) = project(); + stage_pipenv(&root); + let registry = std::fs::read_to_string(root.join("Pipfile.lock")).unwrap(); + vendor_project(&root, &["Pipfile.lock"]); + + let venv = root.join("../patched-venv"); + let mut extra: Vec<(&str, &str)> = Vec::new(); + if venv_present { + venv_installed_from_patch_a(&venv); + extra.push(("VIRTUAL_ENV", venv.to_str().unwrap())); + } + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli(&root, &["vendor"], &extra); + assert_eq!(code, 0, "{lane}: re-vendor to B: {env:#}"); + assert!( + env.to_string().contains("vendor_stale_artifact_removed"), + "{lane}: A's artifact is swept: {env:#}" + ); + let lock = std::fs::read_to_string(root.join("Pipfile.lock")).unwrap(); + assert!( + lock.contains(&format!(".socket/vendor/pypi/{UUID_B}/")) && !lock.contains(UUID), + "{lane}: Pipfile.lock is rewired to B:\n{lock}" + ); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); + let wheels: Vec<_> = std::fs::read_dir(root.join(format!(".socket/vendor/pypi/{UUID_B}"))) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".whl")) + .collect(); + assert_eq!(wheels.len(), 1, "{lane}: B's wheel is vendored"); + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &extra); + assert_eq!(code, 0, "{lane}: revert B: {env:#}"); + let reverted: Value = + serde_json::from_str(&std::fs::read_to_string(root.join("Pipfile.lock")).unwrap()) + .unwrap(); + let registry: Value = serde_json::from_str(®istry).unwrap(); + assert_eq!( + reverted, registry, + "{lane}: revert restores the registry pin" + ); + } +} + const UV_LOCK: &str = r#"version = 1 revision = 2 requires-python = ">=3.9" diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 2c8adc2c5..9d3d03935 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -120,8 +120,8 @@ pub use source::PackageSource; pub(crate) use npm_common::is_safe_npm_name; pub use pypi_requirements::requirements_include_names; pub use state::{ - carry_forward_wiring, load_state, lookup_entry, purl_keys_cover, save_state, save_state_shared, - VendorEntry, VendorState, VENDOR_STATE_REL, + carry_forward_wiring, load_state, lookup_entry, lookup_entry_kv, purl_keys_cover, save_state, + save_state_shared, VendorEntry, VendorState, VENDOR_STATE_REL, }; pub use verify::{ artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 9ebf3be86..be1f3d85e 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -430,7 +430,9 @@ enum WiringPlan { Hatch(super::pypi_hatch::HatchProject), Poetry(Box), Pdm(Box), - Pipenv(Box), + /// The ledger entry of an OLDER patch uuid whose Pipfile.lock wiring the + /// guards admitted for an in-place re-wire (#769), if any. + Pipenv(Box, Option>), /// The lock already routes this package through THIS patch uuid's /// vendored wheel: no wiring — verify (or rebuild) the artifact only. InSync, @@ -869,12 +871,25 @@ async fn pypi_prelude<'p>( ), )); } - let target = match super::pypi_pipenv::check_target_guards( + // A superseding patch (#769): the ledger entry that wired this + // package at an older uuid holds the pre-vendor originals the + // re-wire carries forward. An unreadable ledger leaves none, and + // the guards then refuse the re-wire as before. + let superseded = super::state::load_state_shared(project_root) + .await + .ok() + .and_then(|state| { + super::state::lookup_entry(&state.entries, base) + .filter(|entry| entry.uuid != record.uuid) + .cloned() + }); + let target = match super::pypi_pipenv::check_target_guards_superseding( &project, &canon_name, &record.uuid, version, hosted_origins, + superseded.as_ref(), ) { Ok(target) => target, // A refusal carries no warnings: probe nothing for it. @@ -901,7 +916,9 @@ async fn pypi_prelude<'p>( wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel); WiringPlan::InSync } - PipenvTarget::Fresh => WiringPlan::Pipenv(Box::new(project)), + PipenvTarget::Fresh => { + WiringPlan::Pipenv(Box::new(project), superseded.map(Box::new)) + } } } }; @@ -1308,7 +1325,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( .await .map(|(wiring, meta)| (wiring, MetaSlot::Pdm(meta))) } - WiringPlan::Pipenv(project) => super::pypi_pipenv::wire_pipenv( + WiringPlan::Pipenv(project, superseded) => super::pypi_pipenv::wire_pipenv_superseding( &project, project_root, &canon_name, @@ -1317,6 +1334,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( &artifact.sha256_hex, &record.uuid, &hosted_origins, + superseded.as_deref(), ) .await .map(|(wiring, meta)| (wiring, MetaSlot::Pipenv(meta))), @@ -4487,6 +4505,161 @@ wheels = [ } "#; + /// A Pipenv project (Pipfile.lock, no requirements.txt) over the + /// [`e2e_fixture`] install and blob store. + async fn pipenv_e2e_fixture() -> E2eFixture { + let fx = e2e_fixture().await; + tokio::fs::remove_file(fx.root.join("requirements.txt")) + .await + .unwrap(); + touch(&fx.root, "Pipfile.lock", PIPENV_REGISTRY_LOCK).await; + fx + } + + /// Vendor `record` into the [`pipenv_e2e_fixture`] project. + async fn pipenv_vendor(fx: &E2eFixture, record: &PatchRecord) -> VendorOutcome { + let sources = PatchSources::blobs_only(&fx.blobs); + crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await + } + + async fn read_json(root: &Path, name: &str) -> serde_json::Value { + serde_json::from_str(&tokio::fs::read_to_string(root.join(name)).await.unwrap()).unwrap() + } + + /// #769: a Pipfile.lock wired to an EARLIER patch uuid re-vendors in + /// place to the superseding uuid, as the `would_revendor` preview and + /// the CLI contract promise: the entry moves to the new wheel, its + /// record carries the pre-vendor registry original forward, and + /// `vendor --revert` of the NEW entry restores the registry pin. + #[tokio::test] + async fn pipenv_superseding_uuid_revendors_in_place() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_e2e_fixture().await; + let registry = read_json(&fx.root, "Pipfile.lock").await; + let VendorOutcome::Done { result, entry, .. } = pipenv_vendor(&fx, &fx.record).await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let first = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &first).await; + + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = pipenv_vendor(&fx, &record2).await; + let VendorOutcome::Done { result, entry, .. } = outcome else { + panic!("superseding uuid must re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + let second = entry.expect("entry on success"); + assert_eq!(second.uuid, UUID2); + assert_eq!(second.wiring.len(), 1); + assert_eq!(second.wiring[0].key.as_deref(), Some("default:six")); + assert_eq!( + second.wiring[0].original, + Some(registry["default"]["six"].clone()), + "the pre-vendor registry original is carried forward" + ); + let lock = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + assert!(!lock.contains(UUID), "Pipfile.lock kept uuid A:\n{lock}"); + assert!(lock.contains(UUID2), "Pipfile.lock not on uuid B:\n{lock}"); + assert!(fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}/{WHEEL_NAME}")) + .is_file()); + + save_ledger_entry(&fx.root, &second).await; + let reverted = revert_pypi(&second, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings); + assert_eq!(read_json(&fx.root, "Pipfile.lock").await, registry); + } + + /// #769: without a ledger entry for the older uuid there is no recorded + /// pre-vendor original to carry forward, so a re-wire could never be + /// reverted. That case still refuses, before anything is written. + #[tokio::test] + async fn pipenv_superseding_uuid_without_ledger_refuses() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_e2e_fixture().await; + let VendorOutcome::Done { result, .. } = pipenv_vendor(&fx, &fx.record).await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + let wired = tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(); + + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = pipenv_vendor(&fx, &record2).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_pipenv_source_already_exists"); + assert!(detail.contains(UUID), "{detail}"); + assert!(detail.contains("records no wiring"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(), + wired + ); + assert!(!fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}")) + .exists()); + } + + /// #769: a re-wire replays only what the older entry's ledger recorded. + /// A wired entry edited since vendoring refuses before anything is + /// written. + #[tokio::test] + async fn pipenv_superseding_uuid_drifted_entry_refuses() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = pipenv_e2e_fixture().await; + let VendorOutcome::Done { result, entry, .. } = pipenv_vendor(&fx, &fx.record).await else { + panic!("first vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + save_ledger_entry(&fx.root, &entry.expect("entry on success")).await; + let mut lock = read_json(&fx.root, "Pipfile.lock").await; + lock["default"]["six"]["markers"] = serde_json::json!("python_version >= '3.8'"); + let drifted = serde_json::to_string_pretty(&lock).unwrap() + "\n"; + touch(&fx.root, "Pipfile.lock", &drifted).await; + + let mut record2 = fx.record.clone(); + record2.uuid = UUID2.to_string(); + let outcome = pipenv_vendor(&fx, &record2).await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_pipenv_source_already_exists"); + assert!(detail.contains("changed since vendoring"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("Pipfile.lock")) + .await + .unwrap(), + drifted + ); + assert!(!fx + .root + .join(format!(".socket/vendor/pypi/{UUID2}")) + .exists()); + } + /// A relock regenerated the wired entry to a registry reference whose /// hash list differs from the recorded original (Pipenv 2022.12.19 does /// exactly this; 2026.x reproduces the original and converges silently): diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index ee4ff198a..cbb38c31c 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -156,19 +156,38 @@ pub(super) async fn load_pipenv_project( }) } -/// Target-specific guards (also re-run by [`wire_pipenv`] right before +/// Target-specific guards (also re-run by the wire step right before /// writing). Entries match by PEP 503 canonical NAME in every package /// category. Registry pins and existing vendored wheel identities /// must both match the selected patch version. `hosted_origins` are the /// run's `--patch-server-url` origins: a hosted reference on one of them /// (or on patch.socket.dev) is refused with the rollback remedy, any other -/// file reference as user-declared. +/// file reference as user-declared. Production callers pass the ledger +/// through [`check_target_guards_superseding`]. +#[cfg(test)] pub(super) fn check_target_guards( p: &PipenvProject, canon_name: &str, record_uuid: &str, version: &str, hosted_origins: &[String], +) -> Result { + check_target_guards_superseding(p, canon_name, record_uuid, version, hosted_origins, None) +} + +/// The target guards, given `superseded`: the vendor-ledger entry +/// that wired this package at an OLDER patch uuid. An entry still routed +/// through that uuid's wheel is a superseding patch (#769): it re-wires in +/// place when the ledger records exactly what was written there and the +/// pre-vendor original to carry forward. Without that record it refuses, as +/// a re-wire with no recorded original could never be reverted. +pub(super) fn check_target_guards_superseding( + p: &PipenvProject, + canon_name: &str, + record_uuid: &str, + version: &str, + hosted_origins: &[String], + superseded: Option<&VendorEntry>, ) -> Result { let entries = find_entries(&p.lock, canon_name); if entries.is_empty() { @@ -196,12 +215,7 @@ pub(super) fn check_target_guards( match parse_vendor_path(file_ref) { // Ours, same patch generation. Some(parts) if parts.eco == "pypi" && parts.uuid == record_uuid => { - let filename = file_ref.rsplit('/').next().unwrap_or(""); - let mut fields = filename.split('-'); - let matches_identity = fields - .next() - .is_some_and(|name| canonicalize_pypi_name(name) == canon_name) - && fields.next() == Some(version); + let matches_identity = wheel_identity_matches(file_ref, canon_name, version); let conflicting_source = NON_REGISTRY_KEYS .iter() .filter(|key| **key != "path") @@ -217,19 +231,35 @@ pub(super) fn check_target_guards( "vendored wheel identity or source changed".into(), )); } - // Ours, but a STALE patch generation: wiring over it would - // lose the only recorded registry original — refuse with the - // repair path (mirrors gem's stale-checksum refusal). + // Ours, but an OLDER patch generation: a superseding patch + // (#769). Re-wire it in place when the ledger entry of that + // uuid recorded this very entry and its pre-vendor original + // (carried forward by the wire step); otherwise wiring over + // it would lose the only registry original — refuse with the + // repair path. Some(parts) if parts.eco == "pypi" => { + let why = match superseded_record(superseded, &parts.uuid, section, key) { + None => " and the vendor ledger records no wiring for it to carry over", + Some(rec) if rec.new.as_ref() != Some(*entry) => { + " and the entry changed since vendoring" + } + Some(_) if !wheel_identity_matches(file_ref, canon_name, version) => { + " for another release" + } + Some(_) => { + all_in_sync = false; + continue; + } + }; return Err(( "pypi_pipenv_source_already_exists", format!( "{LOCK_FILE} already routes {section}.{key} through \ - .socket/vendor/pypi/{} (an earlier socket-patch vendor); run \ + .socket/vendor/pypi/{} (an earlier socket-patch vendor){why}; run \ `socket-patch vendor --revert` for it and re-vendor", parts.uuid ), - )) + )); } // Socket's own HOSTED reference (`scan --mode hosted`): the // two modes do not take each other over for Pipenv yet — name @@ -280,6 +310,36 @@ pub(super) fn check_target_guards( }) } +/// Whether a vendored wheel reference names `canon_name` at `version` (the +/// wheel filename's leading `-` fields). +fn wheel_identity_matches(file_ref: &str, canon_name: &str, version: &str) -> bool { + let filename = file_ref.rsplit('/').next().unwrap_or(""); + let mut fields = filename.split('-'); + fields + .next() + .is_some_and(|name| canonicalize_pypi_name(name) == canon_name) + && fields.next() == Some(version) +} + +/// The record `superseded` (the ledger entry at the older `uuid`) holds for +/// the `section`/`key` lock entry, when it carries a pre-vendor original. +fn superseded_record<'e>( + superseded: Option<&'e VendorEntry>, + uuid: &str, + section: &str, + key: &str, +) -> Option<&'e WiringRecord> { + let prev = superseded.filter(|prev| prev.ecosystem == "pypi" && prev.uuid == uuid)?; + let wanted = format!("{section}:{key}"); + prev.wiring.iter().find(|rec| { + rec.file == LOCK_FILE + && rec.kind == KIND_LOCK_ENTRY + && rec.action == WiringAction::Rewritten + && rec.key.as_deref() == Some(wanted.as_str()) + && rec.original.is_some() + }) +} + /// Wire Pipfile.lock for the vendored wheel: replace every matching entry /// in every package category (all keys but `_meta`) with the spike-captured /// file-ref shape (`path` instead of `file` when the entry carries extras; @@ -287,6 +347,8 @@ pub(super) fn check_target_guards( /// pinned pipenv serialization). `rel_wheel` is the project-relative wheel /// path (`.socket/vendor/pypi//`, no `./` prefix — the /// fixture's `./` spelling is applied here). +/// Production callers pass the ledger through [`wire_pipenv_superseding`]. +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub(super) async fn wire_pipenv( p: &PipenvProject, @@ -297,10 +359,48 @@ pub(super) async fn wire_pipenv( wheel_sha256_hex: &str, record_uuid: &str, hosted_origins: &[String], +) -> Result<(Vec, PipenvMeta), (&'static str, String)> { + wire_pipenv_superseding( + p, + root, + canon_name, + version, + rel_wheel, + wheel_sha256_hex, + record_uuid, + hosted_origins, + None, + ) + .await +} + +/// Wire Pipfile.lock as `wire_pipenv` does. Entries `superseded` (the +/// ledger entry of an OLDER patch uuid, see +/// [`check_target_guards_superseding`]) wired are re-wired in place, and +/// each record carries that entry's pre-vendor original forward, so +/// reverting the new entry restores the registry pin. +#[allow(clippy::too_many_arguments)] +pub(super) async fn wire_pipenv_superseding( + p: &PipenvProject, + root: &Path, + canon_name: &str, + version: &str, + rel_wheel: &str, + wheel_sha256_hex: &str, + record_uuid: &str, + hosted_origins: &[String], + superseded: Option<&VendorEntry>, ) -> Result<(Vec, PipenvMeta), (&'static str, String)> { // Before ANY write: a symlinked lock would be replaced by the rename-over. refuse_symlinked(root, &[LOCK_FILE], "pypi_pipenv_symlink_unsupported").await?; - match check_target_guards(p, canon_name, record_uuid, version, hosted_origins)? { + match check_target_guards_superseding( + p, + canon_name, + record_uuid, + version, + hosted_origins, + superseded, + )? { // Defensive: the orchestrator short-circuits in-sync pre-flight and // never calls wire on it (we must never re-record our own edit as an // "original"). @@ -364,22 +464,29 @@ pub(super) async fn wire_pipenv( continue; } // Never record one of our own edits as the "original" — revert - // must restore the pre-vendor registry fragment (a vendor-pointing - // old entry can only reach here through a same-uuid hash refresh; - // stale uuids refuse in the guards). - let was_vendored = old + // must restore the pre-vendor registry fragment. A vendor-pointing + // old entry reaches here through a same-uuid hash refresh (the + // CLI carries the original forward from the ledger) or as a + // superseded uuid the guards admitted only with a recorded + // original, carried forward here. + let vendored_uuid = old .get("file") .or_else(|| old.get("path")) .and_then(Value::as_str) .and_then(parse_vendor_path) - .is_some(); + .map(|parts| parts.uuid); + let original = match vendored_uuid { + None => Some(old), + Some(uuid) => superseded_record(superseded, &uuid, §ion, &key) + .and_then(|rec| rec.original.clone()), + }; map.insert(key.clone(), new_value.clone()); wiring.push(WiringRecord { file: LOCK_FILE.to_string(), kind: KIND_LOCK_ENTRY.to_string(), action: WiringAction::Rewritten, key: Some(format!("{section}:{key}")), - original: if was_vendored { None } else { Some(old) }, + original, new: Some(new_value), }); if !sections.iter().any(|s| s == §ion) {