btrfs_backup/
snapshot.rs

1// Copyright (C) 2023-2024 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::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
40/// The "character" we use for separating intra-component pieces.
41///
42/// This is a `&str` because while conceptually representable as `char`,
43/// the latter is utterly hard to work with and the functions we use
44/// this constant with all interoperate much more nicely with `&str`.
45const ENCODED_INTRA_COMPONENT_SEPARATOR: &str = "-";
46/// The "character" we use for separating the individual components
47/// (such as host name and subvolume path) from each other in snapshot
48/// names.
49const ENCODED_COMPONENT_SEPARATOR: &str = "_";
50
51/// The UTC time zone offset we use throughout the program.
52static UTC_OFFSET: LazyLock<UtcOffset> = LazyLock::new(|| {
53  UtcOffset::current_local_offset().expect("failed to inquire current local time offset")
54});
55
56/// The date format used in snapshot names.
57const 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
65/// The time format used in snapshot names.
66const 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/// Retrieve the current local time.
76#[inline]
77pub fn current_time() -> OffsetDateTime {
78  OffsetDateTime::now_utc().to_offset(*UTC_OFFSET)
79}
80
81
82/// A type identifying a subvolume.
83///
84/// The subvolume is stored in encoded form. Encoding it is a lossy and
85/// non-reversible transformation. As a result, we cannot actually
86/// retrieve back the subvolume path, but we can tell when a provided
87/// path is for this `Subvol` (however, there is the potential for
88/// collisions, where two subvolume paths map to the same `Subvol`
89/// object, but they are rare and we ignore them).
90#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
91pub struct Subvol {
92  /// The subvolume being referenced, in encoded form.
93  encoded: String,
94}
95
96impl Subvol {
97  /// Create a new `Subvol` object for a subvolume at the provided path.
98  pub fn new(subvol: &Path) -> Self {
99    Self {
100      encoded: Self::to_encoded_string(subvol),
101    }
102  }
103
104  /// Create a `Subvol` object from an already encoded string.
105  fn from_encoded(subvol: String) -> Self {
106    Self { encoded: subvol }
107  }
108
109  /// A helper method for encoding the provided path.
110  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  /// Retrieve the encoded representation of the subvolume.
124  fn as_encoded_str(&self) -> &str {
125    &self.encoded
126  }
127}
128
129
130/// A type representing the base name of a snapshot.
131///
132/// The base name is the part of the first part of a snapshot's name
133/// that stays constant over time (but not system), i.e., that has no
134/// time information encoded in it and does not depend on data variable
135/// over time.
136#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
137pub struct SnapshotBase<'snap> {
138  /// See [`Snapshot::host`].
139  pub host: Cow<'snap, str>,
140  /// See [`Snapshot::subvol`].
141  pub subvol: Cow<'snap, Subvol>,
142}
143
144impl SnapshotBase<'_> {
145  /// Create a snapshot base name for the provided subvolume.
146  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/// A snapshot name and its identifying components.
164#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
165pub struct Snapshot {
166  /// The name of the host to which this snapshot belongs.
167  pub host: String,
168  /// The subvolume that was snapshot.
169  pub subvol: Subvol,
170  /// The snapshot's time stamp.
171  ///
172  /// Time is treated as local time.
173  pub timestamp: OffsetDateTime,
174  /// A tag provided by the user at some point.
175  pub tag: String,
176  /// An optional number making the snapshot unique, in case the time
177  /// stamp is insufficient because of its second resolution.
178  pub number: Option<usize>,
179}
180
181impl Snapshot {
182  /// Generate a `Snapshot` object from a snapshot name, parsing the
183  /// constituent parts.
184  ///
185  /// The subvolume name format of a snapshot is:
186  /// <host>_<path>_<date>_<time>_<tag>
187  ///
188  /// It may also contain an additional <number> suffix, separated from
189  /// the main name by another `_`.
190  ///
191  /// <path> itself has all path separators replaced with underscores.
192  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  /// Create a new snapshot name using the provided subvolume path
242  /// together with information gathered from the system (such as the
243  /// current time and date).
244  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      // Make sure to erase all sub-second information.
251      // SANITY: 0 is always a valid millisecond.
252      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  /// Create a new `Snapshot` object based on the current one but with
262  /// with `number` cleared.
263  pub fn strip_number(&self) -> Self {
264    let mut new = self.clone();
265    new.number = None;
266    new
267  }
268
269  /// Create a new `Snapshot` object with its number incremented by one.
270  #[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  /// Retrieve the base name of the snapshot.
278  #[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  /// Check that trailing path separators are handled properly.
336  #[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  /// Check that we can parse a snapshot name and emit it back.
348  #[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  /// Check that snapshot names are ordered as expected.
385  #[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  /// Make sure that subvolume path comparisons for `Snapshot` objects
410  /// work as expected.
411  #[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}