Skip to main content

shep_core/
atomic_file.rs

1//! The atomic file replace, its first step and its last
2//!
3//! Stage a fresh file beside the real one, write it, `fsync` it, `rename`
4//! it over the target, then `fsync` the directory the rename landed in. A
5//! reader sees the whole old file or the whole new one, never a fragment.
6//! [`create_staging_file`] is the first step, [`sync_dir`] the last, and
7//! the middle stays with each store, which maps its failures onto its own
8//! error type.
9//!
10//! [`sync_dir`] makes the *rename* durable, which the temp file's own
11//! `fsync` does not. On unix, and there only where the filesystem
12//! implements the flush: it is a no-op on Windows, and it answers `Ok` to
13//! the `EINVAL` some FUSE and network mounts return instead of flushing.
14
15use std::path::Path;
16
17// `$SHEP_HOME` is already `0700`, but `shep.toml` and `dogs.toml` hold
18// webhook URLs with a bearer token in the path, and a `tar` or `cp -p` of
19// them carries this mode somewhere no directory mode follows.
20/// Mode a file under `$SHEP_HOME` is created with: owner read/write,
21/// nobody else.
22///
23/// # Platforms
24///
25/// Unix only. On Windows a file inherits the ACL of the directory it lands
26/// in.
27pub const OWNER_ONLY_FILE_MODE: u32 = 0o600;
28
29// No mode parameter, because a caller that wanted a looser one would be
30// wrong: every file here holds a credential or an `env` value.
31//
32// The unique middle is not tidiness. Two writers sharing a fixed
33// `<path>.tmp` had one `rename` consume the other's staging file.
34//
35// `tempfile_in` joins a separator onto `parent`, so `../evil` escapes the
36// one directory this is contracted to stay inside.
37/// Creates the staging file a store is rewritten through, in `parent` so
38/// the later `rename` stays within one filesystem.
39///
40/// `prefix` and `suffix` bracket a unique middle `tempfile` picks, and
41/// neither may contain a path separator. The file is created
42/// [`OWNER_ONLY_FILE_MODE`] on unix at the `open` itself, never by a later
43/// `chmod`, so it is never briefly wider. It carries no mode on Windows.
44///
45/// The caller writes it, `sync_all`s it, and `persist`s it over the real
46/// file.
47///
48/// # Errors
49/// - [`std::io::ErrorKind::InvalidInput`] when `prefix` or `suffix`
50///   contains `/` or `\`, both refused on both platforms.
51/// - Otherwise `parent` is missing or unwritable, or `tempfile` ran out of
52///   attempts at a unique name.
53pub fn create_staging_file(
54    parent: &Path,
55    prefix: &str,
56    suffix: &str,
57) -> std::io::Result<tempfile::NamedTempFile> {
58    for (label, part) in [("prefix", prefix), ("suffix", suffix)] {
59        if part.contains(['/', '\\']) {
60            return Err(std::io::Error::new(
61                std::io::ErrorKind::InvalidInput,
62                format!("staging file {label} must not contain a path separator: {part:?}"),
63            ));
64        }
65    }
66
67    let mut builder = tempfile::Builder::new();
68    builder.prefix(prefix).suffix(suffix);
69
70    #[cfg(unix)]
71    {
72        use std::os::unix::fs::PermissionsExt as _;
73        builder.permissions(std::fs::Permissions::from_mode(OWNER_ONLY_FILE_MODE));
74    }
75
76    builder.tempfile_in(parent)
77}
78
79// The two flushes answer different questions and it is easy to buy one
80// believing you bought both. `sync_all` on the staging file flushes its
81// CONTENTS; the entry the rename creates is a change to the parent
82// DIRECTORY, which sits in the page cache until that directory is flushed
83// too. Lose power in between and the data survives with nothing pointing
84// at it. A crash is not that case: a completed `rename(2)` is visible to
85// every later process whether or not anything was flushed, so it takes an
86// unclean shutdown (power cut, kernel panic, hypervisor reset) to undo one,
87// and what comes back is then the old file or the new one, never a
88// fragment. The muster roll needs the difference most, its whole job being
89// read back after a reboot.
90//
91// Why the two arms differ, which is not a caller's question (IR-31).
92//
93// UNIX. `fsync` on a directory descriptor is the portable way to flush the
94// entry a rename created, and `EINVAL` is tolerated because POSIX lets
95// `fsync` answer it when the implementation has no synchronized I/O to
96// perform for the file it was handed. Some FUSE and network mounts answer
97// exactly that for a directory, and reporting it as a failed write would
98// break writes that do land, on hosts where they land today. Every other
99// error propagates: a helper that swallowed `EIO` could never tell a
100// caller that the durability it asked for did not happen.
101//
102// WINDOWS. There is no call to make. `File::open` on a directory fails
103// outright unless the handle carries `FILE_FLAG_BACKUP_SEMANTICS`, which
104// `std` does not pass, so the unix arm would not even compile into
105// something runnable. NTFS journals metadata operations, which keeps the
106// filesystem CONSISTENT across a crash, and that is a weaker promise than
107// the unix arm makes: `MoveFileEx` without `MOVEFILE_WRITE_THROUGH` does
108// not force the rename out, so a power cut can still lose it. Closing that
109// would mean reaching past `std` for a directory handle, or a
110// write-through rename in place of `NamedTempFile::persist`. Neither is
111// free and neither is done here, so the honest position is that the
112// guarantee below is a unix one.
113/// Flushes `dir`'s own metadata, making renames into it durable.
114///
115/// Call it after the `rename` that installs a staged file, not before: it
116/// is the directory entry created by that rename that needs to reach the
117/// disk. Callers that skip it keep the atomicity guarantee (a reader sees
118/// the old file or the new one, never a fragment) and lose only the
119/// durability one, and only to a power cut.
120///
121/// # Platforms
122///
123/// Unix only. On Windows this is a no-op that answers `Ok` without
124/// touching `dir`, so a rename there is as durable as NTFS makes it and no
125/// more. Callers get the same API on both and a weaker guarantee on one.
126///
127/// # Errors
128///
129/// - [`std::io::Error`] when `dir` could not be opened, or when flushing
130///   it failed for a reason the filesystem could act on. `EINVAL` is not
131///   one of them: it reads as "this filesystem has no such step" and the
132///   write stands. Never returns an error on Windows.
133pub fn sync_dir(dir: &Path) -> std::io::Result<()> {
134    #[cfg(unix)]
135    {
136        match std::fs::File::open(dir)?.sync_all() {
137            Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
138            other => other,
139        }
140    }
141    #[cfg(not(unix))]
142    {
143        let _ = dir;
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    /// fails if the file lands outside `parent`, where the later `rename`
153    /// would cross a filesystem and stop being atomic.
154    #[test]
155    fn the_staging_file_lands_in_the_parent_it_was_given() {
156        let dir = tempfile::tempdir().unwrap();
157        let tmp = create_staging_file(dir.path(), "kv", ".tmp").unwrap();
158        assert_eq!(tmp.path().parent(), Some(dir.path()));
159    }
160
161    /// fails if either part is dropped or swapped on the way to `tempfile`.
162    #[test]
163    fn the_name_carries_the_prefix_and_the_suffix() {
164        let dir = tempfile::tempdir().unwrap();
165        let tmp = create_staging_file(dir.path(), "barks", ".tmp").unwrap();
166
167        let name = tmp.path().file_name().unwrap().to_str().unwrap();
168        assert!(name.starts_with("barks"), "{name}");
169        assert!(name.ends_with(".tmp"), "{name}");
170        assert!(name.len() > "barks.tmp".len(), "no unique middle: {name}");
171    }
172
173    /// fails if a separator reaches `tempfile`. Both spellings, both
174    /// platforms, so an argument cannot be legal on one and an escape on
175    /// the other.
176    #[test]
177    fn a_path_separator_is_refused_in_either_argument() {
178        let dir = tempfile::tempdir().unwrap();
179
180        for (prefix, suffix) in [
181            ("../escape", ".tmp"),
182            ("kv", "/etc/passwd"),
183            ("..\\escape", ".tmp"),
184            ("kv", "\\tmp"),
185        ] {
186            let err = create_staging_file(dir.path(), prefix, suffix)
187                .expect_err("a separator must not reach tempfile");
188            assert_eq!(
189                err.kind(),
190                std::io::ErrorKind::InvalidInput,
191                "{prefix:?} {suffix:?}: {err:?}"
192            );
193        }
194    }
195
196    #[test]
197    fn sync_dir_accepts_a_real_directory() {
198        let dir = tempfile::tempdir().unwrap();
199        // The Windows arm returns `Ok` without looking at the path, so this
200        // only has teeth on unix -- which is the only place it has a job.
201        sync_dir(dir.path()).unwrap();
202    }
203
204    #[test]
205    #[cfg(unix)]
206    fn sync_dir_reports_a_directory_that_is_not_there() {
207        let dir = tempfile::tempdir().unwrap();
208        let missing = dir.path().join("never-created");
209
210        // Guards the `EINVAL` arm above from widening into "ignore every
211        // error": a `sync_dir` that answered `Ok` to everything would pass
212        // the test above and leave a caller unable to learn that the flush
213        // it asked for never happened.
214        let err = sync_dir(&missing).unwrap_err();
215        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "{err:?}");
216    }
217}