1use std::borrow::Cow;
5use std::ffi::OsStr;
6use std::fmt::Display;
7use std::fmt::Error as FmtError;
8use std::fmt::Formatter;
9use std::fmt::Result as FmtResult;
10use std::path::Path;
11use std::path::MAIN_SEPARATOR;
12use std::str::FromStr as _;
13use std::sync::LazyLock;
14
15use anyhow::ensure;
16use anyhow::Context as _;
17use anyhow::Result;
18
19use time::format_description::modifier::Day;
20use time::format_description::modifier::Hour;
21use time::format_description::modifier::Minute;
22use time::format_description::modifier::Month;
23use time::format_description::modifier::Second;
24use time::format_description::modifier::Year;
25use time::format_description::Component;
26use time::format_description::FormatItem;
27use time::Date;
28use time::OffsetDateTime;
29use time::PrimitiveDateTime;
30use time::Time;
31use time::UtcOffset;
32
33use uname::uname;
34
35use crate::util::escape;
36use crate::util::split_once_escaped;
37use crate::util::unescape;
38
39
40const ENCODED_INTRA_COMPONENT_SEPARATOR: &str = "-";
46const ENCODED_COMPONENT_SEPARATOR: &str = "_";
50
51static UTC_OFFSET: LazyLock<UtcOffset> = LazyLock::new(|| {
53 UtcOffset::current_local_offset().expect("failed to inquire current local time offset")
54});
55
56const DATE_FORMAT: [FormatItem<'static>; 5] = [
58 FormatItem::Component(Component::Year(Year::default())),
59 FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
60 FormatItem::Component(Component::Month(Month::default())),
61 FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
62 FormatItem::Component(Component::Day(Day::default())),
63];
64
65const TIME_FORMAT: [FormatItem<'static>; 5] = [
67 FormatItem::Component(Component::Hour(Hour::default())),
68 FormatItem::Literal(b":"),
69 FormatItem::Component(Component::Minute(Minute::default())),
70 FormatItem::Literal(b":"),
71 FormatItem::Component(Component::Second(Second::default())),
72];
73
74
75#[inline]
77pub fn current_time() -> OffsetDateTime {
78 OffsetDateTime::now_utc().to_offset(*UTC_OFFSET)
79}
80
81
82#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
91pub struct Subvol {
92 encoded: String,
94}
95
96impl Subvol {
97 pub fn new(subvol: &Path) -> Self {
99 Self {
100 encoded: Self::to_encoded_string(subvol),
101 }
102 }
103
104 fn from_encoded(subvol: String) -> Self {
106 Self { encoded: subvol }
107 }
108
109 fn to_encoded_string(path: &Path) -> String {
111 if path == Path::new(&MAIN_SEPARATOR.to_string()) {
112 ENCODED_INTRA_COMPONENT_SEPARATOR.to_string()
113 } else {
114 let string = path.to_string_lossy();
115 let string = escape(ENCODED_COMPONENT_SEPARATOR, &string);
116 let string = string
117 .trim_matches(MAIN_SEPARATOR)
118 .replace(MAIN_SEPARATOR, ENCODED_INTRA_COMPONENT_SEPARATOR);
119 string
120 }
121 }
122
123 fn as_encoded_str(&self) -> &str {
125 &self.encoded
126 }
127}
128
129
130#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
137pub struct SnapshotBase<'snap> {
138 pub host: Cow<'snap, str>,
140 pub subvol: Cow<'snap, Subvol>,
142}
143
144impl SnapshotBase<'_> {
145 pub fn from_subvol_path(subvol: &Path) -> Result<SnapshotBase<'static>> {
147 ensure!(
148 subvol.is_absolute(),
149 "subvolume path {} is not absolute",
150 subvol.display()
151 );
152
153 let info = uname().context("failed to retrieve uname system information")?;
154 let base_name = SnapshotBase {
155 host: Cow::Owned(info.nodename.to_lowercase()),
156 subvol: Cow::Owned(Subvol::new(subvol)),
157 };
158 Ok(base_name)
159 }
160}
161
162
163#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
165pub struct Snapshot {
166 pub host: String,
168 pub subvol: Subvol,
170 pub timestamp: OffsetDateTime,
174 pub tag: String,
176 pub number: Option<usize>,
179}
180
181impl Snapshot {
182 pub fn from_snapshot_name(subvol: &OsStr) -> Result<Self> {
193 fn from_snapshot_name_impl(subvol: &OsStr) -> Result<Snapshot> {
194 let string = subvol
195 .to_str()
196 .context("subvolume name is not a valid UTF-8 string")?;
197 let (host, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
198 .context("subvolume name does not contain host component")?;
199 let (path, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
200 .context("subvolume name does not contain a path")?;
201 let (date, string) = string
202 .split_once(ENCODED_COMPONENT_SEPARATOR)
203 .context("subvolume name does not contain snapshot date")?;
204 let (time, string) = string
205 .split_once(ENCODED_COMPONENT_SEPARATOR)
206 .context("subvolume name does not contain snapshot time")?;
207 let (tag, number) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR).unzip();
208 let tag = tag.unwrap_or(string);
209
210 let time = Time::parse(time, TIME_FORMAT.as_slice())
211 .with_context(|| format!("failed to parse snapshot time string: {time}"))?;
212
213 let date = Date::parse(date, DATE_FORMAT.as_slice())
214 .with_context(|| format!("failed to parse snapshot date string: {date}"))?;
215
216 let number = number
217 .map(|number| {
218 usize::from_str(number)
219 .with_context(|| format!("failed to parse snapshot number string: {number}"))
220 })
221 .transpose()?;
222
223 let slf = Snapshot {
224 host: unescape(ENCODED_COMPONENT_SEPARATOR, host),
225 subvol: Subvol::from_encoded(path.to_string()),
226 timestamp: PrimitiveDateTime::new(date, time).assume_offset(*UTC_OFFSET),
227 tag: unescape(ENCODED_COMPONENT_SEPARATOR, tag),
228 number,
229 };
230 Ok(slf)
231 }
232
233 from_snapshot_name_impl(subvol).with_context(|| {
234 format!(
235 "subvolume {} is not a valid snapshot identifier",
236 subvol.to_string_lossy()
237 )
238 })
239 }
240
241 pub fn from_subvol_path(subvol: &Path, tag: &str) -> Result<Snapshot> {
245 let SnapshotBase { host, subvol } = SnapshotBase::from_subvol_path(subvol)?;
246
247 let slf = Self {
248 host: host.into_owned(),
249 subvol: subvol.into_owned(),
250 timestamp: current_time()
253 .replace_millisecond(0)
254 .expect("failed to replace milliseconds"),
255 tag: tag.to_string(),
256 number: None,
257 };
258 Ok(slf)
259 }
260
261 pub fn strip_number(&self) -> Self {
264 let mut new = self.clone();
265 new.number = None;
266 new
267 }
268
269 #[inline]
271 pub fn bump_number(&self) -> Self {
272 let mut new = self.clone();
273 new.number = Some(self.number.as_ref().map(|number| number + 1).unwrap_or(0));
274 new
275 }
276
277 #[inline]
279 pub fn as_base_name(&self) -> SnapshotBase<'_> {
280 SnapshotBase {
281 host: Cow::Borrowed(&self.host),
282 subvol: Cow::Borrowed(&self.subvol),
283 }
284 }
285}
286
287impl Display for Snapshot {
288 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
289 let Snapshot {
290 host,
291 subvol,
292 timestamp,
293 tag,
294 number,
295 } = &self;
296
297 let sep = ENCODED_COMPONENT_SEPARATOR;
298 let date = timestamp
299 .date()
300 .format(DATE_FORMAT.as_slice())
301 .map_err(|_err| FmtError)?;
302 let time = timestamp
303 .time()
304 .format(TIME_FORMAT.as_slice())
305 .map_err(|_err| FmtError)?;
306
307 debug_assert_eq!(escape(sep, &date), date);
308 debug_assert_eq!(escape(sep, &time), time);
309
310 let () = write!(
311 f,
312 "{host}{sep}{subvol}{sep}{date}{sep}{time}{sep}{tag}",
313 host = escape(sep, host),
314 subvol = subvol.as_encoded_str(),
315 tag = escape(sep, tag),
316 )?;
317
318 if let Some(number) = number {
319 let () = write!(f, "{sep}{number}")?;
320 }
321 Ok(())
322 }
323}
324
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 use std::cmp::Ordering;
331
332 use time::Month;
333
334
335 #[test]
337 fn snapshot_trailing_path_separator_handling() {
338 let tag = "";
339 let path1 = Path::new("/tmp/foobar");
340 let path2 = Path::new("/tmp/foobar/");
341 let snapshot1 = Snapshot::from_subvol_path(path1, tag).unwrap();
342 let snapshot2 = Snapshot::from_subvol_path(path2, tag).unwrap();
343
344 assert_eq!(snapshot1.subvol, snapshot2.subvol);
345 }
346
347 #[test]
349 fn snapshot_name_parsing_and_emitting() {
350 let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
351 let path = Path::new("/home/deso/media");
352 let snapshot = Snapshot::from_snapshot_name(name).unwrap();
353 assert_eq!(snapshot.host, "vaio");
354 assert_eq!(snapshot.subvol, Subvol::new(path));
355 assert_eq!(
356 snapshot.timestamp.date(),
357 Date::from_calendar_date(2019, Month::October, 27).unwrap()
358 );
359 assert_eq!(
360 snapshot.timestamp.time(),
361 Time::from_hms(8, 23, 16).unwrap()
362 );
363 assert_eq!(snapshot.number, None);
364 assert_eq!(OsStr::new(&snapshot.to_string()), name);
365
366 let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
367 let snapshot = Snapshot::from_snapshot_name(name).unwrap();
368 assert_eq!(snapshot.number, Some(1));
369 assert_eq!(OsStr::new(&snapshot.to_string()), name);
370
371 let name = OsStr::new("nuc_-_2023-03-22_21:03:56_usb128gb-samsung");
372 let snapshot = Snapshot::from_snapshot_name(name).unwrap();
373 assert_eq!(snapshot.number, None);
374 assert_eq!(snapshot.subvol, Subvol::new(Path::new("/")));
375 assert_eq!(OsStr::new(&snapshot.to_string()), name);
376
377 let tag = "foo-baz_baz";
378 let snapshot = Snapshot::from_subvol_path(path, tag).unwrap();
379 let snapshot_name = snapshot.to_string();
380 let parsed = Snapshot::from_snapshot_name(snapshot_name.as_ref()).unwrap();
381 assert_eq!(parsed, snapshot);
382 }
383
384 #[test]
386 fn snapshot_name_ordering() {
387 let name1 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
388 let snapshot1 = Snapshot::from_snapshot_name(name1).unwrap();
389 let name2 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
390 let snapshot2 = Snapshot::from_snapshot_name(name2).unwrap();
391
392 assert_eq!(snapshot1.cmp(&snapshot2), Ordering::Less);
393 assert_eq!(snapshot1.cmp(&snapshot1), Ordering::Equal);
394 assert_eq!(snapshot2.cmp(&snapshot1), Ordering::Greater);
395 assert_eq!(
396 snapshot1.as_base_name().cmp(&snapshot2.as_base_name()),
397 Ordering::Equal
398 );
399 assert_eq!(
400 snapshot1.as_base_name().cmp(&snapshot1.as_base_name()),
401 Ordering::Equal
402 );
403 assert_eq!(
404 snapshot2.as_base_name().cmp(&snapshot1.as_base_name()),
405 Ordering::Equal
406 );
407 }
408
409 #[test]
412 fn snapshot_subvolume_comparison() {
413 fn test(path: &Path) {
414 let tag = "";
415 let snapshot = Snapshot::from_subvol_path(path, tag).unwrap();
416 let name = snapshot.to_string();
417 let snapshot = Snapshot::from_snapshot_name(name.as_ref()).unwrap();
418 assert_eq!(snapshot.subvol, Subvol::new(path));
419 }
420
421 test(Path::new("/snapshots/xxx_yyy"));
422 test(Path::new("/snapshots/xxx/yyy"));
423 test(Path::new("/snapshots/xxx-yyy"));
424 }
425}