rumdl_lib/utils/
atomic_write.rs1use std::fs;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19
20static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23pub fn write_atomically(path: &Path, content: &[u8]) -> io::Result<()> {
34 let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
39 let parent = target.parent().filter(|p| !p.as_os_str().is_empty());
40
41 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
42 let base = target.file_name().and_then(|n| n.to_str()).unwrap_or("rumdl");
43 #[cfg(not(target_arch = "wasm32"))]
44 let tmp_name = format!(".{base}.rumdl-tmp.{}.{counter}", std::process::id());
45 #[cfg(target_arch = "wasm32")]
48 let tmp_name = format!(".{base}.rumdl-tmp.{counter}");
49 let tmp_path: PathBuf = match parent {
50 Some(dir) => dir.join(tmp_name),
51 None => PathBuf::from(tmp_name),
52 };
53
54 let result = (|| {
55 fs::write(&tmp_path, content)?;
56 if let Ok(meta) = fs::metadata(&target) {
59 let _ = fs::set_permissions(&tmp_path, meta.permissions());
60 }
61 fs::rename(&tmp_path, &target)
62 })();
63
64 if result.is_err() {
65 let _ = fs::remove_file(&tmp_path);
66 }
67 result
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use std::io::Read;
74 use std::sync::Arc;
75 use std::sync::atomic::{AtomicBool, Ordering};
76 use tempfile::tempdir;
77
78 #[test]
79 fn writes_new_content() {
80 let dir = tempdir().unwrap();
81 let path = dir.path().join("doc.md");
82 fs::write(&path, "old").unwrap();
83 write_atomically(&path, b"new content").unwrap();
84 assert_eq!(fs::read_to_string(&path).unwrap(), "new content");
85 }
86
87 #[test]
88 fn creates_a_new_file_when_target_absent() {
89 let dir = tempdir().unwrap();
90 let path = dir.path().join("fresh.md");
91 write_atomically(&path, b"hello").unwrap();
92 assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
93 }
94
95 #[cfg(unix)]
96 #[test]
97 fn preserves_unix_permissions() {
98 use std::os::unix::fs::PermissionsExt;
99 let dir = tempdir().unwrap();
100 let path = dir.path().join("doc.md");
101 fs::write(&path, "old").unwrap();
102 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
103 write_atomically(&path, b"new").unwrap();
104 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
105 assert_eq!(mode, 0o600, "fix must not widen a restrictive file mode");
106 }
107
108 #[cfg(unix)]
109 #[test]
110 fn writes_through_a_symlink_and_keeps_the_link() {
111 use std::os::unix::fs::symlink;
112 let dir = tempdir().unwrap();
113 let real = dir.path().join("real.md");
114 let link = dir.path().join("link.md");
115 fs::write(&real, "old").unwrap();
116 symlink(&real, &link).unwrap();
117
118 write_atomically(&link, b"updated").unwrap();
119
120 assert!(fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
122 assert_eq!(fs::read_to_string(&real).unwrap(), "updated");
123 }
124
125 #[test]
126 fn failed_write_leaves_original_intact() {
127 let dir = tempdir().unwrap();
130 let path = dir.path().join("missing-subdir").join("doc.md");
131 assert!(write_atomically(&path, b"content").is_err());
132 assert!(!path.exists(), "no partial file should be left behind");
133 }
134
135 #[test]
136 fn concurrent_reader_never_sees_a_partial_file() {
137 let dir = tempdir().unwrap();
141 let path = dir.path().join("doc.md");
142 let a = "A".repeat(200_000);
143 let b = "B".repeat(200_000);
144 fs::write(&path, &a).unwrap();
145
146 let stop = Arc::new(AtomicBool::new(false));
147 let reader_path = path.clone();
148 let reader_stop = Arc::clone(&stop);
149 let reader = std::thread::spawn(move || {
150 let mut saw_partial = false;
151 while !reader_stop.load(Ordering::Relaxed) {
152 let mut buf = String::new();
153 if let Ok(mut f) = fs::File::open(&reader_path)
154 && f.read_to_string(&mut buf).is_ok()
155 {
156 let len = buf.len();
157 if len != 0 && len != 200_000 {
158 saw_partial = true;
159 break;
160 }
161 }
162 }
163 saw_partial
164 });
165
166 for i in 0..200 {
167 let content = if i % 2 == 0 { &b } else { &a };
168 write_atomically(&path, content.as_bytes()).unwrap();
169 }
170 stop.store(true, Ordering::Relaxed);
171 assert!(!reader.join().unwrap(), "reader observed a partial file");
172 }
173}