btrfs_backup/
snapshot.rs

1// Copyright (C) 2023-2025 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use 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::io;
11use std::path::Path;
12use std::path::MAIN_SEPARATOR;
13use std::str::FromStr as _;
14use std::sync::LazyLock;
15
16use anyhow::ensure;
17use anyhow::Context as _;
18use anyhow::Result;
19
20use time::format_description::modifier::Day;
21use time::format_description::modifier::Hour;
22use time::format_description::modifier::Minute;
23use time::format_description::modifier::Month;
24use time::format_description::modifier::Second;
25use time::format_description::modifier::Year;
26use time::format_description::Component;
27use time::format_description::FormatItem;
28use time::Date;
29use time::OffsetDateTime;
30use time::PrimitiveDateTime;
31use time::Time;
32use time::UtcOffset;
33
34use uname::uname;
35
36use crate::util::escape;
37use crate::util::split_once_escaped;
38use crate::util::unescape;
39
40
41/// The "character" we use for separating intra-component pieces.
42///
43/// This is a `&str` because while conceptually representable as `char`,
44/// the latter is utterly hard to work with and the functions we use
45/// this constant with all interoperate much more nicely with `&str`.
46const ENCODED_INTRA_COMPONENT_SEPARATOR: &str = "-";
47/// The "character" we use for separating the individual components
48/// (such as host name and subvolume path) from each other in snapshot
49/// names.
50const ENCODED_COMPONENT_SEPARATOR: &str = "_";
51
52/// The UTC time zone offset we use throughout the program.
53static UTC_OFFSET: LazyLock<UtcOffset> = LazyLock::new(|| {
54  UtcOffset::current_local_offset().expect("failed to inquire current local time offset")
55});
56
57/// The date format used in snapshot names.
58const DATE_FORMAT: [FormatItem<'static>; 5] = [
59  FormatItem::Component(Component::Year(Year::default())),
60  FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
61  FormatItem::Component(Component::Month(Month::default())),
62  FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
63  FormatItem::Component(Component::Day(Day::default())),
64];
65
66/// The time format used in snapshot names.
67const TIME_FORMAT: [FormatItem<'static>; 5] = [
68  FormatItem::Component(Component::Hour(Hour::default())),
69  FormatItem::Literal(b":"),
70  FormatItem::Component(Component::Minute(Minute::default())),
71  FormatItem::Literal(b":"),
72  FormatItem::Component(Component::Second(Second::default())),
73];
74
75static HOST: LazyLock<io::Result<String>> =
76  LazyLock::new(|| uname().map(|info| info.nodename.to_lowercase()));
77
78
79/// Retrieve the system's host name.
80#[inline]
81pub fn hostname() -> Result<String> {
82  let host = HOST
83    .as_deref()
84    .context("failed to retrieve uname system information")?
85    .to_string();
86  Ok(host)
87}
88
89
90/// Retrieve the current local time.
91#[inline]
92pub fn current_time() -> OffsetDateTime {
93  OffsetDateTime::now_utc().to_offset(*UTC_OFFSET)
94}
95
96
97/// Retrieve the datetime offset to the UTC time zone.
98#[inline]
99pub fn utc_offset() -> UtcOffset {
100  *UTC_OFFSET
101}
102
103
104/// A type identifying a subvolume.
105///
106/// The subvolume is stored in encoded form. Encoding it is a lossy and
107/// non-reversible transformation. As a result, we cannot actually
108/// retrieve back the subvolume path, but we can tell when a provided
109/// path is for this `Subvol` (however, there is the potential for
110/// collisions, where two subvolume paths map to the same `Subvol`
111/// object, but they are rare and we ignore them).
112#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
113pub struct Subvol {
114  /// The subvolume being referenced, in encoded form.
115  encoded: String,
116}
117
118impl Subvol {
119  /// Create a new `Subvol` object for a subvolume at the provided path.
120  pub fn new(subvol: &Path) -> Self {
121    Self {
122      encoded: Self::to_encoded_string(subvol),
123    }
124  }
125
126  /// Create a `Subvol` object from an already encoded string.
127  fn from_encoded(subvol: String) -> Self {
128    Self { encoded: subvol }
129  }
130
131  /// A helper method for encoding the provided path.
132  fn to_encoded_string(path: &Path) -> String {
133    if path == Path::new(&MAIN_SEPARATOR.to_string()) {
134      ENCODED_INTRA_COMPONENT_SEPARATOR.to_string()
135    } else {
136      let string = path.to_string_lossy();
137      let string = escape(ENCODED_COMPONENT_SEPARATOR, &string);
138      let string = string
139        .trim_matches(MAIN_SEPARATOR)
140        .replace(MAIN_SEPARATOR, ENCODED_INTRA_COMPONENT_SEPARATOR);
141      string
142    }
143  }
144
145  /// Retrieve the encoded representation of the subvolume.
146  fn as_encoded_str(&self) -> &str {
147    &self.encoded
148  }
149}
150
151
152/// A type representing the base name of a snapshot.
153///
154/// The base name is the part of the first part of a snapshot's name
155/// that stays constant over time (but not system), i.e., that has no
156/// time information encoded in it and does not depend on data variable
157/// over time.
158#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
159pub struct SnapshotBase<'snap> {
160  /// See [`Snapshot::host`].
161  pub host: Cow<'snap, str>,
162  /// See [`Snapshot::subvol`].
163  pub subvol: Cow<'snap, Subvol>,
164}
165
166impl SnapshotBase<'_> {
167  /// Create a snapshot base name for the provided subvolume.
168  pub fn from_subvol_path(subvol: &Path) -> Result<SnapshotBase<'static>> {
169    ensure!(
170      subvol.is_absolute(),
171      "subvolume path {} is not absolute",
172      subvol.display()
173    );
174
175    let base_name = SnapshotBase {
176      host: Cow::Owned(hostname()?),
177      subvol: Cow::Owned(Subvol::new(subvol)),
178    };
179    Ok(base_name)
180  }
181}
182
183
184#[derive(Debug, Default)]
185pub struct Builder {
186  /// The snapshot's time stamp.
187  timestamp: Option<OffsetDateTime>,
188  /// The snapshot's tag.
189  tag: String,
190}
191
192impl Builder {
193  /// Set the snapshot's time stamp.
194  pub fn timestamp(mut self, timestamp: OffsetDateTime) -> Self {
195    self.timestamp = Some(timestamp);
196    self
197  }
198
199  /// Set the snapshot's tag.
200  pub fn tag(mut self, tag: &str) -> Self {
201    self.tag = tag.to_string();
202    self
203  }
204
205  /// Create a new snapshot name using the provided subvolume path
206  /// together with information gathered from the system (such as the
207  /// current time and date).
208  pub fn try_build(self, subvol: &Path) -> Result<Snapshot> {
209    let Self { timestamp, tag } = self;
210    let SnapshotBase { host, subvol } = SnapshotBase::from_subvol_path(subvol)?;
211
212    let snapshot = Snapshot {
213      host: host.into_owned(),
214      subvol: subvol.into_owned(),
215      // Make sure to erase all sub-second information.
216      // SANITY: 0 is always a valid millisecond.
217      timestamp: timestamp
218        .unwrap_or_else(current_time)
219        .replace_millisecond(0)
220        .expect("failed to replace milliseconds"),
221      tag,
222      number: None,
223    };
224    Ok(snapshot)
225  }
226}
227
228
229/// A snapshot name and its identifying components.
230#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
231pub struct Snapshot {
232  /// The name of the host to which this snapshot belongs.
233  pub host: String,
234  /// The subvolume that was snapshot.
235  pub subvol: Subvol,
236  /// The snapshot's time stamp.
237  ///
238  /// Time is treated as local time.
239  pub timestamp: OffsetDateTime,
240  /// A tag provided by the user at some point.
241  pub tag: String,
242  /// An optional number making the snapshot unique, in case the time
243  /// stamp is insufficient because of its second resolution.
244  pub number: Option<usize>,
245}
246
247impl Snapshot {
248  /// Generate a `Snapshot` object from a snapshot name, parsing the
249  /// constituent parts.
250  ///
251  /// The subvolume name format of a snapshot is:
252  /// <host>_<path>_<date>_<time>_<tag>
253  ///
254  /// It may also contain an additional <number> suffix, separated from
255  /// the main name by another `_`.
256  ///
257  /// <path> itself has all path separators replaced with underscores.
258  pub fn from_snapshot_name(subvol: &OsStr) -> Result<Self> {
259    fn from_snapshot_name_impl(subvol: &OsStr) -> Result<Snapshot> {
260      let string = subvol
261        .to_str()
262        .context("subvolume name is not a valid UTF-8 string")?;
263      let (host, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
264        .context("subvolume name does not contain host component")?;
265      let (path, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
266        .context("subvolume name does not contain a path")?;
267      let (date, string) = string
268        .split_once(ENCODED_COMPONENT_SEPARATOR)
269        .context("subvolume name does not contain snapshot date")?;
270      let (time, string) = string
271        .split_once(ENCODED_COMPONENT_SEPARATOR)
272        .context("subvolume name does not contain snapshot time")?;
273      let (tag, number) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR).unzip();
274      let tag = tag.unwrap_or(string);
275
276      let time = Time::parse(time, TIME_FORMAT.as_slice())
277        .with_context(|| format!("failed to parse snapshot time string: {time}"))?;
278
279      let date = Date::parse(date, DATE_FORMAT.as_slice())
280        .with_context(|| format!("failed to parse snapshot date string: {date}"))?;
281
282      let number = number
283        .map(|number| {
284          usize::from_str(number)
285            .with_context(|| format!("failed to parse snapshot number string: {number}"))
286        })
287        .transpose()?;
288
289      let slf = Snapshot {
290        host: unescape(ENCODED_COMPONENT_SEPARATOR, host),
291        subvol: Subvol::from_encoded(path.to_string()),
292        timestamp: PrimitiveDateTime::new(date, time).assume_offset(*UTC_OFFSET),
293        tag: unescape(ENCODED_COMPONENT_SEPARATOR, tag),
294        number,
295      };
296      Ok(slf)
297    }
298
299    from_snapshot_name_impl(subvol).with_context(|| {
300      format!(
301        "subvolume {} is not a valid snapshot identifier",
302        subvol.to_string_lossy()
303      )
304    })
305  }
306
307  pub fn builder() -> Builder {
308    Builder::default()
309  }
310
311  /// Create a new `Snapshot` object based on the current one but with
312  /// with `number` cleared.
313  pub fn strip_number(&self) -> Self {
314    let mut new = self.clone();
315    new.number = None;
316    new
317  }
318
319  /// Create a new `Snapshot` object with its number incremented by one.
320  #[inline]
321  pub fn bump_number(&self) -> Self {
322    let mut new = self.clone();
323    new.number = Some(self.number.as_ref().map(|number| number + 1).unwrap_or(0));
324    new
325  }
326
327  /// Retrieve the base name of the snapshot.
328  #[inline]
329  pub fn as_base_name(&self) -> SnapshotBase<'_> {
330    SnapshotBase {
331      host: Cow::Borrowed(&self.host),
332      subvol: Cow::Borrowed(&self.subvol),
333    }
334  }
335}
336
337impl Display for Snapshot {
338  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
339    let Snapshot {
340      host,
341      subvol,
342      timestamp,
343      tag,
344      number,
345    } = &self;
346
347    let sep = ENCODED_COMPONENT_SEPARATOR;
348    let date = timestamp
349      .date()
350      .format(DATE_FORMAT.as_slice())
351      .map_err(|_err| FmtError)?;
352    let time = timestamp
353      .time()
354      .format(TIME_FORMAT.as_slice())
355      .map_err(|_err| FmtError)?;
356
357    debug_assert_eq!(escape(sep, &date), date);
358    debug_assert_eq!(escape(sep, &time), time);
359
360    let () = write!(
361      f,
362      "{host}{sep}{subvol}{sep}{date}{sep}{time}{sep}{tag}",
363      host = escape(sep, host),
364      subvol = subvol.as_encoded_str(),
365      tag = escape(sep, tag),
366    )?;
367
368    if let Some(number) = number {
369      let () = write!(f, "{sep}{number}")?;
370    }
371    Ok(())
372  }
373}
374
375
376#[cfg(test)]
377mod tests {
378  use super::*;
379
380  use std::cmp::Ordering;
381
382  use time::Month;
383
384
385  /// Check that trailing path separators are handled properly.
386  #[test]
387  fn snapshot_trailing_path_separator_handling() {
388    let path1 = Path::new("/tmp/foobar");
389    let path2 = Path::new("/tmp/foobar/");
390    let snapshot1 = Snapshot::builder().try_build(path1).unwrap();
391    let snapshot2 = Snapshot::builder().try_build(path2).unwrap();
392
393    assert_eq!(snapshot1.subvol, snapshot2.subvol);
394  }
395
396  /// Check that we can parse a snapshot name and emit it back.
397  #[test]
398  fn snapshot_name_parsing_and_emitting() {
399    let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
400    let path = Path::new("/home/deso/media");
401    let snapshot = Snapshot::from_snapshot_name(name).unwrap();
402    assert_eq!(snapshot.host, "vaio");
403    assert_eq!(snapshot.subvol, Subvol::new(path));
404    assert_eq!(
405      snapshot.timestamp.date(),
406      Date::from_calendar_date(2019, Month::October, 27).unwrap()
407    );
408    assert_eq!(
409      snapshot.timestamp.time(),
410      Time::from_hms(8, 23, 16).unwrap()
411    );
412    assert_eq!(snapshot.number, None);
413    assert_eq!(OsStr::new(&snapshot.to_string()), name);
414
415    let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
416    let snapshot = Snapshot::from_snapshot_name(name).unwrap();
417    assert_eq!(snapshot.number, Some(1));
418    assert_eq!(OsStr::new(&snapshot.to_string()), name);
419
420    let name = OsStr::new("nuc_-_2023-03-22_21:03:56_usb128gb-samsung");
421    let snapshot = Snapshot::from_snapshot_name(name).unwrap();
422    assert_eq!(snapshot.number, None);
423    assert_eq!(snapshot.subvol, Subvol::new(Path::new("/")));
424    assert_eq!(OsStr::new(&snapshot.to_string()), name);
425
426    let tag = "foo-baz_baz";
427    let snapshot = Snapshot::builder().tag(tag).try_build(path).unwrap();
428    let snapshot_name = snapshot.to_string();
429    let parsed = Snapshot::from_snapshot_name(snapshot_name.as_ref()).unwrap();
430    assert_eq!(parsed, snapshot);
431  }
432
433  /// Check that snapshot names are ordered as expected.
434  #[test]
435  fn snapshot_name_ordering() {
436    let name1 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
437    let snapshot1 = Snapshot::from_snapshot_name(name1).unwrap();
438    let name2 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
439    let snapshot2 = Snapshot::from_snapshot_name(name2).unwrap();
440
441    assert_eq!(snapshot1.cmp(&snapshot2), Ordering::Less);
442    assert_eq!(snapshot1.cmp(&snapshot1), Ordering::Equal);
443    assert_eq!(snapshot2.cmp(&snapshot1), Ordering::Greater);
444    assert_eq!(
445      snapshot1.as_base_name().cmp(&snapshot2.as_base_name()),
446      Ordering::Equal
447    );
448    assert_eq!(
449      snapshot1.as_base_name().cmp(&snapshot1.as_base_name()),
450      Ordering::Equal
451    );
452    assert_eq!(
453      snapshot2.as_base_name().cmp(&snapshot1.as_base_name()),
454      Ordering::Equal
455    );
456  }
457
458  /// Make sure that subvolume path comparisons for `Snapshot` objects
459  /// work as expected.
460  #[test]
461  fn snapshot_subvolume_comparison() {
462    fn test(path: &Path) {
463      let snapshot = Snapshot::builder().try_build(path).unwrap();
464      let name = snapshot.to_string();
465      let snapshot = Snapshot::from_snapshot_name(name.as_ref()).unwrap();
466      assert_eq!(snapshot.subvol, Subvol::new(path));
467    }
468
469    test(Path::new("/snapshots/xxx_yyy"));
470    test(Path::new("/snapshots/xxx/yyy"));
471    test(Path::new("/snapshots/xxx-yyy"));
472  }
473}