use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::{OsStr, OsString};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
pub const ARCHIVE_ENTRY_LIMIT: usize = 250_000;
pub const ARCHIVE_BYTES_LIMIT: u64 = 8 * 1024 * 1024 * 1024;
pub const ARCHIVE_PATH_BYTES_LIMIT: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArchiveFormat {
Tar,
TarGz,
Zip,
}
impl ArchiveFormat {
pub fn label(self) -> &'static str {
match self {
Self::Tar => "tar",
Self::TarGz => "tar.gz",
Self::Zip => "zip",
}
}
}
pub fn detect_format(path: &Path) -> Option<ArchiveFormat> {
let name = path.file_name()?.to_str()?.to_lowercase();
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
Some(ArchiveFormat::TarGz)
} else if name.ends_with(".tar") {
Some(ArchiveFormat::Tar)
} else if name.ends_with(".zip") {
Some(ArchiveFormat::Zip)
} else {
None
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ExtractSummary {
pub entries: usize,
pub files: usize,
pub dirs: usize,
pub links: usize,
pub bytes: u64,
}
pub fn extract(
archive: &Path,
target_dir: &Path,
on_entry: impl FnMut(&Path, u64),
) -> Result<ExtractSummary, String> {
validate_empty_target(target_dir)?;
match detect_format(archive) {
Some(format @ (ArchiveFormat::Tar | ArchiveFormat::TarGz)) => {
extract_tar(archive, target_dir, format, on_entry)
}
Some(ArchiveFormat::Zip) => extract_zip(archive, target_dir, on_entry),
None => Err(format!("Unsupported archive type: {}", archive.display())),
}
}
fn extract_tar(
archive: &Path,
target_dir: &Path,
format: ArchiveFormat,
mut on_entry: impl FnMut(&Path, u64),
) -> Result<ExtractSummary, String> {
let mut file = std::fs::File::open(archive)
.map_err(|error| format!("Cannot open archive {}: {error}", archive.display()))?;
let preflight = {
let reader = tar_reader(&mut file, format);
preflight_tar(archive, reader)?
};
file.seek(SeekFrom::Start(0))
.map_err(|error| format!("Cannot rewind archive {}: {error}", archive.display()))?;
let reader = tar_reader(&mut file, format);
let mut tar = tar::Archive::new(reader);
tar.set_preserve_permissions(false);
let entries = tar
.entries()
.map_err(|error| format!("Cannot read archive {}: {error}", archive.display()))?;
for entry in entries {
let mut entry = entry
.map_err(|error| format!("Cannot read entry in {}: {error}", archive.display()))?;
let size = entry
.header()
.size()
.map_err(|error| format!("Cannot read entry size: {error}"))?;
let member = entry
.path()
.map_err(|error| format!("Cannot read member path: {error}"))?
.to_path_buf();
let kind = classify(&entry)?;
let Some(normalized) = normalize_member_path(&member, kind)? else {
continue;
};
validate_link_target(&entry, &normalized)?;
on_entry(&normalized, size);
let unpacked = entry
.unpack_in(target_dir)
.map_err(|error| format!("Cannot extract {}: {error}", normalized.display()))?;
if !unpacked {
return Err(format!(
"Archive member {} escapes the target folder",
normalized.display()
));
}
}
Ok(preflight.summary)
}
fn tar_reader(file: &mut std::fs::File, format: ArchiveFormat) -> Box<dyn Read + '_> {
match format {
ArchiveFormat::TarGz => Box::new(GzDecoder::new(file)),
_ => Box::new(file),
}
}
fn preflight_tar(archive: &Path, reader: Box<dyn Read + '_>) -> Result<ArchivePreflight, String> {
let mut tar = tar::Archive::new(reader);
let mut preflight = ArchivePreflight::default();
let entries = tar
.entries()
.map_err(|error| format!("Cannot read archive {}: {error}", archive.display()))?;
for entry in entries {
let entry = entry
.map_err(|error| format!("Cannot read entry in {}: {error}", archive.display()))?;
let size = entry
.header()
.size()
.map_err(|error| format!("Cannot read entry size: {error}"))?;
let member = entry
.path()
.map_err(|error| format!("Cannot read member path: {error}"))?
.to_path_buf();
let kind = classify(&entry)?;
if let Some(normalized) = normalize_member_path(&member, kind)? {
validate_link_target(&entry, &normalized)?;
preflight.record(normalized, kind, size)?;
}
}
Ok(preflight)
}
fn extract_zip(
archive: &Path,
target_dir: &Path,
mut on_entry: impl FnMut(&Path, u64),
) -> Result<ExtractSummary, String> {
let file = std::fs::File::open(archive)
.map_err(|error| format!("Cannot open archive {}: {error}", archive.display()))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|error| format!("Cannot read archive {}: {error}", archive.display()))?;
let preflight = preflight_zip(archive, &mut zip)?;
for index in 0..zip.len() {
let mut entry = zip
.by_index(index)
.map_err(|error| format!("Cannot read entry in {}: {error}", archive.display()))?;
let size = entry.size();
let member = enclosed_zip_member(&entry)?;
let kind = zip_kind(&entry);
let Some(member) = normalize_member_path(&member, kind)? else {
continue;
};
on_entry(&member, size);
let destination = target_dir.join(&member);
ensure_parent(&destination)?;
if entry.is_dir() {
std::fs::create_dir_all(&destination).map_err(|error| {
format!("Cannot create folder {}: {error}", destination.display())
})?;
} else if entry.is_symlink() {
let mut target_bytes = Vec::new();
entry
.by_ref()
.take(ARCHIVE_PATH_BYTES_LIMIT as u64 + 1)
.read_to_end(&mut target_bytes)
.map_err(|error| format!("Cannot read link {}: {error}", member.display()))?;
if target_bytes.len() as u64 != size {
return Err(format!(
"Archive link {} size changed while extracting",
member.display()
));
}
let link_target = zip_link_target(&member, target_bytes)?;
#[cfg(unix)]
std::os::unix::fs::symlink(&link_target, &destination).map_err(|error| {
format!("Cannot create link {}: {error}", destination.display())
})?;
} else {
let mut output = std::fs::File::create(&destination).map_err(|error| {
format!("Cannot create file {}: {error}", destination.display())
})?;
let copied = std::io::copy(
&mut entry.by_ref().take(size.saturating_add(1)),
&mut output,
)
.map_err(|error| format!("Cannot extract {}: {error}", destination.display()))?;
if copied != size {
return Err(format!(
"Archive member {} size changed while extracting",
member.display()
));
}
if let Some(mode) = entry.unix_mode() {
use std::os::unix::fs::PermissionsExt as _;
let safe_mode = mode & 0o777;
let _ = output.set_permissions(std::fs::Permissions::from_mode(safe_mode));
}
output
.flush()
.map_err(|error| format!("Cannot extract {}: {error}", destination.display()))?;
}
}
Ok(preflight.summary)
}
fn preflight_zip(
archive: &Path,
zip: &mut zip::ZipArchive<std::fs::File>,
) -> Result<ArchivePreflight, String> {
let mut preflight = ArchivePreflight::default();
for index in 0..zip.len() {
let mut entry = zip
.by_index(index)
.map_err(|error| format!("Cannot read entry in {}: {error}", archive.display()))?;
let size = entry.size();
let member = enclosed_zip_member(&entry)?;
let kind = zip_kind(&entry);
let Some(normalized) = normalize_member_path(&member, kind)? else {
continue;
};
if kind == EntryKind::Symlink {
if size > ARCHIVE_PATH_BYTES_LIMIT as u64 {
return Err(format!(
"Archive link target is too long: {} bytes (limit {})",
size, ARCHIVE_PATH_BYTES_LIMIT
));
}
let mut target = Vec::new();
entry
.by_ref()
.take(size.saturating_add(1))
.read_to_end(&mut target)
.map_err(|error| format!("Cannot read link {}: {error}", normalized.display()))?;
if target.len() as u64 != size {
return Err(format!(
"Archive link {} size changed while reading",
normalized.display()
));
}
zip_link_target(&normalized, target)?;
}
preflight.record(normalized, kind, size)?;
}
Ok(preflight)
}
fn enclosed_zip_member(entry: &zip::read::ZipFile<'_>) -> Result<PathBuf, String> {
entry
.enclosed_name()
.ok_or_else(|| format!("Archive member {} escapes the target folder", entry.name()))
}
fn zip_kind(entry: &zip::read::ZipFile<'_>) -> EntryKind {
if entry.is_dir() {
EntryKind::Directory
} else if entry.is_symlink() {
EntryKind::Symlink
} else {
EntryKind::File
}
}
fn zip_link_target(member: &Path, mut bytes: Vec<u8>) -> Result<PathBuf, String> {
if bytes.len() > ARCHIVE_PATH_BYTES_LIMIT {
return Err(format!(
"Archive link target is too long: {} bytes (limit {})",
bytes.len(),
ARCHIVE_PATH_BYTES_LIMIT
));
}
while bytes.last() == Some(&0) {
bytes.pop();
}
#[cfg(unix)]
let target = {
use std::os::unix::ffi::OsStringExt as _;
PathBuf::from(std::ffi::OsString::from_vec(bytes))
};
#[cfg(not(unix))]
let target = PathBuf::from(
String::from_utf8(bytes)
.map_err(|_| format!("Archive link {} is not UTF-8", member.display()))?,
);
if target.as_os_str().is_empty() || target.is_absolute() || resolves_outside(member, &target) {
return Err(format!(
"Archive link {} -> {} escapes the target folder",
member.display(),
target.display()
));
}
Ok(target)
}
fn validate_empty_target(target: &Path) -> Result<(), String> {
let metadata = std::fs::symlink_metadata(target).map_err(|error| {
format!(
"Cannot inspect extraction folder {}: {error}",
target.display()
)
})?;
if !metadata.file_type().is_dir() {
return Err(format!(
"Extraction target is not a folder: {}",
target.display()
));
}
let mut entries = std::fs::read_dir(target).map_err(|error| {
format!(
"Cannot read extraction folder {}: {error}",
target.display()
)
})?;
if entries.next().is_some() {
return Err(format!(
"Extraction target is not empty: {}",
target.display()
));
}
Ok(())
}
fn ensure_parent(path: &Path) -> Result<(), String> {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return Ok(());
};
std::fs::create_dir_all(parent)
.map_err(|error| format!("Cannot create folder {}: {error}", parent.display()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EntryKind {
Directory,
Symlink,
Hardlink,
File,
}
fn classify<R: Read>(entry: &tar::Entry<'_, R>) -> Result<EntryKind, String> {
let kind = entry.header().entry_type();
if kind.is_dir() {
Ok(EntryKind::Directory)
} else if kind.is_symlink() {
Ok(EntryKind::Symlink)
} else if kind.is_hard_link() {
Ok(EntryKind::Hardlink)
} else if kind.is_file() {
Ok(EntryKind::File)
} else {
Err(format!("Unsupported special archive member type: {kind:?}"))
}
}
#[derive(Default)]
struct ArchivePreflight {
members: BTreeMap<PathBuf, EntryKind>,
required_directories: BTreeSet<PathBuf>,
summary: ExtractSummary,
}
impl ArchivePreflight {
fn record(&mut self, member: PathBuf, kind: EntryKind, size: u64) -> Result<(), String> {
if self.summary.entries >= ARCHIVE_ENTRY_LIMIT {
return Err(format!(
"Archive entry limit reached: {ARCHIVE_ENTRY_LIMIT}"
));
}
let bytes = self
.summary
.bytes
.checked_add(size)
.ok_or_else(|| format!("Archive exceeds byte limit: {ARCHIVE_BYTES_LIMIT}"))?;
if bytes > ARCHIVE_BYTES_LIMIT {
return Err(format!("Archive exceeds byte limit: {ARCHIVE_BYTES_LIMIT}"));
}
if self.members.contains_key(&member) {
return Err(format!(
"Archive contains duplicate member: {}",
member.display()
));
}
if kind != EntryKind::Directory && self.required_directories.contains(&member) {
return Err(format!(
"Archive member conflicts with a child path: {}",
member.display()
));
}
let mut ancestor = member.parent();
while let Some(path) = ancestor.filter(|path| !path.as_os_str().is_empty()) {
if self
.members
.get(path)
.is_some_and(|existing| *existing != EntryKind::Directory)
{
return Err(format!(
"Archive member is nested beneath a non-folder: {}",
member.display()
));
}
self.required_directories.insert(path.to_path_buf());
ancestor = path.parent();
}
self.members.insert(member, kind);
self.summary.entries += 1;
self.summary.bytes = bytes;
match kind {
EntryKind::Directory => self.summary.dirs += 1,
EntryKind::Symlink | EntryKind::Hardlink => self.summary.links += 1,
EntryKind::File => self.summary.files += 1,
}
Ok(())
}
}
fn normalize_member_path(member: &Path, kind: EntryKind) -> Result<Option<PathBuf>, String> {
validate_member_path(member)?;
let normalized: PathBuf = member
.components()
.filter_map(|component| match component {
Component::Normal(name) => Some(name),
Component::CurDir => None,
_ => None,
})
.collect();
if normalized.as_os_str().is_empty() {
if kind == EntryKind::Directory {
return Ok(None);
}
return Err("Archive contains an empty member path".to_string());
}
Ok(Some(normalized))
}
pub fn validate_member_path(member: &Path) -> Result<(), String> {
if member.is_absolute() || has_parent_components(member) {
return Err(format!(
"Archive member escapes the target folder: {}",
member.display()
));
}
if encoded_path_len(member) > ARCHIVE_PATH_BYTES_LIMIT {
return Err(format!(
"Archive member path is too long: {} (limit {} bytes)",
member.display(),
ARCHIVE_PATH_BYTES_LIMIT
));
}
Ok(())
}
fn encoded_path_len(path: &Path) -> usize {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
path.as_os_str().as_bytes().len()
}
#[cfg(not(unix))]
{
path.as_os_str().to_string_lossy().len()
}
}
fn has_parent_components(path: &Path) -> bool {
path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::Prefix(_) | Component::RootDir
)
})
}
fn validate_link_target<R: Read>(entry: &tar::Entry<'_, R>, member: &Path) -> Result<(), String> {
let entry_type = entry.header().entry_type();
let requires_target = entry_type.is_symlink() || entry_type.is_hard_link();
let Some(link) = entry
.link_name()
.map_err(|error| format!("Cannot read link target: {error}"))?
else {
return if requires_target {
Err(format!("Archive link {} has no target", member.display()))
} else {
Ok(())
};
};
if link.as_os_str().is_empty() {
return Err(format!(
"Archive link {} has an empty target",
member.display()
));
}
if encoded_path_len(&link) > ARCHIVE_PATH_BYTES_LIMIT {
return Err(format!(
"Archive link target is too long: {} bytes (limit {})",
encoded_path_len(&link),
ARCHIVE_PATH_BYTES_LIMIT
));
}
let escapes = if entry_type.is_hard_link() {
link.is_absolute() || has_parent_components(&link)
} else {
link.is_absolute() || has_parent_components(&link) && resolves_outside(member, &link)
};
if escapes {
return Err(format!(
"Archive link {} -> {} escapes the target folder",
member.display(),
link.display()
));
}
Ok(())
}
fn resolves_outside(member: &Path, link: &Path) -> bool {
let parent = member.parent().unwrap_or_else(|| Path::new(""));
let mut depth = 0usize;
for component in parent.components() {
match component {
Component::Normal(_) => depth += 1,
Component::CurDir => {}
_ => return true,
}
}
for component in link.components() {
match component {
Component::Normal(_) => depth += 1,
Component::CurDir => {}
Component::ParentDir => {
if depth == 0 {
return true;
}
depth -= 1;
}
_ => return true,
}
}
false
}
pub fn extraction_folder_name(archive: &Path) -> Option<String> {
let name = archive.file_name()?.to_str()?;
let lowered = name.to_lowercase();
for suffix in [".tar.gz", ".tgz", ".tar", ".zip"] {
if lowered.ends_with(suffix) {
return Some(name[..name.len() - suffix.len()].to_string());
}
}
None
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CreateSummary {
pub entries: usize,
pub files: usize,
pub dirs: usize,
pub links: usize,
pub skipped: usize,
}
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SourceNodeKind {
Directory,
File,
Symlink,
Other,
}
#[cfg(target_os = "linux")]
struct SourceNode {
display_path: PathBuf,
member: PathBuf,
metadata: std::fs::Metadata,
kind: SourceNodeKind,
contents: Option<std::fs::File>,
link_target: Option<PathBuf>,
}
#[cfg(target_os = "linux")]
fn walk_sources_secure(
sources: &[PathBuf],
mut visit: impl FnMut(SourceNode) -> Result<(), String>,
) -> Result<(), String> {
let mut visited = 0usize;
for source in sources {
let name = source
.file_name()
.ok_or_else(|| format!("{} has no file name", source.display()))?;
let parent = source
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = std::fs::canonicalize(parent)
.map_err(|error| format!("Cannot resolve {}: {error}", parent.display()))?;
let parent_fd = open_absolute_directory_no_follow(&parent)?;
walk_source_at(
&parent_fd,
name,
source,
&PathBuf::from(name),
0,
&mut visited,
&mut visit,
)?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn open_absolute_directory_no_follow(path: &Path) -> Result<rustix::fd::OwnedFd, String> {
use rustix::fs::{openat, Mode, OFlags, CWD};
if !path.is_absolute() {
return Err(format!(
"Resolved source parent is not absolute: {}",
path.display()
));
}
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let mut directory = openat(CWD, Path::new("/"), flags, Mode::empty())
.map_err(|error| format!("Cannot open filesystem root: {error}"))?;
for component in path.components() {
match component {
Component::RootDir | Component::CurDir => {}
Component::Normal(name) => {
directory = openat(&directory, name, flags, Mode::empty()).map_err(|error| {
format!(
"Cannot securely open source folder {}: {error}",
path.display()
)
})?;
}
_ => {
return Err(format!(
"Resolved source parent has an invalid component: {}",
path.display()
));
}
}
}
Ok(directory)
}
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
fn walk_source_at<Fd: std::os::fd::AsFd>(
parent: &Fd,
name: &OsStr,
display_path: &Path,
member: &Path,
depth: usize,
visited: &mut usize,
visit: &mut impl FnMut(SourceNode) -> Result<(), String>,
) -> Result<(), String> {
use rustix::fs::{fstat, openat, readlinkat, statat, AtFlags, FileType, Mode, OFlags};
use std::os::unix::ffi::OsStringExt as _;
if *visited >= ARCHIVE_ENTRY_LIMIT {
return Err(format!(
"Archive traversal limit reached: {ARCHIVE_ENTRY_LIMIT}"
));
}
*visited += 1;
validate_member_path(member)?;
let before = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|error| format!("Cannot inspect {}: {error}", display_path.display()))?;
let kind = FileType::from_raw_mode(before.st_mode);
match kind {
FileType::Directory => {
let fd = openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|error| {
format!(
"Cannot securely open directory {}: {error}",
display_path.display()
)
})?;
ensure_rustix_identity(&before, &fstat(&fd).map_err(rustix_error)?, display_path)?;
let directory_file = std::fs::File::from(fd);
let metadata = directory_file.metadata().map_err(|error| {
format!(
"Cannot inspect open directory {}: {error}",
display_path.display()
)
})?;
visit(SourceNode {
display_path: display_path.to_path_buf(),
member: member.to_path_buf(),
metadata,
kind: SourceNodeKind::Directory,
contents: None,
link_target: None,
})?;
if depth >= crate::ops::OPERATION_DEPTH_LIMIT {
return Err(format!(
"Operation depth limit reached at {} (limit {})",
display_path.display(),
crate::ops::OPERATION_DEPTH_LIMIT
));
}
let mut directory = rustix::fs::Dir::read_from(&directory_file).map_err(|error| {
format!("Cannot read directory {}: {error}", display_path.display())
})?;
let mut children = Vec::<OsString>::new();
while let Some(entry) = directory.read() {
let entry = entry.map_err(|error| {
format!("Cannot read directory {}: {error}", display_path.display())
})?;
let bytes = entry.file_name().to_bytes();
if bytes == b"." || bytes == b".." {
continue;
}
children.push(OsString::from_vec(bytes.to_vec()));
if children.len() > ARCHIVE_ENTRY_LIMIT.saturating_sub(*visited) {
return Err(format!(
"Archive traversal limit reached: {ARCHIVE_ENTRY_LIMIT}"
));
}
}
children.sort();
for child in children {
walk_source_at(
&directory_file,
&child,
&display_path.join(&child),
&member.join(&child),
depth + 1,
visited,
visit,
)?;
}
}
FileType::RegularFile => {
let fd = openat(
parent,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|error| {
format!(
"Cannot securely open file {}: {error}",
display_path.display()
)
})?;
ensure_rustix_identity(&before, &fstat(&fd).map_err(rustix_error)?, display_path)?;
let contents = std::fs::File::from(fd);
let metadata = contents.metadata().map_err(|error| {
format!(
"Cannot inspect open file {}: {error}",
display_path.display()
)
})?;
visit(SourceNode {
display_path: display_path.to_path_buf(),
member: member.to_path_buf(),
metadata,
kind: SourceNodeKind::File,
contents: Some(contents),
link_target: None,
})?;
}
FileType::Symlink => {
let fd = openat(
parent,
name,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|error| {
format!(
"Cannot securely open link {}: {error}",
display_path.display()
)
})?;
ensure_rustix_identity(&before, &fstat(&fd).map_err(rustix_error)?, display_path)?;
let link = readlinkat(&fd, "", Vec::new()).map_err(|error| {
format!(
"Cannot securely read link {}: {error}",
display_path.display()
)
})?;
let link_file = std::fs::File::from(fd);
let metadata = link_file.metadata().map_err(|error| {
format!(
"Cannot inspect open link {}: {error}",
display_path.display()
)
})?;
visit(SourceNode {
display_path: display_path.to_path_buf(),
member: member.to_path_buf(),
metadata,
kind: SourceNodeKind::Symlink,
contents: None,
link_target: Some(PathBuf::from(OsString::from_vec(link.into_bytes()))),
})?;
}
_ => {
let fd = openat(
parent,
name,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|error| {
format!(
"Cannot securely inspect {}: {error}",
display_path.display()
)
})?;
ensure_rustix_identity(&before, &fstat(&fd).map_err(rustix_error)?, display_path)?;
let special = std::fs::File::from(fd);
let metadata = special.metadata().map_err(|error| {
format!(
"Cannot inspect special item {}: {error}",
display_path.display()
)
})?;
visit(SourceNode {
display_path: display_path.to_path_buf(),
member: member.to_path_buf(),
metadata,
kind: SourceNodeKind::Other,
contents: None,
link_target: None,
})?;
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn rustix_error(error: rustix::io::Errno) -> String {
error.to_string()
}
#[cfg(target_os = "linux")]
fn ensure_rustix_identity(
before: &rustix::fs::Stat,
opened: &rustix::fs::Stat,
path: &Path,
) -> Result<(), String> {
if before.st_dev != opened.st_dev
|| before.st_ino != opened.st_ino
|| rustix::fs::FileType::from_raw_mode(before.st_mode)
!= rustix::fs::FileType::from_raw_mode(opened.st_mode)
{
return Err(format!(
"{} changed while it was being opened; archive creation stopped",
path.display()
));
}
Ok(())
}
pub fn create_archive(
sources: &[PathBuf],
output: &Path,
mut on_entry: impl FnMut(&Path, u64),
) -> Result<CreateSummary, String> {
if sources.is_empty() {
return Err("Archive creation has no sources".to_string());
}
#[cfg(target_os = "linux")]
{
create_archive_secure(sources, output, &mut on_entry)
}
#[cfg(not(target_os = "linux"))]
{
create_archive_path_based(sources, output, &mut on_entry)
}
}
#[cfg(target_os = "linux")]
fn create_archive_secure(
sources: &[PathBuf],
output: &Path,
on_entry: &mut impl FnMut(&Path, u64),
) -> Result<CreateSummary, String> {
let file = create_new_archive_file(output, "archive")?;
let encoder = GzEncoder::new(file, flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
let mut summary = CreateSummary::default();
let mut members = BTreeSet::new();
walk_sources_secure(sources, |mut node| {
if !members.insert(node.member.clone()) {
return Err(format!(
"Archive contains duplicate output path: {}",
node.member.display()
));
}
if node.kind == SourceNodeKind::Other {
summary.skipped += 1;
return Ok(());
}
let size = if node.kind == SourceNodeKind::File {
node.metadata.len()
} else {
0
};
on_entry(&node.member, size);
summary.entries += 1;
append_secure_tar_node(&mut builder, &mut node)?;
match node.kind {
SourceNodeKind::Directory => summary.dirs += 1,
SourceNodeKind::File => summary.files += 1,
SourceNodeKind::Symlink => summary.links += 1,
SourceNodeKind::Other => unreachable!("special nodes return above"),
}
Ok(())
})?;
let encoder = builder
.into_inner()
.map_err(|error| format!("Cannot finish archive {}: {error}", output.display()))?;
let output_file = encoder
.finish()
.map_err(|error| format!("Cannot finish archive {}: {error}", output.display()))?;
output_file
.sync_all()
.map_err(|error| format!("Cannot sync archive {}: {error}", output.display()))?;
Ok(summary)
}
#[cfg(target_os = "linux")]
fn append_secure_tar_node(
builder: &mut tar::Builder<GzEncoder<std::fs::File>>,
node: &mut SourceNode,
) -> Result<(), String> {
let mut header = tar::Header::new_gnu();
header.set_metadata(&node.metadata);
match node.kind {
SourceNodeKind::Directory => {
header.set_size(0);
header.set_cksum();
builder
.append_data(&mut header, &node.member, io::empty())
.map_err(|error| format!("Cannot add {}: {error}", node.display_path.display()))
}
SourceNodeKind::Symlink => {
let target = node
.link_target
.as_ref()
.ok_or_else(|| format!("Cannot read link {}", node.display_path.display()))?;
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_cksum();
builder
.append_link(&mut header, &node.member, target)
.map_err(|error| {
format!("Cannot add link {}: {error}", node.display_path.display())
})
}
SourceNodeKind::File => {
let expected = node.metadata.len();
header.set_size(expected);
header.set_cksum();
let contents = node
.contents
.as_mut()
.ok_or_else(|| format!("Cannot open {}", node.display_path.display()))?;
builder
.append_data(&mut header, &node.member, Read::by_ref(contents))
.map_err(|error| format!("Cannot add {}: {error}", node.display_path.display()))?;
let current_len = contents
.metadata()
.map_err(|error| {
format!(
"Cannot recheck {} after archiving: {error}",
node.display_path.display()
)
})?
.len();
if current_len != expected {
return Err(format!(
"{} changed while it was being added to the archive",
node.display_path.display()
));
}
Ok(())
}
SourceNodeKind::Other => Ok(()),
}
}
fn create_new_archive_file(output: &Path, label: &str) -> Result<std::fs::File, String> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(output)
.map_err(|error| format!("Cannot create {label} {}: {error}", output.display()))
}
pub fn create_zip_archive(
sources: &[PathBuf],
output: &Path,
mut on_entry: impl FnMut(&Path, u64),
) -> Result<CreateSummary, String> {
if sources.is_empty() {
return Err("Archive creation has no sources".to_string());
}
#[cfg(target_os = "linux")]
{
create_zip_archive_secure(sources, output, &mut on_entry)
}
#[cfg(not(target_os = "linux"))]
{
create_zip_archive_path_based(sources, output, &mut on_entry)
}
}
#[cfg(target_os = "linux")]
fn create_zip_archive_secure(
sources: &[PathBuf],
output: &Path,
on_entry: &mut impl FnMut(&Path, u64),
) -> Result<CreateSummary, String> {
let file = create_new_archive_file(output, "ZIP archive")?;
let mut writer = zip::ZipWriter::new(file);
let mut summary = CreateSummary::default();
let mut members = BTreeSet::new();
walk_sources_secure(sources, |mut node| {
validate_zip_text_path(&node.member, "member")?;
if !members.insert(node.member.clone()) {
return Err(format!(
"Archive contains duplicate output path: {}",
node.member.display()
));
}
if node.kind == SourceNodeKind::Other {
summary.skipped += 1;
return Ok(());
}
let size = if node.kind == SourceNodeKind::File {
node.metadata.len()
} else {
0
};
on_entry(&node.member, size);
summary.entries += 1;
append_secure_zip_node(&mut writer, &mut node)?;
match node.kind {
SourceNodeKind::Directory => summary.dirs += 1,
SourceNodeKind::File => summary.files += 1,
SourceNodeKind::Symlink => summary.links += 1,
SourceNodeKind::Other => unreachable!("special nodes return above"),
}
Ok(())
})?;
let output_file = writer
.finish()
.map_err(|error| format!("Cannot finish ZIP archive {}: {error}", output.display()))?;
output_file
.sync_all()
.map_err(|error| format!("Cannot sync ZIP archive {}: {error}", output.display()))?;
Ok(summary)
}
#[cfg(target_os = "linux")]
fn append_secure_zip_node(
writer: &mut zip::ZipWriter<std::fs::File>,
node: &mut SourceNode,
) -> Result<(), String> {
use zip::write::SimpleFileOptions;
let permissions = safe_unix_permissions(&node.metadata, node.kind == SourceNodeKind::Directory);
let options = SimpleFileOptions::default()
.unix_permissions(permissions)
.large_file(node.kind == SourceNodeKind::File && node.metadata.len() > u64::from(u32::MAX));
match node.kind {
SourceNodeKind::Directory => writer
.add_directory_from_path(&node.member, options)
.map_err(|error| {
format!(
"Cannot add folder {} to ZIP: {error}",
node.display_path.display()
)
}),
SourceNodeKind::Symlink => {
let target = node
.link_target
.as_ref()
.ok_or_else(|| format!("Cannot read link {}", node.display_path.display()))?;
validate_zip_text_path(target, "symbolic-link target")?;
let member_text = node
.member
.to_str()
.expect("ZIP member path was validated as UTF-8");
let target_text = target
.to_str()
.expect("ZIP symbolic-link target was validated as UTF-8");
writer
.add_symlink(member_text, target_text, options)
.map_err(|error| {
format!(
"Cannot add link {} to ZIP: {error}",
node.display_path.display()
)
})
}
SourceNodeKind::File => {
writer
.start_file_from_path(&node.member, options)
.map_err(|error| {
format!("Cannot add {} to ZIP: {error}", node.display_path.display())
})?;
let expected = node.metadata.len();
let contents = node
.contents
.as_mut()
.ok_or_else(|| format!("Cannot open {}", node.display_path.display()))?;
let copied = io::copy(
&mut Read::by_ref(contents).take(expected.saturating_add(1)),
writer,
)
.map_err(|error| format!("Cannot archive {}: {error}", node.display_path.display()))?;
let current_len = contents
.metadata()
.map_err(|error| {
format!(
"Cannot recheck {} after archiving: {error}",
node.display_path.display()
)
})?
.len();
if copied != expected || current_len != expected {
return Err(format!(
"{} changed while it was being added to the ZIP archive",
node.display_path.display()
));
}
Ok(())
}
SourceNodeKind::Other => Ok(()),
}
}
#[cfg(not(target_os = "linux"))]
fn append_zip_member(
writer: &mut zip::ZipWriter<std::fs::File>,
path: &Path,
member: &Path,
metadata: &std::fs::Metadata,
) -> Result<(), String> {
use zip::write::SimpleFileOptions;
let file_type = metadata.file_type();
let permissions = safe_unix_permissions(metadata, file_type.is_dir());
let options = SimpleFileOptions::default()
.unix_permissions(permissions)
.large_file(file_type.is_file() && metadata.len() > u64::from(u32::MAX));
if file_type.is_dir() {
writer
.add_directory_from_path(member, options)
.map_err(|error| format!("Cannot add folder {} to ZIP: {error}", path.display()))?;
return Ok(());
}
if file_type.is_symlink() {
let target = std::fs::read_link(path)
.map_err(|error| format!("Cannot read link {}: {error}", path.display()))?;
validate_zip_text_path(&target, "symbolic-link target")?;
let member_text = member
.to_str()
.expect("ZIP member path was validated as UTF-8");
let target_text = target
.to_str()
.expect("ZIP symbolic-link target was validated as UTF-8");
writer
.add_symlink(member_text, target_text, options)
.map_err(|error| format!("Cannot add link {} to ZIP: {error}", path.display()))?;
return Ok(());
}
if !file_type.is_file() {
return Ok(());
}
writer
.start_file_from_path(member, options)
.map_err(|error| format!("Cannot add {} to ZIP: {error}", path.display()))?;
let contents = std::fs::File::open(path)
.map_err(|error| format!("Cannot open {}: {error}", path.display()))?;
let copied = io::copy(&mut contents.take(metadata.len().saturating_add(1)), writer)
.map_err(|error| format!("Cannot archive {}: {error}", path.display()))?;
if copied != metadata.len() {
return Err(format!(
"{} changed while it was being added to the ZIP archive",
path.display()
));
}
Ok(())
}
fn validate_zip_text_path(path: &Path, role: &str) -> Result<(), String> {
if path.to_str().is_none() {
return Err(format!(
"ZIP {role} is not valid UTF-8: {}; use tar.gz to preserve this Unix name",
path.display()
));
}
Ok(())
}
fn safe_unix_permissions(metadata: &std::fs::Metadata, directory: bool) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = directory;
metadata.permissions().mode() & 0o777
}
#[cfg(not(unix))]
{
if directory {
0o755
} else {
0o644
}
}
}
#[cfg(not(target_os = "linux"))]
fn append_member(
builder: &mut tar::Builder<GzEncoder<std::fs::File>>,
path: &Path,
member: &Path,
metadata: &std::fs::Metadata,
) -> Result<(), String> {
let file_type = metadata.file_type();
let mut header = tar::Header::new_gnu();
header.set_metadata(metadata);
if file_type.is_dir() {
header.set_size(0);
header.set_cksum();
builder
.append_data(&mut header, member, io::empty())
.map_err(|error| format!("Cannot add {}: {error}", path.display()))?;
return Ok(());
}
if file_type.is_symlink() {
let target = std::fs::read_link(path)
.map_err(|error| format!("Cannot read link {}: {error}", path.display()))?;
header.set_size(0);
header.set_entry_type(tar::EntryType::Symlink);
header.set_cksum();
builder
.append_link(&mut header, member, &target)
.map_err(|error| format!("Cannot add link {}: {error}", path.display()))?;
return Ok(());
}
if !file_type.is_file() {
return Ok(());
}
header.set_size(metadata.len());
header.set_cksum();
let contents = std::fs::File::open(path)
.map_err(|error| format!("Cannot open {}: {error}", path.display()))?;
builder
.append_data(&mut header, member, contents)
.map_err(|error| format!("Cannot add {}: {error}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tar::Builder;
fn temp_root(label: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"guth-archives-{label}-{}-{nonce}",
std::process::id()
))
}
fn write_tar_gz(path: &Path, members: &[(&str, &[u8])]) {
let file = std::fs::File::create(path).unwrap();
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
let mut builder = Builder::new(encoder);
for (name, contents) in members {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, name, *contents).unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
}
#[test]
fn detects_supported_formats_and_rejects_others() {
assert_eq!(
detect_format(Path::new("/tmp/a.tar")),
Some(ArchiveFormat::Tar)
);
assert_eq!(
detect_format(Path::new("/tmp/a.tar.gz")),
Some(ArchiveFormat::TarGz)
);
assert_eq!(
detect_format(Path::new("/tmp/A.TGZ")),
Some(ArchiveFormat::TarGz)
);
assert_eq!(
detect_format(Path::new("/tmp/a.zip")),
Some(ArchiveFormat::Zip)
);
assert_eq!(detect_format(Path::new("/tmp/a.gz")), None);
assert_eq!(detect_format(Path::new("/tmp/tar")), None);
}
#[test]
fn extracts_zip_members_and_rejects_traversal() {
use std::io::Write as _;
let root = temp_root("zip");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("bundle.zip");
let file = std::fs::File::create(&archive).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: zip::write::SimpleFileOptions = Default::default();
zip.add_directory("docs", options).unwrap();
zip.start_file("docs/readme.txt", options).unwrap();
zip.write_all(b"hello").unwrap();
drop(zip);
let summary = extract(&archive, &target, |_, _| {}).expect("zip extraction should succeed");
assert_eq!(summary.files, 1);
assert_eq!(summary.dirs, 1);
assert_eq!(
std::fs::read(target.join("docs/readme.txt")).unwrap(),
b"hello"
);
let linked = root.join("linked.zip");
write_raw_zip_with_symlink(&linked, "alias.txt", "docs/readme.txt");
let linked_target = root.join("linked-out");
std::fs::create_dir_all(&linked_target).unwrap();
extract(&linked, &linked_target, |_, _| {}).expect("symlink zip should extract");
#[cfg(unix)]
{
let link = std::fs::read_link(linked_target.join("alias.txt")).unwrap();
assert_eq!(link.to_str(), Some("docs/readme.txt"));
}
let evil_link = root.join("evil-link.zip");
write_raw_zip_with_symlink(&evil_link, "danger.txt", "../../outside.txt");
let evil_link_target = root.join("evil-link-out");
std::fs::create_dir_all(&evil_link_target).unwrap();
let error = extract(&evil_link, &evil_link_target, |_, _| {}).unwrap_err();
assert!(error.contains("escapes"), "unexpected error: {error}");
assert!(!root.join("outside.txt").exists());
let evil = root.join("evil.zip");
let file = std::fs::File::create(&evil).unwrap();
let mut zip = zip::ZipWriter::new(file);
if zip.start_file("../escaped.txt", options).is_ok() {
zip.write_all(b"nope").unwrap();
drop(zip);
let evil_target = root.join("evil-out");
std::fs::create_dir_all(&evil_target).unwrap();
let error = extract(&evil, &evil_target, |_, _| {}).unwrap_err();
assert!(error.contains("escapes"), "unexpected error: {error}");
assert!(!root.join("escaped.txt").exists());
}
let _ = std::fs::remove_dir_all(root);
}
fn write_raw_zip_with_symlink(path: &Path, name: &str, target: &str) {
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
let payload = target.as_bytes();
let name_bytes = name.as_bytes();
let checksum = crc32(payload);
let size = payload.len() as u32;
let mode: u32 = 0o120777;
let mut blob = Vec::new();
let local_offset = blob.len() as u32;
blob.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
blob.extend_from_slice(&20u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&checksum.to_le_bytes());
blob.extend_from_slice(&size.to_le_bytes());
blob.extend_from_slice(&size.to_le_bytes());
blob.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(name_bytes);
blob.extend_from_slice(payload);
let central_offset = blob.len() as u32;
blob.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
blob.extend_from_slice(&((3u16 << 8) | 20).to_le_bytes()); blob.extend_from_slice(&20u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&checksum.to_le_bytes());
blob.extend_from_slice(&size.to_le_bytes());
blob.extend_from_slice(&size.to_le_bytes());
blob.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&(mode << 16).to_le_bytes()); blob.extend_from_slice(&local_offset.to_le_bytes());
blob.extend_from_slice(name_bytes);
let central_size = blob.len() as u32 - central_offset;
blob.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&0u16.to_le_bytes()); blob.extend_from_slice(&1u16.to_le_bytes()); blob.extend_from_slice(&1u16.to_le_bytes()); blob.extend_from_slice(¢ral_size.to_le_bytes());
blob.extend_from_slice(¢ral_offset.to_le_bytes());
blob.extend_from_slice(&0u16.to_le_bytes()); std::fs::write(path, blob).unwrap();
}
#[test]
fn extracts_nested_members_into_fresh_folder() {
let root = temp_root("extract");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("bundle.tar.gz");
write_tar_gz(
&archive,
&[
("docs/readme.txt", b"hello" as &[u8]),
("docs/deep/guide.txt", b"guide"),
("top.bin", &[1u8, 2, 3]),
],
);
let summary = extract(&archive, &target, |_, _| {}).expect("extraction should succeed");
assert_eq!(summary.files, 3);
assert_eq!(summary.entries, 3);
assert_eq!(
std::fs::read(target.join("docs/readme.txt")).unwrap(),
b"hello"
);
assert_eq!(
std::fs::read(target.join("docs/deep/guide.txt")).unwrap(),
b"guide"
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn rejects_traversal_members_without_writing_them() {
let root = temp_root("traversal");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("evil.tar");
write_raw_tar(&archive, &[("../escaped.txt", b"nope" as &[u8])]);
let error = extract(&archive, &target, |_, _| {}).unwrap_err();
assert!(error.contains("escapes"), "unexpected error: {error}");
assert!(!root.join("escaped.txt").exists());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn preflight_rejects_a_late_traversal_before_writing_safe_members() {
let root = temp_root("late-traversal");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("evil.tar");
write_raw_tar(
&archive,
&[
("looks-safe.txt", b"must not be written" as &[u8]),
("../escaped.txt", b"nope" as &[u8]),
],
);
let error = extract(&archive, &target, |_, _| {}).unwrap_err();
assert!(error.contains("escapes"), "unexpected error: {error}");
assert!(!target.join("looks-safe.txt").exists());
assert!(!root.join("escaped.txt").exists());
assert_eq!(std::fs::read_dir(&target).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn preflight_rejects_duplicate_members_before_writing() {
let root = temp_root("duplicates");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("duplicate.tar.gz");
write_tar_gz(
&archive,
&[
("same.txt", b"first" as &[u8]),
("same.txt", b"second" as &[u8]),
],
);
let error = extract(&archive, &target, |_, _| {}).unwrap_err();
assert!(
error.contains("duplicate member"),
"unexpected error: {error}"
);
assert_eq!(std::fs::read_dir(&target).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn extraction_refuses_a_nonempty_target() {
let root = temp_root("nonempty-target");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("keep.txt"), b"keep").unwrap();
let archive = root.join("bundle.tar.gz");
write_tar_gz(&archive, &[("new.txt", b"new" as &[u8])]);
let error = extract(&archive, &target, |_, _| {}).unwrap_err();
assert!(error.contains("not empty"), "unexpected error: {error}");
assert_eq!(std::fs::read(target.join("keep.txt")).unwrap(), b"keep");
assert!(!target.join("new.txt").exists());
let _ = std::fs::remove_dir_all(root);
}
fn write_raw_tar(path: &Path, members: &[(&str, &[u8])]) {
fn octal(value: usize, width: usize) -> Vec<u8> {
let text = format!("{:0width$o}", value, width = width - 1);
let mut bytes = text.into_bytes();
bytes.push(0);
bytes
}
let mut blob = Vec::new();
for (name, contents) in members {
let mut header = vec![0u8; 512];
header[..name.len()].copy_from_slice(name.as_bytes());
header[100..108].copy_from_slice(&octal(0o644, 8));
header[108..116].copy_from_slice(&octal(0, 8));
header[116..124].copy_from_slice(&octal(0, 8));
header[124..136].copy_from_slice(&octal(contents.len(), 12));
header[136..148].copy_from_slice(&octal(0, 12));
header[148..156].fill(b' ');
header[156] = b'0';
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
let checksum = header.iter().map(|byte| *byte as usize).sum::<usize>();
header[148..156].copy_from_slice(&octal(checksum, 8));
blob.extend_from_slice(&header);
blob.extend_from_slice(contents);
let padding = (512 - contents.len() % 512) % 512;
blob.extend(std::iter::repeat_n(0u8, padding));
}
blob.extend_from_slice(&[0u8; 1024]);
std::fs::write(path, blob).unwrap();
}
#[test]
fn rejects_absolute_member_paths() {
let error = validate_member_path(Path::new("/etc/passwd")).expect_err("absolute must fail");
assert!(error.contains("escapes"));
validate_member_path(Path::new("nested/dir/file.txt")).expect("relative must pass");
let oversized = "x".repeat(ARCHIVE_PATH_BYTES_LIMIT + 1);
let error = validate_member_path(Path::new(&oversized)).expect_err("long path must fail");
assert!(error.contains("too long"));
}
#[test]
fn rejects_symlinks_that_escape_but_keeps_internal_links() {
let root = temp_root("links");
let target = root.join("out");
std::fs::create_dir_all(&target).unwrap();
let archive = root.join("links.tar");
let file = std::fs::File::create(&archive).unwrap();
let mut builder = Builder::new(file);
let mut inside = tar::Header::new_gnu();
inside.set_entry_type(tar::EntryType::Symlink);
inside.set_size(0);
inside.set_mode(0o777);
builder
.append_link(&mut inside, "alias.txt", "docs/readme.txt")
.unwrap();
let mut escaping = tar::Header::new_gnu();
escaping.set_entry_type(tar::EntryType::Symlink);
escaping.set_size(0);
escaping.set_mode(0o777);
builder
.append_link(&mut escaping, "danger.txt", "../../outside.txt")
.unwrap();
drop(builder);
let error = extract(&archive, &target, |_, _| {}).unwrap_err();
assert!(error.contains("escapes"), "unexpected error: {error}");
assert!(!target.join("alias.txt").exists() || !root.join("outside.txt").exists());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn extraction_folder_names_strip_compound_suffixes() {
assert_eq!(
extraction_folder_name(Path::new("/tmp/photos.tar.gz")),
Some("photos".to_string())
);
assert_eq!(
extraction_folder_name(Path::new("/tmp/backup.tgz")),
Some("backup".to_string())
);
assert_eq!(
extraction_folder_name(Path::new("/tmp/data.tar")),
Some("data".to_string())
);
assert_eq!(
extraction_folder_name(Path::new("/tmp/blob.zip")),
Some("blob".to_string())
);
}
#[test]
fn created_archives_round_trip_through_extraction() {
let root = temp_root("create");
let project = root.join("project");
std::fs::create_dir_all(project.join("src/deep")).unwrap();
std::fs::write(project.join("src/main.rs"), b"fn main() {}").unwrap();
std::fs::write(project.join("src/deep/util.rs"), b"pub fn util() {}").unwrap();
std::fs::write(root.join("standalone.txt"), b"standalone").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("main.rs", project.join("src/latest.rs")).unwrap();
let sources = vec![project.clone(), root.join("standalone.txt")];
let output = root.join("bundle.tar.gz");
let mut seen: Vec<String> = Vec::new();
let summary = create_archive(&sources, &output, |member, _| {
seen.push(member.display().to_string())
})
.expect("archive creation should succeed");
assert!(summary.files >= 3);
assert!(summary.dirs >= 2);
assert!(seen.contains(&"project/src/main.rs".to_string()));
assert!(seen.contains(&"standalone.txt".to_string()));
let round_trip = root.join("round-trip");
std::fs::create_dir_all(&round_trip).unwrap();
let extracted = extract(&output, &round_trip, |_, _| {})
.expect("created archive should extract cleanly");
assert_eq!(extracted.files, summary.files);
assert_eq!(
std::fs::read(round_trip.join("project/src/main.rs")).unwrap(),
b"fn main() {}"
);
assert_eq!(
std::fs::read(round_trip.join("standalone.txt")).unwrap(),
b"standalone"
);
#[cfg(unix)]
{
let link = std::fs::read_link(round_trip.join("project/src/latest.rs")).unwrap();
assert_eq!(link.to_str(), Some("main.rs"));
}
let missing_output = root.join("missing-source.tar.gz");
let error = create_archive(&[root.join("does-not-exist")], &missing_output, |_, _| {})
.expect_err("missing source must fail");
assert!(error.contains("Cannot inspect"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn created_zip_archives_round_trip_files_folders_and_links() {
let root = temp_root("create-zip");
let project = root.join("project");
std::fs::create_dir_all(project.join("assets")).unwrap();
std::fs::write(project.join("README.txt"), b"hello zip").unwrap();
std::fs::write(project.join("assets/icon.txt"), b"icon").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("README.txt", project.join("latest.txt")).unwrap();
let output = root.join("project.zip");
let summary = create_zip_archive(std::slice::from_ref(&project), &output, |_, _| {})
.expect("ZIP creation should succeed");
assert_eq!(summary.files, 2);
assert!(summary.dirs >= 2);
let extracted_root = root.join("extracted");
std::fs::create_dir_all(&extracted_root).unwrap();
let extracted = extract(&output, &extracted_root, |_, _| {})
.expect("created ZIP should extract cleanly");
assert_eq!(extracted.files, 2);
assert_eq!(
std::fs::read(extracted_root.join("project/README.txt")).unwrap(),
b"hello zip"
);
assert_eq!(
std::fs::read(extracted_root.join("project/assets/icon.txt")).unwrap(),
b"icon"
);
#[cfg(unix)]
assert_eq!(
std::fs::read_link(extracted_root.join("project/latest.txt")).unwrap(),
PathBuf::from("README.txt")
);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn zip_creation_rejects_non_utf8_names_without_lossy_collisions() {
use std::os::unix::ffi::OsStringExt as _;
let root = temp_root("zip-non-utf8");
std::fs::create_dir_all(&root).unwrap();
let source = root.join(std::ffi::OsString::from_vec(b"report-\xff.txt".to_vec()));
std::fs::write(&source, b"raw name").unwrap();
let output = root.join("bundle.zip");
let error = create_zip_archive(&[source], &output, |_, _| {})
.expect_err("ZIP must not silently rewrite non-UTF-8 names");
assert!(
error.contains("not valid UTF-8"),
"unexpected error: {error}"
);
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[test]
fn zip_creation_strips_special_permission_bits() {
use std::os::unix::fs::PermissionsExt as _;
let root = temp_root("zip-permissions");
std::fs::create_dir_all(&root).unwrap();
let source = root.join("tool");
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o6755)).unwrap();
let output = root.join("tool.zip");
create_zip_archive(&[source], &output, |_, _| {}).unwrap();
let file = std::fs::File::open(&output).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let entry = archive.by_name("tool").unwrap();
assert_eq!(entry.unix_mode().unwrap_or_default() & 0o7000, 0);
assert_eq!(entry.unix_mode().unwrap_or_default() & 0o777, 0o755);
let _ = std::fs::remove_dir_all(root);
}
}