use std::io;
use std::path::{Path, PathBuf};
pub fn write_atomic(dest: &Path, body: &str) -> io::Result<()> {
let dir = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let name = dest
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "settings.json".to_string());
let tmp = dir.join(format!(".amont-agent-tmp-{}-{name}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
std::fs::write(&tmp, body)?;
if let Err(e) = carry_mode(dest, &tmp) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
match std::fs::rename(&tmp, dest) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
#[cfg(unix)]
fn carry_mode(dest: &Path, tmp: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = match std::fs::metadata(dest) {
Ok(m) => m.permissions().mode() & 0o7777,
Err(e) if e.kind() == io::ErrorKind::NotFound => 0o600,
Err(e) => return Err(e),
};
std::fs::set_permissions(tmp, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn carry_mode(_dest: &Path, _tmp: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("amont-agent-atomic-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn writes_a_new_file_and_leaves_no_temp_behind() {
let d = scratch("new");
let dest = d.join("settings.json");
write_atomic(&dest, "{\"a\":1}").unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "{\"a\":1}");
let leftovers: Vec<_> = std::fs::read_dir(&d)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with('.'))
.collect();
assert!(leftovers.is_empty(), "temp files left: {leftovers:?}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn replaces_a_symlink_rather_than_writing_through_it() {
let d = scratch("symlink");
let real = d.join("real.json");
std::fs::write(&real, "ORIGINAL").unwrap();
let link = d.join("settings.json");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(not(unix))]
std::fs::write(&link, "ORIGINAL").unwrap();
write_atomic(&link, "REWRITTEN").unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "REWRITTEN");
#[cfg(unix)]
assert_eq!(
std::fs::read_to_string(&real).unwrap(),
"ORIGINAL",
"the link's TARGET must be untouched"
);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn an_existing_files_mode_is_never_widened() {
use std::os::unix::fs::PermissionsExt;
let d = scratch("mode");
let dest = d.join("settings.json");
std::fs::write(&dest, "{}").unwrap();
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
write_atomic(&dest, "{\"a\":1}").unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "rewriting must not widen 0600 to 0644");
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_file_we_create_starts_private() {
use std::os::unix::fs::PermissionsExt;
let d = scratch("fresh");
let dest = d.join("settings.json");
write_atomic(&dest, "{}").unwrap();
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a settings.json we create is ours alone");
let _ = std::fs::remove_dir_all(&d);
}
}