From 69d0af7ad856cda84fee458c8ef7898c27f6c5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:33:48 +0000 Subject: [PATCH 1/8] Start fix for #742, #650 Assisted-by: Claude Code:claude-opus-5-5 From cb4266eb9e690518055eda4a16e958274ae9e16f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:44:37 +0000 Subject: [PATCH 2/8] Re-vendor uv and Hatch projects to a newer patch A uv project, a uv script lock (or pylock) and a Hatch project vendored with one patch could not move to a newer patch for the same package: scan --mode vendored, vendor and get --mode vendored failed with pypi_uv_source_already_exists, pypi_lock_source_already_exists or pypi_hatch_unsupported, and the project kept installing the old patch. The guards treated socket-patch's own wiring as a user source. When that wiring belongs to an older patch uuid of the same release and the vendor ledger still records it, vendor now replays the older entry's revert, keeping its wheel, and wires the new wheel fresh over the restored files. The new entry records the user's real pre-vendor originals, so vendor --revert still restores them byte for byte. If the old wiring was edited since vendoring, or the restored files refuse the new wiring, every file is put back and the run fails as before. Without a ledger entry it still refuses before writing. (#742, #650) Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 558 +++++++++++++++++++- 1 file changed, 547 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 9ebf3be86..688d29398 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -431,11 +431,24 @@ enum WiringPlan { Poetry(Box), Pdm(Box), Pipenv(Box), + /// The uv, script-lock or Hatch wiring routes this package through an + /// OLDER patch uuid's vendored wheel that the ledger still records + /// (#742, #650): replay that entry's revert, then wire this uuid fresh + /// ([`unwire_superseded`]). + Supersede(Box), /// The lock already routes this package through THIS patch uuid's /// vendored wheel: no wiring — verify (or rebuild) the artifact only. InSync, } +/// The older vendored entry a [`WiringPlan::Supersede`] replaces, with the +/// flavor guard's refusal of its wiring (still the answer when the replay +/// can't run). +struct Superseded { + prev: VendorEntry, + refusal: (&'static str, String), +} + /// Which `VendorEntry` meta slot a flavor's wiring produced. enum MetaSlot { Uv(UvMeta), @@ -763,11 +776,14 @@ async fn pypi_prelude<'p>( warnings.extend(project.warnings.iter().cloned()); WiringPlan::Uv(Box::new(project)) } - Err((code, detail)) => return Err(refused(code, detail)), + Err(refusal) => { + supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) + .await? + } } } PypiFlavor::PythonLocks => { - let project = match super::pypi_lock::load_python_locks( + match super::pypi_lock::load_python_locks( project_root, &canon_name, version, @@ -775,14 +791,15 @@ async fn pypi_prelude<'p>( ) .await { - Ok(project) => project, - Err((code, detail)) => return Err(refused(code, detail)), - }; - if project.in_sync { - wired_pin = project.pin; - WiringPlan::InSync - } else { - WiringPlan::PythonLocks(project) + Ok(project) if project.in_sync => { + wired_pin = project.pin; + WiringPlan::InSync + } + Ok(project) => WiringPlan::PythonLocks(project), + Err(refusal) => { + supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) + .await? + } } } PypiFlavor::Hatch => { @@ -792,7 +809,10 @@ async fn pypi_prelude<'p>( WiringPlan::InSync } Ok(project) => WiringPlan::Hatch(project), - Err((code, detail)) => return Err(refused(code, detail)), + Err(refusal) => { + supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) + .await? + } } } PypiFlavor::Requirements => { @@ -1219,6 +1239,43 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( // Wiring LAST. On failure the wheel artifact is swept back out so a // failed vendor leaves no committed residue. + // + // A superseded older uuid's wiring is unwound first (#742, #650); every + // file it touched is snapshotted, and put back if anything below fails. + let mut snapshot: Option = None; + let plan = match plan { + WiringPlan::Supersede(superseded) => { + match unwire_superseded( + project_root, + flavor, + &superseded, + &canon_name, + version, + &record.uuid, + ) + .await + { + Ok((fresh, snap, fresh_warnings)) => { + warnings.extend(fresh_warnings); + snapshot = Some(snap); + fresh + } + // Nothing is left wired: sweep the new wheel back out, as + // a wiring failure below does. + Err((code, detail)) => { + if !reused { + let _ = tokio::fs::remove_dir_all(project_root.join(&uuid_dir_rel)).await; + prune_empty_vendor_levels(&project_root.join(&uuid_dir_rel)).await; + } + let mut result = result; + result.success = false; + result.error = Some(format!("{code}: {detail}")); + return done(result, None, warnings); + } + } + } + plan => plan, + }; let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { WiringPlan::Uv(project) => wire_uv( &project, @@ -1322,10 +1379,15 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( .map(|(wiring, meta)| (wiring, MetaSlot::Pipenv(meta))), // Returned right after the wheel build above. WiringPlan::InSync => unreachable!("in-sync rebuilds never reach wiring"), + // Replaced by its fresh plan just above. + WiringPlan::Supersede(_) => unreachable!("superseded wiring is unwound before wiring"), }; let (wiring, meta) = match wired { Ok(pair) => pair, Err((code, detail)) => { + if let Some(snapshot) = &snapshot { + restore_snapshot(project_root, snapshot).await; + } // A REUSED wheel is the committed artifact the live ledger entry // still names: never sweep it (nothing was acquired to undo). if !reused { @@ -1373,6 +1435,264 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( done(result, Some(entry), warnings) } +/// The flavor-guard refusals that name socket-patch's own wiring at another +/// patch uuid of the release (alongside user sources the same codes cover): +/// uv's `[tool.uv.sources]` path, a script lock / pylock `path` source, and +/// Hatch's `{root:uri}` direct reference. +const SUPERSEDABLE_REFUSALS: [&str; 3] = [ + "pypi_uv_source_already_exists", + "pypi_lock_source_already_exists", + "pypi_hatch_unsupported", +]; + +/// A pyproject-family flavor guard refused the wiring it found. When that +/// wiring is socket-patch's own, for an OLDER patch uuid of this release +/// that the ledger still records, it is a superseding patch to re-vendor +/// (#742, #650) — the same promise the requirements flavor keeps (#765). +/// Otherwise the refusal stands. +async fn supersede_or_refuse( + project_root: &Path, + flavor: PypiFlavor, + canon_name: &str, + version: &str, + record: &PatchRecord, + (code, detail): (&'static str, String), +) -> Result { + match superseded_entry( + project_root, + flavor, + canon_name, + version, + &record.uuid, + code, + ) + .await + { + Some(prev) => Ok(WiringPlan::Supersede(Box::new(Superseded { + prev, + refusal: (code, detail), + }))), + None => Err(refused(code, detail)), + } +} + +/// The single ledger entry vendoring `canon_name==version` under ANOTHER +/// patch uuid with this flavor, whose wiring the project still carries. +/// `None` (keep refusing) without one: with no recorded pre-vendor +/// originals a re-wire could never be reverted. +async fn superseded_entry( + project_root: &Path, + flavor: PypiFlavor, + canon_name: &str, + version: &str, + uuid: &str, + code: &str, +) -> Option { + if !SUPERSEDABLE_REFUSALS.contains(&code) { + return None; + } + let state = super::state::load_state_shared(project_root).await.ok()?; + let mut hits = state.entries.values().filter(|e| { + e.ecosystem == "pypi" + && e.uuid != uuid + && e.flavor.as_deref() == Some(flavor.as_str()) + && !e.wiring.is_empty() + && vendor_uuid_dir_rel("pypi", &e.uuid).is_some() + && parse_pypi_purl(strip_purl_qualifiers(&e.base_purl)) + .is_some_and(|(n, v)| canonicalize_pypi_name(&n) == canon_name && v == version) + }); + let prev = hits.next()?.clone(); + if hits.next().is_some() { + return None; + } + let files = superseded_files(&prev, flavor)?; + let needle = format!(".socket/vendor/pypi/{}/", prev.uuid); + for file in &files { + if read_regular_to_string(&project_root.join(file)) + .await + .is_ok_and(|text| text.contains(&needle)) + { + return Some(prev); + } + } + None +} + +/// Every project file a superseded entry's revert may write: the files its +/// wiring records plus the flavor's own pair. `None` when a recorded path +/// (from the committed, tamper-able ledger) is not a plain project-relative +/// path outside `.socket/`. +fn superseded_files(prev: &VendorEntry, flavor: PypiFlavor) -> Option> { + use std::path::Component; + let fixed: &[&str] = match flavor { + PypiFlavor::UvProject => &["pyproject.toml", "uv.lock"], + PypiFlavor::Hatch => &["pyproject.toml", "hatch.toml"], + _ => &[], + }; + let mut files: Vec = fixed.iter().map(|f| f.to_string()).collect(); + for rec in &prev.wiring { + let plain = Path::new(&rec.file).components().all(|c| match c { + Component::Normal(part) => part != SOCKET_DIR, + _ => false, + }); + if rec.file.is_empty() || !plain { + return None; + } + if !files.contains(&rec.file) { + files.push(rec.file.clone()); + } + } + Some(files) +} + +/// Each superseded file's bytes before the unwind (`None`: absent). +type Snapshot = Vec<(String, Option>)>; + +/// Put every snapshotted file back. Best effort: this runs on a failure +/// path whose error is already being reported. +async fn restore_snapshot(project_root: &Path, snapshot: &Snapshot) { + for (file, bytes) in snapshot { + let path = project_root.join(file); + let _ = match bytes { + Some(bytes) => crate::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes).await, + None if crate::utils::fs::file_exists(&path).await => { + crate::utils::fs::remove_file(&path).await + } + None => Ok(()), + }; + } +} + +/// Unwind a superseded older uuid's wiring (#742, #650) and plan this uuid +/// fresh over the restored pre-vendor files, so the new entry records the +/// user's real originals and `vendor --revert` still restores them. The +/// older entry's own revert does the unwind (its artifact is kept; the +/// caller sweeps it once the new ledger entry lands). It must restore every +/// recorded fragment and leave no reference to the old uuid dir; otherwise, +/// or when the restored files refuse a fresh wiring, every file is put back +/// and the run fails as it did before. +async fn unwire_superseded( + project_root: &Path, + flavor: PypiFlavor, + superseded: &Superseded, + canon_name: &str, + version: &str, + uuid: &str, +) -> Result<(WiringPlan, Snapshot, Vec), (&'static str, String)> { + let Superseded { prev, refusal } = superseded; + let fail = |why: String| { + ( + refusal.0, + format!( + "{} (re-vendoring over patch {}'s wiring failed: {why})", + refusal.1, prev.uuid + ), + ) + }; + let files = superseded_files(prev, flavor).ok_or_else(|| fail("unsafe wiring path".into()))?; + let mut snapshot: Snapshot = Vec::new(); + for file in files { + let bytes = match crate::utils::fs::read_regular_to_bytes(&project_root.join(&file)).await { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(fail(format!("cannot read {file}: {e}"))), + }; + snapshot.push((file, bytes)); + } + let reverted = revert_pypi_opts( + prev, + project_root, + RevertOpts { + dry_run: false, + keep_artifact: true, + }, + ) + .await; + let needle = format!(".socket/vendor/pypi/{}/", prev.uuid); + let mut residual = None; + for (file, _) in &snapshot { + if read_regular_to_string(&project_root.join(file)) + .await + .is_ok_and(|text| text.contains(&needle)) + { + residual = Some(file.clone()); + break; + } + } + let why = if !reverted.success { + Some( + reverted + .error + .clone() + .unwrap_or_else(|| "revert failed".into()), + ) + } else if reverted.drift_skipped() { + Some("its wiring changed since vendoring".into()) + } else { + residual.map(|file| format!("{file} still references {needle}")) + }; + if let Some(why) = why { + restore_snapshot(project_root, &snapshot).await; + return Err(fail(why)); + } + match fresh_pyproject_plan(project_root, flavor, canon_name, version, uuid).await { + Ok((plan, warnings)) => Ok((plan, snapshot, warnings)), + Err(failure) => { + restore_snapshot(project_root, &snapshot).await; + Err(failure) + } + } +} + +/// The fresh wiring plan of a pyproject-family flavor, re-run over the +/// pre-vendor files [`unwire_superseded`] restored. +async fn fresh_pyproject_plan( + project_root: &Path, + flavor: PypiFlavor, + canon_name: &str, + version: &str, + uuid: &str, +) -> Result<(WiringPlan, Vec), (&'static str, String)> { + let not_fresh = |code: &'static str| { + Err(( + code, + format!("{canon_name} is still wired after reverting the superseded patch"), + )) + }; + match flavor { + PypiFlavor::UvProject => { + let project = load_uv_project(project_root).await?; + match check_target_guards(&project, canon_name, uuid)? { + UvTarget::Fresh => { + let warnings = project.warnings.clone(); + Ok((WiringPlan::Uv(Box::new(project)), warnings)) + } + UvTarget::InSync => not_fresh("pypi_uv_source_already_exists"), + } + } + PypiFlavor::PythonLocks => { + let project = + super::pypi_lock::load_python_locks(project_root, canon_name, version, uuid) + .await?; + if project.in_sync { + return not_fresh("pypi_lock_source_already_exists"); + } + Ok((WiringPlan::PythonLocks(project), Vec::new())) + } + PypiFlavor::Hatch => { + let project = super::pypi_hatch::load(project_root, canon_name, version, uuid).await?; + if project.in_sync { + return not_fresh("pypi_hatch_unsupported"); + } + Ok((WiringPlan::Hatch(project), Vec::new())) + } + other => Err(( + "pypi_vendor_flavor_mismatch", + format!("{} wiring cannot supersede a patch", other.as_str()), + )), + } +} + /// Revert one pypi vendor entry: reverse the wiring per flavor, then remove /// the artifact uuid dir (validated path only — never a path taken on faith /// from state.json). @@ -5528,6 +5848,222 @@ wheels = [ .exists()); } + const SUPERSEDING_UUID: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + const SCRIPT_PY: &str = "# /// script\n# requires-python = \">=3.9\"\n# dependencies = [\"six==1.16.0\"]\n# ///\nprint('preserved')\n"; + const SCRIPT_LOCK: &str = "version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[manifest]\nrequirements = [{name = \"six\", specifier = \"==1.16.0\"}]\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {registry = \"https://pypi.org/simple\"}\nwheels = [{url = \"https://files.pythonhosted.org/six.whl\", hash = \"sha256:upstream\"}]\n"; + const HATCH_PROJECT: &str = "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"proj\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n"; + + /// The pyproject-family flavors #742 (uv project, PEP 723 script lock) + /// and #650 (Hatch) cover, each with the files it wires. + fn superseding_flavors() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { + vec![ + ( + "uv", + vec![ + ("pyproject.toml", UV_PYPROJECT), + ("uv.lock", UV_LOCK_REGISTRY), + ], + ), + ( + "python-lock", + vec![("example.py", SCRIPT_PY), ("example.py.lock", SCRIPT_LOCK)], + ), + ("hatch", vec![("pyproject.toml", HATCH_PROJECT)]), + ] + } + + async fn vendor_six_as( + fx: &E2eFixture, + sources: &PatchSources<'_>, + uuid: &str, + ) -> VendorOutcome { + let mut record = fx.record.clone(); + record.uuid = uuid.to_string(); + 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 + } + + /// #742 / #650: a uv project, a uv script lock and a Hatch project already + /// vendored under an EARLIER patch uuid re-vendor to the superseding uuid + /// (the CLI contract's "re-vendored automatically"), instead of refusing + /// socket-patch's own wiring as a user source. The new entry records the + /// user's PRE-VENDOR originals, so `vendor --revert` of the new entry + /// restores every file byte for byte. + #[tokio::test] + async fn pyproject_flavors_revendor_to_a_superseding_uuid() { + for (flavor, files) in superseding_flavors() { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &files).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("{flavor}: first vendor must be Done"); + }; + assert!(result.success, "{flavor}: {:?}", result.error); + let first = entry.expect("entry on success"); + assert_eq!(first.flavor.as_deref(), Some(flavor)); + save_ledger_entry(&fx.root, &first).await; + + let outcome = vendor_six_as(&fx, &sources, SUPERSEDING_UUID).await; + let VendorOutcome::Done { result, entry, .. } = outcome else { + panic!("{flavor}: superseding uuid must re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{flavor}: {:?}", result.error); + let second = entry.expect("entry on success"); + assert_eq!(second.uuid, SUPERSEDING_UUID, "{flavor}"); + assert_eq!(second.flavor.as_deref(), Some(flavor)); + let surfaces = |e: &VendorEntry| { + e.wiring + .iter() + .map(|r| { + ( + r.file.clone(), + r.kind.clone(), + r.action.clone(), + r.original.clone(), + ) + }) + .collect::>() + }; + assert_eq!( + surfaces(&first), + surfaces(&second), + "{flavor}: the new entry records the pre-vendor originals" + ); + let mut wired = String::new(); + for (name, _) in &files { + let text = tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(); + assert!( + !text.contains(UUID), + "{flavor}: {name} kept the old uuid:\n{text}" + ); + wired.push_str(&text); + } + assert!(wired.contains(SUPERSEDING_UUID), "{flavor}:\n{wired}"); + assert!(fx.root.join(&second.artifact.path).is_file(), "{flavor}"); + assert!(second.artifact.path.contains(SUPERSEDING_UUID), "{flavor}"); + + save_ledger_entry(&fx.root, &second).await; + let reverted = revert_pypi(&second, &fx.root, false).await; + assert!(reverted.success, "{flavor}: {:?}", reverted.error); + assert!( + !reverted.drift_skipped(), + "{flavor}: {:?}", + reverted.warnings + ); + for (name, text) in &files { + assert_eq!( + &tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(), + text, + "{flavor}: {name} restored" + ); + } + } + } + + /// #742 / #650: the re-vendor replays only the wiring the older entry's + /// ledger recorded. When that wiring was hand-edited since vendoring, its + /// revert can't restore the pre-vendor originals, so the re-vendor still + /// refuses: every wired file stays byte-identical and no new uuid dir is + /// left behind. + #[tokio::test] + async fn pyproject_flavors_superseding_uuid_with_drifted_wiring_refuses() { + for (flavor, files) in superseding_flavors() { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &files).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, entry, .. } = vendor_six(&fx, &sources, None).await + else { + panic!("{flavor}: first vendor must be Done"); + }; + assert!(result.success, "{flavor}: {:?}", result.error); + let first = entry.expect("entry on success"); + save_ledger_entry(&fx.root, &first).await; + // Hand-edit the hash every wired file pins for the old wheel. + let mut drifted = Vec::new(); + for (name, _) in &files { + let text = tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(); + let edited = text.replace(&first.artifact.sha256, &"f".repeat(64)); + touch(&fx.root, name, &edited).await; + drifted.push((name, edited)); + } + assert!( + drifted.iter().any(|(name, text)| { + files.iter().any(|(n, orig)| n == *name && orig != text) + }), + "{flavor}: the drift must touch a wired file" + ); + + let outcome = vendor_six_as(&fx, &sources, SUPERSEDING_UUID).await; + let failed = match &outcome { + VendorOutcome::Refused { .. } => true, + VendorOutcome::Done { result, .. } => !result.success, + }; + assert!(failed, "{flavor}: expected a refusal, got {outcome:?}"); + for (name, text) in &drifted { + assert_eq!( + &tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(), + text, + "{flavor}: {name} untouched" + ); + } + assert!( + !fx.root + .join(format!(".socket/vendor/pypi/{SUPERSEDING_UUID}")) + .exists(), + "{flavor}: no new uuid dir" + ); + assert!(fx.root.join(&first.artifact.path).is_file(), "{flavor}"); + } + } + + /// Without a ledger entry for the older uuid there is no recorded + /// pre-vendor original to carry forward, so the script-lock and Hatch + /// lanes keep refusing, before anything is written (the uv lane is + /// `uv_stale_uuid_vendor_refuses_through_orchestrator`). + #[tokio::test] + async fn pyproject_flavors_superseding_uuid_without_ledger_refuses() { + for (flavor, files) in superseding_flavors() { + let fx = e2e_fixture().await; + swap_to_lock_flavor(&fx, &files).await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { result, .. } = vendor_six(&fx, &sources, None).await else { + panic!("{flavor}: first vendor must be Done"); + }; + assert!(result.success, "{flavor}: {:?}", result.error); + let mut wired = Vec::new(); + for (name, _) in &files { + wired.push(tokio::fs::read(fx.root.join(name)).await.unwrap()); + } + let outcome = vendor_six_as(&fx, &sources, SUPERSEDING_UUID).await; + assert!( + matches!(outcome, VendorOutcome::Refused { .. }), + "{flavor}: expected Refused, got {outcome:?}" + ); + for ((name, _), bytes) in files.iter().zip(&wired) { + assert_eq!( + &tokio::fs::read(fx.root.join(name)).await.unwrap(), + bytes, + "{flavor}: {name}" + ); + } + assert!(!fx + .root + .join(format!(".socket/vendor/pypi/{SUPERSEDING_UUID}")) + .exists()); + } + } + /// Deleting ONLY the committed wheel (the marker file survives) must /// still take the artifact-only rebuild: `uuid_dir_has_wheel` scans the /// surviving entries for a `.whl` rather than keying on dir existence. From 061ccc4e8136cc9044c4ca16127431e2dbb10721 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:47:59 +0000 Subject: [PATCH 3/8] Test CLI re-vendor of uv, script and Hatch Drive the real binary through vendor with patch A, then vendor with patch B for a uv project, a uv script lock and a Hatch project. Each must move to patch B, remove patch A's wheel, settle on a re-run and restore the user's files on vendor --revert. (#742, #650) Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/mode_migration_pypi.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b8c92adbe..fe61d9620 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -522,6 +522,115 @@ async fn hatch_vendored_to_hosted() { assert_vendored_to_hosted(&root, files).await; } +/// A uv PEP 723 script with its `.py.lock`; returns its wiring files. +fn stage_script_lock(root: &Path) -> &'static [&'static str] { + std::fs::write( + root.join("job.py"), + "# /// script\n# requires-python = \">=3.9\"\n# dependencies = [\"six==1.16.0\"]\n# ///\nimport six\n", + ) + .unwrap(); + std::fs::write( + root.join("job.py.lock"), + format!( + "version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[manifest]\nrequirements = [{{name = \"six\", specifier = \"==1.16.0\"}}]\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {{registry = \"https://pypi.org/simple\"}}\nwheels = [{{url = \"https://files.pythonhosted.org/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{WHEEL_SHA}\"}}]\n" + ), + ) + .unwrap(); + &["job.py", "job.py.lock"] +} + +/// #742 / #650: a vendored uv project, uv script lock and Hatch project pick +/// up a superseding patch. The manifest moves `six` from patch A to patch B +/// (different patched bytes); the next `vendor` must wire B's wheel, remove +/// A's uuid dir (`vendor_stale_artifact_removed`) and exit 0. Before the fix +/// it failed `pypi_uv_source_already_exists`, +/// `pypi_lock_source_already_exists` or `pypi_hatch_unsupported` (exit 1) +/// and the project kept installing patch A. `vendor --revert` afterwards +/// restores the user's original files byte for byte. +#[tokio::test] +async fn pyproject_flavors_vendored_revendor_superseding_patch() { + const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; + const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; + let stages: [(&str, fn(&Path) -> &'static [&'static str]); 3] = [ + ("uv", stage_uv), + ("script lock", stage_script_lock), + ("hatch", stage_hatch), + ]; + for (flavor, stage) in stages { + let (_tmp, root) = project(); + let files = stage(&root); + let originals: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + vendor_project(&root, files); + + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!( + code, 0, + "{flavor}: re-vendor to the superseding patch: {env:#}" + ); + let rendered = env.to_string(); + assert!(!rendered.contains("already_exists"), "{flavor}: {env:#}"); + assert!( + rendered.contains("vendor_stale_artifact_removed"), + "{flavor}: patch A's artifact is reclaimed: {env:#}" + ); + let wired_b: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + for (f, text) in files.iter().zip(&wired_b) { + assert!(!text.contains(UUID), "{flavor}: {f} kept uuid A:\n{text}"); + } + assert!( + wired_b + .iter() + .any(|t| t.contains(&format!(".socket/vendor/pypi/{UUID_B}/"))), + "{flavor}: wired to patch B: {wired_b:#?}" + ); + assert!( + !root.join(format!(".socket/vendor/pypi/{UUID}")).exists(), + "{flavor}" + ); + assert!( + root.join(format!(".socket/vendor/pypi/{UUID_B}")).is_dir(), + "{flavor}" + ); + let ledger = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + assert!( + ledger.contains(UUID_B) && !ledger.contains(UUID), + "{flavor}: {ledger}" + ); + + // Re-running is settled: in sync, nothing rewritten. + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{flavor}: {env:#}"); + for (f, text) in files.iter().zip(&wired_b) { + assert_eq!( + &std::fs::read_to_string(root.join(f)).unwrap(), + text, + "{flavor}: {f}" + ); + } + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]); + assert_eq!(code, 0, "{flavor}: revert after the re-vendor: {env:#}"); + for (f, text) in files.iter().zip(&originals) { + assert_eq!( + &std::fs::read_to_string(root.join(f)).unwrap(), + text, + "{flavor}: {f} restored to the user's original" + ); + } + assert!( + !root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists(), + "{flavor}" + ); + } +} + /// The uv lock rewrite needs the hosted wheel's METADATA, fetched only /// after the takeover reverted the vendored wiring. When it is unavailable /// the package is left on the unpatched registry release in both modes, so From dc2fadcfabd713df78e3d5fd53979ca640d0b23b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:48:35 +0000 Subject: [PATCH 4/8] Document vendored Hatch re-vendor to a newer patch Assisted-by: Claude Code:claude-opus-5-5 --- docs/testing/hatch.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 3c925346a..5ec36b766 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -24,7 +24,11 @@ supported; vendored groups are refused because Hatch does not expand `{root:uri}` within dependency groups. Unknown direct sources require an explicit revert before patching. A hosted scan re-pins socket-patch's own earlier hosted reference (same package and version on the patch server) -when the grant rotates or a newer patch supersedes it. +when the grant rotates or a newer patch supersedes it. A vendored scan +re-vendors socket-patch's own earlier vendored reference to a newer patch +of the same release when the vendor ledger still records it: the older +entry's wiring is reverted and the new wheel is wired in its place, so +`vendor --revert` still restores the original declaration. Repeated vendored scans compare the declared source with the committed artifact path and digest, and verify an existing wheel's bytes. Missing From 13f6eeeef2559a256d7c8b871a1447d97f0379de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:04:55 +0000 Subject: [PATCH 5/8] Port #878's Gradle digest routing Main is red since #865: its production_digests_go_through_the_helpers guard flags the inline digests #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. Assisted-by: Claude Code:claude-opus-5-5 --- 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)), } } From efc6d912aff27cfba92f220ae50f747f323b5ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:51:27 +0000 Subject: [PATCH 6/8] Check Hatch guards before unwinding; report failed restores Hatch reports its uv-installer and Hatch-version guards under the same pypi_hatch_unsupported code as a foreign direct reference, so a superseding patch could unwind patch A's wiring only for the fresh plan to refuse on a guard unrelated to it. Run those guards (pypi_hatch:: preflight) once a superseded entry is found, before anything is touched. restore_snapshot now attempts every file and names any it could not write back in the reported failure, instead of dropping the error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SECNaPEKiAJVRwYMMVAcLx --- .../tests/mode_migration_pypi.rs | 50 ++++++++- crates/socket-patch-core/src/vendor/pypi.rs | 106 +++++++++++++----- .../src/vendor/pypi_hatch.rs | 24 +++- 3 files changed, 151 insertions(+), 29 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index fe61d9620..3d1de5e1d 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -522,6 +522,9 @@ async fn hatch_vendored_to_hosted() { assert_vendored_to_hosted(&root, files).await; } +/// Stages one flavor's project files; returns its wiring files. +type StageFn = fn(&Path) -> &'static [&'static str]; + /// A uv PEP 723 script with its `.py.lock`; returns its wiring files. fn stage_script_lock(root: &Path) -> &'static [&'static str] { std::fs::write( @@ -551,7 +554,7 @@ fn stage_script_lock(root: &Path) -> &'static [&'static str] { async fn pyproject_flavors_vendored_revendor_superseding_patch() { const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; - let stages: [(&str, fn(&Path) -> &'static [&'static str]); 3] = [ + let stages: [(&str, StageFn); 3] = [ ("uv", stage_uv), ("script lock", stage_script_lock), ("hatch", stage_hatch), @@ -631,6 +634,51 @@ async fn pyproject_flavors_vendored_revendor_superseding_patch() { } } +/// A Hatch guard unrelated to the old wiring (here the uv installer, which +/// Hatch reports under the same `pypi_hatch_unsupported` code) refuses the +/// superseding patch BEFORE patch A's wiring is unwound: the project files, +/// the ledger and patch A's artifact are left exactly as they were, and no +/// patch B wheel is built. +#[tokio::test] +async fn hatch_unrelated_guard_refuses_superseding_patch_before_unwinding() { + const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; + const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; + let (_tmp, root) = project(); + let files = stage_hatch(&root); + vendor_project(&root, files); + let wired_a: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + let ledger_a = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli( + &root, + &["vendor"], + &[("HATCH_ENV_TYPE_VIRTUAL_UV_PATH", "/usr/bin/uv")], + ); + assert_eq!(code, 1, "{env:#}"); + let rendered = env.to_string(); + assert!( + rendered.contains("pypi_hatch_unsupported") && rendered.contains("pip installer"), + "the installer guard is the reported refusal: {env:#}" + ); + for (f, text) in files.iter().zip(&wired_a) { + assert_eq!( + &std::fs::read_to_string(root.join(f)).unwrap(), + text, + "{f} untouched" + ); + } + assert_eq!( + std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + ledger_a + ); + assert!(root.join(format!(".socket/vendor/pypi/{UUID}")).is_dir()); + assert!(!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists()); +} + /// The uv lock rewrite needs the hosted wheel's METADATA, fetched only /// after the takeover reverted the vendored wiring. When it is unavailable /// the package is left on the unpatched registry release in both modes, so diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 688d29398..53de60c4b 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1384,9 +1384,9 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( }; let (wiring, meta) = match wired { Ok(pair) => pair, - Err((code, detail)) => { + Err((code, mut detail)) => { if let Some(snapshot) = &snapshot { - restore_snapshot(project_root, snapshot).await; + restore_snapshot(project_root, snapshot, &mut detail).await; } // A REUSED wheel is the committed artifact the live ledger entry // still names: never sweep it (nothing was acquired to undo). @@ -1458,7 +1458,7 @@ async fn supersede_or_refuse( record: &PatchRecord, (code, detail): (&'static str, String), ) -> Result { - match superseded_entry( + let Some(prev) = superseded_entry( project_root, flavor, canon_name, @@ -1467,13 +1467,21 @@ async fn supersede_or_refuse( code, ) .await - { - Some(prev) => Ok(WiringPlan::Supersede(Box::new(Superseded { - prev, - refusal: (code, detail), - }))), - None => Err(refused(code, detail)), + else { + return Err(refused(code, detail)); + }; + // Hatch reports its installer and Hatch-version guards under the same + // code as a foreign direct reference: settle those first, before any + // unwind rewrites the project only for the fresh plan to refuse again. + if flavor == PypiFlavor::Hatch { + if let Err((code, detail)) = super::pypi_hatch::preflight(project_root, canon_name).await { + return Err(refused(code, detail)); + } } + Ok(WiringPlan::Supersede(Box::new(Superseded { + prev, + refusal: (code, detail), + }))) } /// The single ledger entry vendoring `canon_name==version` under ANOTHER @@ -1548,18 +1556,31 @@ fn superseded_files(prev: &VendorEntry, flavor: PypiFlavor) -> Option>)>; -/// Put every snapshotted file back. Best effort: this runs on a failure -/// path whose error is already being reported. -async fn restore_snapshot(project_root: &Path, snapshot: &Snapshot) { +/// Put every snapshotted file back, attempting each even after one fails, +/// and append any file left unrestored to `detail`, the failure being +/// reported. A vendor run holds these writes in its group commit, so they +/// land together; without one, a failed write is named instead of leaving +/// a silently half-unwound project. +async fn restore_snapshot(project_root: &Path, snapshot: &Snapshot, detail: &mut String) { + let mut unrestored = Vec::new(); for (file, bytes) in snapshot { let path = project_root.join(file); - let _ = match bytes { + let restored = match bytes { Some(bytes) => crate::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes).await, None if crate::utils::fs::file_exists(&path).await => { crate::utils::fs::remove_file(&path).await } None => Ok(()), }; + if let Err(e) = restored { + unrestored.push(format!("{file}: {e}")); + } + } + if !unrestored.is_empty() { + detail.push_str(&format!( + "; could not restore {}; restore them from version control", + unrestored.join(", ") + )); } } @@ -1632,13 +1653,14 @@ async fn unwire_superseded( residual.map(|file| format!("{file} still references {needle}")) }; if let Some(why) = why { - restore_snapshot(project_root, &snapshot).await; - return Err(fail(why)); + let mut failure = fail(why); + restore_snapshot(project_root, &snapshot, &mut failure.1).await; + return Err(failure); } match fresh_pyproject_plan(project_root, flavor, canon_name, version, uuid).await { Ok((plan, warnings)) => Ok((plan, snapshot, warnings)), - Err(failure) => { - restore_snapshot(project_root, &snapshot).await; + Err(mut failure) => { + restore_snapshot(project_root, &snapshot, &mut failure.1).await; Err(failure) } } @@ -5925,14 +5947,7 @@ wheels = [ let surfaces = |e: &VendorEntry| { e.wiring .iter() - .map(|r| { - ( - r.file.clone(), - r.kind.clone(), - r.action.clone(), - r.original.clone(), - ) - }) + .map(|r| (r.file.clone(), r.kind.clone(), r.action, r.original.clone())) .collect::>() }; assert_eq!( @@ -6027,6 +6042,47 @@ wheels = [ } } + /// A snapshot file that cannot be written back is named in the failure + /// being reported, and the files after it are still restored. + #[tokio::test] + async fn restore_snapshot_reports_unrestored_files_and_restores_the_rest() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // A directory where pyproject.toml was: writing it back fails. + tokio::fs::create_dir(root.join("pyproject.toml")) + .await + .unwrap(); + tokio::fs::write(root.join("uv.lock"), "wired") + .await + .unwrap(); + tokio::fs::write(root.join("new.txt"), "created by the unwind") + .await + .unwrap(); + let snapshot: Snapshot = vec![ + ("pyproject.toml".into(), Some(b"original".to_vec())), + ("uv.lock".into(), Some(b"original lock".to_vec())), + ("new.txt".into(), None), + ]; + let mut detail = String::from("fresh plan refused"); + restore_snapshot(root, &snapshot, &mut detail).await; + assert!( + detail.starts_with("fresh plan refused; could not restore pyproject.toml: "), + "{detail}" + ); + assert!(!detail.contains("uv.lock"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join("uv.lock")) + .await + .unwrap(), + "original lock" + ); + assert!(!root.join("new.txt").exists()); + + let mut clean = String::from("refused"); + restore_snapshot(root, &snapshot[1..].to_vec(), &mut clean).await; + assert_eq!(clean, "refused"); + } + /// Without a ledger entry for the older uuid there is no recorded /// pre-vendor original to carry forward, so the script-lock and Hatch /// lanes keep refusing, before anything is written (the uv lane is diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index 43d1cf2d6..a83f91281 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -40,9 +40,7 @@ pub(super) async fn load( uuid: &str, ) -> Result { let files = read_files(root).await?; - if std::env::var("HATCH_ENV_TYPE_VIRTUAL_UV_PATH").is_ok_and(|path| !path.is_empty()) { - return Err(("pypi_hatch_unsupported", "vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into())); - } + require_pip_installer()?; let prefix = format!("{{root:uri}}/.socket/vendor/pypi/{uuid}/"); let state = super::state::load_state_shared(root) .await @@ -112,6 +110,26 @@ pub(super) async fn load( }) } +/// The guards [`load`] applies whatever the project's wiring: the pip +/// installer, and Hatch >=1.2 when `name` is an environment dependency. +/// Checked before a superseded patch's wiring is unwound, so a refusal +/// unrelated to that wiring never touches the project's files. +pub(super) async fn preflight(root: &Path, name: &str) -> Result<(), Failure> { + let files = read_files(root).await?; + require_pip_installer()?; + if hatch::has_environment_dependency(&files, name) { + require_environment_context_support(root).await?; + } + Ok(()) +} + +fn require_pip_installer() -> Result<(), Failure> { + if std::env::var("HATCH_ENV_TYPE_VIRTUAL_UV_PATH").is_ok_and(|path| !path.is_empty()) { + return Err(("pypi_hatch_unsupported", "vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into())); + } + Ok(()) +} + async fn require_environment_context_support(root: &Path) -> Result<(), Failure> { require_environment_context_support_with(root, &|var| std::env::var_os(var)).await } From 3b185a80e51f2c828cbe8b45e41f0512c21d6479 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:41:16 +0000 Subject: [PATCH 7/8] Check Hatch's TOML uv installer before unwinding hatch::plan refuses vendored wheels when an environment sets installer = "uv" or a non-empty uv-path, but the supersede preflight only checked HATCH_ENV_TYPE_VIRTUAL_UV_PATH. A project switched to Hatch's uv installer after vendoring therefore had patch A's wiring unwound and patch B's wheel built before the same refusal put the tree back. Share that check as hatch::require_pip_installer and run it in preflight too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SECNaPEKiAJVRwYMMVAcLx --- .../tests/mode_migration_pypi.rs | 93 ++++++++++++------- crates/socket-patch-core/src/utils/hatch.rs | 86 ++++++++++++----- .../src/vendor/pypi_hatch.rs | 10 +- 3 files changed, 130 insertions(+), 59 deletions(-) diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 3713aaf16..59254f4d2 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -788,48 +788,73 @@ async fn pyproject_flavors_vendored_revendor_superseding_patch() { } /// A Hatch guard unrelated to the old wiring (here the uv installer, which -/// Hatch reports under the same `pypi_hatch_unsupported` code) refuses the -/// superseding patch BEFORE patch A's wiring is unwound: the project files, -/// the ledger and patch A's artifact are left exactly as they were, and no -/// patch B wheel is built. +/// Hatch reports under the same `pypi_hatch_unsupported` code, selected by +/// environment variable or by an environment's `installer` / `uv-path` +/// setting) refuses the superseding patch BEFORE patch A's wiring is +/// unwound: the project files, the ledger and patch A's artifact are left +/// exactly as they were, and no patch B wheel is built. #[tokio::test] async fn hatch_unrelated_guard_refuses_superseding_patch_before_unwinding() { const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d"; const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n"; - let (_tmp, root) = project(); - let files = stage_hatch(&root); - vendor_project(&root, files); - let wired_a: Vec = files - .iter() - .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) - .collect(); - let ledger_a = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); + const UV_PATH: &[(&str, &str)] = &[("HATCH_ENV_TYPE_VIRTUAL_UV_PATH", "/usr/bin/uv")]; + let cases = [ + ("env var", "", UV_PATH), + ( + "installer", + "\n[tool.hatch.envs.default]\ninstaller = \"uv\"\n", + &[], + ), + ( + "uv-path", + "\n[tool.hatch.envs.default]\nuv-path = \"/usr/bin/uv\"\n", + &[], + ), + ]; + for (case, setting, extra) in cases { + let (_tmp, root) = project(); + let files = stage_hatch(&root); + vendor_project(&root, files); + if !setting.is_empty() { + let path = root.join("pyproject.toml"); + let wired = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, wired + setting).unwrap(); + } + let wired_a: Vec = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + let ledger_a = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(); - stage_manifest_with(&root, UUID_B, PATCHED_B); - let (code, env) = run_cli( - &root, - &["vendor"], - &[("HATCH_ENV_TYPE_VIRTUAL_UV_PATH", "/usr/bin/uv")], - ); - assert_eq!(code, 1, "{env:#}"); - let rendered = env.to_string(); - assert!( - rendered.contains("pypi_hatch_unsupported") && rendered.contains("pip installer"), - "the installer guard is the reported refusal: {env:#}" - ); - for (f, text) in files.iter().zip(&wired_a) { + stage_manifest_with(&root, UUID_B, PATCHED_B); + let (code, env) = run_cli(&root, &["vendor"], extra); + assert_eq!(code, 1, "{case}: {env:#}"); + let rendered = env.to_string(); + assert!( + rendered.contains("pypi_hatch_unsupported") && rendered.contains("pip installer"), + "{case}: the installer guard is the reported refusal: {env:#}" + ); + for (f, text) in files.iter().zip(&wired_a) { + assert_eq!( + &std::fs::read_to_string(root.join(f)).unwrap(), + text, + "{case}: {f} untouched" + ); + } assert_eq!( - &std::fs::read_to_string(root.join(f)).unwrap(), - text, - "{f} untouched" + std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), + ledger_a, + "{case}" + ); + assert!( + root.join(format!(".socket/vendor/pypi/{UUID}")).is_dir(), + "{case}" + ); + assert!( + !root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists(), + "{case}" ); } - assert_eq!( - std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), - ledger_a - ); - assert!(root.join(format!(".socket/vendor/pypi/{UUID}")).is_dir()); - assert!(!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists()); } /// The uv lock rewrite needs the hosted wheel's METADATA, fetched only diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 28eadc14d..fd8bf7a22 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -375,6 +375,49 @@ pub fn has_project_direct_references(files: &BTreeMap) -> bool { }) } +/// Refuse a project whose Hatch environments use the uv installer +/// (`installer = "uv"` or a non-empty `uv-path`): uv does not enforce local +/// wheel fragment hashes, so a vendored wheel needs pip. +pub fn require_pip_installer(files: &BTreeMap) -> Result<(), String> { + let mut documents = Vec::new(); + for file in HATCH_FILES { + if let Some(text) = files.get(file) { + documents.push( + text.parse::() + .map_err(|error| format!("{file}: {error}"))?, + ); + } + } + require_pip_installer_documents(documents.iter()) +} + +fn require_pip_installer_documents<'a>( + documents: impl Iterator, +) -> Result<(), String> { + for document in documents { + let hatch = document + .get("tool") + .and_then(|tool| tool.get("hatch")) + .unwrap_or(document.as_item()); + if hatch + .get("envs") + .and_then(Item::as_table_like) + .is_some_and(|envs| { + envs.iter().any(|(_, env)| { + env.get("installer").and_then(Item::as_str) == Some("uv") + || env + .get("uv-path") + .and_then(Item::as_str) + .is_some_and(|path| !path.is_empty()) + }) + }) + { + return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into()); + } + } + Ok(()) +} + pub fn plan( files: &BTreeMap, name: &str, @@ -393,27 +436,7 @@ pub fn plan( } } if url.starts_with("{root:uri}") { - for document in documents.values() { - let hatch = document - .get("tool") - .and_then(|tool| tool.get("hatch")) - .unwrap_or(document.as_item()); - if hatch - .get("envs") - .and_then(Item::as_table_like) - .is_some_and(|envs| { - envs.iter().any(|(_, env)| { - env.get("installer").and_then(Item::as_str) == Some("uv") - || env - .get("uv-path") - .and_then(Item::as_str) - .is_some_and(|path| !path.is_empty()) - }) - }) - { - return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into()); - } - } + require_pip_installer_documents(documents.values())?; } let mut matched = 0; let mut project_matched = 0; @@ -684,6 +707,27 @@ mod tests { } } + #[test] + fn require_pip_installer_refuses_uv_settings_in_either_file() { + for setting in ["installer='uv'", "uv-path='uv'"] { + let project = files(&format!("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n{setting}\n")); + assert!(require_pip_installer(&project) + .unwrap_err() + .contains("pip installer")); + let external = both( + "[project]\ndependencies=[\"urllib3==1.26.18\"]", + Some(&format!("[envs.default]\n{setting}\n")), + ); + assert!(require_pip_installer(&external) + .unwrap_err() + .contains("pip installer")); + } + for allowed in ["installer='pip'", "uv-path=''"] { + let project = files(&format!("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n{allowed}\n")); + assert!(require_pip_installer(&project).is_ok(), "{allowed}"); + } + } + fn both(pyproject: &str, hatch: Option<&str>) -> BTreeMap { let mut inputs = files(pyproject); if let Some(hatch) = hatch { diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index 7696c661e..da492c22d 100644 --- a/crates/socket-patch-core/src/vendor/pypi_hatch.rs +++ b/crates/socket-patch-core/src/vendor/pypi_hatch.rs @@ -110,13 +110,15 @@ pub(super) async fn load( }) } -/// The guards [`load`] applies whatever the project's wiring: the pip -/// installer, and Hatch >=1.2 when `name` is an environment dependency. -/// Checked before a superseded patch's wiring is unwound, so a refusal -/// unrelated to that wiring never touches the project's files. +/// The guards vendoring applies whatever the project's wiring: the pip +/// installer (environment variable and environment settings), and +/// Hatch >=1.2 when `name` is an environment dependency. Checked before a +/// superseded patch's wiring is unwound, so a refusal unrelated to that +/// wiring never touches the project's files. pub(super) async fn preflight(root: &Path, name: &str) -> Result<(), Failure> { let files = read_files(root).await?; require_pip_installer()?; + hatch::require_pip_installer(&files).map_err(|error| ("pypi_hatch_unsupported", error))?; if hatch::has_environment_dependency(&files, name) { require_environment_context_support(root).await?; } From 43ce45d6d80846fdb543a2efe39104ba25f47cee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:08:18 +0000 Subject: [PATCH 8/8] Preview the supersede unwind in vendor --dry-run A superseding re-vendor turned the flavor guard's refusal into a Supersede plan, and a dry run returned success right after acquiring the wheel, so it never ran the unwind. Drifted older wiring, an unsafe wiring path, or restored files that refuse a fresh plan therefore failed only on the wet run, while --dry-run reported success. The dry run now runs the same unwind and fresh plan (probe_supersede) inside a throwaway group commit that is dropped unwritten, restoring the snapshot as well so an enclosing group (the takeover probe) is left as it was. It reports the refusal the wet run would hit, and writes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SECNaPEKiAJVRwYMMVAcLx --- crates/socket-patch-core/src/vendor/pypi.rs | 116 ++++++++++++++++++-- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index f51fa5f5f..76091e214 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -17,6 +17,7 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::utils::fs::{atomic_write_artifact, read_regular_to_string}; +use crate::utils::group_commit::{self, GroupCommit}; use crate::utils::purl::{parse_pypi_purl, strip_purl_qualifiers}; use crate::utils::socket_dir::remove_tree_and_prune; use crate::utils::toml_edit_ext::has_table; @@ -1243,6 +1244,26 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( return done(result, None, warnings); } if dry_run { + // A superseding re-vendor fails on the wet run when the older + // wiring drifted, a wiring path is unsafe, or the restored files + // refuse a fresh plan: run that unwind now, in memory only. + if let WiringPlan::Supersede(superseded) = &plan { + if let Err((code, detail)) = probe_supersede( + project_root, + flavor, + superseded, + &canon_name, + version, + &record.uuid, + ) + .await + { + let mut result = result; + result.success = false; + result.error = Some(format!("{code}: {detail}")); + return done(result, None, warnings); + } + } return done(result, None, warnings); } let Some(artifact) = artifact else { @@ -1671,6 +1692,38 @@ async fn restore_snapshot(project_root: &Path, snapshot: &Snapshot, detail: &mut } } +/// Dry-run [`unwire_superseded`]: the same unwind and fresh plan, inside a +/// throwaway group commit that is dropped unwritten, so `--dry-run` reports +/// the refusal the wet run would hit. Every file it touches is a captured +/// commit point (never under `.socket/`); a successful unwind is restored +/// too, so an enclosing group (the takeover probe) is left as it was. +async fn probe_supersede( + project_root: &Path, + flavor: PypiFlavor, + superseded: &Superseded, + canon_name: &str, + version: &str, + uuid: &str, +) -> Result<(), (&'static str, String)> { + let captured = superseded_files(&superseded.prev, flavor) + .is_some_and(|files| files.iter().all(|f| group_commit::captures(f))); + if !captured { + let (code, detail) = &superseded.refusal; + return Err(( + code, + format!( + "{detail} (re-vendoring over patch {}'s wiring failed: unsafe wiring path)", + superseded.prev.uuid + ), + )); + } + let _probe = GroupCommit::begin(project_root); + let (_, snapshot, _) = + unwire_superseded(project_root, flavor, superseded, canon_name, version, uuid).await?; + restore_snapshot(project_root, &snapshot, &mut String::new()).await; + Ok(()) +} + /// Unwind a superseded older uuid's wiring (#742, #650) and plan this uuid /// fresh over the restored pre-vendor files, so the new entry records the /// user's real originals and `vendor --revert` still restores them. The @@ -6263,6 +6316,15 @@ wheels = [ fx: &E2eFixture, sources: &PatchSources<'_>, uuid: &str, + ) -> VendorOutcome { + vendor_six_as_opts(fx, sources, uuid, false).await + } + + async fn vendor_six_as_opts( + fx: &E2eFixture, + sources: &PatchSources<'_>, + uuid: &str, + dry_run: bool, ) -> VendorOutcome { let mut record = fx.record.clone(); record.uuid = uuid.to_string(); @@ -6273,7 +6335,7 @@ wheels = [ &record, sources, "2026-06-09T00:00:00Z", - false, + dry_run, false, None, ) @@ -6301,6 +6363,30 @@ wheels = [ assert_eq!(first.flavor.as_deref(), Some(flavor)); save_ledger_entry(&fx.root, &first).await; + // A dry run previews the re-vendor and writes nothing. + let mut wired_a = Vec::new(); + for (name, _) in &files { + wired_a.push(tokio::fs::read(fx.root.join(name)).await.unwrap()); + } + let outcome = vendor_six_as_opts(&fx, &sources, SUPERSEDING_UUID, true).await; + let VendorOutcome::Done { result, .. } = &outcome else { + panic!("{flavor}: dry run must preview the re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{flavor}: dry run: {:?}", result.error); + for ((name, _), bytes) in files.iter().zip(&wired_a) { + assert_eq!( + &tokio::fs::read(fx.root.join(name)).await.unwrap(), + bytes, + "{flavor}: dry run left {name} untouched" + ); + } + assert!( + !fx.root + .join(format!(".socket/vendor/pypi/{SUPERSEDING_UUID}")) + .exists(), + "{flavor}: dry run created no uuid dir" + ); + let outcome = vendor_six_as(&fx, &sources, SUPERSEDING_UUID).await; let VendorOutcome::Done { result, entry, .. } = outcome else { panic!("{flavor}: superseding uuid must re-vendor, got {outcome:?}"); @@ -6384,18 +6470,24 @@ wheels = [ "{flavor}: the drift must touch a wired file" ); - let outcome = vendor_six_as(&fx, &sources, SUPERSEDING_UUID).await; - let failed = match &outcome { - VendorOutcome::Refused { .. } => true, - VendorOutcome::Done { result, .. } => !result.success, - }; - assert!(failed, "{flavor}: expected a refusal, got {outcome:?}"); - for (name, text) in &drifted { - assert_eq!( - &tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(), - text, - "{flavor}: {name} untouched" + // The dry run reports the refusal the wet run hits. + for dry_run in [true, false] { + let outcome = vendor_six_as_opts(&fx, &sources, SUPERSEDING_UUID, dry_run).await; + let failed = match &outcome { + VendorOutcome::Refused { .. } => true, + VendorOutcome::Done { result, .. } => !result.success, + }; + assert!( + failed, + "{flavor} dry_run={dry_run}: expected a refusal, got {outcome:?}" ); + for (name, text) in &drifted { + assert_eq!( + &tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(), + text, + "{flavor} dry_run={dry_run}: {name} untouched" + ); + } } assert!( !fx.root