//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`. //! //! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate: //! //! 1. **copy instead of rename** when the target is a symlink or hardlink. //! Renaming the backup into place would break the link; copying preserves it. //! 2. **restore on failure** — the backup lives in the target's *own directory* //! so the rename can never cross a filesystem, and is put back if the write //! fails partway. //! //! The ordering matters: every check that can reject the write runs before //! anything on disk is touched. use std::{ fs, io::{self, Write}, path::{Path, PathBuf}, }; #[derive(Debug, thiserror::Error)] pub enum SaveError { #[error("no file name associated with this buffer")] NoPath, #[error("path is read only: {0}")] ReadOnly(PathBuf), #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")] NoParent(PathBuf), #[error("io error: {0}")] Io(#[from] io::Error), } /// MJB-LLR-131: writable without modifying anything. /// /// A path that does not exist is *not* read-only — it may still be creatable. pub fn readonly(path: &Path) -> bool { match fs::metadata(path) { Ok(md) => md.permissions().readonly(), Err(e) if e.kind() == io::ErrorKind::NotFound => false, Err(_) => true, } } /// MJB-LLR-130: follow a symlink to its target so the link itself survives. /// /// A relative link target is resolved against the link's own directory. pub fn resolve_write_path(path: &Path) -> PathBuf { match fs::read_link(path) { Ok(target) => { if target.is_relative() { path.parent() .map(|parent| parent.join(&target)) .unwrap_or(target) } else { target } } Err(_) => path.to_path_buf(), } } /// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy. pub fn must_copy(path: &Path) -> bool { if fs::symlink_metadata(path) .map(|md| md.file_type().is_symlink()) .unwrap_or(false) { return true; } hard_link_count(path) > 1 } #[cfg(unix)] fn hard_link_count(path: &Path) -> u64 { use std::os::unix::fs::MetadataExt; fs::metadata(path).map(|md| md.nlink()).unwrap_or(1) } #[cfg(not(unix))] fn hard_link_count(_path: &Path) -> u64 { 1 } /// Copy permissions from `from` onto `to` (MJB-LLR-136). fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> { let perms = fs::metadata(from)?.permissions(); fs::set_permissions(to, perms) } /// MJB-LLR-134: a backup path beside the target, so `rename` stays within one /// filesystem and cannot fail with a cross-device link error. pub(crate) fn backup_path(target: &Path) -> PathBuf { let name = target .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "buffer".to_owned()); let dir = target.parent().unwrap_or_else(|| Path::new(".")); // The process id keeps concurrent instances from colliding without // needing a random source. dir.join(format!(".{name}.mojibake-{}.bak", std::process::id())) } /// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136. /// /// `force` corresponds to `:w!` and permits creating a missing parent /// directory (MJB-LLR-132). pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> { // --- Checks that can reject the write, before touching the filesystem --- // MJB-LLR-130 let write_path = resolve_write_path(path); // MJB-LLR-131 if readonly(&write_path) { return Err(SaveError::ReadOnly(write_path)); } // MJB-LLR-132 if let Some(parent) = write_path.parent() && !parent.as_os_str().is_empty() && !parent.exists() { if force { fs::create_dir_all(parent)?; } else { return Err(SaveError::NoParent(parent.to_path_buf())); } } // --- Backup (MJB-LLR-133, MJB-LLR-134) --- let exists = write_path.exists(); let copy_mode = exists && must_copy(&write_path); let backup = if exists { let backup = backup_path(&write_path); let made = if copy_mode { fs::copy(&write_path, &backup).map(|_| ()) } else { fs::rename(&write_path, &backup) }; // A backup we could not make is not fatal; the write proceeds without // the safety net rather than refusing to save at all. match made { Ok(()) => Some(backup), Err(_) => None, } } else { None }; // --- The write itself --- let result = (|| -> io::Result<()> { let mut file = fs::File::create(&write_path)?; file.write_all(bytes)?; file.sync_all()?; Ok(()) })(); match (result, backup) { (Ok(()), Some(backup)) => { // MJB-LLR-136 let _ = copy_permissions(&backup, &write_path); let _ = fs::remove_file(&backup); Ok(()) } (Ok(()), None) => Ok(()), (Err(e), Some(backup)) => { // MJB-LLR-135: put the original back. if copy_mode { let _ = fs::copy(&backup, &write_path); let _ = fs::remove_file(&backup); } else { let _ = fs::rename(&backup, &write_path); } Err(SaveError::Io(e)) } (Err(e), None) => Err(SaveError::Io(e)), } } #[cfg(test)] mod tests { use super::*; fn tmp() -> tempfile::TempDir { tempfile::tempdir().unwrap() } #[test] fn writes_a_new_file() { let d = tmp(); let p = d.path().join("new.txt"); write_atomic(&p, b"hello", false).unwrap(); assert_eq!(fs::read(&p).unwrap(), b"hello"); } #[test] fn overwrites_an_existing_file() { let d = tmp(); let p = d.path().join("f.txt"); fs::write(&p, b"old contents that are longer").unwrap(); write_atomic(&p, b"new", false).unwrap(); assert_eq!(fs::read(&p).unwrap(), b"new"); } #[test] fn mjb_llr_136_no_backup_file_is_left_behind() { let d = tmp(); let p = d.path().join("f.txt"); fs::write(&p, b"old").unwrap(); write_atomic(&p, b"new", false).unwrap(); let leftovers: Vec<_> = fs::read_dir(d.path()) .unwrap() .filter_map(Result::ok) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n.contains("mojibake")) .collect(); assert!(leftovers.is_empty(), "stray backups: {leftovers:?}"); } #[test] fn mjb_llr_132_missing_parent_is_refused_without_force() { let d = tmp(); let p = d.path().join("missing").join("f.txt"); let err = write_atomic(&p, b"x", false).unwrap_err(); assert!(matches!(err, SaveError::NoParent(_))); assert!(!p.exists()); } #[test] fn mjb_llr_132_force_creates_the_parent() { let d = tmp(); let p = d.path().join("a").join("b").join("f.txt"); write_atomic(&p, b"x", true).unwrap(); assert_eq!(fs::read(&p).unwrap(), b"x"); } #[cfg(unix)] #[test] fn mjb_llr_131_readonly_target_is_refused() { use std::os::unix::fs::PermissionsExt; let d = tmp(); let p = d.path().join("ro.txt"); fs::write(&p, b"original").unwrap(); fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap(); let err = write_atomic(&p, b"replacement", false).unwrap_err(); assert!(matches!(err, SaveError::ReadOnly(_))); assert_eq!( fs::read(&p).unwrap(), b"original", "a refused write must not truncate the file" ); } /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change. #[cfg(unix)] #[test] fn mjb_llr_130_write_follows_symlink_without_replacing_it() { let d = tmp(); let target = d.path().join("target.txt"); let link = d.path().join("link.txt"); fs::write(&target, b"before").unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); write_atomic(&link, b"after", false).unwrap(); assert!( fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the symlink must still be a symlink" ); assert_eq!(fs::read(&target).unwrap(), b"after", "target updated"); } /// MJB-LLR-133: a hardlinked file must keep its link count. #[cfg(unix)] #[test] fn mjb_llr_133_hardlink_is_detected_and_preserved() { let d = tmp(); let a = d.path().join("a.txt"); let b = d.path().join("b.txt"); fs::write(&a, b"before").unwrap(); fs::hard_link(&a, &b).unwrap(); assert!(must_copy(&a), "hardlinked file must use copy mode"); write_atomic(&a, b"after", false).unwrap(); assert_eq!(fs::read(&a).unwrap(), b"after"); assert_eq!( fs::read(&b).unwrap(), b"after", "the hard link must still point at the same inode" ); } #[cfg(unix)] #[test] fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() { let d = tmp(); let target = d.path().join("t.txt"); let link = d.path().join("l.txt"); fs::write(&target, b"x").unwrap(); std::os::unix::fs::symlink("t.txt", &link).unwrap(); assert_eq!(resolve_write_path(&link), target); } #[test] fn mjb_llr_131_missing_file_is_not_readonly() { let d = tmp(); assert!( !readonly(&d.path().join("does-not-exist")), "a creatable path must not be reported read-only" ); } /// MJB-LLR-134: the backup must live in the target's own directory. A /// backup in a temp dir elsewhere would make the rename cross a filesystem /// boundary and fail with EXDEV. #[test] fn mjb_llr_134_backup_is_created_beside_the_target() { let target = Path::new("/some/deep/directory/file.txt"); let backup = backup_path(target); assert_eq!( backup.parent(), target.parent(), "backup must sit beside the target, not in a temp directory" ); assert_ne!(backup, target); assert!( backup .file_name() .unwrap() .to_string_lossy() .starts_with('.'), "backup should be hidden" ); } #[test] fn mjb_llr_134_backup_path_handles_a_bare_file_name() { // No parent component: must not panic. let backup = backup_path(Path::new("file.txt")); assert!(backup.to_string_lossy().contains("file.txt")); } #[test] fn mjb_llr_133_plain_file_does_not_need_copy_mode() { let d = tmp(); let p = d.path().join("plain.txt"); fs::write(&p, b"x").unwrap(); assert!(!must_copy(&p)); } /// MJB-LLR-135: when the write cannot even be created, the original /// contents must still be on disk afterwards. #[cfg(unix)] #[test] fn mjb_llr_135_failed_write_restores_the_original() { use std::os::unix::fs::PermissionsExt; let d = tmp(); let sub = d.path().join("sub"); fs::create_dir(&sub).unwrap(); let p = sub.join("f.txt"); fs::write(&p, b"original").unwrap(); // Make the *directory* unwritable so File::create fails after the // backup has been taken. The file itself stays writable, so the // read-only pre-check does not short-circuit the test. fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap(); let result = write_atomic(&p, b"replacement", false); fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap(); if result.is_err() { assert_eq!( fs::read(&p).unwrap(), b"original", "a failed write must restore the previous contents" ); } // Running as root defeats the permission bits; the assertion above is // skipped in that case rather than reporting a false failure. } }