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, Metadata, OpenOptions};
use crate::CryptoError;
use crate::error::{sanitize_for_display, sanitize_path_for_display};
#[cfg(windows)]
use crate::fs::paths::parent_or_cwd;
use crate::fs::paths::{input_leaf_name, unsupported_file_type_error};
#[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::{
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::reasons::MANIFEST_LEN_OVERFLOW;
#[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: {}",
sanitize_path_for_display(path)
)));
}
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 no_longer_regular_file_error(label: &str, path_text: &str) -> CryptoError {
CryptoError::InvalidInput(format!("{label} is no longer a regular file: {path_text}"))
}
fn require_regular_file(
metadata: &fs::Metadata,
label: &str,
path: &Path,
) -> Result<(), CryptoError> {
if !metadata.file_type().is_file() {
return Err(no_longer_regular_file_error(
label,
&sanitize_path_for_display(path),
));
}
Ok(())
}
#[cfg(unix)]
fn require_same_file(
pre_open: &fs::Metadata,
opened: &fs::Metadata,
path: &Path,
) -> Result<(), CryptoError> {
use std::os::unix::fs::MetadataExt;
if (pre_open.dev(), pre_open.ino()) != (opened.dev(), opened.ino()) {
return Err(input_changed_error("file", path));
}
Ok(())
}
#[cfg(not(unix))]
fn require_same_file(
_pre_open: &fs::Metadata,
_opened: &fs::Metadata,
_path: &Path,
) -> Result<(), CryptoError> {
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(unsupported_file_type_error(input_path));
}
if file_type.is_dir() && !platform::DIRECTORY_PROMOTION_SUPPORTED {
return Err(CryptoError::InvalidInput(
"Encrypting a directory is not supported on this target".to_string(),
));
}
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,
source_id: Option<(u64, u64)>,
) -> ArchiveEntry {
ArchiveEntry {
kind,
path_utf8,
mode,
size,
source_path: Some(source_path),
source_id,
entry_ext: Vec::new(),
}
}
#[cfg(unix)]
fn source_identity(metadata: &Metadata) -> Option<(u64, u64)> {
crate::fs::atomic::file_identity(metadata)
}
#[cfg(not(unix))]
fn source_identity(_metadata: &Metadata) -> Option<(u64, u64)> {
None
}
#[cfg(unix)]
fn require_same_source_file(
recorded: Option<(u64, u64)>,
reopened: &Metadata,
name_text: &str,
) -> Result<(), CryptoError> {
let Some(recorded) = recorded else {
return Ok(());
};
if source_identity(reopened) != Some(recorded) {
return Err(source_changed_error(name_text));
}
Ok(())
}
#[cfg(not(unix))]
fn require_same_source_file(
_recorded: Option<(u64, u64)>,
_reopened: &Metadata,
_name_text: &str,
) -> Result<(), CryptoError> {
Ok(())
}
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: 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: {}",
sanitize_path_for_display(path)
))
}
#[cfg(unix)]
fn input_changed_error(kind: &str, path: &Path) -> CryptoError {
CryptoError::InvalidInput(format!(
"Input {kind} changed while archiving: {}",
sanitize_path_for_display(path)
))
}
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()))
))
}
#[cfg(unix)]
fn source_changed_error(path_text: &str) -> CryptoError {
CryptoError::InvalidInput(format!(
"Source file was replaced while archiving: {path_text}; encrypt again to archive its current content"
))
}
fn size_changed_error(expected: u64, observed: u64, path_text: &str) -> CryptoError {
CryptoError::InvalidInput(format!(
"Source file size changed while archiving ({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_str = input_leaf_name(input_path)?;
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)?;
require_same_file(&metadata, &handle_meta, 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(),
None,
);
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 = match input_path.file_name() {
Some(leaf) => {
let parent_anchor = platform::open_anchor(parent_or_cwd(input_path))?;
platform::open_child_dir_nofollow(
&parent_anchor,
leaf,
platform::SYMLINK_IN_ARCHIVE_SOURCE,
)?
}
None => platform::open_anchor(input_path)?,
};
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(input_changed_error("directory", input_path));
}
}
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(),
None,
)];
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
let (dev, ino) = (source_root_meta.dev(), source_root_meta.ino());
if ino != 0 {
counters.seen_dirs.insert((dev, 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(unsupported_file_type_error(input_path))
}
}
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 (dev, ino) = (child_meta.dev(), child_meta.ino());
if ino != 0 && !counters.seen_dirs.insert((dev, ino)) {
return Err(CryptoError::InvalidInput(format!(
"Directory reached twice 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(),
None,
));
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,
source_identity(&metadata),
));
} 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(crate::error::internal_invariant!(
"manifest entry missing source path"
))?;
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(),
&sanitize_path_for_display(source),
));
}
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 while 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(source_root, rel, platform::WalkSide::ArchiveSource)?;
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)?;
let name_text = sanitize_for_display(&file_name.to_string_lossy());
if !metadata.is_file() {
return Err(no_longer_regular_file_error("Source", &name_text));
}
require_same_source_file(entry.source_id, &metadata, &name_text)?;
if metadata.len() != entry.size {
return Err(size_changed_error(entry.size, metadata.len(), &name_text));
}
copy_exact_n(&mut file, writer, entry.size, source_shrank_error)
}
pub(crate) struct PreparedArchive {
manifest: Manifest,
manifest_bytes: Vec<u8>,
entry_count: u32,
manifest_len: u32,
source: ArchiveSource,
}
pub(crate) fn prepare_archive(
input_path: impl AsRef<Path>,
limits: ArchiveLimits,
) -> Result<PreparedArchive, CryptoError> {
let input_path = input_path.as_ref();
validate_encrypt_input(input_path)?;
let (manifest, source) = build_manifest(input_path, &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)?;
Ok(PreparedArchive {
manifest,
manifest_bytes,
entry_count,
manifest_len,
source,
})
}
impl PreparedArchive {
pub(crate) fn write_to<W: Write>(self, mut writer: W) -> Result<W, CryptoError> {
writer = write_fca_header(
writer,
self.entry_count,
0,
self.manifest_len,
self.manifest.total_file_bytes,
)?;
writer.write_all(&self.manifest_bytes)?;
for entry in &self.manifest.entries {
if entry.kind == ArchiveEntryKind::File {
stream_source_file(entry, &self.source, &mut writer)?;
}
}
Ok(writer)
}
}
#[cfg(any(test, feature = "unstable-fuzzing"))]
pub(crate) fn archive<W: Write>(
input_path: impl AsRef<Path>,
writer: W,
limits: ArchiveLimits,
) -> Result<W, CryptoError> {
prepare_archive(input_path, limits)?.write_to(writer)
}
#[cfg(test)]
mod tests {
use super::super::IncompleteOutputPolicy;
use super::super::decode::unarchive;
use super::super::model::make_entry;
#[cfg(unix)]
use super::super::path::FCA_COMPONENT_MAX_BYTES;
use super::*;
#[cfg(windows)]
use crate::archive::platform::try_make_junction;
#[cfg(unix)]
use crate::fs::paths::make_fifo;
use std::io::Cursor;
#[cfg(windows)]
use std::path::Path;
use std::path::PathBuf;
#[derive(Clone, Copy, Debug)]
enum Position {
RootFile,
RootDirectory,
Descendant,
}
#[derive(Clone, Copy, Debug)]
enum Mutation {
ReplacedBySameSizeFile,
ReplacedByLargerFile,
ReplacedByDirectory,
#[cfg(unix)]
ReplacedBySymlink,
#[cfg(unix)]
ReplacedByFifo,
Removed,
Grown,
Shrunk,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Expected {
OriginalContent,
ReplacementContent,
Refused,
}
const ORIGINAL: &[u8] = b"original-content";
const SAME_SIZE: &[u8] = b"replaced-content";
const LARGER: &[u8] = b"replaced-content-and-then-some";
const LEAF: &str = "leaf.bin";
const _: () = assert!(ORIGINAL.len() == SAME_SIZE.len());
const _: () = assert!(ORIGINAL.len() != LARGER.len());
impl Position {
fn all() -> [Position; 3] {
[
Position::RootFile,
Position::RootDirectory,
Position::Descendant,
]
}
}
impl Mutation {
fn all() -> Vec<Mutation> {
let everywhere = [
Mutation::ReplacedBySameSizeFile,
Mutation::ReplacedByLargerFile,
Mutation::ReplacedByDirectory,
Mutation::Removed,
Mutation::Grown,
Mutation::Shrunk,
];
#[cfg(unix)]
let unix_only = [Mutation::ReplacedBySymlink, Mutation::ReplacedByFifo];
#[cfg(not(unix))]
let unix_only: [Mutation; 0] = [];
everywhere.into_iter().chain(unix_only).collect()
}
fn applies_to(self, position: Position) -> bool {
!matches!(
(position, self),
(Position::RootDirectory, Mutation::Grown | Mutation::Shrunk)
)
}
}
fn expected(position: Position, mutation: Mutation) -> Expected {
match position {
Position::RootFile => match mutation {
Mutation::Grown | Mutation::Shrunk => Expected::Refused,
_ => Expected::OriginalContent,
},
Position::RootDirectory => Expected::OriginalContent,
#[cfg(unix)]
Position::Descendant => Expected::Refused,
#[cfg(not(unix))]
Position::Descendant => match mutation {
Mutation::ReplacedBySameSizeFile => Expected::ReplacementContent,
_ => Expected::Refused,
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Interference {
Reached,
RefusedByPlatform,
}
fn apply_mutation(target: &Path, mutation: Mutation) -> Interference {
match mutate(target, mutation) {
Some(()) => Interference::Reached,
None => Interference::RefusedByPlatform,
}
}
fn mutate(target: &Path, mutation: Mutation) -> Option<()> {
let aside = target.with_extension("moved-aside");
match mutation {
Mutation::ReplacedBySameSizeFile => {
move_aside(target, &aside)?;
fs::write(target, SAME_SIZE).unwrap();
}
Mutation::ReplacedByLargerFile => {
move_aside(target, &aside)?;
fs::write(target, LARGER).unwrap();
}
Mutation::ReplacedByDirectory => {
move_aside(target, &aside)?;
fs::create_dir(target).unwrap();
fs::write(target.join(LEAF), SAME_SIZE).unwrap();
}
#[cfg(unix)]
Mutation::ReplacedBySymlink => {
move_aside(target, &aside)?;
let decoy = target.with_extension("decoy");
fs::write(&decoy, SAME_SIZE).unwrap();
std::os::unix::fs::symlink(&decoy, target).unwrap();
}
#[cfg(unix)]
Mutation::ReplacedByFifo => {
move_aside(target, &aside)?;
make_fifo(target);
}
Mutation::Removed => {
move_aside(target, &aside)?;
}
Mutation::Grown => {
use std::io::Write as _;
let mut file = fs::OpenOptions::new().append(true).open(target).unwrap();
file.write_all(b"more").unwrap();
}
Mutation::Shrunk => {
let file = fs::OpenOptions::new().write(true).open(target).unwrap();
file.set_len(ORIGINAL.len() as u64 - 1).unwrap();
}
}
Some(())
}
fn move_aside(target: &Path, aside: &Path) -> Option<()> {
match fs::rename(target, aside) {
Ok(()) => Some(()),
Err(e) if is_sharing_violation(&e) => None,
Err(e) => panic!("moving {} aside: {e}", target.display()),
}
}
#[cfg(windows)]
fn is_sharing_violation(error: &std::io::Error) -> bool {
const ERROR_SHARING_VIOLATION: i32 = 32;
error.raw_os_error() == Some(ERROR_SHARING_VIOLATION)
}
#[cfg(not(windows))]
fn is_sharing_violation(_error: &std::io::Error) -> bool {
false
}
fn archive_holds(archive: &[u8], needle: &[u8]) -> bool {
archive.windows(needle.len()).any(|window| window == needle)
}
fn substituted_content(position: Position, mutation: Mutation) -> Option<&'static [u8]> {
match (position, mutation) {
(_, Mutation::ReplacedBySameSizeFile) => Some(SAME_SIZE),
(_, Mutation::ReplacedByLargerFile) => Some(LARGER),
(_, Mutation::ReplacedByDirectory) => Some(SAME_SIZE),
#[cfg(unix)]
(_, Mutation::ReplacedBySymlink) => Some(SAME_SIZE),
_ => None,
}
}
#[derive(Clone, Default)]
struct SharedSink(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
impl SharedSink {
fn written(&self) -> Vec<u8> {
self.0.borrow().clone()
}
}
impl Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn interference_between_the_two_passes_follows_the_rule_for_its_position() {
let mut checked = 0;
for position in Position::all() {
for mutation in Mutation::all() {
if !mutation.applies_to(position) {
continue;
}
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("root");
let (input, target): (PathBuf, PathBuf) = match position {
Position::RootFile => {
fs::create_dir_all(&root).unwrap();
let file = root.join("payload.bin");
fs::write(&file, ORIGINAL).unwrap();
(file.clone(), file)
}
Position::RootDirectory => {
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
fs::write(tree.join(LEAF), ORIGINAL).unwrap();
(tree.clone(), tree)
}
Position::Descendant => {
let tree = root.join("tree");
fs::create_dir_all(&tree).unwrap();
let leaf = tree.join(LEAF);
fs::write(&leaf, ORIGINAL).unwrap();
(tree, leaf)
}
};
let prepared = prepare_archive(&input, ArchiveLimits::default())
.unwrap_or_else(|e| panic!("{position:?}/{mutation:?}: preparation: {e}"));
if apply_mutation(&target, mutation) == Interference::RefusedByPlatform {
assert!(
cfg!(windows) && matches!(position, Position::RootDirectory),
"{position:?}/{mutation:?}: the interference must reach the source"
);
}
let sink = SharedSink::default();
let outcome = prepared.write_to(sink.clone());
let written = sink.written();
let contract = expected(position, mutation);
if contract == Expected::Refused {
assert!(
outcome.is_err(),
"{position:?}/{mutation:?}: encryption must refuse"
);
} else {
outcome.unwrap_or_else(|e| {
panic!("{position:?}/{mutation:?}: must produce an archive: {e}")
});
}
if let Some(substitute) = substituted_content(position, mutation) {
let admissible = contract == Expected::ReplacementContent;
assert_eq!(
archive_holds(&written, substitute),
admissible,
"{position:?}/{mutation:?}: substituted content present={}, admissible={}",
archive_holds(&written, substitute),
admissible
);
}
if contract == Expected::OriginalContent {
assert!(
archive_holds(&written, ORIGINAL),
"{position:?}/{mutation:?}: archive must hold what the metadata pass saw"
);
}
if contract == Expected::ReplacementContent {
assert!(
!archive_holds(&written, ORIGINAL),
"{position:?}/{mutation:?}: archive must hold the replacement alone"
);
}
checked += 1;
}
}
let expected_cases = if cfg!(unix) { 22 } else { 16 };
assert_eq!(
checked, expected_cases,
"the generated case count must not drift"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
#[ignore = "lowers the process-wide open-file limit; needs --test-threads=1"]
fn walk_directory_handles_wide_tree_under_low_fd_limit() {
use crate::archive::fd_limit::NofileLimit;
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();
}
let _limit = NofileLimit::lower_to(64);
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 prepare_then_write_excludes_files_created_after_prepare() {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let dir = src.path().join("docs");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("a.txt"), b"original").unwrap();
let prepared = prepare_archive(&dir, ArchiveLimits::default()).unwrap();
fs::write(dir.join("late.txt"), b"created after prepare").unwrap();
let buf = prepared.write_to(Vec::new()).unwrap();
let restored_root = unarchive(
Cursor::new(buf),
out.path(),
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.unwrap();
assert_eq!(fs::read(restored_root.join("a.txt")).unwrap(), b"original");
assert!(
!restored_root.join("late.txt").exists(),
"file created after prepare must not be archived"
);
let names: Vec<_> = fs::read_dir(&restored_root)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(
names.len(),
1,
"restored tree must hold only a.txt, got {names:?}"
);
}
#[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 directory_input_allowed_on_supported_platform() {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
validate_encrypt_input(&dir)
.expect("a directory input must be allowed on a supported platform");
}
#[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)]
#[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().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().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(FCA_COMPONENT_MAX_BYTES + 1)), 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::reasons::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(FCA_COMPONENT_MAX_BYTES);
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: crate::archive::reasons::COMPONENT_RESERVED_DEVICE_NAME,
..
}
));
}
#[test]
fn rejects_bidi_formatting_control_in_source_name() {
assert_source_name_rejected_as_format_control("holiday\u{202e}gpj.sh");
}
#[test]
fn rejects_line_separator_in_source_name() {
assert_source_name_rejected_as_format_control("two\u{2028}lines.txt");
}
fn assert_source_name_rejected_as_format_control(name: &str) {
let src = tempfile::TempDir::new().unwrap();
let dir = src.path().join("d");
fs::create_dir(&dir).unwrap();
fs::write(dir.join(name), 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::reasons::COMPONENT_FORMAT_CONTROL,
..
}
));
}
#[test]
fn accepts_bidi_direction_mark_in_source_name() {
for mark in ['\u{061c}', '\u{200e}', '\u{200f}'] {
let src = tempfile::TempDir::new().unwrap();
let out = tempfile::TempDir::new().unwrap();
let name = format!("שלום{mark}-report.txt");
let src_file = src.path().join(&name);
fs::write(&src_file, b"x").unwrap();
let final_path = round_trip(&src_file, out.path());
assert_eq!(final_path.file_name().unwrap().to_str().unwrap(), name);
assert_eq!(fs::read(&final_path).unwrap(), b"x");
}
}
#[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()
.max_path_depth((depth + 8) as u32)
.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 a_same_length_descendant_swap_between_the_passes_is_refused() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("tree");
fs::create_dir(&root).unwrap();
let leaf = root.join("file.txt");
fs::write(&leaf, b"trusted").unwrap();
let prepared = prepare_archive(&root, ArchiveLimits::default()).unwrap();
fs::rename(&leaf, root.join("moved-aside")).unwrap();
fs::write(&leaf, b"hostile").unwrap();
let mut buf = Vec::new();
let err = prepared.write_to(&mut buf).unwrap_err();
assert!(
format!("{err}").contains("Source file was replaced while archiving"),
"expected the identity rejection, got: {err}"
);
let hostile = b"hostile";
assert!(
!buf.windows(hostile.len()).any(|window| window == hostile),
"the substituted content must never reach the archive"
);
}
#[test]
fn an_untouched_descendant_still_archives() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("tree");
fs::create_dir(&root).unwrap();
fs::write(root.join("file.txt"), b"trusted").unwrap();
let prepared = prepare_archive(&root, ArchiveLimits::default()).unwrap();
let mut buf = Vec::new();
prepared.write_to(&mut buf).unwrap();
let trusted = b"trusted";
assert!(buf.windows(trusted.len()).any(|window| window == trusted));
}
#[cfg(unix)]
#[test]
fn a_source_recorded_without_an_identity_skips_the_comparison() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("real.txt");
fs::write(&path, b"actual content").unwrap();
let reopened = open_no_follow(&path).unwrap();
let metadata = cap_std::fs::File::from_std(reopened).metadata().unwrap();
require_same_source_file(None, &metadata, "real.txt").unwrap();
}
#[cfg(unix)]
#[test]
fn a_recorded_identity_fails_against_a_different_object() {
let tmp = tempfile::TempDir::new().unwrap();
let recorded_path = tmp.path().join("recorded.txt");
let other_path = tmp.path().join("other.txt");
fs::write(&recorded_path, b"actual content").unwrap();
fs::write(&other_path, b"actual content").unwrap();
let recorded_file = open_no_follow(&recorded_path).unwrap();
let recorded = source_identity(
&cap_std::fs::File::from_std(recorded_file)
.metadata()
.unwrap(),
);
assert!(
recorded.is_some(),
"a temp directory must supply an identity"
);
let other_file = open_no_follow(&other_path).unwrap();
let other = cap_std::fs::File::from_std(other_file).metadata().unwrap();
let err = require_same_source_file(recorded, &other, "recorded.txt")
.expect_err("a different object must not pass the comparison");
assert!(
format!("{err}").contains("Source file was replaced while archiving"),
"expected the identity rejection, got: {err}"
);
let same_file = open_no_follow(&recorded_path).unwrap();
let same = cap_std::fs::File::from_std(same_file).metadata().unwrap();
require_same_source_file(recorded, &same, "recorded.txt").unwrap();
}
#[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)]
#[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 require_same_file_rejects_substituted_regular_file() {
let tmp = tempfile::TempDir::new().unwrap();
let original = tmp.path().join("original.bin");
let substitute = tmp.path().join("substitute.bin");
fs::write(&original, b"trusted").unwrap();
fs::write(&substitute, b"hostile").unwrap();
let pre_open = fs::symlink_metadata(&original).unwrap();
let opened = open_no_follow(&substitute).unwrap().metadata().unwrap();
let err = require_same_file(&pre_open, &opened, &original).unwrap_err();
assert!(
format!("{err}").contains("Input file changed while archiving"),
"expected identity rejection, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn require_same_file_accepts_untouched_source() {
let tmp = tempfile::TempDir::new().unwrap();
let input = tmp.path().join("data.bin");
fs::write(&input, b"trusted").unwrap();
let pre_open = fs::symlink_metadata(&input).unwrap();
let opened = open_no_follow(&input).unwrap().metadata().unwrap();
require_same_file(&pre_open, &opened, &input).unwrap();
}
#[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().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().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");
}
}