Skip to main content

release_kit/
snapshot.rs

1//! Saved copies: what a destructive step keeps before it changes
2//! anything.
3//!
4//! A landing that replaces a conflict the operator named saves the bytes
5//! it overwrites, and a setup step that removes or replaces forge state
6//! saves the state it found. Both go to one directory per run under the
7//! state root, `snapshots/<repo>/<timestamp>/`, or `<timestamp>-<n>/` for
8//! a second run in the same second, with `replaced/<path>` for files,
9//! `forge/<step>.json` for forge state, and `refs/` for branch bundles.
10//! A saved copy is a backup, never run state: no later run reads it to
11//! decide anything, and only the operator removes it. It never lives
12//! inside a stage, so cleaning a stage never deletes one.
13//!
14//! SATISFIES landing:ownership-is-elementary
15
16use std::fs;
17use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
18use std::path::{Path, PathBuf};
19
20use crate::applog;
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::digest::Digest;
23use crate::error::RkError;
24
25/// The directory below the state root that holds every snapshot.
26pub const SNAPSHOTS_DIR: &str = "snapshots";
27
28/// The directory below one snapshot that holds replaced files.
29pub const REPLACED_DIR: &str = "replaced";
30
31/// The directory below one snapshot that holds forge state.
32pub const FORGE_DIR: &str = "forge";
33
34/// The directory below one snapshot that holds branch bundles.
35pub const REFS_DIR: &str = "refs";
36
37/// The key naming one target below the snapshot base.
38///
39/// It is the target's directory name and the first twelve hex digits of
40/// the digest of its canonical path, so a person can read it and two
41/// targets with one name stay apart.
42#[must_use]
43pub fn repo_key(canonical_target: &Path) -> String {
44    let digest = Digest::of(canonical_target.display().to_string().as_bytes()).to_string();
45    let leaf = canonical_target.file_name().map_or_else(
46        || "root".to_owned(),
47        |name| name.to_string_lossy().into_owned(),
48    );
49    format!("{leaf}-{}", &digest[..12])
50}
51
52/// The current instant as `YYYYMMDDTHHMMSSZ`, a name every filesystem
53/// takes and that sorts by time.
54#[must_use]
55pub fn timestamp() -> String {
56    applog::now_utc()
57        .chars()
58        .filter(char::is_ascii_alphanumeric)
59        .collect()
60}
61
62/// Claim a directory of this run's own under `runs`, named by `stamp`.
63///
64/// The directory is created exclusively and owner-only, so two runs in one
65/// second never share one. Where `stamp` is taken, the next free name
66/// follows it, `<stamp>-2`, `<stamp>-3`, so the names still sort by time.
67///
68/// # Errors
69///
70/// Any I/O error creating `runs` or the run directory, other than the name
71/// being taken.
72pub fn claim(runs: &Path, stamp: &str) -> std::io::Result<PathBuf> {
73    fs::DirBuilder::new()
74        .recursive(true)
75        .mode(0o700)
76        .create(runs)?;
77    let mut counter = 1_u32;
78    loop {
79        let name = if counter == 1 {
80            stamp.to_owned()
81        } else {
82            format!("{stamp}-{counter}")
83        };
84        let candidate = runs.join(name);
85        match fs::DirBuilder::new().mode(0o700).create(&candidate) {
86            Ok(()) => return Ok(candidate),
87            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => counter += 1,
88            Err(error) => return Err(error),
89        }
90    }
91}
92
93/// One run's snapshot directory, claimed when the run first needs it.
94#[derive(Debug, Clone)]
95pub struct Snapshot {
96    root: PathBuf,
97}
98
99impl Snapshot {
100    /// Claim the snapshot directory for one run against `canonical_target`.
101    ///
102    /// A caller begins a snapshot only at its first destructive step, so a
103    /// run that saves nothing claims nothing.
104    ///
105    /// # Errors
106    ///
107    /// A `prerequisite-unmet` refusal where no state root resolves, because
108    /// a destructive step that cannot save first does not run, and
109    /// [`RkError::Io`] where the directory cannot be created.
110    pub fn begin(canonical_target: &Path) -> Result<Self, RkError> {
111        let Some(state) = applog::state_root() else {
112            return Err(RkError::refusal(
113                Diagnostic::new(
114                    Reason::PrerequisiteUnmet,
115                    "no state root resolves, so nothing can be saved before it is changed, and nothing was written",
116                )
117                .expected("a state root under XDG_STATE_HOME or HOME")
118                .action("set XDG_STATE_HOME or HOME, and run it again")
119                .target_state("unchanged"),
120            ));
121        };
122        let runs = state.join(SNAPSHOTS_DIR).join(repo_key(canonical_target));
123        Ok(Self {
124            root: claim(&runs, &timestamp())?,
125        })
126    }
127
128    /// A snapshot rooted at `root`, for a workspace that names its own.
129    #[must_use]
130    pub const fn at(root: PathBuf) -> Self {
131        Self { root }
132    }
133
134    /// The directory this snapshot writes under.
135    #[must_use]
136    pub fn root(&self) -> &Path {
137        &self.root
138    }
139
140    /// Save `bytes` at `relative` below the snapshot, owner-only, and
141    /// return where. A copy already standing there with the same bytes is
142    /// kept; one with other bytes refuses, because overwriting a backup
143    /// loses the bytes it exists to keep.
144    ///
145    /// # Errors
146    ///
147    /// A `state-drift` refusal for a saved copy with other bytes, and
148    /// [`RkError::Io`] for a write that fails.
149    pub fn save(&self, relative: &Path, bytes: &[u8]) -> Result<PathBuf, RkError> {
150        let path = self.root.join(relative);
151        if let Some(parent) = path.parent() {
152            fs::DirBuilder::new()
153                .recursive(true)
154                .mode(0o700)
155                .create(parent)?;
156        }
157        match fs::read(&path) {
158            Ok(existing) if existing == bytes => return Ok(path),
159            Ok(_) => {
160                return Err(RkError::refusal(
161                    Diagnostic::new(
162                        Reason::StateDrift,
163                        format!(
164                            "a saved copy with other bytes already stands at {}, and nothing was written",
165                            path.display()
166                        ),
167                    )
168                    .expected("no saved copy at that path, or one holding the same bytes")
169                    .action("move the saved copy aside, then run it again")
170                    .target_state("unchanged"),
171                ));
172            }
173            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
174            Err(error) => return Err(error.into()),
175        }
176        crate::atomic::write(&path, bytes)?;
177        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
178        Ok(path)
179    }
180
181    /// Save a replaced file's bytes under `replaced/<destination>`.
182    ///
183    /// # Errors
184    ///
185    /// As [`Self::save`].
186    pub fn save_replaced(&self, destination: &str, bytes: &[u8]) -> Result<PathBuf, RkError> {
187        self.save(&Path::new(REPLACED_DIR).join(destination), bytes)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use std::os::unix::fs::PermissionsExt as _;
194    use std::path::Path;
195
196    use super::{Snapshot, claim, repo_key, timestamp};
197
198    #[test]
199    fn the_repo_key_reads_as_a_name_and_keeps_targets_apart() {
200        let one = repo_key(Path::new("/srv/a/widget"));
201        let two = repo_key(Path::new("/srv/b/widget"));
202        assert!(one.starts_with("widget-"), "{one}");
203        assert_eq!(one.len(), "widget-".len() + 12);
204        assert_ne!(one, two);
205    }
206
207    #[test]
208    fn the_timestamp_is_compact_and_utc() {
209        let stamp = timestamp();
210        assert_eq!(stamp.len(), "20261009T153000Z".len(), "{stamp}");
211        assert!(stamp.ends_with('Z'));
212        assert_eq!(stamp.as_bytes()[8], b'T');
213    }
214
215    /// Two runs in one second each claim a directory of their own, so each
216    /// keeps its copy where a shared directory would refuse the second.
217    #[test]
218    fn two_runs_in_one_second_keep_both_copies() {
219        let dir = tempfile::tempdir().expect("a scratch dir exists");
220        let runs = dir.path().join("snapshots").join("widget-000000000000");
221        let stamp = "20261009T213000Z";
222        let first = Snapshot::at(claim(&runs, stamp).expect("claims"));
223        let second = Snapshot::at(claim(&runs, stamp).expect("claims again"));
224        assert_ne!(first.root(), second.root());
225        assert!(second.root().ends_with("20261009T213000Z-2"));
226        let kept = first.save_replaced("AGENTS.md", b"first\n").expect("saves");
227        let again = second
228            .save_replaced("AGENTS.md", b"second\n")
229            .expect("saves");
230        assert_eq!(std::fs::read(kept).expect("reads"), b"first\n");
231        assert_eq!(std::fs::read(again).expect("reads"), b"second\n");
232        let mode = std::fs::metadata(second.root())
233            .expect("stats")
234            .permissions()
235            .mode();
236        assert_eq!(mode & 0o777, 0o700);
237    }
238
239    /// A saved copy is kept, an identical second save is a no-op, and a
240    /// different one refuses rather than overwriting the backup.
241    #[test]
242    fn a_saved_copy_is_never_overwritten_with_other_bytes() {
243        let dir = tempfile::tempdir().expect("a scratch dir exists");
244        let snapshot = Snapshot::at(dir.path().join("snap"));
245        let saved = snapshot
246            .save_replaced(".github/workflows/release.yml", b"ours\n")
247            .expect("saves");
248        assert_eq!(std::fs::read(&saved).expect("reads"), b"ours\n");
249        assert!(saved.ends_with("replaced/.github/workflows/release.yml"));
250        snapshot
251            .save_replaced(".github/workflows/release.yml", b"ours\n")
252            .expect("the same bytes save again");
253        let refused = snapshot
254            .save_replaced(".github/workflows/release.yml", b"theirs\n")
255            .expect_err("other bytes refuse");
256        assert_eq!(refused.exit_code(), 73);
257        assert_eq!(std::fs::read(&saved).expect("reads"), b"ours\n");
258    }
259}