From a3d9d220d9386e7d14c718273f8b28877ed7c7ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:30:10 +0000 Subject: [PATCH 1/6] Start fix for #769 Assisted-by: Claude Code:claude-opus-5-5 From 4fc3896e84eeed4b27a45f5ce5812f34292e571e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:44:31 +0000 Subject: [PATCH 2/6] Re-vendor Pipenv locks to a newer patch A Pipenv project vendored at one patch never moved to a newer patch for the same package: the re-vendor refused with pypi_pipenv_source_already_exists and the run exited 1, although the dry run previewed would_revendor. When the vendor ledger records the Pipfile.lock entry the older patch wrote, and that entry is unchanged, it is now rewired in place to the new wheel. The record carries the older entry's pre-vendor registry original forward, so vendor --revert still restores the user's pin. Without that record, or after an edit, it still refuses as before. Refs #769 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 181 +++++++++++++++++- .../src/vendor/pypi_pipenv.rs | 138 +++++++++++-- 2 files changed, 297 insertions(+), 22 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 74883c23e..9fe855cd4 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -426,7 +426,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, @@ -862,12 +864,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. @@ -888,7 +903,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)) + } } } }; @@ -1285,7 +1302,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, @@ -1294,6 +1311,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))), @@ -4208,6 +4226,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 6665ebb8c..dd0c2c577 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -169,6 +169,23 @@ pub(super) fn check_target_guards( record_uuid: &str, version: &str, hosted_origins: &[String], +) -> Result { + check_target_guards_superseding(p, canon_name, record_uuid, version, hosted_origins, None) +} + +/// [`check_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 +213,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 +229,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 +308,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; @@ -297,10 +355,47 @@ 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_pipenv`] over entries `superseded` (the ledger entry of an OLDER +/// patch uuid, see [`check_target_guards_superseding`]) wired: each one is +/// re-wired in place and its 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 +459,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) { From ee2a74198e2269da890e9ac4a12c91c22775fc03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:48:25 +0000 Subject: [PATCH 3/6] Re-vendor PyPI installs from an older patch When a venv was installed from the vendored wheel of an older patch (pipenv sync after vendoring), re-vendoring to a newer patch skipped the package as package_not_installed and exited 1: the installed files are the old patch's bytes, so they failed the new patch's installed-variant check. When the vendor ledger holds exactly this package at an older patch uuid, such an install is now treated like a lock-only checkout: the pristine wheel comes from the lock, registry or patch service, and the package is re-vendored. The service download plan makes the same call. Fixes #769 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 46 ++++++-- .../tests/mode_migration_pypi.rs | 110 +++++++++++++++++- crates/socket-patch-core/src/vendor/mod.rs | 4 +- 3 files changed, 145 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 59be95b85..0412d2e99 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::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}; @@ -1832,6 +1832,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, @@ -1848,6 +1866,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(); @@ -1879,6 +1899,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) { @@ -1886,7 +1907,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() @@ -1920,7 +1945,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)); } } @@ -2274,6 +2299,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 = @@ -2315,6 +2342,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) { @@ -2323,7 +2351,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() @@ -2606,7 +2638,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 b888a96c4..fb3ef1023 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -63,9 +63,14 @@ fn hosted_wheel() -> Vec { /// Stage `.socket/manifest.json` + the after-hash blob so `vendor` builds /// the vendored wheel from the prebuilt fixture server, offline. fn stage_manifest(root: &Path) { - let after = compute_git_sha256_from_bytes(PATCHED); + stage_manifest_with(root, UUID, PATCHED); +} + +/// [`stage_manifest`] for patch `uuid` producing `patched` bytes. +fn stage_manifest_with(root: &Path, uuid: &str, patched: &[u8]) { + let after = compute_git_sha256_from_bytes(patched); let manifest = json!({ "patches": { PURL: { - "uuid": UUID, + "uuid": uuid, "exportedAt": "2026-01-01T00:00:00Z", "files": { "six.py": { "beforeHash": compute_git_sha256_from_bytes(ORIG), @@ -83,7 +88,7 @@ fn stage_manifest(root: &Path) { serde_json::to_vec_pretty(&manifest).unwrap(), ) .unwrap(); - std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap(); + std::fs::write(socket.join("blobs").join(after), patched).unwrap(); } /// The built binary with every ambient `SOCKET_*` var scrubbed. An EMPTY @@ -326,9 +331,8 @@ async fn poetry_vendored_to_hosted() { const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[requires]\npython_version = \"3.11\"\n"; -#[tokio::test] -async fn pipenv_vendored_to_hosted() { - let (_tmp, root) = project(); +/// Write a Pipenv project (Pipfile + a registry Pipfile.lock pinning six). +fn write_pipenv_project(root: &Path) { std::fs::write(root.join("Pipfile"), PIPFILE).unwrap(); let lock = json!({ "_meta": { @@ -350,9 +354,103 @@ async fn pipenv_vendored_to_hosted() { let mut text = serde_json::to_string_pretty(&lock).unwrap(); text.push('\n'); std::fs::write(root.join("Pipfile.lock"), text).unwrap(); +} + +#[tokio::test] +async fn pipenv_vendored_to_hosted() { + let (_tmp, root) = project(); + write_pipenv_project(&root); assert_vendored_to_hosted(&root, &["Pipfile.lock"]).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(); + write_pipenv_project(&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/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index a2592e400..a9a31dec2 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, From 41dcd4258284c3e0c59e67e9223236bd4a633caa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:02:24 +0000 Subject: [PATCH 4/6] Keep the ledger-less Pipenv wrappers test-only check_target_guards and wire_pipenv now have no production caller (the vendor flow passes the ledger through the _superseding variants), so clippy flagged them as dead code. Compile them for tests only and point the docs at the variants production uses. Refs #769 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/vendor/pypi_pipenv.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index dd0c2c577..ed53f06ba 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -156,13 +156,15 @@ 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, @@ -173,7 +175,7 @@ pub(super) fn check_target_guards( check_target_guards_superseding(p, canon_name, record_uuid, version, hosted_origins, None) } -/// [`check_target_guards`], given `superseded`: the vendor-ledger entry +/// 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 @@ -345,6 +347,8 @@ fn superseded_record<'e>( /// 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, @@ -370,10 +374,11 @@ pub(super) async fn wire_pipenv( .await } -/// [`wire_pipenv`] over entries `superseded` (the ledger entry of an OLDER -/// patch uuid, see [`check_target_guards_superseding`]) wired: each one is -/// re-wired in place and its record carries that entry's pre-vendor -/// original forward, so reverting the new entry restores the registry pin. +/// 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, From 3fade633c8220d654bd194fd69a04b8618aaabbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:39:07 +0000 Subject: [PATCH 5/6] Port #851's vex alias test fix Main has been red since 4646693 (#605): two commands::vex_consumed tests built for #738 assume the name-keyed resolver never returns npm-aliased copies, which #605 changed. This is the same test-only change as #851 and becomes a no-op once that lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSXCFoPbraq7rNKJpXEP2n --- .../src/commands/vex_consumed.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] From ff5fb6c5cbb73063737edc241e39040f4e38ef2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 20:09:46 +0000 Subject: [PATCH 6/6] Port #878's Gradle digest routing Main is red since 1714299 (#865): its production_digests_go_through_the_helpers guard flags the inline digests that #646 added in gradle_cache.rs, jvm_jar.rs and sidecars/maven.rs. This is the same change as #878 and becomes a no-op once that lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VSXCFoPbraq7rNKJpXEP2n --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) 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)), } }