use std::cell::Cell;
use std::fs;
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
use flate2::read::GzDecoder;
pub const MAX_FILES: usize = 10_000;
pub const MAX_UNCOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
const STREAM_SLACK_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum ExtractError {
#[error("archive read/write failed: {0}")]
Io(#[from] io::Error),
#[error("archive entry {path:?} has forbidden type {entry_type} (only plain files and directories are allowed)")]
ForbiddenEntryType {
path: String,
entry_type: &'static str,
},
#[error("archive entry path {path:?} is unsafe (absolute, traversing, or non-normal)")]
UnsafePath { path: String },
#[error("archive exceeds the {limit}-entry cap")]
TooManyEntries { limit: usize },
#[error("archive exceeds the {limit}-byte uncompressed cap")]
TooLarge { limit: u64 },
}
pub(crate) fn extract_tar_gz(archive: &Path, target: &Path) -> Result<(), ExtractError> {
let limits = Limits {
max_entries: MAX_FILES,
max_total_bytes: MAX_UNCOMPRESSED_BYTES,
};
extract_with_limits(archive, target, limits)
}
#[derive(Debug, Clone, Copy)]
struct Limits {
max_entries: usize,
max_total_bytes: u64,
}
fn extract_with_limits(archive: &Path, target: &Path, limits: Limits) -> Result<(), ExtractError> {
let stream_cap = limits.max_total_bytes.saturating_add(STREAM_SLACK_BYTES);
let exceeded = Rc::new(Cell::new(false));
let reader = CappedReader {
inner: GzDecoder::new(fs::File::open(archive)?),
remaining: stream_cap,
exceeded: Rc::clone(&exceeded),
};
let mut tar = tar::Archive::new(reader);
let mut entry_count: usize = 0;
let mut total_bytes: u64 = 0;
let mut entries = tar.entries()?;
loop {
let entry = match entries.next() {
Some(Ok(entry)) => entry,
Some(Err(e)) => return Err(map_stream_error(e, &exceeded, limits)),
None => break,
};
entry_count += 1;
if entry_count > limits.max_entries {
return Err(ExtractError::TooManyEntries {
limit: limits.max_entries,
});
}
let raw_path = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let entry_type = entry.header().entry_type();
let kind = match entry_type {
tar::EntryType::Regular => EntryKind::File,
tar::EntryType::Directory => EntryKind::Dir,
other => {
return Err(ExtractError::ForbiddenEntryType {
path: raw_path,
entry_type: describe_entry_type(other),
})
}
};
let dest = safe_join(target, &raw_path)?;
match kind {
EntryKind::Dir => fs::create_dir_all(&dest)?,
EntryKind::File => {
total_bytes = total_bytes.saturating_add(entry.header().size()?);
if total_bytes > limits.max_total_bytes {
return Err(ExtractError::TooLarge {
limit: limits.max_total_bytes,
});
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut entry = entry;
let mut file = fs::File::create(&dest)?;
match io::copy(&mut entry, &mut file) {
Ok(_) => {}
Err(e) => return Err(map_stream_error(e, &exceeded, limits)),
}
file.sync_all()?;
}
}
}
Ok(())
}
enum EntryKind {
File,
Dir,
}
fn map_stream_error(e: io::Error, exceeded: &Cell<bool>, limits: Limits) -> ExtractError {
if exceeded.get() {
ExtractError::TooLarge {
limit: limits.max_total_bytes,
}
} else {
ExtractError::Io(e)
}
}
fn describe_entry_type(t: tar::EntryType) -> &'static str {
use tar::EntryType::*;
match t {
Symlink => "symlink",
Link => "hardlink",
Char => "char device",
Block => "block device",
Fifo => "fifo",
Continuous => "contiguous file",
GNUSparse => "sparse file",
XGlobalHeader => "pax global header",
XHeader => "pax header",
_ => "unsupported",
}
}
fn safe_join(target: &Path, raw: &str) -> Result<PathBuf, ExtractError> {
let unsafe_path = || ExtractError::UnsafePath { path: raw.into() };
if raw.is_empty() || raw.contains('\\') || raw.contains(':') {
return Err(unsafe_path());
}
let mut out = target.to_path_buf();
let mut pushed = false;
for component in Path::new(raw).components() {
match component {
Component::Normal(part) => {
out.push(part);
pushed = true;
}
_ => return Err(unsafe_path()),
}
}
if !pushed {
return Err(unsafe_path());
}
Ok(out)
}
struct CappedReader<R: Read> {
inner: R,
remaining: u64,
exceeded: Rc<Cell<bool>>,
}
impl<R: Read> Read for CappedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.remaining == 0 {
self.exceeded.set(true);
return Err(io::Error::other("decompressed stream exceeds the size cap"));
}
let allowed = usize::try_from(self.remaining.min(buf.len() as u64)).unwrap_or(buf.len());
let n = self.inner.read(&mut buf[..allowed])?;
self.remaining -= n as u64;
Ok(n)
}
}
#[cfg(test)]
mod tests;