diff --git a/crates/uv-distribution/src/error.rs b/crates/uv-distribution/src/error.rs index 72ee339ad863..530fe8b161e3 100644 --- a/crates/uv-distribution/src/error.rs +++ b/crates/uv-distribution/src/error.rs @@ -92,6 +92,8 @@ pub enum Error { MissingEggInfo, #[error("The source distribution is missing a `requires.txt` file")] MissingRequiresTxt, + #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())] + MissingSubdirectory(Url, PathBuf), #[error("Failed to extract static metadata from `PKG-INFO`")] PkgInfo(#[source] uv_pypi_types::MetadataError), #[error("Failed to extract metadata from `requires.txt`")] diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 7c36bb2c814a..9a1f4d93ed93 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -393,6 +393,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { let cache_shard = cache_shard.shard(revision.id()); let source_dist_entry = cache_shard.entry(SOURCE); + // Validate that the subdirectory exists. + if let Some(subdirectory) = subdirectory { + if !source_dist_entry.path().join(subdirectory).is_dir() { + return Err(Error::MissingSubdirectory( + url.clone(), + subdirectory.to_path_buf(), + )); + } + } + // If there are build settings, we need to scope to a cache shard. let config_settings = self.build_context.config_settings(); let cache_shard = if config_settings.is_empty() { @@ -496,6 +506,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { let cache_shard = cache_shard.shard(revision.id()); let source_dist_entry = cache_shard.entry(SOURCE); + // Validate that the subdirectory exists. + if let Some(subdirectory) = subdirectory { + if !source_dist_entry.path().join(subdirectory).is_dir() { + return Err(Error::MissingSubdirectory( + url.clone(), + subdirectory.to_path_buf(), + )); + } + } + // If the metadata is static, return it. if let Some(metadata) = Self::read_static_metadata(source, source_dist_entry.path(), subdirectory).await? @@ -1303,6 +1323,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { ) .await?; + // Validate that the subdirectory exists. + if let Some(subdirectory) = resource.subdirectory { + if !fetch.path().join(subdirectory).is_dir() { + return Err(Error::MissingSubdirectory( + resource.url.to_url(), + subdirectory.to_path_buf(), + )); + } + } + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let cache_shard = self.build_context.cache().shard( CacheBucket::SourceDistributions, @@ -1390,6 +1420,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { ) .await?; + // Validate that the subdirectory exists. + if let Some(subdirectory) = resource.subdirectory { + if !fetch.path().join(subdirectory).is_dir() { + return Err(Error::MissingSubdirectory( + resource.url.to_url(), + subdirectory.to_path_buf(), + )); + } + } + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let cache_shard = self.build_context.cache().shard( CacheBucket::SourceDistributions, @@ -1438,12 +1478,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .await? .filter(|metadata| metadata.matches(source.name(), source.version())) { - let path = if let Some(subdirectory) = resource.subdirectory { - Cow::Owned(fetch.path().join(subdirectory)) - } else { - Cow::Borrowed(fetch.path()) - }; - let git_member = GitWorkspaceMember { fetch_root: fetch.path(), git_source: resource, diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 2342c295eda3..952bc46cce50 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -7589,3 +7589,45 @@ fn build_tag() { ----- stderr ----- "###); } + +#[test] +fn missing_subdirectory_git() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + + uv_snapshot!(context.pip_install() + .arg("source-distribution @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `source-distribution @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing` + ╰─▶ The source distribution `git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing` has no subdirectory `missing` + "### + ); + + Ok(()) +} + +#[test] +fn missing_subdirectory_url() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + + uv_snapshot!(context.pip_install() + .arg("source-distribution @ https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz#subdirectory=missing"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `source-distribution @ https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz#subdirectory=missing` + ╰─▶ The source distribution `https://files.pythonhosted.org/packages/1f/e5/5b016c945d745f8b108e759d428341488a6aee8f51f07c6c4e33498bb91f/source_distribution-0.0.3.tar.gz#subdirectory=missing` has no subdirectory `missing` + "### + ); + + Ok(()) +}