1use std::fs::{self, File, OpenOptions};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), AtomicError> {
36 let permissions = match fs::symlink_metadata(path) {
37 Ok(metadata) if metadata.file_type().is_file() => Some(metadata.permissions()),
38 Ok(_) => {
39 return Err(AtomicError::at(
40 AtomicStep::Guard,
41 path,
42 std::io::Error::new(
43 std::io::ErrorKind::InvalidInput,
44 "atomic target must be a regular file, not a symlink, directory, or special file",
45 ),
46 ));
47 }
48 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
49 Err(error) => return Err(AtomicError::at(AtomicStep::Guard, path, error)),
50 };
51 install(
52 path,
53 AtomicInstall::replacing(bytes).with_permissions(permissions),
54 )
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(crate) enum InstallMode {
60 Replace,
63 NewOnly,
66}
67
68pub(crate) struct AtomicInstall<'a> {
70 pub bytes: &'a [u8],
72 pub mode: InstallMode,
74 pub permissions: Option<fs::Permissions>,
76 pub unix_mode: Option<u32>,
78}
79
80impl<'a> AtomicInstall<'a> {
81 pub(crate) fn replacing(bytes: &'a [u8]) -> Self {
83 Self {
84 bytes,
85 mode: InstallMode::Replace,
86 permissions: None,
87 unix_mode: None,
88 }
89 }
90
91 pub(crate) fn with_permissions(mut self, permissions: Option<fs::Permissions>) -> Self {
93 self.permissions = permissions;
94 self
95 }
96
97 pub(crate) fn with_unix_mode(mut self, unix_mode: Option<u32>) -> Self {
99 self.unix_mode = unix_mode;
100 self
101 }
102
103 pub(crate) fn new_only(mut self) -> Self {
105 self.mode = InstallMode::NewOnly;
106 self
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum AtomicStep {
113 Guard,
115 NoParent,
117 NonUtf8Name,
119 Temp,
121 TempExhausted,
123 Write,
125 PreservePermissions,
127 #[cfg(unix)]
132 SetPermissions,
133 Sync,
135 Install,
137 SyncParent,
139}
140
141#[derive(Debug)]
143pub struct AtomicError {
144 pub step: AtomicStep,
146 pub path: PathBuf,
149 pub source: Option<std::io::Error>,
151}
152
153impl AtomicError {
154 fn at(step: AtomicStep, path: &Path, source: std::io::Error) -> Self {
155 Self {
156 step,
157 path: path.to_path_buf(),
158 source: Some(source),
159 }
160 }
161
162 pub(crate) fn target_exists(&self) -> bool {
167 self.step == AtomicStep::Install
168 && self
169 .source
170 .as_ref()
171 .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists)
172 }
173
174 pub fn commit_uncertain(&self) -> bool {
181 self.step == AtomicStep::SyncParent
182 }
183}
184
185impl std::fmt::Display for AtomicError {
186 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 let path = self.path.display();
188 let phrase = match self.step {
189 AtomicStep::Guard => "inspect",
190 AtomicStep::NoParent => {
191 return write!(formatter, "has no parent directory for `{path}`");
192 }
193 AtomicStep::NonUtf8Name => {
194 return write!(formatter, "path is not valid UTF-8: `{path}`");
195 }
196 AtomicStep::TempExhausted => {
197 return write!(formatter, "could not allocate temporary file in `{path}`");
198 }
199 AtomicStep::Temp => "temporary file in",
200 AtomicStep::Write => "write",
201 AtomicStep::PreservePermissions => "preserve permissions",
202 #[cfg(unix)]
203 AtomicStep::SetPermissions => "set permissions on",
204 AtomicStep::Sync => "fsync",
205 AtomicStep::Install => "install",
206 AtomicStep::SyncParent => "fsync parent directory",
207 };
208 write!(formatter, "{phrase} `{path}`")?;
209 match &self.source {
210 Some(source) => write!(formatter, ": {source}"),
211 None => Ok(()),
212 }
213 }
214}
215
216const MAX_NAME_BYTES: usize = 255;
218
219fn temp_file_name(file_name: &str, pid: u32, attempt: u32) -> String {
228 let suffix = format!(".afdata.{pid}.{attempt}.tmp");
229 let budget = MAX_NAME_BYTES.saturating_sub(suffix.len() + 1);
231 let stem = &file_name[..file_name.floor_char_boundary(budget)];
232 format!(".{stem}{suffix}")
233}
234
235fn parent_and_name(path: &Path) -> Result<(&Path, String), AtomicError> {
239 let parent = match path.parent() {
240 Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
241 Some(parent) => parent,
242 None => {
243 return Err(AtomicError {
244 step: AtomicStep::NoParent,
245 path: path.to_path_buf(),
246 source: None,
247 });
248 }
249 };
250 let file_name = path
251 .file_name()
252 .and_then(|name| name.to_str())
253 .ok_or_else(|| AtomicError {
254 step: AtomicStep::NonUtf8Name,
255 path: path.to_path_buf(),
256 source: None,
257 })?
258 .to_string();
259 Ok((parent, file_name))
260}
261
262fn allocate_private_temp(parent: &Path, file_name: &str) -> Result<(PathBuf, File), AtomicError> {
263 let pid = std::process::id();
264 for attempt in 0..32_u32 {
265 let candidate = parent.join(temp_file_name(file_name, pid, attempt));
266 let mut options = OpenOptions::new();
267 options.write(true).create_new(true);
268 #[cfg(unix)]
269 {
270 use std::os::unix::fs::OpenOptionsExt as _;
271 options.mode(0o600);
274 }
275 match options.open(&candidate) {
276 Ok(file) => return Ok((candidate, file)),
277 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
278 Err(error) => return Err(AtomicError::at(AtomicStep::Temp, parent, error)),
279 }
280 }
281 Err(AtomicError {
282 step: AtomicStep::TempExhausted,
283 path: parent.to_path_buf(),
284 source: None,
285 })
286}
287
288#[cfg(unix)]
289fn sync_parent(parent: &Path) -> Result<(), AtomicError> {
290 File::open(parent)
291 .and_then(|directory| directory.sync_all())
292 .map_err(|error| AtomicError::at(AtomicStep::SyncParent, parent, error))
293}
294
295#[cfg(not(unix))]
296fn sync_parent(_parent: &Path) -> Result<(), AtomicError> {
297 Ok(())
300}
301
302fn write_temp(
303 mut temp_file: File,
304 temp_path: &Path,
305 target: &Path,
306 request: &AtomicInstall<'_>,
307 write: impl FnOnce(&mut File, &[u8]) -> std::io::Result<()>,
308) -> Result<(), AtomicError> {
309 write(&mut temp_file, request.bytes)
310 .map_err(|error| AtomicError::at(AtomicStep::Write, target, error))?;
311 if let Some(permissions) = request.permissions.clone() {
312 temp_file
313 .set_permissions(permissions)
314 .map_err(|error| AtomicError::at(AtomicStep::PreservePermissions, target, error))?;
315 }
316 #[cfg(unix)]
317 if let Some(unix_mode) = request.unix_mode {
318 use std::os::unix::fs::PermissionsExt as _;
319 temp_file
320 .set_permissions(fs::Permissions::from_mode(unix_mode))
321 .map_err(|error| AtomicError::at(AtomicStep::SetPermissions, target, error))?;
322 }
323 temp_file
324 .sync_all()
325 .map_err(|error| AtomicError::at(AtomicStep::Sync, temp_path, error))
326}
327
328pub(crate) fn install(path: &Path, request: AtomicInstall<'_>) -> Result<(), AtomicError> {
336 install_with_writer(path, request, |file, bytes| file.write_all(bytes))
337}
338
339fn install_with_writer(
343 path: &Path,
344 request: AtomicInstall<'_>,
345 write: impl FnOnce(&mut File, &[u8]) -> std::io::Result<()>,
346) -> Result<(), AtomicError> {
347 let (parent, file_name) = parent_and_name(path)?;
348 let (temp_path, temp_file) = allocate_private_temp(parent, &file_name)?;
349 let result = (|| -> Result<(), AtomicError> {
350 write_temp(temp_file, &temp_path, path, &request, write)?;
351 match request.mode {
352 InstallMode::Replace => {
353 fs::rename(&temp_path, path)
354 .map_err(|error| AtomicError::at(AtomicStep::Install, path, error))?;
355 }
356 InstallMode::NewOnly => {
357 fs::hard_link(&temp_path, path)
358 .map_err(|error| AtomicError::at(AtomicStep::Install, path, error))?;
359 let _ = fs::remove_file(&temp_path);
364 }
365 }
366 sync_parent(parent)?;
367 Ok(())
368 })();
369 if let Err(error) = &result {
370 if !error.commit_uncertain() {
373 let _ = fs::remove_file(&temp_path);
374 }
375 }
376 result
377}
378
379impl std::error::Error for AtomicError {
380 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
381 self.source.as_ref().map(|error| error as _)
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
388 use super::*;
389
390 #[test]
391 fn temp_name_stays_within_one_path_component() {
392 let long = "x".repeat(400);
393 let name = temp_file_name(&long, 1234, 0);
394 assert!(name.len() <= MAX_NAME_BYTES, "{} bytes", name.len());
395 assert!(name.starts_with('.'));
396 assert!(name.ends_with(".tmp"));
397 }
398
399 #[test]
400 fn temp_name_truncates_on_a_character_boundary() {
401 let long = "é".repeat(300);
402 let name = temp_file_name(&long, 1234, 0);
403 assert!(name.is_char_boundary(name.len()));
404 assert!(name.len() <= MAX_NAME_BYTES);
405 }
406
407 #[test]
408 fn bare_relative_paths_use_the_current_directory() {
409 let (parent, file_name) = parent_and_name(Path::new("config.json")).unwrap();
410
411 assert_eq!(parent, Path::new("."));
412 assert_eq!(file_name, "config.json");
413 }
414
415 #[test]
416 fn replace_installs_new_bytes_and_leaves_no_temporary_file() {
417 let dir = tempfile::tempdir().unwrap();
418 let path = dir.path().join("config.json");
419 fs::write(&path, b"old").unwrap();
420
421 install(&path, AtomicInstall::replacing(b"new")).unwrap();
422
423 assert_eq!(fs::read(&path).unwrap(), b"new");
424 let strays: Vec<_> = fs::read_dir(dir.path())
425 .unwrap()
426 .filter_map(Result::ok)
427 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
428 .collect();
429 assert!(strays.is_empty(), "temporary files left behind");
430 }
431
432 #[test]
433 fn new_only_refuses_an_existing_target_without_touching_it() {
434 let dir = tempfile::tempdir().unwrap();
435 let path = dir.path().join("config.json");
436 fs::write(&path, b"original").unwrap();
437
438 let error = install(&path, AtomicInstall::replacing(b"new").new_only()).unwrap_err();
439
440 assert!(error.target_exists());
441 assert_eq!(fs::read(&path).unwrap(), b"original");
442 }
443
444 #[cfg(unix)]
445 #[test]
446 fn replace_swaps_a_symlink_rather_than_following_it() {
447 use std::os::unix::fs::symlink;
448
449 let dir = tempfile::tempdir().unwrap();
450 let outside = dir.path().join("outside.txt");
451 fs::write(&outside, b"untouched").unwrap();
452 let link = dir.path().join("link.txt");
453 symlink(&outside, &link).unwrap();
454
455 install(&link, AtomicInstall::replacing(b"new")).unwrap();
456
457 assert_eq!(fs::read(&outside).unwrap(), b"untouched");
458 assert!(
459 !fs::symlink_metadata(&link)
460 .unwrap()
461 .file_type()
462 .is_symlink()
463 );
464 assert_eq!(fs::read(&link).unwrap(), b"new");
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn explicit_unix_mode_wins_over_the_private_temporary_mode() {
470 use std::os::unix::fs::PermissionsExt as _;
471
472 let dir = tempfile::tempdir().unwrap();
473 let path = dir.path().join("readable.md");
474
475 install(
476 &path,
477 AtomicInstall::replacing(b"body").with_unix_mode(Some(0o644)),
478 )
479 .unwrap();
480
481 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
482 assert_eq!(mode, 0o644);
483 }
484
485 #[test]
486 fn a_missing_parent_directory_fails_before_any_write() {
487 let dir = tempfile::tempdir().unwrap();
488 let path = dir.path().join("absent").join("config.json");
489
490 let error = install(&path, AtomicInstall::replacing(b"new")).unwrap_err();
491
492 assert_eq!(error.step, AtomicStep::Temp);
493 assert!(!error.commit_uncertain());
494 assert!(!path.exists());
495 }
496 #[test]
497 fn failed_writes_preserve_previous_bytes_and_remove_temporary_file() {
498 let dir = tempfile::tempdir().unwrap();
499 let path = dir.path().join("state.json");
500 fs::write(&path, b"old complete value").unwrap();
501 for error in [
502 std::io::Error::from_raw_os_error(28),
503 std::io::Error::from(std::io::ErrorKind::Interrupted),
504 std::io::Error::from(std::io::ErrorKind::WriteZero),
505 ] {
506 let error = install_with_writer(&path, AtomicInstall::replacing(b"new"), |file, _| {
507 file.write_all(b"partial")?;
508 Err(error)
509 })
510 .unwrap_err();
511 assert_eq!(error.step, AtomicStep::Write);
512 assert!(!error.commit_uncertain());
513 assert_eq!(fs::read(&path).unwrap(), b"old complete value");
514 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
515 }
516 }
517
518 #[test]
519 fn write_all_retries_short_writes_and_interruptions_before_publish() {
520 struct ShortWriter<'a> {
521 file: &'a mut File,
522 interrupted: bool,
523 }
524 impl std::io::Write for ShortWriter<'_> {
525 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
526 if !self.interrupted {
527 self.interrupted = true;
528 return Err(std::io::ErrorKind::Interrupted.into());
529 }
530 self.file.write(&bytes[..bytes.len().min(2)])
531 }
532 fn flush(&mut self) -> std::io::Result<()> {
533 self.file.flush()
534 }
535 }
536 let dir = tempfile::tempdir().unwrap();
537 let path = dir.path().join("state.json");
538 let bytes = b"new complete value";
539 install_with_writer(&path, AtomicInstall::replacing(bytes), |file, bytes| {
540 ShortWriter {
541 file,
542 interrupted: false,
543 }
544 .write_all(bytes)
545 })
546 .unwrap();
547 assert_eq!(fs::read(&path).unwrap(), bytes);
548 }
549
550 #[test]
551 fn interrupted_process_leaves_uncommitted_temporary_file_not_target() {
552 const CHILD_PATH: &str = "AFDATA_TEST_INTERRUPTED_ATOMIC_PATH";
553 if let Some(path) = std::env::var_os(CHILD_PATH) {
554 let _ = install_with_writer(
555 Path::new(&path),
556 AtomicInstall::replacing(b"new"),
557 |file, _| {
558 file.write_all(b"partial")?;
559 file.sync_all()?;
560 std::process::exit(86);
561 },
562 );
563 panic!("child did not reach the interrupted write");
564 }
565 let dir = tempfile::tempdir().unwrap();
566 let path = dir.path().join("state.json");
567 write_atomic(&path, b"old complete value").unwrap();
568 let status = std::process::Command::new(std::env::current_exe().unwrap())
569 .args(["--exact", "atomic_file::tests::interrupted_process_leaves_uncommitted_temporary_file_not_target"])
570 .env(CHILD_PATH, &path).status().unwrap();
571 assert_eq!(status.code(), Some(86));
572 assert_eq!(fs::read(&path).unwrap(), b"old complete value");
573 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2);
574 write_atomic(&path, b"new complete value").unwrap();
575 assert_eq!(fs::read(&path).unwrap(), b"new complete value");
576 }
577}