pub mod grouping;
mod modification;
pub use modification::SnapshotModification;
use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
fmt::{self, Display},
path::{Path, PathBuf},
str::FromStr,
};
use derive_setters::Setters;
use dunce::canonicalize;
use gethostname::gethostname;
use itertools::Itertools;
use jiff::{Span, Unit, Zoned};
use log::{info, warn};
use path_dedot::ParseDot;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, serde_as, skip_serializing_none};
#[cfg(feature = "clap")]
use clap::ValueHint;
use crate::{
Id,
backend::{FileType, FindInBackend, decrypt::DecryptReadBackend},
blob::tree::TreeId,
error::{ErrorKind, RusticError, RusticResult},
id::{FindUniqueMultiple, FindUniqueResults, constants::HEX_LEN},
impl_repofile,
progress::Progress,
repofile::{RepoFile, RusticTime},
};
#[derive(thiserror::Error, Debug, displaydoc::Display)]
#[non_exhaustive]
pub enum SnapshotFileErrorKind {
NonUnicodePath(PathBuf),
ValueNotAllowed(String),
RemovingDotsFromPathFailed(std::io::Error),
CanonicalizingPathFailed(std::io::Error),
}
pub(crate) type SnapshotFileResult<T> = Result<T, SnapshotFileErrorKind>;
#[serde_as]
#[cfg_attr(feature = "merge", derive(conflate::Merge))]
#[cfg_attr(feature = "clap", derive(clap::Parser))]
#[derive(Deserialize, Serialize, Clone, Default, Debug, Setters)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
#[setters(into)]
#[non_exhaustive]
pub struct SnapshotOptions {
#[cfg_attr(feature = "clap", clap(long, value_name = "LABEL"))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub label: Option<String>,
#[serde_as(as = "Vec<DisplayFromStr>")]
#[cfg_attr(feature = "clap", clap(long = "tag", value_name = "TAG[,TAG,..]"))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::vec::overwrite_empty))]
pub tags: Vec<StringList>,
#[cfg_attr(feature = "clap", clap(long, value_name = "DESCRIPTION"))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub description: Option<String>,
#[cfg_attr(
feature = "clap",
clap(long, value_name = "FILE", conflicts_with = "description", value_hint = ValueHint::FilePath)
)]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub description_from: Option<PathBuf>,
#[cfg_attr(feature = "clap", clap(long,value_parser = RusticTime::parse_system))]
#[serde_as(as = "Option<RusticTime>")]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub time: Option<Zoned>,
#[cfg_attr(feature = "clap", clap(long, conflicts_with = "delete_after"))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::bool::overwrite_false))]
pub delete_never: bool,
#[cfg_attr(feature = "clap", clap(long, value_name = "DURATION"))]
#[serde_as(as = "Option<DisplayFromStr>")]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub delete_after: Option<Span>,
#[cfg_attr(feature = "clap", clap(long, value_name = "NAME"))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub host: Option<String>,
#[cfg_attr(feature = "clap", clap(long))]
#[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
pub command: Option<String>,
}
impl SnapshotOptions {
pub fn add_tags(mut self, tag: &str) -> RusticResult<Self> {
self.tags.push(StringList::from_str(tag).map_err(|err| {
RusticError::with_source(
ErrorKind::InvalidInput,
"Failed to create string list from tag `{tag}`. The value must be a valid unicode string.",
err,
)
.attach_context("tag", tag)
})?);
Ok(self)
}
pub fn to_snapshot(&self) -> RusticResult<SnapshotFile> {
SnapshotFile::from_options(self)
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(default)]
#[non_exhaustive]
pub struct SnapshotSummary {
pub files_new: u64,
pub files_changed: u64,
pub files_unmodified: u64,
pub total_files_processed: u64,
pub total_bytes_processed: u64,
pub dirs_new: u64,
pub dirs_changed: u64,
pub dirs_unmodified: u64,
pub total_dirs_processed: u64,
pub total_dirsize_processed: u64,
pub data_blobs: u64,
pub tree_blobs: u64,
pub data_added: u64,
pub data_added_packed: u64,
pub data_added_files: u64,
pub data_added_files_packed: u64,
pub data_added_trees: u64,
pub data_added_trees_packed: u64,
pub command: String,
#[serde_as(as = "RusticTime")]
pub backup_start: Zoned,
#[serde_as(as = "RusticTime")]
pub backup_end: Zoned,
pub backup_duration: f64,
pub total_duration: f64,
}
impl Default for SnapshotSummary {
fn default() -> Self {
Self {
files_new: Default::default(),
files_changed: Default::default(),
files_unmodified: Default::default(),
total_files_processed: Default::default(),
total_bytes_processed: Default::default(),
dirs_new: Default::default(),
dirs_changed: Default::default(),
dirs_unmodified: Default::default(),
total_dirs_processed: Default::default(),
total_dirsize_processed: Default::default(),
data_blobs: Default::default(),
tree_blobs: Default::default(),
data_added: Default::default(),
data_added_packed: Default::default(),
data_added_files: Default::default(),
data_added_files_packed: Default::default(),
data_added_trees: Default::default(),
data_added_trees_packed: Default::default(),
command: String::default(),
backup_start: Zoned::now(),
backup_end: Zoned::now(),
backup_duration: Default::default(),
total_duration: Default::default(),
}
}
}
impl SnapshotSummary {
pub(crate) fn finalize(&mut self, snap_time: &Zoned) {
let end_time = Zoned::now();
self.backup_duration = end_time
.since(&self.backup_start)
.and_then(|span| span.total(Unit::Second))
.inspect_err(|err| warn!("ignoring Datetime error: {err}"))
.unwrap_or_default();
self.total_duration = end_time
.since(snap_time)
.and_then(|span| span.total(Unit::Second))
.inspect_err(|err| warn!("ignoring Datetime error: {err}"))
.unwrap_or_default();
self.backup_end = end_time;
}
}
#[derive(Serialize, Default, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum DeleteOption {
#[default]
NotSet,
Never,
After(Zoned),
}
impl DeleteOption {
const fn is_not_set(&self) -> bool {
matches!(self, Self::NotSet)
}
}
impl_repofile!(SnapshotId, FileType::Snapshot, SnapshotFile);
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotFile {
#[serde_as(as = "RusticTime")]
pub time: Zoned,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub program_version: String,
pub parent: Option<SnapshotId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parents: Vec<SnapshotId>,
pub tree: TreeId,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
pub paths: StringList,
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub uid: u32,
#[serde(default)]
pub gid: u32,
#[serde(default)]
pub tags: StringList,
pub original: Option<SnapshotId>,
#[serde(default, skip_serializing_if = "DeleteOption::is_not_set")]
pub delete: DeleteOption,
pub summary: Option<SnapshotSummary>,
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Id::is_null")]
pub id: SnapshotId,
}
impl Default for SnapshotFile {
fn default() -> Self {
Self {
time: Zoned::now(),
program_version: {
let project_version =
option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
format!("rustic {project_version}")
},
parent: Option::default(),
parents: Vec::default(),
tree: TreeId::default(),
label: String::default(),
paths: StringList::default(),
hostname: String::default(),
username: String::default(),
uid: Default::default(),
gid: Default::default(),
tags: StringList::default(),
original: Option::default(),
delete: DeleteOption::default(),
summary: Option::default(),
description: Option::default(),
id: SnapshotId::default(),
}
}
}
enum SnapshotRequest {
Latest(usize),
StartsWith(String),
Id(SnapshotId),
}
impl FromStr for SnapshotRequest {
type Err = Box<RusticError>;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = || {
RusticError::new(
ErrorKind::InvalidInput,
"Invalid snapshot identifier \"{input}\". Expected either a snapshot id: \"01a2b3c4\" or \"latest\" or \"latest~N\" (N >= 0).",
)
.attach_context("input", s)
};
let result = match s.strip_prefix("latest") {
Some(suffix) => {
if suffix.is_empty() {
Self::Latest(0)
} else {
let latest_index = suffix.strip_prefix("~").ok_or_else(err)?;
let n = latest_index.parse::<usize>().map_err(|_| err())?;
Self::Latest(n)
}
}
None => {
if s.len() < HEX_LEN {
Self::StartsWith(s.to_string())
} else {
Self::Id(s.parse()?)
}
}
};
Ok(result)
}
}
struct SnapshotRequests {
requests: Vec<SnapshotRequest>,
max_n_latest: Option<usize>,
starts_with: Vec<String>,
ids: Vec<SnapshotId>,
}
impl SnapshotRequests {
fn from_strs<S: AsRef<str>>(strings: &[S]) -> RusticResult<Self> {
let requests: Vec<SnapshotRequest> = strings
.iter()
.map(|s| s.as_ref().parse())
.collect::<RusticResult<_>>()?;
let mut max_n_latest: Option<usize> = None;
let mut starts_with = Vec::new();
let mut ids = Vec::new();
for r in &requests {
match r {
SnapshotRequest::Latest(n) => {
max_n_latest = Some(max_n_latest.unwrap_or_default().max(*n));
}
SnapshotRequest::StartsWith(s) => starts_with.push(s.clone()),
SnapshotRequest::Id(id) => ids.push(*id),
}
}
Ok(Self {
requests,
max_n_latest,
starts_with,
ids,
})
}
fn map_results<T: Clone>(
self,
latest: &[T],
vec_ids_starts_with: Vec<T>,
vec_ids: Vec<T>,
) -> Vec<T> {
let mut snaps_ids = vec_ids.into_iter();
let mut snaps_ids_start_with = vec_ids_starts_with.into_iter();
self.requests
.into_iter()
.map(|r| match r {
SnapshotRequest::Latest(n) => latest[n].clone(),
SnapshotRequest::StartsWith(..) => snaps_ids_start_with.next().unwrap(),
SnapshotRequest::Id(..) => snaps_ids.next().unwrap(),
})
.collect()
}
}
impl SnapshotFile {
pub fn from_options(opts: &SnapshotOptions) -> RusticResult<Self> {
let hostname = if let Some(host) = &opts.host {
host.clone()
} else {
let hostname = gethostname();
hostname
.to_str()
.ok_or_else(|| {
RusticError::new(
ErrorKind::InvalidInput,
"Failed to convert hostname `{hostname}` to string. The value must be a valid unicode string.",
)
.attach_context("hostname", hostname.to_string_lossy().to_string())
})?
.to_string()
};
let time = opts.time.clone().unwrap_or_else(Zoned::now);
let delete = match (opts.delete_never, opts.delete_after) {
(true, _) => DeleteOption::Never,
(_, Some(duration)) => DeleteOption::After(time.saturating_add(duration)),
(false, None) => DeleteOption::NotSet,
};
let command: String = opts.command.as_ref().map_or_else(
|| {
std::env::args_os()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ")
},
Clone::clone,
);
let mut snap = Self {
time,
hostname,
label: opts.label.clone().unwrap_or_default(),
delete,
summary: Some(SnapshotSummary {
command,
..Default::default()
}),
description: opts.description.clone(),
..Default::default()
};
if let Some(ref path) = opts.description_from {
snap.description = Some(std::fs::read_to_string(path).map_err(|err| {
RusticError::with_source(
ErrorKind::InvalidInput,
"Failed to read description file `{path}`. Please make sure the file exists and is readable.",
err,
)
.attach_context("path", path.to_string_lossy().to_string())
})?);
}
_ = snap.set_tags(opts.tags.clone());
Ok(snap)
}
fn set_id(tuple: (SnapshotId, Self)) -> Self {
let (id, mut snap) = tuple;
snap.id = id;
_ = snap.original.get_or_insert(id);
snap
}
fn from_backend<B: DecryptReadBackend>(be: &B, id: &SnapshotId) -> RusticResult<Self> {
Ok(Self::set_id((*id, be.get_file(id)?)))
}
pub(crate) fn from_str<B: DecryptReadBackend>(
be: &B,
string: &str,
predicate: impl FnMut(&Self) -> bool + Send + Sync,
p: &Progress,
) -> RusticResult<Self> {
match string.parse()? {
SnapshotRequest::Latest(n) => Self::latest_n(be, predicate, p, n),
SnapshotRequest::StartsWith(id) => Self::from_id(be, &id),
SnapshotRequest::Id(id) => Self::from_backend(be, &id),
}
}
pub(crate) fn from_strs<B: DecryptReadBackend, S: AsRef<str>>(
be: &B,
strings: &[S],
predicate: impl FnMut(&Self) -> bool + Send + Sync,
p: &Progress,
) -> RusticResult<Vec<Self>> {
let requests = SnapshotRequests::from_strs(strings)?;
match requests.max_n_latest {
None => {
let ids_starts_with = if requests.starts_with.is_empty() {
Vec::new()
} else {
be.list(FileType::Snapshot)?
.into_iter()
.find_unique_multiple(
|id, v| id.to_hex().starts_with(v),
&requests.starts_with,
)
.assert_found(&requests.starts_with)?
};
let ids: Vec<Id> = requests.ids.iter().map(|sn| **sn).collect();
let all_ids = requests.map_results(&[], ids_starts_with, ids);
Self::fill_missing(be, Vec::new(), all_ids.as_slice(), |_| true, p)
}
Some(max_n) => {
let ids: BTreeMap<_, _> = requests
.ids
.iter()
.enumerate()
.map(|(num, r)| (r, num))
.collect();
let mut vec_ids = vec![Self::default(); ids.len()];
let mut ids_starts_with = FindUniqueResults::new(&requests.starts_with);
let iter = Self::iter_all_from_backend(be, predicate, p)?.inspect(|sn| {
if let Some(idx) = ids.get(&sn.id) {
vec_ids[*idx] = sn.clone();
}
ids_starts_with.add_item(
sn.clone(),
|sn, v| sn.id.to_hex().starts_with(v),
&requests.starts_with,
);
});
let latest = Self::latest_n_from_iter(max_n, iter)?;
let vec_ids_starts_with = ids_starts_with.assert_found(&requests.starts_with)?;
Ok(requests.map_results(&latest, vec_ids_starts_with, vec_ids))
}
}
}
pub(crate) fn latest<B: DecryptReadBackend>(
be: &B,
predicate: impl FnMut(&Self) -> bool + Send + Sync,
p: &Progress,
) -> RusticResult<Self> {
Self::latest_n(be, predicate, p, 0)
}
fn latest_n_from_iter(
n: usize,
iter: impl IntoIterator<Item = Self>,
) -> RusticResult<Vec<Self>> {
let latest: Vec<_> = iter
.into_iter()
.k_smallest_by(n + 1, |s1, s2| s2.time.cmp(&s1.time))
.collect();
if latest.len() > n {
Ok(latest)
} else if n == 0 {
Err(RusticError::new(
ErrorKind::Repository,
"No snapshots found. Please make sure there are snapshots in the repository.",
))
} else {
Err(RusticError::new(
ErrorKind::Repository,
"No snapshots found for latest~{n}. Please make sure there are more than {n} snapshots in the repository.",
).attach_context("n", n.to_string()))
}
}
pub(crate) fn latest_n<B: DecryptReadBackend>(
be: &B,
predicate: impl FnMut(&Self) -> bool + Send + Sync,
p: &Progress,
n: usize,
) -> RusticResult<Self> {
if n == 0 {
p.set_title("getting latest snapshot...");
} else {
p.set_title("getting latest~N snapshot...");
}
let mut snapshots =
Self::latest_n_from_iter(n, Self::iter_all_from_backend(be, predicate, p)?)?;
p.finish();
Ok(snapshots.pop().unwrap()) }
pub(crate) fn from_id<B: DecryptReadBackend>(be: &B, id: &str) -> RusticResult<Self> {
info!("getting snapshot ...");
let id = be.find_id(FileType::Snapshot, id)?;
Self::from_backend(be, &SnapshotId::from(id))
}
pub(crate) fn from_ids<B: DecryptReadBackend, T: AsRef<str>>(
be: &B,
ids: &[T],
p: &Progress,
) -> RusticResult<Vec<Self>> {
Self::update_from_ids(be, Vec::new(), ids, p)
}
pub(crate) fn update_from_ids<B: DecryptReadBackend, T: AsRef<str>>(
be: &B,
current: Vec<Self>,
ids: &[T],
p: &Progress,
) -> RusticResult<Vec<Self>> {
let ids = be.find_ids(FileType::Snapshot, ids)?;
Self::fill_missing(be, current, &ids, |_| true, p)
}
fn fill_missing<B, F>(
be: &B,
current: Vec<Self>,
ids: &[Id],
mut filter: F,
p: &Progress,
) -> RusticResult<Vec<Self>>
where
B: DecryptReadBackend,
F: FnMut(&Self) -> bool,
{
let mut snaps: BTreeMap<_, _> = current.into_iter().map(|snap| (snap.id, snap)).collect();
let missing_ids: Vec<_> = ids
.iter()
.map(|id| SnapshotId::from(*id))
.filter(|id| !snaps.contains_key(id))
.collect();
for res in be.stream_list::<Self>(missing_ids, p)? {
let (id, snap) = res?;
if filter(&snap) {
let _ = snaps.insert(id, snap);
}
}
Ok(ids
.iter()
.filter_map(|id| {
let id = SnapshotId::from(*id);
snaps.get(&id).map(|sn| Self::set_id((id, sn.clone())))
})
.collect())
}
pub(crate) fn iter_all_from_backend<B, F>(
be: &B,
filter: F,
p: &Progress,
) -> RusticResult<impl Iterator<Item = Self>>
where
B: DecryptReadBackend,
F: FnMut(&Self) -> bool,
{
Ok(be
.stream_all::<Self>(p)?
.into_iter()
.map(|item| item.inspect_err(|err| warn!("Error reading snapshot: {err}")))
.filter_map(Result::ok)
.map(Self::set_id)
.filter(filter))
}
pub(crate) fn update_from_backend<B, F>(
be: &B,
current: Vec<Self>,
filter: F,
p: &Progress,
) -> RusticResult<Vec<Self>>
where
B: DecryptReadBackend,
F: FnMut(&Self) -> bool,
{
let ids = be.list(FileType::Snapshot)?;
Self::fill_missing(be, current, &ids, filter, p)
}
pub fn add_tags(&mut self, tag_lists: Vec<StringList>) -> bool {
let old_tags = self.tags.clone();
self.tags.add_all(tag_lists);
old_tags != self.tags
}
pub fn set_tags(&mut self, tag_lists: Vec<StringList>) -> bool {
let old_tags = std::mem::take(&mut self.tags);
self.tags.add_all(tag_lists);
old_tags != self.tags
}
pub fn remove_tags(&mut self, tag_lists: &[StringList]) -> bool {
let old_tags = self.tags.clone();
self.tags.remove_all(tag_lists);
old_tags != self.tags
}
#[must_use]
pub fn must_delete(&self, now: &Zoned) -> bool {
matches!(&self.delete, DeleteOption::After(time) if time < now)
}
#[must_use]
pub fn must_keep(&self, now: &Zoned) -> bool {
match &self.delete {
DeleteOption::Never => true,
DeleteOption::After(time) if time >= now => true,
_ => false,
}
}
pub fn modify(&mut self, modification: &SnapshotModification) -> RusticResult<bool> {
modification.apply_to(self)
}
#[must_use]
pub(crate) fn clear_ids(mut sn: Self) -> Self {
sn.id = SnapshotId::default();
sn.parent = None;
sn.parents = Vec::new();
sn
}
#[must_use]
pub fn get_parents(&self) -> &[SnapshotId] {
if self.parents.is_empty() {
self.parent.as_slice()
} else {
&self.parents
}
}
}
impl PartialEq<Self> for SnapshotFile {
fn eq(&self, other: &Self) -> bool {
self.time.eq(&other.time)
}
}
impl Eq for SnapshotFile {}
impl PartialOrd for SnapshotFile {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SnapshotFile {
fn cmp(&self, other: &Self) -> Ordering {
self.time.cmp(&other.time)
}
}
#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct StringList(pub(crate) BTreeSet<String>);
impl FromStr for StringList {
type Err = SnapshotFileErrorKind;
fn from_str(s: &str) -> SnapshotFileResult<Self> {
Ok(Self(s.split(',').map(ToString::to_string).collect()))
}
}
impl Display for StringList {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.iter().join(","))?;
Ok(())
}
}
impl StringList {
#[must_use]
pub fn contains(&self, s: &str) -> bool {
self.0.contains(s)
}
#[must_use]
pub fn contains_all(&self, sl: &Self) -> bool {
sl.0.is_subset(&self.0)
}
#[must_use]
pub fn matches(&self, sls: &[Self]) -> bool {
sls.is_empty() || sls.iter().any(|sl| self.contains_all(sl))
}
pub fn add(&mut self, s: String) {
_ = self.0.insert(s);
}
pub fn add_list(&mut self, mut sl: Self) {
self.0.append(&mut sl.0);
}
pub fn add_all(&mut self, string_lists: Vec<Self>) {
for sl in string_lists {
self.add_list(sl);
}
}
pub(crate) fn set_paths<T: AsRef<Path>>(&mut self, paths: &[T]) -> SnapshotFileResult<()> {
self.0 = paths
.iter()
.map(|p| {
Ok(p.as_ref()
.to_str()
.ok_or_else(|| SnapshotFileErrorKind::NonUnicodePath(p.as_ref().to_path_buf()))?
.to_string())
})
.collect::<SnapshotFileResult<BTreeSet<_>>>()?;
Ok(())
}
pub fn remove_all(&mut self, string_lists: &[Self]) {
for sl in string_lists {
self.0 = &self.0 - &sl.0;
}
}
#[allow(clippy::needless_pass_by_ref_mut)]
#[deprecated(note = "StringLists are now automatically sorted")]
pub fn sort(&mut self) {}
#[must_use]
pub fn formatln(&self) -> String {
self.0.iter().join("\n")
}
pub fn iter(&self) -> impl Iterator<Item = &String> {
self.0.iter()
}
}
impl<'str> IntoIterator for &'str StringList {
type Item = &'str String;
type IntoIter = std::collections::btree_set::Iter<'str, String>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct PathList(Vec<PathBuf>);
impl Display for PathList {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0
.iter()
.map(|p| p.to_string_lossy())
.format(",")
.fmt(f)
}
}
impl<T: Into<PathBuf>> FromIterator<T> for PathList {
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
Self(iter.into_iter().map(T::into).collect())
}
}
impl PathList {
pub fn from_string(source: &str) -> RusticResult<Self> {
Ok(Self(vec![source.into()]))
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.len() == 0
}
#[must_use]
pub(crate) fn paths(&self) -> Vec<PathBuf> {
self.0.clone()
}
pub fn sanitize(mut self) -> SnapshotFileResult<Self> {
for path in &mut self.0 {
*path = sanitize_dot(path)?;
}
if self.0.iter().any(|p| p.is_absolute()) {
self.0 = self
.0
.into_iter()
.map(|p| canonicalize(p).map_err(SnapshotFileErrorKind::CanonicalizingPathFailed))
.collect::<Result<_, _>>()?;
}
Ok(self.merge())
}
#[must_use]
pub fn merge(self) -> Self {
let mut paths = self.0;
paths.sort_unstable();
let mut root_path = None;
paths.retain(|path| match &root_path {
Some(root_path) if path.starts_with(root_path) => false,
_ => {
root_path = Some(path.clone());
true
}
});
Self(paths)
}
}
fn sanitize_dot(path: &Path) -> SnapshotFileResult<PathBuf> {
if path == Path::new(".") || path == Path::new("./") {
return Ok(PathBuf::from("."));
}
let path = if path.starts_with("./") {
path.strip_prefix("./").unwrap()
} else {
path
};
let path = path
.parse_dot()
.map_err(SnapshotFileErrorKind::RemovingDotsFromPathFailed)?
.to_path_buf();
Ok(path)
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};
use super::*;
use crate::{
backend::{
MockBackend,
decrypt::{DecryptBackend, DecryptWriteBackend},
},
crypto::{CryptoKey, aespoly1305::Key},
progress::NoProgress,
};
use anyhow::Result;
use bytes::Bytes;
use jiff::{Timestamp, tz::TimeZone};
use rstest::rstest;
#[rstest]
#[case(".", ".")]
#[case("./", ".")]
#[case("test", "test")]
#[case("test/", "test")]
#[case("./test", "test")]
#[case("./test/", "test")]
fn sanitize_dot_cases(#[case] input: &str, #[case] expected: &str) {
let path = Path::new(input);
let expected = PathBuf::from(expected);
assert_eq!(expected, sanitize_dot(path).unwrap());
}
#[rstest]
#[case("abc", vec!["abc".to_string()])]
#[case("abc,def", vec!["abc".to_string(), "def".to_string()])]
#[case("abc,abc", vec!["abc".to_string()])]
fn test_set_tags(#[case] tag: &str, #[case] expected: Vec<String>) -> Result<()> {
let mut snap = SnapshotFile::from_options(&SnapshotOptions::default())?;
let tags = StringList::from_str(tag)?;
let expected = StringList(expected.into_iter().collect());
assert!(snap.set_tags(vec![tags]));
assert_eq!(snap.tags, expected);
Ok(())
}
#[test]
fn test_add_tags() -> Result<()> {
let tags = vec![StringList::from_str("abc")?];
let mut snap = SnapshotFile::from_options(&SnapshotOptions::default().tags(tags))?;
let tags = StringList::from_str("def,abc")?;
assert!(snap.add_tags(vec![tags]));
let expected = StringList::from_str("abc,def")?;
assert_eq!(snap.tags, expected);
Ok(())
}
#[rstest]
#[case(vec![], "")]
#[case(vec!["test"], "test")]
#[case(vec!["test", "test", "test"], "test,test,test")]
fn test_display_path_list_passes(#[case] input: Vec<&str>, #[case] expected: &str) {
let path_list = PathList::from_iter(input);
let result = path_list.to_string();
assert_eq!(expected, &result);
}
fn fake_snapshot_file_with_id_time(
id_time_vec: Vec<(Id, Zoned)>,
key: &Key,
) -> HashMap<Id, Bytes> {
let mut res = HashMap::new();
for (id, time) in id_time_vec {
let snapshot_file = SnapshotFile {
id: SnapshotId(id),
time,
..Default::default()
};
let encrypted = Bytes::from(
key.encrypt_data(serde_json::to_string(&snapshot_file).unwrap().as_bytes())
.unwrap(),
);
let _ = res.insert(id, encrypted);
}
res
}
fn setup_mock_backend() -> (DecryptBackend<Key>, [Id; 3]) {
let key = Key::new();
let id1 = Id::from_str("0011223344556677001122334455667700112233445566770000000000000001")
.unwrap();
let id2 = Id::from_str("0021223344556677001122334455667700112233445566770000000000000002")
.unwrap();
let id3 = Id::from_str("0031223344556677001122334455667700112233445566770000000000000003")
.unwrap();
let snapshot_files = fake_snapshot_file_with_id_time(
vec![
(
id1,
Timestamp::from_second(1_752_483_600)
.unwrap()
.to_zoned(TimeZone::UTC),
),
(
id2,
Timestamp::from_second(1_752_483_700)
.unwrap()
.to_zoned(TimeZone::UTC),
),
(
id3,
Timestamp::from_second(1_752_483_800)
.unwrap()
.to_zoned(TimeZone::UTC),
),
],
&key,
);
let mut back = MockBackend::new();
let _ = back.expect_list_with_size().returning(move |_| {
Ok(vec![(id2, 0), (id3, 0), (id1, 0)])
});
let _ = back
.expect_read_full()
.returning(move |_tpe, id| Ok(snapshot_files.get(id).unwrap().clone()));
let mut be = DecryptBackend::new(Arc::new(back), key);
be.set_zstd(None);
(be, [id1, id2, id3])
}
#[rstest]
fn test_snapshot_file_latest() {
let p = Progress::new(NoProgress);
let (be, [id1, id2, id3]) = setup_mock_backend();
let latest = SnapshotFile::latest(&be, |_sn| true, &p).unwrap();
assert_eq!(latest.id, SnapshotId(id3));
let latest_n0 = SnapshotFile::latest_n(&be, |_sn| true, &p, 0).unwrap();
assert_eq!(latest_n0, latest);
let latest_n1 = SnapshotFile::latest_n(&be, |_sn| true, &p, 1).unwrap();
assert_eq!(latest_n1.id, SnapshotId(id2));
let latest_n2 = SnapshotFile::latest_n(&be, |_sn| true, &p, 2).unwrap();
assert_eq!(latest_n2.id, SnapshotId(id1));
let latest_n3 = SnapshotFile::latest_n(&be, |_sn| true, &p, 3);
let latest_n3_err = latest_n3.unwrap_err().to_string();
let expected = "No snapshots found for latest~3.";
assert!(
latest_n3_err.contains(expected),
"Err is: {latest_n3_err}\n\nShould contain: {expected}",
);
}
#[rstest]
fn test_snapshot_file_from_str() {
let p = Progress::new(NoProgress);
let (be, [id1, id2, id3]) = setup_mock_backend();
let latest = SnapshotFile::from_str(&be, "latest", |_sn| true, &p).unwrap();
assert_eq!(latest.id, SnapshotId(id3));
let latest_n0 = SnapshotFile::from_str(&be, "latest~0", |_sn| true, &p).unwrap();
assert_eq!(latest_n0, latest);
let snap_id3 = SnapshotFile::from_str(
&be,
"0031223344556677001122334455667700112233445566770000000000000003",
|_sn| true,
&p,
)
.unwrap();
assert_eq!(latest, snap_id3);
let snap_id3 = SnapshotFile::from_str(&be, "003", |_sn| true, &p).unwrap();
assert_eq!(latest, snap_id3);
let latest_n1 = SnapshotFile::from_str(&be, "latest~1", |_sn| true, &p).unwrap();
assert_eq!(latest_n1.id, SnapshotId(id2));
let latest_n2 = SnapshotFile::from_str(&be, "latest~2", |_sn| true, &p).unwrap();
assert_eq!(latest_n2.id, SnapshotId(id1));
let latest_n3 = SnapshotFile::from_str(&be, "latest~3", |_sn| true, &p);
let latest_n3_err = latest_n3.unwrap_err().to_string();
let expected = "No snapshots found for latest~3.";
assert!(
latest_n3_err.contains(expected),
"Err is: {latest_n3_err}\n\nShould contain: {expected}",
);
let latest_syntax_err = SnapshotFile::from_str(&be, "laztet~1", |_sn| true, &p)
.unwrap_err()
.to_string();
let expected = "No suitable id found for `laztet~1`.";
assert!(
latest_syntax_err.contains(expected),
"Err is: {latest_syntax_err}\n\nShould contain: {expected}",
);
}
#[rstest]
fn test_snapshot_file_from_strs() {
let p = Progress::new(NoProgress);
let (be, [id1, id2, id3]) = setup_mock_backend();
let snaps = SnapshotFile::from_strs(
&be,
&[
"latest~2",
"002",
"0031223344556677001122334455667700112233445566770000000000000003",
],
|_sn| true,
&p,
)
.unwrap();
let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
assert_eq!(ids, vec![id1, id2, id3]);
let snaps = SnapshotFile::from_strs(
&be,
&[
"0021223344556677001122334455667700112233445566770000000000000002",
"latest~1",
"001",
],
|_sn| true,
&p,
)
.unwrap();
let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
assert_eq!(ids, vec![id2, id2, id1]);
let snaps = SnapshotFile::from_strs(&be, &["latest", "latest~1"], |_sn| true, &p).unwrap();
let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
assert_eq!(ids, vec![id3, id2]);
let latest_n3 = SnapshotFile::from_strs(&be, &["003", "latest~3"], |_sn| true, &p);
let latest_n3_err = latest_n3.unwrap_err().to_string();
let expected = "No snapshots found for latest~3.";
assert!(
latest_n3_err.contains(expected),
"Err is: {latest_n3_err}\n\nShould contain: {expected}",
);
let snaps = SnapshotFile::from_strs(
&be,
&[
"0031223344556677001122334455667700112233445566770000000000000003",
"001",
],
|_sn| true,
&p,
)
.unwrap();
let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
assert_eq!(ids, vec![id3, id1]);
let snaps = SnapshotFile::from_strs(
&be,
&[
"0031223344556677001122334455667700112233445566770000000000000003",
"0011223344556677001122334455667700112233445566770000000000000001",
"0031223344556677001122334455667700112233445566770000000000000003",
],
|_sn| true,
&p,
)
.unwrap();
let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
assert_eq!(ids, vec![id3, id1, id3]);
}
}