use std::ffi::{OsStr, OsString};
#[cfg(test)]
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use cap_std::fs::Dir;
use crate::CryptoError;
use crate::crypto::stream::read_uninterrupted;
use crate::error::sanitize_for_display;
use crate::fs::paths::{INCOMPLETE_SUFFIX, path_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> {
unarchive_inner(reader, output_dir, limits, policy)
}
fn unarchive_inner<R: Read>(
mut reader: R,
output_dir: &Path,
limits: ArchiveLimits,
policy: IncompleteOutputPolicy,
) -> 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);
if path_occupied(&final_path)? {
return Err(output_already_exists(output_dir, &manifest.root_name));
}
let output_handle = platform::open_anchor(output_dir)?;
let incomplete_name = incomplete_working_name(&manifest.root_name);
let mut created_incomplete_roots: Vec<OsString> = Vec::new();
let outcome: Result<PathBuf, CryptoError> = (|| {
if manifest.root_is_file {
extract_single_file_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
&mut created_incomplete_roots,
output_dir,
)?;
} else {
extract_directory_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
&mut created_incomplete_roots,
output_dir,
)?;
}
let promote_result = promote_root(
&output_handle,
output_dir,
&incomplete_name,
&manifest.root_name,
manifest.root_is_file,
);
promote_result.map_err(|e| {
if e.kind() == io::ErrorKind::AlreadyExists {
output_already_exists(output_dir, &manifest.root_name)
} else {
CryptoError::Io(e)
}
})?;
let _ = if manifest.root_is_file {
apply_root_file_mode(&output_handle, &manifest)
} else {
apply_root_directory_mode(&output_handle, &manifest)
};
Ok(final_path.clone())
})();
if outcome.is_err() && matches!(policy, IncompleteOutputPolicy::DeleteOnError) {
for root_name in &created_incomplete_roots {
cleanup_incomplete_via_handle(&output_handle, &incomplete_working_name(root_name));
}
}
drop(output_handle);
outcome
}
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, archive_content_truncated)?;
outfile.sync_all().map_err(CryptoError::Io)?;
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, archive_content_truncated)?;
platform::chmod_file_handle(&outfile, entry.mode)?;
outfile.sync_all().map_err(CryptoError::Io)?;
}
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::sync_dir_handle(&dir_handle);
platform::chmod_dir_handle(dir_handle, dir_entry.mode)?;
}
platform::sync_dir_handle(&root_dir);
Ok(())
}
fn apply_root_directory_mode(output_handle: &Dir, manifest: &Manifest) -> Result<(), CryptoError> {
let root_name_str = manifest_root_name_str(manifest)?;
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_handle: &Dir, manifest: &Manifest) -> Result<(), CryptoError> {
let file = platform::open_file_nofollow(output_handle, &manifest.root_name)?;
platform::chmod_file_handle(&file, manifest.root_mode)
}
fn promote_root(
output_handle: &Dir,
output_dir: &Path,
incomplete_name: &OsStr,
final_name: &OsStr,
root_is_file: bool,
) -> io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let _ = (output_dir, root_is_file);
platform::rename_at_no_clobber(output_handle, incomplete_name, final_name)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
use crate::fs::atomic::{promote_single_file_no_clobber, rename_no_clobber};
let _ = output_handle;
let working_path = output_dir.join(incomplete_name);
let final_path = output_dir.join(final_name);
if root_is_file {
promote_single_file_no_clobber(&working_path, &final_path)
} else {
rename_no_clobber(&working_path, &final_path)
}
}
}
fn archive_content_truncated() -> CryptoError {
CryptoError::MalformedArchive {
reason: "file content shorter than the declared size",
}
}
fn verify_archive_eof<R: Read>(reader: &mut R) -> Result<(), CryptoError> {
let mut b = [0u8; 1];
match read_uninterrupted(reader, &mut b) {
Ok(0) => Ok(()),
Ok(_) => Err(CryptoError::MalformedArchive {
reason: "trailing data after the file contents",
}),
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_via_handle(output_handle: &Dir, working_name: &OsStr) {
let meta = match output_handle.symlink_metadata(working_name) {
Ok(m) => m,
Err(_) => return,
};
if meta.file_type().is_symlink() {
let _ = output_handle.remove_file(working_name);
} else if meta.is_dir() {
let _ = output_handle.remove_dir_all(working_name);
} else {
let _ = output_handle.remove_file(working_name);
}
}
fn output_already_exists(output_dir: &Path, root_name: &OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"Output already exists: {}{}{}",
output_dir.display(),
std::path::MAIN_SEPARATOR,
sanitize_for_display(&root_name.to_string_lossy())
))
}
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,
sanitize_for_display(&path.display().to_string())
));
}
}
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_unchecked, write_fca_header};
use std::io::Cursor;
#[test]
fn verify_archive_eof_retries_interrupted() {
struct EintrThenEof {
fired: bool,
}
impl Read for EintrThenEof {
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
if self.fired {
Ok(0)
} else {
self.fired = true;
Err(io::Error::new(io::ErrorKind::Interrupted, "eintr"))
}
}
}
verify_archive_eof(&mut EintrThenEof { fired: false })
.expect("EINTR followed by EOF must verify");
}
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_unchecked(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");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn promotion_is_anchored_to_handle_across_output_dir_swap() {
struct SwapOnEof<F: FnMut()> {
data: Vec<u8>,
pos: usize,
swapped: bool,
swap: F,
}
impl<F: FnMut()> Read for SwapOnEof<F> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.pos >= self.data.len() {
if !self.swapped {
self.swapped = true;
(self.swap)();
}
return Ok(0);
}
let n = (self.data.len() - self.pos).min(buf.len());
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
}
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/a.txt", ArchiveEntryKind::File, 4, 0o644),
],
total_file_bytes: 4,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(&manifest, &[("d/a.txt", b"real")]);
let out_for_swap = out.clone();
let moved = tmp.path().join("out.moved");
let moved_for_swap = moved.clone();
let reader = SwapOnEof {
data: archive,
pos: 0,
swapped: false,
swap: move || {
fs::rename(&out_for_swap, &moved_for_swap).unwrap();
fs::create_dir(&out_for_swap).unwrap();
fs::create_dir(out_for_swap.join("d.incomplete")).unwrap();
fs::write(out_for_swap.join("d.incomplete").join("a.txt"), b"attacker").unwrap();
},
};
let returned = unarchive(
reader,
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.unwrap();
assert_eq!(fs::read(moved.join("d").join("a.txt")).unwrap(), b"real");
assert!(!moved.join("d.incomplete").exists());
assert!(!out.join("d").exists());
assert_eq!(
fs::read(out.join("d.incomplete").join("a.txt")).unwrap(),
b"attacker"
);
assert_eq!(returned, out.join("d"));
assert!(!returned.exists());
}
#[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();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: "file content shorter than the declared size"
} | CryptoError::Io(_)
),
"got: {err}",
);
}
#[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!(matches!(
err,
CryptoError::MalformedArchive {
reason: "trailing data after the file contents"
}
));
}
#[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();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: "file content shorter than the declared size"
} | CryptoError::Io(_)
),
"got: {err}",
);
}
#[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();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: "file content shorter than the declared size"
} | CryptoError::Io(_)
),
"got: {err}",
);
}
#[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();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: "file content shorter than the declared size"
} | CryptoError::Io(_)
),
"got: {err}",
);
}
#[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");
}
#[cfg(unix)]
#[test]
fn cleanup_via_handle_follows_handle_inode_not_path() {
let tmp = tempfile::TempDir::new().unwrap();
let original = tmp.path().join("orig");
fs::create_dir(&original).unwrap();
let staged = original.join("root.incomplete");
fs::write(&staged, b"staged plaintext").unwrap();
let handle = platform::open_anchor(&original).unwrap();
let moved = tmp.path().join("moved");
fs::rename(&original, &moved).unwrap();
fs::create_dir(&original).unwrap();
cleanup_incomplete_via_handle(&handle, OsStr::new("root.incomplete"));
assert!(
!moved.join("root.incomplete").exists(),
"handle-relative cleanup should have removed the staged file from the moved dir",
);
assert!(
original.read_dir().unwrap().next().is_none(),
"the replacement dir at the original path must be untouched",
);
}
#[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 handle = platform::open_anchor(tmp.path()).unwrap();
let err = apply_root_directory_mode(&handle, &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 handle = platform::open_anchor(tmp.path()).unwrap();
let err = apply_root_file_mode(&handle, &manifest).unwrap_err();
assert!(
format!("{err}").contains("Symlink in extraction path"),
"expected symlink rejection, got: {err}",
);
}
#[cfg(unix)]
#[test]
fn apply_root_directory_mode_follows_handle_inode_not_path() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let orig = tmp.path().join("orig");
fs::create_dir(&orig).unwrap();
fs::create_dir(orig.join("root")).unwrap();
let handle = platform::open_anchor(&orig).unwrap();
let moved = tmp.path().join("moved");
fs::rename(&orig, &moved).unwrap();
fs::create_dir(&orig).unwrap();
fs::create_dir(orig.join("root")).unwrap();
fs::set_permissions(orig.join("root"), fs::Permissions::from_mode(0o755)).unwrap();
let manifest = Manifest {
entries: vec![make_entry("root", ArchiveEntryKind::Directory, 0, 0o500)],
total_file_bytes: 0,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o500,
};
apply_root_directory_mode(&handle, &manifest).unwrap();
let moved_mode = fs::metadata(moved.join("root"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(moved_mode, 0o500, "chmod must land in the handle's dir");
let victim_mode = fs::metadata(orig.join("root"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
victim_mode, 0o755,
"victim at the swapped path must be untouched"
);
}
#[cfg(unix)]
#[test]
fn apply_root_file_mode_follows_handle_inode_not_path() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let orig = tmp.path().join("orig");
fs::create_dir(&orig).unwrap();
fs::write(orig.join("hello.txt"), b"extracted").unwrap();
let handle = platform::open_anchor(&orig).unwrap();
let moved = tmp.path().join("moved");
fs::rename(&orig, &moved).unwrap();
fs::create_dir(&orig).unwrap();
fs::write(orig.join("hello.txt"), b"victim").unwrap();
fs::set_permissions(orig.join("hello.txt"), fs::Permissions::from_mode(0o644)).unwrap();
let mut manifest = single_file_manifest("hello.txt", b"extracted");
manifest.entries[0].mode = 0o400;
manifest.root_mode = 0o400;
apply_root_file_mode(&handle, &manifest).unwrap();
let moved_mode = fs::metadata(moved.join("hello.txt"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(moved_mode, 0o400, "chmod must land in the handle's dir");
let victim_mode = fs::metadata(orig.join("hello.txt"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
victim_mode, 0o644,
"victim at the swapped path must be untouched"
);
}
#[test]
fn occupied_output_error_escapes_hostile_root_name() {
let err = output_already_exists(
Path::new("/home/operator/Données/very-long-output-directory-name"),
OsStr::new("evil\u{202e}name"),
);
let msg = format!("{err}");
assert!(msg.contains("Output already exists"), "got: {msg}");
assert!(
msg.contains("Données"),
"operator path must stay raw: {msg}"
);
assert!(
msg.contains("evil\\u{202e}name"),
"hostile root name must be escaped: {msg}"
);
assert!(
!msg.contains('\u{202e}'),
"raw direction-override character leaked: {msg:?}"
);
let tmp = tempfile::TempDir::new().unwrap();
let name = "evil\u{202e}name";
fs::write(tmp.path().join(name), b"existing").unwrap();
let manifest = single_file_manifest(name, b"x");
let archive = build_archive(&manifest, &[(name, b"x")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Output already exists"), "got: {msg}");
assert!(
msg.contains("evil\\u{202e}name"),
"colliding name must appear escaped and in full: {msg}"
);
assert!(
!msg.contains('\u{202e}'),
"raw direction-override character leaked: {msg:?}"
);
}
#[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!(matches!(
err,
CryptoError::MalformedArchive {
reason: "trailing data after the file contents"
}
));
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(_))
}
#[test]
fn rejects_over_long_component_before_filesystem_output() {
let tmp = tempfile::TempDir::new().unwrap();
let name = "n".repeat(250);
let manifest = single_file_manifest(&name, b"x");
let archive = build_archive(&manifest, &[(name.as_str(), b"x")]);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(matches!(
err,
CryptoError::UnsafeArchivePath {
reason: crate::archive::path::COMPONENT_TOO_LONG,
..
}
));
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0, "rejection must precede any filesystem output");
}
#[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",
);
}
}