Skip to content
Closed
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
4 changes: 3 additions & 1 deletion crates/uv-install-wheel/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -88,6 +89,7 @@ pub fn install_wheel<Cache: serde::Serialize, Build: serde::Serialize>(
// > 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");
Expand Down
121 changes: 103 additions & 18 deletions crates/uv-install-wheel/src/wheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u8>().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::<u8>().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> {
Expand Down Expand Up @@ -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::<u8>().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::<u8>().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(),
});
}
Expand Down Expand Up @@ -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> {
Expand All @@ -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()
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -710,23 +777,41 @@ 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!(
?dist_name,
"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!(
?dist_name,
"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!(
Expand Down
78 changes: 77 additions & 1 deletion crates/uv/tests/pip_install/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading