use std::io;
use std::path::Path;
use tempfile::NamedTempFile;
use crate::CryptoError;
use crate::fs::paths::already_exists_error;
#[cfg(unix)]
fn sync_parent_dir(path: &Path) {
if let Ok(dir) = std::fs::File::open(crate::fs::paths::parent_or_cwd(path)) {
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) {}
pub(crate) fn finalize_file(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
) -> Result<(), CryptoError> {
match tmp.persist_noclobber(final_path) {
Ok(_) => {
sync_parent_dir(final_path);
Ok(())
}
Err(e) => finalize_persist_failure(e, final_path, label),
}
}
#[cfg(unix)]
fn finalize_persist_failure(
e: tempfile::PersistError,
final_path: &Path,
label: &str,
) -> Result<(), CryptoError> {
if no_replace_rename_unsupported(&e.error) {
return finalize_file_via_claim(e.file, final_path, label);
}
Err(map_persist_error(e.error, final_path, label))
}
#[cfg(not(unix))]
fn finalize_persist_failure(
e: tempfile::PersistError,
final_path: &Path,
label: &str,
) -> Result<(), CryptoError> {
Err(map_persist_error(e.error, final_path, label))
}
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)
}
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) => rustix::fs::fsync(file).map_err(io::Error::from),
Err(e) => Err(e),
}
}
#[cfg(unix)]
const FINAL_NAME_CLAIM_MODE: u32 = 0o600;
#[cfg(unix)]
fn finalize_file_via_claim(
tmp: NamedTempFile,
final_path: &Path,
label: &str,
) -> Result<(), CryptoError> {
use std::os::unix::fs::OpenOptionsExt;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(FINAL_NAME_CLAIM_MODE)
.open(final_path)
{
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
return Err(already_exists_error(label, final_path));
}
Err(e) => return Err(CryptoError::Io(e)),
}
match tmp.persist(final_path) {
Ok(_) => {
sync_parent_dir(final_path);
Ok(())
}
Err(e) => {
let _ = std::fs::remove_file(final_path);
Err(CryptoError::Io(e.error))
}
}
}
#[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();
match finalize_file(tmp, &final_path, "Output") {
Err(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();
finalize_file(tmp, &final_path, "Output").unwrap();
assert_eq!(fs::read_to_string(&final_path).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();
}
#[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();
finalize_file_via_claim(tmp, &final_path, "Output").unwrap();
assert_eq!(fs::read_to_string(&final_path).unwrap(), "payload");
assert!(!tmp_path.exists());
}
#[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();
match finalize_file_via_claim(tmp, &final_path, "Output") {
Err(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();
match finalize_file_via_claim(tmp, &final_path, "Output") {
Err(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");
}
#[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);
}
}