From 6e089d4273422a08cb154ffe3f6f63c1df58e4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:23:21 +0000 Subject: [PATCH 1/3] Start fix for #1374 Assisted-by: Claude Code:claude-opus-5-5 From 69643adc7296db56b71f4510b4c0f16428a833e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:47:46 +0000 Subject: [PATCH 2/3] Fix uv revert after a vendored dep goes direct When a package vendored as a transitive uv dependency was later added directly (`uv add six==1.16.0`), uv wrote a root requires-dist element that routes through the vendored wheel. `vendor --revert`, `remove` and `rollback` never restored it, so they wrote a pyproject.toml and uv.lock that `uv sync --locked` rejects, and revert still exited 0. The revert now puts any such root requires-dist or requires-dev element back on the registry, spelled the way pyproject.toml declares it now. If it can't (no declaration, or a spelling it can't derive), or uv.lock still routes through the wheel after pyproject.toml stops naming it, it leaves both files untouched instead of writing a pair uv rejects. `vendor --check` no longer claims no file references a vendored PyPI artifact when one still does: it names the file and suggests re-locking first. Fixes #1374 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- .../socket-patch-cli/src/commands/vendor.rs | 14 + .../tests/e2e_vendor_pypi_build.rs | 16 + .../src/patch/redirect/upstream/mod.rs | 2 +- .../src/patch/redirect/upstream/uv.rs | 27 ++ crates/socket-patch-core/src/vendor/pypi.rs | 13 + .../socket-patch-core/src/vendor/pypi_uv.rs | 314 +++++++++++++++++- 7 files changed, 384 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index abfd8da37..4b4e5ee01 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1985,7 +1985,9 @@ wiring: an entry whose lockfile or config no longer references its version from elsewhere (`wiring contested`, naming both locks; delete the one the project does not install from), or, for npm and PyPI, a dependency no lock resolves any more (`dependency removed`; `scan --mode vendored --prune` reverts -the entry). For a package-lock entry, drift also includes a +the entry), or, for PyPI, a project file that still names the artifact while +nothing installs from it (`wiring incomplete`, naming the file; re-lock, then +re-vendor or revert, #1374). For a package-lock entry, drift also includes a `package-lock.json` / `npm-shrinkwrap.json` entry for the vendored `name@version` that `vendor` would rewire but that does not resolve to the vendored artifact (#588); the reason names that entry. It also includes any entry for that diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 517a00f3a..ec67eacb6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -475,6 +475,20 @@ async fn unwired_check_failure( strip_purl_qualifiers(key) ); } + // A half-unwound pair (uv.lock still routing a root requirement through + // the wheel after the rest of the wiring is gone, #1374) does reference + // the dir: name the file instead of claiming nothing does. + if entry.ecosystem == "pypi" { + if let Some(file) = vendor::pypi::pypi_referencing_file(root, &entry.uuid).await { + return format!( + "wiring incomplete: {file} still references {dir}, but nothing installs the \ + package from it any more, so the project files disagree and a fresh install \ + gets the unpatched package or fails; re-lock the project (e.g. `uv lock`), \ + then re-run `socket-patch vendor` to rewire it or `socket-patch vendor \ + --revert` to finish unwinding it" + ); + } + } format!( "wiring missing: no lockfile or config references {dir} any more, so a fresh install \ gets the unpatched package; re-run `socket-patch vendor` to rewire it" 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 637bb40b3..44f59333c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -1234,6 +1234,22 @@ fn uv_vendor_revert_after_uv_remove_of_the_transitive_parent() { ); } +/// #1374: six vendored TRANSITIVELY, then promoted to a direct dependency +/// with `uv add six==1.16.0`. uv writes a root `requires-dist` element +/// routed through the socket-written source, which no wiring record +/// covers. `vendor --revert` must put it back on the registry too, instead +/// of writing a pair whose lock still names the wheel (`uv sync --locked` +/// failed while revert exited 0). +#[test] +#[serial_test::serial] +fn uv_vendor_revert_after_promoting_the_transitive_to_direct() { + uv_relock_then_revert( + "uv-promote-transitive", + "[project]\nname = \"vendor-capstone\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\ndependencies = [\"python-dateutil==2.9.0.post0\", \"attrs>=20\"]\n\n[tool.uv]\nconstraint-dependencies = [\"six==1.16.0\"]\n", + &["add", "-q", "six==1.16.0"], + ); +} + /// #821: six in a PEP 735 dev group, then `uv add --dev zipp` rewrites the /// whole `requires-dev` group line. #[test] diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 2d9b4a1dc..4d72a9ee6 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -43,7 +43,7 @@ mod uv; mod vlt; pub(crate) use client::UpstreamClient; -pub(crate) use uv::{respell_lock_specifier, LockRequirementArray}; +pub(crate) use uv::{lock_requirement_declared, respell_lock_specifier, LockRequirementArray}; use super::staged::{flush_staged, read_rel, Staged, StagedBytes}; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index b78632a04..e02be7929 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1360,6 +1360,33 @@ pub(crate) fn respell_lock_specifier( } } +/// Whether `pyproject_text` declares `name` in the declarations `array` +/// mirrors. An unparseable pyproject declares nothing. +pub(crate) fn lock_requirement_declared( + pyproject_text: &str, + array: LockRequirementArray<'_>, + name: &str, +) -> bool { + let Ok(doc) = pyproject_text.parse::() else { + return false; + }; + let meta = Metadata { + rel: "pyproject.toml".to_string(), + text: String::new(), + script: false, + doc, + }; + let declared = match array { + LockRequirementArray::RequiresDist => Declared::Dist, + LockRequirementArray::RequiresDev(group) => Declared::Dev(group), + LockRequirementArray::Manifest(key) => Declared::Manifest(key), + }; + let canon = canonicalize_pypi_name(name); + declarations(&meta, declared) + .iter() + .any(|d| canonicalize_pypi_name(pep508_name(d.spec)) == canon) +} + /// Every lock requirement array with the declarations it mirrors /// (read-only twin of [`restore_requirements`]'s walk). fn requirement_arrays_ref(doc: &DocumentMut) -> Vec<(Declared<'_>, &toml_edit::Array)> { diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 7ec7a2f89..fa52b49ed 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2223,6 +2223,19 @@ async fn guard_unwired_pypi_revert( }) } +/// The project file that still names `.socket/vendor/pypi//` +/// (`uv.lock`, `requirements.txt`, …), probed the way a revert's +/// residual-reference guard probes ([`pypi_reference_clause`]). `None` +/// when no readable file references it. `vendor --check` names it for an +/// entry nothing installs from any more, so the verdict doesn't claim no +/// file references a dir one still does (#1374). +pub async fn pypi_referencing_file(project_root: &Path, uuid: &str) -> Option { + pypi_reference_clause(project_root, uuid, &[]) + .await? + .strip_suffix(" still resolves through it") + .map(str::to_string) +} + /// The in-use probe behind [`guard_unwired_pypi_revert`] and the /// post-restore keep in [`revert_pypi_opts`]: `None` when every Python /// project file was read and none mentions the uuid dir; otherwise the human diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 1ebb645d7..f869badc0 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -30,9 +30,12 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; // revert) forever in an `open(2)` that waits for a writer — the // flavor-routing probes ahead of the load are metadata-only, so these are // the first opens. -use crate::patch::redirect::upstream::{respell_lock_specifier, LockRequirementArray}; +use crate::patch::redirect::upstream::{ + lock_requirement_declared, respell_lock_specifier, LockRequirementArray, +}; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::line_endings::terminator; +use crate::utils::purl::purl_name_version; use crate::utils::python_lock::preserve_line_endings; use super::common::{ @@ -869,6 +872,7 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // touches them). Those are this entry's own references, which the loop // below reverts, not the user's: they are set aside before probing. let uuid_lower = entry.uuid.to_ascii_lowercase(); + let needle_lower = needle.to_ascii_lowercase(); let in_pyproject = |kind: &str| matches!(kind, "uv_sources_entry" | "uv_override"); let own_fragments_removed = |text: &str, pyproject: bool| { let mut residual = text.to_string(); @@ -1121,9 +1125,43 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // 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"); + let drift_kept = |warnings: &[VendorWarning]| { + warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted") + }; + if !drift_kept(&warnings) { + // A root element uv wrote through our source after vendoring (a + // transitive package promoted by `uv add`, #1374) has no record. + let canon = purl_name_version(&entry.base_purl) + .map(|(name, _)| canonicalize_pypi_name(name)) + .unwrap_or_default(); + match respell_unrecorded_path_entries(&lock_text, &canon, &needle_lower, &pyproject_text) { + Ok(t) => lock_text = t, + Err(reason) => warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!("uv.lock entry for {canon:?} can't be restored ({reason}); left untouched"), + )), + } + } + // Never write a half-reverted pair: a lock that still routes through + // the uuid dir after pyproject.toml stopped declaring any route to it + // is what `uv sync --locked` rejects. Keep both files instead, so a + // `uv lock` and a re-run converge. + if !drift_kept(&warnings) + && lock_text.to_ascii_lowercase().contains(&needle_lower) + && !pyproject_text.to_ascii_lowercase().contains(&needle_lower) + { + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "uv.lock still references {needle} in an entry the vendor ledger did not \ + record, while pyproject.toml no longer does; left both files untouched \ + (run `uv lock`, then revert again)" + ), + )); + } + let drift_kept = drift_kept(&warnings); 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; @@ -1222,6 +1260,102 @@ fn root_requires_dist(lock_text: &str) -> Option<&str> { Some(&unit_text[open..end]) } +/// `lock_text` with every root `requires-dist` / `requires-dev` element for +/// `canon` that routes through `needle` (this entry's uuid dir, lower-case) +/// put back on the registry, spelled the way pyproject.toml declares it +/// now (#840). +/// +/// These elements are the ones no wiring record covers: a package vendored +/// as TRANSITIVE has no root element, and when the user later promotes it +/// to a direct dependency (`uv add six==1.16.0`), uv writes one routed +/// through the source vendor wrote (#1374). Reverting every record without +/// it leaves a lock that routes through a path pyproject.toml no longer +/// declares, which `uv sync --locked` rejects. `Err` when pyproject.toml no +/// longer declares the package or uv's spelling of the declaration isn't +/// derivable: the caller then keeps both files. +fn respell_unrecorded_path_entries( + lock_text: &str, + canon: &str, + needle: &str, + pyproject_text: &str, +) -> Result { + let Some(unit) = find_unit_span(lock_text, unit_is_root) else { + return Ok(lock_text.to_string()); + }; + let unit_text = &lock_text[unit.clone()]; + let routes_here = |element: &str| { + entry_value(element, "name").is_some_and(|n| canonicalize_pypi_name(n) == canon) + && entry_value(element, "path").is_some_and(|p| { + let p = p.to_ascii_lowercase(); + p == needle || p.starts_with(&format!("{needle}/")) + }) + }; + // Every root requirement array: (offset of its `[`, dev group name; + // `None` is requires-dist, which sits in [package.metadata], ahead of + // the requires-dev sub-table). + let dev_hdr = unit_text.find("[package.metadata.requires-dev]"); + let mut arrays: Vec<(usize, Option<&str>)> = Vec::new(); + if let Some(at) = unit_text[..dev_hdr.unwrap_or(unit_text.len())].find("requires-dist = [") { + arrays.push((at + "requires-dist = ".len(), None)); + } + if let Some(hdr) = dev_hdr { + let sect_start = hdr + "[package.metadata.requires-dev]".len(); + let sect_end = unit_text[sect_start..] + .find("\n[") + .map_or(unit_text.len(), |i| sect_start + i + 1); + let mut cursor = sect_start; + while let Some(open_rel) = unit_text[cursor..sect_end].find('[') { + let arr_open = cursor + open_rel; + let key_start = unit_text[..arr_open].rfind('\n').map_or(0, |i| i + 1); + let group = unit_text[key_start..arr_open] + .trim() + .trim_end_matches('=') + .trim() + .trim_matches('"'); + arrays.push((arr_open, Some(group))); + cursor = balanced_span(unit_text, arr_open) + .ok_or_else(|| "uv.lock root requires-dev array is unbalanced".to_string())?; + } + } + let mut edits: Vec<(Range, LockRequirementArray<'_>)> = Vec::new(); + for (arr_open, group) in arrays { + let arr_end = balanced_span(unit_text, arr_open) + .ok_or_else(|| "uv.lock root requirement array is unbalanced".to_string())?; + let array = group.map_or(LockRequirementArray::RequiresDist, |g| { + LockRequirementArray::RequiresDev(g) + }); + let at = unit.start + arr_open; + for (s, e) in top_level_brace_groups(&unit_text[arr_open..arr_end]) { + if routes_here(&lock_text[at + s..at + e]) { + edits.push(((at + s)..(at + e), array)); + } + } + } + // Back-to-front so the earlier spans stay valid. + edits.sort_by_key(|(span, _)| std::cmp::Reverse(span.start)); + let mut out = lock_text.to_string(); + for (span, array) in edits { + let element = &lock_text[span.clone()]; + if !lock_requirement_declared(pyproject_text, array, canon) { + return Err(format!( + "uv.lock routes {canon} through the vendored wheel but pyproject.toml no \ + longer declares it" + )); + } + let marker = entry_value(element, "marker"); + let spec = respell_lock_specifier(pyproject_text, array, canon, None, marker)?; + let inner = element.trim().trim_start_matches('{').trim_end_matches('}'); + let kept: Vec<&str> = split_top_level_commas(inner) + .into_iter() + .map(str::trim) + .filter(|part| !part.is_empty() && !part.starts_with("path =")) + .collect(); + let base = format!("{{ {} }}", kept.join(", ")); + out.replace_range(span, &with_specifier(&base, spec.flatten().as_deref())); + } + Ok(out) +} + /// The string value of `key` in a one-line `{ k = "v", … }` lock entry. fn entry_value<'a>(entry: &'a str, key: &str) -> Option<&'a str> { let inner = entry.trim().strip_prefix('{')?.strip_suffix('}')?; @@ -3133,6 +3267,178 @@ wheels = [ ); } + // ── a vendored transitive package promoted to direct (#1374) ─────── + + const ROOT_DATEUTIL_ONLY: &str = "dependencies = [\n { name = \"python-dateutil\" },\n]\n\n\ + [package.metadata]\nrequires-dist = [{ name = \"python-dateutil\", specifier = \"==2.8.2\" }]\n"; + + /// The root unit's dependencies and requires-dist with `six` added as + /// uv 0.12 writes them, `six_entry` being its requires-dist element. + fn root_with_six(six_entry: &str) -> String { + format!( + "dependencies = [\n {{ name = \"python-dateutil\" }},\n {{ name = \"six\" }},\n]\n\n\ + [package.metadata]\nrequires-dist = [\n \ + {{ name = \"python-dateutil\", specifier = \"==2.8.2\" }},\n {six_entry},\n]\n" + ) + } + + /// Vendor `six` as a transitive override, then apply `promote` to the + /// wired (pyproject, lock) pair the way a later `uv add` rewrites it. + async fn wire_transitive_then( + promote: impl Fn(&str, &str) -> (String, String), + ) -> (tempfile::TempDir, VendorEntry, String, String) { + let tmp = write_pair(TRANSITIVE_REGISTRY_PYPROJECT, TRANSITIVE_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 entry = entry_for(wiring, meta); + let (wired_pyproject, wired_lock) = read_pair(tmp.path()).await; + let (pyproject, lock) = promote(&wired_pyproject, &wired_lock); + assert_ne!((&pyproject, &lock), (&wired_pyproject, &wired_lock)); + tokio::fs::write(tmp.path().join("pyproject.toml"), &pyproject) + .await + .unwrap(); + tokio::fs::write(tmp.path().join("uv.lock"), &lock) + .await + .unwrap(); + (tmp, entry, pyproject, lock) + } + + /// `uv add six==1.16.0` on a project that vendored six as transitive + /// writes a root requires-dist element routed through our source, with + /// no wiring record. The revert puts it back on the registry with the + /// declared specifier, so the pair is what uv writes without vendoring. + #[tokio::test] + async fn revert_after_promoting_a_transitive_to_direct_respells_the_root_entry() { + let declare = |text: &str| { + text.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six==1.16.0\"]", + ) + }; + let (tmp, entry, _, _) = wire_transitive_then(|pyproject, lock| { + let path_entry = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + ( + declare(pyproject), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }) + .await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, declare(TRANSITIVE_REGISTRY_PYPROJECT)); + assert_eq!( + lock, + TRANSITIVE_REGISTRY_LOCK.replace( + ROOT_DATEUTIL_ONLY, + &root_with_six("{ name = \"six\", specifier = \"==1.16.0\" }") + ) + ); + } + + /// The same promotion into a dependency group (`uv add --dev`) lands + /// in the root's requires-dev, which is respelled the same way. + #[tokio::test] + async fn revert_after_promoting_a_transitive_to_a_dev_group_respells_it() { + let declare = + |text: &str| format!("{text}\n[dependency-groups]\ndev = [\"six==1.16.0\"]\n"); + let dev_lock = |text: &str, entry: &str| { + text.replace( + ROOT_DATEUTIL_ONLY, + &format!( + "dependencies = [\n {{ name = \"python-dateutil\" }},\n]\n\n\ + [package.dev-dependencies]\ndev = [\n {{ name = \"six\" }},\n]\n\n\ + [package.metadata]\nrequires-dist = [{{ name = \"python-dateutil\", \ + specifier = \"==2.8.2\" }}]\n\n[package.metadata.requires-dev]\n\ + dev = [{entry}]\n" + ), + ) + }; + let (tmp, entry, _, _) = wire_transitive_then(|pyproject, lock| { + ( + declare(pyproject), + dev_lock( + lock, + &format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"), + ), + ) + }) + .await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, declare(TRANSITIVE_REGISTRY_PYPROJECT)); + assert_eq!( + lock, + dev_lock( + TRANSITIVE_REGISTRY_LOCK, + "{ name = \"six\", specifier = \"==1.16.0\" }" + ) + ); + } + + /// When the unrecorded element can't be respelled (a multi-clause + /// declaration uv's spelling of isn't derivable, or no declaration at + /// all), or the lock routes another name through the uuid dir, the + /// revert writes neither file instead of a half-reverted + /// pair `uv sync --locked` rejects. + #[tokio::test] + async fn revert_keeps_both_files_when_an_unrecorded_reference_survives() { + let path_entry = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); + let cases: [(&str, Box (String, String)>); 3] = [ + ( + "multi-clause declaration", + Box::new(|pyproject: &str, lock: &str| { + ( + pyproject.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six>=1.16,<2\"]", + ), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), + ( + "undeclared", + Box::new(|pyproject: &str, lock: &str| { + ( + format!("{pyproject}# promoted\n"), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), + ( + "another name routed through the wheel", + Box::new(|pyproject: &str, lock: &str| { + let other = format!("{{ name = \"six-fork\", path = \"{REL_WHEEL}\" }}"); + ( + format!("{pyproject}# promoted\n"), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&other)), + ) + }), + ), + ]; + for (name, promote) in cases { + let (tmp, entry, pyproject, lock) = wire_transitive_then(promote).await; + let outcome = revert_uv(&entry, tmp.path(), false).await; + assert!(outcome.drift_skipped(), "{name}: {:?}", outcome.warnings); + assert_eq!(read_pair(tmp.path()).await, (pyproject, lock), "{name}"); + } + } + // ── two packages sharing a created [tool.uv.sources] (#670) ──────── const TWO_REGISTRY_PYPROJECT: &str = r#"[project] From b022befa008be503e461dcdca3355f1bc1e8c4b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:01:13 +0000 Subject: [PATCH 3/3] Fail closed on underivable uv specifiers Review follow-ups for the uv revert fix: - A promoted element whose declaration uv's spelling can't be derived from (`===`, a URL, an unreadable spec) now keeps both files instead of being restored with no specifier, which `uv sync --locked` would also reject. - The keep-both warnings tell the user what to change; re-running `uv lock` alone would rebuild the same element. - `vendor --check` reports the residual-reference probe's own clause, so an unreadable file is reported as such rather than as "no file references it". - Cover the `vendor --check` verdict with a CLI test. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 17 +++-- .../tests/mode_migration_pypi.rs | 42 +++++++++++ .../src/patch/redirect/upstream/mod.rs | 2 +- .../src/patch/redirect/upstream/uv.rs | 34 ++++++--- crates/socket-patch-core/src/vendor/pypi.rs | 20 +++--- .../socket-patch-core/src/vendor/pypi_uv.rs | 69 +++++++++++-------- 6 files changed, 125 insertions(+), 59 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index ec67eacb6..befc92dc6 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -442,6 +442,9 @@ pub(crate) async fn dispatch_revert_one_opts( /// verdict the prune GC reverts by ([`Discovery::vendor_entry_in_use`]) /// says so and no lock resolves the package any more, so `scan --prune` /// is the fix, as `scan`'s own `vendor_ledger_entry_unwired` hint says; +/// * for PyPI, a project file still names the artifact although nothing +/// installs from it (a half-unwound uv pair, #1374): name it, and +/// re-lock before re-vendoring or reverting; /// * otherwise a relock dropped the reference while the package stayed. async fn unwired_check_failure( discovery: &socket_patch_core::vex::discover::Discovery, @@ -477,15 +480,15 @@ async fn unwired_check_failure( } // A half-unwound pair (uv.lock still routing a root requirement through // the wheel after the rest of the wiring is gone, #1374) does reference - // the dir: name the file instead of claiming nothing does. + // the dir: say so instead of claiming nothing does. if entry.ecosystem == "pypi" { - if let Some(file) = vendor::pypi::pypi_referencing_file(root, &entry.uuid).await { + if let Some(clause) = vendor::pypi::pypi_reference(root, &entry.uuid).await { return format!( - "wiring incomplete: {file} still references {dir}, but nothing installs the \ - package from it any more, so the project files disagree and a fresh install \ - gets the unpatched package or fails; re-lock the project (e.g. `uv lock`), \ - then re-run `socket-patch vendor` to rewire it or `socket-patch vendor \ - --revert` to finish unwinding it" + "wiring incomplete: nothing installs the package from {dir} any more, but \ + {clause}, so the project files disagree and a fresh install gets the \ + unpatched package or fails; re-lock the project (e.g. `uv lock`), then \ + re-run `socket-patch vendor` to rewire it or `socket-patch vendor --revert` \ + to finish unwinding it" ); } } diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 448ba51af..0ddea1e59 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -1618,6 +1618,48 @@ async fn vendor_check_fails_after_uv_relock() { assert_check_catches_relock(&root, files); } +/// #1374: a half-unwound uv pair (pyproject.toml and the six unit back on +/// PyPI, but the root requires-dist still routing through the wheel, as a +/// revert left it once `uv add` had promoted a vendored transitive) fails +/// `vendor --check`, and the reason names uv.lock instead of claiming no +/// file references the artifact. +#[tokio::test] +async fn vendor_check_names_the_file_of_a_half_unwound_uv_pair() { + let (_tmp, root) = project(); + let files = stage_uv(&root); + let pristine_pyproject = std::fs::read_to_string(root.join("pyproject.toml")).unwrap(); + let pristine_lock = std::fs::read_to_string(root.join("uv.lock")).unwrap(); + vendor_project(&root, files); + let wired_lock = std::fs::read_to_string(root.join("uv.lock")).unwrap(); + let wired_element = wired_lock + .lines() + .find(|l| l.starts_with("requires-dist = ") && l.contains(".socket/vendor/pypi/")) + .expect("the wired root requires-dist element"); + std::fs::write(root.join("pyproject.toml"), &pristine_pyproject).unwrap(); + std::fs::write( + root.join("uv.lock"), + pristine_lock.replace( + "requires-dist = [{ name = \"six\", specifier = \"==1.16.0\" }]", + wired_element, + ), + ) + .unwrap(); + + let (code, env) = run_cli(&root, &["vendor", "--check"], &[]); + assert_eq!(code, 1, "{env:#}"); + let event = &env["events"][0]; + assert_eq!(event["errorCode"], "vendor_check_failed", "{env:#}"); + let reason = event["reason"].as_str().unwrap_or_default(); + assert!( + reason.contains("wiring incomplete") && reason.contains("uv.lock still resolves"), + "{env:#}" + ); + assert!( + !reason.contains("no lockfile or config references"), + "{env:#}" + ); +} + #[tokio::test] async fn vendor_check_fails_after_hatch_dependency_reset() { let (_tmp, root) = project(); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 4d72a9ee6..15ee3aa7f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -43,7 +43,7 @@ mod uv; mod vlt; pub(crate) use client::UpstreamClient; -pub(crate) use uv::{lock_requirement_declared, respell_lock_specifier, LockRequirementArray}; +pub(crate) use uv::{declared_lock_specifier, respell_lock_specifier, LockRequirementArray}; use super::staged::{flush_staged, read_rel, Staged, StagedBytes}; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index e02be7929..15a7f5b03 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -1360,16 +1360,21 @@ pub(crate) fn respell_lock_specifier( } } -/// Whether `pyproject_text` declares `name` in the declarations `array` -/// mirrors. An unparseable pyproject declares nothing. -pub(crate) fn lock_requirement_declared( +/// The `specifier` uv writes for `name`'s entry in `array` (with +/// `marker`) when nothing recorded a prior spelling: the declaration's +/// one clause, or `None` for a bare name. `Err` when pyproject.toml +/// doesn't declare the name there, or uv's spelling of the declaration +/// isn't derivable (unreadable, multi-clause, or ambiguous), so the +/// caller can fail closed rather than guess. +pub(crate) fn declared_lock_specifier( pyproject_text: &str, array: LockRequirementArray<'_>, name: &str, -) -> bool { - let Ok(doc) = pyproject_text.parse::() else { - return false; - }; + marker: Option<&str>, +) -> Result, String> { + let doc = pyproject_text + .parse::() + .map_err(|e| format!("pyproject.toml does not parse: {e}"))?; let meta = Metadata { rel: "pyproject.toml".to_string(), text: String::new(), @@ -1381,10 +1386,17 @@ pub(crate) fn lock_requirement_declared( LockRequirementArray::RequiresDev(group) => Declared::Dev(group), LockRequirementArray::Manifest(key) => Declared::Manifest(key), }; - let canon = canonicalize_pypi_name(name); - declarations(&meta, declared) - .iter() - .any(|d| canonicalize_pypi_name(pep508_name(d.spec)) == canon) + match declared_clauses(&meta, declared, name, marker)? { + None => Err(format!("pyproject.toml no longer declares {name}")), + Some(clauses) => match clauses.as_slice() { + [] => Ok(None), + [one] => Ok(Some(one.clone())), + _ => Err(format!( + "pyproject.toml declares {name} with a multi-clause specifier, whose \ + spelling in uv.lock is not derivable" + )), + }, + } } /// Every lock requirement array with the declarations it mirrors diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index fa52b49ed..f8e80cb26 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2223,17 +2223,15 @@ async fn guard_unwired_pypi_revert( }) } -/// The project file that still names `.socket/vendor/pypi//` -/// (`uv.lock`, `requirements.txt`, …), probed the way a revert's -/// residual-reference guard probes ([`pypi_reference_clause`]). `None` -/// when no readable file references it. `vendor --check` names it for an -/// entry nothing installs from any more, so the verdict doesn't claim no -/// file references a dir one still does (#1374). -pub async fn pypi_referencing_file(project_root: &Path, uuid: &str) -> Option { - pypi_reference_clause(project_root, uuid, &[]) - .await? - .strip_suffix(" still resolves through it") - .map(str::to_string) +/// Why `.socket/vendor/pypi//` may still be referenced, probed the +/// way a revert's residual-reference guard probes +/// ([`pypi_reference_clause`]): a clause naming the project file that +/// still resolves through it, or the file or listing that couldn't be read +/// to prove none does. `None` when every file was read and none mentions +/// it. `vendor --check` reports it for an entry nothing installs from any +/// more, so the verdict doesn't claim no file references the dir (#1374). +pub async fn pypi_reference(project_root: &Path, uuid: &str) -> Option { + pypi_reference_clause(project_root, uuid, &[]).await } /// The in-use probe behind [`guard_unwired_pypi_revert`] and the diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index f869badc0..d3d4beaf7 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -31,7 +31,7 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; // flavor-routing probes ahead of the load are metadata-only, so these are // the first opens. use crate::patch::redirect::upstream::{ - lock_requirement_declared, respell_lock_specifier, LockRequirementArray, + declared_lock_specifier, respell_lock_specifier, LockRequirementArray, }; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::line_endings::terminator; @@ -1125,12 +1125,10 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - // 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: &[VendorWarning]| { - warnings - .iter() - .any(|w| w.code == "vendor_lock_entry_drifted") - }; - if !drift_kept(&warnings) { + let mut drift_kept = warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"); + if !drift_kept { // A root element uv wrote through our source after vendoring (a // transitive package promoted by `uv add`, #1374) has no record. let canon = purl_name_version(&entry.base_purl) @@ -1138,30 +1136,37 @@ pub(super) async fn revert_uv(entry: &VendorEntry, root: &Path, dry_run: bool) - .unwrap_or_default(); match respell_unrecorded_path_entries(&lock_text, &canon, &needle_lower, &pyproject_text) { Ok(t) => lock_text = t, - Err(reason) => warnings.push(VendorWarning::new( - "vendor_lock_entry_drifted", - format!("uv.lock entry for {canon:?} can't be restored ({reason}); left untouched"), - )), + Err(reason) => { + drift_kept = true; + warnings.push(VendorWarning::new( + "vendor_lock_entry_drifted", + format!( + "uv.lock entry for {canon:?} that `uv add` routed through {needle} \ + can't be put back on the registry ({reason}); left both files \ + untouched (pin it with `==` in pyproject.toml or remove it, then \ + revert again)" + ), + )); + } } } // Never write a half-reverted pair: a lock that still routes through // the uuid dir after pyproject.toml stopped declaring any route to it - // is what `uv sync --locked` rejects. Keep both files instead, so a - // `uv lock` and a re-run converge. - if !drift_kept(&warnings) + // is what `uv sync --locked` rejects. Keep both files instead. + if !drift_kept && lock_text.to_ascii_lowercase().contains(&needle_lower) && !pyproject_text.to_ascii_lowercase().contains(&needle_lower) { + drift_kept = true; warnings.push(VendorWarning::new( "vendor_lock_entry_drifted", format!( "uv.lock still references {needle} in an entry the vendor ledger did not \ - record, while pyproject.toml no longer does; left both files untouched \ - (run `uv lock`, then revert again)" + record, while the reverted pyproject.toml would not; left both files \ + untouched (remove that reference, then revert again)" ), )); } - let drift_kept = drift_kept(&warnings); 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; @@ -1336,14 +1341,8 @@ fn respell_unrecorded_path_entries( let mut out = lock_text.to_string(); for (span, array) in edits { let element = &lock_text[span.clone()]; - if !lock_requirement_declared(pyproject_text, array, canon) { - return Err(format!( - "uv.lock routes {canon} through the vendored wheel but pyproject.toml no \ - longer declares it" - )); - } let marker = entry_value(element, "marker"); - let spec = respell_lock_specifier(pyproject_text, array, canon, None, marker)?; + let spec = declared_lock_specifier(pyproject_text, array, canon, marker)?; let inner = element.trim().trim_start_matches('{').trim_end_matches('}'); let kept: Vec<&str> = split_top_level_commas(inner) .into_iter() @@ -1351,7 +1350,7 @@ fn respell_unrecorded_path_entries( .filter(|part| !part.is_empty() && !part.starts_with("path =")) .collect(); let base = format!("{{ {} }}", kept.join(", ")); - out.replace_range(span, &with_specifier(&base, spec.flatten().as_deref())); + out.replace_range(span, &with_specifier(&base, spec.as_deref())); } Ok(out) } @@ -3390,15 +3389,27 @@ wheels = [ ); } - /// When the unrecorded element can't be respelled (a multi-clause - /// declaration uv's spelling of isn't derivable, or no declaration at - /// all), or the lock routes another name through the uuid dir, the + /// When the unrecorded element can't be respelled (a multi-clause or + /// `===` declaration uv's spelling of isn't derivable, or no declaration + /// at all), or the lock routes another name through the uuid dir, the /// revert writes neither file instead of a half-reverted /// pair `uv sync --locked` rejects. #[tokio::test] async fn revert_keeps_both_files_when_an_unrecorded_reference_survives() { let path_entry = format!("{{ name = \"six\", path = \"{REL_WHEEL}\" }}"); - let cases: [(&str, Box (String, String)>); 3] = [ + let cases: [(&str, Box (String, String)>); 4] = [ + ( + "arbitrary-equality declaration", + Box::new(|pyproject: &str, lock: &str| { + ( + pyproject.replace( + "dependencies = [\"python-dateutil==2.8.2\"]", + "dependencies = [\"python-dateutil==2.8.2\", \"six===1.16.0\"]", + ), + lock.replace(ROOT_DATEUTIL_ONLY, &root_with_six(&path_entry)), + ) + }), + ), ( "multi-clause declaration", Box::new(|pyproject: &str, lock: &str| {