From 2a66349e96ca25d15a6875a54f929c32b7d5757f Mon Sep 17 00:00:00 2001 From: John Mumm Date: Mon, 9 Jun 2025 13:28:39 -0400 Subject: [PATCH] Check if relative URL is valid directory before treating as index (#13917) As per #13874, passing a relative URL like `test` to `--index` for `uv add` causes unexpected behavior if the directory does not exist. The non-existent index is effectively ignored and uv falls back to PyPI. If a package is found there, the spurious index is then written to `pyproject.toml`. This doesn't happen for `--default-index` since resolution will fail without fallback to PyPI. This PR adds a validation step for indexes provided on the command line. If a directory does not exist, uv will fail with an error. Closes #13874 --- crates/uv-pep508/src/verbatim_url.rs | 6 +- crates/uv/src/commands/project/add.rs | 17 ++++- crates/uv/tests/it/edit.rs | 95 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/crates/uv-pep508/src/verbatim_url.rs b/crates/uv-pep508/src/verbatim_url.rs index a7633bdcb..988bebc5e 100644 --- a/crates/uv-pep508/src/verbatim_url.rs +++ b/crates/uv-pep508/src/verbatim_url.rs @@ -106,10 +106,8 @@ impl VerbatimUrl { let (path, fragment) = split_fragment(&path); // Convert to a URL. - let mut url = DisplaySafeUrl::from( - Url::from_file_path(path.clone()) - .unwrap_or_else(|()| panic!("path is absolute: {}", path.display())), - ); + let mut url = DisplaySafeUrl::from_file_path(path.clone()) + .unwrap_or_else(|()| panic!("path is absolute: {}", path.display())); // Set the fragment, if it exists. if let Some(fragment) = fragment { diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 2c64d9dbb..a4091504d 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -23,8 +23,8 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_distribution_types::{ - Index, IndexName, IndexUrls, NameRequirementSpecification, Requirement, RequirementSource, - UnresolvedRequirement, VersionId, + Index, IndexName, IndexUrl, IndexUrls, NameRequirementSpecification, Requirement, + RequirementSource, UnresolvedRequirement, VersionId, }; use uv_fs::{LockedFile, Simplified}; use uv_git::GIT_STORE; @@ -473,6 +473,19 @@ pub(crate) async fn add( &mut toml, )?; + // Validate any indexes that were provided on the command-line to ensure + // they point to existing directories when using path URLs. + for index in &indexes { + if let IndexUrl::Path(url) = &index.url { + let path = url + .to_file_path() + .map_err(|()| anyhow::anyhow!("Invalid file path in index URL: {url}"))?; + if !path.is_dir() { + bail!("Directory not found for index: {url}"); + } + } + } + // Add any indexes that were provided on the command-line, in priority order. if !raw { let urls = IndexUrls::from_indexes(indexes); diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index db47b27ef..5ba64fca2 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -9303,6 +9303,101 @@ fn add_index_without_trailing_slash() -> Result<()> { Ok(()) } +/// Add an index with an existing relative path. +#[test] +fn add_index_with_existing_relative_path_index() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create test-index/ subdirectory and copy our "offline" tqdm wheel there + let packages = context.temp_dir.child("test-index"); + packages.create_dir_all()?; + + let wheel_src = context + .workspace_root + .join("scripts/links/ok-1.0.0-py3-none-any.whl"); + let wheel_dst = packages.child("ok-1.0.0-py3-none-any.whl"); + fs_err::copy(&wheel_src, &wheel_dst)?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--index").arg("test-index"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + Ok(()) +} + +/// Add an index with a non-existent relative path. +#[test] +fn add_index_with_non_existent_relative_path() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--index").arg("test-index"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Directory not found for index: file://[TEMP_DIR]/test-index + "); + + Ok(()) +} + +/// Add an index with a non-existent relative path with the same name as a defined index. +#[test] +fn add_index_with_non_existent_relative_path_with_same_name_as_index() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [[tool.uv.index]] + name = "test-index" + url = "https://pypi-proxy.fly.dev/simple" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--index").arg("test-index"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Directory not found for index: file://[TEMP_DIR]/test-index + "); + + Ok(()) +} + /// Add a PyPI requirement. #[test] fn add_group_comment() -> Result<()> {