use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use cap_std::fs::Dir;
use crate::CryptoError;
use crate::fs::atomic::rename_no_clobber;
use crate::fs::paths::{INCOMPLETE_SUFFIX, reject_occupied};
use super::IncompleteOutputPolicy;
use super::format::{
copy_exact_n, parse_fca_header, parse_manifest_bytes, require_fits_usize,
validate_archive_ext_tlv,
};
use super::limits::ArchiveLimits;
use super::model::{ArchiveEntry, ArchiveEntryKind, Manifest};
use super::path::canonical_path_order;
use super::platform;
pub(crate) fn unarchive<R: Read>(
reader: R,
output_dir: &Path,
limits: ArchiveLimits,
policy: IncompleteOutputPolicy,
) -> Result<PathBuf, CryptoError> {
let mut created_incomplete_roots: Vec<OsString> = Vec::new();
let result = unarchive_inner(reader, output_dir, limits, &mut created_incomplete_roots);
if result.is_err() && matches!(policy, IncompleteOutputPolicy::DeleteOnError) {
for root_name in &created_incomplete_roots {
let working_path = output_dir.join(incomplete_working_name(root_name));
cleanup_incomplete_path(&working_path);
}
}
result
}
fn unarchive_inner<R: Read>(
mut reader: R,
output_dir: &Path,
limits: ArchiveLimits,
created_incomplete_roots: &mut Vec<OsString>,
) -> Result<PathBuf, CryptoError> {
let header = parse_fca_header(&mut reader, limits)?;
let archive_ext_len = require_fits_usize(header.archive_ext_len, "Archive extension length")?;
let mut archive_ext_bytes = vec![0u8; archive_ext_len];
reader.read_exact(&mut archive_ext_bytes)?;
validate_archive_ext_tlv(&archive_ext_bytes, &limits)?;
let manifest_len = require_fits_usize(header.manifest_len, "Archive manifest length")?;
let mut manifest_bytes = vec![0u8; manifest_len];
reader.read_exact(&mut manifest_bytes)?;
let manifest = parse_manifest_bytes(&manifest_bytes, header, limits)?;
let final_path = output_dir.join(&manifest.root_name);
reject_occupied(&final_path, "Output")?;
let output_handle = platform::open_anchor(output_dir)?;
let incomplete_name = incomplete_working_name(&manifest.root_name);
if manifest.root_is_file {
extract_single_file_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
created_incomplete_roots,
output_dir,
)?;
} else {
extract_directory_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
created_incomplete_roots,
output_dir,
)?;
}
drop(output_handle);
let working_path = output_dir.join(&incomplete_name);
rename_no_clobber(&working_path, &final_path).map_err(|e| {
map_already_exists(CryptoError::Io(e), "Output already exists", &final_path)
})?;
if manifest.root_is_file {
apply_root_file_mode(output_dir, &manifest)?;
} else {
apply_root_directory_mode(output_dir, &manifest)?;
}
Ok(final_path)
}
fn extract_single_file_root<R: Read>(
reader: &mut R,
output_handle: &Dir,
incomplete_name: &OsStr,
manifest: &Manifest,
created_incomplete_roots: &mut Vec<OsString>,
output_dir: &Path,
) -> Result<(), CryptoError> {
debug_assert_eq!(manifest.entries.len(), 1);
let entry = &manifest.entries[0];
debug_assert_eq!(entry.kind, ArchiveEntryKind::File);
let mut outfile = platform::create_file_at(
output_handle,
incomplete_name,
platform::INITIAL_FILE_CREATE_MODE,
)
.map_err(|e| {
map_already_exists(
CryptoError::Io(e),
"Previous .incomplete exists",
&output_dir.join(incomplete_name),
)
})?;
created_incomplete_roots.push(manifest.root_name.clone());
copy_exact_n(reader, &mut outfile, entry.size)?;
verify_archive_eof(reader)
}
fn extract_directory_root<R: Read>(
reader: &mut R,
output_handle: &Dir,
incomplete_name: &OsStr,
manifest: &Manifest,
created_incomplete_roots: &mut Vec<OsString>,
output_dir: &Path,
) -> Result<(), CryptoError> {
let root_name_str = manifest_root_name_str(manifest)?;
let root_dir = platform::mkdir_strict(output_handle, incomplete_name).map_err(|e| {
map_already_exists(
e,
"Previous .incomplete exists",
&output_dir.join(incomplete_name),
)
})?;
created_incomplete_roots.push(manifest.root_name.clone());
let mut dir_entries: Vec<&ArchiveEntry> = manifest
.entries
.iter()
.filter(|e| e.kind == ArchiveEntryKind::Directory && e.path_utf8 != root_name_str)
.collect();
dir_entries.sort_by(|a, b| canonical_path_order(&a.path_utf8, &b.path_utf8));
for dir_entry in &dir_entries {
let rel = strip_root_prefix(&dir_entry.path_utf8, root_name_str)?;
let (parent_dir, dir_name) = platform::walk_to_parent(&root_dir, rel)?;
let _new_dir = platform::mkdir_strict(&parent_dir, &dir_name)?;
}
for entry in &manifest.entries {
if entry.kind != ArchiveEntryKind::File {
continue;
}
let rel = strip_root_prefix(&entry.path_utf8, root_name_str)?;
let (parent_dir, file_name) = platform::walk_to_parent(&root_dir, rel)?;
let mut outfile =
platform::create_file_at(&parent_dir, &file_name, platform::INITIAL_FILE_CREATE_MODE)?;
copy_exact_n(reader, &mut outfile, entry.size)?;
platform::chmod_file_handle(&outfile, entry.mode)?;
}
verify_archive_eof(reader)?;
for dir_entry in dir_entries.iter().rev() {
let rel = strip_root_prefix(&dir_entry.path_utf8, root_name_str)?;
let dir_handle = platform::open_dir_at_rel(&root_dir, rel)?;
platform::chmod_dir_handle(dir_handle, dir_entry.mode)?;
}
Ok(())
}
fn apply_root_directory_mode(output_dir: &Path, manifest: &Manifest) -> Result<(), CryptoError> {
let root_name_str = manifest_root_name_str(manifest)?;
let output_handle = platform::open_anchor(output_dir)?;
let root_dir = platform::open_dir_at_rel(&output_handle, Path::new(root_name_str))?;
platform::chmod_dir_handle(root_dir, manifest.root_mode)
}
fn apply_root_file_mode(output_dir: &Path, manifest: &Manifest) -> Result<(), CryptoError> {
let output_handle = platform::open_anchor(output_dir)?;
let file = platform::open_file_nofollow(&output_handle, &manifest.root_name)?;
platform::chmod_file_handle(&file, manifest.root_mode)
}
fn verify_archive_eof<R: Read>(reader: &mut R) -> Result<(), CryptoError> {
let mut b = [0u8; 1];
match reader.read(&mut b) {
Ok(0) => Ok(()),
Ok(_) => Err(CryptoError::InvalidInput(
"Trailing data after archive file contents".to_string(),
)),
Err(e) => Err(CryptoError::from(e)),
}
}
fn incomplete_working_name(root_name: &OsStr) -> OsString {
let mut name = root_name.to_os_string();
name.push(INCOMPLETE_SUFFIX);
name
}
fn cleanup_incomplete_path(path: &Path) {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(_) => return,
};
if meta.file_type().is_symlink() {
let _ = fs::remove_file(path);
} else if meta.is_dir() {
let _ = fs::remove_dir_all(path);
} else {
let _ = fs::remove_file(path);
}
}
fn map_already_exists(e: CryptoError, label: &str, path: &Path) -> CryptoError {
if let CryptoError::Io(io_err) = &e {
if io_err.kind() == io::ErrorKind::AlreadyExists {
return CryptoError::InvalidInput(format!("{}: {}", label, path.display()));
}
}
e
}
fn manifest_root_name_str(manifest: &Manifest) -> Result<&str, CryptoError> {
manifest
.root_name
.to_str()
.ok_or(CryptoError::InternalInvariant(
"Manifest root_name is not valid UTF-8",
))
}
fn strip_root_prefix<'a>(path_utf8: &'a str, root_name: &str) -> Result<&'a Path, CryptoError> {
path_utf8
.strip_prefix(root_name)
.and_then(|rest| rest.strip_prefix('/'))
.map(Path::new)
.ok_or(CryptoError::InternalInvariant(
"Manifest entry missing expected root prefix",
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::archive::format::{serialize_manifest, write_fca_header};
use std::io::Cursor;
use crate::archive::model::make_entry;
use crate::crypto::tlv::tlv_bytes;
fn build_archive_prefix(manifest: &Manifest) -> Vec<u8> {
build_archive_prefix_with_archive_ext(manifest, &[])
}
fn build_archive_prefix_with_archive_ext(manifest: &Manifest, archive_ext: &[u8]) -> Vec<u8> {
let manifest_bytes = serialize_manifest(manifest, ArchiveLimits::default()).unwrap();
let entry_count = u32::try_from(manifest.entries.len()).unwrap();
let archive_ext_len = u32::try_from(archive_ext.len()).unwrap();
let manifest_len = u32::try_from(manifest_bytes.len()).unwrap();
let mut archive = Vec::new();
let _ = write_fca_header(
&mut archive,
entry_count,
archive_ext_len,
manifest_len,
manifest.total_file_bytes,
)
.unwrap();
archive.extend_from_slice(archive_ext);
archive.extend_from_slice(&manifest_bytes);
archive
}
fn build_archive(manifest: &Manifest, file_contents: &[(&str, &[u8])]) -> Vec<u8> {
let mut archive = build_archive_prefix(manifest);
let contents: std::collections::HashMap<&str, &[u8]> =
file_contents.iter().copied().collect();
for entry in &manifest.entries {
if entry.kind == ArchiveEntryKind::File {
let content = contents
.get(entry.path_utf8.as_str())
.expect("test fixture missing file content");
assert_eq!(entry.size as usize, content.len(), "size/content mismatch");
archive.extend_from_slice(content);
}
}
archive
}
fn build_partial_archive(manifest: &Manifest, content_bytes: &[u8]) -> Vec<u8> {
let mut archive = build_archive_prefix(manifest);
archive.extend_from_slice(content_bytes);
archive
}
fn single_file_manifest(path: &str, content: &[u8]) -> Manifest {
Manifest {
entries: vec![make_entry(
path,
ArchiveEntryKind::File,
content.len() as u64,
0o644,
)],
total_file_bytes: content.len() as u64,
root_name: OsString::from(path),
root_is_file: true,
root_mode: 0o644,
}
}
fn dir_with_one_undersized_file_manifest() -> Manifest {
Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/a.bin", ArchiveEntryKind::File, 100, 0o644),
],
total_file_bytes: 100,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
}
}
fn unarchive_with_policy(
archive: Vec<u8>,
tmp: &Path,
policy: IncompleteOutputPolicy,
) -> Result<PathBuf, CryptoError> {
unarchive(Cursor::new(archive), tmp, ArchiveLimits::default(), policy)
}
fn unarchive_default(archive: Vec<u8>, tmp: &Path) -> Result<PathBuf, CryptoError> {
unarchive_with_policy(archive, tmp, IncompleteOutputPolicy::DeleteOnError)
}
#[test]
fn round_trip_single_file() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(final_path, tmp.path().join("hello.txt"));
assert_eq!(fs::read(&final_path).unwrap(), b"Hello, world!");
}
#[test]
fn round_trip_empty_file() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("empty.txt", b"");
let archive = build_archive(&manifest, &[("empty.txt", b"")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(fs::read(&final_path).unwrap(), b"");
}
#[test]
fn round_trip_directory_with_files() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("photos", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("photos/index.txt", ArchiveEntryKind::File, 5, 0o644),
make_entry("photos/cover.jpg", ArchiveEntryKind::File, 7, 0o644),
],
total_file_bytes: 12,
root_name: OsString::from("photos"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(
&manifest,
&[
("photos/index.txt", b"hello"),
("photos/cover.jpg", b"jpegjpe"),
],
);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
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");
}
#[test]
fn round_trip_empty_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![make_entry(
"emptydir",
ArchiveEntryKind::Directory,
0,
0o755,
)],
total_file_bytes: 0,
root_name: OsString::from("emptydir"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(&manifest, &[]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert!(final_path.is_dir());
assert_eq!(fs::read_dir(&final_path).unwrap().count(), 0);
}
#[test]
fn round_trip_nested_directory_tree() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/a", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/a/b", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/a/b/leaf.txt", ArchiveEntryKind::File, 4, 0o644),
],
total_file_bytes: 4,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(&manifest, &[("root/a/b/leaf.txt", b"deep")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(
fs::read(final_path.join("a").join("b").join("leaf.txt")).unwrap(),
b"deep"
);
}
#[test]
fn round_trip_non_canonical_manifest_order() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("root/a/b/leaf.txt", ArchiveEntryKind::File, 4, 0o644),
make_entry("root/a/b", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/a", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
],
total_file_bytes: 4,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(&manifest, &[("root/a/b/leaf.txt", b"deep")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(
fs::read(final_path.join("a").join("b").join("leaf.txt")).unwrap(),
b"deep"
);
}
#[test]
fn round_trip_multiple_files_exact_boundaries() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/a.bin", ArchiveEntryKind::File, 10, 0o644),
make_entry("d/b.bin", ArchiveEntryKind::File, 0, 0o644),
make_entry("d/c.bin", ArchiveEntryKind::File, 5, 0o644),
],
total_file_bytes: 15,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(
&manifest,
&[
("d/a.bin", b"AAAAAAAAAA"),
("d/b.bin", b""),
("d/c.bin", b"CCCCC"),
],
);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(fs::read(final_path.join("a.bin")).unwrap(), b"AAAAAAAAAA");
assert_eq!(fs::read(final_path.join("b.bin")).unwrap(), b"");
assert_eq!(fs::read(final_path.join("c.bin")).unwrap(), b"CCCCC");
}
#[test]
fn round_trip_with_ignorable_archive_ext() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let ignorable = tlv_bytes(0x0001, b"meta");
let mut archive = build_archive_prefix_with_archive_ext(&manifest, &ignorable);
archive.extend_from_slice(b"Hello, world!");
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(fs::read(&final_path).unwrap(), b"Hello, world!");
}
#[test]
fn rejects_unknown_critical_archive_ext_tag() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let critical = tlv_bytes(0x8001, &[]);
let mut archive = build_archive_prefix_with_archive_ext(&manifest, &critical);
archive.extend_from_slice(b"Hello, world!");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("UnknownCriticalTag"));
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0);
}
#[test]
fn rejects_malformed_archive_ext_tlv() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let mut truncated = tlv_bytes(0x0001, &[]);
truncated.pop();
let mut archive = build_archive_prefix_with_archive_ext(&manifest, &truncated);
archive.extend_from_slice(b"Hello, world!");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("MalformedTlv"));
}
#[test]
fn rejects_reserved_zero_archive_ext_tag() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let reserved = tlv_bytes(0x0000, &[]);
let mut archive = build_archive_prefix_with_archive_ext(&manifest, &reserved);
archive.extend_from_slice(b"Hello, world!");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("MalformedTlv"));
}
#[test]
fn rejects_reserved_8000_archive_ext_tag() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let reserved = tlv_bytes(0x8000, &[]);
let mut archive = build_archive_prefix_with_archive_ext(&manifest, &reserved);
archive.extend_from_slice(b"Hello, world!");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err:?}").contains("MalformedTlv"));
}
#[test]
fn rejects_short_file_content() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_partial_archive(&manifest, b"short");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
s.contains("shorter than declared") || matches!(err, CryptoError::Io(_)),
"got: {s}",
);
}
#[test]
fn rejects_trailing_data_after_last_file() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let mut archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
archive.push(0xAA);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Trailing data"));
}
#[test]
fn rejects_truncation_at_zero_content_bytes() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_partial_archive(&manifest, b"");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
s.contains("shorter than declared") || matches!(err, CryptoError::Io(_)),
"got: {s}",
);
}
#[test]
fn rejects_truncation_during_later_file() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/a.bin", ArchiveEntryKind::File, 5, 0o644),
make_entry("d/b.bin", ArchiveEntryKind::File, 10, 0o644),
],
total_file_bytes: 15,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
let mut content = Vec::new();
content.extend_from_slice(b"AAAAA");
content.extend_from_slice(b"BB");
let archive = build_partial_archive(&manifest, &content);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
s.contains("shorter than declared") || matches!(err, CryptoError::Io(_)),
"got: {s}",
);
}
#[test]
fn rejects_truncation_exactly_at_file_boundary() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/a.bin", ArchiveEntryKind::File, 5, 0o644),
make_entry("d/b.bin", ArchiveEntryKind::File, 10, 0o644),
],
total_file_bytes: 15,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_partial_archive(&manifest, b"AAAAA");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
s.contains("shorter than declared") || matches!(err, CryptoError::Io(_)),
"got: {s}",
);
}
#[test]
fn delete_on_error_removes_incomplete() {
let tmp = tempfile::TempDir::new().unwrap();
let archive = build_partial_archive(&dir_with_one_undersized_file_manifest(), b"short");
let result =
unarchive_with_policy(archive, tmp.path(), IncompleteOutputPolicy::DeleteOnError);
assert!(result.is_err());
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0, "DeleteOnError must clean up .incomplete");
}
#[test]
fn retain_on_error_keeps_incomplete() {
let tmp = tempfile::TempDir::new().unwrap();
let archive = build_partial_archive(&dir_with_one_undersized_file_manifest(), b"short");
let result =
unarchive_with_policy(archive, tmp.path(), IncompleteOutputPolicy::RetainOnError);
assert!(result.is_err());
let incomplete = tmp.path().join("root.incomplete");
assert!(
incomplete.exists(),
"RetainOnError must preserve .incomplete"
);
}
#[test]
fn panic_during_extraction_preserves_incomplete_under_delete_on_error() {
use std::panic::AssertUnwindSafe;
struct PanicAfterN<R: Read> {
inner: R,
bytes_read: u64,
panic_at: u64,
}
impl<R: Read> Read for PanicAfterN<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.bytes_read >= self.panic_at {
panic!("test-induced panic at byte {}", self.bytes_read);
}
let remaining = (self.panic_at - self.bytes_read) as usize;
let n_target = buf.len().min(remaining);
let n = self.inner.read(&mut buf[..n_target])?;
self.bytes_read += n as u64;
Ok(n)
}
}
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let panic_at = archive.len() as u64 - 1;
let panicking_reader = PanicAfterN {
inner: Cursor::new(archive),
bytes_read: 0,
panic_at,
};
let tmp_path = tmp.path().to_path_buf();
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
unarchive(
panicking_reader,
&tmp_path,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
}));
assert!(
result.is_err(),
"expected panic to propagate out of unarchive"
);
let incomplete = tmp_path.join("hello.txt.incomplete");
assert!(
incomplete.exists(),
".incomplete must survive panic regardless of policy",
);
}
struct FailAfterN<R: Read> {
inner: R,
bytes_read: u64,
fail_at: u64,
error_kind: io::ErrorKind,
error_msg: &'static str,
}
impl<R: Read> Read for FailAfterN<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.bytes_read >= self.fail_at {
return Err(io::Error::new(self.error_kind, self.error_msg));
}
let remaining = (self.fail_at - self.bytes_read) as usize;
let n_target = buf.len().min(remaining);
let n = self.inner.read(&mut buf[..n_target])?;
self.bytes_read += n as u64;
Ok(n)
}
}
#[test]
fn fail_after_n_during_payload_cleans_up_under_delete_on_error() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let fail_at = archive.len() as u64 - 1;
let reader = FailAfterN {
inner: Cursor::new(archive),
bytes_read: 0,
fail_at,
error_kind: io::ErrorKind::Other,
error_msg: "test-induced AEAD fail",
};
let result = unarchive(
reader,
tmp.path(),
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
);
assert!(result.is_err());
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0, "DeleteOnError must clean up after fault");
}
#[test]
fn fail_after_n_during_payload_preserves_under_retain_on_error() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let fail_at = archive.len() as u64 - 1;
let reader = FailAfterN {
inner: Cursor::new(archive),
bytes_read: 0,
fail_at,
error_kind: io::ErrorKind::Other,
error_msg: "test-induced AEAD fail",
};
let result = unarchive(
reader,
tmp.path(),
ArchiveLimits::default(),
IncompleteOutputPolicy::RetainOnError,
);
assert!(result.is_err());
let incomplete = tmp.path().join("hello.txt.incomplete");
assert!(
incomplete.exists(),
"RetainOnError must keep .incomplete after fault"
);
}
#[cfg(unix)]
#[test]
fn read_only_output_dir_fails_with_no_leak() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
fs::set_permissions(&out, fs::Permissions::from_mode(0o500)).unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let result = unarchive(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
);
assert!(
result.is_err(),
"expected creation failure on read-only dir"
);
fs::set_permissions(&out, fs::Permissions::from_mode(0o700)).unwrap();
let count = fs::read_dir(&out).unwrap().count();
assert_eq!(
count, 0,
"no .incomplete or final output may exist when output_dir is read-only",
);
}
#[test]
fn retain_on_error_staged_content_is_prefix_of_original() {
let tmp = tempfile::TempDir::new().unwrap();
let archive = build_partial_archive(&dir_with_one_undersized_file_manifest(), b"short");
let _ = unarchive_with_policy(archive, tmp.path(), IncompleteOutputPolicy::RetainOnError);
let staged = tmp.path().join("root.incomplete").join("a.bin");
if staged.exists() {
let bytes = fs::read(&staged).unwrap();
assert!(
bytes.len() <= 100,
"staged a.bin ({} bytes) must not exceed declared size (100)",
bytes.len()
);
}
}
#[test]
fn rejects_pre_existing_final_output() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("hello.txt"), b"existing").unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Output already exists"));
}
#[cfg(unix)]
#[test]
fn extract_applies_mode_0o000_to_output_file() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"x");
let mut manifest = manifest;
manifest.entries[0].mode = 0o000;
manifest.root_mode = 0o000;
let archive = build_archive(&manifest, &[("hello.txt", b"x")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o000, "expected 0o000, got 0o{mode:o}");
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o600)).unwrap();
}
#[cfg(unix)]
#[test]
fn rejects_hardlink_at_extraction_file_leaf() {
let tmp = tempfile::TempDir::new().unwrap();
let attacker_target = tmp.path().join("attacker_target.bin");
fs::write(&attacker_target, b"attacker controlled").unwrap();
fs::hard_link(&attacker_target, tmp.path().join("hello.txt")).unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Output already exists"));
assert_eq!(fs::read(&attacker_target).unwrap(), b"attacker controlled");
}
#[cfg(unix)]
#[test]
fn apply_root_directory_mode_rejects_symlink_at_renamed_root() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let real = tmp.path().join("real_dir");
fs::create_dir(&real).unwrap();
symlink(&real, tmp.path().join("root")).unwrap();
let manifest = Manifest {
entries: vec![make_entry("root", ArchiveEntryKind::Directory, 0, 0o755)],
total_file_bytes: 0,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
};
let err = apply_root_directory_mode(tmp.path(), &manifest).unwrap_err();
assert!(
format!("{err}").contains("Symlink in extraction path"),
"expected symlink rejection, got: {err}",
);
}
#[cfg(unix)]
#[test]
fn apply_root_file_mode_rejects_symlink_at_renamed_root() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let real = tmp.path().join("real_file");
fs::write(&real, b"victim").unwrap();
symlink(&real, tmp.path().join("hello.txt")).unwrap();
let manifest = single_file_manifest("hello.txt", b"x");
let err = apply_root_file_mode(tmp.path(), &manifest).unwrap_err();
assert!(
format!("{err}").contains("Symlink in extraction path"),
"expected symlink rejection, got: {err}",
);
}
#[cfg(unix)]
#[test]
fn retain_on_error_staged_root_keeps_default_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o500),
make_entry("root/a.bin", ArchiveEntryKind::File, 100, 0o644),
],
total_file_bytes: 100,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o500,
};
let archive = build_partial_archive(&manifest, b"short");
let _ = unarchive_with_policy(archive, tmp.path(), IncompleteOutputPolicy::RetainOnError);
let staged_root = tmp.path().join("root.incomplete");
assert!(staged_root.exists());
let mode = fs::metadata(&staged_root).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"retained .incomplete must keep staging default 0o700, not manifest 0o{:o}",
manifest.root_mode,
);
}
#[cfg(unix)]
#[test]
fn retain_on_error_staged_file_keeps_default_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let manifest = single_file_manifest("hello.txt", b"payload");
let mut archive = build_archive(&manifest, &[("hello.txt", b"payload")]);
archive.push(0xFFu8);
let err = unarchive_with_policy(archive, tmp.path(), IncompleteOutputPolicy::RetainOnError)
.unwrap_err();
assert!(format!("{err}").contains("Trailing data"));
let staged = tmp.path().join("hello.txt.incomplete");
assert!(
staged.exists(),
"RetainOnError must keep staged .incomplete"
);
let mode = fs::metadata(&staged).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"retained .incomplete must keep staging default 0o600, not manifest 0o{:o}",
manifest.entries[0].mode,
);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "fs-matrix: needs Unicode-normalizing or case-insensitive volume; default APFS preserves form"]
fn unicode_collision_falls_through_to_create_new() {
let tmp = ferrocrypt_test_support::fs_matrix_tempdir().unwrap();
let nfc = "na\u{00EF}ve.txt";
let nfd = "na\u{0069}\u{0308}ve.txt";
assert_ne!(nfc.as_bytes(), nfd.as_bytes(), "test sanity");
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
make_entry(&format!("root/{nfc}"), ArchiveEntryKind::File, 5, 0o644),
make_entry(&format!("root/{nfd}"), ArchiveEntryKind::File, 5, 0o644),
],
total_file_bytes: 10,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(
&manifest,
&[
(&format!("root/{nfc}"), b"AAAAA"),
(&format!("root/{nfd}"), b"BBBBB"),
],
);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
s.contains("already exists") || matches!(err, CryptoError::Io(_)),
"got: {s}",
);
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0);
}
#[test]
fn rejects_pre_existing_incomplete_and_preserves_it() {
let tmp = tempfile::TempDir::new().unwrap();
let stale_path = tmp.path().join("hello.txt.incomplete");
fs::write(&stale_path, b"stale plaintext from earlier run").unwrap();
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Previous .incomplete exists"));
assert!(
stale_path.exists(),
"pre-existing .incomplete must be preserved across a retry",
);
}
#[test]
fn invalid_manifest_creates_no_filesystem_output() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("a.txt", ArchiveEntryKind::File, 1, 0o644),
make_entry("b.txt", ArchiveEntryKind::File, 1, 0o644),
],
total_file_bytes: 2,
root_name: OsString::from("a.txt"),
root_is_file: true,
root_mode: 0o644,
};
let archive = build_partial_archive(&manifest, b"AB");
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(
format!("{err}").contains("multiple top-level roots") || result_is_format_error(&err),
"got: {err}"
);
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(
count, 0,
"no filesystem output may exist when the manifest is invalid",
);
}
fn result_is_format_error(err: &CryptoError) -> bool {
matches!(err, CryptoError::InvalidInput(_))
}
#[cfg(unix)]
#[test]
fn rejects_dangling_symlink_at_final_output() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("absent-target");
let link = tmp.path().join("hello.txt");
symlink(&target, &link).unwrap();
assert!(
!link.exists(),
"test setup: symlink target must be absent (dangling)"
);
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(format!("{err}").contains("Output already exists"));
assert!(
fs::symlink_metadata(&link).is_ok(),
"dangling symlink must be preserved across rejected extraction",
);
}
#[cfg(unix)]
#[test]
fn extracts_with_restrictive_root_and_parent_modes() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("locked", ArchiveEntryKind::Directory, 0, 0o400),
make_entry("locked/child", ArchiveEntryKind::Directory, 0, 0o700),
make_entry("locked/child/secret.txt", ArchiveEntryKind::File, 6, 0o600),
],
total_file_bytes: 6,
root_name: OsString::from("locked"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(&manifest, &[("locked/child/secret.txt", b"secret")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
let root_mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o7777;
assert_eq!(
root_mode, 0o400,
"root mode 0o400 must be applied (post-rename), got 0o{root_mode:o}",
);
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o700)).unwrap();
let child_mode = fs::metadata(final_path.join("child"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(child_mode, 0o700);
assert_eq!(
fs::read(final_path.join("child").join("secret.txt")).unwrap(),
b"secret",
);
}
}