git_policy_cli/subvolume/
subvolume_journal.rs1use crate::iso_time::{format_iso_milliseconds, now_iso};
19use crate::subvolume_error::MigrateError;
20use serde_json::{Map, Value};
21use std::collections::BTreeMap;
22use std::io::Write;
23use std::os::unix::fs::OpenOptionsExt;
24use std::path::{Path, PathBuf};
25
26const SCHEMA: u64 = 1;
28
29const PRIVATE_FILE_MODE: u32 = 0o600;
31
32const TEMPORARY_SUFFIX: &str = ".tmp";
35
36fn is_leftover_name(candidate: &[u8], journal_name: &[u8]) -> bool {
47 let Some(after_name) = candidate.strip_prefix(journal_name) else { return false };
48 let Some(rest) = after_name.strip_prefix(TEMPORARY_SUFFIX.as_bytes()) else { return false };
49 if rest.is_empty() {
50 return true;
51 }
52 let Some(numbers) = rest.strip_prefix(b".") else { return false };
53 let mut parts = numbers.split(|byte: &u8| { return *byte == b'.'; });
54 let numeric = |part: Option<&[u8]>| -> bool { return part.is_some_and(|digits: &[u8]| { return !digits.is_empty() && digits.iter().all(u8::is_ascii_digit); }); };
55 let process: Option<&[u8]> = parts.next();
56 let clock: Option<&[u8]> = parts.next();
57 return numeric(process) && numeric(clock) && parts.next().is_none();
58}
59
60fn write_exclusively(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
71 let mut file: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(PRIVATE_FILE_MODE).open(path)?;
72 let written: std::io::Result<()> = file.write_all(bytes).and_then(|()| { return file.sync_all(); });
73 if let Err(error) = written {
74 let _removed: std::io::Result<()> = std::fs::remove_file(path);
75 return Err(error);
76 }
77 return Ok(());
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct Journal {
92 path: PathBuf,
94 started: String,
96 states: BTreeMap<String, String>,
98 times: BTreeMap<String, i64>,
100 persisted: bool,
102}
103
104impl Journal {
105 pub fn load(path: &Path) -> Result<Journal, MigrateError> {
116 let text: String = match std::fs::read_to_string(path) {
117 Ok(found) => found,
118 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
119 return Ok(Journal { path: path.to_path_buf(), started: now_iso(), states: BTreeMap::new(), times: BTreeMap::new(), persisted: false });
120 }
121 Err(error) => return Err(MigrateError::io("read the journal", path, &error)),
122 };
123 let broken = |why: &str| { return MigrateError::Journal(format!("the journal {} {why}; read it, and delete it only if no migration is in progress", path.display())); };
124 let value: Value = serde_json::from_str(text.as_str()).map_err(|error: serde_json::Error| { return broken(format!("is not valid JSON ({error})").as_str()); })?;
125 if value.get("schema").and_then(Value::as_u64) != Some(SCHEMA) {
126 return Err(broken("has an unknown schema version"));
127 }
128 let started: String = value.get("started").and_then(Value::as_str).ok_or_else(|| { return broken("has no start time"); })?.to_string();
129 let steps: &Map<String, Value> = value.get("steps").and_then(Value::as_object).ok_or_else(|| { return broken("has no steps"); })?;
130 let mut states: BTreeMap<String, String> = BTreeMap::new();
131 let mut times: BTreeMap<String, i64> = BTreeMap::new();
132 for (name, record) in steps {
133 let state: &str = record.get("state").and_then(Value::as_str).ok_or_else(|| { return broken("has a step without a state"); })?;
134 states.insert(name.clone(), state.to_string());
135 if let Some(milliseconds) = record.get("epoch_ms").and_then(Value::as_i64) {
136 times.insert(name.clone(), milliseconds);
137 }
138 }
139 return Ok(Journal { path: path.to_path_buf(), started, states, times, persisted: true });
140 }
141
142 pub fn started(&self) -> &str {
144 return self.started.as_str();
145 }
146
147 pub fn path(&self) -> &Path {
149 return self.path.as_path();
150 }
151
152 pub fn is_persisted(&self) -> bool {
154 return self.persisted;
155 }
156
157 pub fn seconds_of(&self, step: &str) -> Option<i64> {
160 return self.times.get(step).map(|milliseconds: &i64| { return milliseconds.div_euclid(1000); });
161 }
162
163 pub fn state(&self, step: &str) -> Option<&str> {
165 return self.states.get(step).map(String::as_str);
166 }
167
168 pub fn is_done(&self, step: &str) -> bool {
170 return self.state(step) == Some("done");
171 }
172
173 pub fn has_begun(&self, step: &str) -> bool {
175 return matches!(self.state(step), Some("started" | "done"));
176 }
177
178 pub fn mark(&mut self, step: &str, state: &str) -> Result<(), MigrateError> {
190 self.states.insert(step.to_string(), state.to_string());
191 let now: i64 = std::time::SystemTime::now()
192 .duration_since(std::time::UNIX_EPOCH)
193 .map_or(0, |elapsed: std::time::Duration| { return i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX); });
194 self.times.insert(step.to_string(), now);
195 let mut steps: Map<String, Value> = Map::new();
196 for (name, recorded) in &self.states {
197 let milliseconds: i64 = self.times.get(name).copied().unwrap_or(now);
198 let mut record: Map<String, Value> = Map::new();
199 record.insert(String::from("state"), Value::String(recorded.clone()));
200 record.insert(String::from("at"), Value::String(format_iso_milliseconds(milliseconds)));
201 record.insert(String::from("epoch_ms"), Value::from(milliseconds));
202 steps.insert(name.clone(), Value::Object(record));
203 }
204 let mut document: Map<String, Value> = Map::new();
205 document.insert(String::from("schema"), Value::from(SCHEMA));
206 document.insert(String::from("started"), Value::String(self.started.clone()));
207 document.insert(String::from("steps"), Value::Object(steps));
208 let text: String = serde_json::to_string_pretty(&Value::Object(document)).unwrap_or_default();
209 let sibling_path: PathBuf = self.temporary_path();
210 let write = || -> std::io::Result<()> {
211 write_exclusively(sibling_path.as_path(), text.as_bytes())?;
212 if let Err(error) = std::fs::rename(&sibling_path, &self.path) {
213 let _removed: std::io::Result<()> = std::fs::remove_file(&sibling_path);
214 return Err(error);
215 }
216 return match self.path.parent() {
218 Some(directory) => std::fs::File::open(directory).and_then(|held: std::fs::File| { return held.sync_all(); }),
219 None => Ok(()),
220 };
221 };
222 write().map_err(|error: std::io::Error| { return MigrateError::io("write the journal", self.path.as_path(), &error); })?;
223 self.persisted = true;
224 self.remove_leftovers();
225 return Ok(());
226 }
227
228 fn temporary_path(&self) -> PathBuf {
231 let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
232 let mut name: std::ffi::OsString = self.path.as_os_str().to_os_string();
233 name.push(format!("{TEMPORARY_SUFFIX}.{}.{nanos}", std::process::id()));
234 return PathBuf::from(name);
235 }
236
237 fn remove_leftovers(&self) {
245 let (Some(directory), Some(journal_name)) = (self.path.parent(), self.path.file_name()) else { return };
246 let Ok(listing) = std::fs::read_dir(directory) else { return };
247 for item in listing.flatten() {
248 let name: std::ffi::OsString = item.file_name();
249 if !is_leftover_name(name.as_encoded_bytes(), journal_name.as_encoded_bytes()) {
250 continue;
251 }
252 let is_plain_file: bool = item.file_type().is_ok_and(|kind: std::fs::FileType| { return kind.is_file(); });
253 if is_plain_file {
254 let _removed: std::io::Result<()> = std::fs::remove_file(item.path());
255 }
256 }
257 }
258
259 pub fn delete(&mut self) -> Result<(), MigrateError> {
269 match std::fs::remove_file(&self.path) {
270 Ok(()) => {}
271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
272 Err(error) => return Err(MigrateError::io("delete the journal", self.path.as_path(), &error)),
273 }
274 self.states.clear();
275 self.times.clear();
276 self.persisted = false;
277 return Ok(());
278 }
279}
280
281#[cfg(test)]
283#[path = "subvolume_journal_tests.rs"]
284mod tests;