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}