use std::borrow::Cow;
use std::ffi::OsStr;
use std::fmt::Display;
use std::fmt::Error as FmtError;
use std::fmt::Formatter;
use std::fmt::Result as FmtResult;
use std::io;
use std::path::Path;
use std::path::MAIN_SEPARATOR;
use std::str::FromStr as _;
use std::sync::LazyLock;
use anyhow::ensure;
use anyhow::Context as _;
use anyhow::Result;
use time::format_description::modifier::Day;
use time::format_description::modifier::Hour;
use time::format_description::modifier::Minute;
use time::format_description::modifier::Month;
use time::format_description::modifier::Second;
use time::format_description::modifier::Year;
use time::format_description::Component;
use time::format_description::FormatItem;
use time::Date;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use time::Time;
use time::UtcOffset;
use uname::uname;
use crate::util::escape;
use crate::util::split_once_escaped;
use crate::util::unescape;
const ENCODED_INTRA_COMPONENT_SEPARATOR: &str = "-";
const ENCODED_COMPONENT_SEPARATOR: &str = "_";
static UTC_OFFSET: LazyLock<UtcOffset> = LazyLock::new(|| {
UtcOffset::current_local_offset().expect("failed to inquire current local time offset")
});
const DATE_FORMAT: [FormatItem<'static>; 5] = [
FormatItem::Component(Component::Year(Year::default())),
FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
FormatItem::Component(Component::Month(Month::default())),
FormatItem::Literal(ENCODED_INTRA_COMPONENT_SEPARATOR.as_bytes()),
FormatItem::Component(Component::Day(Day::default())),
];
const TIME_FORMAT: [FormatItem<'static>; 5] = [
FormatItem::Component(Component::Hour(Hour::default())),
FormatItem::Literal(b":"),
FormatItem::Component(Component::Minute(Minute::default())),
FormatItem::Literal(b":"),
FormatItem::Component(Component::Second(Second::default())),
];
static HOST: LazyLock<io::Result<String>> =
LazyLock::new(|| uname().map(|info| info.nodename.to_lowercase()));
#[inline]
pub fn hostname() -> Result<String> {
let host = HOST
.as_deref()
.context("failed to retrieve uname system information")?
.to_string();
Ok(host)
}
#[inline]
pub fn current_time() -> OffsetDateTime {
OffsetDateTime::now_utc().to_offset(*UTC_OFFSET)
}
#[inline]
pub fn utc_offset() -> UtcOffset {
*UTC_OFFSET
}
#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
pub struct Subvol {
encoded: String,
}
impl Subvol {
pub fn new(subvol: &Path) -> Self {
Self {
encoded: Self::to_encoded_string(subvol),
}
}
fn from_encoded(subvol: String) -> Self {
Self { encoded: subvol }
}
fn to_encoded_string(path: &Path) -> String {
if path == Path::new(&MAIN_SEPARATOR.to_string()) {
ENCODED_INTRA_COMPONENT_SEPARATOR.to_string()
} else {
let string = path.to_string_lossy();
let string = escape(ENCODED_COMPONENT_SEPARATOR, &string);
let string = string
.trim_matches(MAIN_SEPARATOR)
.replace(MAIN_SEPARATOR, ENCODED_INTRA_COMPONENT_SEPARATOR);
string
}
}
fn as_encoded_str(&self) -> &str {
&self.encoded
}
}
#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
pub struct SnapshotBase<'snap> {
pub host: Cow<'snap, str>,
pub subvol: Cow<'snap, Subvol>,
}
impl SnapshotBase<'_> {
pub fn from_subvol_path(subvol: &Path) -> Result<SnapshotBase<'static>> {
ensure!(
subvol.is_absolute(),
"subvolume path {} is not absolute",
subvol.display()
);
let base_name = SnapshotBase {
host: Cow::Owned(hostname()?),
subvol: Cow::Owned(Subvol::new(subvol)),
};
Ok(base_name)
}
}
#[derive(Debug, Default)]
pub struct Builder {
timestamp: Option<OffsetDateTime>,
tag: String,
}
impl Builder {
pub fn timestamp(mut self, timestamp: OffsetDateTime) -> Self {
self.timestamp = Some(timestamp);
self
}
pub fn tag(mut self, tag: &str) -> Self {
self.tag = tag.to_string();
self
}
pub fn try_build(self, subvol: &Path) -> Result<Snapshot> {
let Self { timestamp, tag } = self;
let SnapshotBase { host, subvol } = SnapshotBase::from_subvol_path(subvol)?;
let snapshot = Snapshot {
host: host.into_owned(),
subvol: subvol.into_owned(),
timestamp: timestamp
.unwrap_or_else(current_time)
.replace_millisecond(0)
.expect("failed to replace milliseconds"),
tag,
number: None,
};
Ok(snapshot)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialOrd, PartialEq)]
pub struct Snapshot {
pub host: String,
pub subvol: Subvol,
pub timestamp: OffsetDateTime,
pub tag: String,
pub number: Option<usize>,
}
impl Snapshot {
pub fn from_snapshot_name(subvol: &OsStr) -> Result<Self> {
fn from_snapshot_name_impl(subvol: &OsStr) -> Result<Snapshot> {
let string = subvol
.to_str()
.context("subvolume name is not a valid UTF-8 string")?;
let (host, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
.context("subvolume name does not contain host component")?;
let (path, string) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR)
.context("subvolume name does not contain a path")?;
let (date, string) = string
.split_once(ENCODED_COMPONENT_SEPARATOR)
.context("subvolume name does not contain snapshot date")?;
let (time, string) = string
.split_once(ENCODED_COMPONENT_SEPARATOR)
.context("subvolume name does not contain snapshot time")?;
let (tag, number) = split_once_escaped(string, ENCODED_COMPONENT_SEPARATOR).unzip();
let tag = tag.unwrap_or(string);
let time = Time::parse(time, TIME_FORMAT.as_slice())
.with_context(|| format!("failed to parse snapshot time string: {time}"))?;
let date = Date::parse(date, DATE_FORMAT.as_slice())
.with_context(|| format!("failed to parse snapshot date string: {date}"))?;
let number = number
.map(|number| {
usize::from_str(number)
.with_context(|| format!("failed to parse snapshot number string: {number}"))
})
.transpose()?;
let slf = Snapshot {
host: unescape(ENCODED_COMPONENT_SEPARATOR, host),
subvol: Subvol::from_encoded(path.to_string()),
timestamp: PrimitiveDateTime::new(date, time).assume_offset(*UTC_OFFSET),
tag: unescape(ENCODED_COMPONENT_SEPARATOR, tag),
number,
};
Ok(slf)
}
from_snapshot_name_impl(subvol).with_context(|| {
format!(
"subvolume {} is not a valid snapshot identifier",
subvol.to_string_lossy()
)
})
}
pub fn builder() -> Builder {
Builder::default()
}
pub fn strip_number(&self) -> Self {
let mut new = self.clone();
new.number = None;
new
}
#[inline]
pub fn bump_number(&self) -> Self {
let mut new = self.clone();
new.number = Some(self.number.as_ref().map(|number| number + 1).unwrap_or(0));
new
}
#[inline]
pub fn as_base_name(&self) -> SnapshotBase<'_> {
SnapshotBase {
host: Cow::Borrowed(&self.host),
subvol: Cow::Borrowed(&self.subvol),
}
}
}
impl Display for Snapshot {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
let Snapshot {
host,
subvol,
timestamp,
tag,
number,
} = &self;
let sep = ENCODED_COMPONENT_SEPARATOR;
let date = timestamp
.date()
.format(DATE_FORMAT.as_slice())
.map_err(|_err| FmtError)?;
let time = timestamp
.time()
.format(TIME_FORMAT.as_slice())
.map_err(|_err| FmtError)?;
debug_assert_eq!(escape(sep, &date), date);
debug_assert_eq!(escape(sep, &time), time);
let () = write!(
f,
"{host}{sep}{subvol}{sep}{date}{sep}{time}{sep}{tag}",
host = escape(sep, host),
subvol = subvol.as_encoded_str(),
tag = escape(sep, tag),
)?;
if let Some(number) = number {
let () = write!(f, "{sep}{number}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering;
use time::Month;
#[test]
fn snapshot_trailing_path_separator_handling() {
let path1 = Path::new("/tmp/foobar");
let path2 = Path::new("/tmp/foobar/");
let snapshot1 = Snapshot::builder().try_build(path1).unwrap();
let snapshot2 = Snapshot::builder().try_build(path2).unwrap();
assert_eq!(snapshot1.subvol, snapshot2.subvol);
}
#[test]
fn snapshot_name_parsing_and_emitting() {
let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
let path = Path::new("/home/deso/media");
let snapshot = Snapshot::from_snapshot_name(name).unwrap();
assert_eq!(snapshot.host, "vaio");
assert_eq!(snapshot.subvol, Subvol::new(path));
assert_eq!(
snapshot.timestamp.date(),
Date::from_calendar_date(2019, Month::October, 27).unwrap()
);
assert_eq!(
snapshot.timestamp.time(),
Time::from_hms(8, 23, 16).unwrap()
);
assert_eq!(snapshot.number, None);
assert_eq!(OsStr::new(&snapshot.to_string()), name);
let name = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
let snapshot = Snapshot::from_snapshot_name(name).unwrap();
assert_eq!(snapshot.number, Some(1));
assert_eq!(OsStr::new(&snapshot.to_string()), name);
let name = OsStr::new("nuc_-_2023-03-22_21:03:56_usb128gb-samsung");
let snapshot = Snapshot::from_snapshot_name(name).unwrap();
assert_eq!(snapshot.number, None);
assert_eq!(snapshot.subvol, Subvol::new(Path::new("/")));
assert_eq!(OsStr::new(&snapshot.to_string()), name);
let tag = "foo-baz_baz";
let snapshot = Snapshot::builder().tag(tag).try_build(path).unwrap();
let snapshot_name = snapshot.to_string();
let parsed = Snapshot::from_snapshot_name(snapshot_name.as_ref()).unwrap();
assert_eq!(parsed, snapshot);
}
#[test]
fn snapshot_name_ordering() {
let name1 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16_");
let snapshot1 = Snapshot::from_snapshot_name(name1).unwrap();
let name2 = OsStr::new("vaio_home-deso-media_2019-10-27_08:23:16__1");
let snapshot2 = Snapshot::from_snapshot_name(name2).unwrap();
assert_eq!(snapshot1.cmp(&snapshot2), Ordering::Less);
assert_eq!(snapshot1.cmp(&snapshot1), Ordering::Equal);
assert_eq!(snapshot2.cmp(&snapshot1), Ordering::Greater);
assert_eq!(
snapshot1.as_base_name().cmp(&snapshot2.as_base_name()),
Ordering::Equal
);
assert_eq!(
snapshot1.as_base_name().cmp(&snapshot1.as_base_name()),
Ordering::Equal
);
assert_eq!(
snapshot2.as_base_name().cmp(&snapshot1.as_base_name()),
Ordering::Equal
);
}
#[test]
fn snapshot_subvolume_comparison() {
fn test(path: &Path) {
let snapshot = Snapshot::builder().try_build(path).unwrap();
let name = snapshot.to_string();
let snapshot = Snapshot::from_snapshot_name(name.as_ref()).unwrap();
assert_eq!(snapshot.subvol, Subvol::new(path));
}
test(Path::new("/snapshots/xxx_yyy"));
test(Path::new("/snapshots/xxx/yyy"));
test(Path::new("/snapshots/xxx-yyy"));
}
}