diff --git a/Cargo.lock b/Cargo.lock index 03fdd3cd8074..8b4cfd56475e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3098,6 +3098,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "schannel" version = "0.1.26" @@ -4720,6 +4730,7 @@ dependencies = [ "rayon", "reqwest", "rustc-hash", + "sanitize-filename", "sha2", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index c5e7e483b33d..35981b31aebf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,7 @@ rust-netrc = { version = "0.1.2" } rustc-hash = { version = "2.0.0" } rustix = { version = "0.38.37", default-features = false, features = ["fs", "std"] } same-file = { version = "1.0.6" } +sanitize-filename = { version = "0.5.0" } schemars = { version = "0.8.21", features = ["url"] } seahash = { version = "4.1.0" } serde = { version = "1.0.210", features = ["derive"] } diff --git a/crates/uv-extract/Cargo.toml b/crates/uv-extract/Cargo.toml index 1ebc4edc69c3..e9f37c71ae8a 100644 --- a/crates/uv-extract/Cargo.toml +++ b/crates/uv-extract/Cargo.toml @@ -28,6 +28,7 @@ md-5 = { workspace = true } rayon = { workspace = true } reqwest = { workspace = true } rustc-hash = { workspace = true } +sanitize-filename = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/crates/uv-extract/src/stream.rs b/crates/uv-extract/src/stream.rs index 2ff9278d85f9..218b236a4582 100644 --- a/crates/uv-extract/src/stream.rs +++ b/crates/uv-extract/src/stream.rs @@ -1,13 +1,15 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::pin::Pin; -use crate::Error; use futures::StreamExt; use rustc_hash::FxHashSet; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; use tracing::warn; + use uv_distribution_filename::SourceDistExtension; +use crate::Error; + const DEFAULT_BUF_SIZE: usize = 128 * 1024; /// Unpack a `.zip` archive into the target directory, without requiring `Seek`. @@ -19,6 +21,24 @@ pub async fn unzip( reader: R, target: impl AsRef, ) -> Result<(), Error> { + /// Sanitize a filename for use on Windows. + fn sanitize(filename: &str) -> PathBuf { + filename + .replace('\\', "/") + .split('/') + .map(|segment| { + sanitize_filename::sanitize_with_options( + segment, + sanitize_filename::Options { + windows: cfg!(windows), + truncate: false, + replacement: "", + }, + ) + }) + .collect() + } + let target = target.as_ref(); let mut reader = futures::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader.compat()); let mut zip = async_zip::base::read::stream::ZipFileReader::new(&mut reader); @@ -28,7 +48,7 @@ pub async fn unzip( while let Some(mut entry) = zip.next_with_entry().await? { // Construct the (expected) path to the file on-disk. let path = entry.reader().entry().filename().as_str()?; - let path = target.join(path); + let path = target.join(sanitize(path)); let is_dir = entry.reader().entry().dir()?; // Either create the directory or write the file to disk. @@ -84,7 +104,7 @@ pub async fn unzip( if has_any_executable_bit != 0 { // Construct the (expected) path to the file on-disk. let path = entry.filename().as_str()?; - let path = target.join(path); + let path = target.join(sanitize(path)); let permissions = fs_err::tokio::metadata(&path).await?.permissions(); if permissions.mode() & 0o111 != 0o111 { diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index 3b0a78510031..9bb93c921a26 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5607,3 +5607,32 @@ fn sync_seed() -> Result<()> { Ok(()) } + +/// Sanitize zip files during extraction. +#[test] +fn sanitize() -> Result<()> { + let context = TestContext::new("3.12"); + + // Install a zip file that includes a path that extends outside the parent. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("payload-package @ https://github.com/astral-sh/sanitize-wheel-test/raw/bc59283d5b4b136a191792e32baa51b477fdf65e/payload_package-0.1.0-py3-none-any.whl")?; + + uv_snapshot!(context.pip_sync() + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + payload-package==0.1.0 (from https://github.com/astral-sh/sanitize-wheel-test/raw/bc59283d5b4b136a191792e32baa51b477fdf65e/payload_package-0.1.0-py3-none-any.whl) + "### + ); + + // There should be a `payload` file in `site-packages` (but _not_ outside of it). + assert!(context.site_packages().join("payload").exists()); + + Ok(()) +}