From 782e38a8a348c08294c8e31f78d343f28a662e67 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 14 Jul 2026 15:24:14 -0500 Subject: [PATCH] Fix tar wheel RECORD traversal healing --- Cargo.lock | 1 + crates/uv-extract/src/stream.rs | 13 +-- crates/uv/Cargo.toml | 1 + crates/uv/tests/pip_install/pip_install.rs | 100 +++++++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dbef4db81c..fc082518e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5789,6 +5789,7 @@ dependencies = [ "astral-tokio-tar", "astral-version-ranges", "astral_async_zip", + "async-compression", "axoupdater", "backon", "byteorder", diff --git a/crates/uv-extract/src/stream.rs b/crates/uv-extract/src/stream.rs index b38339493f5..368ae07c8b3 100644 --- a/crates/uv-extract/src/stream.rs +++ b/crates/uv-extract/src/stream.rs @@ -619,18 +619,19 @@ async fn untar_in( continue; } - // Collect file paths (excluding directories). let entry_type = file.header().entry_type(); - if entry_type.is_file() || entry_type.is_hard_link() { - let relpath = file.path()?.into_owned(); - let size = file.header().size()?; - files.push((relpath, size)); - } // Unpack the file into the destination directory. #[cfg_attr(not(unix), allow(unused_variables))] let unpacked_at = file.unpack_in_raw(&dst, &mut memo).await?; + // Collect file paths (excluding directories) that were unpacked successfully. + if unpacked_at.is_some() && (entry_type.is_file() || entry_type.is_hard_link()) { + let relpath = file.path()?.into_owned(); + let size = file.header().size()?; + files.push((relpath, size)); + } + // Preserve the executable bit. #[cfg(unix)] { diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 0a8ed03cc29..e706f54b8be 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -133,6 +133,7 @@ uv-test = { workspace = true } assert_cmd = { workspace = true } assert_fs = { workspace = true } +async-compression = { workspace = true, features = ["zstd"] } backon = { workspace = true } byteorder = { workspace = true } bytes = { workspace = true } diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index da4a671c577..67885c0ee2e 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -6,6 +6,8 @@ use std::process::Command; use anyhow::{Context, Result, anyhow}; use assert_cmd::prelude::*; use assert_fs::prelude::*; +#[cfg(unix)] +use async_compression::tokio::write::ZstdEncoder; use async_zip::base::write::ZipFileWriter; use async_zip::{Compression, ZipEntryBuilder}; use flate2::write::GzEncoder; @@ -16,6 +18,8 @@ use futures::io::AllowStdIo; use indoc::{formatdoc, indoc}; use insta::assert_snapshot; use predicates::prelude::predicate; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; use tokio_util::compat::{FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt}; use url::Url; use walkdir::WalkDir; @@ -16953,6 +16957,102 @@ fn handle_record_mismatches() -> Result<()> { Ok(()) } +/// A skipped traversal entry in a zstd-compressed tar wheel must not survive RECORD healing and +/// remove an unrelated environment executable during uninstall. +#[cfg(unix)] +#[tokio::test] +async fn tar_wheel_traversal_is_not_recorded() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context.venv().assert().success(); + + let victim = venv_bin_path(&context.venv).join("victim"); + fs_err::write(&victim, "I should not be deleted")?; + + let record = indoc! {" + tar_wheel/__init__.py,, + tar_wheel-1.0.0.dist-info/METADATA,, + tar_wheel-1.0.0.dist-info/WHEEL,, + tar_wheel-1.0.0.dist-info/RECORD,, + ../../../bin/victim,, + "}; + let entries = [ + ("tar_wheel/__init__.py", ""), + ( + "tar_wheel-1.0.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: tar-wheel\nVersion: 1.0.0\n", + ), + ( + "tar_wheel-1.0.0.dist-info/WHEEL", + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ), + ("tar_wheel-1.0.0.dist-info/RECORD", record), + ("../../../bin/victim", "malicious replacement"), + ]; + + let mut tar = tokio_tar::Builder::new_non_terminated(ZstdEncoder::new(Vec::new())); + for (path, contents) in entries { + let mut header = tokio_tar::Header::new_gnu(); + header.as_mut_bytes()[..path.len()].copy_from_slice(path.as_bytes()); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append(&header, contents.as_bytes()).await?; + } + let mut encoder = tar.into_inner().await?; + encoder.shutdown().await?; + let tar_wheel = encoder.into_inner(); + + let server = MockServer::start().await; + let wheel_url = format!("{}/files/tar_wheel-1.0.0-py3-none-any.whl", server.uri()); + Mock::given(method("GET")) + .and(path("/tar-wheel/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + formatdoc! {r#" + {{ + "name": "tar-wheel", + "files": [{{ + "filename": "tar_wheel-1.0.0-py3-none-any.whl", + "url": "{wheel_url}", + "hashes": {{}}, + "zstd": {{ + "hashes": {{}}, + "size": {} + }} + }}] + }} + "#, tar_wheel.len()}, + "application/vnd.pyx.simple.v1+json", + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/files/tar_wheel-1.0.0-py3-none-any.whl.tar.zst")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tar_wheel)) + .expect(1) + .mount(&server) + .await; + + context + .pip_install() + .arg("tar-wheel==1.0.0") + .arg("--default-index") + .arg(server.uri()) + .assert() + .success(); + + let installed_record = fs_err::read_to_string( + context + .site_packages() + .join("tar_wheel-1.0.0.dist-info/RECORD"), + )?; + assert!(!installed_record.contains("../../../bin/victim")); + + context.pip_uninstall().arg("tar-wheel").assert().success(); + assert_eq!(fs_err::read_to_string(&victim)?, "I should not be deleted"); + + Ok(()) +} + /// Compile installed packages without compiling the Python standard library. #[test] fn compile_bytecode_excludes_stdlib() -> Result<()> {