Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preserve executable bit when untarring archives #1790

Merged
merged 2 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/uv-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub use sync::*;
mod error;
pub mod stream;
mod sync;
mod tar;
mod vendor;
42 changes: 41 additions & 1 deletion crates/uv-extract/src/stream.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::path::Path;
use std::pin::Pin;

use futures::StreamExt;
use rustc_hash::FxHashSet;
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};

Expand Down Expand Up @@ -97,6 +99,44 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
Ok(())
}

/// Unpack the given tar archive into the destination directory.
///
/// This is equivalent to `archive.unpack_in(dst)`, but it also preserves the executable bit.
async fn untar_in<R: tokio::io::AsyncRead + Unpin, P: AsRef<Path>>(
archive: &mut tokio_tar::Archive<R>,
dst: P,
) -> std::io::Result<()> {
let mut entries = archive.entries()?;
let mut pinned = Pin::new(&mut entries);
while let Some(entry) = pinned.next().await {
// Unpack the file into the destination directory.
let mut file = entry?;
file.unpack_in(dst.as_ref()).await?;

// Preserve the executable bit.
#[cfg(unix)]
{
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?;
}
}
}
}
Ok(())
}

/// Unzip a `.tar.gz` archive into the target directory, without requiring `Seek`.
///
/// This is useful for unpacking files as they're being downloaded.
Expand All @@ -108,7 +148,7 @@ pub async fn untar<R: tokio::io::AsyncBufRead + Unpin>(
let mut archive = tokio_tar::ArchiveBuilder::new(decompressed_bytes)
.set_preserve_mtime(false)
.build();
Ok(archive.unpack(target.as_ref()).await?)
Ok(untar_in(&mut archive, target.as_ref()).await?)
}

/// Unzip a `.zip` or `.tar.gz` archive into the target directory, without requiring `Seek`.
Expand Down
40 changes: 40 additions & 0 deletions crates/uv-extract/src/tar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use std::path::{Component, Path, PathBuf};

/// Determine the path at which the given tar entry will be unpacked, when unpacking into `dst`.
///
/// See: <https://github.com/vorot93/tokio-tar/blob/87338a76092330bc6fe60de95d83eae5597332e1/src/entry.rs#L418>
#[cfg_attr(not(target_os = "unix"), allow(dead_code))]
pub(crate) fn unpacked_at(dst: &Path, entry: &Path) -> Option<PathBuf> {
let mut file_dst = dst.to_path_buf();
{
for part in entry.components() {
match part {
// Leading '/' characters, root paths, and '.'
// components are just ignored and treated as "empty
// components"
Component::Prefix(..) | Component::RootDir | Component::CurDir => {
continue;
}

// If any part of the filename is '..', then skip over
// unpacking the file to prevent directory traversal
// security issues. See, e.g.: CVE-2001-1267,
// CVE-2002-0399, CVE-2005-1918, CVE-2007-4131
Component::ParentDir => return None,

Component::Normal(part) => file_dst.push(part),
}
}
}

// Skip cases where only slashes or '.' parts were seen, because
// this is effectively an empty filename.
if *dst == *file_dst {
return None;
}

// Skip entries without a parent (i.e. outside of FS root)
file_dst.parent()?;

Some(file_dst)
}