Skip to content

Commit e98a6a1

Browse files
committed
Sanitize filenames during zip extraction
1 parent 8d3408f commit e98a6a1

File tree

5 files changed

+66
-4
lines changed

5 files changed

+66
-4
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ rust-netrc = { version = "0.1.2" }
142142
rustc-hash = { version = "2.0.0" }
143143
rustix = { version = "0.38.37", default-features = false, features = ["fs", "std"] }
144144
same-file = { version = "1.0.6" }
145+
sanitize-filename = { version = "0.5.0" }
145146
schemars = { version = "0.8.21", features = ["url"] }
146147
seahash = { version = "4.1.0" }
147148
serde = { version = "1.0.210", features = ["derive"] }

crates/uv-extract/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ md-5 = { workspace = true }
2828
rayon = { workspace = true }
2929
reqwest = { workspace = true }
3030
rustc-hash = { workspace = true }
31+
sanitize-filename = { workspace = true }
3132
sha2 = { workspace = true }
3233
thiserror = { workspace = true }
3334
tokio = { workspace = true }

crates/uv-extract/src/stream.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
use std::path::Path;
1+
use std::path::{Path, PathBuf};
22
use std::pin::Pin;
33

4-
use crate::Error;
54
use futures::StreamExt;
65
use rustc_hash::FxHashSet;
76
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
87
use tracing::warn;
8+
99
use uv_distribution_filename::SourceDistExtension;
1010

11+
use crate::Error;
12+
1113
const DEFAULT_BUF_SIZE: usize = 128 * 1024;
1214

1315
/// Unpack a `.zip` archive into the target directory, without requiring `Seek`.
@@ -19,6 +21,24 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
1921
reader: R,
2022
target: impl AsRef<Path>,
2123
) -> Result<(), Error> {
24+
/// Sanitize a filename for use on Windows.
25+
fn sanitize(filename: &str) -> PathBuf {
26+
filename
27+
.replace('\\', "/")
28+
.split('/')
29+
.map(|segment| {
30+
sanitize_filename::sanitize_with_options(
31+
segment,
32+
sanitize_filename::Options {
33+
windows: cfg!(windows),
34+
truncate: false,
35+
replacement: "",
36+
},
37+
)
38+
})
39+
.collect()
40+
}
41+
2242
let target = target.as_ref();
2343
let mut reader = futures::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader.compat());
2444
let mut zip = async_zip::base::read::stream::ZipFileReader::new(&mut reader);
@@ -28,7 +48,7 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
2848
while let Some(mut entry) = zip.next_with_entry().await? {
2949
// Construct the (expected) path to the file on-disk.
3050
let path = entry.reader().entry().filename().as_str()?;
31-
let path = target.join(path);
51+
let path = target.join(sanitize(path));
3252
let is_dir = entry.reader().entry().dir()?;
3353

3454
// Either create the directory or write the file to disk.
@@ -84,7 +104,7 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
84104
if has_any_executable_bit != 0 {
85105
// Construct the (expected) path to the file on-disk.
86106
let path = entry.filename().as_str()?;
87-
let path = target.join(path);
107+
let path = target.join(sanitize(path));
88108

89109
let permissions = fs_err::tokio::metadata(&path).await?.permissions();
90110
if permissions.mode() & 0o111 != 0o111 {

crates/uv/tests/it/pip_sync.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5607,3 +5607,32 @@ fn sync_seed() -> Result<()> {
56075607

56085608
Ok(())
56095609
}
5610+
5611+
/// Sanitize zip files during extraction.
5612+
#[test]
5613+
fn sanitize() -> Result<()> {
5614+
let context = TestContext::new("3.12");
5615+
5616+
// Install a zip file that includes a path that extends outside the parent.
5617+
let requirements_txt = context.temp_dir.child("requirements.txt");
5618+
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")?;
5619+
5620+
uv_snapshot!(context.pip_sync()
5621+
.arg("requirements.txt"), @r###"
5622+
success: true
5623+
exit_code: 0
5624+
----- stdout -----
5625+
5626+
----- stderr -----
5627+
Resolved 1 package in [TIME]
5628+
Prepared 1 package in [TIME]
5629+
Installed 1 package in [TIME]
5630+
+ 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)
5631+
"###
5632+
);
5633+
5634+
// There should be a `payload` file in `site-packages` (but _not_ outside of it).
5635+
assert!(context.site_packages().join("payload").exists());
5636+
5637+
Ok(())
5638+
}

0 commit comments

Comments
 (0)