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::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
41const ENCODED_INTRA_COMPONENT_SEPARATOR: &str = "-";
47const ENCODED_COMPONENT_SEPARATOR: &str = "_";
51
52static UTC_OFFSET: LazyLock<UtcOffset> = LazyLock::new(|| {
54 UtcOffset::current_local_offset().expect("failed to inquire current local time offset")
55});
56
57const 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
66const 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#[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#[inline]
92pub fn current_time() -> OffsetDateTime {
93 OffsetDateTime::now_utc().to_offset(*UTC_OFFSET)
94}
95
96
97#[inline]
99pub fn utc_offset() -> UtcOffset {
100 *UTC_OFFSET
101}
102
103
104#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
113pub struct Subvol {
114 encoded: String,
116}
117
118impl Subvol {
119 pub fn new(subvol: &Path) -> Self {
121 Self {
122 encoded: Self::to_encoded_string(subvol),
123 }
124 }
125
126 fn from_encoded(subvol: String) -> Self {
128 Self { encoded: subvol }
129 }
130
131 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 fn as_encoded_str(&self) -> &str {
147 &self.encoded
148 }
149}
150
151
152#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
159pub struct SnapshotBase<'snap> {
160 pub host: Cow<'snap, str>,
162 pub subvol: Cow<'snap, Subvol>,
164}
165
166impl SnapshotBase<'_> {
167 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 timestamp: Option<OffsetDateTime>,
188 tag: String,
190}
191
192impl Builder {
193 pub fn timestamp(mut self, timestamp: OffsetDateTime) -> Self {
195 self.timestamp = Some(timestamp);
196 self
197 }
198
199 pub fn tag(mut self, tag: &str) -> Self {
201 self.tag = tag.to_string();
202 self
203 }
204
205 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 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#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
231pub struct Snapshot {
232 pub host: String,
234 pub subvol: Subvol,
236 pub timestamp: OffsetDateTime,
240 pub tag: String,
242 pub number: Option<usize>,
245}
246
247impl Snapshot {
248 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 pub fn strip_number(&self) -> Self {
314 let mut new = self.clone();
315 new.number = None;
316 new
317 }
318
319 #[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 #[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 #[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 #[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 #[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 #[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}