Skip to main content

rusty_sponge/
writethrough.rs

1//! Non-atomic write-through path for symlink and reparse-point targets (FR-010).
2//!
3//! moreutils `sponge` uses `fopen(path, "w")` which follows symbolic links and
4//! truncates the underlying file. POSIX `rename(2)` would replace the symlink
5//! itself with a fresh regular file — diverging from moreutils. We therefore
6//! detect symlink targets up the call chain (in [`crate::Sponge::run`]) and
7//! dispatch here, where we use `OpenOptions` with `truncate(true)` (or
8//! `append(true)` for `-a` mode) so the linked file is updated and the link
9//! itself stays in place.
10//!
11//! ## Atomic-safety scope
12//!
13//! Per FR-006: the atomic-safety guarantee does **NOT** apply on this path.
14//! `OpenOptions::truncate(true).open(...)` zeroes the linked file BEFORE we
15//! write the buffer; a mid-write failure can leave the linked file partial.
16//! This matches moreutils behavior and is documented in the compatibility
17//! statement. Use [`crate::atomic::write_atomic`] for the regular-file path
18//! when you need the atomic-safety guarantee.
19
20use std::fs::OpenOptions;
21use std::path::Path;
22
23use crate::{Error, buffer::Buffer};
24
25/// Write `buffer` to the target by following any symlink and truncating (or
26/// appending to) the linked file. Non-atomic — see module docs.
27pub fn write_through(buffer: Buffer, target: &Path, append: bool) -> Result<(), Error> {
28    let mut file = if append {
29        // Append mode: open existing linked file for append; create if missing
30        // (matches moreutils which would create-on-missing in append mode).
31        // O_APPEND semantics: writes go to current EOF atomically per-syscall,
32        // which is equivalent to "read existing + append stdin + truncate-write"
33        // in end-state (the moreutils approach).
34        OpenOptions::new().create(true).append(true).open(target)?
35    } else {
36        // Non-append: truncate the linked file and write buffer.
37        OpenOptions::new()
38            .write(true)
39            .create(true)
40            .truncate(true)
41            .open(target)?
42    };
43
44    buffer.write_to(&mut file)?;
45    // Best-effort durability; matches moreutils (no fsync there either).
46    file.sync_data().ok();
47    Ok(())
48}
49
50/// Decide whether `target` requires the write-through path. Returns true for
51/// POSIX symlinks (`is_symlink()`) and for any non-regular existing file
52/// (FIFOs, devices, Windows reparse points). Missing targets return false —
53/// the caller dispatches to the atomic-rename path, which creates them.
54pub fn requires_write_through(target: &Path) -> bool {
55    let Ok(meta) = std::fs::symlink_metadata(target) else {
56        return false;
57    };
58    let ft = meta.file_type();
59    if ft.is_symlink() {
60        return true;
61    }
62    // is_file() and is_dir() are the two "regular" kinds. Anything else
63    // (FIFOs, char devices, sockets, Windows reparse points that aren't
64    // junctions/dirs) should not get the atomic-rename path.
65    if !ft.is_file() && !ft.is_dir() {
66        return true;
67    }
68    false
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::io::Cursor;
75
76    fn buffer_from(bytes: &[u8]) -> Buffer {
77        let mut b = Buffer::new();
78        let tmpdir = tempfile::tempdir().unwrap();
79        b.drain_reader(Cursor::new(bytes), 1 << 30, tmpdir.path())
80            .unwrap();
81        b
82    }
83
84    #[test]
85    fn write_through_to_new_file_creates_with_content() {
86        let tmpdir = tempfile::tempdir().unwrap();
87        let target = tmpdir.path().join("new.txt");
88        write_through(buffer_from(b"hello\n"), &target, false).unwrap();
89        assert_eq!(std::fs::read(&target).unwrap(), b"hello\n");
90    }
91
92    #[test]
93    fn write_through_truncates_existing_file() {
94        let tmpdir = tempfile::tempdir().unwrap();
95        let target = tmpdir.path().join("preexisting.txt");
96        std::fs::write(&target, b"OLD_CONTENT\n").unwrap();
97        write_through(buffer_from(b"NEW\n"), &target, false).unwrap();
98        assert_eq!(std::fs::read(&target).unwrap(), b"NEW\n");
99    }
100
101    #[test]
102    fn write_through_append_mode_concatenates() {
103        let tmpdir = tempfile::tempdir().unwrap();
104        let target = tmpdir.path().join("append.txt");
105        std::fs::write(&target, b"first\n").unwrap();
106        write_through(buffer_from(b"second\n"), &target, true).unwrap();
107        assert_eq!(std::fs::read(&target).unwrap(), b"first\nsecond\n");
108    }
109
110    #[test]
111    fn requires_write_through_false_for_missing_target() {
112        let tmpdir = tempfile::tempdir().unwrap();
113        let missing = tmpdir.path().join("does-not-exist");
114        assert!(!requires_write_through(&missing));
115    }
116
117    #[test]
118    fn requires_write_through_false_for_regular_file() {
119        let tmpdir = tempfile::tempdir().unwrap();
120        let f = tmpdir.path().join("regular.txt");
121        std::fs::write(&f, b"x").unwrap();
122        assert!(!requires_write_through(&f));
123    }
124
125    #[cfg(unix)]
126    #[test]
127    fn requires_write_through_true_for_unix_symlink() {
128        let tmpdir = tempfile::tempdir().unwrap();
129        let realfile = tmpdir.path().join("real.txt");
130        std::fs::write(&realfile, b"linked\n").unwrap();
131        let link = tmpdir.path().join("via.link");
132        std::os::unix::fs::symlink(&realfile, &link).unwrap();
133        assert!(requires_write_through(&link));
134    }
135
136    #[cfg(unix)]
137    #[test]
138    fn write_through_unix_symlink_updates_linked_file_keeps_link() {
139        let tmpdir = tempfile::tempdir().unwrap();
140        let realfile = tmpdir.path().join("real.txt");
141        std::fs::write(&realfile, b"original\n").unwrap();
142        let link = tmpdir.path().join("via.link");
143        std::os::unix::fs::symlink(&realfile, &link).unwrap();
144
145        write_through(buffer_from(b"via the link\n"), &link, false).unwrap();
146
147        // The linked file's bytes are replaced.
148        assert_eq!(std::fs::read(&realfile).unwrap(), b"via the link\n");
149        // The link itself is still a symbolic link pointing at realfile.
150        let link_meta = std::fs::symlink_metadata(&link).unwrap();
151        assert!(
152            link_meta.file_type().is_symlink(),
153            "FR-010: the symlink itself MUST be preserved (not replaced)"
154        );
155    }
156}