use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
mod tar_format;
#[cfg(all(test, feature = "archive-auth-test-support"))]
mod zip_reader;
#[cfg(all(test, feature = "archive-auth-test-support"))]
pub(crate) mod authenticated_staging;
#[cfg(test)]
mod owned_source_tests {
use super::*;
#[test]
fn anonymous_seekable_source_extracts_large_entry_without_a_source_path() {
const LENGTH: u64 = 17 * 1024 * 1024;
let mut writer = zip::ZipWriter::new(tempfile::tempfile().unwrap());
writer.start_file("payload", zip::write::SimpleFileOptions::default()).unwrap();
let chunk = [0x5a; 64 * 1024];
for _ in 0..LENGTH / chunk.len() as u64 {
writer.write_all(&chunk).unwrap();
}
let source = writer.finish().unwrap();
let output = tempfile::tempdir().unwrap();
extract_file(source, output.path(), ArchiveFormat::Zip, ExtractionLimits {
max_entry_bytes: LENGTH,
max_output_bytes: LENGTH,
..ExtractionLimits::default()
}).unwrap();
let mut payload = File::open(output.path().join("payload")).unwrap();
let mut read = [0; 64 * 1024];
let mut total = 0;
loop {
let count = payload.read(&mut read).unwrap();
if count == 0 { break; }
assert!(read[..count].iter().all(|byte| *byte == 0x5a));
total += count as u64;
}
assert_eq!(total, LENGTH);
}
}
struct MetadataBudget {
file: File,
remaining: Arc<AtomicU64>,
}
impl Read for MetadataBudget {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
let remaining = self.remaining.load(Ordering::Relaxed);
let allowed = remaining.min(buffer.len() as u64) as usize;
if allowed == 0 {
return Err(invalid("ZIP metadata read budget exhausted"));
}
let count = self.file.read(&mut buffer[..allowed])?;
self.remaining
.store(remaining - count as u64, Ordering::Relaxed);
Ok(count)
}
}
impl Seek for MetadataBudget {
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
self.file.seek(position)
}
}
fn copy_bounded(
reader: &mut impl Read,
writer: &mut impl Write,
remaining: &mut u64,
) -> io::Result<()> {
let mut buffer = [0; 64 * 1024];
loop {
let capacity = remaining.saturating_add(1).min(buffer.len() as u64) as usize;
let count = match reader.read(&mut buffer[..capacity]) {
Ok(0) => return Ok(()),
Ok(count) => count,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
};
if count as u64 > *remaining {
return Err(invalid("archive output exceeds byte limit"));
}
writer.write_all(&buffer[..count])?;
*remaining -= count as u64;
}
}
fn copy_entry_bounded(
reader: &mut impl Read,
writer: &mut impl Write,
remaining: &mut u64,
max_entry_bytes: u64,
) -> io::Result<()> {
let before = (*remaining).min(max_entry_bytes);
let mut entry_remaining = before;
let result = copy_bounded(reader, writer, &mut entry_remaining);
*remaining -= before - entry_remaining;
result
}
#[cfg(test)]
mod entry_budget_tests {
use super::*;
#[test]
fn entry_copy_limits_actual_bytes_even_without_size_metadata() {
for (bytes, limit, succeeds) in [
(&b""[..], 0, true),
(&b"x"[..], 0, false),
(&b"1234"[..], 4, true),
(&b"12345"[..], 4, false),
] {
let mut output = Vec::new();
let mut total = 10;
let result = copy_entry_bounded(&mut &bytes[..], &mut output, &mut total, limit);
assert_eq!(result.is_ok(), succeeds);
assert!(output.len() as u64 <= limit);
assert_eq!(10 - total, output.len() as u64);
}
let mut total = 3;
let mut output = Vec::new();
assert!(copy_entry_bounded(&mut &b"1234"[..], &mut output, &mut total, 10).is_err());
assert!(output.len() <= 3);
}
}
#[derive(Clone, Copy, Debug)]
pub enum ArchiveFormat {
Zip,
TarGzip,
TarZstd,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DanglingLinks {
#[default]
Reject,
PreserveMissingLeaf,
}
#[derive(Clone, Copy, Debug)]
pub struct ExtractionLimits {
pub max_input_bytes: u64,
pub max_output_bytes: u64,
pub max_entry_bytes: u64,
pub max_entries: u64,
pub max_metadata_bytes: u64,
pub max_path_bytes: usize,
pub max_link_steps: u64,
pub dangling_links: DanglingLinks,
}
impl Default for ExtractionLimits {
fn default() -> Self {
Self {
max_input_bytes: 16 * 1024 * 1024 * 1024,
max_output_bytes: 64 * 1024 * 1024 * 1024,
max_entry_bytes: 64 * 1024 * 1024 * 1024,
max_entries: 1_000_000,
max_metadata_bytes: 64 * 1024 * 1024,
max_path_bytes: 4096,
max_link_steps: 1_000_000,
dangling_links: DanglingLinks::Reject,
}
}
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn relative_path(name: &str, limit: usize) -> io::Result<PathBuf> {
if name.len() > limit || name.contains(['\\', ':', '\0']) {
return Err(invalid("unsafe or oversized archive path"));
}
let path = Path::new(name);
if path
.components()
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(invalid("archive path escapes destination"));
}
let path: PathBuf = path
.components()
.filter(|c| matches!(c, Component::Normal(_)))
.collect();
if path.as_os_str().is_empty() {
return Err(invalid("empty archive path"));
}
Ok(path)
}
fn prepare_destination(dest: &Path) -> io::Result<PathBuf> {
match fs::symlink_metadata(dest) {
Ok(meta) if !meta.is_dir() || meta.file_type().is_symlink() => {
return Err(invalid("destination is not a real directory"))
}
Ok(_) if fs::read_dir(dest)?.next().is_some() => {
return Err(invalid("destination is not empty"))
}
Ok(_) => (),
Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir_all(dest)?,
Err(error) => return Err(error),
}
fs::canonicalize(dest)
}
struct ArchiveLink {
target: PathBuf,
hard: bool,
}
fn resolve_link_path(
root: &Path,
path: &Path,
links: &BTreeMap<PathBuf, ArchiveLink>,
depth: usize,
steps: &mut u64,
) -> io::Result<PathBuf> {
if depth > 64 {
return Err(invalid("archive link cycle or excessive depth"));
}
let mut resolved = PathBuf::new();
for component in path.components() {
*steps = steps
.checked_sub(1)
.ok_or_else(|| invalid("archive link work limit exceeded"))?;
if matches!(component, Component::Normal(_) | Component::ParentDir)
&& !fs::metadata(root.join(&resolved))?.is_dir()
{
return Err(invalid("archive link traverses a non-directory"));
}
match component {
Component::CurDir => (),
Component::ParentDir => {
if !resolved.pop() {
return Err(invalid("archive link escapes destination"));
}
}
Component::Normal(name) => {
resolved.push(name);
if let Some(link) = links.get(&resolved) {
let parent = if link.hard {
Path::new("")
} else {
resolved.parent().unwrap_or(Path::new(""))
};
resolved = resolve_link_path(
root,
&parent.join(&link.target),
links,
depth + 1,
steps,
)?;
}
}
_ => return Err(invalid("absolute archive link target")),
}
}
let raw = path
.to_str()
.ok_or_else(|| invalid("non-UTF-8 link path"))?;
let requires_directory = raw.ends_with('/')
|| raw.ends_with("/.")
|| (cfg!(windows) && (raw.ends_with('\\') || raw.ends_with("\\.")));
if requires_directory && !fs::metadata(root.join(&resolved))?.is_dir() {
return Err(invalid("archive link requires a directory target"));
}
Ok(resolved)
}
#[cfg(any(windows, test))]
fn windows_link_target(target: &Path) -> io::Result<PathBuf> {
Ok(PathBuf::from(
target
.to_str()
.ok_or_else(|| invalid("non-UTF-8 link target"))?
.replace('/', "\\"),
))
}
#[cfg(test)]
#[test]
fn windows_link_target_preserves_relative_components_and_trailing_separator() {
assert_eq!(
windows_link_target(Path::new("../alias/./tool/"))
.unwrap()
.as_os_str(),
std::ffi::OsStr::new("..\\alias\\.\\tool\\")
);
}
fn install_links(
root: &Path,
links: BTreeMap<PathBuf, ArchiveLink>,
mut steps: u64,
dangling_links: DanglingLinks,
) -> io::Result<()> {
for path in links.keys() {
if let Some(parent) = root.join(path).parent() {
fs::create_dir_all(parent)?;
}
}
let mut validated = Vec::with_capacity(links.len());
for (path, link) in &links {
let output = root.join(path);
match fs::symlink_metadata(&output) {
Err(error) if error.kind() == io::ErrorKind::NotFound => (),
Err(error) => return Err(error),
Ok(_) => return Err(invalid("archive link collides with another entry")),
}
let resolved = resolve_link_path(root, path, &links, 0, &mut steps)?;
let canonical = match fs::canonicalize(root.join(&resolved)) {
Ok(path) => path,
Err(error)
if error.kind() == io::ErrorKind::NotFound
&& !link.hard
&& dangling_links == DanglingLinks::PreserveMissingLeaf =>
{
root.join(resolved)
}
Err(error) => return Err(error),
};
if !canonical.starts_with(root) {
return Err(invalid("archive link escapes destination"));
}
if link.hard && !canonical.is_file() {
return Err(invalid("hardlink target is not a regular file"));
}
let is_dir = canonical.is_dir();
validated.push((output, link, canonical, is_dir));
}
for (output, link, canonical, is_dir) in validated {
if link.hard {
fs::hard_link(canonical, output)?;
continue;
}
#[cfg(unix)]
{
let _ = is_dir;
std::os::unix::fs::symlink(&link.target, output)?;
}
#[cfg(windows)]
{
let target = windows_link_target(&link.target)?;
if is_dir {
std::os::windows::fs::symlink_dir(target, output)?;
} else {
std::os::windows::fs::symlink_file(target, output)?;
}
}
}
Ok(())
}
pub fn extract(
archive: &Path,
dest: &Path,
format: ArchiveFormat,
limits: ExtractionLimits,
) -> io::Result<()> {
let file = File::open(archive)?;
extract_file(file, dest, format, limits)
}
pub(crate) fn extract_file(
mut file: File,
dest: &Path,
format: ArchiveFormat,
limits: ExtractionLimits,
) -> io::Result<()> {
if file.metadata()?.len() > limits.max_input_bytes {
return Err(invalid("archive input exceeds byte limit"));
}
file.rewind()?;
match format {
ArchiveFormat::Zip => extract_zip(file, dest, limits),
ArchiveFormat::TarGzip | ArchiveFormat::TarZstd => {
tar_format::extract_tar(file, dest, format, limits, None)
}
}
}
pub fn extract_member(
archive: &Path,
member: &str,
dest: &Path,
format: ArchiveFormat,
limits: ExtractionLimits,
) -> io::Result<()> {
let member = relative_path(member, limits.max_path_bytes)?;
let file = File::open(archive)?;
if file.metadata()?.len() > limits.max_input_bytes {
return Err(invalid("archive input exceeds byte limit"));
}
if matches!(format, ArchiveFormat::Zip) {
return Err(invalid("selected ZIP member is unsupported"));
}
tar_format::extract_tar(file, dest, format, limits, Some(member))
}
fn preflight_zip(file: &mut File, limits: ExtractionLimits) -> io::Result<()> {
let length = file.metadata()?.len();
let tail_length = length.min(65557) as usize;
file.seek(SeekFrom::End(-(tail_length as i64)))?;
let mut tail = vec![0; tail_length];
file.read_exact(&mut tail)?;
let offset = (0..tail.len().saturating_sub(21))
.rev()
.find(|&i| {
tail[i..].starts_with(b"PK\x05\x06")
&& i + 22 + u16::from_le_bytes([tail[i + 20], tail[i + 21]]) as usize == tail.len()
})
.ok_or_else(|| invalid("missing ZIP end directory"))?;
let end = &tail[offset..];
if end[4..8] != [0; 4] {
return Err(invalid("multi-disk ZIP is unsupported"));
}
let mut count = u16::from_le_bytes([end[10], end[11]]) as u64;
let disk_count = u16::from_le_bytes([end[8], end[9]]) as u64;
if disk_count != count {
return Err(invalid("inconsistent ZIP directory counts"));
}
let mut metadata_size = u32::from_le_bytes(end[12..16].try_into().unwrap()) as u64;
let directory_offset = u32::from_le_bytes(end[16..20].try_into().unwrap());
let position = length - tail_length as u64 + offset as u64;
let mut locator = [0; 20];
if position >= 20 {
file.seek(SeekFrom::Start(position - 20))?;
file.read_exact(&mut locator)?;
}
let has_zip64 = locator.starts_with(b"PK\x06\x07");
if !has_zip64
&& (count == u16::MAX as u64
|| metadata_size == u32::MAX as u64
|| directory_offset == u32::MAX)
{
return Err(invalid("missing ZIP64 locator"));
}
if has_zip64 {
if locator[4..8] != [0; 4] || locator[16..20] != 1_u32.to_le_bytes() {
return Err(invalid("multi-disk ZIP64 is unsupported"));
}
let record = u64::from_le_bytes(locator[8..16].try_into().unwrap());
if record.checked_add(56).is_none_or(|end| end > position - 20) {
return Err(invalid("ZIP64 record outside archive"));
}
file.seek(SeekFrom::Start(record))?;
let mut header = [0; 56];
file.read_exact(&mut header)?;
if !header.starts_with(b"PK\x06\x06") {
return Err(invalid("bad ZIP64 directory"));
}
let record_size = u64::from_le_bytes(header[4..12].try_into().unwrap());
if record_size < 44
|| record_size > limits.max_metadata_bytes
|| record
.checked_add(record_size)
.and_then(|end| end.checked_add(12))
!= Some(position - 20)
{
return Err(invalid("invalid or oversized ZIP64 metadata record"));
}
if header[16..24] != [0; 8] || header[24..32] != header[32..40] {
return Err(invalid("inconsistent ZIP64 disks or counts"));
}
count = u64::from_le_bytes(header[32..40].try_into().unwrap());
metadata_size = u64::from_le_bytes(header[40..48].try_into().unwrap());
}
if count > limits.max_entries || metadata_size > limits.max_metadata_bytes {
return Err(invalid("ZIP metadata exceeds resource limits"));
}
file.rewind()
}
fn open_zip(mut file: File, limits: ExtractionLimits) -> io::Result<zip::ZipArchive<MetadataBudget>> {
if file.metadata()?.len() > limits.max_input_bytes {
return Err(invalid("archive input exceeds byte limit"));
}
preflight_zip(&mut file, limits)?;
let budget = Arc::new(AtomicU64::new(limits.max_metadata_bytes.saturating_add(65557)));
let reader = MetadataBudget {
file,
remaining: Arc::clone(&budget),
};
let config = zip::read::Config {
archive_offset: zip::read::ArchiveOffset::Known(0),
};
let archive = zip::ZipArchive::with_config(config, reader).map_err(io::Error::other)?;
budget.store(u64::MAX, Ordering::Relaxed);
if archive.len() as u64 > limits.max_entries {
return Err(invalid("too many archive entries"));
}
Ok(archive)
}
fn extract_zip(file: File, dest: &Path, limits: ExtractionLimits) -> io::Result<()> {
let mut archive = open_zip(file, limits)?;
let root = prepare_destination(dest)?;
let mut remaining = limits.max_output_bytes;
let mut link_bytes = limits.max_metadata_bytes;
let mut links = BTreeMap::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(io::Error::other)?;
if entry.size() > limits.max_entry_bytes {
return Err(invalid("ZIP entry exceeds byte limit"));
}
let relative = relative_path(entry.name(), limits.max_path_bytes)?;
let output = root.join(&relative);
if entry.is_symlink() {
let mut bytes = Vec::new();
let mut allowed = (limits.max_path_bytes as u64)
.min(link_bytes)
.min(remaining)
.min(limits.max_entry_bytes);
let before = allowed;
copy_bounded(&mut entry, &mut bytes, &mut allowed)?;
link_bytes -= before - allowed;
remaining -= before - allowed;
let target =
String::from_utf8(bytes).map_err(|_| invalid("non-UTF-8 archive link target"))?;
if target.is_empty() || target.contains([':', '\0']) {
return Err(invalid("unsafe archive link target"));
}
if links
.insert(
relative,
ArchiveLink {
target: PathBuf::from(target),
hard: false,
},
)
.is_some()
{
return Err(invalid("duplicate archive link"));
}
continue;
}
if entry.is_dir() {
fs::create_dir_all(output)?;
continue;
}
if entry.size() > remaining {
return Err(invalid("archive output exceeds byte limit"));
}
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut target = OpenOptions::new()
.write(true)
.create_new(true)
.open(&output)?;
copy_entry_bounded(&mut entry, &mut target, &mut remaining, limits.max_entry_bytes)?;
target.flush()?;
#[cfg(unix)]
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&output, fs::Permissions::from_mode(mode & 0o777))?;
}
}
install_links(&root, links, limits.max_link_steps, limits.dangling_links)
}