Skip to main content

shep_core/
atomic_file.rs

1//! The atomic file replace.
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//! [`write_json`] is all of it for a store holding one serializable value;
7//! a store that writes its own bytes stages with [`create_staging_file`]
8//! and finishes with [`publish`].
9//!
10//! [`sync_dir`], which [`publish`] calls last, makes the rename durable,
11//! where the temp file's own `fsync` does not, on unix only, and only
12//! where the filesystem implements the flush.
13
14use std::path::Path;
15
16// `$SHEP_HOME` is already `0700`, but `shep.toml` and `dogs.toml` hold
17// webhook URLs with a bearer token in the path, and a `tar` or `cp -p` of
18// them carries this mode somewhere no directory mode follows.
19/// Mode a file under `$SHEP_HOME` is created with: owner read/write,
20/// nobody else.
21///
22/// # Platforms
23///
24/// Unix only. On Windows a file inherits the ACL of the directory it lands
25/// in.
26pub const OWNER_ONLY_FILE_MODE: u32 = 0o600;
27
28// No mode parameter: everything staged here holds a credential or an
29// `env` value, so one fixed mode is always right. Prefix and suffix
30// refuse separators because `tempfile_in` joins them onto `parent`, and
31// `../evil` would escape the directory this is contracted to stay inside.
32/// Creates the staging file a store is rewritten through, in `parent` so
33/// the later `rename` stays within one filesystem.
34///
35/// `prefix` and `suffix` bracket a unique middle `tempfile` picks: two
36/// concurrent writers never share a name. Neither may contain a path
37/// separator. Created [`OWNER_ONLY_FILE_MODE`] on unix at the `open`
38/// itself, never by a later `chmod`. Carries no mode on Windows.
39///
40/// # Errors
41/// - [`std::io::ErrorKind::InvalidInput`] if `prefix` or `suffix` contains `/` or `\`.
42/// - Otherwise `parent` is missing or unwritable, or `tempfile` ran out of unique names.
43pub fn create_staging_file(
44    parent: &Path,
45    prefix: &str,
46    suffix: &str,
47) -> std::io::Result<tempfile::NamedTempFile> {
48    for (label, part) in [("prefix", prefix), ("suffix", suffix)] {
49        if part.contains(['/', '\\']) {
50            return Err(std::io::Error::new(
51                std::io::ErrorKind::InvalidInput,
52                format!("staging file {label} must not contain a path separator: {part:?}"),
53            ));
54        }
55    }
56
57    let mut builder = tempfile::Builder::new();
58    builder.prefix(prefix).suffix(suffix);
59
60    #[cfg(unix)]
61    {
62        use std::os::unix::fs::PermissionsExt as _;
63        builder.permissions(std::fs::Permissions::from_mode(OWNER_ONLY_FILE_MODE));
64    }
65
66    builder.tempfile_in(parent)
67}
68
69// `sync_all` on the staged file flushes its contents; the rename's
70// directory entry is a separate change to the parent directory, which
71// this flushes. Only an unclean shutdown can lose an entry a completed
72// `rename` already made visible to every later process.
73/// Flushes `dir`'s own metadata, making renames into it durable.
74///
75/// Call after the `rename` that installs a staged file: the directory
76/// entry it created needs a separate flush to reach disk. Skipping this
77/// keeps the atomicity guarantee and loses only durability, to a power cut.
78///
79/// # Platforms
80/// Unix only. A no-op on Windows: as durable as NTFS makes it.
81///
82/// # Errors
83/// - [`std::io::Error`] if `dir` could not be opened or flushed. `EINVAL`
84///   is tolerated as "no such step"; never errors on Windows.
85pub fn sync_dir(dir: &Path) -> std::io::Result<()> {
86    #[cfg(unix)]
87    {
88        match std::fs::File::open(dir)?.sync_all() {
89            Err(err) if err.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
90            other => other,
91        }
92    }
93    #[cfg(not(unix))]
94    {
95        let _ = dir;
96        Ok(())
97    }
98}
99
100/// Installs `tmp` at `path`, replacing whatever was there.
101///
102/// Returns only once both the contents and the rename that published them
103/// have reached disk.
104///
105/// # Errors
106/// - [`std::io::Error`] if the `fsync`, the rename, or the directory flush
107///   failed. `path` keeps its old contents unless the rename succeeded.
108pub fn publish(tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
109    tmp.as_file().sync_all()?;
110
111    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
112    // inside the error and its `Drop` removes the staging file, so a failed
113    // replace does not leave one behind.
114    tmp.persist(path).map_err(|err| err.error)?;
115    sync_dir(path.parent().unwrap_or_else(|| Path::new(".")))
116}
117
118/// Replaces `path` with `value` as pretty-printed JSON and one trailing
119/// newline, atomically and durably.
120///
121/// The staging file lands beside `path` under `prefix`; see
122/// [`create_staging_file`] for what a prefix may hold. `path` is left as it
123/// was unless the whole value serialized and reached disk.
124///
125/// # Errors
126/// - [`std::io::ErrorKind::InvalidInput`] if `prefix` holds `/` or `\`.
127/// - [`std::io::Error`] carrying a `serde_json::Error` if `value` would not
128///   serialize, or reporting a failed stage, write, `fsync` or rename.
129pub fn write_json<T>(path: &Path, prefix: &str, value: &T) -> std::io::Result<()>
130where
131    T: serde::Serialize + ?Sized,
132{
133    use std::io::Write as _;
134
135    let parent = path.parent().unwrap_or_else(|| Path::new("."));
136    let mut tmp = create_staging_file(parent, prefix, ".tmp")?;
137
138    let json = serde_json::to_string_pretty(value).map_err(std::io::Error::other)?;
139    tmp.write_all(json.as_bytes())?;
140    tmp.write_all(b"\n")?;
141
142    publish(tmp, path)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    /// fails if the file lands outside `parent`, where the later `rename`
150    /// would cross a filesystem and stop being atomic.
151    #[test]
152    fn the_staging_file_lands_in_the_parent_it_was_given() {
153        let dir = tempfile::tempdir().unwrap();
154        let tmp = create_staging_file(dir.path(), "kv", ".tmp").unwrap();
155        assert_eq!(tmp.path().parent(), Some(dir.path()));
156    }
157
158    /// fails if either part is dropped or swapped on the way to `tempfile`.
159    #[test]
160    fn the_name_carries_the_prefix_and_the_suffix() {
161        let dir = tempfile::tempdir().unwrap();
162        let tmp = create_staging_file(dir.path(), "barks", ".tmp").unwrap();
163
164        let name = tmp.path().file_name().unwrap().to_str().unwrap();
165        assert!(name.starts_with("barks"), "{name}");
166        assert!(name.ends_with(".tmp"), "{name}");
167        assert!(name.len() > "barks.tmp".len(), "no unique middle: {name}");
168    }
169
170    /// fails if a separator reaches `tempfile`. Both spellings, both
171    /// platforms, so an argument cannot be legal on one and an escape on
172    /// the other.
173    #[test]
174    fn a_path_separator_is_refused_in_either_argument() {
175        let dir = tempfile::tempdir().unwrap();
176
177        for (prefix, suffix) in [
178            ("../escape", ".tmp"),
179            ("kv", "/etc/passwd"),
180            ("..\\escape", ".tmp"),
181            ("kv", "\\tmp"),
182        ] {
183            let err = create_staging_file(dir.path(), prefix, suffix)
184                .expect_err("a separator must not reach tempfile");
185            assert_eq!(
186                err.kind(),
187                std::io::ErrorKind::InvalidInput,
188                "{prefix:?} {suffix:?}: {err:?}"
189            );
190        }
191    }
192
193    /// fails if a refused rename leaves a staging file in `$SHEP_HOME`, the
194    /// claim `publish`'s own comment makes about `persist`.
195    #[test]
196    fn a_refused_rename_takes_the_staging_file_with_it() {
197        let dir = tempfile::tempdir().unwrap();
198        let occupied = dir.path().join("a-directory");
199        std::fs::create_dir(&occupied).unwrap();
200
201        let tmp = create_staging_file(dir.path(), "kv", ".tmp").unwrap();
202        publish(tmp, &occupied).expect_err("a rename over a directory must fail");
203
204        assert!(occupied.is_dir(), "the target was replaced anyway");
205        assert_eq!(entry_names(dir.path()), vec!["a-directory"]);
206    }
207
208    /// fails if the trailing newline or the pretty printing is dropped: four
209    /// stores' on-disk bytes are this exact shape.
210    #[test]
211    fn write_json_writes_pretty_json_under_one_trailing_newline() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("store.json");
214        let value = std::collections::BTreeMap::from([("one", 1), ("two", 2)]);
215
216        write_json(&path, "kv", &value).unwrap();
217
218        assert_eq!(
219            std::fs::read_to_string(&path).unwrap(),
220            "{\n  \"one\": 1,\n  \"two\": 2\n}\n"
221        );
222        assert_eq!(entry_names(dir.path()), vec!["store.json"]);
223    }
224
225    /// fails if `write_json` stops routing its prefix through
226    /// `create_staging_file`, where the separator check lives.
227    #[test]
228    fn write_json_refuses_a_prefix_holding_a_separator() {
229        let dir = tempfile::tempdir().unwrap();
230        let err = write_json(&dir.path().join("store.json"), "../escape", &1)
231            .expect_err("a separator must not reach tempfile");
232        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "{err:?}");
233    }
234
235    /// Every entry in `dir`, sorted, so a leftover staging file shows up as a
236    /// mismatch naming itself.
237    fn entry_names(dir: &Path) -> Vec<String> {
238        let mut names: Vec<String> = std::fs::read_dir(dir)
239            .unwrap()
240            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
241            .collect();
242        names.sort();
243        names
244    }
245
246    #[test]
247    fn sync_dir_accepts_a_real_directory() {
248        let dir = tempfile::tempdir().unwrap();
249        // Windows returns `Ok` unconditionally, so this only has teeth on unix.
250        sync_dir(dir.path()).unwrap();
251    }
252
253    #[test]
254    #[cfg(unix)]
255    fn sync_dir_reports_a_directory_that_is_not_there() {
256        let dir = tempfile::tempdir().unwrap();
257        let missing = dir.path().join("never-created");
258
259        // Guards the `EINVAL` tolerance from widening into swallowing every error.
260        let err = sync_dir(&missing).unwrap_err();
261        assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "{err:?}");
262    }
263}