From 9faf1afd38a7512a6fa1914c5fef0e846ac7ed71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:22:30 +0000 Subject: [PATCH 1/6] Start fix for #1368 Assisted-by: Claude Code:claude-opus-5-5 From c58f5998b99e2d45a4543976a26209c9725411c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:33:14 +0000 Subject: [PATCH 2/6] Vendor uv's exported requirements.txt too A uv project often keeps a requirements.txt made by `uv export` for Docker or plain pip installs. Vendored mode wired only uv.lock and named the export as an unpatched install source, so `vendor --check` and `vex` reported contested wiring, and re-running vendor changed nothing, so the suggested remedy looped. Vendoring a uv project now also rewrites an exact registry pin of the package in requirements.txt (or an in-root -r include) to the same committed wheel, as already done beside Pipfile.lock (#612). The ledger entry records both, revert restores both, a superseding patch re-wires both, and a re-run over an already wired uv.lock wires an export made since. Pins the requirements wiring cannot rewrite (a range, a UTF-16 file) stay named by pypi_multiple_lockfiles. The Pipenv and uv paths now share one helper for the sibling wiring and one for its revert. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 541 ++++++++++++++------ 1 file changed, 397 insertions(+), 144 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 7ec7a2f89..733c45b20 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -392,16 +392,16 @@ async fn detect_pypi_flavor( // (`pipenv requirements`, `uv export`) is an install source the // single-lock wiring leaves untouched — name it among the losers when it // pins this package. - // A Pipenv project's exported pin is wired with Pipfile.lock instead - // (`pipenv_sibling_requirements`), so it is no loser. - let wired_with_pipenv = match (governing, target) { - (Some("Pipfile.lock"), Some((name, version))) => { + // A Pipenv (#612) or uv (#1368) project's exported pin is wired with + // its lock instead (`wire_in_sync_sibling`), so it is no loser. + let wired_with_lock = match (governing, target) { + (Some("Pipfile.lock" | "uv.lock"), Some((name, version))) => { super::pypi_requirements::sibling_pin_wirable(project_root, name, version).await } _ => false, }; if governing.is_some() - && !wired_with_pipenv + && !wired_with_lock && requirements_pins_target(project_root, target).await { present.push(PYPI_REQUIREMENTS); @@ -521,7 +521,9 @@ async fn detect_pypi_flavor( /// Per-flavor pre-flight result carried into the wiring step (the loaded /// project is reused so the lock is parsed once). enum WiringPlan { - Uv(Box), + /// The uv.lock plan, and whether the exported requirements file is + /// wired too (#1368). + Uv(Box, bool), PythonLocks(super::pypi_lock::PythonLocks), Requirements, /// Re-wire the vendor lines an OLDER patch uuid's ledger entry recorded @@ -864,9 +866,9 @@ struct PypiPrelude<'p> { prior: Option, expected_pin: Option<(String, String)>, reused_wheel: Option, - /// An in-sync Pipenv lock whose exported requirements pin still needs - /// wiring once the missing wheel is rebuilt (#612). - pipenv_sibling_pending: bool, + /// An in-sync Pipenv or uv lock whose exported requirements pin still + /// needs wiring once the missing wheel is rebuilt (#612, #1368). + sibling_pending: bool, } #[allow(clippy::too_many_arguments)] @@ -924,9 +926,10 @@ async fn pypi_prelude<'p>( // entry left (a state.json lost in a merge, clobbered, or never // committed — the exact window the state `repair` exists for). let mut wired_pin: Option<(String, String)> = None; - // An in-sync Pipenv lock whose exported requirements pin is not wired - // yet (a project vendored before #612, or an export made since). - let mut pipenv_sibling_pending = false; + // An in-sync Pipenv or uv lock whose exported requirements pin is not + // wired yet (a project vendored before #612 / #1368, or an export made + // since). + let mut sibling_pending = false; let mut warnings: Vec = flavor_warnings; let plan = match flavor { PypiFlavor::UvProject => { @@ -937,11 +940,25 @@ async fn pypi_prelude<'p>( match check_target_guards(&project, &canon_name, &record.uuid) { Ok(UvTarget::InSync) => { wired_pin = super::pypi_uv::wired_pin(&project, &canon_name, &record.uuid); + sibling_pending = super::pypi_requirements::sibling_pin_wirable( + project_root, + &canon_name, + version, + ) + .await; WiringPlan::InSync } Ok(UvTarget::Fresh) => { warnings.extend(project.warnings.iter().cloned()); - WiringPlan::Uv(Box::new(project)) + // #1368: a `uv export` requirements file is an install + // path of its own; wire its pin with the lock. + let sibling = super::pypi_requirements::sibling_pin_wirable( + project_root, + &canon_name, + version, + ) + .await; + WiringPlan::Uv(Box::new(project), sibling) } Err(refusal) => { supersede_or_refuse(project_root, flavor, &canon_name, version, record, refusal) @@ -1127,7 +1144,7 @@ async fn pypi_prelude<'p>( match target { PipenvTarget::InSync => { wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel); - pipenv_sibling_pending = sibling; + sibling_pending = sibling; WiringPlan::InSync } PipenvTarget::Fresh => { @@ -1154,10 +1171,11 @@ async fn pypi_prelude<'p>( } else { uuid_dir_has_wheel(&project_root.join(&uuid_dir_rel)).await }; - if pipenv_sibling_pending && (artifact_present || dry_run) { - return Err(wire_in_sync_pipenv_sibling( + if sibling_pending && (artifact_present || dry_run) { + return Err(wire_in_sync_sibling( already_patched_result(base, Path::new(""), &record.files), project_root, + flavor, record, &canon_name, version, @@ -1243,32 +1261,39 @@ async fn pypi_prelude<'p>( prior, expected_pin, reused_wheel, - pipenv_sibling_pending, + sibling_pending, }) } -/// #612 for a Pipenv lock already wired to this patch: wire the exported -/// requirements pin to the same committed wheel and extend the ledger -/// entry with its records, so the revert restores both files. `result` is -/// the run's outcome so far (the in-sync skip, or a rebuilt wheel). Without -/// a ledger entry (whose records the revert replays), or when the committed -/// wheel the entry names does not verify, the pin is left alone and named. -async fn wire_in_sync_pipenv_sibling( +/// #612 / #1368 for a Pipenv or uv lock already wired to this patch: wire +/// the exported requirements pin to the same committed wheel and extend +/// the ledger entry with its records, so the revert restores both files. +/// `result` is the run's outcome so far (the in-sync skip, or a rebuilt +/// wheel). Without a ledger entry (whose records the revert replays), or +/// when the committed wheel the entry names does not verify, the pin is +/// left alone and named. +#[allow(clippy::too_many_arguments)] +async fn wire_in_sync_sibling( result: ApplyResult, project_root: &Path, + flavor: PypiFlavor, record: &PatchRecord, canon_name: &str, version: &str, dry_run: bool, mut warnings: Vec, ) -> VendorOutcome { + let (lock, export) = match flavor { + PypiFlavor::UvProject => ("uv.lock", "uv export --frozen -o requirements.txt"), + _ => (super::pypi_pipenv::LOCK_FILE, "pipenv requirements"), + }; let unwired = |why: &str| { VendorWarning::new( "pypi_multiple_lockfiles", format!( - "Pipfile.lock is wired, but {} still pins {canon_name}=={version} from the \ + "{lock} is wired, but {} still pins {canon_name}=={version} from the \ registry and {why}; installs driven by it will still install the UNPATCHED \ - registry bytes (re-export it from Pipfile.lock with `pipenv requirements`)", + registry bytes (re-export it from {lock} with `{export}`)", crate::formats::governing_locks::PYPI_REQUIREMENTS ), ) @@ -1276,7 +1301,7 @@ async fn wire_in_sync_pipenv_sibling( let prior = reuse::prior_entry(project_root, "pypi", record, None) .await .ok() - .filter(|e| e.flavor.as_deref() == Some("pipenv")); + .filter(|e| e.flavor.as_deref() == Some(flavor.as_str())); let Some(entry) = prior else { warnings.push(unwired( "the vendor ledger has no entry to record its wiring in", @@ -1288,7 +1313,7 @@ async fn wire_in_sync_pipenv_sibling( "pypi_requirements_sibling_wired", format!( "would wire the exported {} pin of {canon_name}=={version} to the vendored \ - wheel already wired in Pipfile.lock", + wheel already wired in {lock}", crate::formats::governing_locks::PYPI_REQUIREMENTS ), )); @@ -1321,7 +1346,7 @@ async fn wire_in_sync_pipenv_sibling( "pypi_requirements_sibling_wired", format!( "wired the exported {} pin of {canon_name}=={version} to the vendored \ - wheel already wired in Pipfile.lock", + wheel already wired in {lock}", crate::formats::governing_locks::PYPI_REQUIREMENTS ), )); @@ -1411,7 +1436,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( prior, expected_pin, reused_wheel, - pipenv_sibling_pending, + sibling_pending, } = match pypi_prelude( purl, project_root, @@ -1558,10 +1583,11 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( // Restore the informational marker the deleted uuid dir lost. let marker = VendorMarker::new("pypi", base, record, vendored_at); write_marker_or_warn(&project_root.join(&uuid_dir_rel), &marker, &mut warnings).await; - if pipenv_sibling_pending { - return wire_in_sync_pipenv_sibling( + if sibling_pending { + return wire_in_sync_sibling( result, project_root, + flavor, record, &canon_name, version, @@ -1620,21 +1646,51 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( plan => plan, }; let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { - WiringPlan::Uv(project) => wire_uv( - &project, - project_root, - &canon_name, - version, - &rel_wheel, - &wheel_name, - &artifact.sha256_hex, - &record.uuid, - ) - .await - .map(|(wiring, meta, advisories)| { - warnings.extend(advisories); - (wiring, MetaSlot::Uv(meta)) - }), + WiringPlan::Uv(project, sibling) => { + let lock_before = if sibling { + snapshot_files(project_root, UV_PAIR).await + } else { + Vec::new() + }; + match wire_uv( + &project, + project_root, + &canon_name, + version, + &rel_wheel, + &wheel_name, + &artifact.sha256_hex, + &record.uuid, + ) + .await + { + Ok((mut wiring, meta, advisories)) => { + warnings.extend(advisories); + if sibling { + // #1368: the `uv export` pin goes to the same wheel. + // A superseded entry's lines were restored by its + // revert, so they are wired fresh here. + wire_sibling_requirements( + project_root, + &lock_before, + None, + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|lines| { + wiring.extend(lines); + (wiring, MetaSlot::Uv(meta)) + }) + } else { + Ok((wiring, MetaSlot::Uv(meta))) + } + } + Err(refusal) => Err(refusal), + } + } WiringPlan::PythonLocks(project) => super::pypi_lock::wire_python_locks( &project, project_root, @@ -1710,13 +1766,9 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( } WiringPlan::Pipenv(project, superseded, sibling) => { let lock_before = if sibling { - crate::utils::fs::read_regular_to_bytes( - &project_root.join(super::pypi_pipenv::LOCK_FILE), - ) - .await - .ok() + snapshot_files(project_root, &[super::pypi_pipenv::LOCK_FILE]).await } else { - None + Vec::new() }; let wired = super::pypi_pipenv::wire_pipenv_superseding( &project, @@ -1731,63 +1783,20 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( ) .await; match wired { - Ok((mut wiring, meta)) if sibling => { - // The export's pin goes to the same wheel (#612): a - // superseded entry's recorded lines are re-wired in - // place, a registry pin is rewritten fresh. - let prev_lines = superseded.as_deref().and_then(|prev| { - let lines: Vec<_> = prev - .wiring - .iter() - .filter(|r| r.kind == super::pypi_requirements::RECORD_KIND) - .cloned() - .collect(); - (!lines.is_empty()).then(|| VendorEntry { - wiring: lines, - ..prev.clone() - }) - }); - let lines = match &prev_lines { - Some(prev) => { - rewire_requirements( - project_root, - prev, - &canon_name, - version, - &rel_wheel, - &artifact.sha256_hex, - ) - .await - } - None => { - wire_requirements( - project_root, - &canon_name, - version, - &rel_wheel, - &artifact.sha256_hex, - ) - .await - } - }; - match lines { - Ok(lines) => { - wiring.extend(lines); - Ok((wiring, MetaSlot::Pipenv(meta))) - } - Err(refusal) => { - // Leave neither file half-wired. - if let Some(bytes) = &lock_before { - let _ = crate::utils::fs::atomic_write_bytes_preserving_mode( - &project_root.join(super::pypi_pipenv::LOCK_FILE), - bytes, - ) - .await; - } - Err(refusal) - } - } - } + Ok((mut wiring, meta)) if sibling => wire_sibling_requirements( + project_root, + &lock_before, + superseded.as_deref(), + &canon_name, + version, + &rel_wheel, + &artifact.sha256_hex, + ) + .await + .map(|lines| { + wiring.extend(lines); + (wiring, MetaSlot::Pipenv(meta)) + }), other => other.map(|(wiring, meta)| (wiring, MetaSlot::Pipenv(meta))), } } @@ -2130,7 +2139,15 @@ async fn fresh_pyproject_plan( match check_target_guards(&project, canon_name, uuid)? { UvTarget::Fresh => { let warnings = project.warnings.clone(); - Ok((WiringPlan::Uv(Box::new(project)), warnings)) + // The revert just put the export's registry pin back + // (#1368): wire it again with the lock. + let sibling = super::pypi_requirements::sibling_pin_wirable( + project_root, + canon_name, + version, + ) + .await; + Ok((WiringPlan::Uv(Box::new(project), sibling), warnings)) } UvTarget::InSync => not_fresh("pypi_uv_source_already_exists"), } @@ -2505,38 +2522,115 @@ fn residual_reference_warning(uuid: &str, clause: &str) -> VendorWarning { ) } -/// Revert a Pipenv entry: the exported requirements lines wired with it -/// (#612) through the requirements backend, then its Pipfile.lock records -/// through the Pipenv backend. The export goes first and is put back if the -/// lock's revert then fails, so a failure on either side leaves both files -/// wired to the vendored wheel (and the entry kept) instead of half +/// The files the uv wiring writes. +const UV_PAIR: &[&str] = &["pyproject.toml", "uv.lock"]; + +/// The bytes of each of `files` that is a regular file now, to put back +/// with [`restore_files`]. +async fn snapshot_files(project_root: &Path, files: &[&str]) -> Vec<(std::path::PathBuf, Vec)> { + let mut snapshot = Vec::new(); + for file in files { + let path = project_root.join(file); + if let Ok(bytes) = read_regular_to_bytes(&path).await { + snapshot.push((path, bytes)); + } + } + snapshot +} + +async fn restore_files(snapshot: &[(std::path::PathBuf, Vec)]) { + for (path, bytes) in snapshot { + let _ = crate::utils::fs::atomic_write_bytes_preserving_mode(path, bytes).await; + } +} + +/// Wire the exported requirements pin beside a just-wired Pipenv or uv lock +/// to the same wheel (#612, #1368): a superseded entry's recorded lines are +/// re-wired in place, a registry pin is rewritten fresh. On a refusal the +/// lock files in `lock_before` are put back, so neither file is left half +/// wired. +#[allow(clippy::too_many_arguments)] +async fn wire_sibling_requirements( + project_root: &Path, + lock_before: &[(std::path::PathBuf, Vec)], + superseded: Option<&VendorEntry>, + canon_name: &str, + version: &str, + rel_wheel: &str, + sha256_hex: &str, +) -> Result, (&'static str, String)> { + let prev_lines = superseded.and_then(|prev| { + let lines: Vec<_> = prev + .wiring + .iter() + .filter(|r| r.kind == super::pypi_requirements::RECORD_KIND) + .cloned() + .collect(); + (!lines.is_empty()).then(|| VendorEntry { + wiring: lines, + ..prev.clone() + }) + }); + let lines = match &prev_lines { + Some(prev) => { + rewire_requirements( + project_root, + prev, + canon_name, + version, + rel_wheel, + sha256_hex, + ) + .await + } + None => wire_requirements(project_root, canon_name, version, rel_wheel, sha256_hex).await, + }; + if lines.is_err() { + restore_files(lock_before).await; + } + lines +} + +/// Revert a Pipenv or uv entry: the exported requirements lines wired with +/// it (#612, #1368) through the requirements backend, then its lock records +/// through the flavor's backend. The export goes first and is put back if +/// the lock's revert then fails, so a failure on either side leaves both +/// files wired to the vendored wheel (and the entry kept) instead of half /// reverted. -async fn revert_pipenv_with_sibling( +async fn revert_with_sibling( entry: &VendorEntry, project_root: &Path, + flavor: PypiFlavor, dry_run: bool, ) -> RevertOutcome { + async fn revert_lock( + flavor: PypiFlavor, + entry: &VendorEntry, + project_root: &Path, + dry_run: bool, + ) -> RevertOutcome { + match flavor { + PypiFlavor::UvProject => revert_uv(entry, project_root, dry_run).await, + _ => super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await, + } + } let (lines, lock): (Vec<_>, Vec<_>) = entry .wiring .iter() .cloned() .partition(|r| r.kind == super::pypi_requirements::RECORD_KIND); if lines.is_empty() { - return super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await; + return revert_lock(flavor, entry, project_root, dry_run).await; } // The exported files as they are now, to put back if the lock fails. - let mut snapshot: Vec<(std::path::PathBuf, Vec)> = Vec::new(); - if !dry_run { + let snapshot = if dry_run { + Vec::new() + } else { let mut files: Vec<&str> = lines.iter().map(|r| r.file.as_str()).collect(); files.sort_unstable(); files.dedup(); - for file in files { - let path = project_root.join(file); - if let Ok(bytes) = crate::utils::fs::read_regular_to_bytes(&path).await { - snapshot.push((path, bytes)); - } - } - } + snapshot_files(project_root, &files).await + }; let lines_entry = VendorEntry { wiring: lines, ..entry.clone() @@ -2549,11 +2643,9 @@ async fn revert_pipenv_with_sibling( wiring: lock, ..entry.clone() }; - let mut outcome = super::pypi_pipenv::revert_pipenv(&lock_entry, project_root, dry_run).await; + let mut outcome = revert_lock(flavor, &lock_entry, project_root, dry_run).await; if !outcome.success { - for (path, bytes) in &snapshot { - let _ = crate::utils::fs::atomic_write_bytes_preserving_mode(path, bytes).await; - } + restore_files(&snapshot).await; return outcome; } outcome.warnings.extend(lines_outcome.warnings); @@ -2626,7 +2718,9 @@ pub async fn revert_pypi_opts( RevertOutcome::ok() } else { match entry.flavor.as_deref() { - Some("uv") => revert_uv(entry, project_root, dry_run).await, + Some("uv") => { + revert_with_sibling(entry, project_root, PypiFlavor::UvProject, dry_run).await + } Some("python-lock") => { super::pypi_lock::revert_python_locks(entry, project_root, dry_run).await } @@ -2634,7 +2728,9 @@ pub async fn revert_pypi_opts( Some("requirements") => revert_requirements(entry, project_root, dry_run).await, Some("poetry") => super::pypi_poetry::revert_poetry(entry, project_root, dry_run).await, Some("pdm") => super::pypi_pdm::revert_pdm(entry, project_root, dry_run).await, - Some("pipenv") => revert_pipenv_with_sibling(entry, project_root, dry_run).await, + Some("pipenv") => { + revert_with_sibling(entry, project_root, PypiFlavor::Pipenv, dry_run).await + } other => { return RevertOutcome::failed(format!( "unknown pypi vendor flavor {other:?}; cannot revert" @@ -2991,25 +3087,33 @@ mod tests { /// lock (`uv export`, `poetry export`) is an install source the /// single-lock wiring leaves UNPATCHED, so it must be named by the /// documented `pypi_multiple_lockfiles` warning — but only when it pins - /// the package being vendored. A Pipenv export's exact pin is wired - /// with Pipfile.lock instead, so it is no loser; one the wiring cannot - /// rewrite (a range) still is. + /// the package being vendored. A Pipenv (#612) or uv (#1368) export's + /// exact pin is wired with the lock instead, so it is no loser; one the + /// wiring cannot rewrite (a range) still is. #[tokio::test] async fn requirements_beside_the_governing_lock_is_a_loud_loser() { - for exported in [ - "idna==3.7\nsix==1.16.0 ; python_version >= \"3\"\n", - "-i https://pypi.org/simple\nsix==1.16.0\n", - ] { + for ((lock, content, flavor), exported) in [ + ("Pipfile.lock", "{}", PypiFlavor::Pipenv), + ("uv.lock", "version = 1\n", PypiFlavor::UvProject), + ] + .into_iter() + .flat_map(|lock| { + [ + "idna==3.7\nsix==1.16.0 ; python_version >= \"3\"\n", + "-i https://pypi.org/simple\nsix==1.16.0\n", + ] + .map(|exported| (lock, exported)) + }) { let tmp = tempfile::tempdir().unwrap(); - touch(tmp.path(), "Pipfile.lock", "{}").await; + touch(tmp.path(), lock, content).await; touch(tmp.path(), "requirements.txt", exported).await; let (selected, warnings) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) .await .unwrap(); - assert_eq!(selected, PypiFlavor::Pipenv); + assert_eq!(selected, flavor, "{lock}"); assert!( warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), - "a wirable Pipenv export is wired, not a loser: {warnings:?}" + "{lock}: a wirable export is wired, not a loser: {warnings:?}" ); touch(tmp.path(), "requirements.txt", "six>=1.15\n").await; let (_, warnings) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0"))) @@ -3021,7 +3125,6 @@ mod tests { ); } for (lock, content, flavor) in [ - ("uv.lock", "version = 1\n", PypiFlavor::UvProject), ("poetry.lock", "", PypiFlavor::Poetry), ("pdm.lock", "", PypiFlavor::Pdm), ] { @@ -7750,6 +7853,156 @@ wheels = [ entry } + /// `uv export --frozen -o requirements.txt` output pinning six from the + /// registry, hashes on continuation lines. + const UV_EXPORT: &str = "# This file was autogenerated by uv via the following command:\n\ + # uv export --frozen -o requirements.txt\n\ + six==1.16.0 \\\n \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \\\n \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\n"; + + /// A uv project whose `uv export` requirements.txt pins six (#1368). + async fn uv_export_fixture() -> E2eFixture { + let fx = e2e_fixture().await; + swap_to_lock_flavor( + &fx, + &[ + ("pyproject.toml", UV_PYPROJECT), + ("uv.lock", UV_LOCK_REGISTRY), + ("requirements.txt", UV_EXPORT), + ], + ) + .await; + fx + } + + async fn uv_export_wired_to(fx: &E2eFixture, uuid: &str) -> bool { + read_requirements(fx) + .await + .contains(&format!(".socket/vendor/pypi/{uuid}/")) + } + + async fn assert_uv_export_restored(fx: &E2eFixture) { + for (file, original) in [ + ("requirements.txt", UV_EXPORT), + ("pyproject.toml", UV_PYPROJECT), + ("uv.lock", UV_LOCK_REGISTRY), + ] { + assert_eq!( + tokio::fs::read_to_string(fx.root.join(file)).await.unwrap(), + original, + "{file} not restored" + ); + } + } + + /// #1368: a `uv export` requirements.txt beside uv.lock is a second + /// install source (Docker / plain pip). Vendored mode wires both, as + /// hosted mode does, so `vendor --check` and `vex` see no contest, and + /// revert restores all three files byte for byte. + #[tokio::test] + async fn uv_vendor_wires_the_exported_sibling_requirements() { + let fx = uv_export_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { + result, + entry, + warnings, + } = vendor_six(&fx, &sources, None).await + else { + panic!("uv vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + assert!( + uv_export_wired_to(&fx, UUID).await, + "requirements.txt left unpatched:\n{}", + read_requirements(&fx).await + ); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + let discovery = crate::vex::discover::discover_patched_refs(&fx.root).await; + assert!(discovery.contested.is_empty(), "{:?}", discovery.contested); + let wired_in: std::collections::BTreeSet = discovery + .refs + .iter() + .filter(|r| r.uuid == UUID) + .map(|r| r.source_file.to_string_lossy().replace('\\', "/")) + .collect(); + assert!( + wired_in.contains("uv.lock") && wired_in.contains("requirements.txt"), + "{:?}", + discovery.refs + ); + + let entry = entry.expect("a wet vendor records its entry"); + assert_eq!(entry.flavor.as_deref(), Some("uv")); + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(!outcome.kept_artifact, "{:?}", outcome.warnings); + assert_uv_export_restored(&fx).await; + } + + /// #1368 on an in-sync re-run: an export made after uv.lock was + /// vendored is wired to the committed wheel and recorded once; a + /// re-export replaces that record instead of adding a duplicate. + #[tokio::test] + async fn uv_in_sync_rerun_wires_a_later_export_once() { + let fx = e2e_fixture().await; + let entry = vendor_uv_project(&fx).await; + save_ledger_entry(&fx.root, &entry).await; + let sources = PatchSources::blobs_only(&fx.blobs); + for _ in 0..2 { + touch(&fx.root, "requirements.txt", UV_EXPORT).await; + let VendorOutcome::Done { + entry: Some(entry), .. + } = vendor_six(&fx, &sources, None).await + else { + panic!("the in-sync re-run must record the export's wiring"); + }; + assert!(uv_export_wired_to(&fx, UUID).await); + assert_eq!(requirement_records(&entry), 1, "{:?}", entry.wiring); + save_ledger_entry(&fx.root, &entry).await; + } + } + + /// #1368: a superseding patch uuid re-wires both uv.lock and the export + /// to the new wheel, and reverting the new entry restores the + /// pre-vendor registry pins of all three files. + #[tokio::test] + async fn uv_superseding_uuid_rewires_the_export() { + const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; + let fx = uv_export_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { + entry: Some(first), .. + } = vendor_six(&fx, &sources, None).await + else { + panic!("first vendor must be Done"); + }; + save_ledger_entry(&fx.root, &first).await; + + let outcome = vendor_six_as(&fx, &sources, UUID2).await; + let VendorOutcome::Done { + result, + entry: Some(second), + .. + } = outcome + else { + panic!("superseding uuid must re-vendor, got {outcome:?}"); + }; + assert!(result.success, "{:?}", result.error); + assert!(uv_export_wired_to(&fx, UUID2).await); + assert!(!uv_export_wired_to(&fx, UUID).await); + assert_eq!(requirement_records(&second), 1, "{:?}", second.wiring); + + save_ledger_entry(&fx.root, &second).await; + let reverted = revert_pypi(&second, &fx.root, false).await; + assert!(reverted.success, "{:?}", reverted.error); + assert_uv_export_restored(&fx).await; + } + /// #996: a `uv export`-ed requirements.txt or pylock.toml still names /// the vendored wheel after the uv pair is restored, so the wheel (and /// the ledger entry) must stay; once the export is regenerated, the From 73184f56819f375cf9d2299ef420bc1f5f56cf0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:39:43 +0000 Subject: [PATCH 3/6] Document uv export co-wiring in the contract The pypi_multiple_lockfiles row now says an exact pin in a uv export requirements.txt is wired with uv.lock, as one beside Pipfile.lock is, and the #1120 CLI test keeps covering the UTF-16 exports that the requirements wiring still refuses. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/mode_migration_pypi.rs | 76 ++++++++++++++++--- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index abfd8da37..a1fde80ec 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1418,7 +1418,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_unconfirmed` | `skipped` (`details.mode: "hosted"`) | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | | `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. | -| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` output) is wired with the lock instead and never named here (#612): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, and a re-run over a project whose `Pipfile.lock` is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. | +| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. | | `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | | `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. | | `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. | diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 448ba51af..7723fadb1 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -1625,15 +1625,71 @@ async fn vendor_check_fails_after_hatch_dependency_reset() { assert_check_catches_relock(&root, files); } +/// #1368: a `uv export` requirements.txt beside `uv.lock` is an install +/// source of its own (Docker / plain pip). `vendor` wires its exact pin to +/// the same committed wheel as uv.lock, `vendor --check` passes with no +/// contest, a re-run is settled, and `vendor --revert` restores all three +/// files byte for byte. +#[tokio::test] +async fn uv_requirements_export_is_wired_with_the_lock() { + let export = "six==1.16.0 \\\n --hash=sha256:WHEEL_SHA\n".replace("WHEEL_SHA", WHEEL_SHA); + let (_tmp, root) = project(); + let files = stage_uv(&root); + std::fs::write(root.join("requirements.txt"), &export).unwrap(); + let originals: Vec<(&str, Vec)> = files + .iter() + .chain(&["requirements.txt"]) + .map(|f| (*f, std::fs::read(root.join(f)).unwrap())) + .collect(); + stage_manifest(&root); + let needle = format!(".socket/vendor/pypi/{UUID}/"); + + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + for file in ["uv.lock", "requirements.txt"] { + assert!( + std::fs::read_to_string(root.join(file)) + .unwrap() + .contains(&needle), + "{file} is wired: {env:#}" + ); + } + assert!( + !env.to_string().contains("pypi_multiple_lockfiles"), + "the export is wired, not an unpatched loser: {env:#}" + ); + + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!(code, 0, "both install sources are wired: {env:#}"); + + // Re-running is settled: nothing rewritten. + let wired = std::fs::read(root.join("requirements.txt")).unwrap(); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(std::fs::read(root.join("requirements.txt")).unwrap(), wired); + + let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]); + assert_eq!(code, 0, "{env:#}"); + for (file, bytes) in &originals { + assert_eq!( + &std::fs::read(root.join(file)).unwrap(), + bytes, + "{file} is restored" + ); + } + assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists()); +} + /// #1120: a `requirements.txt` exported beside `uv.lock` pins the -/// package from PyPI, so the uv-only wiring leaves installs from it -/// unpatched. `uv export > requirements.txt` in Windows PowerShell 5.1 -/// writes it as UTF-16 with a byte-order mark, which pip and uv read like -/// its UTF-8 twin, so `vendor` must name it (`pypi_multiple_lockfiles`) -/// and `vendor --check` must fail on the contested wiring in every -/// encoding, not pass as if the file were absent. +/// package from PyPI. `uv export > requirements.txt` in Windows +/// PowerShell 5.1 writes it as UTF-16 with a byte-order mark, which pip +/// and uv read like its UTF-8 twin but the requirements wiring refuses to +/// rewrite, so the uv-only wiring leaves installs from it unpatched: +/// `vendor` must name it (`pypi_multiple_lockfiles`) and `vendor --check` +/// must fail on the contested wiring, not pass as if the file were absent. +/// (Its UTF-8 twin is wired with the lock, #1368.) #[tokio::test] -async fn uv_requirements_export_contests_the_wiring_in_any_encoding() { +async fn uv_requirements_export_contests_the_wiring_in_utf16() { let text = "six==1.16.0\r\n"; let le: Vec = [0xFF, 0xFE] .into_iter() @@ -1643,11 +1699,7 @@ async fn uv_requirements_export_contests_the_wiring_in_any_encoding() { .into_iter() .chain(text.encode_utf16().flat_map(u16::to_be_bytes)) .collect(); - for (case, bytes) in [ - ("utf-8", text.as_bytes().to_vec()), - ("utf-16 le", le), - ("utf-16 be", be), - ] { + for (case, bytes) in [("utf-16 le", le), ("utf-16 be", be)] { let (_tmp, root) = project(); stage_uv(&root); std::fs::write(root.join("requirements.txt"), &bytes).unwrap(); From f098faf4f247ba6c1e5dd901b5175df9472428d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:47:28 +0000 Subject: [PATCH 4/6] Reuse uv's own wiring-pair constant Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 5 +---- crates/socket-patch-core/src/vendor/pypi_uv.rs | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 733c45b20..1bbea2d86 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1648,7 +1648,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { WiringPlan::Uv(project, sibling) => { let lock_before = if sibling { - snapshot_files(project_root, UV_PAIR).await + snapshot_files(project_root, &super::pypi_uv::UV_PAIR).await } else { Vec::new() }; @@ -2522,9 +2522,6 @@ fn residual_reference_warning(uuid: &str, clause: &str) -> VendorWarning { ) } -/// The files the uv wiring writes. -const UV_PAIR: &[&str] = &["pyproject.toml", "uv.lock"]; - /// The bytes of each of `files` that is a regular file now, to put back /// with [`restore_files`]. async fn snapshot_files(project_root: &Path, files: &[&str]) -> Vec<(std::path::PathBuf, Vec)> { diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 1ebb645d7..86747ab31 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1487,7 +1487,7 @@ fn locate_lock_array( /// The two files this backend edits — both are checked for symlinks before /// any write (uv itself writes through a link; the atomic rename would /// replace it) and re-verified against the pre-flight snapshot. -const UV_PAIR: [&str; 2] = ["pyproject.toml", "uv.lock"]; +pub(super) const UV_PAIR: [&str; 2] = ["pyproject.toml", "uv.lock"]; /// Whether a header for this `[tool.uv…]` table would be socket-patch's own /// bytes once a key is added: the table is absent, or exists only From 9a0f05fd26adb15b377f78af2559de3eceddacbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:05:52 +0000 Subject: [PATCH 5/6] Harden uv and Pipenv export wiring from review - A symlinked requirements.txt or -r include can't be rewritten in place, so it is no longer treated as wirable. Those projects vendor the lock and name the export as before, instead of failing the run. - A hosted takeover of a uv or Pipenv entry whose export pin sits in a -r include is refused before the revert, as it already is for a requirements-flavor entry, since hosted mode re-pins only the root requirements.txt. - A fresh --dry-run now previews the export wiring. - Rollback after a refused export reuses restore_snapshot, so a file that can't be put back is named in the error. - The flavor matches for the sibling lock and its revert are exhaustive, and the doc comments no longer say Pipenv only. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/pypi_takeover.rs | 66 ++++++++ crates/socket-patch-core/src/vendor/pypi.rs | 155 +++++++++++++++--- .../src/vendor/pypi_requirements.rs | 38 +++-- 3 files changed, 216 insertions(+), 43 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs index 2aeeeef06..ca937cf07 100644 --- a/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs @@ -28,6 +28,7 @@ pub async fn preflight_pypi_takeover( if entry.ecosystem != "pypi" { return Ok(()); } + preflight_sibling_requirements_takeover(entry)?; match entry.flavor.as_deref() { Some("requirements") => preflight_requirements_takeover(entry), Some("uv") => preflight_uv_takeover(entry), @@ -36,6 +37,31 @@ pub async fn preflight_pypi_takeover( } } +/// A Pipenv or uv entry also carries the `requirements_line` records of +/// the exported requirements pin wired with its lock (#612, #1368). After +/// the revert, the hosted requirements rewriter re-pins only the root +/// requirements.txt, so a line wired in a `-r` include is refused exactly +/// as for a requirements-flavor entry. +fn preflight_sibling_requirements_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> { + if !matches!(entry.flavor.as_deref(), Some("uv" | "pipenv")) { + return Ok(()); + } + let lines: Vec<_> = entry + .wiring + .iter() + .filter(|r| r.kind == "requirements_line") + .cloned() + .collect(); + if lines.is_empty() { + return Ok(()); + } + preflight_requirements_takeover(&VendorEntry { + flavor: Some("requirements".into()), + wiring: lines, + ..entry.clone() + }) +} + /// Vendored uv pins the lock's `[[package]]` unit to the patch's version /// even when the lock resolved another one (#723). The revert restores the /// recorded pre-vendor unit, and the hosted uv rewriter only pins an entry @@ -178,6 +204,46 @@ mod tests { assert!(detail.contains("EVERY vendored package"), "{detail}"); } + fn requirements_line(file: &str) -> WiringRecord { + WiringRecord { + file: file.into(), + kind: "requirements_line".into(), + action: WiringAction::Rewritten, + key: Some(format!("{file}:1")), + original: None, + new: None, + } + } + + /// #1368 / #612: the exported pin wired with a uv or Pipenv lock is + /// re-pinned by the hosted requirements rewriter, which reaches only + /// the root requirements.txt; one wired in a `-r` include is refused + /// before the revert, a root one is admitted. + #[tokio::test] + async fn sibling_export_in_an_include_is_refused() { + let root = tempfile::tempdir().unwrap(); + for (flavor, lock) in [ + ("uv", uv_package("1.16.0")), + ("pipenv", uv_package("1.16.0")), + ] { + let e = entry( + flavor, + vec![lock.clone(), requirements_line("requirements.txt")], + ); + assert!( + preflight_pypi_takeover(root.path(), &e).await.is_ok(), + "{flavor}" + ); + let e = entry(flavor, vec![lock, requirements_line("req/base.txt")]); + let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err(); + assert_eq!( + err.code, "redirect_requirements_takeover_unreachable", + "{flavor}" + ); + assert!(err.detail.contains("req/base.txt"), "{}", err.detail); + } + } + #[test] fn uv_entry_at_the_patch_version_is_admitted() { assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok()); diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 1bbea2d86..4c1b47014 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -958,6 +958,9 @@ async fn pypi_prelude<'p>( version, ) .await; + if sibling && dry_run { + warnings.push(sibling_dry_run_notice(flavor, &canon_name, version)); + } WiringPlan::Uv(Box::new(project), sibling) } Err(refusal) => { @@ -1148,6 +1151,9 @@ async fn pypi_prelude<'p>( WiringPlan::InSync } PipenvTarget::Fresh => { + if sibling && dry_run { + warnings.push(sibling_dry_run_notice(flavor, &canon_name, version)); + } WiringPlan::Pipenv(Box::new(project), superseded.map(Box::new), sibling) } } @@ -1265,6 +1271,34 @@ async fn pypi_prelude<'p>( }) } +/// The lock an exported requirements pin is wired with, and the command +/// that exports it, for the flavors that co-wire one (#612, #1368). +fn sibling_lock(flavor: PypiFlavor) -> Option<(&'static str, &'static str)> { + match flavor { + PypiFlavor::Pipenv => Some((super::pypi_pipenv::LOCK_FILE, "pipenv requirements")), + PypiFlavor::UvProject => Some(("uv.lock", "uv export --frozen -o requirements.txt")), + PypiFlavor::PythonLocks + | PypiFlavor::Poetry + | PypiFlavor::Pdm + | PypiFlavor::Requirements + | PypiFlavor::Hatch => None, + } +} + +/// The dry-run preview of a fresh vendor that wires the exported +/// requirements pin together with `flavor`'s lock. +fn sibling_dry_run_notice(flavor: PypiFlavor, canon_name: &str, version: &str) -> VendorWarning { + let lock = sibling_lock(flavor).map_or("the lock", |(lock, _)| lock); + VendorWarning::new( + "pypi_requirements_sibling_wired", + format!( + "would wire the exported {} pin of {canon_name}=={version} to the vendored wheel \ + together with {lock}", + crate::formats::governing_locks::PYPI_REQUIREMENTS + ), + ) +} + /// #612 / #1368 for a Pipenv or uv lock already wired to this patch: wire /// the exported requirements pin to the same committed wheel and extend /// the ledger entry with its records, so the revert restores both files. @@ -1283,9 +1317,8 @@ async fn wire_in_sync_sibling( dry_run: bool, mut warnings: Vec, ) -> VendorOutcome { - let (lock, export) = match flavor { - PypiFlavor::UvProject => ("uv.lock", "uv export --frozen -o requirements.txt"), - _ => (super::pypi_pipenv::LOCK_FILE, "pipenv requirements"), + let Some((lock, export)) = sibling_lock(flavor) else { + return done(result, None, warnings); }; let unwired = |why: &str| { VendorWarning::new( @@ -1647,8 +1680,19 @@ pub async fn vendor_pypi_with_pipenv_version<'a>( }; let wired: Result<(Vec<_>, MetaSlot), (&'static str, String)> = match plan { WiringPlan::Uv(project, sibling) => { - let lock_before = if sibling { - snapshot_files(project_root, &super::pypi_uv::UV_PAIR).await + // The pair as `wire_uv` checks it still is, to put back if the + // export then refuses. + let lock_before: Snapshot = if sibling { + vec![ + ( + "pyproject.toml".into(), + Some(project.pyproject_text.clone().into_bytes()), + ), + ( + "uv.lock".into(), + Some(project.lock_text.clone().into_bytes()), + ), + ] } else { Vec::new() }; @@ -2522,25 +2566,17 @@ fn residual_reference_warning(uuid: &str, clause: &str) -> VendorWarning { ) } -/// The bytes of each of `files` that is a regular file now, to put back -/// with [`restore_files`]. -async fn snapshot_files(project_root: &Path, files: &[&str]) -> Vec<(std::path::PathBuf, Vec)> { - let mut snapshot = Vec::new(); +/// The current bytes of each of `files` (`None` when absent), to put back +/// with [`restore_snapshot`]. +async fn snapshot_files(project_root: &Path, files: &[&str]) -> Snapshot { + let mut snapshot = Snapshot::new(); for file in files { - let path = project_root.join(file); - if let Ok(bytes) = read_regular_to_bytes(&path).await { - snapshot.push((path, bytes)); - } + let bytes = read_regular_to_bytes(&project_root.join(file)).await.ok(); + snapshot.push((file.to_string(), bytes)); } snapshot } -async fn restore_files(snapshot: &[(std::path::PathBuf, Vec)]) { - for (path, bytes) in snapshot { - let _ = crate::utils::fs::atomic_write_bytes_preserving_mode(path, bytes).await; - } -} - /// Wire the exported requirements pin beside a just-wired Pipenv or uv lock /// to the same wheel (#612, #1368): a superseded entry's recorded lines are /// re-wired in place, a registry pin is rewritten fresh. On a refusal the @@ -2549,7 +2585,7 @@ async fn restore_files(snapshot: &[(std::path::PathBuf, Vec)]) { #[allow(clippy::too_many_arguments)] async fn wire_sibling_requirements( project_root: &Path, - lock_before: &[(std::path::PathBuf, Vec)], + lock_before: &Snapshot, superseded: Option<&VendorEntry>, canon_name: &str, version: &str, @@ -2582,10 +2618,13 @@ async fn wire_sibling_requirements( } None => wire_requirements(project_root, canon_name, version, rel_wheel, sha256_hex).await, }; - if lines.is_err() { - restore_files(lock_before).await; + match lines { + Ok(lines) => Ok(lines), + Err((code, mut detail)) => { + restore_snapshot(project_root, lock_before, &mut detail).await; + Err((code, detail)) + } } - lines } /// Revert a Pipenv or uv entry: the exported requirements lines wired with @@ -2608,7 +2647,13 @@ async fn revert_with_sibling( ) -> RevertOutcome { match flavor { PypiFlavor::UvProject => revert_uv(entry, project_root, dry_run).await, - _ => super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await, + PypiFlavor::Pipenv => { + super::pypi_pipenv::revert_pipenv(entry, project_root, dry_run).await + } + other => RevertOutcome::failed(format!( + "no exported-requirements revert for the {} flavor", + other.as_str() + )), } } let (lines, lock): (Vec<_>, Vec<_>) = entry @@ -2642,7 +2687,9 @@ async fn revert_with_sibling( }; let mut outcome = revert_lock(flavor, &lock_entry, project_root, dry_run).await; if !outcome.success { - restore_files(&snapshot).await; + let mut detail = outcome.error.take().unwrap_or_default(); + restore_snapshot(project_root, &snapshot, &mut detail).await; + outcome.error = Some(detail); return outcome; } outcome.warnings.extend(lines_outcome.warnings); @@ -7964,6 +8011,64 @@ wheels = [ } } + /// #1368: a symlinked export cannot be rewritten in place (the rename + /// would replace the link), so it stays a named loser and uv.lock is + /// still vendored, instead of failing the whole run. + #[cfg(unix)] + #[tokio::test] + async fn uv_symlinked_export_stays_a_loser() { + let fx = uv_export_fixture().await; + tokio::fs::remove_file(fx.root.join("requirements.txt")) + .await + .unwrap(); + touch(&fx.root, "exported.txt", UV_EXPORT).await; + std::os::unix::fs::symlink("exported.txt", fx.root.join("requirements.txt")).unwrap(); + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { + result, warnings, .. + } = vendor_six(&fx, &sources, None).await + else { + panic!("uv vendor must be Done"); + }; + assert!(result.success, "{:?}", result.error); + assert!( + warnings.iter().any(|w| w.code == "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + assert!(tokio::fs::read_to_string(fx.root.join("uv.lock")) + .await + .unwrap() + .contains(UUID)); + assert_eq!(read_requirements(&fx).await, UV_EXPORT); + assert!(std::fs::symlink_metadata(fx.root.join("requirements.txt")) + .unwrap() + .is_symlink()); + } + + /// #1368: the dry run of a fresh uv vendor previews the export wiring + /// and writes nothing. + #[tokio::test] + async fn uv_dry_run_previews_the_export_wiring() { + let fx = uv_export_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { warnings, .. } = + vendor_six_as_opts(&fx, &sources, UUID, true).await + else { + panic!("the dry run must be Done"); + }; + assert!( + warnings.iter().any( + |w| w.code == "pypi_requirements_sibling_wired" && w.detail.contains("uv.lock") + ), + "{warnings:?}" + ); + assert!( + warnings.iter().all(|w| w.code != "pypi_multiple_lockfiles"), + "{warnings:?}" + ); + assert_uv_export_restored(&fx).await; + } + /// #1368: a superseding patch uuid re-wires both uv.lock and the export /// to the new wheel, and reverting the new entry restores the /// pre-vendor registry pins of all three files. diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 7ec9241a2..28b57887b 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -271,13 +271,14 @@ fn wired_pin_in(content: &str, canon_name: &str, record_uuid: &str) -> Option<(S }) } -/// Whether the requirements set beside a Pipenv lock (`pipenv requirements -/// > requirements.txt`, the Docker / plain-pip install path) pins -/// `canon_name==version` exactly from the registry in a shape the wiring -/// rewrites in place, with no vendor line for the package yet (#612). -/// Vendored Pipenv wires such a pin together with `Pipfile.lock`, as hosted -/// mode rewrites both; anything else (no pin, a range, extras, an include -/// outside the root, an existing vendor line) is left to the +/// Whether the requirements set beside a Pipenv or uv lock (`pipenv +/// requirements > requirements.txt`, `uv export -o requirements.txt`: the +/// Docker / plain-pip install path) pins `canon_name==version` exactly from +/// the registry in a shape the wiring rewrites in place, with no vendor line +/// for the package yet (#612, #1368). Vendored Pipenv and uv wire such a pin +/// together with their lock, as hosted mode rewrites both; anything else (no +/// pin, a range, extras, an include outside the root, an existing vendor +/// line, a symlinked file the rewrite would replace) is left to the /// `pypi_multiple_lockfiles` warning. pub(super) async fn sibling_pin_wirable(root: &Path, canon_name: &str, version: &str) -> bool { let Ok(files) = collect_requirements_files(root).await else { @@ -289,19 +290,20 @@ pub(super) async fn sibling_pin_wirable(root: &Path, canon_name: &str, version: { return false; } - plan_requirements(root, canon_name, version, "", "") - .await - .is_ok_and(|plan| { - !plan.is_empty() - && plan - .iter() - .flat_map(|f| f.records.iter()) - .all(|r| r.action == WiringAction::Rewritten) - }) + let Ok(plan) = plan_requirements(root, canon_name, version, "", "").await else { + return false; + }; + let rels: Vec<&str> = plan.iter().map(|f| f.rel.as_str()).collect(); + !plan.is_empty() + && plan + .iter() + .flat_map(|f| f.records.iter()) + .all(|r| r.action == WiringAction::Rewritten) + && refuse_symlinked(root, &rels).await.is_ok() } -/// The `requirements_line` records of a mixed entry (a Pipenv entry that -/// also wired its exported requirements file, #612). +/// The `requirements_line` records of a mixed entry (a Pipenv or uv entry +/// that also wired its exported requirements file, #612 / #1368). pub(super) const RECORD_KIND: &str = "requirements_line"; /// Refuse a wheel path or digest that cannot sit on one requirements line From c1f669ba7939ba625ed4972979b83c0040edcff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:38:53 +0000 Subject: [PATCH 6/6] Keep the export wired when the lock revert drift-keeps - revert_with_sibling reverted the export, then let a drift-kept lock revert (uv's pair gate writes neither file) leave uv.lock wired. The export is now put back on the wheel too, matching the kept entry, so a re-run reverts both once the drift is undone. - snapshot_files recorded an unreadable file as absent, so a rollback could delete it. Only NotFound is recorded as absent now; anything else unreadable is left out and left alone. Refs #1368 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01645q8Fr6CEo5KdPqh3Ne7t --- crates/socket-patch-core/src/vendor/pypi.rs | 76 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 4c1b47014..ed00f46af 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2567,12 +2567,19 @@ fn residual_reference_warning(uuid: &str, clause: &str) -> VendorWarning { } /// The current bytes of each of `files` (`None` when absent), to put back -/// with [`restore_snapshot`]. +/// with [`restore_snapshot`]. A file that exists but can't be read as a +/// regular file (a symlink, a FIFO, EACCES) is left out, so the restore +/// leaves it alone instead of deleting it as absent. async fn snapshot_files(project_root: &Path, files: &[&str]) -> Snapshot { let mut snapshot = Snapshot::new(); for file in files { - let bytes = read_regular_to_bytes(&project_root.join(file)).await.ok(); - snapshot.push((file.to_string(), bytes)); + match read_regular_to_bytes(&project_root.join(file)).await { + Ok(bytes) => snapshot.push((file.to_string(), Some(bytes))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + snapshot.push((file.to_string(), None)) + } + Err(_) => {} + } } snapshot } @@ -2692,6 +2699,23 @@ async fn revert_with_sibling( outcome.error = Some(detail); return outcome; } + // A drift-kept lock (uv's pair gate writes neither file) makes the + // caller keep the artifact and the whole ledger entry, export lines + // included. Put the export back on the wheel too, so the tree matches + // that entry and a re-run reverts both once the drift is undone. + if outcome.drift_skipped() { + let mut detail = String::new(); + restore_snapshot(project_root, &snapshot, &mut detail).await; + if !detail.is_empty() { + outcome.warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "{} kept wired with the drifted lock{detail}", + crate::formats::governing_locks::PYPI_REQUIREMENTS + ), + )); + } + } outcome.warnings.extend(lines_outcome.warnings); outcome.kept_artifact |= lines_outcome.kept_artifact; outcome @@ -7988,6 +8012,52 @@ wheels = [ assert_uv_export_restored(&fx).await; } + /// #1368: when uv.lock drifted since vendoring, the pair gate keeps + /// the lock wired and the entry kept; the export must stay wired with + /// it, not be reverted alone. + #[tokio::test] + async fn uv_drifted_lock_keeps_the_export_wired() { + let fx = uv_export_fixture().await; + let sources = PatchSources::blobs_only(&fx.blobs); + let VendorOutcome::Done { + entry: Some(entry), .. + } = vendor_six(&fx, &sources, None).await + else { + panic!("uv vendor must record its entry"); + }; + let lock = tokio::fs::read_to_string(fx.root.join("uv.lock")) + .await + .unwrap(); + let drifted = lock.replacen(&format!("{UUID}/"), &format!("{UUID}//"), 1); + assert_ne!(lock, drifted); + touch(&fx.root, "uv.lock", &drifted).await; + let wired_export = read_requirements(&fx).await; + + let outcome = revert_pypi(&entry, &fx.root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.kept_artifact, "{:?}", outcome.warnings); + assert_eq!(read_requirements(&fx).await, wired_export); + assert!(uv_export_wired_to(&fx, UUID).await); + } + + /// An export that can't be read as a regular file is left out of the + /// rollback snapshot, so the restore never deletes it as absent. + #[tokio::test] + async fn snapshot_skips_unreadable_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + tokio::fs::write(root.join("real.txt"), "x").await.unwrap(); + tokio::fs::create_dir(root.join("dir.txt")).await.unwrap(); + let snapshot = snapshot_files(root, &["real.txt", "dir.txt", "gone.txt"]).await; + assert_eq!( + snapshot, + vec![ + ("real.txt".to_string(), Some(b"x".to_vec())), + ("gone.txt".to_string(), None), + ] + ); + } + /// #1368 on an in-sync re-run: an export made after uv.lock was /// vendored is wired to the committed wheel and recorded once; a /// re-export replaces that record instead of adding a duplicate.