diff --git a/Cargo.lock b/Cargo.lock index d10810213..76c7f205d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4434,6 +4434,7 @@ dependencies = [ "tokio", "tokio-tar", "tokio-util", + "tracing", "zip", ] diff --git a/crates/uv-extract/Cargo.toml b/crates/uv-extract/Cargo.toml index 528181e84..e89b3406b 100644 --- a/crates/uv-extract/Cargo.toml +++ b/crates/uv-extract/Cargo.toml @@ -24,4 +24,5 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["io-util"] } tokio-tar = { workspace = true } tokio-util = { workspace = true, features = ["compat"] } +tracing = { workspace = true } zip = { workspace = true } diff --git a/crates/uv-extract/src/stream.rs b/crates/uv-extract/src/stream.rs index b76cdfd84..2798ab89e 100644 --- a/crates/uv-extract/src/stream.rs +++ b/crates/uv-extract/src/stream.rs @@ -4,6 +4,7 @@ use std::pin::Pin; use futures::StreamExt; use rustc_hash::FxHashSet; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use tracing::warn; use crate::Error; @@ -41,7 +42,7 @@ pub async fn unzip( } // We don't know the file permissions here, because we haven't seen the central directory yet. - let file = fs_err::tokio::File::create(path).await?; + let file = fs_err::tokio::File::create(&path).await?; let mut writer = if let Ok(size) = usize::try_from(entry.reader().entry().uncompressed_size()) { tokio::io::BufWriter::with_capacity(size, file) @@ -111,6 +112,19 @@ async fn untar_in>( while let Some(entry) = pinned.next().await { // Unpack the file into the destination directory. let mut file = entry?; + + // On Windows, skip symlink entries, as they're not supported. pip recursively copies the + // symlink target instead. + if cfg!(windows) { + if file.header().entry_type().is_symlink() { + warn!( + "Skipping symlink in tar archive: {}", + file.path()?.display() + ); + continue; + } + } + file.unpack_in(dst.as_ref()).await?; // Preserve the executable bit. @@ -119,17 +133,19 @@ async fn untar_in>( use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; - let mode = file.header().mode()?; - - let has_any_executable_bit = mode & 0o111; - if has_any_executable_bit != 0 { - if let Some(path) = crate::tar::unpacked_at(dst.as_ref(), &file.path()?) { - let permissions = fs_err::tokio::metadata(&path).await?.permissions(); - fs_err::tokio::set_permissions( - &path, - Permissions::from_mode(permissions.mode() | 0o111), - ) - .await?; + let entry_type = file.header().entry_type(); + if entry_type.is_file() || entry_type.is_hard_link() { + let mode = file.header().mode()?; + let has_any_executable_bit = mode & 0o111; + if has_any_executable_bit != 0 { + if let Some(path) = crate::tar::unpacked_at(dst.as_ref(), &file.path()?) { + let permissions = fs_err::tokio::metadata(&path).await?.permissions(); + fs_err::tokio::set_permissions( + &path, + Permissions::from_mode(permissions.mode() | 0o111), + ) + .await?; + } } } } diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index f216ae36b..54e4a836b 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -49,6 +49,19 @@ fn command(context: &TestContext) -> Command { command } +/// Create a `pip uninstall` command with options shared across scenarios. +fn uninstall_command(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("pip") + .arg("uninstall") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .current_dir(&context.temp_dir); + command +} + #[test] fn missing_requirements_txt() { let context = TestContext::new("3.12"); @@ -1770,3 +1783,38 @@ fn reinstall_duplicate() -> Result<()> { Ok(()) } + +/// Install a package that contains a symlink within the archive. +#[test] +fn install_symlink() { + let context = TestContext::new("3.12"); + + uv_snapshot!(command(&context) + .arg("pgpdump==1.5") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + pgpdump==1.5 + "### + ); + + context.assert_command("import pgpdump").success(); + + uv_snapshot!(uninstall_command(&context) + .arg("pgpdump"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Uninstalled 1 package in [TIME] + - pgpdump==1.5 + "### + ); +}