diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index c3b431d85..c0da761ad 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -946,6 +946,125 @@ fn uv_vendor_revert_sub_table_sources() { ); } +/// Vendor six on a REAL uv project, run `relock` (a uv command the user +/// runs after vendoring that re-serializes a lock array around our +/// element), then `vendor --revert`. The revert must recognise our element +/// inside uv's re-serialized array and unwind BOTH files: no drift, nothing +/// left routing through `.socket/vendor`, and `uv lock --check` accepts +/// the pair (#806, #821). Before the fix the pyproject side reverted alone +/// and `uv sync --locked` failed while revert reported success. +fn uv_relock_then_revert(tag: &str, pyproject: &str, relock: &[&str]) { + let Some((uv, python)) = capstone_uv(tag) else { + return; + }; + bake_leak_guards(); + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let cache = tmp.path().join("uv-cache"); + let mut cache_env: Vec<(&str, &str)> = vec![("UV_CACHE_DIR", cache.to_str().unwrap())]; + if let Some(py) = python.as_deref() { + cache_env.push(("UV_PYTHON", py)); + } + std::fs::write(proj.join("pyproject.toml"), pyproject).unwrap(); + for step in [&["lock", "-q"][..], &["sync", "-q"][..]] { + let out = tool(&uv, &proj, step, &cache_env); + if !out.status.success() { + println!( + "SKIP e2e_vendor_pypi_build({tag}): `uv {}` failed (PyPI unreachable?):\n{}", + step[0], + String::from_utf8_lossy(&out.stderr) + ); + return; + } + } + let installed_six = site_packages(&proj.join(".venv")).join("six.py"); + stage_patch(&proj, &installed_six); + + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_vendored_applied(&parse_envelope(&stdout)); + let check = tool(&uv, &proj, &["lock", "--check"], &cache_env); + assert_tool_ok(&check, "`uv lock --check` on the wired pair"); + + let out = tool(&uv, &proj, relock, &cache_env); + if !out.status.success() { + println!( + "SKIP e2e_vendor_pypi_build({tag}): `uv {}` failed (PyPI unreachable?):\n{}", + relock.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + return; + } + let relocked = std::fs::read_to_string(proj.join("uv.lock")).unwrap(); + assert!( + relocked.contains(".socket/vendor/pypi/"), + "the relock must keep six vendored: {relocked}" + ); + + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--revert", + "--json", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let renv = parse_envelope(&stdout); + assert_eq!(renv["status"], "success", "revert envelope: {renv}"); + assert!( + !stdout.contains("vendor_lock_entry_drifted"), + "uv re-serializing the array around our element is not drift: {renv}" + ); + for file in ["pyproject.toml", "uv.lock"] { + let text = std::fs::read_to_string(proj.join(file)).unwrap(); + assert!( + !text.contains(".socket/vendor"), + "{file} must be fully unwired:\n{text}" + ); + } + let check = tool(&uv, &proj, &["lock", "--check"], &cache_env); + assert_tool_ok(&check, "`uv lock --check` after the revert"); + assert!( + !proj.join(".socket/vendor").exists(), + ".socket/vendor must be fully removed after revert" + ); +} + +/// #821: six in a PEP 735 dev group, then `uv add --dev zipp` rewrites the +/// whole `requires-dev` group line. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_dev_group_relock() { + uv_relock_then_revert( + "uv-dev-group-relock", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = []\n\n[dependency-groups]\ndev = [\"six==1.16.0\", \"attrs>=20\"]\n", + &["add", "-q", "--dev", "zipp"], + ); +} + +/// #806: six arrives transitively next to a user-authored +/// `override-dependencies`, then `uv add idna` re-serializes `[manifest] +/// overrides` sorted and multi-line. +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_manifest_overrides_relock() { + uv_relock_then_revert( + "uv-overrides-relock", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"python-dateutil==2.9.0.post0\"]\n\n[tool.uv]\nconstraint-dependencies = [\"six==1.16.0\"]\noverride-dependencies = [\"attrs>=20\"]\n", + &["add", "-q", "idna==3.7"], + ); +} + /// `get --mode vendored` twin of the uv capstone above (v3.6): the /// SAME vendor engine and wiring, driven through get's uuid path — exempt /// from installed narrowing, so only the mocked `view/{uuid}` route is diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 3b602ba6b..1b2d533cb 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -826,10 +826,20 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - ) }; match rec.kind.as_str() { - "uv_lock_package" - | "uv_lock_requires_dist" - | "uv_lock_requires_dev" - | "uv_lock_manifest_constraints" => { + // Whole-array records are anchored on their key, never matched + // by text anywhere in the lock: a byte-identical array under + // another key (`build-constraints` holds `constraints = [`, a + // root `requires-dist` can equal the user's overrides) would + // otherwise splice the wrong array or fake convergence. + "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { + match revert_array_elements(&lock_text, &rec.kind, new_text, original_text, &needle) + { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + } + } + "uv_lock_package" | "uv_lock_requires_dist" => { match replace_fragment(&lock_text, new_text, original_text) { Some(t) => lock_text = t, None => { @@ -878,18 +888,21 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - } } } - WiringAction::Rewritten => { - match replace_fragment(&lock_text, new_text, original_text) { - Some(t) => lock_text = t, - None => { - // ALREADY CONVERGED: see the package-unit arm. - if original_text.is_some_and(|orig| lock_text.contains(orig)) { - continue; - } - warnings.push(drifted("uv.lock")); - } - } - } + // The record holds the bare array, so only the `overrides` + // key can anchor it (see the whole-array arm above). uv + // re-serializes it sorted and multi-line on any relock that + // touches it (#806). + WiringAction::Rewritten => match revert_array_elements( + &lock_text, + &rec.kind, + new_text, + original_text, + &needle, + ) { + ArrayRevert::Reverted(t) => lock_text = t, + ArrayRevert::Converged => {} + ArrayRevert::Drift => warnings.push(drifted("uv.lock")), + }, }, "uv_sources_entry" => { let Some(new) = new_text else { @@ -965,7 +978,16 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - } } - if !dry_run { + // PAIR GATE (docs/testing/uv-compatibility.md: "conflicting changes + // preserve both files"): pyproject.toml and uv.lock are one unit. When + // any record was drift-kept, restoring the rest would leave one file + // wired and the other not, which `uv sync --locked` rejects (#806, + // #821). Write neither; the drift warning keeps the artifact and ledger + // entry so a re-run can finish once the drift is undone. + let drift_kept = warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"); + if !dry_run && !drift_kept { // Reverse of the wire order: the lock first, then the pyproject. let write = atomic_write_bytes_preserving_mode(&lock_path, lock_text.as_bytes()).await; LOCK_MEMO.invalidate(); @@ -999,6 +1021,231 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // ── helpers ────────────────────────────────────────────────────────────── +/// Result of [`revert_array_elements`]. +enum ArrayRevert { + /// Our elements were restored or removed; the new lock text. + Reverted(String), + /// Nothing of ours is left in the array: the reverted state already + /// holds (the LIVENESS CONTRACT; silent). + Converged, + /// Our element is gone but the array still routes through the + /// artifact, or the record can't be read: genuine drift. + Drift, +} + +/// Where a whole-array lock record lives. +#[derive(Clone, Copy)] +enum ArrayScope { + /// The root unit's `[package.metadata.requires-dev]` sub-table. + RootRequiresDev, + /// The top-level `[manifest]` table. + Manifest, +} + +/// Key-anchored revert of a record that captured a WHOLE lock array +/// (`uv_lock_requires_dev` / `uv_lock_manifest_constraints` as a ` = +/// […]` line, `uv_lock_manifest_overrides` Rewritten as the bare array). +/// uv re-serializes those arrays on any relock that touches a sibling +/// element (`uv add --dev x`, `uv add y` sorting `[manifest] overrides` +/// into its multi-line form), so the recorded text stops matching while +/// our element is byte-identical inside. An array still holding exactly the +/// recorded text is restored verbatim; otherwise diff the recorded old/new +/// arrays into our element edits (an element rewritten in place, or one appended), +/// apply them to the live array under the same key, and re-render the +/// array the way uv writes it: one element inline, more one per line. +fn revert_array_elements( + lock_text: &str, + kind: &str, + new: Option<&str>, + orig: Option<&str>, + needle: &str, +) -> ArrayRevert { + let (Some(new), Some(orig)) = (new, orig) else { + return ArrayRevert::Drift; + }; + let (scope, key, old_array, new_array) = match kind { + "uv_lock_requires_dev" | "uv_lock_manifest_constraints" => { + let (Some((key, old_arr)), Some((new_key, new_arr))) = + (orig.split_once(" = "), new.split_once(" = ")) + else { + return ArrayRevert::Drift; + }; + if key != new_key { + return ArrayRevert::Drift; + } + let scope = if kind == "uv_lock_requires_dev" { + ArrayScope::RootRequiresDev + } else { + ArrayScope::Manifest + }; + (scope, key, old_arr, new_arr) + } + "uv_lock_manifest_overrides" => (ArrayScope::Manifest, "overrides", orig, new), + _ => return ArrayRevert::Drift, + }; + let elements = |arr: &str| -> Vec { + top_level_brace_groups(arr) + .into_iter() + .map(|(s, e)| arr[s..e].to_string()) + .collect() + }; + let (old_els, new_els) = (elements(old_array), elements(new_array)); + // (ours, Some(original)) = rewritten in place; (ours, None) = appended. + let mut edits: Vec<(String, Option)> = Vec::new(); + if old_els.len() == new_els.len() { + for (o, n) in old_els.iter().zip(&new_els) { + if o != n { + edits.push((n.clone(), Some(o.clone()))); + } + } + } else if new_els.len() == old_els.len() + 1 && new_els[..old_els.len()] == old_els[..] { + edits.push((new_els[old_els.len()].clone(), None)); + } + if edits.is_empty() || edits.iter().any(|(ours, _)| !ours.contains(needle)) { + return ArrayRevert::Drift; + } + + let span = match locate_lock_array(lock_text, scope, key) { + Ok(span) => span, + // The whole key is gone (uv drops an emptied group): nothing in it + // routes through the artifact. + Err(ArrayMiss::Absent) => return ArrayRevert::Converged, + Err(ArrayMiss::Unreadable) => return ArrayRevert::Drift, + }; + if lock_text[span.clone()] == *new_array { + // Untouched since vendoring: restore the recorded original verbatim. + let mut text = lock_text.to_string(); + text.replace_range(span, old_array); + return ArrayRevert::Reverted(text); + } + let mut live = elements(&lock_text[span.clone()]); + let mut changed = false; + for (ours, original) in &edits { + match live.iter().position(|el| el == ours) { + Some(i) => { + match original { + Some(o) => live[i] = o.clone(), + None => { + live.remove(i); + } + } + changed = true; + } + // Our element was edited in place and still routes through the + // artifact: drift, fail-closed. + None if live.iter().any(|el| el.contains(needle)) => return ArrayRevert::Drift, + // Our element is gone and nothing else in the array routes + // through the artifact: that element's reverted state holds. + None => {} + } + } + if !changed { + return ArrayRevert::Converged; + } + let nl = newline_of(lock_text); + let rendered = match live.len() { + 0 => "[]".to_string(), + 1 => format!("[{}]", live[0]), + _ => { + let mut out = format!("[{nl}"); + for el in &live { + out.push_str(&format!(" {el},{nl}")); + } + out.push(']'); + out + } + }; + let mut text = lock_text.to_string(); + text.replace_range(span, &rendered); + ArrayRevert::Reverted(text) +} + +/// Why [`locate_lock_array`] found no array. +enum ArrayMiss { + /// The section or the key is provably not in the lock (uv drops an + /// emptied group, or a `[manifest]` with nothing left in it). + Absent, + /// Something is there but not in a shape we can read: the root unit is + /// missing, the key is spelled differently, or the array is + /// unbalanced. Fail closed — it may still route through the artifact. + Unreadable, +} + +/// Byte span of the `[…]` array assigned to `key` at line start inside +/// `scope`. A miss is [`ArrayMiss::Absent`] only when no line in the scope +/// assigns `key` (or opens the section) in ANY spelling. +fn locate_lock_array( + lock_text: &str, + scope: ArrayScope, + key: &str, +) -> Result, ArrayMiss> { + // A line that assigns `key` however it is spaced or quoted. + let assigns_key = |line: &str| { + let l = line.trim_start(); + let rest = l + .strip_prefix(key) + .or_else(|| l.strip_prefix(&format!("\"{key}\""))); + rest.is_some_and(|r| r.trim_start().starts_with('=')) + }; + let section = match scope { + ArrayScope::RootRequiresDev => { + let unit = find_unit_span(lock_text, unit_is_root).ok_or(ArrayMiss::Unreadable)?; + let unit_text = &lock_text[unit.clone()]; + let header = "[package.metadata.requires-dev]"; + let Some(hdr_rel) = unit_text.find(header) else { + return Err(if unit_text.contains("requires-dev") { + ArrayMiss::Unreadable + } else { + ArrayMiss::Absent + }); + }; + let start = unit.start + hdr_rel + header.len(); + // Elements are indented, so a line-leading `[` is the next + // sub-table header. + let end = lock_text[start..unit.end] + .find("\n[") + .map_or(unit.end, |i| start + i + 1); + start..end + } + ArrayScope::Manifest => { + let index = line_index(lock_text); + let Some(h) = index.iter().position(|(_, l)| l.trim_end() == "[manifest]") else { + return Err( + if index + .iter() + .any(|(_, l)| l.trim_start().starts_with("[manifest")) + { + ArrayMiss::Unreadable + } else { + ArrayMiss::Absent + }, + ); + }; + let end = index[h + 1..] + .iter() + .find(|(_, l)| l.starts_with('[')) + .map_or(lock_text.len(), |(off, _)| *off); + index[h].0..end + } + }; + let prefix = format!("{key} = ["); + let lines = line_index(&lock_text[section.clone()]); + let Some(line_off) = lines + .iter() + .find(|(_, l)| l.starts_with(&prefix)) + .map(|(off, _)| section.start + off) + else { + return Err(if lines.iter().any(|(_, l)| assigns_key(l)) { + ArrayMiss::Unreadable + } else { + ArrayMiss::Absent + }); + }; + let open = line_off + prefix.len() - 1; + let end = balanced_span(lock_text, open).ok_or(ArrayMiss::Unreadable)?; + Ok(open..end) +} + /// 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. @@ -2634,6 +2881,7 @@ wheels = [ tokio::fs::write(tmp.path().join("uv.lock"), &drifted) .await .unwrap(); + let (wired_py, _) = read_pair(tmp.path()).await; let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success); @@ -2645,9 +2893,11 @@ wheels = [ "{:?}", outcome.warnings ); - // The pyproject side (undrifted) was still reverted. - let (pyproject, _) = read_pair(tmp.path()).await; - assert_eq!(pyproject, DIRECT_REGISTRY_PYPROJECT); + // PAIR GATE: the undrifted pyproject side is NOT reverted alone — + // that would leave a pair `uv sync --locked` rejects. + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, wired_py); + assert_eq!(lock, drifted); } /// mkfifo(2) directly rather than shelling out to the `mkfifo` binary — @@ -3743,11 +3993,12 @@ wheels = [ assert_eq!(lock, input_lock, "only our added line may be removed"); } - /// A third-party edit to the REWRITTEN `[manifest] overrides` array must - /// be left alone with a drift warning — the never-clobber contract for - /// this record kind. + /// A third-party edit to the REWRITTEN `[manifest] overrides` array + /// around our unchanged element (a sibling replaced, ours moved) is not + /// drift of OUR element: revert removes just our element and keeps the + /// user's reshaping (#806). #[tokio::test] - async fn revert_warns_and_skips_on_drifted_manifest_overrides_array() { + async fn revert_removes_our_element_from_a_reshaped_manifest_overrides_array() { let one_el = "overrides = [{ name = \"other\", path = \"o.whl\" }]"; let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( "requires-python = \">=3.10\"\n", @@ -3789,24 +4040,16 @@ wheels = [ let entry = entry_for(wiring, meta); let outcome = revert_uv(&entry, tmp.path(), false).await; assert!(outcome.success, "{:?}", outcome.error); - assert_eq!(outcome.warnings.len(), 1, "{:?}", outcome.warnings); - assert_eq!(outcome.warnings[0].code, "vendor_lock_entry_drifted"); - assert!( - outcome.warnings[0].detail.contains("uv.lock"), - "{}", - outcome.warnings[0].detail - ); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); let (py, lock) = read_pair(tmp.path()).await; assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); let expected = input_lock.replace( "[{ name = \"other\", path = \"o.whl\" }]", - &format!( - "[{{ name = \"six\", path = \"{REL_WHEEL}\" }}, {{ name = \"extra\", path = \"e.whl\" }}]" - ), + "[{ name = \"extra\", path = \"e.whl\" }]", ); assert_eq!( lock, expected, - "the undrifted [[package]] fragment still reverts; the array is left as the user edited it" + "our element is removed; the user's reshaping of the array stays" ); } @@ -3854,13 +4097,12 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, DIRECT_REGISTRY_LOCK, "the lock side still reverts"); - assert!( - py.contains(&format!( - "six = {{ path = \"{REL_WHEEL}\", editable = false }}" - )), - "the drifted pyproject is left alone: {py}" + assert_eq!(py, tampered, "the drifted pyproject is left alone"); + assert_ne!( + lock, DIRECT_REGISTRY_LOCK, + "PAIR GATE: the lock stays wired with its drift-kept pyproject" ); + assert!(lock.contains(REL_WHEEL), "{lock}"); } /// LIVENESS CONTRACT (vendor/mod.rs): a hand-restored pair — the user @@ -4068,14 +4310,10 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK); - assert!( - py.contains("override-dependencies = [\"six==1.17.0\"]"), - "the user's edit must survive: {py}" - ); + assert_eq!(py, tampered, "the user's edit must survive, untouched"); assert!( - !py.contains("[tool.uv.sources]"), - "the undrifted sources entry (and its created table) still reverts: {py}" + lock.contains(REL_WHEEL), + "PAIR GATE: the lock stays wired with its drift-kept pyproject: {lock}" ); } @@ -4741,12 +4979,12 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); - let expected = tampered.replacen(&pkg_new, &pkg_orig, 1); assert_eq!( - lock, expected, - "the [[package]] fragment reverts; the reshaped [manifest] stays" + py, OVERRIDE_TRANSITIVE_PYPROJECT, + "PAIR GATE: the pyproject stays wired with the drift-kept lock" ); + assert_eq!(lock, tampered, "the lock is left exactly as found"); + assert!(tampered.contains(&pkg_new) && !tampered.contains(&pkg_orig)); } /// Hand-restoring a pair whose lock had a PRE-EXISTING overrides array @@ -4867,14 +5105,13 @@ wheels = [ outcome.warnings[0].detail ); let (py, lock) = read_pair(tmp.path()).await; - assert_eq!(lock, TRANSITIVE_REGISTRY_LOCK, "the lock still reverts"); - assert!( - py.contains("override-dependencies = [\"attrs==23.9.9\", \"six==1.16.0\"]"), - "the drifted array is left as the user edited it: {py}" + assert_eq!( + py, tampered, + "the drifted pyproject is left as the user edited it" ); assert!( - !py.contains("[tool.uv.sources]"), - "the undrifted sources entry still reverts: {py}" + lock.contains(REL_WHEEL), + "PAIR GATE: the lock stays wired with its drift-kept pyproject: {lock}" ); } @@ -5907,4 +6144,368 @@ six = { path = ".socket/vendor/pypi/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/six-1.1 assert_sources_spelling_round_trips(&pyproject, DIRECT_REGISTRY_LOCK, &six_source_line("")) .await; } + + // ── relock re-serialization (#806, #821) + the pair gate ────────────── + + /// #821: six is a dev-group dependency and the user runs `uv add --dev + /// zipp` after vendoring. uv re-serializes the WHOLE `dev = […]` + /// requires-dev line (multi-line, sorted) while six's element stays + /// byte-identical. Revert must recognise our element inside the + /// re-serialized group, restore just that element, and unwind BOTH + /// files — no drift, no half-revert. + #[tokio::test] + async fn revert_survives_uv_reserializing_the_dev_group_line() { + let tmp = write_pair(DEV_GROUP_REGISTRY_PYPROJECT, DEV_GROUP_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + // What `uv add --dev zipp` does to the pair. + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let relocked_lock = wired_lock.replace( + &format!("dev = [{six_el}]"), + &format!("dev = [\n {six_el},\n {{ name = \"zipp\", specifier = \">=3\" }},\n]"), + ); + assert_ne!(relocked_lock, wired_lock, "the relock must hit the group"); + let relocked_py = wired_py.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"zipp>=3\"]", + ); + tokio::fs::write(tmp.path().join("uv.lock"), &relocked_lock) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("pyproject.toml"), &relocked_py) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.drift_skipped()); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!( + py, + DEV_GROUP_REGISTRY_PYPROJECT.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"zipp>=3\"]", + ) + ); + assert_eq!( + lock, + DEV_GROUP_REGISTRY_LOCK.replace( + "dev = [{ name = \"six\", specifier = \"==1.16.0\" }]", + "dev = [\n { name = \"six\", specifier = \"==1.16.0\" },\n { name = \"zipp\", specifier = \">=3\" },\n]", + ), + "only six's element is restored; the user's zipp stays" + ); + assert!(!lock.contains(".socket/vendor"), "{lock}"); + } + + /// #821 (`uv remove --dev`): a sibling leaving the group collapses uv's + /// multi-line array back to one inline element — revert still finds and + /// restores our element. + #[tokio::test] + async fn revert_survives_a_sibling_leaving_the_dev_group() { + let two = "dev = [\n { name = \"attrs\", specifier = \">=20\" },\n { name = \"six\", specifier = \"==1.16.0\" },\n]"; + let input_lock = DEV_GROUP_REGISTRY_LOCK + .replace("dev = [{ name = \"six\", specifier = \"==1.16.0\" }]", two); + let input_py = DEV_GROUP_REGISTRY_PYPROJECT.replace( + "dev = [\"six==1.16.0\"]", + "dev = [\"six==1.16.0\", \"attrs>=20\"]", + ); + let tmp = write_pair(&input_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let wired_group = + format!("dev = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n]"); + assert!(wired_lock.contains(&wired_group), "{wired_lock}"); + // `uv remove --dev attrs`. + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&wired_group, &format!("dev = [{six_el}]")), + ) + .await + .unwrap(); + tokio::fs::write( + tmp.path().join("pyproject.toml"), + wired_py.replace(", \"attrs>=20\"", ""), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, DEV_GROUP_REGISTRY_PYPROJECT); + assert_eq!(lock, DEV_GROUP_REGISTRY_LOCK); + } + + /// #806: the user already authors `override-dependencies`, six arrives + /// transitively, and a later relock (`uv add idna`) re-serializes + /// `[manifest] overrides` sorted and multi-line. Our element is + /// unchanged inside it, so revert must remove just that element and + /// unwind BOTH files. + #[tokio::test] + async fn revert_survives_uv_reserializing_manifest_overrides() { + let user_py = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"attrs>=20\"]\n" + ); + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [{ name = \"attrs\", specifier = \">=20\" }]\n", + ); + let tmp = write_pair(&user_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + assert_eq!(classify_dependency(&p, "six"), UvDepClass::Transitive); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let ours = format!("overrides = [{{ name = \"attrs\", specifier = \">=20\" }}, {six_el}]"); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + // uv's own spelling after any relock that rewrites the array. + let uv_spelling = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n]" + ); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &uv_spelling), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert!(!outcome.drift_skipped()); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, user_py); + assert_eq!( + lock, input_lock, + "one remaining element renders inline, as uv writes it" + ); + } + + /// #806 with a second user override sorted AFTER ours: removing our + /// element from uv's multi-line array keeps uv's multi-line shape. + #[tokio::test] + async fn revert_removes_our_override_from_a_sorted_multi_line_array() { + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [\n { name = \"attrs\", specifier = \">=20\" },\n { name = \"zipp\", specifier = \">=3\" },\n]\n", + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let ours = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {{ name = \"zipp\", specifier = \">=3\" }},\n {six_el},\n]" + ); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + let sorted = format!( + "overrides = [\n {{ name = \"attrs\", specifier = \">=20\" }},\n {six_el},\n {{ name = \"zipp\", specifier = \">=3\" }},\n]" + ); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &sorted), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, TRANSITIVE_REGISTRY_PYPROJECT); + assert_eq!(lock, input_lock); + } + + /// The user's `[manifest] overrides` array is byte-identical to another + /// array in the lock (the root `requires-dist` pins the same package the + /// same way). After uv re-serializes the overrides, the recorded + /// original still appears elsewhere — that must NOT read as convergence: + /// revert decides from the `overrides` key itself, so our element is + /// removed instead of stranded pointing at a deleted artifact. + #[tokio::test] + async fn revert_converges_on_the_overrides_key_not_a_lookalike_array() { + let user_py = format!( + "{TRANSITIVE_REGISTRY_PYPROJECT}\n[tool.uv]\noverride-dependencies = [\"python-dateutil==2.8.2\"]\n" + ); + let lookalike = "[{ name = \"python-dateutil\", specifier = \"==2.8.2\" }]"; + assert!( + TRANSITIVE_REGISTRY_LOCK.contains(&format!("requires-dist = {lookalike}")), + "the root requires-dist must be the lookalike" + ); + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + &format!("requires-python = \">=3.10\"\n\n[manifest]\noverrides = {lookalike}\n"), + ); + let tmp = write_pair(&user_py, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (_, wired_lock) = read_pair(tmp.path()).await; + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let dateutil_el = "{ name = \"python-dateutil\", specifier = \"==2.8.2\" }"; + let ours = format!("overrides = [{dateutil_el}, {six_el}]"); + assert!(wired_lock.contains(&ours), "{wired_lock}"); + let uv_spelling = format!("overrides = [\n {dateutil_el},\n {six_el},\n]"); + tokio::fs::write( + tmp.path().join("uv.lock"), + wired_lock.replace(&ours, &uv_spelling), + ) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, user_py); + assert_eq!(lock, input_lock, "our element must not be stranded"); + } + + /// A key we can't read (here re-spelled `overrides=[…]`) is not proof + /// the array is gone: it still routes six through the artifact, so the + /// revert must fail closed — drift, neither file written — never read + /// the locator miss as convergence and delete the artifact. + #[tokio::test] + async fn revert_treats_an_unreadable_array_key_as_drift() { + let input_lock = TRANSITIVE_REGISTRY_LOCK.replace( + "requires-python = \">=3.10\"\n", + "requires-python = \">=3.10\"\n\n[manifest]\noverrides = [{ name = \"other\", path = \"o.whl\" }]\n", + ); + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, &input_lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + let respelled = wired_lock.replace("overrides = [", "overrides=["); + assert_ne!(respelled, wired_lock); + tokio::fs::write(tmp.path().join("uv.lock"), &respelled) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, wired_py); + assert_eq!(lock, respelled); + } + + /// The pair gate: when a uv.lock record is GENUINELY drift-kept (our + /// element itself was edited and still routes through the artifact), + /// revert must write NEITHER file — restoring pyproject.toml alone would + /// leave a pair `uv sync --locked` rejects (#806, #821). + #[tokio::test] + async fn revert_writes_neither_file_when_a_lock_record_is_drift_kept() { + let tmp = write_pair(DEV_GROUP_REGISTRY_PYPROJECT, DEV_GROUP_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let (wiring, meta, _) = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap(); + let (wired_py, wired_lock) = read_pair(tmp.path()).await; + // Our own element was edited (a marker added) — genuine drift. + let six_el = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let edited = format!( + "{{ name = \"six\", marker = \"python_full_version >= '3.11'\", path = \"{REL_WHEEL}\" }}" + ); + let drifted_lock = wired_lock.replace(&six_el, &edited); + assert_ne!(drifted_lock, wired_lock); + tokio::fs::write(tmp.path().join("uv.lock"), &drifted_lock) + .await + .unwrap(); + + let entry = entry_for(wiring, meta); + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.drift_skipped(), "{:?}", outcome.warnings); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, wired_py, "pyproject.toml must stay wired with its lock"); + assert_eq!(lock, drifted_lock, "uv.lock is left exactly as found"); + } } diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 42d248742..427a1367c 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -175,7 +175,11 @@ frozen, locked, and ordinary installation outcomes separately where supported. Revert state retains the original wiring. Script and lock edits are treated as a pair: conflicting changes preserve both files and their recovery state rather -than restoring only one side. Tests also cover restoring one package while +than restoring only one side. When any uv.lock or pyproject.toml record has +drifted, neither file is written. A relock that only re-serializes an array +around socket-patch's unchanged element is not drift: `uv add --dev x` rewrites +the dev group's `requires-dev` line, and `uv add y` sorts `[manifest] overrides` +into its multi-line form. Revert restores or removes just that element. Tests also cover restoring one package while preserving another package's vendored entries. ## Reproduce the release-family matrix