Skip to content

Commit

Permalink
Preserve executable bit when untarring archives
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Feb 21, 2024
1 parent 2e60c1d commit 2c760aa
Showing 1 changed file with 96 additions and 2 deletions.
98 changes: 96 additions & 2 deletions crates/uv-extract/src/stream.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::path::Path;
use async_compression::tokio::write::GzipDecoder;
use futures::StreamExt;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;

use rustc_hash::FxHashSet;
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
Expand Down Expand Up @@ -104,11 +108,101 @@ pub async fn untar<R: tokio::io::AsyncBufRead + Unpin>(
reader: R,
target: impl AsRef<Path>,
) -> Result<(), Error> {
/// Determine the path at which the given tar entry will be unpacked, when unpacking into `dst`.
///
/// This is effectively a vendored version of some logic in `tokio_tar`, within `unpack_in`.
fn unpacked_at(dst: &Path, entry: &Path) -> Option<PathBuf> {
// Notes regarding bsdtar 2.8.3 / libarchive 2.8.3:
// * Leading '/'s are trimmed. For example, `///test` is treated as
// `test`.
// * If the filename contains '..', then the file is skipped when
// extracting the tarball.
// * '//' within a filename is effectively skipped. An error is
// logged, but otherwise the effect is as if any two or more
// adjacent '/'s within the filename were consolidated into one
// '/'.
//
// Most of this is handled by the `path` module of the standard
// library, but we specially handle a few cases here as well.

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)
if file_dst.parent().is_none() {
return None;
};

Some(file_dst)
}

/// 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 unpack<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 {
let path = file.path()?;
if let Some(path) = unpacked_at(dst.as_ref(), &path) {
let permissions = fs_err::tokio::metadata(&path).await?.permissions();
fs_err::tokio::set_permissions(
&path,
Permissions::from_mode(permissions.mode() | 0o111),
)
.await?;
}
}
}
}
Ok(())
}

let decompressed_bytes = async_compression::tokio::bufread::GzipDecoder::new(reader);
let mut archive = tokio_tar::ArchiveBuilder::new(decompressed_bytes)
.set_preserve_mtime(false)
.build();
Ok(archive.unpack(target.as_ref()).await?)
Ok(unpack(&mut archive, target.as_ref()).await?)
}

/// Unzip a `.zip` or `.tar.gz` archive into the target directory, without requiring `Seek`.
Expand Down

0 comments on commit 2c760aa

Please sign in to comment.