diff --git a/crates/uv-install-wheel/src/install.rs b/crates/uv-install-wheel/src/install.rs index 6a4ee177d192c..e2bfa500a6c89 100644 --- a/crates/uv-install-wheel/src/install.rs +++ b/crates/uv-install-wheel/src/install.rs @@ -14,7 +14,8 @@ use uv_pypi_types::{DirectUrl, Metadata10}; use crate::linker::{InstallState, LinkMode, link_wheel_files}; use crate::wheel::{ LibKind, WheelFile, dist_info_metadata, find_dist_info, install_data, parse_scripts, - read_record, write_installer_metadata, write_record, write_script_entrypoints, + read_record, validate_data_script_destinations, write_installer_metadata, write_record, + write_script_entrypoints, }; use crate::{Error, Layout}; @@ -88,6 +89,7 @@ pub fn install_wheel( // > 1.b Check that installer is compatible with Wheel-Version. Warn if minor version is greater, abort if major version is greater. // > 1.c If Root-Is-Purelib == ‘true’, unpack archive into purelib (site-packages). // > 1.d Else unpack archive into platlib (site-packages). + validate_data_script_destinations(layout, wheel, &dist_info_prefix)?; trace!(?name, "Extracting wheel files"); link_wheel_files(link_mode, site_packages, wheel, state, filename)?; trace!(?name, "Extracted wheel files"); diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index 6cf90e50636c1..eab35689d2ca8 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -167,6 +167,76 @@ const RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR: &str = "python3."; const RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR: &[&str; 2] = &["python3.", "pythonw3."]; const RESERVED_SCRIPT_NAMES_WARN: &[&str; 2] = &["activate", "activate_this.py"]; +fn reserved_script_name(name: &str) -> Option<&str> { + let normalized_name = name.strip_suffix(".py").unwrap_or(name); + (RESERVED_SCRIPT_NAMES_ERROR.contains(&normalized_name) + || normalized_name + .strip_prefix(RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR) + .is_some_and(|minor| minor.parse::().is_ok()) + || RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR + .iter() + .any(|prefix| { + normalized_name + .strip_prefix(prefix) + .and_then(|minor| minor.strip_suffix('t')) + .is_some_and(|minor| minor.parse::().is_ok()) + })) + .then_some(normalized_name) +} + +fn validate_data_script_destination(target: &Path, scripts: &Path) -> Result<(), Error> { + let Some(name) = target + .strip_prefix(scripts) + .ok() + .filter(|relative| relative.components().count() == 1) + .and_then(Path::to_str) + else { + return Ok(()); + }; + + let normalized_name = name.to_ascii_lowercase(); + let normalized_name = normalized_name + .strip_suffix(".exe") + .unwrap_or(&normalized_name); + if let Some(reserved) = reserved_script_name(normalized_name) { + return Err(Error::ReservedScriptName { + reserved: reserved.to_string(), + declared: name.to_string(), + }); + } + + Ok(()) +} + +/// Validate every `.data` script destination before linking the wheel into site-packages. +pub(crate) fn validate_data_script_destinations( + layout: &Layout, + wheel: &Path, + dist_info_prefix: &str, +) -> Result<(), Error> { + let data_dir = wheel.join(format!("{dist_info_prefix}.data")); + for (source, destination) in [ + (data_dir.join("scripts"), &layout.scheme.scripts), + (data_dir.join("data"), &layout.scheme.data), + ] { + if !source.is_dir() { + continue; + } + + for entry in WalkDir::new(&source).min_depth(1) { + let entry = entry?; + if entry.file_type().is_dir() { + continue; + } + + let relative = relative_to(entry.path(), &source)?; + validate_data_script_destination(&destination.join(relative), &layout.scheme.scripts)?; + } + } + + Ok(()) +} + /// A form of [`Script`] guaranteed by [`ValidatedScript::try_from_script`] to be constrained to /// the scripts directory. struct ValidatedScript<'script> { @@ -201,21 +271,9 @@ impl<'script> ValidatedScript<'script> { // Apply the Windows launcher normalization before checking so wheel validity is portable. // FIXME: What are the in-reality rules here for name normalization? let normalized_name = name.strip_suffix(".py").unwrap_or(name.as_str()); - if RESERVED_SCRIPT_NAMES_ERROR.contains(&normalized_name) - || normalized_name - .strip_prefix(RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR) - .is_some_and(|minor| minor.parse::().is_ok()) - || RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR - .iter() - .any(|prefix| { - normalized_name - .strip_prefix(prefix) - .and_then(|minor| minor.strip_suffix('t')) - .is_some_and(|minor| minor.parse::().is_ok()) - }) - { + if let Some(reserved) = reserved_script_name(normalized_name) { return Err(Error::ReservedScriptName { - reserved: normalized_name.to_string(), + reserved: reserved.to_string(), declared: script.name.clone(), }); } @@ -392,6 +450,7 @@ pub(crate) enum LibKind { fn move_folder_recorded( src_dir: &Path, dest_dir: &Path, + scripts: &Path, site_packages: &Path, record: &mut [RecordEntry], ) -> Result<(), Error> { @@ -414,6 +473,7 @@ fn move_folder_recorded( if entry.file_type().is_dir() { fs::create_dir_all(&target)?; } else { + validate_data_script_destination(&target, scripts)?; rename_or_copy.rename_or_copy(src, &target)?; let entry = record .iter_mut() @@ -467,6 +527,7 @@ fn install_script( } let script_absolute = layout.scheme.scripts.join(file.file_name()); + validate_data_script_destination(&script_absolute, &layout.scheme.scripts)?; let script_relative = pathdiff::diff_paths(&script_absolute, site_packages).ok_or_else(|| { Error::Io(io::Error::other(format!( @@ -658,7 +719,13 @@ pub(crate) fn install_data( layout.scheme.data.user_display() ); // Move the content of the folder to the root of the venv - move_folder_recorded(&path, &layout.scheme.data, site_packages, record)?; + move_folder_recorded( + &path, + &layout.scheme.data, + &layout.scheme.scripts, + site_packages, + record, + )?; } Some("scripts") => { trace!( @@ -710,7 +777,13 @@ pub(crate) fn install_data( "Installing data/headers to {}", target_path.user_display() ); - move_folder_recorded(&path, &target_path, site_packages, record)?; + move_folder_recorded( + &path, + &target_path, + &layout.scheme.scripts, + site_packages, + record, + )?; } Some("purelib") => { trace!( @@ -718,7 +791,13 @@ pub(crate) fn install_data( "Installing data/purelib to {}", layout.scheme.purelib.user_display() ); - move_folder_recorded(&path, &layout.scheme.purelib, site_packages, record)?; + move_folder_recorded( + &path, + &layout.scheme.purelib, + &layout.scheme.scripts, + site_packages, + record, + )?; } Some("platlib") => { trace!( @@ -726,7 +805,13 @@ pub(crate) fn install_data( "Installing data/platlib to {}", layout.scheme.platlib.user_display() ); - move_folder_recorded(&path, &layout.scheme.platlib, site_packages, record)?; + move_folder_recorded( + &path, + &layout.scheme.platlib, + &layout.scheme.scripts, + site_packages, + record, + )?; } _ => { return Err(Error::InvalidWheel(format!( diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index 118570ccacec7..96d37a8a3a552 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -14,7 +14,7 @@ use fs_err::File; use futures::executor::block_on; use futures::io::AllowStdIo; use indoc::{formatdoc, indoc}; -use insta::assert_snapshot; +use insta::{allow_duplicates, assert_snapshot}; use predicates::prelude::predicate; use tokio_util::compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt}; use url::Url; @@ -14153,6 +14153,82 @@ fn reserved_script_name() -> Result<()> { Ok(()) } +#[test] +fn reject_reserved_wheel_data_script_name() -> Result<()> { + let interpreter = if cfg!(windows) { + "python.exe" + } else { + "python" + }; + let scripts = if cfg!(windows) { "Scripts" } else { "bin" }; + + allow_duplicates! { + for data_path in ["python", "Python.exe", "python.EXE"] + .into_iter() + .flat_map(|executable| { + [ + format!("foo-0.1.0.data/scripts/{executable}"), + format!("foo-0.1.0.data/data/{scripts}/{executable}"), + ] + }) + { + let context = uv_test::test_context!("3.12") + .with_filter((r"got: `(?:Python\.exe|python(?:\.EXE)?)`", "got: `python`")); + let wheel = context.temp_dir.join("foo-0.1.0-py3-none-any.whl"); + let record = formatdoc! {" + foo/__init__.py,, + foo-0.1.0.dist-info/METADATA,, + foo-0.1.0.dist-info/WHEEL,, + foo-0.1.0.dist-info/RECORD,, + foo-0.1.0.data/data/harmless.txt,, + {data_path},, + "}; + let mut writer = ZipFileWriter::new(Vec::new()); + for (name, contents) in [ + ("foo/__init__.py", ""), + ( + "foo-0.1.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: foo\nVersion: 0.1.0\n", + ), + ( + "foo-0.1.0.dist-info/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ), + ("foo-0.1.0.dist-info/RECORD", record.as_str()), + ("foo-0.1.0.data/data/harmless.txt", "this must not be moved"), + (data_path.as_str(), "this must not replace python"), + ] { + let entry = ZipEntryBuilder::new(name.into(), Compression::Stored); + block_on(writer.write_entry_whole(entry, contents.as_bytes()))?; + } + fs_err::write(&wheel, block_on(writer.close())?)?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `python`, got: `python` + "); + + Command::new(venv_bin_path(&context.venv).join(interpreter)) + .arg("--version") + .assert() + .success(); + assert!(!context.site_packages().join("foo").exists()); + assert!(!context.site_packages().join("foo-0.1.0.dist-info").exists()); + assert!(!context.venv.join("harmless.txt").exists()); + } + Ok::<(), anyhow::Error>(()) + }?; + + Ok(()) +} + fn repacked_wheel_with_entrypoint( context: &TestContext, section: &str,