use std::ffi::{OsStr, OsString};
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::fs::{Dir, OpenOptions};
use crate::CryptoError;
use crate::error::sanitize_for_display;
#[cfg(windows)]
use crate::fs::paths::parent_or_cwd;
#[cfg(unix)]
use super::format::PERMISSION_BITS_MASK;
use super::format::{checked_entry_wire_len, copy_exact_n, serialize_manifest, write_fca_header};
use super::limits::{
ARCHIVE_MANIFEST_LEN_OVERFLOW, ArchiveLimits, enforce_entry_count_cap,
enforce_manifest_len_cap, enforce_per_entry_caps, enforce_total_bytes_cap,
entry_count_cap_error, manifest_len_cap_error,
};
use super::model::{ArchiveEntry, ArchiveEntryKind, Manifest};
use super::path::{canonical_path_order, validate_fca_path};
use super::platform;
use super::tree::validate_manifest_tree;
#[cfg(not(any(unix, windows)))]
compile_error!("The FCA archive writer supports only Unix and Windows targets");
#[cfg(not(unix))]
const DEFAULT_FILE_MODE: u32 = 0o644;
#[cfg(not(unix))]
const DEFAULT_DIR_MODE: u32 = 0o755;
#[cfg(unix)]
fn metadata_perm_mode(metadata: &fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & PERMISSION_BITS_MASK
}
#[cfg(unix)]
fn cap_metadata_perm_mode(metadata: &cap_std::fs::Metadata) -> u32 {
use cap_std::fs::PermissionsExt;
metadata.permissions().mode() & PERMISSION_BITS_MASK
}
#[cfg(unix)]
fn archive_file_mode(metadata: &fs::Metadata) -> u32 {
metadata_perm_mode(metadata)
}
#[cfg(not(unix))]
fn archive_file_mode(_metadata: &fs::Metadata) -> u32 {
DEFAULT_FILE_MODE
}
#[cfg(unix)]
fn archive_file_mode_cap(metadata: &cap_std::fs::Metadata) -> u32 {
cap_metadata_perm_mode(metadata)
}
#[cfg(not(unix))]
fn archive_file_mode_cap(_metadata: &cap_std::fs::Metadata) -> u32 {
DEFAULT_FILE_MODE
}
#[cfg(unix)]
fn archive_dir_mode_cap(metadata: &cap_std::fs::Metadata) -> u32 {
cap_metadata_perm_mode(metadata)
}
#[cfg(not(unix))]
fn archive_dir_mode_cap(_metadata: &cap_std::fs::Metadata) -> u32 {
DEFAULT_DIR_MODE
}
#[cfg(windows)]
fn reject_windows_reparse_point(
metadata: &fs::Metadata,
label: &str,
path: &Path,
) -> Result<(), CryptoError> {
use std::os::windows::fs::MetadataExt;
if metadata.file_attributes() & platform::FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(CryptoError::InvalidInput(format!(
"{label} is a Windows reparse point: {}",
path.display()
)));
}
Ok(())
}
#[cfg(not(windows))]
fn reject_windows_reparse_point(
_metadata: &fs::Metadata,
_label: &str,
_path: &Path,
) -> Result<(), CryptoError> {
Ok(())
}
#[cfg(windows)]
fn reject_windows_reparse_point_cap(
metadata: &cap_std::fs::Metadata,
label: &str,
name: &OsStr,
) -> Result<(), CryptoError> {
use cap_std::fs::MetadataExt;
if metadata.file_attributes() & platform::FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(CryptoError::InvalidInput(format!(
"{label} is a Windows reparse point: {}",
sanitize_for_display(&name.to_string_lossy())
)));
}
Ok(())
}
#[cfg(not(windows))]
fn reject_windows_reparse_point_cap(
_metadata: &cap_std::fs::Metadata,
_label: &str,
_name: &OsStr,
) -> Result<(), CryptoError> {
Ok(())
}
#[cfg(unix)]
fn open_no_follow(path: &Path) -> Result<File, CryptoError> {
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.map_err(|e| {
if e.raw_os_error() == Some(libc::ELOOP) {
input_is_symlink_error(path)
} else {
CryptoError::Io(e)
}
})
}
#[cfg(windows)]
fn open_no_follow(path: &Path) -> Result<File, CryptoError> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let metadata = fs::symlink_metadata(path)?;
reject_windows_reparse_point(&metadata, "Input", path)?;
require_regular_file(&metadata, "Input", path)?;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let opened_metadata = file.metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point(&opened_metadata, "Input", path)?;
require_regular_file(&opened_metadata, "Input", path)?;
Ok(file)
}
fn require_regular_file(
metadata: &fs::Metadata,
label: &str,
path: &Path,
) -> Result<(), CryptoError> {
if !metadata.file_type().is_file() {
return Err(CryptoError::InvalidInput(format!(
"{label} is no longer a regular file: {}",
path.display()
)));
}
Ok(())
}
pub(crate) fn validate_encrypt_input(input_path: &Path) -> Result<(), CryptoError> {
let metadata = match fs::symlink_metadata(input_path) {
Ok(metadata) => metadata,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(CryptoError::InputPath),
Err(e) => return Err(CryptoError::Io(e)),
};
reject_windows_reparse_point(&metadata, "Input", input_path)?;
if metadata.file_type().is_symlink() {
return Err(input_is_symlink_error(input_path));
}
let file_type = metadata.file_type();
if !file_type.is_file() && !file_type.is_dir() {
return Err(CryptoError::InvalidInput(format!(
"Unsupported file type: {}",
input_path.display()
)));
}
Ok(())
}
#[derive(Debug, Default)]
struct ArchiveCounters {
entry_count: u32,
total_bytes: u64,
manifest_len: u64,
#[cfg(unix)]
seen_dirs: std::collections::HashSet<(u64, u64)>,
}
fn writer_entry(
kind: ArchiveEntryKind,
path_utf8: String,
mode: u32,
size: u64,
source_path: PathBuf,
) -> ArchiveEntry {
ArchiveEntry {
kind,
path_utf8,
mode,
size,
source_path: Some(source_path),
entry_ext: Vec::new(),
}
}
fn record_entry(
counters: &mut ArchiveCounters,
fca_path_utf8: &str,
file_size: Option<u64>,
limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
counters.entry_count = counters
.entry_count
.checked_add(1)
.ok_or_else(|| CryptoError::InvalidInput("Archive entry-count overflow".to_string()))?;
enforce_per_entry_caps(counters.entry_count, fca_path_utf8, limits)?;
counters.manifest_len = checked_entry_wire_len(fca_path_utf8.len(), 0)
.and_then(|entry_len| counters.manifest_len.checked_add(entry_len as u64))
.ok_or(CryptoError::MalformedArchive {
reason: ARCHIVE_MANIFEST_LEN_OVERFLOW,
})?;
enforce_manifest_len_cap(counters.manifest_len, limits)?;
if let Some(size) = file_size {
enforce_total_bytes_cap(size, &mut counters.total_bytes, limits)?;
}
validate_fca_path(fca_path_utf8, *limits)?;
Ok(())
}
fn input_is_symlink_error(path: &Path) -> CryptoError {
CryptoError::InvalidInput(format!("Input is a symlink: {}", path.display()))
}
fn symlink_in_archive_source_error(fca_prefix: &str, name: &OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"{}: {}",
platform::SYMLINK_IN_ARCHIVE_SOURCE,
sanitize_for_display(&format!("{fca_prefix}/{}", name.to_string_lossy()))
))
}
fn size_changed_error(expected: u64, observed: u64, path_text: &str) -> CryptoError {
CryptoError::InvalidInput(format!(
"Source file size changed during archive ({expected} → {observed}): {path_text}"
))
}
enum ArchiveSource {
RootFile(File),
RootDir(Dir),
}
fn build_manifest(
input_path: &Path,
limits: &ArchiveLimits,
) -> Result<(Manifest, ArchiveSource), CryptoError> {
let metadata = fs::symlink_metadata(input_path)?;
reject_windows_reparse_point(&metadata, "Input", input_path)?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(input_is_symlink_error(input_path));
}
let name = input_path
.file_name()
.ok_or_else(|| CryptoError::InvalidInput("Cannot get input file name".to_string()))?;
let name_str = name
.to_str()
.ok_or_else(|| {
CryptoError::InvalidInput(format!(
"Input name is not valid UTF-8: {}",
input_path.display()
))
})?
.to_string();
validate_fca_path(&name_str, *limits)?;
if file_type.is_file() {
let source = open_no_follow(input_path)?;
let handle_meta = source.metadata().map_err(CryptoError::Io)?;
require_regular_file(&handle_meta, "Input", input_path)?;
let mode = archive_file_mode(&handle_meta);
let size = handle_meta.len();
let mut total_bytes = 0u64;
enforce_total_bytes_cap(size, &mut total_bytes, limits)?;
let entry = writer_entry(
ArchiveEntryKind::File,
name_str.clone(),
mode,
size,
input_path.to_path_buf(),
);
let manifest = Manifest {
entries: vec![entry],
total_file_bytes: size,
root_name: OsString::from(&name_str),
root_is_file: true,
root_mode: mode,
};
Ok((manifest, ArchiveSource::RootFile(source)))
} else if file_type.is_dir() {
#[cfg(unix)]
let source_root = platform::open_anchor(input_path)?;
#[cfg(windows)]
let source_root = {
let parent_anchor = platform::open_anchor(parent_or_cwd(input_path))?;
platform::open_child_dir_nofollow(
&parent_anchor,
name,
platform::SYMLINK_IN_ARCHIVE_SOURCE,
)?
};
let source_root_meta = source_root.dir_metadata().map_err(CryptoError::Io)?;
#[cfg(unix)]
{
use cap_std::fs::MetadataExt as _;
use std::os::unix::fs::MetadataExt as _;
if (source_root_meta.dev(), source_root_meta.ino()) != (metadata.dev(), metadata.ino())
{
return Err(CryptoError::InvalidInput(format!(
"Input directory changed during archive: {}",
input_path.display()
)));
}
}
let root_mode = archive_dir_mode_cap(&source_root_meta);
let mut counters = ArchiveCounters::default();
record_entry(&mut counters, &name_str, None, limits)?;
let mut entries = vec![writer_entry(
ArchiveEntryKind::Directory,
name_str.clone(),
root_mode,
0,
PathBuf::new(),
)];
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
counters
.seen_dirs
.insert((source_root_meta.dev(), source_root_meta.ino()));
}
walk_directory(
&source_root,
&name_str,
Path::new(""),
&mut entries,
&mut counters,
limits,
)?;
sort_entries_canonically(&mut entries);
let manifest = Manifest {
entries,
total_file_bytes: counters.total_bytes,
root_name: OsString::from(&name_str),
root_is_file: false,
root_mode,
};
Ok((manifest, ArchiveSource::RootDir(source_root)))
} else {
Err(CryptoError::InvalidInput(format!(
"Unsupported file type: {}",
input_path.display()
)))
}
}
struct PendingDir {
parent: Rc<Dir>,
name: OsString,
fca_path: String,
rel_path: PathBuf,
}
fn walk_directory(
parent_dir: &Dir,
fca_prefix: &str,
rel_prefix: &Path,
entries: &mut Vec<ArchiveEntry>,
counters: &mut ArchiveCounters,
limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
let root = Rc::new(parent_dir.try_clone().map_err(CryptoError::Io)?);
let mut stack: Vec<PendingDir> = Vec::new();
scan_directory(
&root, fca_prefix, rel_prefix, &mut stack, entries, counters, limits,
)?;
drop(root);
while let Some(PendingDir {
parent,
name,
fca_path,
rel_path,
}) = stack.pop()
{
let child_dir = parent.open_dir_nofollow(&name).map_err(|e| {
platform::classify_open_failure(
&parent,
&name,
e,
platform::SYMLINK_IN_ARCHIVE_SOURCE,
&fca_path,
)
})?;
drop(parent);
let child_meta = child_dir.dir_metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point_cap(&child_meta, "Source directory", &name)?;
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
let key = (child_meta.dev(), child_meta.ino());
if !counters.seen_dirs.insert(key) {
return Err(CryptoError::InvalidInput(format!(
"Directory cycle in archive source: {}",
sanitize_for_display(&fca_path)
)));
}
}
let mode = archive_dir_mode_cap(&child_meta);
entries.push(writer_entry(
ArchiveEntryKind::Directory,
fca_path.clone(),
mode,
0,
rel_path.clone(),
));
let child = Rc::new(child_dir);
scan_directory(
&child, &fca_path, &rel_path, &mut stack, entries, counters, limits,
)?;
}
Ok(())
}
fn scan_directory(
dir: &Rc<Dir>,
fca_prefix: &str,
rel_prefix: &Path,
stack: &mut Vec<PendingDir>,
entries: &mut Vec<ArchiveEntry>,
counters: &mut ArchiveCounters,
limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
for read_dir_entry in dir.entries().map_err(CryptoError::Io)? {
let dir_entry = read_dir_entry.map_err(CryptoError::Io)?;
let metadata = dir_entry.metadata().map_err(CryptoError::Io)?;
let file_type = metadata.file_type();
let name_os = dir_entry.file_name();
reject_windows_reparse_point_cap(&metadata, "Source entry", &name_os)?;
if file_type.is_symlink() {
return Err(symlink_in_archive_source_error(fca_prefix, &name_os));
}
let name_str = name_os.to_str().ok_or_else(|| {
CryptoError::InvalidInput(format!(
"Source filename is not valid UTF-8: {}",
sanitize_for_display(&format!("{fca_prefix}/{}", name_os.to_string_lossy()))
))
})?;
if name_str.bytes().any(|b| b == b'/' || b == b'\\') {
return Err(CryptoError::InvalidInput(format!(
"Source filename contains path separator: {}",
sanitize_for_display(&format!("{fca_prefix}/{name_str}"))
)));
}
let fca_path_utf8 = format!("{fca_prefix}/{name_str}");
let mut child_rel = rel_prefix.to_path_buf();
child_rel.push(&name_os);
if file_type.is_file() {
let mode = archive_file_mode_cap(&metadata);
let size = metadata.len();
record_entry(counters, &fca_path_utf8, Some(size), limits)?;
entries.push(writer_entry(
ArchiveEntryKind::File,
fca_path_utf8,
mode,
size,
child_rel,
));
} else if file_type.is_dir() {
record_entry(counters, &fca_path_utf8, None, limits)?;
stack.push(PendingDir {
parent: Rc::clone(dir),
name: name_os,
fca_path: fca_path_utf8,
rel_path: child_rel,
});
} else {
return Err(CryptoError::InvalidInput(format!(
"Unsupported file type in archive: {}",
sanitize_for_display(&fca_path_utf8)
)));
}
}
Ok(())
}
fn sort_entries_canonically(entries: &mut [ArchiveEntry]) {
entries.sort_by(|a, b| canonical_path_order(&a.path_utf8, &b.path_utf8));
}
fn stream_source_file<W: Write>(
entry: &ArchiveEntry,
source: &ArchiveSource,
writer: &mut W,
) -> Result<(), CryptoError> {
let source_path = entry
.source_path
.as_ref()
.ok_or(CryptoError::InternalInvariant(
"Manifest entry missing source_path during content streaming",
))?;
match source {
ArchiveSource::RootFile(file) => stream_single_file_root(entry, file, source_path, writer),
ArchiveSource::RootDir(root) => {
stream_directory_descendant(root, entry, source_path, writer)
}
}
}
fn stream_single_file_root<W: Write>(
entry: &ArchiveEntry,
file: &File,
source: &Path,
writer: &mut W,
) -> Result<(), CryptoError> {
let metadata = file.metadata().map_err(CryptoError::Io)?;
require_regular_file(&metadata, "Source", source)?;
if metadata.len() != entry.size {
return Err(size_changed_error(
entry.size,
metadata.len(),
&source.display().to_string(),
));
}
let mut reader = file;
copy_exact_n(&mut reader, writer, entry.size, source_shrank_error)
}
fn source_shrank_error() -> CryptoError {
CryptoError::InvalidInput("Source file shrank during archiving".to_string())
}
fn stream_directory_descendant<W: Write>(
source_root: &Dir,
entry: &ArchiveEntry,
rel: &Path,
writer: &mut W,
) -> Result<(), CryptoError> {
let (parent, file_name) = platform::walk_to_parent_readonly(source_root, rel)?;
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsSyncExt;
options.nonblock(true);
}
let mut file = parent
.open_with(&file_name, &options)
.map_err(CryptoError::Io)?;
let metadata = file.metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point_cap(&metadata, "Source", &file_name)?;
if !metadata.is_file() {
return Err(CryptoError::InvalidInput(format!(
"Source is no longer a regular file: {}",
sanitize_for_display(&file_name.to_string_lossy())
)));
}
if metadata.len() != entry.size {
return Err(size_changed_error(
entry.size,
metadata.len(),
&sanitize_for_display(&file_name.to_string_lossy()),
));
}
copy_exact_n(&mut file, writer, entry.size, source_shrank_error)
}
pub(crate) fn archive<W: Write>(
input_path: impl AsRef<Path>,
mut writer: W,
limits: ArchiveLimits,
) -> Result<W, CryptoError> {
let input_path = input_path.as_ref();
let limits = limits.validate()?;
validate_encrypt_input(input_path)?;
let (manifest, source) = build_manifest(input_path, &limits)?;
let _ = validate_manifest_tree(&manifest.entries, manifest.total_file_bytes, limits)?;
let manifest_bytes = serialize_manifest(&manifest, limits)?;
let entry_count = u32::try_from(manifest.entries.len())
.map_err(|_| entry_count_cap_error(u32::MAX, limits.max_entry_count))?;
enforce_entry_count_cap(entry_count, &limits)?;
let manifest_len = u32::try_from(manifest_bytes.len()).map_err(|_| {
manifest_len_cap_error(manifest_bytes.len() as u64, limits.max_manifest_bytes)
})?;
enforce_manifest_len_cap(u64::from(manifest_len), &limits)?;
writer = write_fca_header(
writer,
entry_count,
0,
manifest_len,
manifest.total_file_bytes,
)?;
writer.write_all(&manifest_bytes).map_err(CryptoError::Io)?;
for entry in &manifest.entries {
if entry.kind == ArchiveEntryKind::File {
stream_source_file(entry, &source, &mut writer)?;
}
}
Ok(writer)
}
#[cfg(test)]
mod tests {
use super::super::IncompleteOutputPolicy;
use super::super::decode::unarchive;
use super::super::model::make_entry;
use super::*;
use std::io::Cursor;
#[cfg(windows)]
use std::path::Path;
use std::path::PathBuf;
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn walk_directory_handles_wide_tree_under_low_fd_limit() {
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("wide");
fs::create_dir(&root).unwrap();
for i in 0..200 {
fs::create_dir(root.join(format!("d{i:03}"))).unwrap();
}
struct RestoreNofile(Rlimit);
impl Drop for RestoreNofile {
fn drop(&mut self) {
let _ = rustix::process::setrlimit(rustix::process::Resource::Nofile, self.0);
}
}
let saved = getrlimit(Resource::Nofile);
let _restore = RestoreNofile(saved);
setrlimit(
Resource::Nofile,
Rlimit {
current: Some(64),
maximum: saved.maximum,
},
)
.expect("lower the NOFILE soft limit");
let (manifest, _source) =
build_manifest(&root, &ArchiveLimits::default()).expect("wide tree must walk");
assert_eq!(manifest.entries.len(), 201, "root + 200 subdirectories");
}
fn round_trip(src_root: &Path, out_root: &Path) -> PathBuf {
let mut buf = Vec::new();
let _ = archive(src_root, &mut buf, ArchiveLimits::default()).unwrap();
unarchive(
Cursor::new(buf),
out_root,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.unwrap()
}
#[test]
fn round_trip_single_file() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"Hello, world!").unwrap();
let final_path = round_trip(&src_file, out.path());
assert_eq!(final_path, out.path().join("hello.txt"));
assert_eq!(fs::read(&final_path).unwrap(), b"Hello, world!");
}
#[test]
fn round_trip_empty_file() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("empty.bin");
fs::write(&src_file, b"").unwrap();
let final_path = round_trip(&src_file, out.path());
assert_eq!(fs::read(&final_path).unwrap(), b"");
}
#[test]
fn round_trip_directory_tree() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("photos");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("index.txt"), b"hello").unwrap();
fs::write(dir.join("cover.jpg"), b"jpegjpe").unwrap();
fs::create_dir(dir.join("raw")).unwrap();
fs::write(dir.join("raw").join("a.dng"), b"raw_data").unwrap();
let final_path = round_trip(&dir, out.path());
assert!(final_path.is_dir());
assert_eq!(fs::read(final_path.join("index.txt")).unwrap(), b"hello");
assert_eq!(fs::read(final_path.join("cover.jpg")).unwrap(), b"jpegjpe");
assert_eq!(
fs::read(final_path.join("raw").join("a.dng")).unwrap(),
b"raw_data",
);
}
#[test]
fn round_trip_empty_directory() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("emptydir");
fs::create_dir(&dir).unwrap();
let final_path = round_trip(&dir, out.path());
assert!(final_path.is_dir());
assert_eq!(fs::read_dir(&final_path).unwrap().count(), 0);
}
#[test]
fn archive_output_is_deterministic() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("z.txt"), b"zzz").unwrap();
fs::write(dir.join("a.txt"), b"aaa").unwrap();
fs::write(dir.join("m.txt"), b"mmm").unwrap();
let mut buf1 = Vec::new();
let _ = archive(&dir, &mut buf1, ArchiveLimits::default()).unwrap();
let mut buf2 = Vec::new();
let _ = archive(&dir, &mut buf2, ArchiveLimits::default()).unwrap();
assert_eq!(buf1, buf2);
}
#[cfg(unix)]
#[test]
fn rejects_root_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("real.txt");
fs::write(&target, b"data").unwrap();
let link = tmp.path().join("link.txt");
symlink(&target, &link).unwrap();
let mut buf = Vec::new();
let err = archive(&link, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("Input is a symlink"));
}
#[cfg(unix)]
#[test]
fn rejects_dangling_symlink() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let link = tmp.path().join("dangling");
symlink(tmp.path().join("absent-target"), &link).unwrap();
let mut buf = Vec::new();
let err = archive(&link, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("symlink"));
}
#[cfg(unix)]
#[test]
fn rejects_symlink_inside_directory_tree() {
use std::os::unix::fs::symlink;
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("real.txt"), b"data").unwrap();
symlink("real.txt", dir.join("link.txt")).unwrap();
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("Symlink in archive source"));
}
#[cfg(windows)]
fn try_make_junction(target: &Path, junction: &Path) -> std::io::Result<()> {
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(junction)
.arg(target)
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"mklink /J failed with exit code {status}"
)))
}
}
#[cfg(windows)]
#[test]
fn rejects_root_windows_junction() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("target");
fs::create_dir_all(&target).unwrap();
let junction = tmp.path().join("junction");
try_make_junction(&target, &junction).unwrap();
let mut buf = Vec::new();
let err = archive(&junction, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("Windows reparse point"));
}
#[cfg(windows)]
#[test]
fn rejects_windows_junction_inside_directory_tree() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("d");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::create_dir_all(&target).unwrap();
let junction = dir.join("junction");
try_make_junction(&target, &junction).unwrap();
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("Windows reparse point"));
}
#[test]
fn rejects_missing_input() {
let tmp = tempfile::TempDir::new().unwrap();
let absent = tmp.path().join("does-not-exist");
let mut buf = Vec::new();
let err = archive(&absent, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(matches!(err, CryptoError::InputPath));
}
#[test]
fn rejects_tree_above_entry_count_cap() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
for i in 0..5 {
fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
}
let limits = ArchiveLimits::default().with_max_entry_count(3);
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, limits).unwrap_err();
assert!(matches!(
err,
CryptoError::ArchiveEntryCountCapExceeded { .. }
));
assert!(buf.is_empty(), "writer must not emit bytes when caps fail");
}
#[test]
fn rejects_tree_above_total_bytes_cap() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("big.bin"), vec![0u8; 1000]).unwrap();
let limits = ArchiveLimits::default().with_max_total_plaintext_bytes(100);
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, limits).unwrap_err();
assert!(matches!(
err,
CryptoError::ArchiveTotalBytesCapExceeded { .. }
));
}
#[cfg(unix)]
#[test]
fn rejects_over_long_component_in_source() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("n".repeat(250)), b"x").unwrap();
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::UnsafeArchivePath {
reason: crate::archive::path::COMPONENT_TOO_LONG,
..
}
));
assert!(buf.is_empty(), "writer must not emit bytes when caps fail");
}
#[cfg(unix)]
#[test]
fn round_trip_component_at_byte_cap() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let name = "n".repeat(244);
let src_file = src.path().join(&name);
fs::write(&src_file, b"payload").unwrap();
let final_path = round_trip(&src_file, out.path());
assert_eq!(final_path, out.path().join(&name));
assert_eq!(fs::read(&final_path).unwrap(), b"payload");
}
#[cfg(unix)]
#[test]
fn rejects_windows_reserved_device_name_in_source() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("CON"), b"x").unwrap();
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::UnsafeArchivePath {
reason: "Windows-reserved device name",
..
}
));
}
#[test]
fn round_trip_binary_file() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let content: Vec<u8> = (0..=255u8).cycle().take(1024).collect();
let src_file = src.path().join("binary.bin");
fs::write(&src_file, &content).unwrap();
let final_path = round_trip(&src_file, out.path());
assert_eq!(fs::read(&final_path).unwrap(), content);
}
#[test]
fn round_trip_nested_empty_directories() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("root");
fs::create_dir(&dir).unwrap();
fs::create_dir(dir.join("a")).unwrap();
fs::create_dir(dir.join("a").join("b")).unwrap();
fs::create_dir(dir.join("a").join("b").join("c")).unwrap();
let final_path = round_trip(&dir, out.path());
assert!(final_path.is_dir());
assert!(final_path.join("a").is_dir());
assert!(final_path.join("a").join("b").is_dir());
assert!(final_path.join("a").join("b").join("c").is_dir());
}
#[cfg(unix)]
#[test]
fn round_trip_preserves_file_mode() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"x").unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o600)).unwrap();
let final_path = round_trip(&src_file, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o600, "file mode lost in round trip");
}
#[cfg(unix)]
#[test]
fn round_trip_preserves_directory_mode() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("root");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
let final_path = round_trip(&dir, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o700, "directory mode lost in round trip");
}
#[cfg(unix)]
#[test]
fn round_trip_strips_setuid_bit_from_source() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"x").unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o4644)).unwrap();
let final_path = round_trip(&src_file, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o644, "setuid bit must be stripped, got 0o{mode:o}",);
}
#[cfg(unix)]
#[test]
fn round_trip_file_mode_0o400() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"x").unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o400)).unwrap();
let final_path = round_trip(&src_file, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o400);
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o600)).unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(unix)]
#[test]
fn round_trip_file_mode_0o755() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"x").unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o755)).unwrap();
let final_path = round_trip(&src_file, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o755);
}
#[cfg(unix)]
#[test]
fn round_trip_file_mode_0o777() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let src_file = src.path().join("hello.txt");
fs::write(&src_file, b"x").unwrap();
fs::set_permissions(&src_file, fs::Permissions::from_mode(0o777)).unwrap();
let final_path = round_trip(&src_file, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o777);
}
#[cfg(unix)]
#[test]
fn round_trip_directory_mode_0o500() {
use std::os::unix::fs::PermissionsExt;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("locked");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("file"), b"x").unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).unwrap();
let final_path = round_trip(&dir, out.path());
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o500);
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn round_trip_very_wide_directory() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("wide");
fs::create_dir(&dir).unwrap();
for i in 0..1000 {
fs::write(dir.join(format!("f{i:04}.bin")), b"x").unwrap();
}
let final_path = round_trip(&dir, out.path());
assert!(final_path.is_dir());
assert_eq!(fs::read_dir(&final_path).unwrap().count(), 1000);
}
#[test]
fn round_trip_depth_at_cap() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let cap = ArchiveLimits::default().max_path_depth as usize;
assert!(
cap >= 2,
"round_trip_depth_at_cap requires max_path_depth >= 2 (got {cap})"
);
let intermediate_dirs = cap - 2;
let root = src.path().join("root");
let mut p = root.clone();
for _ in 0..intermediate_dirs {
p = p.join("a");
}
fs::create_dir_all(&p).unwrap();
let leaf = p.join("leaf.txt");
fs::write(&leaf, b"deep").unwrap();
let final_path = round_trip(&root, out.path());
let mut q = final_path.clone();
for _ in 0..intermediate_dirs {
q = q.join("a");
}
assert_eq!(fs::read(q.join("leaf.txt")).unwrap(), b"deep");
}
#[test]
fn deep_source_tree_does_not_stack_overflow() {
use std::thread;
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let depth: usize = 400;
let root = src.path().join("root");
let mut p = root.clone();
for _ in 0..depth {
p = p.join("a");
}
fs::create_dir_all(&p).unwrap();
fs::write(p.join("leaf.txt"), b"deep").unwrap();
let limits = ArchiveLimits::default()
.with_max_path_depth((depth + 8) as u32)
.with_max_path_bytes(((depth * 2) + 64) as u32);
let src_root = root;
let out_root = out.path().to_path_buf();
let handle = thread::Builder::new()
.stack_size(128 * 1024)
.spawn(move || {
let mut buf = Vec::new();
archive(&src_root, &mut buf, limits).expect("archive deep tree");
unarchive(
Cursor::new(buf),
&out_root,
limits,
IncompleteOutputPolicy::DeleteOnError,
)
.expect("unarchive deep tree")
})
.expect("spawn small-stack thread");
let final_path = handle.join().expect("small-stack worker panicked");
let mut q = final_path;
for _ in 0..depth {
q = q.join("a");
}
assert_eq!(fs::read(q.join("leaf.txt")).unwrap(), b"deep");
}
#[test]
fn round_trip_many_empty_directories() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let root = src.path().join("dirs");
fs::create_dir(&root).unwrap();
for i in 0..1000 {
fs::create_dir(root.join(format!("d{i:04}"))).unwrap();
}
let final_path = round_trip(&root, out.path());
assert_eq!(fs::read_dir(&final_path).unwrap().count(), 1000);
}
#[test]
fn round_trip_many_zero_byte_files() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let root = src.path().join("zeros");
fs::create_dir(&root).unwrap();
for i in 0..1000 {
fs::write(root.join(format!("f{i:04}.empty")), b"").unwrap();
}
let final_path = round_trip(&root, out.path());
assert_eq!(fs::read_dir(&final_path).unwrap().count(), 1000);
}
#[cfg(unix)]
#[test]
fn round_trip_hardlinked_files_stored_as_independent() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("original.txt"), b"shared content").unwrap();
fs::hard_link(dir.join("original.txt"), dir.join("link.txt")).unwrap();
let final_path = round_trip(&dir, out.path());
assert_eq!(
fs::read(final_path.join("original.txt")).unwrap(),
b"shared content"
);
assert_eq!(
fs::read(final_path.join("link.txt")).unwrap(),
b"shared content"
);
fs::write(final_path.join("original.txt"), b"modified").unwrap();
assert_eq!(
fs::read(final_path.join("link.txt")).unwrap(),
b"shared content"
);
}
#[cfg(unix)]
#[test]
fn rejects_root_symlink_to_directory() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("real_dir");
fs::create_dir(&target).unwrap();
fs::write(target.join("inside.txt"), b"data").unwrap();
let link = tmp.path().join("link_dir");
symlink(&target, &link).unwrap();
let mut buf = Vec::new();
let err = archive(&link, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(format!("{err}").contains("Input is a symlink"));
}
#[cfg(target_os = "linux")]
#[test]
fn rejects_non_utf8_source_filename() {
use std::os::unix::ffi::OsStrExt;
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
let invalid_name = std::ffi::OsString::from(OsStr::from_bytes(b"bad\xFF"));
let bad_path = dir.join(&invalid_name);
fs::write(&bad_path, b"data").unwrap();
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(
format!("{err}").contains("not valid UTF-8"),
"expected UTF-8 rejection, got: {err}",
);
}
#[test]
fn stream_source_file_rejects_size_mismatch() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("real.txt");
fs::write(&path, b"actual content").unwrap();
let mut entry = make_entry("real.txt", ArchiveEntryKind::File, 9999, 0o644);
entry.source_path = Some(path.clone());
let source = ArchiveSource::RootFile(open_no_follow(&path).unwrap());
let mut buf = Vec::new();
let err = stream_source_file(&entry, &source, &mut buf).unwrap_err();
assert!(format!("{err}").contains("size changed"));
}
#[cfg(unix)]
#[test]
fn stream_source_file_rejects_intermediate_symlink_substitution() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let src_root = tmp.path().join("source");
fs::create_dir_all(src_root.join("a").join("b")).unwrap();
fs::write(src_root.join("a").join("b").join("file.txt"), b"trusted").unwrap();
let source = ArchiveSource::RootDir(platform::open_anchor(&src_root).unwrap());
let mut entry = make_entry("source/a/b/file.txt", ArchiveEntryKind::File, 7, 0o644);
entry.source_path = Some(PathBuf::from("a/b/file.txt"));
let attacker = tmp.path().join("attacker");
fs::create_dir(&attacker).unwrap();
fs::write(attacker.join("file.txt"), b"hostile").unwrap();
fs::remove_dir_all(src_root.join("a").join("b")).unwrap();
symlink(&attacker, src_root.join("a").join("b")).unwrap();
let mut buf = Vec::new();
let err = stream_source_file(&entry, &source, &mut buf).unwrap_err();
assert!(
format!("{err}").contains(platform::SYMLINK_IN_ARCHIVE_SOURCE),
"expected `{}` rejection, got: {err}",
platform::SYMLINK_IN_ARCHIVE_SOURCE,
);
}
#[cfg(unix)]
fn make_fifo(path: &Path) {
let status = std::process::Command::new("mkfifo")
.arg(path)
.status()
.expect("spawn mkfifo");
assert!(status.success(), "mkfifo failed for {}", path.display());
}
#[cfg(unix)]
#[test]
fn held_handle_ignores_fifo_swapped_for_file_root() {
let tmp = tempfile::TempDir::new().unwrap();
let file = tmp.path().join("data.bin");
fs::write(&file, b"trusted").unwrap();
let source = ArchiveSource::RootFile(open_no_follow(&file).unwrap());
let mut entry = make_entry("data.bin", ArchiveEntryKind::File, 7, 0o644);
entry.source_path = Some(file.clone());
fs::remove_file(&file).unwrap();
make_fifo(&file);
let mut buf = Vec::new();
stream_source_file(&entry, &source, &mut buf).unwrap();
assert_eq!(buf, b"trusted");
}
#[cfg(unix)]
#[test]
fn held_handle_streams_original_after_ancestor_swap() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let src_root = tmp.path().join("source");
fs::create_dir_all(src_root.join("a").join("b")).unwrap();
let input = src_root.join("a").join("b").join("file.txt");
fs::write(&input, b"trusted").unwrap();
let source = ArchiveSource::RootFile(open_no_follow(&input).unwrap());
let mut entry = make_entry("file.txt", ArchiveEntryKind::File, 7, 0o644);
entry.source_path = Some(input.clone());
let attacker = tmp.path().join("attacker");
fs::create_dir(&attacker).unwrap();
fs::write(attacker.join("file.txt"), b"hostile").unwrap();
fs::remove_dir_all(src_root.join("a").join("b")).unwrap();
symlink(&attacker, src_root.join("a").join("b")).unwrap();
let mut buf = Vec::new();
stream_source_file(&entry, &source, &mut buf).unwrap();
assert_eq!(buf, b"trusted", "content must come from the held handle");
}
#[cfg(unix)]
#[test]
fn rejects_fifo_input_root() {
let tmp = tempfile::TempDir::new().unwrap();
let fifo = tmp.path().join("pipe.bin");
make_fifo(&fifo);
let mut buf = Vec::new();
let err = archive(&fifo, &mut buf, ArchiveLimits::default()).unwrap_err();
assert!(
format!("{err}").contains("Unsupported file type"),
"expected unsupported-type rejection, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn stream_source_file_rejects_fifo_swapped_for_descendant() {
let tmp = tempfile::TempDir::new().unwrap();
let src_root = tmp.path().join("source");
fs::create_dir_all(&src_root).unwrap();
fs::write(src_root.join("file.txt"), b"trusted").unwrap();
let source = ArchiveSource::RootDir(platform::open_anchor(&src_root).unwrap());
let mut entry = make_entry("source/file.txt", ArchiveEntryKind::File, 7, 0o644);
entry.source_path = Some(PathBuf::from("file.txt"));
fs::remove_file(src_root.join("file.txt")).unwrap();
make_fifo(&src_root.join("file.txt"));
let mut buf = Vec::new();
let err = stream_source_file(&entry, &source, &mut buf).unwrap_err();
assert!(
format!("{err}").contains("no longer a regular file"),
"expected regular-file rejection, got: {err}"
);
}
#[test]
fn record_entry_enforces_manifest_len_cap_during_walk() {
use crate::archive::format::FCA_ENTRY_FIXED_SIZE;
let one_entry = (FCA_ENTRY_FIXED_SIZE + 1) as u32;
let limits = ArchiveLimits::default().with_max_manifest_bytes(2 * one_entry);
let mut counters = ArchiveCounters::default();
record_entry(&mut counters, "a", None, &limits).unwrap();
record_entry(&mut counters, "b", None, &limits).unwrap();
let err = record_entry(&mut counters, "c", None, &limits).unwrap_err();
assert!(matches!(
err,
CryptoError::ArchiveManifestLenCapExceeded { .. }
));
}
#[test]
fn rejects_tree_above_manifest_len_cap() {
use crate::archive::format::FCA_ENTRY_FIXED_SIZE;
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
for i in 0..4 {
fs::write(dir.join(format!("f{i}.txt")), b"x").unwrap();
}
let root_entry = FCA_ENTRY_FIXED_SIZE + "d".len();
let child_entry = FCA_ENTRY_FIXED_SIZE + "d/f0.txt".len();
let cap = (root_entry + 2 * child_entry) as u32;
let limits = ArchiveLimits::default().with_max_manifest_bytes(cap);
let mut buf = Vec::new();
let err = archive(&dir, &mut buf, limits).unwrap_err();
assert!(matches!(
err,
CryptoError::ArchiveManifestLenCapExceeded { .. }
));
assert!(buf.is_empty(), "writer must not emit bytes when caps fail");
}
}