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
78 changes: 53 additions & 25 deletions crates/uv-build-backend/src/wheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use tracing::{debug, trace};
use walkdir::WalkDir;

use uv_distribution_filename::WheelFilename;
use uv_fs::Simplified;
use uv_fs::{Simplified, normalize_path};
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag};
use uv_preview::PreviewFeature;
Expand Down Expand Up @@ -138,27 +138,7 @@ fn write_wheel(
.cloned()
.unwrap_or_else(BuildBackendSettings::default);

// Wheel excludes
let mut excludes: Vec<String> = Vec::new();
if settings.default_excludes {
excludes.extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
}
for exclude in settings.wheel_exclude {
// Avoid duplicate entries.
if !excludes.contains(&exclude) {
excludes.push(exclude);
}
}
// The wheel must not include any files excluded by the source distribution (at least until we
// have files generated in the source dist -> wheel build step).
for exclude in &settings.source_exclude {
// Avoid duplicate entries.
if !excludes.contains(exclude) {
excludes.push(exclude.clone());
}
}
debug!("Wheel excludes: {:?}", excludes);
let exclude_matcher = build_exclude_matcher(excludes)?;
let exclude_matcher = build_wheel_exclude_matcher(&settings)?;

debug!("Adding content files to wheel");
let (src_root, module_relative) = find_roots(
Expand Down Expand Up @@ -239,13 +219,15 @@ fn write_wheel(
pyproject_toml.license_files_wheel(),
&mut wheel_writer,
"project.license-files",
None,
)?;
}

write_data_files(
source_tree,
pyproject_toml,
settings.data.iter(),
&exclude_matcher,
&mut wheel_writer,
)?;

Expand Down Expand Up @@ -278,6 +260,7 @@ pub fn build_editable(
.settings()
.cloned()
.unwrap_or_else(BuildBackendSettings::default);
let exclude_matcher = build_wheel_exclude_matcher(&settings)?;

crate::check_metadata_directory(source_tree, metadata_directory, &pyproject_toml)?;

Expand Down Expand Up @@ -322,6 +305,7 @@ pub fn build_editable(
source_tree,
&pyproject_toml,
settings.data.iter(),
&exclude_matcher,
&mut wheel_writer,
)?;

Expand All @@ -347,9 +331,13 @@ fn write_data_files<'data>(
source_tree: &Path,
pyproject_toml: &PyProjectToml,
data: impl Iterator<Item = (&'static str, &'data Path)>,
exclude_matcher: &GlobSet,
wheel_writer: &mut impl DirectoryWriter,
) -> Result<(), Error> {
let canonical_source_tree = source_tree.simple_canonicalize()?;

for (name, directory) in data {
let directory = normalize_path(directory);
debug!(
"Adding {name} data files from: {}",
directory.user_display()
Expand All @@ -364,6 +352,16 @@ fn write_data_files<'data>(
path: directory.to_path_buf(),
});
}
let data_root = source_tree.join(directory.as_ref());
if !data_root
.simple_canonicalize()?
.starts_with(&canonical_source_tree)
{
return Err(Error::InvalidDataRoot {
name: name.to_string(),
path: directory.to_path_buf(),
});
}
let data_dir = format!(
"{}-{}.data/{}/",
pyproject_toml.name().as_dist_info_name(),
Expand All @@ -372,17 +370,37 @@ fn write_data_files<'data>(
);

wheel_subdir_from_globs(
&source_tree.join(directory),
&data_root,
&data_dir,
&["**".to_string()],
wheel_writer,
&format!("tool.uv.build-backend.data.{name}"),
Some((exclude_matcher, source_tree)),
)?;
}

Ok(())
}

/// Build a globset matcher for all files that must be excluded from a wheel.
fn build_wheel_exclude_matcher(settings: &BuildBackendSettings) -> Result<GlobSet, Error> {
let mut excludes: Vec<String> = Vec::new();
if settings.default_excludes {
excludes.extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
}
for exclude in settings
.wheel_exclude
.iter()
.chain(&settings.source_exclude)
{
if !excludes.contains(exclude) {
excludes.push(exclude.clone());
}
}
debug!("Wheel excludes: {:?}", excludes);
build_exclude_matcher(excludes)
}

/// Write the dist-info directory to the output directory without building the wheel.
pub fn metadata(
source_tree: &Path,
Expand Down Expand Up @@ -535,6 +553,7 @@ fn wheel_subdir_from_globs(
wheel_writer: &mut impl DirectoryWriter,
// For error messages
globs_field: &str,
exclude_matcher: Option<(&GlobSet, &Path)>,
) -> Result<(), Error> {
let license_files_globs: Vec<_> = globs
.into_iter()
Expand All @@ -561,6 +580,15 @@ fn wheel_subdir_from_globs(

let mut written_directories = FxHashSet::<PathBuf>::default();
let target = Path::new(target);
let is_excluded = |path: &Path| {
exclude_matcher.is_some_and(|(exclude_matcher, source_tree)| {
if let Ok(relative) = path.strip_prefix(source_tree) {
exclude_matcher.is_match(relative)
} else {
true
}
})
};

for entry in WalkDir::new(src)
.sort_by_file_name()
Expand All @@ -573,7 +601,7 @@ fn wheel_subdir_from_globs(
.expect("walkdir starts with root");

// Fast path: Don't descend into a directory that can't be included.
matcher.match_directory(relative)
matcher.match_directory(relative) && !is_excluded(entry.path())
})
{
let entry = entry.map_err(|err| Error::WalkDir {
Expand All @@ -594,7 +622,7 @@ fn wheel_subdir_from_globs(
.strip_prefix(src)
.expect("walkdir starts with root");

if !matcher.match_path(relative) {
if !matcher.match_path(relative) || is_excluded(entry.path()) {
trace!("Excluding {}: {}", globs_field, relative.user_display());
continue;
}
Expand Down
162 changes: 162 additions & 0 deletions crates/uv/tests/build/build_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,168 @@ fn error_on_relative_data_dir_outside_project_root() -> Result<()> {
Caused by: The path for the data directory headers must be inside the project: ../header
");

pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"

[tool.uv.build-backend.data]
headers = "header/../../outside"

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
project.child("header").create_dir_all()?;
context
.temp_dir
.child("outside/secret.h")
.write_str("not for distribution")?;

uv_snapshot!(context.filters(), context.build().arg("project").arg("--wheel"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Building wheel (uv build backend)...
error: Failed to build `[TEMP_DIR]/project`
Caused by: The path for the data directory headers must be inside the project: ../outside
");

Ok(())
}

/// Files excluded from a source distribution or wheel must not leak through a wheel data root.
#[test]
fn wheel_data_respects_excludes() -> Result<()> {
let context = uv_test::test_context!("3.12");

context
.temp_dir
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"

[tool.uv.build-backend]
source-exclude = ["*.source-secret"]
wheel-exclude = ["*.wheel-secret"]

[tool.uv.build-backend.data]
data = "assets"

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;
context.temp_dir.child("src/project/__init__.py").touch()?;
context.temp_dir.child("assets/public.txt").touch()?;
context
.temp_dir
.child("assets/private.source-secret")
.touch()?;
context
.temp_dir
.child("assets/private.wheel-secret")
.touch()?;
context.temp_dir.child("assets/generated.pyc").touch()?;

uv_snapshot!(context.build().arg("--wheel").arg("--list"), @"
success: true
exit_code: 0
----- stdout -----
Building project-0.1.0-py3-none-any.whl will include the following files:
project/__init__.py (src/project/__init__.py)
project-0.1.0.data/data/public.txt (assets/public.txt)
project-0.1.0.dist-info/WHEEL (generated)
project-0.1.0.dist-info/METADATA (generated)

----- stderr -----
");

Ok(())
}

/// A symlinked data root must not package files from outside the project, while an internal
/// symlink still honors the configured excludes.
#[test]
#[cfg(unix)]
fn wheel_data_symlink_containment() -> Result<()> {
let context = uv_test::test_context!("3.12");

let project = context.temp_dir.child("project");
project.child("src/project/__init__.py").touch()?;
context
.temp_dir
.child("outside/secret.txt")
.write_str("not for distribution")?;
fs_err::os::unix::fs::symlink(
context.temp_dir.child("outside").path(),
project.child("external-assets").path(),
)?;

let pyproject_toml = project.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"

[tool.uv.build-backend.data]
data = "external-assets"

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;

uv_snapshot!(context.filters(), context.build().arg("project").arg("--wheel").arg("--list"), @"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: Failed to build `[TEMP_DIR]/project`
Caused by: The path for the data directory data must be inside the project: external-assets
");

project.child("assets/public.txt").touch()?;
project.child("assets/private.secret").touch()?;
fs_err::os::unix::fs::symlink(
project.child("assets").path(),
project.child("internal-assets").path(),
)?;
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"

[tool.uv.build-backend]
wheel-exclude = ["*.secret"]

[tool.uv.build-backend.data]
data = "internal-assets"

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#})?;

uv_snapshot!(context.build().arg("project").arg("--wheel").arg("--list"), @"
success: true
exit_code: 0
----- stdout -----
Building project-0.1.0-py3-none-any.whl will include the following files:
project/__init__.py (src/project/__init__.py)
project-0.1.0.data/data/public.txt (internal-assets/public.txt)
project-0.1.0.dist-info/WHEEL (generated)
project-0.1.0.dist-info/METADATA (generated)

----- stderr -----
");

Ok(())
}

Expand Down
Loading