1use 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
25pub const SNAPSHOTS_DIR: &str = "snapshots";
27
28pub const REPLACED_DIR: &str = "replaced";
30
31pub const FORGE_DIR: &str = "forge";
33
34pub const REFS_DIR: &str = "refs";
36
37#[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#[must_use]
55pub fn timestamp() -> String {
56 applog::now_utc()
57 .chars()
58 .filter(char::is_ascii_alphanumeric)
59 .collect()
60}
61
62pub 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#[derive(Debug, Clone)]
95pub struct Snapshot {
96 root: PathBuf,
97}
98
99impl Snapshot {
100 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, ×tamp())?,
125 })
126 }
127
128 #[must_use]
130 pub const fn at(root: PathBuf) -> Self {
131 Self { root }
132 }
133
134 #[must_use]
136 pub fn root(&self) -> &Path {
137 &self.root
138 }
139
140 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 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 #[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 #[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}