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", diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index f3e630a5d..67cdc5b6d 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -5679,6 +5679,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.