Skip to main content

rusty_sponge/
atomic.rs

1//! Atomic in-place file rewrite: sibling tempfile + atomic rename.
2//!
3//! Per AD-001/AD-014: write to a sibling tempfile in `target.parent()`, then
4//! call `NamedTempFile::persist(target)` (which wraps `std::fs::rename`, with
5//! Windows `FileRenameInfoEx`+POSIX semantics where available). Mid-write
6//! failures leave the original target byte-identical to its prior state
7//! (FR-006).
8//!
9//! Mode preservation on Unix (FR-008): if the target exists as a regular
10//! non-symlink file, capture its `st_mode` and reapply to the tempfile before
11//! persist. Read-only attribute preservation on Windows (FR-009): same idea
12//! for `Permissions::readonly()`.
13//!
14//! Append mode (`-a`, FR-004): if requested AND the target exists, copy its
15//! current bytes to the tempfile first, *then* write the incoming buffer.
16//! Missing target with `-a` is a no-op per FR-005.
17
18use std::fs;
19use std::io;
20use std::path::Path;
21
22use tempfile::NamedTempFile;
23
24use crate::{Error, buffer::Buffer};
25
26/// Write `buffer` to `target` atomically. See module docs.
27pub fn write_atomic(buffer: Buffer, target: &Path, append: bool) -> Result<(), Error> {
28    // Resolve the sibling-tempfile directory. Use target's parent or "." as a
29    // last-resort default — `tempfile::Builder::tempfile_in(".")` is well-defined.
30    let parent = target
31        .parent()
32        .filter(|p| !p.as_os_str().is_empty())
33        .unwrap_or(Path::new("."));
34
35    let mut tempfile: NamedTempFile = tempfile::Builder::new()
36        .prefix(".rusty-sponge-")
37        .tempfile_in(parent)?;
38
39    // -a append: pre-copy existing target bytes into the tempfile, BEFORE we
40    // dump the incoming buffer. If the target is missing, this is silently a
41    // no-op (FR-005).
42    if append {
43        if let Ok(mut existing) = fs::File::open(target) {
44            io::copy(&mut existing, tempfile.as_file_mut())?;
45        }
46    }
47
48    // Write the buffered stdin bytes. Empty buffer → zero bytes written
49    // (FR-013); tempfile remains a valid 0-byte file and rename still happens.
50    buffer.write_to(tempfile.as_file_mut())?;
51
52    // Best-effort durability — match moreutils sponge (which does NOT call
53    // fsync) but at least sync our own writes through to the OS buffer cache.
54    // We do not fsync the parent directory; this matches moreutils.
55    tempfile.as_file_mut().sync_data().ok();
56
57    // Preserve mode/attrs from the existing target, if one exists.
58    if let Ok(existing_meta) = fs::symlink_metadata(target) {
59        if existing_meta.file_type().is_file() {
60            preserve_perms_from(&existing_meta, tempfile.path())?;
61        }
62    }
63
64    // Atomic rename. `NamedTempFile::persist()` wraps `std::fs::rename`, which
65    // employs Windows POSIX-semantics where available (Rust 1.81+).
66    tempfile.persist(target).map_err(|e| Error::Io(e.error))?;
67    Ok(())
68}
69
70#[cfg(unix)]
71fn preserve_perms_from(meta: &fs::Metadata, tempfile_path: &Path) -> Result<(), Error> {
72    use std::os::unix::fs::{MetadataExt, PermissionsExt};
73    let mode = meta.mode();
74    let mut perms = fs::metadata(tempfile_path)?.permissions();
75    perms.set_mode(mode);
76    fs::set_permissions(tempfile_path, perms)?;
77    Ok(())
78}
79
80#[cfg(windows)]
81fn preserve_perms_from(meta: &fs::Metadata, tempfile_path: &Path) -> Result<(), Error> {
82    let was_readonly = meta.permissions().readonly();
83    let mut perms = fs::metadata(tempfile_path)?.permissions();
84    #[allow(clippy::permissions_set_readonly_false)]
85    perms.set_readonly(was_readonly);
86    fs::set_permissions(tempfile_path, perms)?;
87    Ok(())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::io::Cursor;
94    use std::path::PathBuf;
95
96    fn empty_buffer() -> Buffer {
97        Buffer::new()
98    }
99
100    fn buffer_from(bytes: &[u8]) -> Buffer {
101        let mut b = Buffer::new();
102        let tmpdir = tempfile::tempdir().unwrap();
103        b.drain_reader(Cursor::new(bytes), 1 << 30, tmpdir.path())
104            .unwrap();
105        // Note: tmpdir drops here but the InMemory variant doesn't reference it.
106        b
107    }
108
109    fn target_in(tmpdir: &Path, name: &str) -> PathBuf {
110        tmpdir.join(name)
111    }
112
113    #[test]
114    fn writes_buffer_atomically_to_new_target() {
115        let tmpdir = tempfile::tempdir().unwrap();
116        let target = target_in(tmpdir.path(), "out.txt");
117        write_atomic(buffer_from(b"hello\n"), &target, false).unwrap();
118        assert_eq!(fs::read(&target).unwrap(), b"hello\n");
119    }
120
121    #[test]
122    fn empty_buffer_creates_zero_byte_target() {
123        let tmpdir = tempfile::tempdir().unwrap();
124        let target = target_in(tmpdir.path(), "empty.txt");
125        write_atomic(empty_buffer(), &target, false).unwrap();
126        assert!(target.exists());
127        assert_eq!(fs::metadata(&target).unwrap().len(), 0);
128    }
129
130    #[test]
131    fn binary_bytes_passthrough_unchanged() {
132        let tmpdir = tempfile::tempdir().unwrap();
133        let target = target_in(tmpdir.path(), "bin.dat");
134        let bytes: &[u8] = &[0x00, 0xFE, 0xFF, 0xC3, 0x28, 0xA0, 0xA1];
135        write_atomic(buffer_from(bytes), &target, false).unwrap();
136        assert_eq!(fs::read(&target).unwrap(), bytes);
137    }
138
139    #[test]
140    fn replaces_existing_target() {
141        let tmpdir = tempfile::tempdir().unwrap();
142        let target = target_in(tmpdir.path(), "replace.txt");
143        fs::write(&target, b"OLD\n").unwrap();
144        write_atomic(buffer_from(b"NEW\n"), &target, false).unwrap();
145        assert_eq!(fs::read(&target).unwrap(), b"NEW\n");
146    }
147
148    #[test]
149    fn append_mode_concatenates_existing_and_stdin() {
150        let tmpdir = tempfile::tempdir().unwrap();
151        let target = target_in(tmpdir.path(), "append.txt");
152        fs::write(&target, b"original\n").unwrap();
153        write_atomic(buffer_from(b"appended\n"), &target, true).unwrap();
154        assert_eq!(fs::read(&target).unwrap(), b"original\nappended\n");
155    }
156
157    #[test]
158    fn append_mode_missing_target_treats_as_empty() {
159        let tmpdir = tempfile::tempdir().unwrap();
160        let target = target_in(tmpdir.path(), "missing.txt");
161        // No pre-existing target.
162        write_atomic(buffer_from(b"first\n"), &target, true).unwrap();
163        assert_eq!(fs::read(&target).unwrap(), b"first\n");
164    }
165
166    #[test]
167    fn append_mode_empty_stdin_preserves_existing() {
168        let tmpdir = tempfile::tempdir().unwrap();
169        let target = target_in(tmpdir.path(), "preserve.txt");
170        fs::write(&target, b"keep me\n").unwrap();
171        write_atomic(empty_buffer(), &target, true).unwrap();
172        assert_eq!(fs::read(&target).unwrap(), b"keep me\n");
173    }
174
175    #[cfg(unix)]
176    #[test]
177    fn unix_mode_bits_preserved_on_replacement() {
178        use std::os::unix::fs::PermissionsExt;
179        let tmpdir = tempfile::tempdir().unwrap();
180        let target = target_in(tmpdir.path(), "perms.txt");
181        fs::write(&target, b"old\n").unwrap();
182        fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap();
183        write_atomic(buffer_from(b"new\n"), &target, false).unwrap();
184        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
185        assert_eq!(
186            mode, 0o640,
187            "prior mode must be preserved on atomic replace"
188        );
189    }
190}