From 7d3837784d8d9d280a8aecaa0da86d71bd78e382 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:57:28 +0000 Subject: [PATCH 1/4] Start fix for #979 Assisted-by: Claude Code:claude-opus-5-5 From 951740b41b790fc3406f5eb3ae6020db568b27a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 03:57:29 +0000 Subject: [PATCH 2/4] Preview uv inline-table refusal in dry runs A uv project whose [tool.uv] or [tool.uv.sources] is written as an inline table (sources = { ... }, [tool] uv = { ... }, dotted uv.sources = { ... }) can't be wired, and the real `vendor` run refused it with pypi_uv_lock_parse_failed. But that refusal was only raised while wiring, after the dry run had already returned, so `vendor --dry-run` previewed a clean vendor (exit 0) and the real run downloaded the prebuilt wheel before failing (exit 1). The uv preflight now checks the same table chain wiring will edit ([tool.uv] for a transitive package, then [tool.uv.sources]) and refuses with the same code and message. Dry runs now preview the refusal, and the real run refuses before any download or write. Fixes #979 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/pypi.rs | 55 +++++++ .../socket-patch-core/src/vendor/pypi_uv.rs | 153 ++++++++++++++++-- 2 files changed, 199 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 9ebf3be86..e02c8176f 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -5528,6 +5528,61 @@ wheels = [ .exists()); } + /// #979: an inline `[tool.uv] sources = {…}` table is refused by the + /// preflight, so the dry run previews the same `pypi_uv_lock_parse_failed` + /// refusal as the real run (which refuses before any download or write) + /// instead of previewing a clean vendor. + #[tokio::test] + async fn uv_inline_sources_table_refused_in_dry_and_wet_runs() { + let pyproject = + format!("{UV_PYPROJECT}\n[tool.uv]\nsources = {{ idna = {{ index = \"pypi\" }} }}\n"); + let fx = e2e_fixture().await; + swap_to_lock_flavor( + &fx, + &[ + ("pyproject.toml", pyproject.as_str()), + ("uv.lock", UV_LOCK_REGISTRY), + ], + ) + .await; + let sources = PatchSources::blobs_only(&fx.blobs); + for dry_run in [true, false] { + let outcome = crate::vendor::test_support::vendor_pypi( + "pkg:pypi/six@1.16.0", + &fx.site_packages, + &fx.root, + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + dry_run, + false, + None, + ) + .await; + let VendorOutcome::Refused { code, detail } = outcome else { + panic!("dry_run={dry_run}: expected Refused, got {outcome:?}"); + }; + assert_eq!(code, "pypi_uv_lock_parse_failed", "dry_run={dry_run}"); + assert_eq!( + detail, "pyproject.toml [tool.uv.sources] is not a standard table", + "dry_run={dry_run}" + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("pyproject.toml")) + .await + .unwrap(), + pyproject + ); + assert_eq!( + tokio::fs::read_to_string(fx.root.join("uv.lock")) + .await + .unwrap(), + UV_LOCK_REGISTRY + ); + assert!(!uuid_dir_of(&fx).exists(), "dry_run={dry_run}"); + } + } + /// Deleting ONLY the committed wheel (the marker file survives) must /// still take the artifact-only rebuild: `uuid_dir_has_wheel` scans the /// surviving entries for a `.whl` rather than keying on dir existence. diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 7ccd493cf..bdf092c4a 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -404,6 +404,16 @@ pub(super) fn check_target_guards( } } } + + // `wire_uv` adds keys to the `[tool.uv]` (transitive) and + // `[tool.uv.sources]` tables through `ensure_table`, which refuses an + // inline-table or non-table link. Refuse the same shape here, in the + // same order and with the same detail, so a dry run previews it and the + // real run refuses before the prebuilt download (#979). + if classify_dependency(p, canon_name) == UvDepClass::Transitive { + check_standard_tables(&p.pyproject, &["tool", "uv"])?; + } + check_standard_tables(&p.pyproject, &["tool", "uv", "sources"])?; Ok(UvTarget::Fresh) } @@ -1432,19 +1442,39 @@ fn ensure_table<'a>( ) -> Result<&'a mut Table, (&'static str, String)> { let mut table: &mut Table = doc.as_table_mut(); for key in path { - table = crate::utils::toml_edit_ext::ensure_table(table, key, true).map_err(|_| { - ( - "pypi_uv_lock_parse_failed", - format!( - "pyproject.toml [{}] is not a standard table", - path.join(".") - ), - ) - })?; + table = crate::utils::toml_edit_ext::ensure_table(table, key, true) + .map_err(|_| not_a_standard_table(path))?; } Ok(table) } +/// Read-only twin of [`ensure_table`] for the preflight: every link of +/// `path` that exists must be a standard (header or dotted-key) table, the +/// only shape `ensure_table` can add keys to. A missing link is fine: +/// `ensure_table` creates it. +fn check_standard_tables(doc: &DocumentMut, path: &[&str]) -> Result<(), (&'static str, String)> { + let mut table: &Table = doc.as_table(); + for key in path { + match table.get(key) { + None => return Ok(()), + Some(item) => { + table = item.as_table().ok_or_else(|| not_a_standard_table(path))?; + } + } + } + Ok(()) +} + +fn not_a_standard_table(path: &[&str]) -> (&'static str, String) { + ( + "pypi_uv_lock_parse_failed", + format!( + "pyproject.toml [{}] is not a standard table", + path.join(".") + ), + ) +} + /// Whether the lock has a root `[[package]]` (source virtual/editable `.`). fn lock_has_root_package(lock: &DocumentMut) -> bool { lock.get("package") @@ -4035,6 +4065,111 @@ wheels = [ ); } + /// #979: every non-standard-table spelling of the chain `wire_uv` + /// edits (inline `sources = {…}`, `[tool] uv = {…}`, dotted + /// `uv.sources = {…}`) is refused by the PREFLIGHT with the exact code + /// and detail the wet run's `ensure_table` raises, so a dry run (which + /// returns before wire) previews the same refusal and the real run + /// refuses before the prebuilt download. + #[tokio::test] + async fn guards_refuse_non_standard_uv_tables_like_wire() { + let direct_cases = [ + "\n[tool.uv]\nsources = { idna = { index = \"pypi\" } }\n", + "\n[tool]\nuv = { sources = { idna = { index = \"pypi\" } }, index = [{ name = \"pypi\", url = \"https://pypi.org/simple\" }] }\n", + "\n[tool]\nuv.sources = { idna = { index = \"pypi\" } }\n", + "\n[tool]\nuv = 3\n", + ]; + for extra in direct_cases { + let pyproject = format!("{DIRECT_REGISTRY_PYPROJECT}{extra}"); + let tmp = write_pair(&pyproject, DIRECT_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let guard = check_target_guards(&p, "six", UUID).unwrap_err(); + let wire = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(guard, wire, "{extra}: preflight must match wire"); + assert_eq!(guard.0, "pypi_uv_lock_parse_failed", "{extra}"); + assert_eq!( + guard.1, "pyproject.toml [tool.uv.sources] is not a standard table", + "{extra}" + ); + let (py, lock) = read_pair(tmp.path()).await; + assert_eq!(py, pyproject, "{extra}: pyproject untouched"); + assert_eq!(lock, DIRECT_REGISTRY_LOCK, "{extra}: lock untouched"); + } + + // A transitive package is wired through `[tool.uv] + // override-dependencies` first, so wire names `[tool.uv]`. + let transitive_cases = ["\n[tool]\nuv = { constraint-dependencies = [\"six==1.16.0\"] }\n"]; + for extra in transitive_cases { + let pyproject = format!("{TRANSITIVE_REGISTRY_PYPROJECT}{extra}"); + let tmp = write_pair(&pyproject, TRANSITIVE_REGISTRY_LOCK).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let guard = check_target_guards(&p, "six", UUID).unwrap_err(); + let wire = wire_uv( + &p, + tmp.path(), + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ) + .await + .unwrap_err(); + assert_eq!(guard, wire, "{extra}: preflight must match wire"); + assert_eq!(guard.0, "pypi_uv_lock_parse_failed", "{extra}"); + assert_eq!( + guard.1, "pyproject.toml [tool.uv] is not a standard table", + "{extra}" + ); + } + + // Standard and header-less (implied by a sub-table) tables, and a + // transitive package under a dotted `uv.override-dependencies`, + // stay wireable. + let ok_cases = [ + (DIRECT_REGISTRY_PYPROJECT, "\n[tool.uv]\npackage = true\n"), + ( + DIRECT_REGISTRY_PYPROJECT, + "\n[tool.uv.sources.idna]\nindex = \"pypi\"\n", + ), + (DIRECT_REGISTRY_PYPROJECT, "\n[tool]\nuv.package = true\n"), + (TRANSITIVE_REGISTRY_PYPROJECT, "\n[tool.uv]\nsources = {}\n"), + ]; + for (base, extra) in ok_cases { + let lock = if base == DIRECT_REGISTRY_PYPROJECT { + DIRECT_REGISTRY_LOCK + } else { + TRANSITIVE_REGISTRY_LOCK + }; + let tmp = write_pair(&format!("{base}{extra}"), lock).await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let got = check_target_guards(&p, "six", UUID); + if base == DIRECT_REGISTRY_PYPROJECT { + assert_eq!(got, Ok(UvTarget::Fresh), "{extra}"); + } else { + // Transitive + inline sources: [tool.uv] is fine, the + // sources link is not. + assert_eq!( + got.unwrap_err().1, + "pyproject.toml [tool.uv.sources] is not a standard table", + "{extra}" + ); + } + } + } + /// A `[tool.uv.sources]` entry or an override pin for a DIFFERENT /// package must be tolerated (Fresh), not refused — the guards skip /// non-matching entries. From 5f01753b0e8b038139fa6a1687ef16183354cc56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 3/4] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From d456e5b8b942ab6fceb93bf546950cfe60c36501 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:51:03 +0000 Subject: [PATCH 4/4] Drop migrated Gradle files from digest list main has been red on test (macos/windows), test-release and coverage since #690: it moved crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs onto the utils::digest helpers but left them on PENDING_INLINE_DIGESTS. The guard test production_digests_go_through_the_helpers fails on a stale entry, so socket-patch-core's lib tests fail on main and on every PR based on it. Remove the three entries. No production behaviour changes. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/digest.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs index 105225e5e..630adefa1 100644 --- a/crates/socket-patch-core/src/utils/digest.rs +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -135,9 +135,6 @@ mod tests { /// when you move it onto the helpers above; the test fails on a stale /// entry as well as on a new inline copy. const PENDING_INLINE_DIGESTS: &[&str] = &[ - "crawlers/gradle_cache.rs", - "patch/jvm_jar.rs", - "patch/sidecars/maven.rs", "utils/group_commit.rs", "vendor/jvm/mod.rs", "vendor/maven_repo.rs",