Skip to content

Commit

Permalink
Sanitize filenames during zip extraction
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Oct 31, 2024
1 parent 8d3408f commit e98a6a1
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 4 deletions.
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions crates/uv-extract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
28 changes: 24 additions & 4 deletions crates/uv-extract/src/stream.rs
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -19,6 +21,24 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
reader: R,
target: impl AsRef<Path>,
) -> 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);
Expand All @@ -28,7 +48,7 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
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.
Expand Down Expand Up @@ -84,7 +104,7 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
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 {
Expand Down
29 changes: 29 additions & 0 deletions crates/uv/tests/it/pip_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

0 comments on commit e98a6a1

Please sign in to comment.