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
This commit is contained in:
John Mumm 2025-06-09 13:28:39 -04:00 committed by GitHub
parent 619a0eafa1
commit 2a66349e96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 112 additions and 6 deletions

View file

@ -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 {

View file

@ -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);

View file

@ -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<()> {