use std::ffi::{OsStr, OsString};
#[cfg(test)]
use std::fs;
use std::io::{self, Read};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use cap_std::fs::{Dir, File};
use crate::CryptoError;
use crate::crypto::stream::read_uninterrupted;
use crate::error::sanitize_for_display;
use crate::fs::paths::{
INCOMPLETE_SUFFIX, OUTPUT_LABEL, already_exists_error, path_occupied,
sanitize_path_keeping_parent,
};
use super::IncompleteOutputPolicy;
use super::format::{
copy_exact_n, parse_fca_header, parse_manifest_bytes, read_exact_fca, 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;
use super::platform::{ObjectId, OwnerComparison};
use super::reasons::{
ARCHIVE_EXT_PLATFORM_LIMIT, ARCHIVE_EXT_REGION_TRUNCATED, FILE_CONTENT_TRUNCATED,
MANIFEST_PLATFORM_LIMIT, MANIFEST_REGION_TRUNCATED, TRAILING_FILE_CONTENT,
};
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>(
reader: R,
output_dir: &Path,
limits: ArchiveLimits,
policy: IncompleteOutputPolicy,
) -> Result<PathBuf, CryptoError> {
unarchive_inner_with_hooks(
reader,
output_dir,
limits,
policy,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
}
type CompareOwners = fn(&Dir, &File) -> Result<OwnerComparison, CryptoError>;
struct Seams<B, A, M>
where
B: FnOnce() -> Result<(), CryptoError>,
A: FnOnce(&mut platform::PromotionOutcome) -> Result<(), CryptoError>,
M: FnOnce(&Option<StagedRoot>) -> Result<(), CryptoError>,
{
compare_owners: CompareOwners,
before_promotion: B,
after_promotion: A,
after_root_mode: M,
}
fn unarchive_inner_with_hooks<R, B, A, M>(
mut reader: R,
output_dir: &Path,
limits: ArchiveLimits,
policy: IncompleteOutputPolicy,
seams: Seams<B, A, M>,
) -> Result<PathBuf, CryptoError>
where
R: Read,
B: FnOnce() -> Result<(), CryptoError>,
A: FnOnce(&mut platform::PromotionOutcome) -> Result<(), CryptoError>,
M: FnOnce(&Option<StagedRoot>) -> Result<(), CryptoError>,
{
let Seams {
compare_owners,
before_promotion,
after_promotion,
after_root_mode,
} = seams;
let header = parse_fca_header(&mut reader, limits)?;
let archive_ext_len = require_fits_usize(header.archive_ext_len, ARCHIVE_EXT_PLATFORM_LIMIT)?;
let mut archive_ext_bytes = vec![0u8; archive_ext_len];
read_exact_fca(
&mut reader,
&mut archive_ext_bytes,
ARCHIVE_EXT_REGION_TRUNCATED,
)?;
validate_archive_ext_tlv(&archive_ext_bytes, &limits)?;
let manifest_len = require_fits_usize(header.manifest_len, MANIFEST_PLATFORM_LIMIT)?;
let mut manifest_bytes = vec![0u8; manifest_len];
read_exact_fca(&mut reader, &mut manifest_bytes, MANIFEST_REGION_TRUNCATED)?;
let manifest = parse_manifest_bytes(&manifest_bytes, header, limits)?;
reject_unsupported_directory_root(&manifest)?;
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 cleanup = StagedCleanup::new(&output_handle, &manifest, &policy);
let outcome: Result<PathBuf, CryptoError> = (|| {
if manifest.root_is_file {
extract_single_file_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
&mut cleanup.root,
output_dir,
)?;
} else {
extract_directory_root(
&mut reader,
&output_handle,
&incomplete_name,
&manifest,
&mut cleanup.root,
output_dir,
compare_owners,
)?;
}
before_promotion()?;
let mut promotion = promote_root(
&output_handle,
output_dir,
&incomplete_name,
&manifest.root_name,
manifest.root_is_file,
)
.map_err(|failure| promotion_failed(failure, output_dir, &manifest.root_name))?;
after_promotion(&mut promotion)?;
let mut extra_name_error = None;
if manifest.root_is_file {
let handle = cleanup
.root
.as_ref()
.and_then(StagedRoot::file_handle)
.ok_or(crate::error::internal_invariant!(
"promoted file root lost its handle"
))?;
extra_name_error = require_single_linked_file(handle, &manifest.root_name).err();
}
let staged_identity = cleanup
.root
.as_ref()
.map_or(StagedIdentity::NoHandle, StagedRoot::identity);
let ratified = if extra_name_error.is_some() {
Ok(PromotedIdentity::Unconfirmed)
} else if manifest.root_is_file {
apply_root_file_mode(&output_handle, &manifest, &staged_identity)
} else {
apply_root_directory_mode(&output_handle, &manifest, &staged_identity)
};
let mut ratified_root: Option<StagedRoot> = None;
if matches!(ratified, Ok(PromotedIdentity::Confirmed)) {
ratified_root = cleanup.root.take();
}
after_root_mode(&ratified_root)?;
let staged_id = staged_identity.known();
require_promoted_root(&output_handle, &manifest.root_name, staged_id)?;
if ratified_root.is_none() {
ratified_root = cleanup.root.take();
}
let anchor =
require_output_anchor_unchanged(&output_handle, output_dir, &manifest.root_name);
drop(ratified_root);
let error = match (anchor, promotion.into_staged_link_error()) {
(Ok(()), None) => None,
(Ok(()), Some(source)) => Some(staged_link_retained(
output_dir,
&incomplete_name,
&manifest.root_name,
source,
)),
(Err(error), None) => Some(error),
(Err(error), Some(source)) => Some(crate::error::append_report(
error,
&staged_link_report(&incomplete_name, &source),
)),
};
match (error, extra_name_error) {
(None, None) => Ok(final_path.clone()),
(Some(error), None) | (None, Some(error)) => Err(error),
(Some(error), Some(extra)) => {
Err(crate::error::append_report(error, &extra.to_string()))
}
}
})();
let outcome = cleanup.finish(outcome, output_dir, &incomplete_name);
drop(output_handle);
outcome
}
fn extract_single_file_root<R: Read>(
reader: &mut R,
output_handle: &Dir,
incomplete_name: &OsStr,
manifest: &Manifest,
staged_root: &mut Option<StagedRoot>,
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), || {
incomplete_output_exists(output_dir, incomplete_name)
})
})?;
match outfile.try_clone() {
Ok(recorded) => *staged_root = Some(StagedRoot::file(incomplete_name, Some(recorded))),
Err(e) => {
*staged_root = Some(StagedRoot::file(incomplete_name, Some(outfile)));
return Err(staged_handle_unavailable(&e));
}
}
copy_exact_n(reader, &mut outfile, entry.size, archive_content_truncated)?;
verify_archive_eof(reader)?;
platform::sync_single_file_durable(&outfile).map_err(CryptoError::Io)
}
fn extract_directory_root<R: Read>(
reader: &mut R,
output_handle: &Dir,
incomplete_name: &OsStr,
manifest: &Manifest,
staged_root: &mut Option<StagedRoot>,
output_dir: &Path,
compare_owners: CompareOwners,
) -> 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, || incomplete_output_exists(output_dir, incomplete_name))
})?;
let recorded = match retain_staged_directory(output_handle, incomplete_name, &root_dir) {
Ok(recorded) => recorded,
Err(e) => {
*staged_root = Some(StagedRoot::directory(incomplete_name, Some(root_dir)));
return Err(e);
}
};
*staged_root = Some(StagedRoot::directory(incomplete_name, Some(recorded)));
let dir_entries = descendant_directories(manifest, root_name_str);
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, platform::WalkSide::Extraction)?;
let _new_dir = platform::mkdir_strict(&parent_dir, &dir_name)
.map_err(|e| map_already_exists(e, || archive_path_collides(&dir_entry.path_utf8)))?;
}
let mut root_owner_confirmed = false;
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, platform::WalkSide::Extraction)?;
let mut outfile =
platform::create_file_at(&parent_dir, &file_name, platform::INITIAL_FILE_CREATE_MODE)
.map_err(|e| {
map_already_exists(CryptoError::Io(e), || {
archive_path_collides(&entry.path_utf8)
})
})?;
if !root_owner_confirmed {
require_staged_root_owner(
compare_owners(&root_dir, &outfile)?,
staged_root,
output_dir,
incomplete_name,
)?;
root_owner_confirmed = true;
}
copy_exact_n(reader, &mut outfile, entry.size, archive_content_truncated)?;
platform::chmod_file_handle(&outfile, entry.mode)?;
platform::sync_file_standard(&outfile).map_err(CryptoError::Io)?;
}
verify_archive_eof(reader)?;
if let Some(root) = staged_root {
root.mark_directory_modes_applied();
}
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_durable(dir_handle, dir_entry.mode)?;
}
platform::sync_dir_handle(&root_dir);
platform::sync_extraction_barrier(&root_dir).map_err(CryptoError::Io)?;
Ok(())
}
fn require_staged_root_owner(
comparison: OwnerComparison,
staged_root: &mut Option<StagedRoot>,
output_dir: &Path,
incomplete_name: &OsStr,
) -> Result<(), CryptoError> {
if comparison == OwnerComparison::Different {
*staged_root = None;
return Err(staged_root_replaced(output_dir, incomplete_name));
}
Ok(())
}
fn retain_staged_directory(
output_handle: &Dir,
incomplete_name: &OsStr,
root_dir: &Dir,
) -> Result<Dir, CryptoError> {
let retained = platform::retain_staged_dir(output_handle, incomplete_name, root_dir)
.map_err(|e| staged_handle_unavailable(&e))?;
if let (Some(created), Some(opened)) = (
platform::dir_object_id(root_dir)?,
platform::dir_object_id(&retained)?,
) {
require_staged_identity(created, opened, incomplete_name)?;
}
Ok(retained)
}
#[derive(Debug)]
enum PromotedIdentity {
Confirmed,
Unconfirmed,
}
fn apply_root_directory_mode(
output_handle: &Dir,
manifest: &Manifest,
staged: &StagedIdentity,
) -> Result<PromotedIdentity, CryptoError> {
if matches!(staged, StagedIdentity::Unavailable) {
return Ok(PromotedIdentity::Unconfirmed);
}
let root_name_str = manifest_root_name_str(manifest)?;
let root_dir = platform::open_dir_at_rel(output_handle, Path::new(root_name_str))?;
if let StagedIdentity::Known(staged_id) = staged {
let Some(found) = platform::dir_object_id(&root_dir)? else {
return Ok(PromotedIdentity::Unconfirmed);
};
require_staged_identity(*staged_id, found, &manifest.root_name)?;
let _ = platform::chmod_dir_handle(root_dir, manifest.root_mode);
return Ok(PromotedIdentity::Confirmed);
}
platform::chmod_dir_handle(root_dir, manifest.root_mode)?;
Ok(PromotedIdentity::Unconfirmed)
}
fn apply_root_file_mode(
output_handle: &Dir,
manifest: &Manifest,
staged: &StagedIdentity,
) -> Result<PromotedIdentity, CryptoError> {
if matches!(staged, StagedIdentity::Unavailable) {
return Ok(PromotedIdentity::Unconfirmed);
}
let file = platform::open_file_nofollow(output_handle, &manifest.root_name)?;
if let StagedIdentity::Known(staged_id) = staged {
let Some(found) = platform::file_object_id(&file)? else {
return Ok(PromotedIdentity::Unconfirmed);
};
require_staged_identity(*staged_id, found, &manifest.root_name)?;
let _ = platform::chmod_file_handle(&file, manifest.root_mode);
return Ok(PromotedIdentity::Confirmed);
}
platform::chmod_file_handle(&file, manifest.root_mode)?;
Ok(PromotedIdentity::Unconfirmed)
}
fn require_staged_identity(
staged_id: ObjectId,
found: ObjectId,
root_name: &OsStr,
) -> Result<(), CryptoError> {
if found != staged_id {
return Err(promoted_root_replaced(root_name));
}
Ok(())
}
fn require_promoted_root(
output_handle: &Dir,
root_name: &OsStr,
staged_id: Option<ObjectId>,
) -> Result<(), CryptoError> {
let Some(staged_id) = staged_id else {
return Ok(());
};
let promoted = match output_handle.symlink_metadata(root_name) {
Ok(promoted) => promoted,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(promoted_root_replaced(root_name));
}
Err(_) => return Ok(()),
};
match platform::metadata_object_id(&promoted) {
Some(found) => require_staged_identity(staged_id, found, root_name),
None => Ok(()),
}
}
fn require_output_anchor_unchanged(
output_handle: &Dir,
output_dir: &Path,
root_name: &OsStr,
) -> Result<(), CryptoError> {
let unconfirmed = |error| output_directory_unconfirmed(output_dir, root_name, error);
let opened = platform::open_anchor(output_dir);
if matches!(&opened, Err(CryptoError::Io(e)) if crate::fs::paths::path_no_longer_resolves(e)) {
return Err(output_directory_changed(output_dir, root_name));
}
let Some(current) = confirmation_step(opened).map_err(unconfirmed)? else {
return Ok(());
};
let Some(current_id) =
confirmation_step(platform::dir_object_id(¤t)).map_err(unconfirmed)?
else {
return Ok(());
};
let Some(committed_id) =
confirmation_step(platform::dir_object_id(output_handle)).map_err(unconfirmed)?
else {
return Ok(());
};
match (current_id, committed_id) {
(Some(current_id), Some(committed_id)) if current_id != committed_id => {
Err(output_directory_changed(output_dir, root_name))
}
_ => Ok(()),
}
}
fn confirmation_step<T>(step: Result<T, CryptoError>) -> Result<Option<T>, CryptoError> {
match step {
Ok(value) => Ok(Some(value)),
Err(error) if output_confirmation_resource_error(&error) => Ok(None),
Err(error) => Err(error),
}
}
fn output_confirmation_resource_error(error: &CryptoError) -> bool {
matches!(
error,
CryptoError::Io(error)
if matches!(error.raw_os_error(), Some(code) if RESOURCE_EXHAUSTION_CODES.contains(&code))
)
}
#[cfg(unix)]
const RESOURCE_EXHAUSTION_CODES: [i32; 3] = [libc::EMFILE, libc::ENFILE, libc::ENOMEM];
#[cfg(windows)]
const RESOURCE_EXHAUSTION_CODES: [i32; 4] = [
ERROR_TOO_MANY_OPEN_FILES,
ERROR_NOT_ENOUGH_MEMORY,
ERROR_OUTOFMEMORY,
ERROR_NO_SYSTEM_RESOURCES,
];
#[cfg(windows)]
const ERROR_TOO_MANY_OPEN_FILES: i32 = 4;
#[cfg(windows)]
const ERROR_NOT_ENOUGH_MEMORY: i32 = 8;
#[cfg(windows)]
const ERROR_OUTOFMEMORY: i32 = 14;
#[cfg(windows)]
const ERROR_NO_SYSTEM_RESOURCES: i32 = 1450;
fn promoted_root_replaced(root_name: &OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"Output was replaced while decrypting: {}",
sanitize_for_display(&root_name.to_string_lossy())
))
}
fn staged_root_replaced(output_dir: &Path, working_name: &OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"{STAGED_ROOT_REPLACED}: {}",
sanitize_path_keeping_parent(&output_dir.join(working_name))
))
}
const STAGED_ROOT_REPLACED: &str =
"Incomplete output belongs to another owner and was left in place";
fn staged_handle_unavailable(source: &io::Error) -> CryptoError {
CryptoError::Io(io::Error::new(
source.kind(),
format!("{STAGED_HANDLE_UNAVAILABLE}: {source}"),
))
}
const STAGED_HANDLE_UNAVAILABLE: &str = "Cannot hold a handle to the staged output";
fn output_directory_changed(output_dir: &Path, root_name: &OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"Output {} is complete but its directory changed: {}",
sanitize_for_display(&root_name.to_string_lossy()),
output_dir.display()
))
}
fn output_directory_unconfirmed(
output_dir: &Path,
root_name: &OsStr,
error: CryptoError,
) -> CryptoError {
let kind = match &error {
CryptoError::Io(source) => source.kind(),
_ => io::ErrorKind::Other,
};
CryptoError::Io(io::Error::new(
kind,
format!(
"Output {} is complete, but its directory could not be confirmed: {}: {error}",
sanitize_for_display(&root_name.to_string_lossy()),
output_dir.display()
),
))
}
fn staged_link_retained(
output_dir: &Path,
incomplete_name: &OsStr,
root_name: &OsStr,
source: io::Error,
) -> CryptoError {
CryptoError::Io(io::Error::new(
source.kind(),
format!(
"Output {} in {} is complete, but {}",
sanitize_for_display(&root_name.to_string_lossy()),
output_dir.display(),
staged_link_report(incomplete_name, &source),
),
))
}
fn staged_link_report(incomplete_name: &OsStr, source: &io::Error) -> String {
format!(
"temporary name {} could not be removed: {source}",
sanitize_for_display(&incomplete_name.to_string_lossy()),
)
}
fn require_single_linked_file(handle: &File, root_name: &OsStr) -> Result<(), CryptoError> {
use cap_fs_ext::MetadataExt;
let metadata = handle.metadata().map_err(|source| {
CryptoError::Io(io::Error::new(
source.kind(),
format!(
"Output {} is complete, but its number of filesystem names could not be read: {source}",
sanitize_for_display(&root_name.to_string_lossy()),
),
))
})?;
let link_count = metadata.nlink();
if link_count != 1 {
return Err(crate::fs::atomic::committed_link_count_error(
&sanitize_for_display(&root_name.to_string_lossy()),
link_count,
));
}
Ok(())
}
fn promote_root(
output_handle: &Dir,
output_dir: &Path,
incomplete_name: &OsStr,
final_name: &OsStr,
root_is_file: bool,
) -> Result<platform::PromotionOutcome, platform::PromotionFailure> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let _ = output_dir;
platform::rename_at_no_clobber(output_handle, incomplete_name, final_name, root_is_file)
}
#[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)
.map(|()| platform::PromotionOutcome::for_tempfile_file_promotion())
} else {
rename_no_clobber(&working_path, &final_path)
.map(|()| platform::PromotionOutcome::Clean)
}
.map_err(platform::PromotionFailure::plain)
}
}
fn promotion_failed(
failure: platform::PromotionFailure,
output_dir: &Path,
root_name: &OsStr,
) -> CryptoError {
let error = if failure.error.kind() == io::ErrorKind::AlreadyExists {
output_already_exists(output_dir, root_name)
} else {
CryptoError::Io(failure.error)
};
if failure.claim_left {
crate::error::append_report(error, &claim_left_report(output_dir, root_name))
} else {
error
}
}
fn claim_left_report(output_dir: &Path, root_name: &OsStr) -> String {
format!(
"the output name {} may still be occupied by an entry this run did not remove",
sanitize_path_keeping_parent(&output_dir.join(root_name))
)
}
fn reject_unsupported_directory_root(manifest: &Manifest) -> Result<(), CryptoError> {
if !platform::DIRECTORY_PROMOTION_SUPPORTED && !manifest.root_is_file {
return Err(CryptoError::InvalidInput(
"Decrypting a directory is not supported on this target".to_string(),
));
}
Ok(())
}
fn archive_content_truncated() -> CryptoError {
CryptoError::MalformedArchive {
reason: FILE_CONTENT_TRUNCATED,
}
}
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_FILE_CONTENT,
}),
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
}
enum StagedRoot {
File {
working_name: OsString,
handle: Option<File>,
},
Directory {
working_name: OsString,
handle: Option<Dir>,
modes_applied: bool,
},
}
impl StagedRoot {
fn file(working_name: &OsStr, handle: Option<File>) -> Self {
Self::File {
working_name: working_name.to_os_string(),
handle,
}
}
fn directory(working_name: &OsStr, handle: Option<Dir>) -> Self {
Self::Directory {
working_name: working_name.to_os_string(),
handle,
modes_applied: false,
}
}
fn mark_directory_modes_applied(&mut self) {
if let Self::Directory { modes_applied, .. } = self {
*modes_applied = true;
}
}
fn identity(&self) -> StagedIdentity {
let read = match self {
Self::File { handle, .. } => handle.as_ref().map(platform::file_object_id),
Self::Directory { handle, .. } => handle.as_ref().map(platform::dir_object_id),
};
match read {
Some(Ok(Some(id))) => StagedIdentity::Known(id),
Some(Ok(None) | Err(_)) => StagedIdentity::Unavailable,
None => StagedIdentity::NoHandle,
}
}
fn file_handle(&self) -> Option<&File> {
match self {
Self::File { handle, .. } => handle.as_ref(),
Self::Directory { .. } => None,
}
}
fn remove(self, output_handle: &Dir, manifest: &Manifest) -> CleanupOutcome {
match self {
Self::File {
working_name,
handle,
} => {
let emptied = handle.as_ref().is_some_and(|h| h.set_len(0).is_ok());
drop(handle);
match output_handle.remove_file(&working_name) {
Ok(()) => CleanupOutcome::Removed,
Err(e) if emptied && e.kind() == io::ErrorKind::NotFound => {
CleanupOutcome::Removed
}
Err(cause) => CleanupOutcome::Unconfirmed { cause, emptied },
}
}
Self::Directory {
working_name,
handle,
modes_applied,
} => remove_staged_directory(
output_handle,
&working_name,
handle,
modes_applied.then_some(manifest),
),
}
}
}
struct StagedCleanup<'a> {
root: Option<StagedRoot>,
output_handle: &'a Dir,
manifest: &'a Manifest,
delete_on_error: bool,
}
impl<'a> StagedCleanup<'a> {
fn new(
output_handle: &'a Dir,
manifest: &'a Manifest,
policy: &IncompleteOutputPolicy,
) -> Self {
Self {
root: None,
output_handle,
manifest,
delete_on_error: matches!(policy, IncompleteOutputPolicy::DeleteOnError),
}
}
fn finish(
mut self,
outcome: Result<PathBuf, CryptoError>,
output_dir: &Path,
working_name: &OsStr,
) -> Result<PathBuf, CryptoError> {
let staged = self.root.take();
outcome.map_err(|error| match staged {
Some(root) if self.delete_on_error => root
.remove(self.output_handle, self.manifest)
.report(error, output_dir, working_name),
_ => error,
})
}
}
impl Drop for StagedCleanup<'_> {
fn drop(&mut self) {
if !self.delete_on_error {
return;
}
if let Some(root) = self.root.take() {
let (output_handle, manifest) = (self.output_handle, self.manifest);
let _ = catch_unwind(AssertUnwindSafe(|| root.remove(output_handle, manifest)));
}
}
}
enum CleanupOutcome {
Removed,
Unconfirmed { cause: io::Error, emptied: bool },
}
impl CleanupOutcome {
fn from_removal(result: io::Result<()>) -> Self {
match result {
Ok(()) => Self::Removed,
Err(cause) => Self::Unconfirmed {
cause,
emptied: false,
},
}
}
fn no_handle() -> Self {
Self::Unconfirmed {
cause: io::Error::other("no handle to the staged output was held"),
emptied: false,
}
}
fn report(self, error: CryptoError, output_dir: &Path, working_name: &OsStr) -> CryptoError {
let Self::Unconfirmed { cause, emptied } = self else {
return error;
};
let shown = sanitize_path_keeping_parent(&output_dir.join(working_name));
let report = if emptied {
format!("the incomplete output {shown} was emptied but could not be removed: {cause}")
} else {
format!(
"the incomplete output {shown} could not be removed and may still hold plaintext: {cause}"
)
};
crate::error::append_report(error, &report)
}
}
enum StagedIdentity {
Known(ObjectId),
NoHandle,
Unavailable,
}
impl StagedIdentity {
fn known(self) -> Option<ObjectId> {
match self {
Self::Known(id) => Some(id),
Self::NoHandle | Self::Unavailable => None,
}
}
}
#[cfg(unix)]
fn remove_staged_directory(
_output_handle: &Dir,
_working_name: &OsStr,
handle: Option<Dir>,
restore: Option<&Manifest>,
) -> CleanupOutcome {
let Some(handle) = handle else {
return CleanupOutcome::no_handle();
};
if let Some(manifest) = restore {
restore_descendant_access(&handle, manifest);
}
CleanupOutcome::from_removal(handle.remove_open_dir_all())
}
#[cfg(unix)]
fn restore_descendant_access(root: &Dir, manifest: &Manifest) {
let Ok(root_name_str) = manifest_root_name_str(manifest) else {
return;
};
for dir_entry in descendant_directories(manifest, root_name_str) {
let Ok(rel) = strip_root_prefix(&dir_entry.path_utf8, root_name_str) else {
continue;
};
let Ok((parent_dir, dir_name)) =
platform::walk_to_parent(root, rel, platform::WalkSide::Extraction)
else {
continue;
};
let _ = platform::restore_owner_access(&parent_dir, &dir_name);
}
}
#[cfg(not(unix))]
fn remove_staged_directory(
output_handle: &Dir,
working_name: &OsStr,
handle: Option<Dir>,
_restore: Option<&Manifest>,
) -> CleanupOutcome {
let Some(handle) = handle else {
return CleanupOutcome::no_handle();
};
let staged = platform::dir_object_id(&handle);
let at_name = output_handle
.symlink_metadata(working_name)
.map(|metadata| platform::metadata_object_id(&metadata));
drop(handle);
let (staged, at_name) = match (staged, at_name) {
(Ok(Some(staged)), Ok(Some(at_name))) => (staged, at_name),
(Ok(None), _) | (_, Ok(None)) => {
return CleanupOutcome::from_removal(Err(io::Error::other(
"no filesystem identity confirms that the entry at the working name is the \
staged directory",
)));
}
(Err(e), _) => return CleanupOutcome::from_removal(Err(io::Error::other(e.to_string()))),
(_, Err(e)) => return CleanupOutcome::from_removal(Err(e)),
};
if staged != at_name {
return CleanupOutcome::from_removal(Err(io::Error::other(
"the working name no longer denotes the staged directory",
)));
}
CleanupOutcome::from_removal(output_handle.remove_dir_all(working_name))
}
fn output_already_exists(output_dir: &Path, root_name: &OsStr) -> CryptoError {
already_exists_error(OUTPUT_LABEL, &output_dir.join(root_name))
}
const INCOMPLETE_OUTPUT_LABEL: &str = "Incomplete output";
const ARCHIVE_PATH_COLLIDES: &str = "Archive path collides with an existing entry";
fn incomplete_output_exists(output_dir: &Path, working_name: &OsStr) -> CryptoError {
already_exists_error(INCOMPLETE_OUTPUT_LABEL, &output_dir.join(working_name))
}
fn archive_path_collides(path_utf8: &str) -> CryptoError {
CryptoError::InvalidInput(format!(
"{ARCHIVE_PATH_COLLIDES}: {}",
sanitize_for_display(path_utf8)
))
}
fn map_already_exists(e: CryptoError, on_exists: impl FnOnce() -> CryptoError) -> CryptoError {
if let CryptoError::Io(io_err) = &e {
if io_err.kind() == io::ErrorKind::AlreadyExists {
return on_exists();
}
}
e
}
fn manifest_root_name_str(manifest: &Manifest) -> Result<&str, CryptoError> {
manifest
.root_name
.to_str()
.ok_or(crate::error::internal_invariant!(
"manifest root name is not valid UTF-8"
))
}
fn descendant_directories<'a>(
manifest: &'a Manifest,
root_name_str: &str,
) -> Vec<&'a ArchiveEntry> {
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));
dir_entries
}
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(crate::error::internal_invariant!(
"manifest entry missing root prefix"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use crate::archive::fd_limit;
use crate::archive::format::{serialize_manifest_unchecked, write_fca_header};
use std::io::Cursor;
#[test]
fn a_promotion_reports_an_output_name_it_left_occupied() {
let failure = platform::PromotionFailure {
error: io::Error::other("rename refused"),
claim_left: true,
};
let message =
promotion_failed(failure, Path::new("/out"), OsStr::new("payload")).to_string();
assert!(
message.contains("rename refused")
&& message.contains("payload")
&& message.contains("may still be occupied"),
"the failure and the name left occupied must both be reported, got: {message}"
);
}
#[test]
fn a_confirmation_step_keeps_a_value_and_skips_only_exhaustion() {
assert!(matches!(confirmation_step(Ok(7)), Ok(Some(7))));
let exhausted = CryptoError::Io(io::Error::from_raw_os_error(RESOURCE_EXHAUSTION_CODES[0]));
assert!(
matches!(confirmation_step::<()>(Err(exhausted)), Ok(None)),
"exhaustion must leave the confirmation unavailable"
);
let denied = CryptoError::Io(io::Error::from(io::ErrorKind::PermissionDenied));
assert!(
confirmation_step::<()>(Err(denied)).is_err(),
"permission denial must propagate rather than skip the confirmation"
);
}
#[test]
#[ignore = "holds every free descriptor; needs --test-threads=1"]
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn descriptor_exhaustion_leaves_the_destination_confirmation_unavailable() {
let tmp = tempfile::TempDir::new().unwrap();
let output = tmp.path().join("out");
let other = tmp.path().join("other");
fs::create_dir(&output).unwrap();
fs::create_dir(&other).unwrap();
let handle = platform::open_anchor(&output).expect("open the destination");
fs::remove_dir(&output).unwrap();
fs::rename(&other, &output).unwrap();
assert!(
require_output_anchor_unchanged(&handle, &output, OsStr::new("payload")).is_err(),
"the substituted destination must be reported while descriptors are free"
);
let held = fd_limit::HeldDescriptors::leaving(0);
let outcome = require_output_anchor_unchanged(&handle, &output, OsStr::new("payload"));
drop(held);
assert!(
outcome.is_ok(),
"exhaustion must skip the confirmation, not fail the run, got {outcome:?}"
);
}
#[test]
#[cfg(unix)]
fn an_unreadable_final_name_is_not_a_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).expect("open the destination");
let staged = platform::dir_object_id(&handle)
.expect("read the destination identity")
.expect("a Unix filesystem reports a device and inode");
let long_name = "n".repeat(4096);
let long_name = OsStr::new(&long_name);
let read = handle
.symlink_metadata(long_name)
.expect_err("a name this long cannot be read");
assert_ne!(
read.kind(),
io::ErrorKind::NotFound,
"the read must fail on the name rather than report the entry missing"
);
let outcome = require_promoted_root(&handle, long_name, Some(staged));
assert!(
outcome.is_ok(),
"a read failure that is not `NotFound` must not report a replacement, got {outcome:?}"
);
}
#[test]
fn a_promotion_that_left_no_claim_reports_the_failure_alone() {
let failure = platform::PromotionFailure::plain(io::Error::other("rename refused"));
let message =
promotion_failed(failure, Path::new("/out"), OsStr::new("payload")).to_string();
assert!(
!message.contains("may still be occupied"),
"nothing of this run's was left, so nothing is reported, got: {message}"
);
}
#[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,
}
}
#[test]
fn directory_root_allowed_on_supported_platform() {
let manifest = dir_with_one_undersized_file_manifest();
assert!(!manifest.root_is_file);
reject_unsupported_directory_root(&manifest)
.expect("a directory root must be allowed on a supported platform");
}
fn unarchive_with_policy(
archive: Vec<u8>,
tmp: &Path,
policy: IncompleteOutputPolicy,
) -> Result<PathBuf, CryptoError> {
unarchive(Cursor::new(archive), tmp, ArchiveLimits::default(), policy)
}
fn unarchive_with_limits(
archive: Vec<u8>,
tmp: &Path,
limits: ArchiveLimits,
) -> Result<PathBuf, CryptoError> {
unarchive(
Cursor::new(archive),
tmp,
limits,
IncompleteOutputPolicy::DeleteOnError,
)
}
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"))]
struct SwapOnEof<F: FnMut()> {
data: Vec<u8>,
pos: usize,
swapped: bool,
swap: F,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
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)
}
}
fn directory_root_manifest() -> 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,
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn promotion_is_anchored_to_handle_across_output_dir_swap() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let archive = build_archive(&directory_root_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 err = unarchive(
reader,
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.unwrap_err();
let rendered = format!("{err}");
assert!(
rendered.starts_with("Output d is complete but its directory changed"),
"expected the moved destination to be reported by name, got: {err}"
);
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"
);
}
#[test]
fn a_staged_root_with_another_owner_is_refused_before_any_content_and_left_in_place() {
fn another_owner(_: &Dir, _: &File) -> Result<OwnerComparison, CryptoError> {
Ok(OwnerComparison::Different)
}
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/sub", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/sub/a.txt", ArchiveEntryKind::File, 6, 0o644),
make_entry("d/b.txt", ArchiveEntryKind::File, 6, 0o644),
],
total_file_bytes: 12,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
let contents: &[(&str, &[u8])] = &[("d/sub/a.txt", b"secret"), ("d/b.txt", b"second")];
for policy in [
IncompleteOutputPolicy::DeleteOnError,
IncompleteOutputPolicy::RetainOnError,
] {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let staged = out.join("d.incomplete");
let err = unarchive_inner_with_hooks(
Cursor::new(build_archive(&manifest, contents)),
&out,
ArchiveLimits::default(),
policy.clone(),
Seams {
compare_owners: another_owner,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
let message = err.to_string();
assert!(
matches!(err, CryptoError::InvalidInput(_))
&& message.contains(STAGED_ROOT_REPLACED)
&& message.contains("d.incomplete"),
"{policy:?}: expected the replaced staged root to be named, got: {message}"
);
assert!(
staged.is_dir(),
"{policy:?}: the directory is not the run's and must stay in place"
);
assert!(!out.join("d").exists(), "{policy:?}: nothing was promoted");
assert!(staged.join("sub").is_dir(), "{policy:?}");
assert_eq!(
fs::metadata(staged.join("sub").join("a.txt"))
.unwrap()
.len(),
0,
"{policy:?}: the run must stop before writing content"
);
assert!(
!staged.join("b.txt").exists(),
"{policy:?}: no later file may be created"
);
let retry = unarchive_inner(
Cursor::new(build_archive(&manifest, contents)),
&out,
ArchiveLimits::default(),
policy,
)
.unwrap_err();
assert!(
retry.to_string().contains(INCOMPLETE_OUTPUT_LABEL),
"the entry left in place must block a retry, got: {retry}"
);
}
}
#[test]
fn an_unavailable_owner_comparison_does_not_refuse_the_run() {
fn unavailable(_: &Dir, _: &File) -> Result<OwnerComparison, CryptoError> {
Ok(OwnerComparison::Unavailable)
}
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let path = unarchive_inner_with_hooks(
Cursor::new(build_archive(
&directory_root_manifest(),
&[("d/a.txt", b"real")],
)),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: unavailable,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap();
assert_eq!(path, out.join("d"));
assert_eq!(fs::read(out.join("d").join("a.txt")).unwrap(), b"real");
assert!(!out.join("d.incomplete").exists());
}
#[test]
fn substituted_staging_entry_is_not_reported_as_the_output() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let archive = build_archive(&directory_root_manifest(), &[("d/a.txt", b"real")]);
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::rename(out.join("d.incomplete"), out.join("moved-aside"))?;
fs::create_dir(out.join("d.incomplete"))?;
fs::write(out.join("d.incomplete").join("a.txt"), b"attacker")?;
Ok(())
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"expected the substitution to be reported, got: {err}"
);
assert_eq!(
fs::read(out.join("d").join("a.txt")).unwrap(),
b"attacker",
"the entry the attacker planted stays where it is",
);
#[cfg(unix)]
assert!(
!out.join("moved-aside").exists(),
"DeleteOnError must still remove the staged plaintext",
);
#[cfg(windows)]
assert_eq!(
fs::read(out.join("moved-aside").join("a.txt")).unwrap(),
b"real",
"Windows removes a staged directory by name, so a moved-aside tree survives",
);
}
#[cfg(windows)]
#[test]
fn a_junction_repointed_before_promotion_is_not_reported_as_the_output() {
for root_is_file in [true, false] {
let tmp = tempfile::TempDir::new().unwrap();
let held = tmp.path().join("held");
let decoy = tmp.path().join("decoy");
fs::create_dir(&held).unwrap();
fs::create_dir(&decoy).unwrap();
let out = tmp.path().join("out");
platform::try_make_junction(&held, &out).unwrap();
let (archive, root, planted) = if root_is_file {
let manifest = single_file_manifest("f.txt", b"real");
fs::write(decoy.join("f.txt.incomplete"), b"attacker").unwrap();
(
build_archive(&manifest, &[("f.txt", b"real")]),
"f.txt",
decoy.join("f.txt"),
)
} else {
fs::create_dir(decoy.join("d.incomplete")).unwrap();
fs::write(decoy.join("d.incomplete").join("a.txt"), b"attacker").unwrap();
(
build_archive(&directory_root_manifest(), &[("d/a.txt", b"real")]),
"d",
decoy.join("d").join("a.txt"),
)
};
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::remove_dir(&out)?;
platform::try_make_junction(&decoy, &out)?;
Ok(())
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"root_is_file={root_is_file}: expected the missing final name to be reported, got: {err}"
);
assert!(
!held.join(root).exists(),
"root_is_file={root_is_file}: nothing was promoted in the held directory"
);
assert!(
!held
.join(incomplete_working_name(OsStr::new(root)))
.exists(),
"root_is_file={root_is_file}: DeleteOnError must remove the staged output"
);
assert_eq!(
fs::read(&planted).unwrap(),
b"attacker",
"root_is_file={root_is_file}: the decoy was promoted inside the writer's directory",
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn root_mode_is_not_applied_to_a_substituted_entry() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
let elsewhere = tmp.path().join("elsewhere");
fs::create_dir(&out).unwrap();
fs::create_dir(&elsewhere).unwrap();
let secret = elsewhere.join("private.key");
fs::write(&secret, b"key material").unwrap();
fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).unwrap();
let mut manifest = single_file_manifest("f.txt", b"real");
manifest.root_mode = 0o666;
manifest.entries[0].mode = 0o666;
let archive = build_archive(&manifest, &[("f.txt", b"real")]);
let out_for_swap = out.clone();
let secret_for_swap = secret.clone();
let reader = SwapOnEof {
data: archive,
pos: 0,
swapped: false,
swap: move || {
let staged = out_for_swap.join("f.txt.incomplete");
fs::rename(&staged, out_for_swap.join("moved-aside")).unwrap();
fs::hard_link(&secret_for_swap, &staged).unwrap();
},
};
let err = unarchive(
reader,
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"expected the substitution to be reported, got: {err}"
);
let mode = fs::metadata(&secret).unwrap().permissions().mode() & 0o7777;
assert_eq!(
mode, 0o600,
"the substituted entry must keep its own mode, got 0o{mode:o}",
);
}
#[test]
fn cleanup_of_a_staged_file_moved_aside_still_destroys_its_plaintext() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let manifest = single_file_manifest("f.txt", b"real");
let archive = build_archive(&manifest, &[("f.txt", b"real")]);
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
let staged = out.join("f.txt.incomplete");
fs::rename(&staged, out.join("moved-aside"))?;
fs::write(&staged, b"attacker")?;
Ok(())
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"expected the substitution to be reported, got: {err}"
);
assert_eq!(
fs::read(out.join("moved-aside")).unwrap(),
b"",
"the staged plaintext must not survive at the name it was moved to",
);
assert!(
!format!("{err}").contains("could not be removed"),
"an emptied file whose name is gone must not be reported as left behind: {err}"
);
assert_eq!(fs::read(out.join("f.txt")).unwrap(), b"attacker");
}
#[test]
fn a_promoted_root_that_is_gone_is_reported_as_replaced() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let staged_file =
platform::create_file_at(&handle, OsStr::new("f.txt.incomplete"), 0o600).unwrap();
let staged = StagedRoot::file(OsStr::new("f.txt.incomplete"), staged_file.try_clone().ok());
let staged_id = staged.identity().known();
let err = require_promoted_root(&handle, OsStr::new("f.txt"), staged_id).unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"expected the missing final name to be reported, got: {err}"
);
}
#[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_truncated_archive_ext_region_at_every_byte() {
use crate::archive::format::FCA_HEADER_SIZE;
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let ignorable = tlv_bytes(0x0001, b"meta");
let full = build_archive_prefix_with_archive_ext(&manifest, &ignorable);
for cut in 0..ignorable.len() {
let tmp = tempfile::TempDir::new().unwrap();
let archive = full[..FCA_HEADER_SIZE + cut].to_vec();
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: ARCHIVE_EXT_REGION_TRUNCATED
}
),
"archive_ext cut at {cut} bytes must reject as truncated region, got {err:?}",
);
assert_eq!(
fs::read_dir(tmp.path()).unwrap().count(),
0,
"no filesystem output may exist after an archive_ext truncation"
);
}
}
#[test]
fn rejects_truncated_manifest_region_at_every_byte() {
use crate::archive::format::FCA_HEADER_SIZE;
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let full = build_archive_prefix(&manifest);
let manifest_len = full.len() - FCA_HEADER_SIZE;
for cut in 0..manifest_len {
let tmp = tempfile::TempDir::new().unwrap();
let archive = full[..FCA_HEADER_SIZE + cut].to_vec();
let err = unarchive_default(archive, tmp.path()).unwrap_err();
assert!(
matches!(
err,
CryptoError::MalformedArchive {
reason: MANIFEST_REGION_TRUNCATED
}
),
"manifest cut at {cut} bytes must reject as truncated region, got {err:?}",
);
assert_eq!(
fs::read_dir(tmp.path()).unwrap().count(),
0,
"no filesystem output may exist after a manifest truncation"
);
}
}
#[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");
}
#[test]
fn delete_on_error_removes_an_incomplete_file_root() {
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 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();
let outcome = StagedRoot::file(OsStr::new("root.incomplete"), None)
.remove(&handle, &directory_root_manifest());
assert!(matches!(outcome, CleanupOutcome::Removed));
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 cleanup_of_staged_file_leaves_substituted_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let substitute = tmp.path().join("root.incomplete");
fs::create_dir(&substitute).unwrap();
fs::write(substitute.join("keep.txt"), b"not ours").unwrap();
let outcome = StagedRoot::file(OsStr::new("root.incomplete"), None)
.remove(&handle, &directory_root_manifest());
assert!(
matches!(outcome, CleanupOutcome::Unconfirmed { .. }),
"a removal that left the working name occupied is reported, not swallowed",
);
assert_eq!(
fs::read(substitute.join("keep.txt")).unwrap(),
b"not ours",
"a substituted directory must not be removed",
);
}
#[test]
fn cleanup_of_staged_directory_follows_the_staged_handle() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let staged_path = tmp.path().join("root.incomplete");
let created = platform::mkdir_strict(&handle, OsStr::new("root.incomplete")).unwrap();
let staged_handle =
platform::retain_staged_dir(&handle, OsStr::new("root.incomplete"), &created).unwrap();
drop(created);
fs::write(staged_path.join("plaintext.txt"), b"staged plaintext").unwrap();
let moved = tmp.path().join("moved-aside");
fs::rename(&staged_path, &moved).unwrap();
fs::create_dir(&staged_path).unwrap();
fs::write(staged_path.join("keep.txt"), b"not ours").unwrap();
let outcome = StagedRoot::directory(OsStr::new("root.incomplete"), Some(staged_handle))
.remove(&handle, &directory_root_manifest());
#[cfg(unix)]
{
assert!(matches!(outcome, CleanupOutcome::Removed));
assert!(
!moved.exists(),
"the staged tree must be removed wherever it was moved to",
);
}
#[cfg(windows)]
{
assert!(
matches!(outcome, CleanupOutcome::Unconfirmed { .. }),
"a staged tree that was not found by name is reported as not removed",
);
assert_eq!(
fs::read(moved.join("plaintext.txt")).unwrap(),
b"staged plaintext",
"Windows removes by name, so a moved-aside staged tree is left",
);
}
assert_eq!(
fs::read(staged_path.join("keep.txt")).unwrap(),
b"not ours",
"a substituted directory must not be removed",
);
}
#[cfg(windows)]
#[test]
fn a_retained_staged_directory_must_be_the_created_one() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let created = platform::mkdir_strict(&handle, OsStr::new("d.incomplete")).unwrap();
let _other = platform::mkdir_strict(&handle, OsStr::new("other")).unwrap();
retain_staged_directory(&handle, OsStr::new("d.incomplete"), &created)
.expect("the retained handle denotes the created directory");
let err = retain_staged_directory(&handle, OsStr::new("other"), &created).unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"a handle on another directory must be refused, got: {err}"
);
}
#[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"
);
}
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)
}
}
fn unarchive_expecting_panic<R, B, A, M>(
reader: R,
out: &Path,
policy: IncompleteOutputPolicy,
seams: Seams<B, A, M>,
) -> Box<dyn std::any::Any + Send>
where
R: Read,
B: FnOnce() -> Result<(), CryptoError>,
A: FnOnce(&mut platform::PromotionOutcome) -> Result<(), CryptoError>,
M: FnOnce(&Option<StagedRoot>) -> Result<(), CryptoError>,
{
catch_unwind(AssertUnwindSafe(|| {
unarchive_inner_with_hooks(reader, out, ArchiveLimits::default(), policy, seams)
}))
.expect_err("expected the panic to propagate out of the extraction")
}
fn unarchive_with_panicking_reader(
archive: Vec<u8>,
out: &Path,
policy: IncompleteOutputPolicy,
) {
let panic_at = archive.len() as u64 - 1;
let reader = PanicAfterN {
inner: Cursor::new(archive),
bytes_read: 0,
panic_at,
};
unarchive_expecting_panic(
reader,
out,
policy,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
);
}
#[test]
fn panic_during_extraction_removes_incomplete_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!")]);
unarchive_with_panicking_reader(archive, tmp.path(), IncompleteOutputPolicy::DeleteOnError);
assert!(
!tmp.path().join("hello.txt.incomplete").exists(),
"DeleteOnError must remove the staged file during the unwind"
);
assert!(!tmp.path().join("hello.txt").exists());
}
#[test]
fn panic_during_extraction_keeps_incomplete_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!")]);
unarchive_with_panicking_reader(archive, tmp.path(), IncompleteOutputPolicy::RetainOnError);
assert!(
tmp.path().join("hello.txt.incomplete").exists(),
"RetainOnError must keep the staged file across the unwind"
);
}
#[test]
fn panic_after_ratification_preserves_the_committed_output() {
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 payload = unarchive_expecting_panic(
Cursor::new(archive),
tmp.path(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |ratified: &Option<StagedRoot>| -> Result<(), CryptoError> {
if ratified.is_none() {
panic!("the record must have left the cleanup slot");
}
panic!("test-induced panic after ratification")
},
},
);
assert_eq!(
payload.downcast_ref::<&str>().copied(),
Some("test-induced panic after ratification")
);
assert_eq!(
fs::read(tmp.path().join("hello.txt")).unwrap(),
b"Hello, world!"
);
assert!(!tmp.path().join("hello.txt.incomplete").exists());
}
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, &StagedIdentity::NoHandle).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, &StagedIdentity::NoHandle).unwrap_err();
assert!(
format!("{err}").contains("Symlink in extraction path"),
"expected symlink rejection, got: {err}",
);
}
#[cfg(unix)]
#[test]
fn apply_root_file_mode_rejects_fifo_at_renamed_root() {
let tmp = tempfile::TempDir::new().unwrap();
crate::fs::paths::make_fifo(&tmp.path().join("hello.txt"));
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, &StagedIdentity::NoHandle).unwrap_err();
assert!(
format!("{err}").contains("Extraction path is no longer a regular file"),
"expected regular-file 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, &StagedIdentity::NoHandle).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, &StagedIdentity::NoHandle).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 readable: {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{200b}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{200b}name"),
"colliding name must appear escaped and in full: {msg}"
);
assert!(
!msg.contains('\u{200b}'),
"raw zero-width 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 a case-folding volume; case-sensitive volumes extract both names"]
fn non_ascii_case_collision_falls_through_to_create_new() {
let tmp = ferrocrypt_test_support::fs_matrix_tempdir().unwrap();
let upper = "\u{00C9}cole.txt";
let lower = "\u{00E9}cole.txt";
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
make_entry(&format!("root/{upper}"), ArchiveEntryKind::File, 5, 0o644),
make_entry(&format!("root/{lower}"), 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/{upper}"), b"AAAAA"),
(&format!("root/{lower}"), b"BBBBB"),
],
);
let err = unarchive_default(archive, tmp.path()).unwrap_err();
let s = format!("{err}");
assert!(
matches!(err, CryptoError::InvalidInput(_)) && s.contains(ARCHIVE_PATH_COLLIDES),
"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("Incomplete output already exists"));
assert!(
stale_path.exists(),
"pre-existing .incomplete must be preserved across a retry",
);
}
#[test]
fn incomplete_output_error_escapes_hostile_root_name() {
let err = incomplete_output_exists(
Path::new("/home/operator/Données"),
&incomplete_working_name(OsStr::new("evil\u{202e}name")),
);
let msg = format!("{err}");
assert!(
msg.contains("Incomplete output already exists"),
"got: {msg}"
);
assert!(
msg.contains("Données"),
"operator path must stay readable: {msg}"
);
assert!(
msg.contains("evil\\u{202e}name.incomplete"),
"hostile working name must be escaped: {msg}"
);
assert!(
!msg.contains('\u{202e}'),
"raw direction-override character leaked: {msg:?}"
);
}
#[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::reasons::COMPONENT_TOO_LONG,
..
}
));
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0, "rejection must precede any filesystem output");
}
#[test]
fn rejects_bidi_formatting_control_before_filesystem_output() {
assert_entry_name_rejected_as_format_control("holiday\u{202e}gpj.sh");
}
#[test]
fn rejects_line_separator_before_filesystem_output() {
assert_entry_name_rejected_as_format_control("two\u{2028}lines.txt");
}
fn assert_entry_name_rejected_as_format_control(name: &str) {
let tmp = tempfile::TempDir::new().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();
assert!(matches!(
err,
CryptoError::UnsafeArchivePath {
reason: crate::archive::reasons::COMPONENT_FORMAT_CONTROL,
..
}
));
let count = fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 0, "rejection must precede any filesystem output");
}
#[test]
fn accepts_bidi_direction_mark_in_entry_name() {
for mark in ['\u{061c}', '\u{200e}', '\u{200f}'] {
let tmp = tempfile::TempDir::new().unwrap();
let name = format!("שלום{mark}-report.txt");
let manifest = single_file_manifest(&name, b"x");
let archive = build_archive(&manifest, &[(name.as_str(), b"x")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
assert_eq!(final_path.file_name().unwrap().to_str().unwrap(), name);
assert_eq!(fs::read(&final_path).unwrap(), b"x");
}
}
#[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",
);
}
#[cfg(unix)]
#[test]
fn extracts_descendant_directory_mode_without_read_bit() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o700),
make_entry("root/searchonly", ArchiveEntryKind::Directory, 0, 0o311),
make_entry(
"root/searchonly/inner.txt",
ArchiveEntryKind::File,
5,
0o600,
),
],
total_file_bytes: 5,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o700,
};
let archive = build_archive(&manifest, &[("root/searchonly/inner.txt", b"inner")]);
let final_path = unarchive_default(archive, tmp.path()).unwrap();
let dir = final_path.join("searchonly");
let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o7777;
assert_eq!(mode, 0o311, "expected mode 0o311, got 0o{mode:o}");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
const RUN_PLAINTEXT: &[u8] = b"run-plaintext-marker";
#[cfg(any(target_os = "linux", target_os = "macos"))]
const LOCKED_DESCENDANT_MODE: u32 = 0o500;
#[cfg(any(target_os = "linux", target_os = "macos"))]
const OUTSIDER_PLAINTEXT: &[u8] = b"outsider-content";
#[cfg(any(target_os = "linux", target_os = "macos"))]
const PLANTED_DIR_MODE: u32 = 0o711;
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Interference {
None,
MoveAsideLeaveNothing,
MoveAsideLeaveFile,
MoveAsideLeaveDirectory,
MoveAsideLinkOutsider,
LinkStagedRootThenSwap,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Payload {
Complete,
Truncated,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, Clone, Copy)]
struct Case {
root_is_file: bool,
policy_deletes: bool,
payload: Payload,
interference: Interference,
permissive_mode: bool,
locked_descendant: bool,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Case {
fn policy(self) -> IncompleteOutputPolicy {
if self.policy_deletes {
IncompleteOutputPolicy::DeleteOnError
} else {
IncompleteOutputPolicy::RetainOnError
}
}
fn root_mode(self) -> u32 {
match (self.root_is_file, self.permissive_mode) {
(true, true) => 0o666,
(true, false) => 0o600,
(false, true) => 0o777,
(false, false) => 0o700,
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn walk_files(dir: &Path, found: &mut Vec<(PathBuf, Vec<u8>, u32)>) {
use std::os::unix::fs::PermissionsExt;
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
if meta.is_dir() {
walk_files(&path, found);
} else if meta.is_file() {
let content = fs::read(&path).unwrap_or_default();
found.push((path, content, meta.permissions().mode() & 0o7777));
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn case_archive(case: Case) -> (Vec<u8>, &'static str, &'static str) {
if case.root_is_file {
let mut manifest = single_file_manifest("f.txt", RUN_PLAINTEXT);
manifest.root_mode = case.root_mode();
manifest.entries[0].mode = case.root_mode();
let archive = match case.payload {
Payload::Complete => build_archive(&manifest, &[("f.txt", RUN_PLAINTEXT)]),
Payload::Truncated => build_partial_archive(&manifest, b"cut"),
};
(archive, "f.txt.incomplete", "f.txt")
} else {
let mut entries = vec![
make_entry("d", ArchiveEntryKind::Directory, 0, case.root_mode()),
make_entry(
"d/a.txt",
ArchiveEntryKind::File,
RUN_PLAINTEXT.len() as u64,
0o644,
),
];
let mut contents: Vec<(&str, &[u8])> = vec![("d/a.txt", RUN_PLAINTEXT)];
if case.locked_descendant {
entries.push(make_entry(
"d/locked",
ArchiveEntryKind::Directory,
0,
LOCKED_DESCENDANT_MODE,
));
entries.push(make_entry(
"d/locked/b.txt",
ArchiveEntryKind::File,
RUN_PLAINTEXT.len() as u64,
0o644,
));
contents.push(("d/locked/b.txt", RUN_PLAINTEXT));
}
let manifest = Manifest {
total_file_bytes: (RUN_PLAINTEXT.len() * contents.len()) as u64,
entries,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: case.root_mode(),
};
let archive = match case.payload {
Payload::Complete => build_archive(&manifest, &contents),
Payload::Truncated => build_partial_archive(&manifest, b"cut"),
};
(archive, "d.incomplete", "d")
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn check_case(case: Case) {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
let outside = tmp.path().join("outside");
fs::create_dir(&out).unwrap();
fs::create_dir(&outside).unwrap();
let outsider = outside.join("private.key");
fs::write(&outsider, OUTSIDER_PLAINTEXT).unwrap();
fs::set_permissions(&outsider, fs::Permissions::from_mode(0o600)).unwrap();
let (archive, staging_name, final_name) = case_archive(case);
let out_for_swap = out.clone();
let outsider_for_swap = outsider.clone();
let interference = case.interference;
let reader = SwapOnEof {
data: archive,
pos: 0,
swapped: false,
swap: move || {
let staged = out_for_swap.join(staging_name);
let aside = out_for_swap.join("moved-aside");
match interference {
Interference::None => {}
Interference::MoveAsideLeaveNothing => {
fs::rename(&staged, &aside).unwrap();
}
Interference::MoveAsideLeaveFile => {
fs::rename(&staged, &aside).unwrap();
fs::write(&staged, b"planted-file").unwrap();
}
Interference::MoveAsideLeaveDirectory => {
fs::rename(&staged, &aside).unwrap();
fs::create_dir(&staged).unwrap();
fs::write(staged.join("planted.txt"), b"planted-dir-content").unwrap();
fs::set_permissions(&staged, fs::Permissions::from_mode(PLANTED_DIR_MODE))
.unwrap();
}
Interference::MoveAsideLinkOutsider => {
fs::rename(&staged, &aside).unwrap();
fs::hard_link(&outsider_for_swap, &staged).unwrap();
}
Interference::LinkStagedRootThenSwap => {
fs::hard_link(&staged, out_for_swap.join("second-name")).unwrap();
fs::rename(&staged, &aside).unwrap();
fs::write(&staged, b"planted-file").unwrap();
}
}
},
};
let result = unarchive(reader, &out, ArchiveLimits::default(), case.policy());
let label = format!("{case:?}");
let outsider_meta = fs::symlink_metadata(&outsider)
.unwrap_or_else(|e| panic!("{label}: outsider must survive the run: {e}"));
assert_eq!(
outsider_meta.permissions().mode() & 0o7777,
0o600,
"{label}: the run changed the mode of a file outside output_dir",
);
assert_eq!(
fs::read(&outsider).unwrap(),
OUTSIDER_PLAINTEXT,
"{label}: the run changed the content of a file outside output_dir",
);
let mut files = Vec::new();
walk_files(&out, &mut files);
if case.interference == Interference::MoveAsideLeaveDirectory {
let planted = files
.iter()
.find(|(_, content, _)| content.as_slice() == b"planted-dir-content")
.map(|(path, _, _)| path.clone())
.unwrap_or_else(|| {
panic!("{label}: the run removed a directory it did not create")
});
let planted_dir = planted.parent().expect("planted file has a parent");
let mode = fs::symlink_metadata(planted_dir)
.expect("planted directory must survive")
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode, PLANTED_DIR_MODE,
"{label}: the run changed the mode of a directory it did not create",
);
}
match &result {
Ok(path) => {
assert_eq!(
path,
&out.join(final_name),
"{label}: reported path is not the requested output name",
);
let meta = fs::symlink_metadata(path)
.unwrap_or_else(|e| panic!("{label}: reported path does not exist: {e}"));
if case.root_is_file {
assert_eq!(
fs::read(path).unwrap(),
RUN_PLAINTEXT,
"{label}: reported output does not hold the archive content",
);
} else {
assert_eq!(
fs::read(path.join("a.txt")).unwrap(),
RUN_PLAINTEXT,
"{label}: reported output does not hold the archive content",
);
}
assert_eq!(
meta.permissions().mode() & 0o7777,
case.root_mode(),
"{label}: the stored root mode was not applied",
);
assert!(
!out.join(staging_name).exists(),
"{label}: a successful run left its staging entry",
);
}
Err(_) if case.policy_deletes => {
let leaked: Vec<&PathBuf> = files
.iter()
.filter(|(_, content, _)| content.as_slice() == RUN_PLAINTEXT)
.map(|(path, _, _)| path)
.collect();
assert!(
leaked.is_empty(),
"{label}: DeleteOnError left this run's plaintext at {leaked:?}",
);
}
Err(_) => {
if case.interference == Interference::None {
let retained = files
.iter()
.any(|(path, _, _)| path.to_string_lossy().contains(INCOMPLETE_SUFFIX));
assert!(retained, "{label}: RetainOnError removed the staged output",);
}
}
}
restore_traversable(&out);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn restore_traversable(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if entry.path().is_dir() {
restore_traversable(&entry.path());
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
struct RestoreTraversableOnDrop<'a>(&'a Path);
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl Drop for RestoreTraversableOnDrop<'_> {
fn drop(&mut self) {
restore_traversable(self.0);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn post_commit_and_cleanup_invariants_hold_across_every_case() {
let shared = [
Interference::None,
Interference::MoveAsideLeaveNothing,
Interference::MoveAsideLeaveFile,
Interference::MoveAsideLeaveDirectory,
Interference::MoveAsideLinkOutsider,
];
let mut checked = 0usize;
for root_is_file in [true, false] {
for policy_deletes in [true, false] {
let interferences: Vec<Interference> = shared
.iter()
.copied()
.chain(root_is_file.then_some(Interference::LinkStagedRootThenSwap))
.collect();
for payload in [Payload::Complete, Payload::Truncated] {
for &interference in &interferences {
if payload == Payload::Truncated && interference != Interference::None {
continue;
}
for permissive_mode in [false, true] {
let locked_options: &[bool] = if root_is_file {
&[false]
} else {
&[false, true]
};
for &locked_descendant in locked_options {
check_case(Case {
root_is_file,
policy_deletes,
payload,
interference,
permissive_mode,
locked_descendant,
});
checked += 1;
}
}
}
}
}
}
assert_eq!(checked, 76, "the generated case count must not drift");
}
#[test]
fn the_archive_extension_cap_admits_a_region_of_exactly_its_size() {
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let region = tlv_bytes(0x0001, b"meta");
let region_len = u32::try_from(region.len()).unwrap();
let build = || {
let mut archive = build_archive_prefix_with_archive_ext(&manifest, ®ion);
archive.extend_from_slice(b"Hello, world!");
archive
};
let tmp = tempfile::TempDir::new().unwrap();
let extracted = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_archive_ext_bytes(region_len),
)
.expect("a region exactly at the cap must extract");
assert_eq!(fs::read(&extracted).unwrap(), b"Hello, world!");
let tmp = tempfile::TempDir::new().unwrap();
let err = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_archive_ext_bytes(region_len - 1),
)
.expect_err("a cap one byte short must refuse the region");
assert!(
matches!(err, CryptoError::ArchiveExtLenCapExceeded { .. }),
"expected the archive-extension cap, got {err}"
);
}
#[test]
fn the_entry_extension_cap_admits_a_region_of_exactly_its_size() {
let mut manifest = single_file_manifest("hello.txt", b"Hello, world!");
manifest.entries[0].entry_ext = tlv_bytes(0x0001, b"meta");
let region_len = u32::try_from(manifest.entries[0].entry_ext.len()).unwrap();
let build = || build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
let tmp = tempfile::TempDir::new().unwrap();
let extracted = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_entry_ext_bytes(region_len),
)
.expect("a region exactly at the cap must extract");
assert_eq!(fs::read(&extracted).unwrap(), b"Hello, world!");
let tmp = tempfile::TempDir::new().unwrap();
let err = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_entry_ext_bytes(region_len - 1),
)
.expect_err("a cap one byte short must refuse the region");
assert!(
matches!(err, CryptoError::ArchiveEntryExtLenCapExceeded { .. }),
"expected the entry-extension cap, got {err}"
);
}
#[test]
fn the_total_entry_extension_cap_admits_a_sum_of_exactly_its_size() {
let mut manifest = directory_root_manifest();
let region = tlv_bytes(0x0001, b"meta");
for entry in &mut manifest.entries {
entry.entry_ext = region.clone();
}
let total = u64::try_from(region.len() * manifest.entries.len()).unwrap();
let build = || build_archive(&manifest, &[("d/a.txt", b"real")]);
let tmp = tempfile::TempDir::new().unwrap();
let extracted = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_total_entry_ext_bytes(total),
)
.expect("a sum exactly at the cap must extract");
assert_eq!(fs::read(extracted.join("a.txt")).unwrap(), b"real");
let tmp = tempfile::TempDir::new().unwrap();
let err = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_total_entry_ext_bytes(total - 1),
)
.expect_err("a cap one byte short must refuse the sum");
assert!(
matches!(err, CryptoError::ArchiveTotalEntryExtCapExceeded { .. }),
"expected the cumulative entry-extension cap, got {err}"
);
}
#[test]
fn the_tlv_value_cap_admits_a_value_of_exactly_its_size() {
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let value = b"metadata-value";
let value_len = u32::try_from(value.len()).unwrap();
let build = || {
let mut archive =
build_archive_prefix_with_archive_ext(&manifest, &tlv_bytes(0x0001, value));
archive.extend_from_slice(b"Hello, world!");
archive
};
let tmp = tempfile::TempDir::new().unwrap();
let extracted = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_tlv_value_bytes(value_len),
)
.expect("a value exactly at the cap must extract");
assert_eq!(fs::read(&extracted).unwrap(), b"Hello, world!");
let tmp = tempfile::TempDir::new().unwrap();
let err = unarchive_with_limits(
build(),
tmp.path(),
ArchiveLimits::default().max_tlv_value_bytes(value_len - 1),
)
.expect_err("a cap one byte short must refuse the value");
assert!(
matches!(
err,
CryptoError::InvalidFormat(crate::error::FormatDefect::MalformedTlv)
),
"expected a malformed-TLV rejection, got {err}"
);
}
#[test]
fn a_staged_identity_that_cannot_be_read_is_skipped() {
assert!(StagedIdentity::Unavailable.known().is_none());
assert!(StagedIdentity::NoHandle.known().is_none());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn an_unavailable_staged_identity_skips_the_mode_application() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let _file = platform::create_file_at(&handle, OsStr::new("f.txt"), 0o600).unwrap();
let mut manifest = single_file_manifest("f.txt", b"");
manifest.root_mode = 0o666;
manifest.entries[0].mode = 0o666;
let unread = apply_root_file_mode(&handle, &manifest, &StagedIdentity::Unavailable)
.expect("an unavailable identity must skip, not fail");
assert!(matches!(unread, PromotedIdentity::Unconfirmed));
let mode = fs::metadata(tmp.path().join("f.txt"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode, 0o600,
"an unconfirmable entry must keep the staged mode, got 0o{mode:o}",
);
let no_handle = apply_root_file_mode(&handle, &manifest, &StagedIdentity::NoHandle)
.expect("the ordinary no-identity state must apply the mode");
assert!(matches!(no_handle, PromotedIdentity::Unconfirmed));
let mode = fs::metadata(tmp.path().join("f.txt"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(mode, 0o666, "NoHandle must apply the mode, got 0o{mode:o}");
}
#[test]
fn a_matching_staged_identity_ratifies_and_a_mismatch_rejects() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
let staged_file = platform::create_file_at(&handle, OsStr::new("f.txt"), 0o600).unwrap();
let staged_id = platform::file_object_id(&staged_file).unwrap().unwrap();
let manifest = single_file_manifest("f.txt", b"");
let confirmed = apply_root_file_mode(&handle, &manifest, &StagedIdentity::Known(staged_id))
.expect("a matching identity must confirm");
assert!(matches!(confirmed, PromotedIdentity::Confirmed));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(tmp.path().join("f.txt"))
.unwrap()
.permissions()
.mode()
& 0o7777;
assert_eq!(
mode, 0o644,
"the confirmed entry receives the manifest mode"
);
}
fs::write(tmp.path().join("other"), b"planted").unwrap();
fs::rename(tmp.path().join("other"), tmp.path().join("f.txt")).unwrap();
let err = apply_root_file_mode(&handle, &manifest, &StagedIdentity::Known(staged_id))
.unwrap_err();
assert!(
format!("{err}").contains("Output was replaced while decrypting"),
"expected the substitution to be reported, got: {err}",
);
}
#[test]
fn a_missing_staged_identity_accepts_a_substituted_final_name() {
let tmp = tempfile::TempDir::new().unwrap();
let handle = platform::open_anchor(tmp.path()).unwrap();
fs::write(tmp.path().join("f.txt"), b"planted").unwrap();
require_promoted_root(&handle, OsStr::new("f.txt"), None)
.expect("with nothing to compare against the check must accept");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
const NESTED_DIRECTORY_MODES: [u32; 9] = [
0o000, 0o100, 0o200, 0o300, 0o400, 0o500, 0o555, 0o600, 0o755,
];
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn nested_mode_archive(nested_mode: u32) -> Vec<u8> {
let manifest = Manifest {
entries: vec![
make_entry("d", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("d/ro", ArchiveEntryKind::Directory, 0, nested_mode),
make_entry("d/ro/a.txt", ArchiveEntryKind::File, 4, 0o644),
make_entry("d/ro/deep", ArchiveEntryKind::Directory, 0, nested_mode),
make_entry("d/ro/deep/b.txt", ArchiveEntryKind::File, 4, 0o644),
],
total_file_bytes: 8,
root_name: OsString::from("d"),
root_is_file: false,
root_mode: 0o755,
};
build_archive(
&manifest,
&[("d/ro/a.txt", b"real"), ("d/ro/deep/b.txt", b"real")],
)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn cleanup_restores_the_modes_it_applied_before_removing_the_staged_tree() {
for nested_mode in NESTED_DIRECTORY_MODES {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let _traversable = RestoreTraversableOnDrop(&out);
let archive = nested_mode_archive(nested_mode);
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
Err(CryptoError::InvalidInput("injected failure".to_owned()))
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
let remaining: Vec<_> = fs::read_dir(&out)
.unwrap()
.flatten()
.map(|e| e.file_name())
.collect();
assert!(
remaining.is_empty(),
"mode {nested_mode:o}: DeleteOnError left {remaining:?} in the output directory",
);
assert!(
matches!(&err, CryptoError::InvalidInput(message) if message == "injected failure"),
"mode {nested_mode:o}: a confirmed removal must add no report, got: {err}",
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn panic_after_descendant_modes_restores_them_and_removes_the_staged_tree() {
for nested_mode in NESTED_DIRECTORY_MODES {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let _traversable = RestoreTraversableOnDrop(&out);
let archive = nested_mode_archive(nested_mode);
let payload = unarchive_expecting_panic(
Cursor::new(archive),
&out,
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || -> Result<(), CryptoError> {
panic!("test-induced panic before promotion")
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
);
assert_eq!(
payload.downcast_ref::<&str>().copied(),
Some("test-induced panic before promotion"),
"mode {nested_mode:o}: the unwind must carry the injected panic",
);
let remaining: Vec<_> = fs::read_dir(&out)
.unwrap()
.flatten()
.map(|e| e.file_name())
.collect();
assert!(
remaining.is_empty(),
"mode {nested_mode:o}: the unwind left {remaining:?} in the output directory",
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_removal_the_environment_refuses_is_reported_with_the_working_path() {
use std::os::unix::fs::PermissionsExt;
if rustix::process::geteuid().is_root() {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let archive = build_archive(&directory_root_manifest(), &[("d/a.txt", b"real")]);
let out_for_hook = out.clone();
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: move || {
fs::set_permissions(&out_for_hook, fs::Permissions::from_mode(0o500))?;
Err(CryptoError::InvalidInput("injected failure".to_owned()))
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
fs::set_permissions(&out, fs::Permissions::from_mode(0o700)).unwrap();
let message = match &err {
CryptoError::InvalidInput(message) => message,
other => panic!("the original error class must be kept, got: {other:?}"),
};
assert!(
message.starts_with("injected failure; "),
"the original error must come first: {message}",
);
assert!(
message.contains("could not be removed and may still hold plaintext"),
"the report must say plaintext may remain: {message}",
);
assert!(
message.contains(&out.join("d.incomplete").display().to_string()),
"the report must name the working path: {message}",
);
assert!(
out.join("d.incomplete").is_dir(),
"the staged root the environment refused to remove is still there",
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn an_emptied_file_the_environment_refuses_to_unlink_is_reported_as_emptied() {
use std::os::unix::fs::PermissionsExt;
if rustix::process::geteuid().is_root() {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let manifest = single_file_manifest("f.txt", b"real");
let archive = build_archive(&manifest, &[("f.txt", b"real")]);
let out_for_hook = out.clone();
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: move || {
fs::set_permissions(&out_for_hook, fs::Permissions::from_mode(0o500))?;
Err(CryptoError::InvalidInput("injected failure".to_owned()))
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.unwrap_err();
fs::set_permissions(&out, fs::Permissions::from_mode(0o700)).unwrap();
let message = format!("{err}");
assert!(
message.contains("was emptied but could not be removed"),
"the report must say the entry holds no plaintext: {message}",
);
assert!(
!message.contains("may still hold plaintext"),
"an emptied file must not be reported as possibly holding plaintext: {message}",
);
let staged = out.join("f.txt.incomplete");
assert_eq!(
fs::metadata(&staged).unwrap().len(),
0,
"the entry the environment refused to unlink must have been emptied",
);
}
#[test]
fn a_post_ratification_replacement_preserves_the_committed_output() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let manifest = single_file_manifest("f.txt", b"real plaintext");
let archive = build_archive(&manifest, &[("f.txt", b"real plaintext")]);
let final_path = out.join("f.txt");
let committed_elsewhere = out.join("committed-moved-aside");
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| {
fs::rename(&final_path, &committed_elsewhere)?;
fs::write(&final_path, b"replacement")?;
Ok(())
},
},
)
.expect_err("step 17 must reject the replaced final entry");
assert!(
err.to_string()
.contains("Output was replaced while decrypting"),
"the replacement must be reported, got: {err}"
);
assert_eq!(
fs::read(&committed_elsewhere).unwrap(),
b"real plaintext",
"cleanup must stay off the ratified committed output"
);
assert_eq!(
fs::read(&final_path).unwrap(),
b"replacement",
"cleanup must not remove the replacement by name"
);
assert!(
!out.join(incomplete_working_name(OsStr::new("f.txt")))
.exists(),
"the successful promotion left no staging name"
);
}
#[test]
fn the_retained_handle_is_held_until_the_last_comparison() {
for root_is_file in [true, false] {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let (archive, root) = if root_is_file {
let manifest = single_file_manifest("f.txt", b"real");
(build_archive(&manifest, &[("f.txt", b"real")]), "f.txt")
} else {
(
build_archive(&directory_root_manifest(), &[("d/a.txt", b"real")]),
"d",
)
};
let mut observed = false;
let path = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |ratified| {
let held = ratified.as_ref().map(StagedRoot::identity);
assert!(
matches!(held, Some(StagedIdentity::Known(_))),
"root_is_file={root_is_file}: the ratified record must still hold a \
readable handle"
);
observed = true;
Ok(())
},
},
)
.unwrap();
assert!(
observed,
"root_is_file={root_is_file}: the hook must have run"
);
assert_eq!(path, out.join(root));
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn extraction_reports_a_retained_temporary_name_after_promotion() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let final_path = out.join(root_name);
let incomplete_path = out.join(incomplete_working_name(OsStr::new(root_name)));
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |promotion| {
fs::hard_link(&final_path, &incomplete_path)?;
*promotion = platform::PromotionOutcome::StagedLinkRetained(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected temporary-name removal failure",
));
Ok(())
},
after_root_mode: |_| Ok(()),
},
)
.expect_err("a retained temporary name must be reported");
let rendered = err.to_string();
assert!(
rendered.contains("temporary name") && rendered.contains("could not be removed"),
"the retained name must be explicit, got: {rendered}"
);
assert_eq!(fs::read(&final_path).unwrap(), plaintext);
assert_eq!(fs::read(&incomplete_path).unwrap(), plaintext);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_moved_destination_does_not_hide_the_extra_names_of_the_output() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let moved = tmp.path().join("out.moved");
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let incomplete_name = incomplete_working_name(OsStr::new(root_name));
let final_path = out.join(root_name);
let incomplete_path = out.join(&incomplete_name);
let outsider_name = "outsider-link";
let outsider_path = out.join(outsider_name);
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::hard_link(&incomplete_path, &outsider_path)?;
Ok(())
},
after_promotion: |promotion| {
fs::hard_link(&final_path, &incomplete_path)?;
*promotion = platform::PromotionOutcome::StagedLinkRetained(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected temporary-name removal failure",
));
Ok(())
},
after_root_mode: |_| {
fs::rename(&out, &moved)?;
fs::create_dir(&out)?;
Ok(())
},
},
)
.expect_err("a moved destination must be reported");
let rendered = err.to_string();
for expected in [
"Output f.txt is complete but its directory changed",
"temporary name f.txt.incomplete could not be removed",
"has 3 filesystem names",
] {
assert!(
rendered.contains(expected),
"missing {expected:?}, got: {rendered}"
);
}
assert_eq!(
rendered.matches(&out.display().to_string()).count(),
1,
"only the moved directory may be named, got: {rendered}"
);
for name in [
OsStr::new(root_name),
incomplete_name.as_os_str(),
OsStr::new(outsider_name),
] {
assert_eq!(
fs::read(moved.join(name)).unwrap(),
plaintext,
"{name:?} must still denote the committed output"
);
}
}
#[cfg(unix)]
#[test]
fn extraction_reports_a_staged_directory_moved_out_between_two_entries() {
struct ActAfter<F: FnMut()> {
data: Vec<u8>,
pos: usize,
at: usize,
act: Option<F>,
}
impl<F: FnMut()> Read for ActAfter<F> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let remaining = self.data.len() - self.pos;
if remaining == 0 {
return Ok(0);
}
let n = remaining.min(buf.len());
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
if self.pos >= self.at {
if let Some(mut act) = self.act.take() {
act();
}
}
Ok(n)
}
}
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let first = b"first entry";
let second = b"second entry";
let manifest = Manifest {
entries: vec![
make_entry("root", ArchiveEntryKind::Directory, 0, 0o755),
make_entry("root/sub", ArchiveEntryKind::Directory, 0, 0o755),
make_entry(
"root/sub/a.txt",
ArchiveEntryKind::File,
first.len() as u64,
0o644,
),
make_entry(
"root/sub/b.txt",
ArchiveEntryKind::File,
second.len() as u64,
0o644,
),
],
total_file_bytes: (first.len() + second.len()) as u64,
root_name: OsString::from("root"),
root_is_file: false,
root_mode: 0o755,
};
let archive = build_archive(
&manifest,
&[("root/sub/a.txt", first), ("root/sub/b.txt", second)],
);
let at = archive.len() - second.len();
let staged_sub = out.join("root.incomplete").join("sub");
let moved = out.join("moved");
let reader = ActAfter {
data: archive,
pos: 0,
at,
act: Some(|| {
let _ = fs::rename(&staged_sub, &moved);
}),
};
let err = unarchive_inner(
reader,
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
)
.expect_err("a staged directory moved out must fail the run");
assert!(
err.to_string()
.contains("Directory in extraction path went missing: sub"),
"the run must name the directory that went and the side it was on, got: {err}"
);
assert!(
!out.join("root").exists(),
"nothing may be promoted after the staged tree changed"
);
}
#[test]
fn extraction_rejects_a_link_made_against_the_staged_file_before_promotion() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let final_path = out.join(root_name);
let incomplete_path = out.join(incomplete_working_name(OsStr::new(root_name)));
let outsider_path = out.join("outsider-link");
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::hard_link(&incomplete_path, &outsider_path)?;
Ok(())
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.expect_err("a second plaintext link must not report success");
let rendered = err.to_string();
assert!(
rendered.contains("has 2 filesystem names"),
"the retained inode link must be explicit, got: {rendered}"
);
assert_eq!(
fs::read(&final_path).unwrap(),
plaintext,
"the committed output is ratified and must be preserved"
);
assert_eq!(fs::read(&outsider_path).unwrap(), plaintext);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&final_path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
platform::INITIAL_FILE_CREATE_MODE,
"the archive's mode must never reach an inode with a second name"
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn extraction_rejects_a_hidden_link_after_nominal_staging_cleanup() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let final_path = out.join(root_name);
let hidden_path = out.join("moved-staging-link");
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |promotion| {
fs::hard_link(&final_path, &hidden_path)?;
*promotion = platform::PromotionOutcome::LinkedFile;
Ok(())
},
after_root_mode: |_| Ok(()),
},
)
.expect_err("a hidden second plaintext link must not report success");
let rendered = err.to_string();
assert!(
rendered.contains("has 2 filesystem names"),
"the retained inode link must be explicit, got: {rendered}"
);
assert_eq!(fs::read(&final_path).unwrap(), plaintext);
assert_eq!(fs::read(&hidden_path).unwrap(), plaintext);
assert!(
!out.join(incomplete_working_name(OsStr::new(root_name)))
.exists(),
"the moved link, not the original staging name, survives"
);
}
fn assert_anchor_reported_changed(handle: &Dir, path: &Path, what: &str) {
let err = require_output_anchor_unchanged(handle, path, OsStr::new("f.txt")).unwrap_err();
assert!(
format!("{err}").contains("is complete but its directory changed"),
"{what} must report as changed, got: {err}"
);
}
#[test]
fn the_anchor_check_reports_a_path_that_no_longer_leads_to_a_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let handle = platform::open_anchor(&out).unwrap();
require_output_anchor_unchanged(&handle, &out, OsStr::new("f.txt"))
.expect("an unchanged path must be accepted");
assert_anchor_reported_changed(&handle, &tmp.path().join("nowhere"), "a missing path");
let file_path = tmp.path().join("plain");
fs::write(&file_path, b"not a directory").unwrap();
assert_anchor_reported_changed(&handle, &file_path, "a non-directory at the path");
let cycle = tmp.path().join("cycle");
#[cfg(unix)]
std::os::unix::fs::symlink(&cycle, &cycle).unwrap();
#[cfg(windows)]
{
let other = tmp.path().join("cycle-other");
platform::try_make_junction(&other, &cycle).unwrap();
platform::try_make_junction(&cycle, &other).unwrap();
}
assert_anchor_reported_changed(&handle, &cycle, "a link cycle at the path");
}
#[cfg(windows)]
#[test]
fn the_anchor_check_reports_a_junction_repointed_at_another_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let committed = tmp.path().join("committed");
let other = tmp.path().join("other");
fs::create_dir(&committed).unwrap();
fs::create_dir(&other).unwrap();
let out = tmp.path().join("out");
platform::try_make_junction(&committed, &out).unwrap();
let handle = platform::open_anchor(&out).unwrap();
require_output_anchor_unchanged(&handle, &out, OsStr::new("f.txt"))
.expect("a junction still leading to the held directory must be accepted");
fs::remove_dir(&out).unwrap();
platform::try_make_junction(&other, &out).unwrap();
assert_anchor_reported_changed(&handle, &out, "a junction leading elsewhere");
assert!(
committed.exists(),
"re-pointing the junction leaves the held directory in place"
);
}
#[cfg(unix)]
#[test]
fn the_anchor_check_propagates_access_denial() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = tmp.path().join("parent");
let out = parent.join("out");
fs::create_dir_all(&out).unwrap();
let handle = platform::open_anchor(&out).unwrap();
fs::set_permissions(&parent, fs::Permissions::from_mode(0o000)).unwrap();
let outcome = require_output_anchor_unchanged(&handle, &out, OsStr::new("f.txt"));
fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
match outcome {
Ok(()) => {
}
Err(CryptoError::Io(error)) => {
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_directory_unconfirmed(&error.to_string(), "f.txt", &out);
}
Err(other) => panic!("access denial must propagate as I/O, got: {other}"),
}
}
#[cfg(unix)]
fn assert_directory_unconfirmed(message: &str, root_name: &str, out: &Path) {
let expected = format!(
"Output {root_name} is complete, but its directory could not be confirmed: {}: ",
out.display()
);
assert!(
message.starts_with(&expected),
"expected a message starting with {expected:?}, got: {message}"
);
}
#[cfg(unix)]
#[test]
fn an_unconfirmed_destination_reports_the_complete_output_and_keeps_it() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let parent = tmp.path().join("parent");
let out = parent.join("out");
fs::create_dir_all(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let outcome = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || Ok(()),
after_promotion: |_| Ok(()),
after_root_mode: |_| {
fs::set_permissions(&parent, fs::Permissions::from_mode(0o000))
.map_err(CryptoError::Io)
},
},
);
fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
match outcome {
Ok(path) => {
assert_eq!(path, out.join(root_name));
}
Err(CryptoError::Io(error)) => {
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_directory_unconfirmed(&error.to_string(), root_name, &out);
}
Err(other) => panic!("the denial must be reported as I/O, got: {other}"),
}
assert_eq!(
fs::read(out.join(root_name)).unwrap(),
plaintext,
"the committed output must survive the failed confirmation"
);
}
#[test]
fn the_anchor_check_skips_only_resource_exhaustion() {
for skipped in RESOURCE_EXHAUSTION_CODES {
assert!(output_confirmation_resource_error(&CryptoError::Io(
io::Error::from_raw_os_error(skipped)
)));
}
#[cfg(unix)]
let propagated = [libc::EACCES, libc::EPERM, libc::EIO, libc::ENOENT];
#[cfg(windows)]
let propagated = [2, 3, 5, 32];
for code in propagated {
assert!(!output_confirmation_resource_error(&CryptoError::Io(
io::Error::from_raw_os_error(code)
)));
}
assert!(!output_confirmation_resource_error(&CryptoError::Io(
io::Error::from(io::ErrorKind::PermissionDenied)
)));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
#[ignore = "holds every free descriptor; needs --test-threads=1"]
fn a_run_that_cannot_hold_the_staged_handle_refuses_before_any_plaintext() {
const BUDGETS: std::ops::Range<usize> = 0..8;
let mut refusals = 0;
for root_is_file in [true, false] {
for policy in [
IncompleteOutputPolicy::DeleteOnError,
IncompleteOutputPolicy::RetainOnError,
] {
let mut refused_here = false;
for free in BUDGETS {
let tmp = tempfile::TempDir::new().unwrap();
let (archive, root, content) = if root_is_file {
let manifest = single_file_manifest("hello.txt", b"Hello, world!");
let archive = build_archive(&manifest, &[("hello.txt", b"Hello, world!")]);
(archive, "hello.txt", &b"Hello, world!"[..])
} else {
let manifest = directory_root_manifest();
let archive = build_archive(&manifest, &[("d/a.txt", b"real")]);
(archive, "d", &b"real"[..])
};
let held = fd_limit::HeldDescriptors::leaving(free);
let result = unarchive_with_policy(archive, tmp.path(), policy.clone());
drop(held);
let mut found = Vec::new();
walk_files(tmp.path(), &mut found);
match result {
Ok(path) => {
assert_eq!(path, tmp.path().join(root));
let total: usize = found.iter().map(|(_, bytes, _)| bytes.len()).sum();
assert_eq!(
total,
content.len(),
"a successful decrypt must hold exactly the archive's content"
);
}
Err(e) => {
assert!(
found.iter().all(|(_, bytes, _)| bytes.is_empty()),
"a refused decrypt must leave no plaintext, got {found:?} at \
{free} free descriptors: {e}"
);
assert!(
!tmp.path().join(root).exists(),
"a refused decrypt must leave nothing at the final name"
);
if format!("{e}").contains(STAGED_HANDLE_UNAVAILABLE) {
refused_here = true;
}
}
}
}
assert!(
refused_here,
"the staged-handle refusal must be reachable for root_is_file={root_is_file} \
under {policy:?}"
);
refusals += 1;
}
}
assert_eq!(refusals, 4, "both root kinds under both policies");
}
#[cfg(unix)]
#[test]
fn a_substituted_promotion_still_destroys_this_runs_plaintext() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let incomplete_path = out.join(incomplete_working_name(OsStr::new(root_name)));
let attacker_link = out.join("attacker-link");
let aside = out.join("aside");
let decoy = out.join("decoy-source");
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::hard_link(&incomplete_path, &attacker_link)?;
fs::rename(&incomplete_path, &aside)?;
fs::write(&decoy, b"decoy content")?;
fs::hard_link(&decoy, &incomplete_path)?;
Ok(())
},
after_promotion: |_| Ok(()),
after_root_mode: |_| Ok(()),
},
)
.expect_err("must fail");
assert!(
err.to_string()
.contains("Output was replaced while decrypting"),
"the refusal must name the substitution, got: {err}"
);
let mut leaked = Vec::new();
for entry in fs::read_dir(&out).unwrap() {
let p = entry.unwrap().path();
if p.is_file() && fs::read(&p).unwrap_or_default() == plaintext {
leaked.push(p);
}
}
assert!(
leaked.is_empty(),
"DeleteOnError left this run's plaintext at {leaked:?}"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_retained_staging_name_never_receives_the_archive_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
fs::create_dir(&out).unwrap();
let root_name = "f.txt";
let plaintext = b"real plaintext";
let manifest = single_file_manifest(root_name, plaintext);
let archive = build_archive(&manifest, &[(root_name, plaintext)]);
let incomplete_path = out.join(incomplete_working_name(OsStr::new(root_name)));
let evil = out.join("evil-link");
let err = unarchive_inner_with_hooks(
Cursor::new(archive),
&out,
ArchiveLimits::default(),
IncompleteOutputPolicy::DeleteOnError,
Seams {
compare_owners: platform::compare_owners,
before_promotion: || {
fs::hard_link(&incomplete_path, &evil)?;
Ok(())
},
after_promotion: |promotion| {
*promotion = platform::PromotionOutcome::StagedLinkRetained(io::Error::other(
"simulated staging unlink failure",
));
Ok(())
},
after_root_mode: |_| Ok(()),
},
)
.expect_err("must fail");
let rendered = err.to_string();
assert!(
rendered.contains("could not be removed"),
"the run must report the failed staging removal, got: {rendered}"
);
assert!(
rendered.contains("has 2 filesystem names"),
"the failed staging removal must not hide the count, got: {rendered}"
);
assert_eq!(
fs::read(&evil).unwrap(),
plaintext,
"the extra name still denotes the committed file"
);
let evil_mode = fs::metadata(&evil).unwrap().permissions().mode() & 0o777;
assert_eq!(
evil_mode,
platform::INITIAL_FILE_CREATE_MODE,
"the archive mode must never reach an inode with a second name"
);
}
}