use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime};
static COUNTER: AtomicU64 = AtomicU64::new(0);
const STALE_TEMP_SECS: u64 = 3600;
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
write_atomic_inner(path, bytes, None)
}
pub fn write_atomic_with_mode(path: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
write_atomic_inner(path, bytes, Some(mode))
}
fn write_atomic_inner(path: &Path, bytes: &[u8], mode: Option<u32>) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp");
sweep_stale_temps(parent, stem, Duration::from_secs(STALE_TEMP_SECS));
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!(".{}.{}.{}.tmp", stem, std::process::id(), n));
{
let mut f = create_temp(&tmp, mode)?;
f.write_all(bytes)?;
f.sync_all()?;
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
if let Ok(dir) = File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
#[cfg(unix)]
fn create_temp(tmp: &Path, mode: Option<u32>) -> std::io::Result<File> {
match mode {
Some(mode) => {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(mode)
.open(tmp)
},
None => File::create(tmp),
}
}
#[cfg(windows)]
fn create_temp(tmp: &Path, mode: Option<u32>) -> std::io::Result<File> {
if mode.is_none() {
return File::create(tmp);
}
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Storage::FileSystem::{
CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE,
};
let mut security = crate::daemon::PipeSecurity::owner_only().map_err(std::io::Error::other)?;
let wide: Vec<u16> = tmp
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
FILE_GENERIC_WRITE,
0,
security.attributes_ptr().cast(),
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error());
}
Ok(unsafe { File::from_raw_handle(handle.cast()) })
}
#[cfg(not(any(unix, windows)))]
fn create_temp(tmp: &Path, _mode: Option<u32>) -> std::io::Result<File> {
File::create(tmp)
}
fn sweep_stale_temps(parent: &Path, stem: &str, max_age: Duration) {
let prefix = format!(".{stem}.");
let Ok(entries) = fs::read_dir(parent) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
continue;
}
let stale = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| now.duration_since(mtime).ok())
.map(|age| age >= max_age)
.unwrap_or(false);
if stale {
let _ = fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_write_replaces_existing_and_no_temp_left() {
let dir = std::env::temp_dir().join(format!("mermaid_atomic_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let target = dir.join("conv.json");
write_atomic(&target, b"first").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "first");
write_atomic(&target, b"second").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "second");
let leftovers = fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn sweep_removes_only_matching_stale_temps() {
let dir = std::env::temp_dir().join(format!("mermaid_atomic_sweep_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let _ = fs::create_dir_all(&dir);
let target = dir.join("conv.json");
write_atomic(&target, b"live").unwrap();
let orphan = dir.join(".conv.json.99999.0.tmp");
fs::write(&orphan, b"half-written").unwrap();
let other = dir.join(".other.json.99999.0.tmp");
fs::write(&other, b"someone else").unwrap();
let unrelated = dir.join("notes.txt");
fs::write(&unrelated, b"keep me").unwrap();
sweep_stale_temps(&dir, "conv.json", Duration::ZERO);
assert!(!orphan.exists(), "matching stale temp must be swept");
assert!(other.exists(), "a different target's temp must survive");
assert!(unrelated.exists(), "unrelated files must survive");
assert!(target.exists(), "the destination must never be swept");
assert_eq!(fs::read_to_string(&target).unwrap(), "live");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn sweep_preserves_fresh_in_flight_temps() {
let dir = std::env::temp_dir().join(format!("mermaid_atomic_fresh_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let _ = fs::create_dir_all(&dir);
let fresh = dir.join(".conv.json.12345.7.tmp");
fs::write(&fresh, b"being written").unwrap();
sweep_stale_temps(&dir, "conv.json", Duration::from_secs(STALE_TEMP_SECS));
assert!(fresh.exists(), "a fresh/in-flight temp must not be swept");
let _ = fs::remove_dir_all(&dir);
}
#[test]
#[cfg(windows)]
fn write_atomic_with_mode_gives_the_file_an_owner_only_dacl() {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::{
ConvertSecurityDescriptorToStringSecurityDescriptorW,
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW,
SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR};
fn dacl_sddl(descriptor: PSECURITY_DESCRIPTOR) -> String {
let mut sddl_w: *mut u16 = std::ptr::null_mut();
let ok = unsafe {
ConvertSecurityDescriptorToStringSecurityDescriptorW(
descriptor,
SDDL_REVISION_1,
DACL_SECURITY_INFORMATION,
&mut sddl_w,
std::ptr::null_mut(),
)
};
assert_ne!(
ok, 0,
"ConvertSecurityDescriptorToStringSecurityDescriptorW"
);
let mut len = 0;
while unsafe { *sddl_w.add(len) } != 0 {
len += 1;
}
let sddl = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(sddl_w, len) });
unsafe {
LocalFree(sddl_w.cast());
}
sddl
}
let dir = std::env::temp_dir().join(format!("mermaid-atomic-dacl-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let target = dir.join("config.toml");
write_atomic_with_mode(&target, b"secret = true", 0o600).unwrap();
let wide: Vec<u16> = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut actual: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let rc = unsafe {
GetNamedSecurityInfoW(
wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut actual,
)
};
assert_eq!(rc, 0, "GetNamedSecurityInfoW");
let actual_sddl = dacl_sddl(actual);
unsafe {
LocalFree(actual.cast());
}
let _ = fs::remove_dir_all(&dir);
let expected_source = crate::daemon::pipe_sddl(&crate::daemon::current_user_sid().unwrap());
let wide: Vec<u16> = expected_source
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut expected: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let ok = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut expected,
std::ptr::null_mut(),
)
};
assert_ne!(
ok, 0,
"ConvertStringSecurityDescriptorToSecurityDescriptorW"
);
let expected_sddl = dacl_sddl(expected);
unsafe {
LocalFree(expected.cast());
}
let expected_sddl = expected_sddl.replace(";GA;", ";FA;");
assert!(
actual_sddl.starts_with("D:P"),
"not protected: {actual_sddl}"
);
assert_eq!(
actual_sddl, expected_sddl,
"file DACL differs from the owner-only descriptor"
);
}
#[test]
#[cfg(unix)]
fn write_atomic_with_mode_creates_0600_and_leaves_no_temp() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("mermaid_atomic_mode_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let _ = fs::create_dir_all(&dir);
let target = dir.join("config.toml");
write_atomic_with_mode(&target, b"secret = true", 0o600).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "secret = true");
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "config must be created 0600, not at umask");
fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
write_atomic_with_mode(&target, b"secret = false", 0o600).unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"an overwrite must not leave the file world-readable"
);
let leftovers = fs::read_dir(&dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0, "no temp left behind");
let _ = fs::remove_dir_all(&dir);
}
}