use std::io;
use std::path::Path;
use tempfile::NamedTempFile;
use crate::CryptoError;
use crate::error::sanitize_path_for_display;
#[cfg(unix)]
use crate::fs::commit::{CommitFailure, CommitKind, CommitRoute};
use crate::fs::paths::already_exists_error;
#[derive(Debug)]
pub(crate) struct FinalizedFile {
file: std::fs::File,
}
impl FinalizedFile {
fn new(file: std::fs::File) -> Self {
Self { file }
}
pub(crate) fn confirm_reported_path(&self, path: &Path) -> Result<(), CryptoError> {
let committed = cap_std::fs::Metadata::from_file(&self.file)
.map_err(|e| reported_path_unconfirmed(path, e))?;
let reported = reported_entry_metadata(path).map_err(|e| reported_entry_error(path, e))?;
if !reported.is_file() || identities_differ(&reported, &committed) {
return Err(reported_output_changed(path));
}
Ok(())
}
fn link_count(&self) -> io::Result<u64> {
use cap_fs_ext::MetadataExt;
Ok(cap_std::fs::Metadata::from_file(&self.file)?.nlink())
}
fn into_confirmed(
self,
path: &Path,
finding: Option<CryptoError>,
) -> Result<Self, FinalizeFileError> {
let error = match (finding, self.link_count()) {
(None, Ok(1)) => return Ok(self),
(None, Ok(link_count)) => {
committed_link_count_error(&sanitize_path_for_display(path), link_count)
}
(None, Err(source)) => link_count_unreadable(path, source),
(Some(finding), Ok(1)) => finding,
(Some(finding), Ok(link_count)) => crate::error::append_report(
finding,
&format!("the output {}", link_count_clause(link_count)),
),
(Some(finding), Err(source)) => crate::error::append_report(
finding,
&format!("the output's {}", link_count_unreadable_clause(&source)),
),
};
Err(FinalizeFileError::after_commit(error))
}
}
pub(crate) fn file_identity(metadata: &cap_std::fs::Metadata) -> Option<FileIdentity> {
use cap_fs_ext::MetadataExt;
identity_pair(metadata.dev(), metadata.ino())
}
pub(crate) type FileIdentity = (u64, u64);
fn identity_pair(dev: u64, ino: u64) -> Option<FileIdentity> {
(ino != 0).then_some((dev, ino))
}
pub(crate) fn identities_differ(a: &cap_std::fs::Metadata, b: &cap_std::fs::Metadata) -> bool {
match (file_identity(a), file_identity(b)) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
#[cfg(unix)]
fn reported_entry_metadata(path: &Path) -> io::Result<cap_std::fs::Metadata> {
std::fs::symlink_metadata(path).map(cap_std::fs::Metadata::from_just_metadata)
}
#[cfg(windows)]
fn reported_entry_metadata(path: &Path) -> io::Result<cap_std::fs::Metadata> {
use std::os::windows::fs::OpenOptionsExt;
let entry = std::fs::OpenOptions::new()
.access_mode(0)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(path)?;
cap_std::fs::Metadata::from_file(&entry)
}
#[cfg(windows)]
pub(crate) const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
#[cfg(windows)]
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
fn reported_output_changed(path: &Path) -> CryptoError {
CryptoError::InvalidInput(format!(
"Output is complete but its reported path changed: {}",
sanitize_path_for_display(path)
))
}
fn reported_entry_error(path: &Path, error: io::Error) -> CryptoError {
if crate::fs::paths::path_no_longer_resolves(&error) {
reported_output_changed(path)
} else {
reported_path_unconfirmed(path, error)
}
}
fn reported_path_unconfirmed(path: &Path, source: io::Error) -> CryptoError {
CryptoError::Io(io::Error::new(
source.kind(),
format!(
"Output is complete, but its reported path could not be confirmed: {}: {source}",
sanitize_path_for_display(path)
),
))
}
fn link_count_unreadable(path: &Path, source: io::Error) -> CryptoError {
CryptoError::Io(io::Error::new(
source.kind(),
format!(
"Output {} is complete, but its {}",
sanitize_path_for_display(path),
link_count_unreadable_clause(&source),
),
))
}
fn link_count_unreadable_clause(source: &io::Error) -> String {
format!("number of filesystem names could not be read: {source}")
}
pub(crate) fn committed_link_count_error(display_name: &str, link_count: u64) -> CryptoError {
CryptoError::Io(io::Error::other(format!(
"Output {display_name} is complete, but {}",
link_count_clause(link_count),
)))
}
fn link_count_clause(link_count: u64) -> String {
format!("has {link_count} filesystem names (expected 1)")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RollbackOutcome {
Removed,
RemovedButLinked { link_count: u64 },
Replaced,
Unconfirmed,
}
impl RollbackOutcome {
fn report(self, path: &Path, file: RemovedFile) -> Option<String> {
use RemovedFile::{Committed, Staged};
let shown = final_component_for_display(path);
Some(match (file, self) {
(_, Self::Removed) => return None,
(Committed, Self::RemovedButLinked { link_count }) => format!(
"a complete {shown} was committed and then removed, but it had {link_count} filesystem names, so a copy may remain under another name"
),
(Staged, Self::RemovedButLinked { link_count }) => format!(
"the removed {shown} had {link_count} filesystem names, so a copy may remain under another name"
),
(Committed, Self::Replaced) => format!(
"a complete {shown} was committed, but the entry at its name was replaced and left in place, and the committed file may remain under another name"
),
(Staged, Self::Replaced) => format!(
"{shown} was replaced during the operation and left in place, and the file this run wrote may remain under another name"
),
(Committed, Self::Unconfirmed) => {
format!("a complete {shown} was committed, and its removal could not be confirmed")
}
(Staged, Self::Unconfirmed) => format!("the removal of {shown} could not be confirmed"),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemovedFile {
Committed,
Staged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Rollback {
outcome: RollbackOutcome,
file: RemovedFile,
}
fn final_component_for_display(path: &Path) -> String {
let name = path.file_name().unwrap_or(path.as_os_str());
crate::error::sanitize_for_display(&name.to_string_lossy())
}
pub(crate) fn with_rollback_report(
error: CryptoError,
rollback: Rollback,
path: &Path,
) -> CryptoError {
match rollback.outcome.report(path, rollback.file) {
Some(report) => crate::error::append_report(error, &report),
None => error,
}
}
#[derive(Debug)]
pub(crate) struct FinalizeFileError {
error: CryptoError,
committed: bool,
}
impl FinalizeFileError {
fn before_commit(error: CryptoError) -> Self {
Self {
error,
committed: false,
}
}
fn after_commit(error: CryptoError) -> Self {
#[cfg(test)]
assert!(
says_output_is_complete(&error),
"a post-commit error must say that the output is complete: {error}"
);
Self {
error,
committed: true,
}
}
#[cfg(test)]
pub(crate) fn after_commit_for_test(error: CryptoError) -> Self {
Self::after_commit(error)
}
pub(crate) fn committed(&self) -> bool {
self.committed
}
pub(crate) fn into_crypto_error(self) -> CryptoError {
self.error
}
}
#[cfg(test)]
fn says_output_is_complete(error: &CryptoError) -> bool {
let message = error.to_string();
message.starts_with("Output ") && message.contains(" is complete")
}
#[cfg(any(unix, windows))]
fn sync_parent_dir(path: &Path) {
let _ = sync_dir_durable(crate::fs::paths::parent_or_cwd(path));
}
#[cfg(not(any(unix, windows)))]
fn sync_parent_dir(_path: &Path) {}
pub(crate) fn finalize_file(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
anchor: &OutputDir,
) -> Result<FinalizedFile, FinalizeFileError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
finalize_file_at(tmp, final_path, label, anchor)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
finalize_by_persist(tmp, final_path, label, Some(anchor))
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn finalize_file_path_based(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
) -> Result<FinalizedFile, FinalizeFileError> {
finalize_by_persist(tmp, final_path, label, None)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn finalize_by_persist(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
anchor: Option<&OutputDir>,
) -> Result<FinalizedFile, FinalizeFileError> {
match tmp.persist_noclobber(final_path) {
Ok(file) => finalized_from_persist(file, final_path),
Err(e) => finalize_persist_failure(e, final_path, label, anchor),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn finalize_file_at(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
anchor: &OutputDir,
) -> Result<FinalizedFile, FinalizeFileError> {
use crate::fs::commit::{FlaggedRename, commit_by_link_or_claim, rename_no_replace_at};
finalize_file_commit(
tmp,
final_path,
label,
anchor,
|anchor, staged, final_name| match rename_no_replace_at(anchor, staged, final_name) {
Ok(FlaggedRename::Committed) => Ok(CommitRoute::Renamed),
Ok(FlaggedRename::Unsupported) => {
commit_by_link_or_claim(anchor, staged, final_name, CommitKind::File)
}
Err(error) => Err(CommitFailure::plain(error)),
},
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn finalized_from_persist(
file: std::fs::File,
final_path: &Path,
) -> Result<FinalizedFile, FinalizeFileError> {
sync_parent_dir(final_path);
let finalized = FinalizedFile::new(file);
let finding = finalized.confirm_reported_path(final_path).err();
finalized.into_confirmed(final_path, finding)
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
fn finalize_persist_failure(
e: tempfile::PersistError,
final_path: &Path,
label: &str,
anchor: Option<&OutputDir>,
) -> Result<FinalizedFile, FinalizeFileError> {
if no_replace_rename_unsupported(&e.error) {
return match anchor {
Some(anchor) => finalize_file_via_link_or_claim_in(e.file, final_path, label, anchor),
None => finalize_file_via_link_or_claim(e.file, final_path, label),
};
}
Err(FinalizeFileError::before_commit(map_persist_error(
e.error, final_path, label,
)))
}
#[cfg(not(unix))]
fn finalize_persist_failure(
e: tempfile::PersistError,
final_path: &Path,
label: &str,
_anchor: Option<&OutputDir>,
) -> Result<FinalizedFile, FinalizeFileError> {
Err(FinalizeFileError::before_commit(map_persist_error(
e.error, final_path, label,
)))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn map_persist_error(error: io::Error, final_path: &Path, label: &str) -> CryptoError {
if error.kind() == io::ErrorKind::AlreadyExists {
already_exists_error(label, final_path)
} else {
CryptoError::Io(error)
}
}
#[cfg(unix)]
pub(crate) fn errno_not_supported(e: &io::Error) -> bool {
if e.kind() == io::ErrorKind::Unsupported {
return true;
}
matches!(
e.raw_os_error(),
Some(code) if code == libc::ENOTSUP || code == libc::EOPNOTSUPP
)
}
#[cfg(unix)]
pub(crate) fn no_replace_rename_unsupported(e: &io::Error) -> bool {
errno_not_supported(e) || e.raw_os_error() == Some(libc::EINVAL)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn fsync_uninterrupted<Fd: std::os::fd::AsFd>(fd: Fd) -> io::Result<()> {
rustix::io::retry_on_intr(|| rustix::fs::fsync(&fd)).map_err(io::Error::from)
}
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
match file.sync_all() {
Ok(()) => Ok(()),
#[cfg(any(target_os = "linux", target_os = "macos"))]
Err(e) if errno_not_supported(&e) => fsync_uninterrupted(file),
Err(e) => Err(e),
}
}
#[cfg(unix)]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let handle = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NONBLOCK)
.open(dir)
{
Ok(handle) => handle,
Err(e) if dir_sync_unsupported(&e) => return Ok(()),
Err(e) => return Err(e),
};
flush_dir_handle(&handle)
}
#[cfg(unix)]
fn flush_dir_handle(handle: &std::fs::File) -> io::Result<()> {
match handle.sync_all() {
Ok(()) => Ok(()),
#[cfg(any(target_os = "linux", target_os = "macos"))]
Err(e) if dir_sync_unsupported(&e) => match fsync_uninterrupted(handle) {
Ok(()) => Ok(()),
Err(again) if dir_sync_unsupported(&again) => Ok(()),
Err(again) => Err(again),
},
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
Err(e) if dir_sync_unsupported(&e) => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(windows)]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
let handle = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(dir)
{
Ok(handle) => handle,
Err(e) if dir_sync_unsupported(&e) => return Ok(()),
Err(e) => return Err(e),
};
if !handle.metadata()?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"Path to flush is not a directory",
));
}
match handle.sync_all() {
Ok(()) => Ok(()),
Err(e) if dir_sync_unsupported(&e) => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(unix)]
pub(crate) fn dir_sync_unsupported(e: &io::Error) -> bool {
errno_not_supported(e)
|| matches!(
e.raw_os_error(),
Some(code) if code == libc::EINVAL || code == libc::EBADF
)
}
#[cfg(windows)]
fn dir_sync_unsupported(e: &io::Error) -> bool {
const ERROR_INVALID_FUNCTION: i32 = 1;
const ERROR_NOT_SUPPORTED: i32 = 50;
const ERROR_INVALID_PARAMETER: i32 = 87;
e.kind() == io::ErrorKind::Unsupported
|| matches!(
e.raw_os_error(),
Some(ERROR_INVALID_FUNCTION | ERROR_NOT_SUPPORTED | ERROR_INVALID_PARAMETER)
)
}
pub(crate) struct OutputDir {
dir: cap_std::fs::Dir,
}
impl OutputDir {
pub(crate) fn open(path: &Path) -> io::Result<Self> {
let dir = cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority())?;
Ok(Self { dir })
}
#[cfg(any(test, target_os = "linux", target_os = "macos"))]
pub(crate) fn open_for_commit(path: &Path) -> io::Result<Self> {
Ok(Self {
dir: crate::fs::commit::open_commit_anchor(path)?,
})
}
#[cfg(unix)]
pub(crate) fn confirm_staged(&self, tmp: &NamedTempFile) -> Result<(), CryptoError> {
let Some(name) = tmp.path().file_name() else {
return Err(CryptoError::Io(no_final_component_error()));
};
require_staged_temp_in_output_dir(tmp, self, name)
}
#[cfg(not(unix))]
pub(crate) fn confirm_staged(&self, _tmp: &NamedTempFile) -> Result<(), CryptoError> {
Ok(())
}
#[must_use = "the outcome says whether the staged file is gone; report it"]
pub(crate) fn remove_staged_if_retained(&self, tmp: NamedTempFile) -> Rollback {
let outcome = match tmp.path().file_name().map(|name| name.to_os_string()) {
Some(name) => {
let (retained, temp_path) = tmp.into_parts();
let _ = temp_path.keep();
self.remove_if_retained(&name, retained)
}
None => {
let _ = tmp.into_temp_path().keep();
RollbackOutcome::Unconfirmed
}
};
Rollback {
outcome,
file: RemovedFile::Staged,
}
}
#[must_use = "the outcome says whether the committed file is gone; report it"]
pub(crate) fn remove_published_if_retained(
&self,
path: &Path,
finalized: FinalizedFile,
) -> Rollback {
let outcome = match path.file_name() {
Some(name) => self.remove_if_retained(name, finalized.file),
None => RollbackOutcome::Unconfirmed,
};
Rollback {
outcome,
file: RemovedFile::Committed,
}
}
fn remove_if_retained(
&self,
name: &std::ffi::OsStr,
retained: std::fs::File,
) -> RollbackOutcome {
self.remove_if_retained_with(name, retained, || {})
}
fn remove_if_retained_with(
&self,
name: &std::ffi::OsStr,
retained: std::fs::File,
between: impl FnOnce(),
) -> RollbackOutcome {
use cap_fs_ext::MetadataExt;
let Ok(committed) = cap_std::fs::Metadata::from_file(&retained) else {
return RollbackOutcome::Unconfirmed;
};
let Ok(entry) = self.dir.symlink_metadata(name) else {
return RollbackOutcome::Unconfirmed;
};
if !entry.is_file() {
return RollbackOutcome::Replaced;
}
match (file_identity(&entry), file_identity(&committed)) {
(Some(entry_id), Some(committed_id)) if entry_id == committed_id => {}
(Some(_), Some(_)) => return RollbackOutcome::Replaced,
_ => return RollbackOutcome::Unconfirmed,
}
between();
if remove_retained(&self.dir, name, retained).is_err() {
return RollbackOutcome::Unconfirmed;
}
match committed.nlink() {
1 => RollbackOutcome::Removed,
link_count => RollbackOutcome::RemovedButLinked { link_count },
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn flush_durable(&self) -> io::Result<()> {
use std::os::fd::AsFd;
use rustix::fs::{Mode, OFlags, openat};
let fd = match rustix::io::retry_on_intr(|| {
openat(
self.dir.as_fd(),
".",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
}) {
Ok(fd) => fd,
Err(e) => {
let e = io::Error::from(e);
return if errno_not_supported(&e) {
Ok(())
} else {
Err(e)
};
}
};
flush_dir_handle(&std::fs::File::from(fd))
}
}
#[cfg(unix)]
fn remove_retained(
dir: &cap_std::fs::Dir,
name: &std::ffi::OsStr,
retained: std::fs::File,
) -> io::Result<()> {
let result = dir.remove_file(name);
drop(retained);
result
}
#[cfg(windows)]
fn remove_retained(
_dir: &cap_std::fs::Dir,
_name: &std::ffi::OsStr,
retained: std::fs::File,
) -> io::Result<()> {
use cap_fs_ext::Reopen;
use cap_std::fs::OpenOptionsExt;
let mut options = cap_std::fs::OpenOptions::new();
options
.access_mode(DELETE | FILE_READ_ATTRIBUTES)
.custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
let marked_for_deletion = retained.reopen(&options)?;
drop(marked_for_deletion);
drop(retained);
Ok(())
}
#[cfg(windows)]
const DELETE: u32 = 0x0001_0000;
#[cfg(windows)]
const FILE_READ_ATTRIBUTES: u32 = 0x0080;
#[cfg(windows)]
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
#[cfg(unix)]
fn no_final_component_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"Output path has no final component",
)
}
#[cfg(all(unix, any(test, not(any(target_os = "linux", target_os = "macos")))))]
fn finalize_file_via_link_or_claim(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
) -> Result<FinalizedFile, FinalizeFileError> {
let output_dir = OutputDir::open(crate::fs::paths::parent_or_cwd(final_path))
.map_err(CryptoError::Io)
.map_err(FinalizeFileError::before_commit)?;
finalize_file_via_link_or_claim_in(tmp, final_path, label, &output_dir)
}
#[cfg_attr(any(target_os = "linux", target_os = "macos"), allow(dead_code))]
#[cfg(unix)]
fn finalize_file_via_link_or_claim_in(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
output_dir: &OutputDir,
) -> Result<FinalizedFile, FinalizeFileError> {
finalize_file_commit(
tmp,
final_path,
label,
output_dir,
|anchor, staged, final_name| {
crate::fs::commit::commit_by_link_or_claim(anchor, staged, final_name, CommitKind::File)
},
)
}
#[cfg(unix)]
fn finalize_file_commit(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
output_dir: &OutputDir,
commit: impl FnOnce(
&cap_std::fs::Dir,
&std::ffi::OsStr,
&std::ffi::OsStr,
) -> Result<CommitRoute, CommitFailure>,
) -> Result<FinalizedFile, FinalizeFileError> {
let Some(tmp_name) = tmp.path().file_name().map(|name| name.to_os_string()) else {
return Err(FinalizeFileError::before_commit(CryptoError::Io(
no_final_component_error(),
)));
};
if let Err(error) = require_staged_temp_in_output_dir(&tmp, output_dir, &tmp_name) {
return Err(FinalizeFileError::before_commit(error));
}
let Some(final_name) = final_path.file_name() else {
let _ = remove_staged_temp(tmp, output_dir);
return Err(FinalizeFileError::before_commit(CryptoError::Io(
no_final_component_error(),
)));
};
match commit(&output_dir.dir, &tmp_name, final_name) {
Ok(CommitRoute::Linked) => {
finish_link_commit(tmp, output_dir, final_path, final_name, &tmp_name)
}
Ok(CommitRoute::Renamed) => finish_renamed_commit(tmp, output_dir, final_path),
Err(failure) => {
let _ = remove_staged_temp(tmp, output_dir);
let error = if failure.error.kind() == io::ErrorKind::AlreadyExists {
already_exists_error(label, final_path)
} else {
CryptoError::Io(failure.error)
};
Err(FinalizeFileError::before_commit(if failure.claim_left {
crate::error::append_report(error, &claim_left_report(final_path))
} else {
error
}))
}
}
}
#[cfg(unix)]
fn finish_renamed_commit(
tmp: NamedTempFile,
output_dir: &OutputDir,
final_path: &Path,
) -> Result<FinalizedFile, FinalizeFileError> {
let (file, temp_path) = tmp.into_parts();
let _ = temp_path.keep();
sync_committed_parent(output_dir, final_path);
let finalized = FinalizedFile::new(file);
let finding = finalized.confirm_reported_path(final_path).err();
finalized.into_confirmed(final_path, finding)
}
#[cfg(unix)]
fn require_staged_temp_in_output_dir(
tmp: &NamedTempFile,
output_dir: &OutputDir,
tmp_name: &std::ffi::OsStr,
) -> Result<(), CryptoError> {
let staged = cap_std::fs::Metadata::from_file(tmp.as_file()).map_err(CryptoError::Io)?;
let entry = match output_dir.dir.symlink_metadata(tmp_name) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(staged_temp_not_in_output_dir(tmp_name));
}
Err(error) => return Err(CryptoError::Io(error)),
};
if !entry.is_file() || identities_differ(&entry, &staged) {
return Err(staged_temp_not_in_output_dir(tmp_name));
}
Ok(())
}
#[cfg(unix)]
fn staged_temp_not_in_output_dir(staged_name: &std::ffi::OsStr) -> CryptoError {
CryptoError::InvalidInput(format!(
"Staged temporary file {} is not the entry under its name in the destination directory",
sanitize_path_for_display(Path::new(staged_name)),
))
}
#[cfg(unix)]
fn finish_link_commit(
tmp: NamedTempFile,
output_dir: &OutputDir,
final_path: &Path,
final_name: &std::ffi::OsStr,
tmp_name: &std::ffi::OsStr,
) -> Result<FinalizedFile, FinalizeFileError> {
finish_link_commit_with_remove(
tmp,
output_dir,
final_path,
final_name,
tmp_name,
|dir, name| dir.remove_file(name),
)
}
#[cfg(unix)]
fn finish_link_commit_with_remove(
tmp: NamedTempFile,
output_dir: &OutputDir,
final_path: &Path,
final_name: &std::ffi::OsStr,
tmp_name: &std::ffi::OsStr,
remove_staged: impl FnOnce(&cap_std::fs::Dir, &std::ffi::OsStr) -> io::Result<()>,
) -> Result<FinalizedFile, FinalizeFileError> {
let (committed_identity, staged_unlink) =
unlink_staged_temp_with_remove(tmp, output_dir, tmp_name, remove_staged);
sync_committed_parent(output_dir, final_path);
let reopened = committed_identity
.map_err(|e| reported_path_unconfirmed(final_path, e))
.and_then(|identity| reopen_committed_file(output_dir, final_name, identity, final_path));
let finalized = match reopened {
Ok(finalized) => finalized,
Err(error) => {
return Err(FinalizeFileError::after_commit(with_staged_link_report(
error,
tmp_name,
staged_unlink,
)));
}
};
let finding = match (finalized.confirm_reported_path(final_path), staged_unlink) {
(Ok(()), Ok(())) => None,
(Ok(()), Err(unlink)) => Some(staged_temp_link_retained(final_path, tmp_name, unlink)),
(Err(error), unlink) => Some(with_staged_link_report(error, tmp_name, unlink)),
};
finalized.into_confirmed(final_path, finding)
}
#[cfg(unix)]
fn with_staged_link_report(
error: CryptoError,
staged_name: &std::ffi::OsStr,
unlink: io::Result<()>,
) -> CryptoError {
match unlink {
Ok(()) => error,
Err(unlink) => {
crate::error::append_report(error, &staged_link_report(staged_name, &unlink))
}
}
}
#[cfg(unix)]
fn claim_left_report(final_path: &Path) -> String {
format!(
"{} may still be occupied by an entry this run did not remove",
final_component_for_display(final_path)
)
}
#[cfg(unix)]
fn unlink_staged_temp(
tmp: NamedTempFile,
output_dir: &OutputDir,
name: &std::ffi::OsStr,
) -> (io::Result<Option<FileIdentity>>, io::Result<()>) {
unlink_staged_temp_with_remove(tmp, output_dir, name, |dir, name| dir.remove_file(name))
}
#[cfg(unix)]
fn unlink_staged_temp_with_remove(
tmp: NamedTempFile,
output_dir: &OutputDir,
name: &std::ffi::OsStr,
remove_staged: impl FnOnce(&cap_std::fs::Dir, &std::ffi::OsStr) -> io::Result<()>,
) -> (io::Result<Option<FileIdentity>>, io::Result<()>) {
let (file, temp_path) = tmp.into_parts();
let identity = cap_std::fs::Metadata::from_file(&file).map(|metadata| file_identity(&metadata));
drop(file);
let _ = temp_path.keep();
let result = match remove_staged(&output_dir.dir, name) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
};
(identity, result)
}
#[cfg(unix)]
fn remove_staged_temp(tmp: NamedTempFile, output_dir: &OutputDir) -> io::Result<()> {
let Some(name) = tmp.path().file_name().map(|name| name.to_os_string()) else {
let _ = tmp.into_temp_path().keep();
return Err(no_final_component_error());
};
let (_, result) = unlink_staged_temp(tmp, output_dir, &name);
result
}
#[cfg(unix)]
fn reopen_committed_file(
output_dir: &OutputDir,
final_name: &std::ffi::OsStr,
expected: Option<FileIdentity>,
final_path: &Path,
) -> Result<FinalizedFile, CryptoError> {
use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt, OpenOptionsSyncExt};
let mut options = cap_std::fs::OpenOptions::new();
options.read(true).follow(FollowSymlinks::No).nonblock(true);
let file = output_dir
.dir
.open_with(final_name, &options)
.map_err(|e| reopen_failure(output_dir, final_name, final_path, e))?
.into_std();
let metadata = cap_std::fs::Metadata::from_file(&file)
.map_err(|e| reported_path_unconfirmed(final_path, e))?;
if !metadata.is_file() {
return Err(reported_output_changed(final_path));
}
if matches!(
(file_identity(&metadata), expected),
(Some(found), Some(expected)) if found != expected
) {
return Err(reported_output_changed(final_path));
}
Ok(FinalizedFile::new(file))
}
#[cfg(unix)]
fn reopen_failure(
output_dir: &OutputDir,
final_name: &std::ffi::OsStr,
final_path: &Path,
error: io::Error,
) -> CryptoError {
match output_dir.dir.symlink_metadata(final_name) {
Ok(entry) if !entry.is_file() => reported_output_changed(final_path),
_ => reported_entry_error(final_path, error),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn sync_committed_parent(output_dir: &OutputDir, _final_path: &Path) {
let _ = output_dir.flush_durable();
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
fn sync_committed_parent(_output_dir: &OutputDir, final_path: &Path) {
sync_parent_dir(final_path);
}
#[cfg(unix)]
fn staged_temp_link_retained(
final_path: &Path,
staged_name: &std::ffi::OsStr,
source: io::Error,
) -> CryptoError {
CryptoError::Io(io::Error::new(
source.kind(),
format!(
"Output {} is complete, but {}",
sanitize_path_for_display(final_path),
staged_link_report(staged_name, &source),
),
))
}
#[cfg(unix)]
fn staged_link_report(staged_name: &std::ffi::OsStr, source: &io::Error) -> String {
format!(
"temporary name {} could not be removed: {source}",
sanitize_path_for_display(Path::new(staged_name)),
)
}
#[cfg_attr(any(target_os = "linux", target_os = "macos"), allow(dead_code))]
pub(crate) fn promote_single_file_no_clobber(from: &Path, to: &Path) -> io::Result<()> {
let temp_path = tempfile::TempPath::try_from_path(from)?;
match temp_path.persist_noclobber(to) {
Ok(()) => {
sync_parent_dir(to);
Ok(())
}
Err(e) => {
let mut recovered = e.path;
recovered.disable_cleanup(true);
Err(e.error)
}
}
}
#[cfg_attr(any(target_os = "linux", target_os = "macos"), allow(dead_code))]
pub(crate) fn rename_no_clobber(from: &Path, to: &Path) -> io::Result<()> {
rename_no_clobber_impl(from, to)?;
sync_parent_dir(to);
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[allow(dead_code)]
fn rename_no_clobber_impl(from: &Path, to: &Path) -> io::Result<()> {
use rustix::fs::{CWD, RenameFlags, renameat_with};
renameat_with(CWD, from, CWD, to, RenameFlags::NOREPLACE).map_err(io::Error::from)
}
#[cfg(target_os = "windows")]
fn rename_no_clobber_impl(from: &Path, to: &Path) -> io::Result<()> {
match std::fs::symlink_metadata(to) {
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"Target already exists",
));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
std::fs::rename(from, to)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn rename_no_clobber_impl(_from: &Path, _to: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Atomic rename is not supported on this target",
))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Write;
use super::*;
#[test]
fn finalize_file_refuses_to_overwrite() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
fs::write(&final_path, "existing").unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"new").unwrap();
let tmp_path = tmp.path().to_path_buf();
let error = commit_to(tmp, &final_path, "Output")
.expect_err("an occupied output must fail before commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(msg) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
assert_eq!(fs::read_to_string(&final_path).unwrap(), "existing");
assert!(!tmp_path.exists(), "temp file must be removed on failure");
}
#[test]
fn finalize_file_succeeds_when_target_missing() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
commit_to(tmp, &final_path, "Output").unwrap();
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
}
#[cfg(unix)]
#[test]
fn finalized_file_rejects_a_reported_path_after_parent_replacement() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let out = tmp_dir.path().join("out");
fs::create_dir(&out).unwrap();
let final_path = out.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&out).unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
let moved = tmp_dir.path().join("out.moved");
fs::rename(&out, &moved).unwrap();
fs::create_dir(&out).unwrap();
fs::write(&final_path, b"replacement").unwrap();
let err = finalized
.confirm_reported_path(&final_path)
.expect_err("the returned path no longer denotes the committed file");
assert!(
matches!(
err,
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the path mismatch must be explicit"
);
assert_eq!(fs::read(moved.join("out.txt")).unwrap(), b"payload");
assert_eq!(fs::read(&final_path).unwrap(), b"replacement");
}
#[test]
fn finalized_file_rejects_a_reported_path_after_entry_replacement() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
let moved = tmp_dir.path().join("out.moved");
fs::rename(&final_path, &moved).unwrap();
fs::write(&final_path, b"replacement").unwrap();
let err = finalized
.confirm_reported_path(&final_path)
.expect_err("the returned path no longer denotes the committed file");
assert!(
matches!(
err,
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the entry mismatch must be explicit"
);
assert_eq!(fs::read(&moved).unwrap(), b"payload");
assert_eq!(fs::read(&final_path).unwrap(), b"replacement");
}
fn plant_file_symlink(target: &Path, link: &Path) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
let result = std::os::windows::fs::symlink_file(target, link);
if let Err(e) = &result {
if std::env::var_os("FERROCRYPT_REQUIRE_WINDOWS_SYMLINK_TESTS").is_some() {
panic!("symlink creation required by CI but failed: {e}");
}
}
result
}
}
#[test]
fn finalized_file_rejects_a_symlink_planted_at_the_reported_path() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
let moved = tmp_dir.path().join("out.moved");
fs::rename(&final_path, &moved).unwrap();
if let Err(e) = plant_file_symlink(&moved, &final_path) {
eprintln!("symlink creation unavailable ({e}); skipping");
return;
}
let err = finalized
.confirm_reported_path(&final_path)
.expect_err("a symlink at the reported name is not the committed entry");
assert!(
matches!(
err,
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the planted symlink must report as a path mismatch"
);
assert_eq!(fs::read(&moved).unwrap(), b"payload");
}
#[test]
fn finalized_file_rejects_a_reported_path_that_no_longer_exists() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
fs::rename(&final_path, tmp_dir.path().join("out.moved")).unwrap();
let err = finalized
.confirm_reported_path(&final_path)
.expect_err("a missing final name no longer denotes the output");
assert!(
matches!(
err,
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the missing name must report as a path mismatch"
);
}
#[cfg(unix)]
#[test]
fn finalized_file_rejects_a_reported_path_that_no_longer_resolves() {
type Plant = fn(&Path) -> io::Result<()>;
let plants: [(&str, Plant); 2] = [
("file", |dir| fs::write(dir, b"not a directory")),
("symlink cycle", |dir| std::os::unix::fs::symlink(dir, dir)),
];
for (substitute, plant) in plants {
let tmp_dir = tempfile::TempDir::new().unwrap();
let out = tmp_dir.path().join("out");
fs::create_dir(&out).unwrap();
let final_path = out.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&out).unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
fs::rename(&out, tmp_dir.path().join("out.moved")).unwrap();
plant(&out).unwrap();
let err = finalized
.confirm_reported_path(&final_path)
.expect_err("a path that no longer resolves no longer denotes the output");
assert!(
matches!(
&err,
CryptoError::InvalidInput(message) if message.contains("reported path changed")
),
"a {substitute} in place of the directory must report as changed, got: {err}"
);
}
}
#[cfg(unix)]
#[test]
fn finalized_file_reports_an_unreadable_reported_path_as_complete() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::TempDir::new().unwrap();
let out = tmp_dir.path().join("out");
fs::create_dir(&out).unwrap();
let final_path = out.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&out).unwrap();
tmp.write_all(b"payload").unwrap();
let finalized = commit_to(tmp, &final_path, "Output").unwrap();
fs::set_permissions(&out, fs::Permissions::from_mode(0o000)).unwrap();
let outcome = finalized.confirm_reported_path(&final_path);
fs::set_permissions(&out, fs::Permissions::from_mode(0o755)).unwrap();
match outcome {
Ok(()) => {}
Err(CryptoError::Io(error)) => {
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_unconfirmed_path(&error.to_string(), &final_path);
}
Err(other) => panic!("the denial must be reported as I/O, got: {other}"),
}
assert_eq!(fs::read(&final_path).unwrap(), b"payload");
}
#[cfg(unix)]
fn assert_unconfirmed_path(message: &str, path: &Path) {
let expected = format!(
"Output is complete, but its reported path could not be confirmed: {}: ",
sanitize_path_for_display(path)
);
assert!(
message.starts_with(&expected),
"expected a message starting with {expected:?}, got: {message}"
);
}
#[test]
fn every_post_commit_error_says_the_output_is_complete() {
let path = Path::new("out.fcr");
let denied = || io::Error::from(io::ErrorKind::PermissionDenied);
let read_failed = Some(io::ErrorKind::PermissionDenied);
let cases = [
(reported_output_changed(path), None),
(
reported_entry_error(path, io::Error::from(io::ErrorKind::NotFound)),
None,
),
(reported_entry_error(path, denied()), read_failed),
(reported_path_unconfirmed(path, denied()), read_failed),
(link_count_unreadable(path, denied()), read_failed),
(committed_link_count_error("out.fcr", 2), None),
#[cfg(unix)]
(
staged_temp_link_retained(path, std::ffi::OsStr::new(".staged"), denied()),
read_failed,
),
];
for (error, kind) in cases {
assert!(
says_output_is_complete(&error),
"a post-commit error must say the output is complete, got: {error}"
);
if let Some(kind) = kind {
assert!(
matches!(&error, CryptoError::Io(source) if source.kind() == kind),
"the failure's kind must survive, got: {error:?}"
);
}
}
let unreadable = link_count_unreadable(path, denied()).to_string();
assert!(
unreadable.contains("its number of filesystem names could not be read: "),
"an unreadable count must say what could not be read, got: {unreadable}"
);
}
#[test]
#[should_panic(expected = "a post-commit error must say that the output is complete")]
fn a_post_commit_error_without_the_wording_fails_the_tests() {
let _ = FinalizeFileError::after_commit(CryptoError::Io(io::Error::other(
"Output out.fcr could not be checked",
)));
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[test]
fn persist_arm_rejects_a_second_link_to_the_committed_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
fs::write(&final_path, "payload").unwrap();
let leftover = tmp_dir.path().join(".tmpleftover.incomplete");
fs::hard_link(&final_path, &leftover).unwrap();
let file = fs::File::open(&final_path).unwrap();
let error = finalized_from_persist(file, &final_path)
.expect_err("a surviving second link must fail after commit");
assert!(error.committed());
let message = error.into_crypto_error().to_string();
assert!(message.contains("has 2 filesystem names"), "got: {message}");
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert_eq!(fs::read_to_string(&leftover).unwrap(), "payload");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn finalize_file_commits_into_a_write_and_search_only_directory() {
let out = crate::fs::commit::SearchOnlyDir::new();
let final_path = out.path().join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(out.path()).unwrap();
tmp.write_all(b"payload").unwrap();
out.close_reading();
let finalized = commit_to(tmp, &final_path, "Output")
.map_err(FinalizeFileError::into_crypto_error)
.expect("a write-and-search-only directory must still receive the output");
finalized.confirm_reported_path(&final_path).unwrap();
assert_eq!(fs::read(&final_path).unwrap(), b"payload");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn finalize_file_still_refuses_an_occupied_name_in_a_search_only_directory() {
let out = crate::fs::commit::SearchOnlyDir::new();
let final_path = out.path().join("out.txt");
fs::write(&final_path, "existing").unwrap();
let mut tmp = tempfile::Builder::new().tempfile_in(out.path()).unwrap();
tmp.write_all(b"payload").unwrap();
out.close_reading();
let error = commit_to(tmp, &final_path, "Output")
.expect_err("an occupied final name must refuse the commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(message) => {
assert!(
message.starts_with("Output already exists: "),
"got: {message}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
assert_eq!(fs::read_to_string(&final_path).unwrap(), "existing");
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_directory_swapped_in_after_staging_receives_nothing() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let chosen = tmp_dir.path().join("out");
let substituted = tmp_dir.path().join("theirs");
fs::create_dir(&chosen).unwrap();
fs::create_dir(&substituted).unwrap();
let anchor = OutputDir::open_for_commit(&chosen).unwrap();
let mut tmp = tempfile::Builder::new().tempfile_in(&chosen).unwrap();
anchor.confirm_staged(&tmp).unwrap();
tmp.write_all(b"payload").unwrap();
fs::rename(&chosen, tmp_dir.path().join("out.moved")).unwrap();
fs::rename(&substituted, &chosen).unwrap();
let error = finalize_file(tmp, &chosen.join("out.txt"), "Output", &anchor)
.expect_err("the reported path no longer names the output");
assert!(
error.committed(),
"the output is committed; only the reported path is wrong"
);
assert!(
!chosen.join("out.txt").exists(),
"nothing may reach the substituted directory"
);
assert_eq!(
fs::read(tmp_dir.path().join("out.moved").join("out.txt")).unwrap(),
b"payload",
"the output belongs in the directory the write began in"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn a_staged_file_moved_into_a_swapped_in_directory_refuses_the_commit() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let chosen = tmp_dir.path().join("out");
let replacement = tmp_dir.path().join("replacement");
fs::create_dir(&chosen).unwrap();
fs::create_dir(&replacement).unwrap();
let anchor = OutputDir::open_for_commit(&chosen).unwrap();
let mut tmp = tempfile::Builder::new().tempfile_in(&chosen).unwrap();
anchor.confirm_staged(&tmp).unwrap();
tmp.write_all(b"payload").unwrap();
let staged_name = tmp.path().file_name().unwrap().to_os_string();
fs::rename(chosen.join(&staged_name), replacement.join(&staged_name)).unwrap();
fs::rename(&chosen, tmp_dir.path().join("out.moved")).unwrap();
fs::rename(&replacement, &chosen).unwrap();
let error = finalize_file(tmp, &chosen.join("out.txt"), "Output", &anchor)
.expect_err("a staged file outside the anchor must refuse the commit");
assert!(!error.committed(), "nothing may be committed");
assert!(
!chosen.join("out.txt").exists(),
"the replacement directory must receive nothing"
);
assert!(
!tmp_dir.path().join("out.moved").join("out.txt").exists(),
"the original directory must receive nothing either"
);
}
#[test]
fn finalize_file_rejects_a_link_planted_against_the_staged_temporary() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let planted = tmp_dir.path().join("planted.txt");
fs::hard_link(tmp.path(), &planted).unwrap();
let error = commit_to(tmp, &final_path, "Output")
.expect_err("a second link to the committed file must fail after commit");
assert!(error.committed());
let message = error.into_crypto_error().to_string();
assert!(message.contains("has 2 filesystem names"), "got: {message}");
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert_eq!(fs::read_to_string(&planted).unwrap(), "payload");
}
#[cfg(unix)]
#[test]
fn no_replace_rename_unsupported_matches_capability_errors_only() {
assert!(no_replace_rename_unsupported(
&io::Error::from_raw_os_error(libc::ENOTSUP)
));
assert!(no_replace_rename_unsupported(
&io::Error::from_raw_os_error(libc::EOPNOTSUPP)
));
assert!(no_replace_rename_unsupported(
&io::Error::from_raw_os_error(libc::EINVAL)
));
assert!(no_replace_rename_unsupported(&io::Error::new(
io::ErrorKind::Unsupported,
"flag not supported",
)));
assert!(!no_replace_rename_unsupported(
&io::Error::from_raw_os_error(libc::EEXIST)
));
assert!(!no_replace_rename_unsupported(&io::Error::new(
io::ErrorKind::NotFound,
"missing",
)));
}
#[cfg(unix)]
#[test]
fn errno_not_supported_excludes_einval() {
assert!(errno_not_supported(&io::Error::from_raw_os_error(
libc::ENOTSUP
)));
assert!(!errno_not_supported(&io::Error::from_raw_os_error(
libc::EINVAL
)));
}
#[test]
fn sync_file_durable_succeeds_on_regular_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let path = tmp_dir.path().join("synced.txt");
fs::write(&path, b"bytes").unwrap();
let file = fs::OpenOptions::new().write(true).open(&path).unwrap();
sync_file_durable(&file).unwrap();
}
#[test]
fn sync_dir_durable_succeeds_on_regular_directory() {
let tmp_dir = tempfile::TempDir::new().unwrap();
fs::write(tmp_dir.path().join("entry.txt"), b"bytes").unwrap();
sync_dir_durable(tmp_dir.path()).unwrap();
}
#[test]
fn sync_dir_durable_reports_missing_directory() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let gone = tmp_dir.path().join("vanished");
let err =
sync_dir_durable(&gone).expect_err("missing directory must fail the directory flush");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn sync_dir_durable_rejects_regular_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let file_path = tmp_dir.path().join("not-a-directory");
fs::write(&file_path, b"bytes").unwrap();
let err =
sync_dir_durable(&file_path).expect_err("regular file must fail the directory flush");
assert_eq!(err.kind(), io::ErrorKind::NotADirectory);
}
#[cfg(unix)]
#[test]
fn sync_dir_durable_reports_permission_denied_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::TempDir::new().unwrap();
let locked = tmp_dir.path().join("writeonly");
fs::create_dir(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o333)).unwrap();
let read_denied = fs::File::open(&locked).is_err();
let result = sync_dir_durable(&locked);
fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
if read_denied {
let err = result.expect_err("a read-denied directory must fail the required flush");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
}
#[cfg(unix)]
#[test]
fn dir_sync_unsupported_classifies_unix_errnos() {
for unavailable in [libc::ENOTSUP, libc::EINVAL, libc::EBADF] {
assert!(
dir_sync_unsupported(&io::Error::from_raw_os_error(unavailable)),
"errno {unavailable} must classify as directory-flush unsupported"
);
}
assert!(dir_sync_unsupported(&io::Error::new(
io::ErrorKind::Unsupported,
"synthesized unsupported"
)));
for genuine in [
libc::EIO,
libc::ENOENT,
libc::ENOTDIR,
libc::ENOSPC,
libc::EACCES,
] {
assert!(
!dir_sync_unsupported(&io::Error::from_raw_os_error(genuine)),
"errno {genuine} must propagate as a genuine failure"
);
}
}
#[cfg(windows)]
#[test]
fn dir_sync_unsupported_classifies_windows_errors() {
for unavailable in [1, 50, 87] {
assert!(
dir_sync_unsupported(&io::Error::from_raw_os_error(unavailable)),
"code {unavailable} must classify as directory-flush unsupported"
);
}
for genuine in [2, 1117, 5] {
assert!(
!dir_sync_unsupported(&io::Error::from_raw_os_error(genuine)),
"code {genuine} must propagate as a genuine failure"
);
}
}
#[cfg(unix)]
fn within_timeout<T: Send + 'static>(what: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(value) => value,
Err(_) => panic!("{what} blocked instead of refusing a non-directory path"),
}
}
#[cfg(unix)]
#[test]
fn sync_dir_durable_rejects_fifo_without_blocking() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let fifo = tmp_dir.path().join("pipe");
crate::fs::paths::make_fifo(&fifo);
let err = within_timeout("sync_dir_durable", move || sync_dir_durable(&fifo))
.expect_err("a FIFO must fail the directory flush");
assert_eq!(err.kind(), io::ErrorKind::NotADirectory);
}
#[cfg(unix)]
#[test]
fn sync_parent_dir_does_not_block_on_a_fifo_parent() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let fifo = tmp_dir.path().join("parent");
crate::fs::paths::make_fifo(&fifo);
let child = fifo.join("committed.txt");
within_timeout("sync_parent_dir", move || sync_parent_dir(&child));
}
#[cfg(unix)]
#[test]
fn output_dir_removal_stays_in_the_anchored_directory() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let anchored = tmp_dir.path().join("out");
let substituted = tmp_dir.path().join("elsewhere");
fs::create_dir(&anchored).unwrap();
fs::create_dir(&substituted).unwrap();
fs::write(substituted.join("key"), "unrelated").unwrap();
let handle = OutputDir::open(&anchored).unwrap();
let ours = crate::fs::commit::create_file_at(
&handle.dir,
std::ffi::OsStr::new("key"),
crate::fs::commit::STAGED_FILE_MODE,
)
.unwrap()
.into_std();
fs::rename(&anchored, tmp_dir.path().join("out.moved")).unwrap();
std::os::unix::fs::symlink(&substituted, &anchored).unwrap();
assert_eq!(
handle.remove_if_retained(std::ffi::OsStr::new("key"), ours),
RollbackOutcome::Removed
);
assert_eq!(
fs::read_to_string(substituted.join("key")).unwrap(),
"unrelated",
"a substituted directory's file must not be removed"
);
assert!(
!tmp_dir.path().join("out.moved").join("key").exists(),
"the anchored directory's own entry must still be removed"
);
}
fn commit_through_anchor(
dir: &Path,
anchor: &OutputDir,
name: &str,
) -> (std::path::PathBuf, FinalizedFile) {
let mut tmp = tempfile::Builder::new().tempfile_in(dir).unwrap();
tmp.write_all(b"committed").unwrap();
let path = dir.join(name);
let finalized = finalize_file(tmp, &path, "Key file", anchor)
.expect("the commit must succeed on a fresh name");
(path, finalized)
}
#[test]
fn rollback_reports_a_removed_linked_replaced_or_missing_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let dir = tmp_dir.path();
let anchor = OutputDir::open(dir).unwrap();
let committed = |outcome| Rollback {
outcome,
file: RemovedFile::Committed,
};
let (removed, finalized) = commit_through_anchor(dir, &anchor, "removed");
assert_eq!(
anchor.remove_published_if_retained(&removed, finalized),
committed(RollbackOutcome::Removed)
);
assert!(!removed.exists(), "a file with one name must be gone");
let (linked, finalized) = commit_through_anchor(dir, &anchor, "linked");
let other_name = dir.join("linked.other");
fs::hard_link(&linked, &other_name).unwrap();
assert_eq!(
anchor.remove_published_if_retained(&linked, finalized),
committed(RollbackOutcome::RemovedButLinked { link_count: 2 })
);
assert!(!linked.exists(), "the committed name must still be removed");
assert_eq!(
fs::read(&other_name).unwrap(),
b"committed",
"the other name is not this operation's to remove"
);
let (replaced, finalized) = commit_through_anchor(dir, &anchor, "replaced");
let moved = dir.join("replaced.moved");
fs::rename(&replaced, &moved).unwrap();
fs::write(&replaced, b"planted").unwrap();
assert_eq!(
anchor.remove_published_if_retained(&replaced, finalized),
committed(RollbackOutcome::Replaced)
);
assert_eq!(fs::read(&replaced).unwrap(), b"planted");
assert_eq!(fs::read(&moved).unwrap(), b"committed");
let (missing, finalized) = commit_through_anchor(dir, &anchor, "missing");
fs::remove_file(&missing).unwrap();
assert_eq!(
anchor.remove_published_if_retained(&missing, finalized),
committed(RollbackOutcome::Unconfirmed)
);
}
#[cfg(windows)]
#[test]
fn rollback_removal_acts_on_the_committed_file_not_on_the_name() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let dir = tmp_dir.path();
let anchor = OutputDir::open(dir).unwrap();
let (path, finalized) = commit_through_anchor(dir, &anchor, "private.key");
let moved = dir.join("private.moved");
let outcome =
anchor.remove_if_retained_with(path.file_name().unwrap(), finalized.file, || {
fs::rename(&path, &moved).unwrap();
fs::write(&path, b"planted").unwrap();
});
assert_eq!(outcome, RollbackOutcome::Removed);
assert_eq!(
fs::read(&path).unwrap(),
b"planted",
"an entry planted after the identity check must survive"
);
assert!(
!moved.exists(),
"the committed file must be removed under whatever name it holds"
);
}
#[test]
fn rollback_report_is_appended_without_changing_the_error_class() {
let path = Path::new("out/private.key");
let io_error = || CryptoError::Io(io::Error::new(io::ErrorKind::TimedOut, "flush failed"));
let staged = |outcome| Rollback {
outcome,
file: RemovedFile::Staged,
};
let unchanged = with_rollback_report(io_error(), staged(RollbackOutcome::Removed), path);
assert_eq!(unchanged.to_string(), "flush failed");
let linked = with_rollback_report(
io_error(),
staged(RollbackOutcome::RemovedButLinked { link_count: 2 }),
path,
);
assert_eq!(
linked.to_string(),
"flush failed; the removed private.key had 2 filesystem names, so a copy may remain under another name"
);
match linked {
CryptoError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::TimedOut),
other => panic!("the I/O class must be kept, got {other:?}"),
}
let replaced = with_rollback_report(
CryptoError::InvalidInput("Key file already exists: out/public.key".into()),
staged(RollbackOutcome::Replaced),
path,
);
assert_eq!(
replaced.to_string(),
"Key file already exists: out/public.key; private.key was replaced during the operation and left in place, and the file this run wrote may remain under another name"
);
assert!(matches!(replaced, CryptoError::InvalidInput(_)));
let unconfirmed =
with_rollback_report(io_error(), staged(RollbackOutcome::Unconfirmed), path);
assert_eq!(
unconfirmed.to_string(),
"flush failed; the removal of private.key could not be confirmed"
);
}
#[test]
fn a_committed_file_rollback_reports_the_commit_beside_its_outcome() {
let path = Path::new("out/private.key");
let io_error = || CryptoError::Io(io::Error::new(io::ErrorKind::TimedOut, "flush failed"));
let committed = |outcome| {
let rollback = Rollback {
outcome,
file: RemovedFile::Committed,
};
with_rollback_report(io_error(), rollback, path)
};
assert_eq!(
committed(RollbackOutcome::Removed).to_string(),
"flush failed"
);
assert_eq!(
committed(RollbackOutcome::Unconfirmed).to_string(),
"flush failed; a complete private.key was committed, and its removal could not be confirmed"
);
assert_eq!(
committed(RollbackOutcome::Replaced).to_string(),
"flush failed; a complete private.key was committed, but the entry at its name was replaced and left in place, and the committed file may remain under another name"
);
let linked = committed(RollbackOutcome::RemovedButLinked { link_count: 2 });
assert_eq!(
linked.to_string(),
"flush failed; a complete private.key was committed and then removed, but it had 2 filesystem names, so a copy may remain under another name"
);
match linked {
CryptoError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::TimedOut),
other => panic!("the I/O class must be kept, got {other:?}"),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn output_dir_flush_durable_follows_the_handle_after_a_path_swap() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let anchored = tmp_dir.path().join("out");
fs::create_dir(&anchored).unwrap();
let handle = OutputDir::open(&anchored).unwrap();
fs::rename(&anchored, tmp_dir.path().join("out.moved")).unwrap();
handle
.flush_durable()
.expect("the flush must follow the handle, not the path");
assert!(
sync_dir_durable(&anchored).is_err(),
"the old path no longer names a directory to flush"
);
}
#[test]
fn a_zero_inode_is_no_identity() {
assert_eq!(identity_pair(7, 0), None);
assert_eq!(identity_pair(0, 0), None);
assert_eq!(identity_pair(7, 1), Some((7, 1)));
assert_eq!(identity_pair(0, 1), Some((0, 1)));
}
#[cfg(unix)]
#[test]
fn finalize_via_link_commits_and_drops_the_staged_name() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.permissions(fs::Permissions::from_mode(0o600))
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_path = tmp.path().to_path_buf();
finalize_file_via_link_or_claim(tmp, &final_path, "Output").unwrap();
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert!(
!tmp_path.exists(),
"staged name must not survive the commit"
);
assert_eq!(
fs::metadata(&final_path).unwrap().permissions().mode() & 0o777,
0o600,
"the staged file's mode must survive the commit"
);
}
#[cfg(unix)]
#[test]
fn fallback_refuses_a_temp_staged_outside_the_destination_directory() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let elsewhere = tmp_dir.path().join("elsewhere");
let out = tmp_dir.path().join("out");
fs::create_dir(&elsewhere).unwrap();
fs::create_dir(&out).unwrap();
let final_path = out.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&elsewhere).unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_path = tmp.path().to_path_buf();
let error = finalize_file_via_link_or_claim(tmp, &final_path, "Output")
.expect_err("a temp staged elsewhere must be refused");
assert!(!error.committed());
assert!(
matches!(
error.into_crypto_error(),
CryptoError::InvalidInput(message)
if message.contains("is not the entry under its name")
),
"the refusal must name the staged-entry mismatch"
);
assert_eq!(
fs::read_dir(&out).unwrap().count(),
0,
"the refusal must precede any entry at the final name"
);
assert!(
!tmp_path.exists(),
"the refused temp is cleaned up at its own path"
);
}
#[cfg(unix)]
#[test]
fn fallback_verifies_the_staged_temp_before_any_removal() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let elsewhere = tmp_dir.path().join("elsewhere");
let out = tmp_dir.path().join("out");
fs::create_dir(&elsewhere).unwrap();
fs::create_dir(&out).unwrap();
let mut tmp = tempfile::Builder::new().tempfile_in(&elsewhere).unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_path = tmp.path().to_path_buf();
let planted = out.join(tmp_path.file_name().unwrap());
fs::write(&planted, b"unrelated").unwrap();
let error = finalize_file_via_link_or_claim_in(
tmp,
Path::new("/"),
"Output",
&OutputDir::open(&out).unwrap(),
)
.expect_err("an unverified staged temp must be refused");
assert!(!error.committed());
assert!(
matches!(
error.into_crypto_error(),
CryptoError::InvalidInput(message)
if message.contains("is not the entry under its name")
),
"the identity refusal must come before the missing-name error"
);
assert_eq!(
fs::read(&planted).unwrap(),
b"unrelated",
"the same-named entry in the anchor must survive"
);
assert!(!tmp_path.exists(), "the temp is removed at its own path");
}
#[cfg(unix)]
#[test]
fn fallback_removes_a_verified_temp_when_the_final_path_has_no_name() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_path = tmp.path().to_path_buf();
let error = finalize_file_via_link_or_claim_in(
tmp,
Path::new("/"),
"Output",
&OutputDir::open(tmp_dir.path()).unwrap(),
)
.expect_err("a final path with no last component must be refused");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidInput),
other => panic!("expected an I/O error, got {other:?}"),
}
assert!(!tmp_path.exists(), "the staged temp must be removed");
}
#[cfg(unix)]
#[test]
fn finalize_via_link_reports_a_retained_staging_link() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|_, _| {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected staged unlink failure",
))
},
)
.expect_err("a retained temporary name must not report success");
assert!(error.committed());
let rendered = error.into_crypto_error().to_string();
assert!(
rendered.contains("temporary name") && rendered.contains("could not be removed"),
"the retained link must be explicit, got: {rendered}"
);
assert!(
rendered.contains("has 2 filesystem names"),
"the retained link must not hide the count, got: {rendered}"
);
assert_eq!(fs::read(&final_path).unwrap(), b"payload");
assert_eq!(fs::read(tmp_dir.path().join(tmp_name)).unwrap(), b"payload");
}
#[cfg(unix)]
#[test]
fn finalize_via_link_reports_an_unopenable_final_name_as_complete() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let outcome = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|dir, name| {
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o000))?;
dir.remove_file(name)
},
);
fs::set_permissions(&final_path, fs::Permissions::from_mode(0o600)).unwrap();
match outcome {
Ok(_) => {}
Err(error) => {
assert!(error.committed());
match error.into_crypto_error() {
CryptoError::Io(error) => {
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_unconfirmed_path(&error.to_string(), &final_path);
}
other => panic!("the denial must be reported as I/O, got: {other}"),
}
}
}
assert_eq!(fs::read(&final_path).unwrap(), b"payload");
assert!(!tmp_dir.path().join(tmp_name).exists());
}
#[cfg(unix)]
#[test]
fn finalize_via_link_reports_a_retained_staging_link_beside_a_failed_check() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let moved = tmp_dir.path().join("out.moved");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|_, _| {
fs::rename(&final_path, &moved)?;
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected staged unlink failure",
))
},
)
.expect_err("a moved final name must not report success");
assert!(error.committed());
let message = error.into_crypto_error().to_string();
assert!(
message.contains("reported path changed")
&& message.contains("temporary name")
&& message.contains("could not be removed"),
"both conditions must be reported, got: {message}"
);
assert_eq!(fs::read(&moved).unwrap(), b"payload");
assert_eq!(fs::read(tmp_dir.path().join(tmp_name)).unwrap(), b"payload");
}
#[cfg(unix)]
fn staged_with_outsider_link(dir: &Path, outsider: &Path) -> NamedTempFile {
let mut tmp = tempfile::Builder::new().tempfile_in(dir).unwrap();
tmp.write_all(b"payload").unwrap();
fs::hard_link(tmp.path(), outsider).unwrap();
tmp
}
#[cfg(unix)]
fn assert_changed_path_with_count(error: FinalizeFileError, path: &Path) {
assert!(error.committed());
let error = error.into_crypto_error();
let shown = sanitize_path_for_display(path);
assert!(
matches!(
&error,
CryptoError::InvalidInput(message)
if message.contains(&format!("reported path changed: {shown}"))
&& message.contains("; the output has 2 filesystem names (expected 1)")
&& message.matches(&shown).count() == 1
),
"a changed path must not hide the count or be named as the output, got: {error}"
);
}
#[cfg(unix)]
#[test]
fn finalize_via_link_reports_the_count_beside_a_changed_path() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let out = tmp_dir.path().join("out");
fs::create_dir(&out).unwrap();
let moved = tmp_dir.path().join("out.moved");
let final_path = out.join("out.txt");
let tmp = staged_with_outsider_link(&out, &out.join("outsider"));
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(&out).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|dir, name| {
dir.remove_file(name)?;
fs::rename(&out, &moved)?;
fs::create_dir(&out)
},
)
.expect_err("a changed path must not report success");
assert_changed_path_with_count(error, &final_path);
assert_eq!(fs::read(moved.join("out.txt")).unwrap(), b"payload");
assert_eq!(fs::read(moved.join("outsider")).unwrap(), b"payload");
}
#[cfg(unix)]
#[test]
fn finish_renamed_commit_reports_the_count_beside_a_changed_path() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let out = tmp_dir.path().join("out");
fs::create_dir(&out).unwrap();
let moved = tmp_dir.path().join("out.moved");
let final_path = out.join("out.txt");
let tmp = staged_with_outsider_link(&out, &out.join("outsider"));
let output_dir = OutputDir::open(&out).unwrap();
fs::rename(tmp.path(), &final_path).unwrap();
fs::rename(&out, &moved).unwrap();
fs::create_dir(&out).unwrap();
let error = finish_renamed_commit(tmp, &output_dir, &final_path)
.expect_err("a changed path must not report success");
assert_changed_path_with_count(error, &final_path);
assert_eq!(fs::read(moved.join("out.txt")).unwrap(), b"payload");
assert_eq!(fs::read(moved.join("outsider")).unwrap(), b"payload");
}
#[cfg(unix)]
#[test]
fn finalize_via_link_reports_a_substituted_final_name_as_changed() {
type Plant = fn(&Path, &Path) -> io::Result<()>;
let plants: [(&str, Plant); 2] = [
("symlink", |moved, name| {
std::os::unix::fs::symlink(moved, name)
}),
("socket", |_, name| {
std::os::unix::net::UnixListener::bind(name).map(drop)
}),
];
for (substitute, plant) in plants {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let moved = tmp_dir.path().join("out.moved");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|dir, name| {
fs::rename(&final_path, &moved)?;
plant(&moved, &final_path)?;
dir.remove_file(name)
},
)
.expect_err("a substituted final name must not report success");
assert!(error.committed(), "{substitute}");
assert!(
matches!(
error.into_crypto_error(),
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"a {substitute} at the final name must report as a changed path"
);
assert_eq!(fs::read(&moved).unwrap(), b"payload", "{substitute}");
}
}
#[cfg(unix)]
#[test]
fn finalize_via_link_rejects_a_renamed_staging_link_after_not_found() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let hidden_name = std::ffi::OsString::from("moved-staging-link");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|dir, name| {
dir.rename(name, dir, &hidden_name)?;
dir.remove_file(name)
},
)
.expect_err("a renamed second link must not report success");
assert!(error.committed());
let rendered = error.into_crypto_error().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(), b"payload");
assert_eq!(
fs::read(tmp_dir.path().join(hidden_name)).unwrap(),
b"payload"
);
assert!(!tmp_dir.path().join(tmp_name).exists());
}
#[cfg(unix)]
#[test]
fn finalize_via_link_rejects_a_hidden_link_when_replacement_unlink_succeeds() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let hidden_name = std::ffi::OsString::from("moved-staging-link");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
output_dir
.dir
.hard_link(&tmp_name, &output_dir.dir, final_path.file_name().unwrap())
.unwrap();
let error = finish_link_commit_with_remove(
tmp,
&output_dir,
&final_path,
final_path.file_name().unwrap(),
&tmp_name,
|dir, name| {
dir.rename(name, dir, &hidden_name)?;
let mut options = cap_std::fs::OpenOptions::new();
options.write(true).create_new(true);
let mut replacement = dir.open_with(name, &options)?;
replacement.write_all(b"replacement")?;
drop(replacement);
dir.remove_file(name)
},
)
.expect_err("a hidden second link must not report success");
assert!(error.committed());
let rendered = error.into_crypto_error().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(), b"payload");
assert_eq!(
fs::read(tmp_dir.path().join(hidden_name)).unwrap(),
b"payload"
);
assert!(!tmp_dir.path().join(tmp_name).exists());
}
#[cfg(unix)]
#[test]
fn finalize_via_link_refuses_to_overwrite() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
fs::write(&final_path, "existing").unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"new").unwrap();
let tmp_path = tmp.path().to_path_buf();
let error = finalize_file_via_link_or_claim(tmp, &final_path, "Output")
.expect_err("an occupied output must fail before commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(msg) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
assert_eq!(fs::read_to_string(&final_path).unwrap(), "existing");
assert!(!tmp_path.exists(), "temp file must be removed on failure");
}
#[cfg(unix)]
#[test]
fn finalize_via_link_refuses_dangling_symlink_at_target() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
std::os::unix::fs::symlink(tmp_dir.path().join("nowhere"), &final_path).unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"new").unwrap();
let error = finalize_file_via_link_or_claim(tmp, &final_path, "Output")
.expect_err("a dangling final symlink must fail before commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(msg) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
let meta = fs::symlink_metadata(&final_path).unwrap();
assert!(meta.file_type().is_symlink(), "symlink must be left as-is");
assert!(
!tmp_dir.path().join("nowhere").exists(),
"the link target must not have been created"
);
}
fn commit_to(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
) -> Result<FinalizedFile, FinalizeFileError> {
let anchor = OutputDir::open_for_commit(crate::fs::paths::parent_or_cwd(final_path))
.expect("the destination directory must open as a commit anchor");
finalize_file(tmp, final_path, label, &anchor)
}
#[cfg(unix)]
fn claim_commit(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
output_dir: &OutputDir,
) -> Result<FinalizedFile, FinalizeFileError> {
claim_commit_with(tmp, final_path, label, output_dir, |_| {})
}
#[cfg(unix)]
fn claim_commit_with(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
output_dir: &OutputDir,
after_claim: impl FnOnce(&cap_std::fs::Dir),
) -> Result<FinalizedFile, FinalizeFileError> {
finalize_file_commit(
tmp,
final_path,
label,
output_dir,
|anchor, staged, final_name| {
crate::fs::commit::commit_by_claim_with(
anchor,
staged,
final_name,
CommitKind::File,
after_claim,
)
},
)
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_succeeds_when_target_missing() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_path = tmp.path().to_path_buf();
claim_commit(
tmp,
&final_path,
"Output",
&OutputDir::open(tmp_dir.path()).unwrap(),
)
.unwrap();
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert!(!tmp_path.exists());
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_rejects_a_second_link_to_the_committed_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let planted = tmp_dir.path().join("planted.txt");
fs::hard_link(tmp.path(), &planted).unwrap();
let error = claim_commit(
tmp,
&final_path,
"Output",
&OutputDir::open(tmp_dir.path()).unwrap(),
)
.expect_err("a second link to the committed inode must fail after commit");
assert!(error.committed());
let message = error.into_crypto_error().to_string();
assert!(message.contains("has 2 filesystem names"), "got: {message}");
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert_eq!(fs::read_to_string(&planted).unwrap(), "payload");
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_refuses_to_overwrite() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
fs::write(&final_path, "existing").unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"new").unwrap();
let tmp_path = tmp.path().to_path_buf();
let error = claim_commit(
tmp,
&final_path,
"Output",
&OutputDir::open(tmp_dir.path()).unwrap(),
)
.expect_err("an occupied output must fail before commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(msg) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
assert_eq!(fs::read_to_string(&final_path).unwrap(), "existing");
assert!(!tmp_path.exists(), "temp file must be removed on failure");
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_refuses_dangling_symlink_at_target() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
std::os::unix::fs::symlink(tmp_dir.path().join("nowhere"), &final_path).unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"new").unwrap();
let error = claim_commit(
tmp,
&final_path,
"Output",
&OutputDir::open(tmp_dir.path()).unwrap(),
)
.expect_err("a dangling final symlink must fail before commit");
assert!(!error.committed());
match error.into_crypto_error() {
CryptoError::InvalidInput(msg) => {
assert!(msg.starts_with("Output already exists: "), "got: {msg}");
}
other => panic!("expected InvalidInput, got {other:?}"),
}
let meta = fs::symlink_metadata(&final_path).unwrap();
assert!(meta.file_type().is_symlink(), "symlink must be left as-is");
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_leaves_the_claim_and_reports_it_when_the_rename_fails() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
let err = claim_commit_with(tmp, &final_path, "Output", &output_dir, |dir| {
dir.remove_file(&tmp_name).unwrap();
})
.expect_err("a rename with no staged file must fail the commit");
assert!(!err.committed());
let message = match err.into_crypto_error() {
CryptoError::Io(e) => e.to_string(),
other => panic!("expected an I/O failure, got {other:?}"),
};
assert!(
message.contains("out.txt") && message.contains("may still be occupied"),
"the name left occupied must be reported, got: {message}"
);
let left = fs::metadata(&final_path).expect("the placeholder stays at the final name");
assert!(left.is_file() && left.len() == 0, "{left:?}");
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_leaves_a_replaced_claim_in_place() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let final_path = tmp_dir.path().join("out.txt");
let final_name = final_path.file_name().unwrap();
let mut tmp = tempfile::Builder::new()
.tempfile_in(tmp_dir.path())
.unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(tmp_dir.path()).unwrap();
let err = claim_commit_with(tmp, &final_path, "Output", &output_dir, |dir| {
dir.remove_file(final_name).unwrap();
dir.write(final_name, b"planted").unwrap();
dir.remove_file(&tmp_name).unwrap();
})
.expect_err("a rename with no staged file must fail the commit");
assert!(!err.committed());
assert_eq!(
fs::read(&final_path).unwrap(),
b"planted",
"an entry planted in place of the placeholder must survive"
);
}
#[cfg(unix)]
#[test]
fn anchored_fallback_commits_in_the_threaded_anchor_across_a_path_swap() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let orig = tmp_dir.path().join("orig");
fs::create_dir(&orig).unwrap();
let final_path = orig.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&orig).unwrap();
tmp.write_all(b"payload").unwrap();
let anchor = OutputDir::open(&orig).unwrap();
let moved = tmp_dir.path().join("moved");
fs::rename(&orig, &moved).unwrap();
fs::create_dir(&orig).unwrap();
fs::write(&final_path, "victim").unwrap();
let error = finalize_file_via_link_or_claim_in(tmp, &final_path, "Output", &anchor)
.expect_err("the ambient path no longer names the committed file");
assert!(error.committed());
assert!(
matches!(
error.into_crypto_error(),
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the path mismatch must be reported explicitly"
);
assert_eq!(
fs::read_to_string(moved.join("out.txt")).unwrap(),
"payload",
"the commit must land in the anchored directory"
);
assert_eq!(
fs::read_to_string(&final_path).unwrap(),
"victim",
"the entry at the old path must survive"
);
}
#[cfg(unix)]
#[test]
fn finalize_via_claim_rejects_the_reported_path_after_a_directory_swap() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let orig = tmp_dir.path().join("orig");
fs::create_dir(&orig).unwrap();
let final_path = orig.join("out.txt");
let mut tmp = tempfile::Builder::new().tempfile_in(&orig).unwrap();
tmp.write_all(b"payload").unwrap();
let tmp_name = tmp.path().file_name().unwrap().to_os_string();
let output_dir = OutputDir::open(&orig).unwrap();
let moved = tmp_dir.path().join("moved");
fs::rename(&orig, &moved).unwrap();
fs::create_dir(&orig).unwrap();
fs::write(&final_path, "victim").unwrap();
let error = claim_commit(tmp, &final_path, "Output", &output_dir)
.expect_err("the ambient path no longer names the committed file");
assert!(error.committed());
assert!(
matches!(
error.into_crypto_error(),
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
),
"the path mismatch must be reported explicitly"
);
assert_eq!(
fs::read_to_string(moved.join("out.txt")).unwrap(),
"payload",
"the commit must land in the anchored directory"
);
assert_eq!(
fs::read_to_string(&final_path).unwrap(),
"victim",
"the entry at the swapped-in path must survive the commit"
);
assert!(
!moved.join(&tmp_name).exists(),
"the staged entry must not survive the commit"
);
}
#[test]
fn rename_no_clobber_refuses_to_overwrite_dir() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("src");
let to = tmp_dir.path().join("dst");
fs::create_dir(&from).unwrap();
fs::write(from.join("inner.txt"), "new").unwrap();
fs::create_dir(&to).unwrap();
fs::write(to.join("existing.txt"), "existing").unwrap();
let err = rename_no_clobber(&from, &to).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert!(from.exists(), "source should not have been moved");
assert!(
to.join("existing.txt").exists(),
"destination should be untouched"
);
}
#[test]
fn rename_no_clobber_succeeds_when_target_missing_dir() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("src");
let to = tmp_dir.path().join("dst");
fs::create_dir(&from).unwrap();
fs::write(from.join("payload.txt"), "hello").unwrap();
rename_no_clobber(&from, &to).unwrap();
assert!(!from.exists(), "source should have been moved");
assert!(to.is_dir(), "destination should exist as a directory");
assert_eq!(fs::read_to_string(to.join("payload.txt")).unwrap(), "hello",);
}
#[test]
fn rename_no_clobber_handles_regular_file() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("staged.txt");
let to = tmp_dir.path().join("final.txt");
fs::write(&from, "payload").unwrap();
rename_no_clobber(&from, &to).unwrap();
assert!(!from.exists());
assert_eq!(fs::read_to_string(&to).unwrap(), "payload");
fs::write(&from, "second").unwrap();
let err = rename_no_clobber(&from, &to).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&to).unwrap(), "payload");
assert_eq!(fs::read_to_string(&from).unwrap(), "second");
}
#[test]
fn promote_single_file_succeeds_when_target_missing() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("staged.txt");
let to = tmp_dir.path().join("final.txt");
fs::write(&from, "payload").unwrap();
promote_single_file_no_clobber(&from, &to).unwrap();
assert!(!from.exists(), "Source should have been moved");
assert_eq!(fs::read_to_string(&to).unwrap(), "payload");
}
#[test]
fn promote_single_file_refuses_existing_target() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("staged.txt");
let to = tmp_dir.path().join("final.txt");
fs::write(&from, "new").unwrap();
fs::write(&to, "existing").unwrap();
let err = promote_single_file_no_clobber(&from, &to).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&to).unwrap(), "existing");
}
#[test]
fn promote_single_file_leaves_source_in_place_after_refusal() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let from = tmp_dir.path().join("staged.txt");
let to = tmp_dir.path().join("final.txt");
fs::write(&from, "new").unwrap();
fs::write(&to, "existing").unwrap();
let _ = promote_single_file_no_clobber(&from, &to).unwrap_err();
assert!(
from.exists(),
"RetainOnError contract: source must remain after refused promotion"
);
assert_eq!(fs::read_to_string(&from).unwrap(), "new");
}
#[test]
fn sync_parent_dir_swallows_missing_parent() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let phantom_parent = tmp_dir.path().join("does-not-exist");
let phantom_child = phantom_parent.join("child.txt");
sync_parent_dir(&phantom_child);
}
}