arcbox_atomic_file/
lib.rs1#![forbid(unsafe_code)]
25
26use std::fs::{self, File, OpenOptions};
27use std::io::Write;
28use std::os::unix::fs::OpenOptionsExt;
29use std::path::{Path, PathBuf};
30
31use thiserror::Error;
32use uuid::Uuid;
33
34#[derive(Debug, Error)]
36pub enum AtomicWriteError {
37 #[error("{} was not replaced: {source}", .path.display())]
39 NotCommitted {
40 path: PathBuf,
42 #[source]
44 source: std::io::Error,
45 },
46
47 #[error("{} was renamed but directory durability is unconfirmed: {source}", .path.display())]
51 DurabilityUncertain {
52 path: PathBuf,
54 #[source]
56 source: std::io::Error,
57 },
58}
59
60impl AtomicWriteError {
61 #[must_use]
63 pub const fn source_io(&self) -> &std::io::Error {
64 match self {
65 Self::NotCommitted { source, .. } | Self::DurabilityUncertain { source, .. } => source,
66 }
67 }
68}
69
70pub fn write(destination: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> {
82 let not_committed = |source| AtomicWriteError::NotCommitted {
83 path: destination.to_path_buf(),
84 source,
85 };
86
87 let Some(parent) = destination.parent() else {
88 return Err(not_committed(std::io::Error::new(
89 std::io::ErrorKind::InvalidInput,
90 format!("path has no parent: {}", destination.display()),
91 )));
92 };
93 let temp_path = parent.join(format!(
94 ".{}.{}.tmp",
95 destination
96 .file_stem()
97 .and_then(|name| name.to_str())
98 .unwrap_or("atomic"),
99 Uuid::new_v4()
100 ));
101
102 (|| {
103 let mut temp = OpenOptions::new()
104 .write(true)
105 .create_new(true)
106 .mode(0o600)
107 .open(&temp_path)?;
108 temp.write_all(bytes)?;
109 temp.sync_all()?;
110 drop(temp);
111 fs::rename(&temp_path, destination)?;
112 Ok::<(), std::io::Error>(())
113 })()
114 .map_err(|original| {
115 match fs::remove_file(&temp_path) {
118 Ok(()) => original,
119 Err(error) if error.kind() == std::io::ErrorKind::NotFound => original,
120 Err(cleanup) => std::io::Error::new(
121 original.kind(),
122 format!(
123 "{original}; failed to remove temporary file {}: {cleanup}",
124 temp_path.display()
125 ),
126 ),
127 }
128 })
129 .map_err(not_committed)?;
130
131 File::open(parent)
132 .and_then(|directory| directory.sync_all())
133 .map_err(|source| AtomicWriteError::DurabilityUncertain {
134 path: destination.to_path_buf(),
135 source,
136 })
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn a_replaced_file_holds_the_new_bytes_and_leaves_no_temp() {
145 let dir = tempfile::tempdir().unwrap();
146 let path = dir.path().join("record.json");
147 write(&path, b"first").unwrap();
148 write(&path, b"second").unwrap();
149
150 assert_eq!(fs::read(&path).unwrap(), b"second");
151 let leftovers: Vec<_> = fs::read_dir(dir.path())
152 .unwrap()
153 .map(|entry| entry.unwrap().file_name())
154 .filter(|name| name != "record.json")
155 .collect();
156 assert!(
157 leftovers.is_empty(),
158 "temp files left behind: {leftovers:?}"
159 );
160 }
161
162 #[test]
163 fn a_failed_write_leaves_the_destination_untouched() {
164 let dir = tempfile::tempdir().unwrap();
165 let path = dir.path().join("nested").join("record.json");
166 let error = write(&path, b"payload").unwrap_err();
168
169 assert!(matches!(error, AtomicWriteError::NotCommitted { .. }));
170 assert!(!path.exists());
171 }
172
173 #[test]
174 fn a_pathless_destination_is_reported_rather_than_panicking() {
175 let error = write(Path::new("/"), b"payload").unwrap_err();
176 assert!(matches!(error, AtomicWriteError::NotCommitted { .. }));
177 assert_eq!(error.source_io().kind(), std::io::ErrorKind::InvalidInput);
178 }
179
180 #[test]
181 fn concurrent_writers_each_land_their_own_bytes_whole() {
182 let dir = tempfile::tempdir().unwrap();
183 let path = dir.path().join("contended.json");
184 let long = vec![b'a'; 64 * 1024];
185 let short = vec![b'b'; 64 * 1024];
186
187 std::thread::scope(|scope| {
188 for bytes in [&long, &short] {
189 scope.spawn(|| {
190 for _ in 0..20 {
191 write(&path, bytes).unwrap();
192 }
193 });
194 }
195 });
196
197 let final_bytes = fs::read(&path).unwrap();
200 assert!(final_bytes == long || final_bytes == short);
201 }
202}