diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/save.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/save.rs')
| -rw-r--r-- | src/buffer/save.rs | 387 |
1 files changed, 387 insertions, 0 deletions
diff --git a/src/buffer/save.rs b/src/buffer/save.rs new file mode 100644 index 0000000..70dacd0 --- /dev/null +++ b/src/buffer/save.rs @@ -0,0 +1,387 @@ +//! 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. + } +} |
