1use std::fs;
9use std::io::{self, Write as _};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13static NEXT: AtomicU64 = AtomicU64::new(0);
16
17const MAX_LINKS: usize = 40;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum WriteStep {
23 Wrote(PathBuf),
25 SyncedFile,
27 Renamed,
29 SyncedDirectory,
32}
33
34pub fn atomic_write(path: &Path, contents: &[u8]) -> io::Result<()> {
60 atomic_write_reporting(path, contents, |_| {})
61}
62
63pub fn atomic_write_reporting(path: &Path, contents: &[u8], mut on_step: impl FnMut(WriteStep)) -> io::Result<()> {
71 let target = link_target(path)?;
73 let path = target.as_path();
74 let directory = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
75 let temporary = directory.join(temporary_name(path));
76 let written = (|| {
77 let mut file = fs::File::create(&temporary)?;
78 #[cfg(unix)]
81 if let Some(existing) = fs::metadata(path).ok().filter(fs::Metadata::is_file) {
82 file.set_permissions(existing.permissions())?;
83 }
84 file.write_all(contents)?;
85 on_step(WriteStep::Wrote(temporary.clone()));
86 file.sync_all()?;
87 on_step(WriteStep::SyncedFile);
88 fs::rename(&temporary, path)?;
89 on_step(WriteStep::Renamed);
90 if sync_directory(directory)? {
91 on_step(WriteStep::SyncedDirectory);
92 }
93 Ok(())
94 })();
95 if written.is_err() {
96 let _ = fs::remove_file(&temporary);
99 }
100 written
101}
102
103fn link_target(path: &Path) -> io::Result<PathBuf> {
106 let mut target = path.to_path_buf();
107 for _ in 0..MAX_LINKS {
108 let is_link = fs::symlink_metadata(&target).is_ok_and(|meta| meta.file_type().is_symlink());
109 if !is_link {
110 return Ok(target);
111 }
112 let next = fs::read_link(&target)?;
113 target = match target.parent() {
114 Some(parent) if next.is_relative() => parent.join(next),
115 _ => next,
116 };
117 }
118 Err(io::Error::other(format!("{}: too many levels of symbolic links", path.display())))
119}
120
121fn temporary_name(path: &Path) -> String {
123 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("file");
124 let ticket = NEXT.fetch_add(1, Ordering::Relaxed);
125 format!("{name}.tmp-{}-{ticket}", std::process::id())
126}
127
128#[cfg(unix)]
131fn sync_directory(directory: &Path) -> io::Result<bool> {
132 fs::File::open(directory)?.sync_all()?;
134 Ok(true)
135}
136
137#[cfg(not(unix))]
140fn sync_directory(_directory: &Path) -> io::Result<bool> {
141 Ok(false)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn temp_dir(name: &str) -> PathBuf {
150 let dir = std::env::temp_dir().join(format!("quvyta-atomic-{name}-{}", std::process::id()));
151 let _ = fs::remove_dir_all(&dir);
152 fs::create_dir_all(&dir).expect("test directory");
153 dir
154 }
155
156 #[test]
157 fn the_steps_happen_in_the_order_that_makes_the_write_safe() {
158 let dir = temp_dir("steps");
159 let path = dir.join("tree.toml");
160 let mut steps = Vec::new();
161 atomic_write(&path, b"first\n").expect("first write");
162 atomic_write_reporting(&path, b"second\n", |step| steps.push(step)).expect("second write");
163
164 let temporary = match steps.first() {
165 Some(WriteStep::Wrote(temporary)) => temporary.clone(),
166 other => panic!("the first step writes a temporary file, not {other:?}"),
167 };
168 assert_eq!(temporary.parent(), path.parent(), "the temporary file shares the directory");
169 assert_ne!(temporary, path);
170 let rest: Vec<WriteStep> = steps[1..].to_vec();
171 if cfg!(unix) {
172 assert_eq!(
173 rest,
174 [WriteStep::SyncedFile, WriteStep::Renamed, WriteStep::SyncedDirectory],
175 "the directory is flushed, and only after the rename"
176 );
177 } else {
178 assert_eq!(rest, [WriteStep::SyncedFile, WriteStep::Renamed]);
179 }
180 assert_eq!(fs::read_to_string(&path).expect("read"), "second\n");
181 let leftovers = fs::read_dir(&dir).expect("list").count();
182 assert_eq!(leftovers, 1, "nothing but the file itself stays behind");
183 fs::remove_dir_all(&dir).expect("clean");
184 }
185
186 #[test]
187 fn a_failed_write_keeps_the_old_file_and_cleans_up_after_itself() {
188 let dir = temp_dir("failure");
189 let path = dir.join("occupied");
192 fs::create_dir(&path).expect("directory in the way");
193 fs::write(path.join("inside.toml"), "kept\n").expect("content under it");
194
195 let mut steps = Vec::new();
196 let error = atomic_write_reporting(&path, b"new\n", |step| steps.push(step)).expect_err("rename fails");
197 assert!(matches!(steps.first(), Some(WriteStep::Wrote(_))), "{steps:?}");
198 assert!(!steps.contains(&WriteStep::Renamed), "{steps:?} after {error}");
199 assert_eq!(fs::read_to_string(path.join("inside.toml")).expect("read"), "kept\n", "the old state is intact");
200 let names: Vec<String> = fs::read_dir(&dir)
201 .expect("list")
202 .map(|entry| entry.expect("entry").file_name().display().to_string())
203 .collect();
204 assert_eq!(names, ["occupied"], "the temporary file was removed");
205 fs::remove_dir_all(&dir).expect("clean");
206 }
207
208 #[cfg(unix)]
209 #[test]
210 fn the_file_keeps_the_permissions_it_had() {
211 use std::os::unix::fs::PermissionsExt as _;
212
213 let dir = temp_dir("mode");
214 let path = dir.join("secrets.toml");
215 fs::write(&path, "token = \"old\"\n").expect("first version");
216 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("private");
217 atomic_write(&path, b"token = \"new\"\n").expect("replace");
218 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
219 assert_eq!(mode, 0o600, "a private file stays private after it is replaced");
220 fs::remove_dir_all(&dir).expect("clean");
221 }
222
223 #[cfg(unix)]
224 #[test]
225 fn a_symbolic_link_is_written_through_and_stays_a_link() {
226 use std::os::unix::fs::{PermissionsExt as _, symlink};
227
228 let dir = temp_dir("link");
229 let dotfiles = dir.join("dotfiles");
230 let config = dir.join("config");
231 fs::create_dir_all(&dotfiles).expect("dotfiles");
232 fs::create_dir_all(&config).expect("config");
233 let target = dotfiles.join("settings.toml");
234 fs::write(&target, "theme = \"old\"\n").expect("the linked file");
235 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("private");
236 let link = config.join("settings.toml");
238 symlink("../dotfiles/settings.toml", &link).expect("link");
239 let second = config.join("again.toml");
240 symlink(&link, &second).expect("link to the link");
241
242 let mut steps = Vec::new();
243 atomic_write_reporting(&second, b"theme = \"new\"\n", |step| steps.push(step)).expect("write");
244 assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink(), "the link is still a link");
245 assert!(fs::symlink_metadata(&second).expect("link").file_type().is_symlink());
246 assert_eq!(fs::read_to_string(&target).expect("read"), "theme = \"new\"\n", "the file it points at changed");
247 let mode = fs::metadata(&target).expect("metadata").permissions().mode() & 0o777;
248 assert_eq!(mode, 0o600, "the linked file keeps its permissions");
249 let Some(WriteStep::Wrote(temporary)) = steps.first() else { panic!("{steps:?}") };
250 let folder = fs::canonicalize(temporary.parent().expect("a folder")).expect("folder");
251 assert_eq!(folder, fs::canonicalize(&dotfiles).expect("dotfiles"), "renamed within the linked file's folder");
252 assert_eq!(fs::read_dir(&dotfiles).expect("list").count(), 1, "nothing left next to the file");
253 assert_eq!(fs::read_dir(&config).expect("list").count(), 2, "only the two links");
254 fs::remove_dir_all(&dir).expect("clean");
255 }
256
257 #[cfg(unix)]
258 #[test]
259 fn a_link_to_a_missing_file_creates_it_and_a_loop_writes_nothing() {
260 use std::os::unix::fs::symlink;
261
262 let dir = temp_dir("dangling");
263 let link = dir.join("settings.toml");
264 symlink("real.toml", &link).expect("link");
265 atomic_write(&link, b"x = 1\n").expect("write");
266 assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink());
267 assert_eq!(fs::read_to_string(dir.join("real.toml")).expect("created"), "x = 1\n");
268
269 let a = dir.join("a.toml");
270 let b = dir.join("b.toml");
271 symlink("b.toml", &a).expect("a");
272 symlink("a.toml", &b).expect("b");
273 let error = atomic_write(&a, b"y = 2\n").expect_err("a loop has no file at its end");
274 assert!(error.to_string().contains("symbolic links"), "{error}");
275 assert_eq!(fs::read_dir(&dir).expect("list").count(), 4, "nothing was written");
276 fs::remove_dir_all(&dir).expect("clean");
277 }
278
279 #[test]
280 fn a_missing_directory_is_an_error_and_writes_nothing() {
281 let dir = temp_dir("missing");
282 let path = dir.join("absent").join("tree.toml");
283 let error = atomic_write(&path, b"x\n").expect_err("no directory to write in");
284 assert_eq!(error.kind(), io::ErrorKind::NotFound);
285 assert!(!path.exists());
286 fs::remove_dir_all(&dir).expect("clean");
287 }
288
289 #[test]
290 fn two_writes_at_once_use_two_temporary_files() {
291 let dir = temp_dir("parallel");
292 let path = dir.join("tree.toml");
293 let mut first = None;
294 atomic_write_reporting(&path, b"a\n", |step| {
295 if let WriteStep::Wrote(temporary) = step {
296 first = Some(temporary);
297 }
298 })
299 .expect("first");
300 let mut second = None;
301 atomic_write_reporting(&path, b"b\n", |step| {
302 if let WriteStep::Wrote(temporary) = step {
303 second = Some(temporary);
304 }
305 })
306 .expect("second");
307 assert_ne!(first.expect("first name"), second.expect("second name"));
308 fs::remove_dir_all(&dir).expect("clean");
309 }
310}