Skip to main content

greentic_deployer/environment/
atomic_write.rs

1//! Atomic file write helpers for the [`EnvironmentStore`](super::EnvironmentStore).
2//!
3//! The store mutates persistent state through the same pattern everywhere:
4//!
5//! 1. Create a `NamedTempFile` in the **same directory** as the target so the
6//!    final `rename` is intra-filesystem (otherwise rename would fall back to
7//!    copy+unlink and lose atomicity).
8//! 2. Write the bytes; `flush()` + `sync_all()` so the data hits disk.
9//! 3. `persist(target)` atomically renames over the existing target.
10//! 4. On Unix, `fsync()` the parent directory so the rename itself is durable
11//!    across power loss.
12//!
13//! Callers that want to back up the current target before clobbering it should
14//! call [`copy_to_backup`] first.
15
16use std::fs;
17use std::io::Write;
18use std::path::Path;
19
20use chrono::Utc;
21use serde::Serialize;
22use tempfile::NamedTempFile;
23use thiserror::Error;
24
25#[derive(Debug, Error)]
26pub enum AtomicWriteError {
27    #[error("target path has no parent directory: {0}")]
28    NoParent(std::path::PathBuf),
29    #[error("io error on {path}: {source}")]
30    Io {
31        path: std::path::PathBuf,
32        #[source]
33        source: std::io::Error,
34    },
35    #[error("serde_json error on {path}: {source}")]
36    Json {
37        path: std::path::PathBuf,
38        #[source]
39        source: serde_json::Error,
40    },
41    #[error("could not persist temp file over {target}: {source}")]
42    Persist {
43        target: std::path::PathBuf,
44        #[source]
45        source: tempfile::PersistError,
46    },
47    #[error("could not allocate a unique backup name at {0} after {1} attempts")]
48    BackupCollision(std::path::PathBuf, u32),
49}
50
51/// Atomically write `bytes` to `target`, fsyncing the parent directory afterward.
52pub fn atomic_write_bytes(target: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> {
53    let parent = target
54        .parent()
55        .ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
56    fs::create_dir_all(parent).map_err(|e| AtomicWriteError::Io {
57        path: parent.to_path_buf(),
58        source: e,
59    })?;
60    let mut tmp = NamedTempFile::new_in(parent).map_err(|e| AtomicWriteError::Io {
61        path: parent.to_path_buf(),
62        source: e,
63    })?;
64    tmp.write_all(bytes).map_err(|e| AtomicWriteError::Io {
65        path: tmp.path().to_path_buf(),
66        source: e,
67    })?;
68    tmp.flush().map_err(|e| AtomicWriteError::Io {
69        path: tmp.path().to_path_buf(),
70        source: e,
71    })?;
72    tmp.as_file().sync_all().map_err(|e| AtomicWriteError::Io {
73        path: tmp.path().to_path_buf(),
74        source: e,
75    })?;
76    tmp.persist(target).map_err(|e| AtomicWriteError::Persist {
77        target: target.to_path_buf(),
78        source: e,
79    })?;
80    fsync_parent(parent)?;
81    Ok(())
82}
83
84/// Atomically write `value` as pretty-printed JSON (trailing newline) to `target`.
85pub fn atomic_write_json<T: Serialize>(target: &Path, value: &T) -> Result<(), AtomicWriteError> {
86    let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| AtomicWriteError::Json {
87        path: target.to_path_buf(),
88        source: e,
89    })?;
90    bytes.push(b'\n');
91    atomic_write_bytes(target, &bytes)
92}
93
94/// If `target` exists, copy it to `<backup_dir>/<target.file_name>.<rfc3339-utc>.bak`
95/// and return the backup path. If it does not exist, return `Ok(None)`.
96pub fn copy_to_backup(
97    target: &Path,
98    backup_dir: &Path,
99) -> Result<Option<std::path::PathBuf>, AtomicWriteError> {
100    if !target.exists() {
101        return Ok(None);
102    }
103    fs::create_dir_all(backup_dir).map_err(|e| AtomicWriteError::Io {
104        path: backup_dir.to_path_buf(),
105        source: e,
106    })?;
107    let filename = target
108        .file_name()
109        .and_then(|s| s.to_str())
110        .ok_or_else(|| AtomicWriteError::NoParent(target.to_path_buf()))?;
111    // Nanosecond precision so back-to-back mutations under the per-env flock
112    // (which can complete in well under a millisecond) get distinct backup
113    // filenames. Disambiguate with a sequence suffix on the off-chance that
114    // two writes land in the same nanosecond (the OS clock may not provide
115    // ns resolution on all platforms).
116    let stamp = Utc::now().format("%Y%m%dT%H%M%S%.9fZ").to_string();
117    const MAX_ATTEMPTS: u32 = 1024;
118    for attempt in 0..MAX_ATTEMPTS {
119        let candidate = if attempt == 0 {
120            backup_dir.join(format!("{filename}.{stamp}.bak"))
121        } else {
122            backup_dir.join(format!("{filename}.{stamp}.{attempt}.bak"))
123        };
124        // `OpenOptions::create_new` reserves the destination atomically (the
125        // syscall sets `O_CREAT | O_EXCL`), so a concurrent backup with the
126        // same name cannot clobber ours.
127        match fs::OpenOptions::new()
128            .write(true)
129            .create_new(true)
130            .open(&candidate)
131        {
132            Ok(_handle) => {
133                // Drop the empty file we just created so the rest of the copy
134                // does a real overwrite of our reservation rather than failing
135                // on Windows (where `fs::copy` to an open file is allowed,
136                // but cross-platform behavior is cleaner if we close first).
137                drop(_handle);
138                fs::copy(target, &candidate).map_err(|e| AtomicWriteError::Io {
139                    path: candidate.clone(),
140                    source: e,
141                })?;
142                return Ok(Some(candidate));
143            }
144            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
145                // Same nanosecond + same suffix as a previous backup on this
146                // host. Try the next sequence number.
147                continue;
148            }
149            Err(e) => {
150                return Err(AtomicWriteError::Io {
151                    path: candidate,
152                    source: e,
153                });
154            }
155        }
156    }
157    Err(AtomicWriteError::BackupCollision(
158        backup_dir.to_path_buf(),
159        MAX_ATTEMPTS,
160    ))
161}
162
163#[cfg(unix)]
164fn fsync_parent(parent: &Path) -> Result<(), AtomicWriteError> {
165    let dir = fs::File::open(parent).map_err(|e| AtomicWriteError::Io {
166        path: parent.to_path_buf(),
167        source: e,
168    })?;
169    dir.sync_all().map_err(|e| AtomicWriteError::Io {
170        path: parent.to_path_buf(),
171        source: e,
172    })
173}
174
175#[cfg(not(unix))]
176fn fsync_parent(_parent: &Path) -> Result<(), AtomicWriteError> {
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::TempDir;
184
185    #[test]
186    fn write_bytes_round_trip() {
187        let tmp = TempDir::new().unwrap();
188        let target = tmp.path().join("hello.txt");
189        atomic_write_bytes(&target, b"hello world").unwrap();
190        assert_eq!(fs::read(&target).unwrap(), b"hello world");
191    }
192
193    #[test]
194    fn write_json_round_trip() {
195        let tmp = TempDir::new().unwrap();
196        let target = tmp.path().join("doc.json");
197        let value = serde_json::json!({ "k": [1, 2, 3] });
198        atomic_write_json(&target, &value).unwrap();
199        let read: serde_json::Value = serde_json::from_slice(&fs::read(&target).unwrap()).unwrap();
200        assert_eq!(read, value);
201        let raw = fs::read_to_string(&target).unwrap();
202        assert!(raw.ends_with('\n'), "expected trailing newline");
203    }
204
205    #[test]
206    fn write_creates_missing_parent() {
207        let tmp = TempDir::new().unwrap();
208        let target = tmp.path().join("a/b/c/file.json");
209        atomic_write_json(&target, &serde_json::json!({})).unwrap();
210        assert!(target.exists());
211    }
212
213    #[test]
214    fn overwrites_existing_file() {
215        let tmp = TempDir::new().unwrap();
216        let target = tmp.path().join("doc.json");
217        atomic_write_bytes(&target, b"first").unwrap();
218        atomic_write_bytes(&target, b"second").unwrap();
219        assert_eq!(fs::read(&target).unwrap(), b"second");
220    }
221
222    #[test]
223    fn copy_to_backup_no_target() {
224        let tmp = TempDir::new().unwrap();
225        let target = tmp.path().join("never.json");
226        let backup = tmp.path().join("backups");
227        assert!(copy_to_backup(&target, &backup).unwrap().is_none());
228        assert!(!backup.exists());
229    }
230
231    #[test]
232    fn copy_to_backup_creates_timestamped_copy() {
233        let tmp = TempDir::new().unwrap();
234        let target = tmp.path().join("doc.json");
235        atomic_write_bytes(&target, b"v1").unwrap();
236        let backup_dir = tmp.path().join("backups");
237        let backup_path = copy_to_backup(&target, &backup_dir)
238            .unwrap()
239            .expect("backup should be Some when target exists");
240        assert_eq!(fs::read(&backup_path).unwrap(), b"v1");
241        assert!(backup_path.starts_with(&backup_dir));
242        let name = backup_path.file_name().unwrap().to_str().unwrap();
243        assert!(name.starts_with("doc.json."), "got: {name}");
244        assert!(name.ends_with(".bak"), "got: {name}");
245    }
246
247    #[test]
248    fn no_partial_state_if_serialization_succeeds() {
249        let tmp = TempDir::new().unwrap();
250        let target = tmp.path().join("doc.json");
251        // After two writes, only the second should survive — there should be
252        // no orphaned tempfiles left in the parent dir.
253        atomic_write_bytes(&target, b"first").unwrap();
254        atomic_write_bytes(&target, b"second").unwrap();
255        let entries: Vec<_> = fs::read_dir(tmp.path()).unwrap().collect();
256        assert_eq!(entries.len(), 1);
257    }
258}