Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions crates/uv/src/commands/build_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what does "active" mean here?

@konstin konstin Jun 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can create two cache dirs with uv pip install tqdm --cache-dir a and uv pip install tqdm --cache-dir b, but while a is a uv cache dir, in the latter call only b is an active cache dir.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm OK I guess, but how's that relevant from the context of this PR? When you're running uv pip install tqdm --cache-dir b then a is just a regular directory, we don't care about it. The nit is that "active" in this context isn't a useful distinction

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's two ways we could detect cache dirs: We could look for a uv marker that tells us this is any uv cache dir and ban that, or we could only consider the one active cache dir to reject. In this PR, we chose the latter approach: Only reject the active cache, but allow any other cache (without saying whether that's a good idea, we just say we don't explicitly reject those).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, exactly that. I figured rejecting the "active" one is the best balance between guarding most users and needing to identify all possible caches in the project tree. We could do the latter by walking and looking for CACHEDIR.TAG, but I was concerned about the performance hit from doing that 🙂

(OTOH maybe we should, at least for our own build backend? I'm not sure.)

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(),
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB: This is the happy path, I don't expect we'd usually have situations where the cache directory is only enclosed under the project directory after canonicalization.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this cause false positives for paths like /foo/bar/../baz which will be treated as within /foo/bar?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making sure you don't miss this comment^

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, thanks. Yeah, I think this is wrong. I thought Path::starts_with would reject this, but looks like not.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'll do a follow-up PR with the fix.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @EliteTK another case

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)]
Expand Down
139 changes: 139 additions & 0 deletions crates/uv/tests/build/cache.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
Loading