Skip to main content

agent_first_data/
atomic_file.rs

1//! One atomic file installation, shared by every writer in this crate.
2//!
3//! Installing a file so that a crash can never expose a half-written one is a
4//! fixed sequence — private same-directory temporary file, write, permissions,
5//! fsync the file, atomically install it over the target, fsync the directory
6//! entry — and each step has a way to be got subtly wrong. Writing that
7//! sequence a second time somewhere else is how one copy ends up durable and
8//! the other only looks it: a `rename` that is never followed by a parent
9//! directory fsync returns success while the new directory entry may still be
10//! lost to a power cut.
11//!
12//! So the sequence lives here once, and callers bring only what is genuinely
13//! theirs: the pre-write guard (what may be overwritten is a policy question,
14//! and different writers answer it differently) and the error type. Failures
15//! name the step that failed through [`AtomicError`], whose `Display` is the
16//! tail of a caller's own message — `write: ` + `install \`p\`: …`.
17
18use std::fs::{self, File, OpenOptions};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22/// Atomically replace a regular file, preserving its permissions. On Unix a
23/// new file starts owner-only (`0600`); Windows has no mode bits, so a new file
24/// takes the directory's inherited ACL. Symbolic links and special files are refused; parent
25/// directories must already exist. A directory-sync error reports an uncertain
26/// durable commit through [`AtomicError::commit_uncertain`].
27///
28/// Hard links are not checked. The new file is renamed over the path, so a
29/// target with other hard links is split from them: this path gets the new
30/// bytes and every other link keeps the old file. A caller that needs a
31/// hard-linked target refused writes through
32/// [`DocumentFile`](crate::document::DocumentFile) instead, whose
33/// [`ensure_mutable`](crate::document::DocumentFile::ensure_mutable) guard (run
34/// by `save` and by a replacing `create_atomic`) refuses one on Unix.
35pub 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/// How the finished temporary file becomes the target.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub(crate) enum InstallMode {
60    /// `rename` over the target. Replaces whatever is at the path — including a
61    /// symbolic link, which is replaced rather than followed.
62    Replace,
63    /// `hard_link`, which refuses to install over an existing target. The
64    /// caller distinguishes that refusal by [`AtomicError::target_exists`].
65    NewOnly,
66}
67
68/// What to install, and how.
69pub(crate) struct AtomicInstall<'a> {
70    /// The complete new contents.
71    pub bytes: &'a [u8],
72    /// How to install the finished file.
73    pub mode: InstallMode,
74    /// Permissions to re-apply, normally the replaced file's own.
75    pub permissions: Option<fs::Permissions>,
76    /// Explicit unix permission bits, applied after `permissions`.
77    pub unix_mode: Option<u32>,
78}
79
80impl<'a> AtomicInstall<'a> {
81    /// Replace whatever is at the path, leaving permissions to the platform.
82    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    /// Re-apply these permissions to the new file.
92    pub(crate) fn with_permissions(mut self, permissions: Option<fs::Permissions>) -> Self {
93        self.permissions = permissions;
94        self
95    }
96
97    /// Apply these unix permission bits to the new file.
98    pub(crate) fn with_unix_mode(mut self, unix_mode: Option<u32>) -> Self {
99        self.unix_mode = unix_mode;
100        self
101    }
102
103    /// Install with `hard_link`, refusing an existing target.
104    pub(crate) fn new_only(mut self) -> Self {
105        self.mode = InstallMode::NewOnly;
106        self
107    }
108}
109
110/// The step of an atomic installation that failed.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum AtomicStep {
113    /// Inspecting the target without following links.
114    Guard,
115    /// The target has no parent directory to write the temporary file into.
116    NoParent,
117    /// The target's own file name is not valid UTF-8.
118    NonUtf8Name,
119    /// Creating the private temporary file.
120    Temp,
121    /// Every candidate temporary name was taken.
122    TempExhausted,
123    /// Writing the new bytes.
124    Write,
125    /// Re-applying the replaced file's permissions.
126    PreservePermissions,
127    /// Applying explicitly requested permission bits. Unix only, because the
128    /// bits themselves are: the step does not exist on a platform that has no
129    /// mode to set, and a variant no code can construct is a step a reader
130    /// would look for and never find.
131    #[cfg(unix)]
132    SetPermissions,
133    /// Flushing the temporary file to storage.
134    Sync,
135    /// Installing the temporary file over the target.
136    Install,
137    /// Recording the new directory entry.
138    SyncParent,
139}
140
141/// A failed atomic installation: which step, which path, and the OS error.
142#[derive(Debug)]
143pub struct AtomicError {
144    /// The step that failed.
145    pub step: AtomicStep,
146    /// The path that step was about — the target, its temporary file, or the
147    /// parent directory, depending on the step.
148    pub path: PathBuf,
149    /// The underlying OS error, absent only where the step had none.
150    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    /// Whether this is [`InstallMode::NewOnly`] refusing an existing target,
163    /// which is a caller-visible outcome rather than an I/O fault. Crate-only:
164    /// the public [`write_atomic`] always replaces, so it could never be true
165    /// there.
166    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    /// Whether the target may already carry the new bytes.
175    ///
176    /// Only the final directory fsync can fail after the installation itself
177    /// succeeded: the file is complete and in place, but its survival across a
178    /// crash is unconfirmed. Every other step fails before the install and
179    /// leaves the original untouched.
180    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
216/// The smallest `NAME_MAX` across the platforms this runs on.
217const MAX_NAME_BYTES: usize = 255;
218
219/// Compose a temporary file name that stays within one path component's limit.
220///
221/// The marker, pid, and attempt add about 35 bytes. A target whose own name is
222/// close to `NAME_MAX` would push the composed name past it, and the failure
223/// would surface at install time — after the caller had already done its work,
224/// on a file it opened successfully. Truncating the stem keeps the write
225/// possible; uniqueness still comes from the pid and attempt, with `create_new`
226/// retrying the rare collision.
227fn temp_file_name(file_name: &str, pid: u32, attempt: u32) -> String {
228    let suffix = format!(".afdata.{pid}.{attempt}.tmp");
229    // One byte for the leading dot that hides the temporary file.
230    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
235/// Split a target into the directory the temporary file must share with it and
236/// the target's own name. Sharing the directory is what makes the final install
237/// a rename within one filesystem, which is the atomic operation this relies on.
238fn 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            // Nothing may read the new contents through the temporary name
272            // before the caller's own permissions are applied below.
273            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    // Rust does not expose a portable directory handle on non-unix targets.
298    // The file itself is still synced before its atomic installation.
299    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
328/// Install `request` at `path` atomically.
329///
330/// The caller has already decided that this target may be written; this only
331/// performs the write. Every failure before the install leaves the original
332/// untouched and removes the temporary file. A failure at the final parent
333/// directory fsync — the one [`AtomicError::commit_uncertain`] reports — means
334/// the new file may already be in place without confirmed durability.
335pub(crate) fn install(path: &Path, request: AtomicInstall<'_>) -> Result<(), AtomicError> {
336    install_with_writer(path, request, |file, bytes| file.write_all(bytes))
337}
338
339// The writer seam keeps disk-full, short-write, and interrupted-process
340// regressions at the primitive that owns publication, without exposing a
341// callback or partial-file API to callers.
342fn 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                // The file is installed from here on. Failing to unlink the
360                // temporary link leaves a stray file, but reporting an error
361                // would tell the caller the commit did not happen — and a retry
362                // would then refuse a target that this call in fact created.
363                let _ = fs::remove_file(&temp_path);
364            }
365        }
366        sync_parent(parent)?;
367        Ok(())
368    })();
369    if let Err(error) = &result {
370        // A parent-directory fsync failure comes after the install, where the
371        // temporary name no longer refers to anything to clean up.
372        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}