use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime};
use tar::Archive;
use tracing::{debug, error, info};
pub fn extract<R: Read>(reader: R, dest_dir: &Path) -> Result<()> {
let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;
let decoder = GzDecoder::new(reader);
let mut archive = Archive::new(decoder);
let mut entry_count = 0u64;
for entry_result in archive.entries().context("reading tar entries")? {
let mut entry = entry_result.context("reading tar entry")?;
entry_count = entry_count.saturating_add(1);
let path = entry.path().context("reading entry path")?.into_owned();
if entry_count <= 10 {
info!(
name = %path.display(),
kind = entry.header().entry_type().as_byte(),
size = entry.header().size().unwrap_or(0),
"tar entry"
);
}
match entry_action(&path, &entry, &abs_dest) {
EntryAction::Skip => {
debug!(name = %path.display(), "skipping tar entry");
continue;
}
EntryAction::Process => {}
}
let target = abs_dest.join(clean_path(&path));
write_entry(&mut entry, &target)?;
}
info!(total_entries = entry_count, "tar extraction complete");
Ok(())
}
fn write_entry(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
match entry.header().entry_type() {
tar::EntryType::Directory => {
fs::create_dir_all(target)
.with_context(|| format!("creating directory {}", target.display()))?;
}
tar::EntryType::Regular | tar::EntryType::Continuous => write_file(entry, target)?,
_ => {}
}
Ok(())
}
fn write_file(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating parent dir for {}", target.display()))?;
}
let mode = entry.header().mode().unwrap_or(0o644);
let mtime = entry.header().mtime().ok();
let mut file =
fs::File::create(target).with_context(|| format!("creating file {}", target.display()))?;
std::io::copy(entry, &mut file)
.with_context(|| format!("writing file {}", target.display()))?;
if let Some(modified) =
mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
{
file.set_modified(modified).ok();
}
drop(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
}
Ok(())
}
fn clean_path(path: &Path) -> PathBuf {
let mut clean = PathBuf::new();
for component in path.components() {
if let Component::Normal(c) = component {
clean.push(c);
}
}
clean
}
enum EntryAction {
Process,
Skip,
}
fn entry_action(path: &Path, entry: &tar::Entry<impl Read>, dest_dir: &Path) -> EntryAction {
let entry_type = entry.header().entry_type();
if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
return EntryAction::Skip;
}
let target = dest_dir.join(clean_path(path));
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
|| !target.starts_with(dest_dir)
{
error!(name = %path.display(), "unsafe path detected");
return EntryAction::Skip;
}
EntryAction::Process
}
#[cfg(test)]
mod tests {
use super::extract;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::BufReader;
use std::time::{Duration, SystemTime};
const MTIME: u64 = 1_700_000_000;
fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
builder.preserve_absolute(true);
for (name, contents) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(u64::try_from(contents.len()).unwrap());
header.set_mode(0o644);
header.set_mtime(MTIME);
builder.append_data(&mut header, name, *contents).unwrap();
}
builder.into_inner().unwrap().finish().unwrap()
}
fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("repo.tar.gz");
std::fs::write(&path, archive).unwrap();
extract(
BufReader::new(std::fs::File::open(&path).unwrap()),
dir.path(),
)
.unwrap();
dir
}
#[test]
fn an_archive_is_extracted_from_a_reader_over_a_file() {
let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
assert_eq!(
std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
"ref: refs/heads/trunk\n"
);
}
#[test]
fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
let dir = extract_to_temp(&tar_gz(&[
("/etc/passwd", b"root::0:0:::\n"),
("repo/HEAD", b"ref: refs/heads/trunk\n"),
]));
assert!(!dir.path().join("etc/passwd").exists());
assert!(dir.path().join("repo/HEAD").exists());
}
#[test]
fn a_name_holding_two_dots_is_not_a_traversal() {
let dir = extract_to_temp(&tar_gz(&[("repo/a..b", b"kept\n")]));
assert_eq!(
std::fs::read_to_string(dir.path().join("repo/a..b")).unwrap(),
"kept\n"
);
}
#[test]
fn the_entry_mtime_is_preserved() {
let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
.unwrap()
.modified()
.unwrap();
assert_eq!(
modified,
SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(MTIME))
.unwrap()
);
}
}