use std::path::PathBuf;
pub(crate) fn save_file_durable(
path: &std::path::Path,
body: &[u8],
trailing_nl: bool,
) -> std::io::Result<()> {
use std::io::Write;
fn write_body(f: &mut std::fs::File, body: &[u8], trailing_nl: bool) -> std::io::Result<()> {
f.write_all(body)?;
if trailing_nl {
f.write_all(b"\n")?;
}
Ok(())
}
hjkl_engine::policy::check_fs_path(path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
match std::fs::OpenOptions::new().write(true).open(&target) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut temp_opened = false;
let atomic = (|| -> std::io::Result<()> {
let parent = match target.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let file_name = target
.file_name()
.ok_or_else(|| std::io::Error::other("target has no file name"))?;
let tmp_path = parent.join(format!(
".{}.hjkl-tmp.{}",
file_name.to_string_lossy(),
std::process::id()
));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true) .open(&tmp_path)?;
temp_opened = true;
let res = (|| -> std::io::Result<()> {
if let Ok(meta) = std::fs::metadata(&target) {
f.set_permissions(meta.permissions())?;
}
write_body(&mut f, body, trailing_nl)?;
f.sync_all()?; std::fs::rename(&tmp_path, &target)?;
if let Some(parent) = target.parent()
&& let Ok(pdir) = std::fs::File::open(parent)
{
let _ = pdir.sync_all();
}
Ok(())
})();
if res.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
res
})();
match atomic {
Ok(()) => Ok(()),
Err(e) if !temp_opened || e.kind() == std::io::ErrorKind::CrossesDevices => {
let mut f = std::fs::File::create(path)?;
write_body(&mut f, body, trailing_nl)
}
Err(e) => Err(e),
}
}
#[cfg(test)]
mod save_file_durable_tests {
use super::save_file_durable;
#[test]
fn overwrites_existing_file_atomically() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.txt");
std::fs::write(&p, "old contents\n").unwrap();
save_file_durable(&p, b"new contents", true).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "new contents\n");
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("hjkl-tmp"))
.collect();
assert!(leftovers.is_empty(), "temp file leaked: {leftovers:?}");
}
#[test]
fn creates_new_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("fresh.txt");
save_file_durable(&p, b"hello", true).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello\n");
}
#[cfg(unix)]
#[test]
fn preserves_symlink_and_updates_target() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.txt");
let link = dir.path().join("link.txt");
std::fs::write(&real, "old\n").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
save_file_durable(&link, b"new", true).unwrap();
let meta = std::fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "symlink was replaced");
assert_eq!(std::fs::read_link(&link).unwrap(), real);
assert_eq!(std::fs::read_to_string(&real).unwrap(), "new\n");
}
#[cfg(unix)]
#[test]
fn preserves_permission_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("script.sh");
std::fs::write(&p, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
save_file_durable(&p, b"#!/bin/sh\necho hi", true).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "permission mode not preserved");
}
#[cfg(unix)]
#[test]
fn readonly_target_still_errors() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("ro.txt");
std::fs::write(&p, "locked\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o444)).unwrap();
assert!(save_file_durable(&p, b"nope", true).is_err());
assert_eq!(std::fs::read_to_string(&p).unwrap(), "locked\n");
}
}