diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 02197d8f0..2f54cf1c1 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -824,6 +824,51 @@ async fn uv_vendored_to_hosted() { assert_vendored_to_hosted(&root, files).await; } +/// #564: under `no-sources = true` (pyproject's `[tool.uv]` or a project +/// `uv.toml`) uv ignores `[tool.uv.sources]`, so a wired source fails +/// `uv sync --locked` and a plain `uv sync` installs the unpatched wheel. +/// Both modes used to wire it and exit 0; they must refuse and write +/// nothing. +#[tokio::test] +async fn uv_no_sources_is_refused_in_both_modes() { + for (file, setting) in [ + ("pyproject.toml", "\n[tool.uv]\nno-sources = true\n"), + ("uv.toml", "no-sources = true\n"), + ] { + let (_tmp, root) = project(); + stage_uv(&root); + let path = root.join(file); + let mut text = std::fs::read_to_string(&path).unwrap_or_default(); + text.push_str(setting); + std::fs::write(&path, text).unwrap(); + let before = tree(&root); + + let server = MockServer::start().await; + mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + // Skipped with the refusal as its warning, like the other uv + // project shapes hosted mode cannot wire. + assert_eq!(code, 0, "hosted {file}: {env:#}"); + assert_eq!(hosted_pinned(&env), 0, "hosted {file}: {env:#}"); + assert!( + env.to_string().contains("redirect_uv_project_unsupported") + && env.to_string().contains("no-sources"), + "hosted {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "hosted {file} wrote nothing"); + + stage_manifest(&root); + let before = tree(&root); + let (code, env) = run_cli(&root, &["vendor"], &[]); + assert_ne!(code, 0, "vendored {file}: {env:#}"); + assert!( + env.to_string().contains("pypi_uv_no_sources"), + "vendored {file}: {env:#}" + ); + assert_eq!(tree(&root), before, "vendored {file} wrote nothing"); + } +} + /// A Hatch project; returns its wiring files. fn stage_hatch(root: &Path) -> &'static [&'static str] { std::fs::write( diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 88a7fdecd..a75386d00 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -698,6 +698,21 @@ pub async fn read_candidate_files( out.read(view, unreadable, &path).await; } + // Beside a uv project or script lock, the project's `uv.toml` may set + // `no-sources`, under which uv ignores the `[tool.uv.sources]` entry the + // rewrite would add (#564). Advisory: never rewritten, so a link or an + // unreadable in-memory entry is left out rather than refused. + if out + .files + .keys() + .any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path)) + { + let rel = crate::utils::python_script::UV_TOML; + if let Some(text) = read_advisory(view, unreadable, rel).await { + out.files.insert(rel.to_string(), text); + } + } + // Rush monorepos have no root package.json/lock pair: the single pnpm // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and // (when subspaces are enabled) one lock per subspace under diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 7a8d54452..cfac846fe 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -5782,6 +5782,18 @@ fn plan_python_metadata_with( detail: format!("{metadata_path}: {detail}"), }; source_scope().map_err(unsupported)?; + let project_file = |rel: &str| files.get(rel).map(String::as_str); + crate::utils::python_script::refuse_uv_no_sources( + &dep.name, + script.then_some(original.as_str()), + if script { + project_file("pyproject.toml") + } else { + Some(&original) + }, + project_file(crate::utils::python_script::UV_TOML), + ) + .map_err(unsupported)?; let rewritten = if script { rewrite_script_metadata( &original, @@ -25356,6 +25368,152 @@ mod python_metadata_pairing_tests { }; assert_eq!(bad.code, "redirect_uv_project_unsupported"); } + + /// #564: under `no-sources = true` uv ignores every `[tool.uv.sources]` + /// entry, so a wired source fails `uv sync --locked` and a plain + /// `uv sync` installs the unpatched registry wheel. The planner refuses + /// before writing. A project `uv.toml` replaces pyproject's `[tool.uv]` + /// settings, while a script's own `[tool.uv]` wins over `uv.toml`. + #[test] + fn no_sources_refuses_a_source_uv_would_ignore() { + let refused = |path: &str, files: &[(&str, &str)]| match plan(path, files, &[]) { + Err(warning) => { + assert!(warning.detail.contains("no-sources"), "{}", warning.detail); + warning.code + } + Ok(_) => panic!("{path} with {files:?} must refuse"), + }; + let wired = |path: &str, files: &[(&str, &str)]| { + let (edit, _) = + plan(path, files, &[]).unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail)); + assert!(edit.is_some(), "{path} with {files:?} must be wired"); + }; + let project_no_sources = format!("{PYPROJECT}\n[tool.uv]\nno-sources = true\n"); + let script_no_sources = |value: bool| { + SCRIPT.replace( + "# ///\n", + &format!("#\n# [tool.uv]\n# no-sources = {value}\n# ///\n"), + ) + }; + let script_true = script_no_sources(true); + let script_false = script_no_sources(false); + + assert_eq!( + refused("uv.lock", &[("pyproject.toml", &project_no_sources)]), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = true\n") + ] + ), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused("tool.py.lock", &[("tool.py", &script_true)]), + "redirect_uv_script_unsupported" + ); + assert_eq!( + refused( + "tool.py.lock", + &[("tool.py", SCRIPT), ("uv.toml", "no-sources = true\n")] + ), + "redirect_uv_script_unsupported" + ); + + // uv.toml without the key overrides pyproject's [tool.uv] entirely. + wired( + "uv.lock", + &[ + ("pyproject.toml", &project_no_sources), + ("uv.toml", "offline = false\n"), + ], + ); + wired( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = false\n"), + ], + ); + // The script's own setting wins over uv.toml. + wired( + "tool.py.lock", + &[ + ("tool.py", &script_false), + ("uv.toml", "no-sources = true\n"), + ], + ); + // A script without its own setting takes the project's: uv.toml, + // else pyproject's [tool.uv]. + assert_eq!( + refused( + "tool.py.lock", + &[("tool.py", SCRIPT), ("pyproject.toml", &project_no_sources)] + ), + "redirect_uv_script_unsupported" + ); + wired( + "tool.py.lock", + &[ + ("tool.py", SCRIPT), + ("pyproject.toml", &project_no_sources), + ("uv.toml", "offline = false\n"), + ], + ); + // A uv.toml that does not parse sets nothing (uv reports it). + wired( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources = [\n"), + ], + ); + + // no-sources-package drops the source of the packages it names + // (normalized), in either file; a script's list combines with the + // project's, and an explicit no-sources = false disables it. + let names = |list: &str| format!("{PYPROJECT}\n[tool.uv]\nno-sources-package = {list}\n"); + assert_eq!( + refused("uv.lock", &[("pyproject.toml", &names("[\"Click\"]"))]), + "redirect_uv_project_unsupported" + ); + assert_eq!( + refused( + "uv.lock", + &[ + ("pyproject.toml", PYPROJECT), + ("uv.toml", "no-sources-package = [\"click\"]\n") + ] + ), + "redirect_uv_project_unsupported" + ); + wired("uv.lock", &[("pyproject.toml", &names("[\"requests\"]"))]); + let script_names = SCRIPT.replace( + "# ///\n", + "#\n# [tool.uv]\n# no-sources-package = [\"requests\"]\n# ///\n", + ); + assert_eq!( + refused( + "tool.py.lock", + &[ + ("tool.py", &script_names), + ("uv.toml", "no-sources-package = [\"click\"]\n") + ] + ), + "redirect_uv_script_unsupported" + ); + wired( + "tool.py.lock", + &[ + ("tool.py", &script_false), + ("uv.toml", "no-sources-package = [\"click\"]\n"), + ], + ); + } } #[cfg(test)] diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index cf750924a..88f1fbd93 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value}; +use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::python_lock::ArtifactSource; @@ -304,6 +304,91 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool { }) } +/// The project-level uv settings file, read beside `pyproject.toml` (and +/// beside a PEP 723 script, from uv's working directory). +pub const UV_TOML: &str = "uv.toml"; + +/// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would +/// ignore (#564). +/// +/// Under `no-sources = true`, or a `no-sources-package` list naming the +/// package, uv drops the source, so a wired entry fails `uv sync --locked`, +/// and a plain `uv sync` relocks the package to the registry and installs +/// the unpatched wheel. `script` is the PEP 723 script being wired (`None` +/// for a project), and `pyproject` and `uv_toml` are the project's files, +/// when present. Each file's settings are uv's (checked against uv 0.11): a +/// `uv.toml` replaces pyproject's `[tool.uv]` outright; a script's own +/// `no-sources` wins over the project's, while the two `no-sources-package` +/// lists combine; and an explicit `no-sources = false` disables the list. +/// A file that does not parse sets nothing here: uv reports it itself, and +/// the rewriters report a metadata file they cannot edit. +pub fn refuse_uv_no_sources( + name: &str, + script: Option<&str>, + pyproject: Option<&str>, + uv_toml: Option<&str>, +) -> Result<(), String> { + struct Settings { + file: &'static str, + no_sources: Option, + packages: Vec, + } + fn settings(file: &'static str, toml: &str, path: &[&str]) -> Option { + let document: DocumentMut = toml.parse().ok()?; + let mut table = document.as_table() as &dyn TableLike; + for key in path { + table = table.get(key)?.as_table_like()?; + } + Some(Settings { + file, + no_sources: table.get("no-sources").and_then(Item::as_bool), + packages: table + .get("no-sources-package") + .and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(canonicalize_pypi_name) + .collect(), + }) + } + let script = script.and_then(|text| { + let (_, metadata) = script_metadata(text).ok()?; + settings("the script's [tool.uv]", &metadata, &["tool", "uv"]) + }); + let project = match uv_toml { + Some(text) => settings(UV_TOML, text, &[]), + None => { + pyproject.and_then(|text| settings("pyproject.toml [tool.uv]", text, &["tool", "uv"])) + } + }; + let levels = [script, project]; + let levels = levels.iter().flatten(); + let refusal = |file: &str, setting: &str| { + Err(format!( + "{file} sets {setting}, so uv ignores the [tool.uv.sources] entry and a plain \ + `uv sync` would install the unpatched registry wheel; remove the setting to \ + wire a patch" + )) + }; + match levels + .clone() + .find_map(|level| Some((level.file, level.no_sources?))) + { + Some((file, true)) => return refusal(file, "no-sources = true"), + Some((_, false)) => return Ok(()), + None => {} + } + let name = canonicalize_pypi_name(name); + match levels + .into_iter() + .find(|level| level.packages.contains(&name)) + { + Some(level) => refusal(level.file, &format!("no-sources-package naming {name}")), + None => Ok(()), + } +} + pub fn rewrite_project_metadata( text: &str, name: &str, diff --git a/crates/socket-patch-core/src/vendor/pypi_lock.rs b/crates/socket-patch-core/src/vendor/pypi_lock.rs index 165dabda4..65b7c7d47 100644 --- a/crates/socket-patch-core/src/vendor/pypi_lock.rs +++ b/crates/socket-patch-core/src/vendor/pypi_lock.rs @@ -10,7 +10,7 @@ use crate::utils::python_lock::{ is_python_lock_name, python_lock_paths, rewrite_python_lock, script_of_lock, ArtifactSource, }; use crate::utils::python_script::{ - replace_script_metadata, rewrite_script_metadata, script_metadata, + refuse_uv_no_sources, replace_script_metadata, rewrite_script_metadata, script_metadata, }; use super::common::{record, refuse_symlinked}; @@ -179,6 +179,9 @@ async fn load_listed_locks( let mut files = Vec::new(); let mut in_sync = true; let mut pin = None; + // The project's pyproject.toml and uv.toml, read once on the first + // script lock for the no-sources check (#564). + let mut project_config: Option<(Option, Option)> = None; for path in paths.into_iter().filter(|path| path != "uv.lock") { let text = read_file(&root.join(&path)).await?; let rewritten = rewrite_python_lock( @@ -241,6 +244,22 @@ async fn load_listed_locks( return Err(("pypi_script_multiple_versions", format!("{path} resolves multiple versions of {name}; a script source override cannot select only {version}"))); } let script = read_file(&root.join(script_name)).await?; + let (pyproject, uv_toml) = match &project_config { + Some(config) => config, + None => project_config.insert(( + read_regular_to_string(&root.join("pyproject.toml")) + .await + .ok(), + super::pypi_uv::read_uv_toml(root).await, + )), + }; + refuse_uv_no_sources( + name, + Some(&script), + pyproject.as_deref(), + uv_toml.as_deref(), + ) + .map_err(|detail| ("pypi_uv_no_sources", format!("{script_name}: {detail}")))?; let script_wheel = source_path(package).unwrap_or(&placeholder); if rewrite_script_metadata(&script, name, version, ArtifactSource::Path(script_wheel)) .map_err(|error| { @@ -1233,6 +1252,56 @@ mod tests { ); } + /// #564: a script lock whose script (or, without its own setting, the + /// project's uv.toml or pyproject `[tool.uv]`) sets `no-sources = true` + /// is refused before anything is written, since uv would ignore the + /// vendored `[tool.uv.sources]` entry. + #[tokio::test] + async fn script_no_sources_refuses_before_writing() { + let lock = "version = 1\n[[package]]\nname = \"two\"\nversion = \"2\"\nsource = {registry = \"https://pypi.org/simple\"}\n"; + let plain = "# /// script\n# dependencies = [\"two==2\"]\n# ///\n"; + let no_sources = + "# /// script\n# dependencies = [\"two==2\"]\n#\n# [tool.uv]\n# no-sources = true\n# ///\n"; + let pyproject_no_sources = + "[project]\nname = \"p\"\nversion = \"0\"\n[tool.uv]\nno-sources = true\n"; + for (script, file, config) in [ + (no_sources, None, ""), + (plain, Some("uv.toml"), "no-sources = true\n"), + (plain, Some("pyproject.toml"), pyproject_no_sources), + (plain, Some("uv.toml"), "no-sources-package = [\"Two\"]\n"), + ] { + let temp = tempfile::tempdir().unwrap(); + tokio::fs::write(temp.path().join("job.py.lock"), lock) + .await + .unwrap(); + tokio::fs::write(temp.path().join("job.py"), script) + .await + .unwrap(); + if let Some(file) = file { + tokio::fs::write(temp.path().join(file), config) + .await + .unwrap(); + } + let Err(error) = load_python_locks( + temp.path(), + "two", + "2", + "11111111-1111-4111-8111-111111111111", + ) + .await + else { + panic!("{script:?} with {file:?} must refuse"); + }; + assert_eq!(error.0, "pypi_uv_no_sources", "{}", error.1); + assert_eq!( + tokio::fs::read_to_string(temp.path().join("job.py")) + .await + .unwrap(), + script + ); + } + } + #[tokio::test] async fn script_global_sources_cannot_replace_multiple_versions() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 090960e55..96a6afa53 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -37,6 +37,7 @@ use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_strin use crate::utils::line_endings::terminator; use crate::utils::purl::purl_name_version; use crate::utils::python_lock::preserve_line_endings; +use crate::utils::python_script::{refuse_uv_no_sources, UV_TOML}; use super::common::{ ensure_unchanged, item_get, pep508_name, pyproject_dependency_specs, record, refuse_symlinked, @@ -93,10 +94,19 @@ pub(super) struct UvProject { pub lock: Arc, /// uv.lock `revision` (diagnostics; recorded into [`UvMeta`]). pub lock_revision: Option, + /// The project's `uv.toml`, for the no-sources check (#564). + pub uv_toml: Option, /// Non-fatal advisories raised during load (untested lock revision). pub warnings: Vec, } +/// The project's `uv.toml` beside `root`'s pyproject.toml (or a script), +/// for [`refuse_uv_no_sources`]. Advisory: one that is missing or cannot +/// be read sets nothing (uv reports an unreadable one itself). +pub(super) async fn read_uv_toml(root: &Path) -> Option { + read_regular_to_string(&root.join(UV_TOML)).await.ok() +} + /// Read + parse the pair and run every project-level guard. Refuses before /// ANY write — the orchestrator runs this (and the target guards) before the /// wheel is even built, so a refusal leaves the tree byte-untouched. @@ -212,6 +222,8 @@ pub(super) async fn load_uv_project(root: &Path) -> Result Result Result { + // uv drops the [tool.uv.sources] entry under no-sources (or a + // no-sources-package list naming the package), so the vendored source + // would be ignored by the next `uv sync` (#564). + refuse_uv_no_sources( + canon_name, + None, + Some(&p.pyproject_text), + p.uv_toml.as_deref(), + ) + .map_err(|detail| ("pypi_uv_no_sources", detail))?; // The same name at multiple versions/sources (platform forks) means one // surgical [[package]] rewrite would mispin the other forks — refuse. let units = p @@ -2870,6 +2893,46 @@ wheels = [ let err = load_uv_project(tmp.path()).await.unwrap_err(); assert_eq!(err.0, "pypi_uv_dynamic_dependencies"); + // #564: uv ignores [tool.uv.sources] under no-sources = true, or a + // no-sources-package list naming the package, set in pyproject's + // [tool.uv] or in a project uv.toml (which replaces it). + let target = |p: &UvProject| { + check_target_guards(p, "six", "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f").map(|_| ()) + }; + for setting in ["no-sources = true", "no-sources-package = [\"Six\"]"] { + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\n{setting}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + let p = load_uv_project(tmp.path()).await.unwrap(); + let err = target(&p).unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_sources", "{setting}: {}", err.1); + tokio::fs::write(tmp.path().join("uv.toml"), "offline = false\n") + .await + .unwrap(); + target(&load_uv_project(tmp.path()).await.unwrap()).unwrap(); + let tmp = write_pair(DIRECT_REGISTRY_PYPROJECT, DIRECT_REGISTRY_LOCK).await; + tokio::fs::write(tmp.path().join("uv.toml"), format!("{setting}\n")) + .await + .unwrap(); + let err = target(&load_uv_project(tmp.path()).await.unwrap()).unwrap_err(); + assert_eq!(err.0, "pypi_uv_no_sources", "{setting}: {}", err.1); + } + // A list naming other packages, or disabled by no-sources = false, + // leaves the source in force. + for setting in [ + "no-sources-package = [\"requests\"]", + "no-sources = false\nno-sources-package = [\"six\"]", + ] { + let tmp = write_pair( + &format!("{DIRECT_REGISTRY_PYPROJECT}\n[tool.uv]\n{setting}\n"), + DIRECT_REGISTRY_LOCK, + ) + .await; + target(&load_uv_project(tmp.path()).await.unwrap()).unwrap(); + } + // forked package (six at two versions) let fork = format!( "{DIRECT_REGISTRY_LOCK}\n[[package]]\nname = \"six\"\nversion = \"1.17.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\n" @@ -5682,6 +5745,7 @@ wheels = [ pyproject: Arc::new(pyproject.parse().unwrap()), lock: Arc::new(lock.parse().unwrap()), lock_revision: None, + uv_toml: None, warnings: Vec::new(), } } diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 85eb0833d..f944d1b6b 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -175,6 +175,19 @@ frozen, locked, and ordinary installation outcomes separately where supported. `redirect_uv_lock_unsupported` (#767). Before, vendored left a lock that `uv sync --locked` rejects, and hosted replaced the user's URL with one its own rollback then refused to restore. +- `no-sources = true` makes uv ignore every `[tool.uv.sources]` entry, and + `no-sources-package = [...]` ignores the entries of the packages it names + (uv 0.10+), so a wired source fails `uv sync --locked` and a plain + `uv sync` installs the unpatched registry wheel. Both modes refuse it + before any file is written: vendored with `pypi_uv_no_sources`, hosted + with `redirect_uv_project_unsupported` / `redirect_uv_script_unsupported` + (#564). The settings are read where uv reads them (checked against uv + 0.11): a project `uv.toml` replaces pyproject's `[tool.uv]` outright; a + PEP 723 script's own `no-sources` wins over the project's, while the two + `no-sources-package` lists combine; and an explicit `no-sources = false` + disables the list. `UV_NO_SOURCES`, `UV_NO_SOURCES_PACKAGE`, the matching + flags and a user-level `uv.toml` belong to the machine that installs, + which the scan cannot see. - Native projects and scripts resolving multiple versions of the same package are refused when a global uv source would replace another version. Supporting those cases requires marker-specific source mappings. Standalone PEP 751