diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 2c21b6fb4992f..110520180684e 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -357,6 +357,20 @@ async fn build_impl( vec![AnnotatedSource::from(src)] }; + // Build backends can include arbitrary files from the source directory in the distribution. + // Reject an active cache within the source to avoid including cache contents in the build. + for source in &packages { + if let Source::Directory(source_dir) = &source.source + && is_path_within(cache.root(), source_dir) + { + return Err(anyhow::anyhow!( + "The cache directory `{}` is inside the build source directory `{}`", + cache.root().user_display(), + source_dir.user_display() + )); + } + } + let results: Vec<_> = futures::future::join_all(packages.into_iter().map(|source| { let future = build_package( source.clone(), @@ -1241,6 +1255,21 @@ impl Source<'_> { } } +/// Return `true` if `path` is within `directory`, resolving symlinks when possible. +fn is_path_within(path: &Path, directory: &Path) -> bool { + if path.starts_with(directory) { + return true; + } + + if let Ok(path) = fs_err::canonicalize(path) + && let Ok(directory) = fs_err::canonicalize(directory) + { + path.starts_with(directory) + } else { + false + } +} + /// We run all builds in parallel, so we wait until all builds are done to show the success messages /// in order. #[derive(Debug, Clone)] diff --git a/crates/uv/tests/build/cache.rs b/crates/uv/tests/build/cache.rs index 83b5f3d051d95..30ce8ad9ff3cf 100644 --- a/crates/uv/tests/build/cache.rs +++ b/crates/uv/tests/build/cache.rs @@ -1,11 +1,150 @@ use anyhow::Result; use assert_fs::prelude::*; +use predicates::prelude::predicate; use std::process::Command; #[cfg(unix)] use uv_fs::create_symlink; use uv_test::{get_bin, uv_snapshot}; +/// When the active cache directory is inside an explicit build source, we should error before +/// invoking the build backend or creating the output directory. +#[test] +fn build_rejects_cache_inside_source() -> Result<()> { + let mut context = uv_test::test_context!("3.12"); + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + project.child("src/project/__init__.py").touch()?; + + context.cache_dir = project.child(".uv-cache"); + + uv_snapshot!(context.filters(), context.build().arg("--sdist").arg("project"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The cache directory `project/.uv-cache` is inside the build source directory `project` + "); + + project.child("dist").assert(predicate::path::missing()); + + Ok(()) +} + +/// When the canonical cache directory is inside an explicit build source, we should error even if +/// the configured cache path itself is outside the source. +#[test] +#[cfg(unix)] +fn build_rejects_symlinked_cache_inside_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let project = context.temp_dir.child("project"); + + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + project.child("src/project/__init__.py").touch()?; + + let cache_dir = project.child(".uv-cache"); + cache_dir.create_dir_all()?; + let cache_link = context.temp_dir.child("cache-link"); + create_symlink(cache_dir.path(), cache_link.path())?; + + let mut command = Command::new(get_bin!()); + command + .arg("build") + .arg("--sdist") + .arg("project") + .arg("--cache-dir") + .arg(cache_link.path()); + context.add_shared_env(&mut command, false); + command.current_dir(context.temp_dir.path()); + + uv_snapshot!(context.filters(), command, @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The cache directory `cache-link` is inside the build source directory `project` + "); + + project.child("dist").assert(predicate::path::missing()); + + Ok(()) +} + +/// A cache in the workspace root is allowed when building a member that does not contain it. +#[test] +fn build_allows_cache_outside_selected_source() -> Result<()> { + let mut context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("workspace"); + let member = workspace.child("member"); + + workspace.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["member"] + "#, + )?; + member.child("pyproject.toml").write_str( + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["uv_build>=0.5.15,<10000"] + build-backend = "uv_build" + "#, + )?; + member.child("src/member/__init__.py").touch()?; + + context.cache_dir = workspace.child(".uv-cache"); + + uv_snapshot!(context.filters(), context.build() + .arg("--sdist") + .arg("--package") + .arg("member") + .current_dir(&workspace), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Building source distribution (uv build backend)... + Successfully built dist/member-0.1.0.tar.gz + "); + + workspace + .child("dist/member-0.1.0.tar.gz") + .assert(predicate::path::is_file()); + + Ok(()) +} + /// When the project directory defaults to a current directory inside the cache directory, we should /// error before using the cache. #[test]