use std::ffi::{OsStr, OsString};
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
use cap_std::fs::{Dir, OpenOptions};
use crate::CryptoError;
use crate::fs::paths::file_stem;
#[cfg(unix)]
use super::format::PERMISSION_BITS_MASK;
use super::format::{copy_exact_n, serialize_manifest, write_fca_header};
use super::limits::{
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(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;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
if metadata.file_attributes() & 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: {}",
Path::new(name).display()
)));
}
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)
.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)
}
#[cfg(all(not(unix), not(windows)))]
fn open_no_follow(path: &Path) -> Result<File, CryptoError> {
let metadata = fs::symlink_metadata(path)?;
require_regular_file(&metadata, "Input", path)?;
Ok(File::open(path)?)
}
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,
#[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)?;
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!(
"{}: {fca_prefix}/{}",
platform::SYMLINK_IN_ARCHIVE_SOURCE,
Path::new(name).display()
))
}
fn size_changed_error(
expected: u64,
observed: u64,
display: std::path::Display<'_>,
) -> CryptoError {
CryptoError::InvalidInput(format!(
"Source file size changed during archive ({expected} → {observed}): {display}"
))
}
fn build_manifest(
input_path: &Path,
limits: &ArchiveLimits,
) -> Result<(Manifest, Option<Dir>), 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 mode = archive_file_mode(&metadata);
let size = metadata.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, None))
} else if file_type.is_dir() {
let source_root = platform::open_anchor(input_path)?;
let source_root_meta = source_root.dir_metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point_cap(&source_root_meta, "Input", name)?;
let root_mode = archive_dir_mode_cap(&source_root_meta);
let mut entries = vec![writer_entry(
ArchiveEntryKind::Directory,
name_str.clone(),
root_mode,
0,
PathBuf::new(),
)];
let mut counters = ArchiveCounters {
entry_count: 1,
total_bytes: 0,
#[cfg(unix)]
seen_dirs: std::collections::HashSet::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, Some(source_root)))
} else {
Err(CryptoError::InvalidInput(format!(
"Unsupported file type: {}",
input_path.display()
)))
}
}
fn walk_directory(
parent_dir: &Dir,
fca_prefix: &str,
rel_prefix: &Path,
entries: &mut Vec<ArchiveEntry>,
counters: &mut ArchiveCounters,
limits: &ArchiveLimits,
) -> Result<(), CryptoError> {
struct PendingDir {
dir: Dir,
fca_prefix: String,
rel_prefix: PathBuf,
}
let mut stack = vec![PendingDir {
dir: parent_dir.try_clone().map_err(CryptoError::Io)?,
fca_prefix: fca_prefix.to_owned(),
rel_prefix: rel_prefix.to_path_buf(),
}];
while let Some(PendingDir {
dir,
fca_prefix,
rel_prefix,
}) = stack.pop()
{
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: {fca_prefix}/{}",
Path::new(&name_os).display()
))
})?;
if name_str.bytes().any(|b| b == b'/' || b == b'\\') {
return Err(CryptoError::InvalidInput(format!(
"Source filename contains path separator: {fca_prefix}/{name_str}"
)));
}
let fca_path_utf8 = format!("{fca_prefix}/{name_str}");
let mut child_rel = rel_prefix.clone();
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() {
let child_dir = dir.open_dir_nofollow(&name_os).map_err(|e| {
platform::classify_open_failure(
&dir,
&name_os,
e,
platform::SYMLINK_IN_ARCHIVE_SOURCE,
&fca_path_utf8,
)
})?;
let child_meta = child_dir.dir_metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point_cap(&child_meta, "Source directory", &name_os)?;
#[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: {fca_path_utf8}"
)));
}
}
let mode = archive_dir_mode_cap(&child_meta);
record_entry(counters, &fca_path_utf8, None, limits)?;
entries.push(writer_entry(
ArchiveEntryKind::Directory,
fca_path_utf8.clone(),
mode,
0,
child_rel.clone(),
));
stack.push(PendingDir {
dir: child_dir,
fca_prefix: fca_path_utf8,
rel_prefix: child_rel,
});
} else {
return Err(CryptoError::InvalidInput(format!(
"Unsupported file type in archive: {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_root: Option<&Dir>,
writer: &mut W,
) -> Result<(), CryptoError> {
let source = entry
.source_path
.as_ref()
.ok_or(CryptoError::InternalInvariant(
"Manifest entry missing source_path during content streaming",
))?;
match source_root {
None => stream_single_file_root(entry, source, writer),
Some(root) => stream_directory_descendant(root, entry, source, writer),
}
}
fn stream_single_file_root<W: Write>(
entry: &ArchiveEntry,
source: &Path,
writer: &mut W,
) -> Result<(), CryptoError> {
let mut file = open_no_follow(source)?;
let metadata = file.metadata().map_err(CryptoError::Io)?;
reject_windows_reparse_point(&metadata, "Source", source)?;
require_regular_file(&metadata, "Source", source)?;
if metadata.len() != entry.size {
return Err(size_changed_error(
entry.size,
metadata.len(),
source.display(),
));
}
copy_exact_n(&mut file, writer, entry.size)
}
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);
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: {}",
Path::new(&file_name).display()
)));
}
if metadata.len() != entry.size {
return Err(size_changed_error(
entry.size,
metadata.len(),
Path::new(&file_name).display(),
));
}
copy_exact_n(&mut file, writer, entry.size)
}
pub(crate) fn archive<W: Write>(
input_path: impl AsRef<Path>,
mut writer: W,
limits: ArchiveLimits,
) -> Result<(String, W), CryptoError> {
let input_path = input_path.as_ref();
let limits = limits.validate()?;
validate_encrypt_input(input_path)?;
let (manifest, source_root) = 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_root.as_ref(), &mut writer)?;
}
}
let stem = output_stem(input_path)?;
Ok((stem, writer))
}
fn output_stem(input_path: &Path) -> Result<String, CryptoError> {
if input_path.is_dir() {
let name = input_path
.file_name()
.ok_or_else(|| CryptoError::InvalidInput("Cannot get directory name".to_string()))?;
Ok(name.to_string_lossy().into_owned())
} else {
Ok(file_stem(input_path)?.to_string_lossy().into_owned())
}
}
#[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;
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);
}
#[test]
fn returns_correct_output_stem() {
let src = tempfile::TempDir::new().unwrap();
let mut buf = Vec::new();
let file = src.path().join("hello.txt");
fs::write(&file, b"x").unwrap();
let (stem, _) = archive(&file, &mut buf, ArchiveLimits::default()).unwrap();
assert_eq!(stem, "hello");
buf.clear();
let dotfile = src.path().join("photos.v1");
fs::create_dir(&dotfile).unwrap();
let (stem, _) = archive(&dotfile, &mut buf, ArchiveLimits::default()).unwrap();
assert_eq!(stem, "photos.v1");
}
#[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!(format!("{err}").contains("entry-count cap exceeded"));
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!(format!("{err}").contains("total-bytes cap exceeded"));
}
#[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!(format!("{err}").contains("Windows-reserved device"));
}
#[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);
let mut buf = Vec::new();
let err = stream_source_file(&entry, None, &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_root = 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, Some(&source_root), &mut buf).unwrap_err();
assert!(
format!("{err}").contains(platform::SYMLINK_IN_ARCHIVE_SOURCE),
"expected `{}` rejection, got: {err}",
platform::SYMLINK_IN_ARCHIVE_SOURCE,
);
}
}