Skip to main content

wowlab_fs/atomic/
mod.rs

1// #t(file: rust_ambient_syscall) Durable filesystem commits must synchronize the containing directory through the native filesystem boundary.
2
3//! Interruption-safe file replacement.
4
5use std::io::Write as _;
6
7use crate::{
8    error::{Error, Operation, Result},
9    path::Path,
10};
11
12/// Atomically replace a file with fully flushed bytes.
13///
14/// The temporary file is created beside the destination.
15/// This prevents the commit from crossing filesystems.
16/// The destination's parent must already exist.
17///
18/// # Errors
19///
20/// Returns a contextual error when temporary creation fails.
21/// It also reports writing, synchronization, and commit failures.
22pub fn replace(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
23    let parent = destination_parent(path)?;
24    let temporary = prepare(parent, path, contents)?;
25
26    commit(temporary, parent, path, CommitMode::Replace)
27}
28
29/// Atomically create a file without replacing an existing entry.
30///
31/// Complete contents are flushed to a temporary file beside the destination.
32/// The file is then committed without replacing an existing entry.
33/// The destination's parent must already exist.
34///
35/// # Errors
36///
37/// Returns an already-exists error when the destination is present.
38/// Other creation, writing, synchronization, and commit failures are contextual.
39pub fn create(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
40    let parent = destination_parent(path)?;
41    let temporary = prepare(parent, path, contents)?;
42
43    commit(temporary, parent, path, CommitMode::Create)
44}
45
46#[derive(Clone, Copy, Debug)]
47enum CommitMode {
48    Create,
49    Replace,
50}
51
52fn destination_parent(path: &Path) -> Result<&Path> {
53    path.parent().ok_or_else(|| {
54        Error::new(
55            Operation::CreateFile,
56            path,
57            std::io::Error::new(
58                std::io::ErrorKind::InvalidInput,
59                "atomic destination has no parent",
60            ),
61        )
62    })
63}
64
65fn prepare(
66    parent: &Path,
67    path: &Path,
68    contents: impl AsRef<[u8]>,
69) -> Result<tempfile::NamedTempFile> {
70    let mut temporary = tempfile::NamedTempFile::new_in(parent)
71        .map_err(|source| Error::new(Operation::CreateFile, path, source))?;
72
73    temporary
74        .write_all(contents.as_ref())
75        .map_err(|source| Error::new(Operation::WriteFile, path, source))?;
76    temporary
77        .as_file_mut()
78        .flush()
79        .map_err(|source| Error::new(Operation::WriteFile, path, source))?;
80    temporary
81        .as_file()
82        .sync_all()
83        .map_err(|source| Error::new(Operation::Sync, path, source))?;
84
85    Ok(temporary)
86}
87
88fn commit(
89    temporary: tempfile::NamedTempFile,
90    parent: &Path,
91    path: &Path,
92    mode: CommitMode,
93) -> Result<()> {
94    let committed = match mode {
95        CommitMode::Create => temporary.persist_noclobber(path),
96        CommitMode::Replace => temporary.persist(path),
97    }
98    .map_err(|error| Error::new(Operation::Rename, path, error.error))?;
99
100    committed
101        .sync_all()
102        .map_err(|source| Error::new(Operation::Sync, path, source))?;
103
104    sync_parent(parent, path)
105}
106
107#[cfg(unix)]
108pub(crate) fn sync_parent(parent: &Path, path: &Path) -> Result<()> {
109    std::fs::File::open(parent)
110        .and_then(|directory| directory.sync_all())
111        .map_err(|source| Error::new(Operation::Sync, path, source))
112}
113
114#[cfg(not(unix))]
115pub(crate) fn sync_parent(_parent: &Path, _path: &Path) -> Result<()> {
116    Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121    use googletest::prelude::*;
122
123    use super::{create, replace};
124    use crate::{directory, file, temporary::Directory};
125
126    #[gtest]
127    fn replace_commits_exact_bytes_without_visible_temporary_entries() -> Result<()> {
128        let directory = Directory::new().or_fail()?;
129        let path = directory.path().join("artifact.txt");
130
131        replace(&path, "first").or_fail()?;
132        replace(&path, "second").or_fail()?;
133
134        verify_that!(file::read_text(&path).or_fail()?, eq("second"))?;
135
136        verify_that!(directory::entries(directory.path()).or_fail()?, len(eq(1)))
137    }
138
139    #[gtest]
140    fn create_commits_once_without_clobbering() -> Result<()> {
141        let directory = Directory::new().or_fail()?;
142        let path = directory.path().join("config.toml");
143
144        create(&path, "first").or_fail()?;
145        let error = create(&path, "second").unwrap_err();
146
147        verify_true!(error.is_already_exists())?;
148        verify_that!(file::read_text(&path).or_fail()?, eq("first"))?;
149
150        verify_that!(directory::entries(directory.path()).or_fail()?, len(eq(1)))
151    }
152}