Skip to main content

nmbrs_workload/edit/
backup.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backup-rotation transaction for workload edits.
5//!
6//! Per SRD-64 §6.5: every workload mutation rotates two
7//! sibling files alongside the workload:
8//!
9//! - `<workload>.bak` — the immediate-previous content,
10//!   written *before* the new content lands on disk.
11//! - `<workload>.bak.prev` — one step further back (the
12//!   pre-previous content). Lets the user recover from
13//!   "the last edit was right but the one before was the
14//!   one I wanted" without reaching for git.
15//!
16//! Two-deep is the policy floor; deeper history belongs in
17//! version control.
18//!
19//! ## Rotation order
20//!
21//! Three steps per edit, all `fs::rename` for atomicity:
22//!
23//! 1. Move `<workload>.bak` → `<workload>.bak.prev`
24//!    (overwriting any existing `.bak.prev`).
25//! 2. Copy `<workload>` → `<workload>.bak`. Copy, not
26//!    rename, because the workload itself has to stay in
27//!    place until step 3 (otherwise readers see a missing
28//!    file mid-edit). On filesystems that support reflinks
29//!    (btrfs, xfs), the copy is essentially free.
30//! 3. Write the new content to `<workload>` via a temp
31//!    file + atomic rename so the workload itself is never
32//!    half-written.
33//!
34//! Failure during step 1 leaves the prior backup pair
35//! untouched. Failure during step 2 leaves a mismatched
36//! pair (`bak.prev` is the old `bak`, `bak` is missing) —
37//! the rollback path restores `bak.prev` → `bak` to keep
38//! the pair consistent. Failure during step 3 leaves the
39//! workload at its pre-edit state with a fresh `bak` that
40//! matches it.
41
42use std::fs;
43use std::io;
44use std::path::{Path, PathBuf};
45
46/// Sibling-file paths for one workload.
47#[derive(Debug, Clone)]
48pub struct BackupPaths {
49    /// The workload file itself.
50    pub workload: PathBuf,
51    /// `<workload>.bak` — most-recent prior content.
52    pub bak: PathBuf,
53    /// `<workload>.bak.prev` — one step further back.
54    pub bak_prev: PathBuf,
55    /// Tempfile used for the atomic write of the new
56    /// content; renamed over `workload` once the buffer is
57    /// flushed.
58    pub temp: PathBuf,
59}
60
61impl BackupPaths {
62    /// Derive the sibling-file paths for `workload_path`.
63    /// Convention: `<file>.bak`, `<file>.bak.prev`, and
64    /// `<file>.tmp` all live in the same directory as the
65    /// workload, so the renames stay within one filesystem
66    /// (atomic).
67    pub fn for_workload(workload_path: &Path) -> Self {
68        let workload = workload_path.to_path_buf();
69        let bak = path_with_suffix(&workload, ".bak");
70        let bak_prev = path_with_suffix(&workload, ".bak.prev");
71        let temp = path_with_suffix(&workload, ".tmp");
72        Self {
73            workload,
74            bak,
75            bak_prev,
76            temp,
77        }
78    }
79}
80
81fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
82    let mut s = p.as_os_str().to_owned();
83    s.push(suffix);
84    PathBuf::from(s)
85}
86
87/// Run the backup rotation: rotate `.bak` → `.bak.prev`,
88/// copy `workload` → `.bak`. Caller subsequently writes the
89/// new content to `temp` and atomically renames it over
90/// `workload`. See [`commit_temp`].
91///
92/// Returns the [`BackupPaths`] so the caller doesn't have
93/// to re-derive them.
94pub fn rotate(workload_path: &Path) -> io::Result<BackupPaths> {
95    let paths = BackupPaths::for_workload(workload_path);
96    if !paths.workload.exists() {
97        return Err(io::Error::new(
98            io::ErrorKind::NotFound,
99            format!(
100                "workload '{}' does not exist; cannot create backup",
101                paths.workload.display()
102            ),
103        ));
104    }
105
106    // Step 1: bak → bak.prev. Either may be absent on the
107    // first edit; absence is fine, we just skip.
108    if paths.bak.exists() {
109        // Remove any stale bak.prev so the rename succeeds
110        // on Windows (which fails rename when destination
111        // exists; Unix overwrites silently).
112        let _ = fs::remove_file(&paths.bak_prev);
113        fs::rename(&paths.bak, &paths.bak_prev).map_err(|e| {
114            io::Error::new(
115                e.kind(),
116                format!(
117                    "backup rotate {} → {}: {e}",
118                    paths.bak.display(),
119                    paths.bak_prev.display()
120                ),
121            )
122        })?;
123    }
124
125    // Step 2: workload → bak (copy, not rename — the
126    // workload has to stay in place for callers reading it
127    // mid-edit).
128    fs::copy(&paths.workload, &paths.bak).map_err(|e| {
129        io::Error::new(
130            e.kind(),
131            format!(
132                "backup copy {} → {}: {e}",
133                paths.workload.display(),
134                paths.bak.display()
135            ),
136        )
137    })?;
138
139    Ok(paths)
140}
141
142/// Atomically replace the workload with the contents of
143/// `temp`. Caller has written the new content to `temp`;
144/// this rename promotes it to the workload's path. After
145/// this returns, `<workload>.bak` holds the pre-edit
146/// content and the workload itself holds the post-edit
147/// content.
148pub fn commit_temp(paths: &BackupPaths) -> io::Result<()> {
149    fs::rename(&paths.temp, &paths.workload).map_err(|e| {
150        io::Error::new(
151            e.kind(),
152            format!(
153                "commit {} → {}: {e}",
154                paths.temp.display(),
155                paths.workload.display()
156            ),
157        )
158    })
159}
160
161/// Roll back a partially-applied rotation. Used when the
162/// in-memory mutation step fails (or its post-write parse
163/// fails). Restores the on-disk state to what it was
164/// before [`rotate`] ran:
165///
166/// - The workload itself wasn't touched by `rotate` (we
167///   copied, didn't move), so it's already correct.
168/// - `<workload>.bak` was overwritten by the copy from the
169///   workload — that's a no-op semantically (the bak now
170///   matches the workload, same as it would after a clean
171///   commit on the same content). To preserve the
172///   "<workload>.bak holds the pre-edit content" invariant
173///   strictly, we restore the previous `.bak` from
174///   `.bak.prev` (which was the prior pre-edit content
175///   before the rotate).
176/// - `<workload>.bak.prev` is restored to whatever it was
177///   before the rotate — but we don't have that; the
178///   rotation overwrote it. Best effort: leave the current
179///   `.bak.prev` as the recovery point.
180///
181/// In short: roll back the bak↔bak.prev swap so the
182/// invariant "<workload>.bak == content prior to the most
183/// recent successful edit" holds.
184pub fn rollback(paths: &BackupPaths) -> io::Result<()> {
185    // If the temp file exists from an aborted write, drop it.
186    let _ = fs::remove_file(&paths.temp);
187
188    // Reverse step 1 — bak.prev → bak — if a prev exists.
189    // This restores the .bak to what it was before the
190    // (failed) edit kicked off.
191    if paths.bak_prev.exists() {
192        let _ = fs::remove_file(&paths.bak);
193        fs::rename(&paths.bak_prev, &paths.bak).map_err(|e| {
194            io::Error::new(
195                e.kind(),
196                format!(
197                    "backup rollback {} → {}: {e}",
198                    paths.bak_prev.display(),
199                    paths.bak.display()
200                ),
201            )
202        })?;
203    } else {
204        // No prev — first edit. Drop the new bak so we
205        // return to the no-history state.
206        let _ = fs::remove_file(&paths.bak);
207    }
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::sync::atomic::{AtomicUsize, Ordering};
215
216    static COUNTER: AtomicUsize = AtomicUsize::new(0);
217    fn fresh_dir(label: &str) -> PathBuf {
218        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
219        let p = std::env::temp_dir().join(format!(
220            "nmbrs-edit-backup-{label}-{}-{n}",
221            std::process::id(),
222        ));
223        let _ = fs::remove_dir_all(&p);
224        fs::create_dir_all(&p).unwrap();
225        p
226    }
227
228    #[test]
229    fn first_edit_creates_bak_only() {
230        let dir = fresh_dir("first_edit");
231        let workload = dir.join("w.yaml");
232        fs::write(&workload, b"v1\n").unwrap();
233        let paths = rotate(&workload).expect("rotate");
234        assert!(paths.bak.exists(), ".bak should exist");
235        assert!(
236            !paths.bak_prev.exists(),
237            ".bak.prev should not exist on first edit"
238        );
239        assert_eq!(fs::read(&paths.bak).unwrap(), b"v1\n");
240    }
241
242    #[test]
243    fn second_edit_promotes_bak_to_bak_prev() {
244        let dir = fresh_dir("second_edit");
245        let workload = dir.join("w.yaml");
246        fs::write(&workload, b"v1\n").unwrap();
247        let paths = rotate(&workload).expect("rotate v1");
248        // Simulate a successful first edit: workload now
249        // holds v2, .bak holds v1.
250        fs::write(&workload, b"v2\n").unwrap();
251        // Second edit: rotate again with workload=v2.
252        let paths2 = rotate(&workload).expect("rotate v2");
253        assert!(paths2.bak.exists());
254        assert!(paths2.bak_prev.exists());
255        assert_eq!(
256            fs::read(&paths2.bak).unwrap(),
257            b"v2\n",
258            ".bak should hold the just-prior content (v2)"
259        );
260        assert_eq!(
261            fs::read(&paths2.bak_prev).unwrap(),
262            b"v1\n",
263            ".bak.prev should hold the one-before-prior content (v1)"
264        );
265        // Original BackupPaths still consistent.
266        let _ = paths;
267    }
268
269    #[test]
270    fn third_edit_drops_oldest() {
271        let dir = fresh_dir("third_edit");
272        let workload = dir.join("w.yaml");
273        fs::write(&workload, b"v1\n").unwrap();
274        let _ = rotate(&workload).expect("rotate v1");
275        fs::write(&workload, b"v2\n").unwrap();
276        let _ = rotate(&workload).expect("rotate v2");
277        fs::write(&workload, b"v3\n").unwrap();
278        let paths = rotate(&workload).expect("rotate v3");
279        // Two-deep: .bak=v3 (just-prior), .bak.prev=v2.
280        // The original v1 is gone.
281        assert_eq!(fs::read(&paths.bak).unwrap(), b"v3\n");
282        assert_eq!(fs::read(&paths.bak_prev).unwrap(), b"v2\n");
283    }
284
285    #[test]
286    fn commit_temp_renames_atomically() {
287        let dir = fresh_dir("commit_temp");
288        let workload = dir.join("w.yaml");
289        fs::write(&workload, b"v1\n").unwrap();
290        let paths = rotate(&workload).expect("rotate");
291        fs::write(&paths.temp, b"v2 new content\n").unwrap();
292        commit_temp(&paths).expect("commit");
293        assert_eq!(fs::read(&workload).unwrap(), b"v2 new content\n");
294        assert!(!paths.temp.exists(), "temp consumed by rename");
295        assert_eq!(
296            fs::read(&paths.bak).unwrap(),
297            b"v1\n",
298            ".bak still holds pre-edit content"
299        );
300    }
301
302    #[test]
303    fn rollback_after_first_edit_restores_no_history_state() {
304        let dir = fresh_dir("rollback_first");
305        let workload = dir.join("w.yaml");
306        fs::write(&workload, b"v1\n").unwrap();
307        let paths = rotate(&workload).expect("rotate");
308        // Simulate the mutation step failing — caller never
309        // wrote temp, never committed. Roll back.
310        rollback(&paths).expect("rollback");
311        assert!(
312            !paths.bak.exists(),
313            ".bak should be dropped on rollback of first edit"
314        );
315        assert!(!paths.bak_prev.exists());
316        assert_eq!(
317            fs::read(&workload).unwrap(),
318            b"v1\n",
319            "workload itself untouched"
320        );
321    }
322
323    #[test]
324    fn rollback_after_second_edit_restores_pre_rotate_state() {
325        let dir = fresh_dir("rollback_second");
326        let workload = dir.join("w.yaml");
327        fs::write(&workload, b"v1\n").unwrap();
328        let _ = rotate(&workload).expect("first rotate");
329        fs::write(&workload, b"v2\n").unwrap();
330        let paths2 = rotate(&workload).expect("second rotate");
331        // Pre-rollback state: .bak=v2, .bak.prev=v1.
332        // Roll back the second rotate. Expected post-state:
333        // .bak=v1 (the original pre-history backup),
334        // .bak.prev gone.
335        rollback(&paths2).expect("rollback");
336        assert_eq!(
337            fs::read(&paths2.bak).unwrap(),
338            b"v1\n",
339            ".bak restored from .bak.prev"
340        );
341        assert!(!paths2.bak_prev.exists(), ".bak.prev consumed by rollback");
342        assert_eq!(
343            fs::read(&workload).unwrap(),
344            b"v2\n",
345            "workload still at v2 (rollback doesn't touch workload — that's the temp's job)"
346        );
347    }
348
349    #[test]
350    fn rotate_errors_when_workload_missing() {
351        let dir = fresh_dir("missing_workload");
352        let workload = dir.join("missing.yaml");
353        let err = rotate(&workload).unwrap_err();
354        assert_eq!(err.kind(), io::ErrorKind::NotFound);
355        assert!(err.to_string().contains("does not exist"), "got: {err}");
356    }
357}