diff --git a/crates/uv-build-backend/src/wheel.rs b/crates/uv-build-backend/src/wheel.rs index 2b0c8f95291ab..2d4f2103fd00d 100644 --- a/crates/uv-build-backend/src/wheel.rs +++ b/crates/uv-build-backend/src/wheel.rs @@ -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; @@ -138,27 +138,7 @@ fn write_wheel( .cloned() .unwrap_or_else(BuildBackendSettings::default); - // Wheel excludes - let mut excludes: Vec = 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( @@ -239,6 +219,7 @@ fn write_wheel( pyproject_toml.license_files_wheel(), &mut wheel_writer, "project.license-files", + None, )?; } @@ -246,6 +227,7 @@ fn write_wheel( source_tree, pyproject_toml, settings.data.iter(), + &exclude_matcher, &mut wheel_writer, )?; @@ -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)?; @@ -322,6 +305,7 @@ pub fn build_editable( source_tree, &pyproject_toml, settings.data.iter(), + &exclude_matcher, &mut wheel_writer, )?; @@ -347,9 +331,13 @@ fn write_data_files<'data>( source_tree: &Path, pyproject_toml: &PyProjectToml, data: impl Iterator, + 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() @@ -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(), @@ -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 { + let mut excludes: Vec = 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, @@ -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() @@ -561,6 +580,15 @@ fn wheel_subdir_from_globs( let mut written_directories = FxHashSet::::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() @@ -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 { @@ -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; } diff --git a/crates/uv/tests/build/build_backend.rs b/crates/uv/tests/build/build_backend.rs index b73f86ac4812b..e9d2acc9a4024 100644 --- a/crates/uv/tests/build/build_backend.rs +++ b/crates/uv/tests/build/build_backend.rs @@ -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(()) }