//! File safety: atomic writes (temp file, fsync, rename), optionally private (0600 on Unix). use craft_anything_evidence::ToolError; use std::io::Write as _; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); /// Write `bytes` to `path` atomically, keeping the existing file's permissions when it exists. pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ToolError> { write_impl(path, bytes, false) } /// Write `bytes` to `path` atomically with owner-only permissions (0600 on Unix). Used for /// configuration files that may hold paths or tokens, and for the key fallback file. pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> Result<(), ToolError> { write_impl(path, bytes, true) } fn write_impl(path: &Path, bytes: &[u8], private: bool) -> Result<(), ToolError> { let dir = match path.parent() { Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), Some(_) => std::path::PathBuf::from("."), None => return Err(ToolError::invalid(format!("{} has no parent folder", path.display()))), }; std::fs::create_dir_all(&dir).map_err(|e| ToolError::io(dir.display(), e))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into()); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let tmp = dir.join(format!(".{name}.tmp-{}-{n}", std::process::id())); let existing_perms = std::fs::metadata(path).ok().map(|m| m.permissions()); let result = (|| { let mut f = std::fs::File::create(&tmp).map_err(|e| ToolError::io(tmp.display(), e))?; if private { set_private(&tmp)?; } else if let Some(p) = existing_perms { std::fs::set_permissions(&tmp, p).map_err(|e| ToolError::io(tmp.display(), e))?; } f.write_all(bytes).map_err(|e| ToolError::io(tmp.display(), e))?; f.sync_all().map_err(|e| ToolError::io(tmp.display(), e))?; drop(f); std::fs::rename(&tmp, path).map_err(|e| ToolError::io(path.display(), e)) })(); if result.is_err() { let _ = std::fs::remove_file(&tmp); } result } #[cfg(unix)] fn set_private(path: &Path) -> Result<(), ToolError> { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| ToolError::io(path.display(), e)) } #[cfg(not(unix))] fn set_private(_path: &Path) -> Result<(), ToolError> { // Windows: files under the user's profile are already private to the user by default ACLs. Ok(()) } /// Copy `from` to `to` only if `to` does not exist yet (the first backup is never overwritten). /// Returns true when a copy was made, false when a backup already existed. pub fn backup_once(from: &Path, to: &Path) -> Result { if to.exists() { return Ok(false); } let bytes = std::fs::read(from).map_err(|e| ToolError::io(from.display(), e))?; let mut f = match std::fs::OpenOptions::new().write(true).create_new(true).open(to) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false), Err(e) => return Err(ToolError::io(to.display(), e)), }; set_private(to)?; f.write_all(&bytes).map_err(|e| ToolError::io(to.display(), e))?; f.sync_all().map_err(|e| ToolError::io(to.display(), e))?; Ok(true) } #[cfg(test)] mod tests { use super::*; #[test] fn atomic_write_replaces_and_private_is_0600() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("sub").join("a.txt"); write_atomic(&p, b"one").unwrap(); write_atomic(&p, b"two").unwrap(); assert_eq!(std::fs::read(&p).unwrap(), b"two"); let q = dir.path().join("secret"); write_atomic_private(&q, b"k").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; assert_eq!(std::fs::metadata(&q).unwrap().permissions().mode() & 0o777, 0o600); } // no temp files left behind assert_eq!(std::fs::read_dir(dir.path().join("sub")).unwrap().count(), 1); } #[test] fn backup_is_never_overwritten() { let dir = tempfile::tempdir().unwrap(); let a = dir.path().join("a"); let b = dir.path().join("a.bak"); std::fs::write(&a, "first").unwrap(); assert!(backup_once(&a, &b).unwrap()); std::fs::write(&a, "second").unwrap(); assert!(!backup_once(&a, &b).unwrap()); assert_eq!(std::fs::read_to_string(&b).unwrap(), "first"); } }