pub(crate) fn save_file_durable(
path: &std::path::Path,
body: &[u8],
trailing_nl: bool,
cwd: &std::path::Path,
) -> 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 = if hjkl_engine::policy::fs_restricted() {
match hjkl_fs::resolve_under(cwd, path) {
Ok(resolved) => resolved,
Err(e) => {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("{path}: {e}", path = path.display()),
));
}
}
} else {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
};
hjkl_fs::probe_writable_nofollow(&target)?;
hjkl_fs::write_atomic_with(&target, &hjkl_fs::WriteOptions::document(), |f| {
write_body(f, body, trailing_nl)
})
}
#[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, &std::env::current_dir().unwrap()).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, &std::env::current_dir().unwrap()).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, &std::env::current_dir().unwrap()).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,
&std::env::current_dir().unwrap(),
)
.unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "permission mode not preserved");
}
#[cfg(unix)]
#[test]
fn dangling_symlink_target_is_refused_by_nofollow_probe() {
let dir = tempfile::tempdir().unwrap();
let victim = dir.path().join("victim.txt");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&victim, &link).unwrap();
assert!(
save_file_durable(&link, b"pwned", true, &std::env::current_dir().unwrap()).is_err(),
"write followed a symlink — O_NOFOLLOW probe lost"
);
assert!(!victim.exists(), "wrote through the symlink to its target");
}
#[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, &std::env::current_dir().unwrap()).is_err());
assert_eq!(std::fs::read_to_string(&p).unwrap(), "locked\n");
}
}