diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 706ac5b0c..59254f4d2 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -675,6 +675,188 @@ 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( + 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, StageFn); 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}" + ); + } +} + +/// A Hatch guard unrelated to the old wiring (here the uv installer, which +/// 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"; + 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"], 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(".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}" + ); + } +} + /// 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/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.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 73a5dc2cf..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; @@ -434,11 +435,24 @@ enum WiringPlan { /// 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 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), @@ -824,11 +838,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, @@ -836,14 +853,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 => { @@ -863,7 +881,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 => { @@ -1223,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 { @@ -1305,6 +1346,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, @@ -1409,10 +1487,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)) => { + Err((code, mut detail)) => { + if let Some(snapshot) = &snapshot { + 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). if !reused { @@ -1460,6 +1543,318 @@ 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 { + let Some(prev) = superseded_entry( + project_root, + flavor, + canon_name, + version, + &record.uuid, + code, + ) + .await + 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 +/// 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, 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 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(", ") + )); + } +} + +/// 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 +/// 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 { + 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(mut failure) => { + restore_snapshot(project_root, &snapshot, &mut failure.1).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). @@ -5893,6 +6288,295 @@ 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 { + 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(); + crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &record, + sources, + "2026-06-09T00:00:00Z", + dry_run, + 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; + + // 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:?}"); + }; + 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, 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" + ); + + // 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 + .join(format!(".socket/vendor/pypi/{SUPERSEDING_UUID}")) + .exists(), + "{flavor}: no new uuid dir" + ); + assert!(fx.root.join(&first.artifact.path).is_file(), "{flavor}"); + } + } + + /// 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 + /// `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()); + } + } + /// #979: an inline `[tool.uv] sources = {…}` table is refused by the /// preflight, so the dry run previews the same `pypi_uv_lock_parse_failed` /// refusal as the real run (which refuses before any download or write) diff --git a/crates/socket-patch-core/src/vendor/pypi_hatch.rs b/crates/socket-patch-core/src/vendor/pypi_hatch.rs index 6916e6e39..da492c22d 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,28 @@ pub(super) async fn load( }) } +/// 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?; + } + 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 } diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 361534764..405c69e37 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