fallow_types/
atomic_file.rs1use std::io::Write as _;
13use std::path::Path;
14
15#[cfg(unix)]
17const DEFAULT_FILE_MODE: u32 = 0o666;
18
19pub fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
32 let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
33 return Err(std::io::Error::new(
34 std::io::ErrorKind::InvalidInput,
35 "cache file path has no parent directory or file name",
36 ));
37 };
38 let dir = if dir.as_os_str().is_empty() {
39 Path::new(".")
40 } else {
41 dir
42 };
43 let mut prefix = name.to_os_string();
44 prefix.push(".");
45 let mut builder = tempfile::Builder::new();
46 builder.prefix(&prefix).suffix(".tmp");
47 #[cfg(unix)]
51 {
52 use std::os::unix::fs::PermissionsExt as _;
53 builder.permissions(std::fs::Permissions::from_mode(DEFAULT_FILE_MODE));
54 }
55 let mut tmp = builder.tempfile_in(dir)?;
56 tmp.write_all(data)?;
57 let _ = tmp.as_file().sync_all();
59 persist(tmp, path)
60}
61
62#[cfg(windows)]
64const PERSIST_ATTEMPTS: u32 = 20;
65
66#[cfg(windows)]
68const PERSIST_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
69
70#[cfg(not(windows))]
71fn persist(tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
72 tmp.persist(path).map_err(|error| error.error)?;
73 Ok(())
74}
75
76#[cfg(windows)]
78const ERROR_ACCESS_DENIED: i32 = 5;
79#[cfg(windows)]
81const ERROR_SHARING_VIOLATION: i32 = 32;
82#[cfg(windows)]
84const ERROR_LOCK_VIOLATION: i32 = 33;
85
86#[cfg(windows)]
88fn is_transient_rename_error(error: &std::io::Error) -> bool {
89 matches!(
90 error.raw_os_error(),
91 Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
92 )
93}
94
95#[cfg(windows)]
100fn persist(mut tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
101 let mut attempt = 1;
102 loop {
103 match tmp.persist(path) {
104 Ok(_) => return Ok(()),
105 Err(error) if is_transient_rename_error(&error.error) && attempt < PERSIST_ATTEMPTS => {
106 tmp = error.file;
107 attempt += 1;
108 std::thread::sleep(PERSIST_RETRY_DELAY);
109 }
110 Err(error) => return Err(error.error),
111 }
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn dir_names(dir: &Path) -> Vec<String> {
120 let mut names: Vec<String> = std::fs::read_dir(dir)
121 .unwrap()
122 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
123 .collect();
124 names.sort();
125 names
126 }
127
128 #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
129 #[test]
130 fn write_replaces_the_file_and_leaves_no_temp_file() {
131 let dir = tempfile::tempdir().unwrap();
132 let path = dir.path().join("cache.bin");
133 write_atomic(&path, b"first").unwrap();
134 write_atomic(&path, b"second payload").unwrap();
135 assert_eq!(std::fs::read(&path).unwrap(), b"second payload");
136 assert_eq!(dir_names(dir.path()), ["cache.bin"]);
137 }
138
139 #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
140 #[test]
141 fn concurrent_writers_each_move_one_whole_payload_into_place() {
142 const WRITERS: u8 = 8;
143 const ROUNDS: usize = 50;
144 const PAYLOAD_LEN: usize = 256 * 1024;
145 let dir = tempfile::tempdir().unwrap();
146 let path = dir.path().join("cache.bin");
147 let errors = std::sync::Mutex::new(Vec::<std::io::Error>::new());
148 std::thread::scope(|scope| {
149 for writer in 0..WRITERS {
150 let (path, errors) = (&path, &errors);
151 scope.spawn(move || {
152 let payload = vec![writer; PAYLOAD_LEN];
153 for _ in 0..ROUNDS {
154 if let Err(error) = write_atomic(path, &payload) {
155 errors.lock().unwrap().push(error);
156 }
157 }
158 });
159 }
160 });
161 let errors = errors.into_inner().unwrap();
162 assert!(errors.is_empty(), "writes failed: {errors:?}");
163
164 let data = std::fs::read(&path).unwrap();
165 assert_eq!(data.len(), PAYLOAD_LEN, "the file holds one whole payload");
166 assert!(
167 data.iter().all(|byte| *byte == data[0]),
168 "the file holds bytes from more than one writer"
169 );
170 assert_eq!(dir_names(dir.path()), ["cache.bin"]);
171 }
172
173 #[cfg(unix)]
176 #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
177 #[test]
178 fn the_file_gets_the_mode_that_file_create_gives() {
179 use std::os::unix::fs::PermissionsExt as _;
180 let dir = tempfile::tempdir().unwrap();
181 let reference = dir.path().join("reference");
182 std::fs::File::create(&reference).unwrap();
183 let path = dir.path().join("cache.bin");
184 write_atomic(&path, b"data").unwrap();
185 let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
186 assert_eq!(mode(&path), mode(&reference));
187 }
188
189 #[test]
190 fn a_path_without_a_file_name_is_refused() {
191 let error = write_atomic(Path::new("/"), b"data").unwrap_err();
192 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
193 }
194}