Skip to main content

rustic_core/repofile/
snapshotfile.rs

1pub mod grouping;
2mod modification;
3
4pub use modification::SnapshotModification;
5
6use std::{
7    cmp::Ordering,
8    collections::{BTreeMap, BTreeSet},
9    fmt::{self, Display},
10    path::{Path, PathBuf},
11    str::FromStr,
12};
13
14use derive_setters::Setters;
15use dunce::canonicalize;
16use gethostname::gethostname;
17use itertools::Itertools;
18use jiff::{Span, Unit, Zoned};
19use log::{info, warn};
20use path_dedot::ParseDot;
21use serde::{Deserialize, Serialize};
22use serde_with::{DisplayFromStr, serde_as, skip_serializing_none};
23
24#[cfg(feature = "clap")]
25use clap::ValueHint;
26
27use crate::{
28    Id,
29    backend::{FileType, FindInBackend, decrypt::DecryptReadBackend},
30    blob::tree::TreeId,
31    error::{ErrorKind, RusticError, RusticResult},
32    id::{FindUniqueMultiple, FindUniqueResults, constants::HEX_LEN},
33    impl_repofile,
34    progress::Progress,
35    repofile::{RepoFile, RusticTime},
36};
37
38/// [`SnapshotFileErrorKind`] describes the errors that can be returned for `SnapshotFile`s
39#[derive(thiserror::Error, Debug, displaydoc::Display)]
40#[non_exhaustive]
41pub enum SnapshotFileErrorKind {
42    /// non-unicode path `{0:?}`
43    NonUnicodePath(PathBuf),
44    /// value `{0:?}` not allowed
45    ValueNotAllowed(String),
46    /// removing dots from paths failed: `{0:?}`
47    RemovingDotsFromPathFailed(std::io::Error),
48    /// canonicalizing path failed: `{0:?}`
49    CanonicalizingPathFailed(std::io::Error),
50}
51
52pub(crate) type SnapshotFileResult<T> = Result<T, SnapshotFileErrorKind>;
53
54/// Options for creating a new [`SnapshotFile`] structure for a new backup snapshot.
55///
56/// This struct derives [`serde::Deserialize`] allowing to use it in config files.
57///
58/// # Features
59///
60/// * With the feature `merge` enabled, this also derives [`conflate::Merge`] to allow merging [`SnapshotOptions`] from multiple sources.
61/// * With the feature `clap` enabled, this also derives [`clap::Parser`] allowing it to be used as CLI options.
62///
63/// # Note
64///
65/// The preferred way is to use [`SnapshotFile::from_options`] to create a `SnapshotFile` for a new backup.
66#[serde_as]
67#[cfg_attr(feature = "merge", derive(conflate::Merge))]
68#[cfg_attr(feature = "clap", derive(clap::Parser))]
69#[derive(Deserialize, Serialize, Clone, Default, Debug, Setters)]
70#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
71#[setters(into)]
72#[non_exhaustive]
73pub struct SnapshotOptions {
74    /// Label snapshot with given label
75    #[cfg_attr(feature = "clap", clap(long, value_name = "LABEL"))]
76    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
77    pub label: Option<String>,
78
79    /// Tags to add to snapshot (can be specified multiple times)
80    #[serde_as(as = "Vec<DisplayFromStr>")]
81    #[cfg_attr(feature = "clap", clap(long = "tag", value_name = "TAG[,TAG,..]"))]
82    #[cfg_attr(feature = "merge", merge(strategy = conflate::vec::overwrite_empty))]
83    pub tags: Vec<StringList>,
84
85    /// Add description to snapshot
86    #[cfg_attr(feature = "clap", clap(long, value_name = "DESCRIPTION"))]
87    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
88    pub description: Option<String>,
89
90    /// Add description to snapshot from file
91    #[cfg_attr(
92        feature = "clap",
93        clap(long, value_name = "FILE", conflicts_with = "description", value_hint = ValueHint::FilePath)
94    )]
95    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
96    pub description_from: Option<PathBuf>,
97
98    /// Set the backup time manually (e.g. "2021-01-21 14:15:23+0000")
99    #[cfg_attr(feature = "clap", clap(long,value_parser = RusticTime::parse_system))]
100    #[serde_as(as = "Option<RusticTime>")]
101    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
102    pub time: Option<Zoned>,
103
104    /// Mark snapshot as uneraseable
105    #[cfg_attr(feature = "clap", clap(long, conflicts_with = "delete_after"))]
106    #[cfg_attr(feature = "merge", merge(strategy = conflate::bool::overwrite_false))]
107    pub delete_never: bool,
108
109    /// Mark snapshot to be deleted after given duration (e.g. 10d)
110    #[cfg_attr(feature = "clap", clap(long, value_name = "DURATION"))]
111    #[serde_as(as = "Option<DisplayFromStr>")]
112    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
113    pub delete_after: Option<Span>,
114
115    /// Set the host name manually
116    #[cfg_attr(feature = "clap", clap(long, value_name = "NAME"))]
117    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
118    pub host: Option<String>,
119
120    /// Set the backup command manually
121    #[cfg_attr(feature = "clap", clap(long))]
122    #[cfg_attr(feature = "merge", merge(strategy = conflate::option::overwrite_none))]
123    pub command: Option<String>,
124}
125
126impl SnapshotOptions {
127    /// Add tags to this [`SnapshotOptions`]
128    ///
129    /// # Arguments
130    ///
131    /// * `tag` - The tag to add
132    ///
133    /// # Errors
134    ///
135    /// * If the tag is not valid unicode
136    ///
137    /// # Returns
138    ///
139    /// The modified [`SnapshotOptions`]
140    pub fn add_tags(mut self, tag: &str) -> RusticResult<Self> {
141        self.tags.push(StringList::from_str(tag).map_err(|err| {
142            RusticError::with_source(
143                ErrorKind::InvalidInput,
144                "Failed to create string list from tag `{tag}`. The value must be a valid unicode string.",
145                err,
146            )
147            .attach_context("tag", tag)
148        })?);
149        Ok(self)
150    }
151
152    /// Create a new [`SnapshotFile`] using this `SnapshotOption`s
153    ///
154    /// # Errors
155    ///
156    /// * If the hostname is not valid unicode
157    ///
158    /// # Returns
159    ///
160    /// The new [`SnapshotFile`]
161    pub fn to_snapshot(&self) -> RusticResult<SnapshotFile> {
162        SnapshotFile::from_options(self)
163    }
164}
165
166/// Summary information about a snapshot.
167///
168/// This is an extended version of the summaryOutput structure of restic in
169/// restic/internal/ui/backup$/json.go
170#[serde_as]
171#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
172#[serde(default)]
173#[non_exhaustive]
174pub struct SnapshotSummary {
175    /// New files compared to the last (i.e. parent) snapshot
176    pub files_new: u64,
177
178    /// Changed files compared to the last (i.e. parent) snapshot
179    pub files_changed: u64,
180
181    /// Unchanged files compared to the last (i.e. parent) snapshot
182    pub files_unmodified: u64,
183
184    /// Total processed files
185    pub total_files_processed: u64,
186
187    /// Total size of all processed files
188    pub total_bytes_processed: u64,
189
190    /// New directories compared to the last (i.e. parent) snapshot
191    pub dirs_new: u64,
192
193    /// Changed directories compared to the last (i.e. parent) snapshot
194    pub dirs_changed: u64,
195
196    /// Unchanged directories compared to the last (i.e. parent) snapshot
197    pub dirs_unmodified: u64,
198
199    /// Total processed directories
200    pub total_dirs_processed: u64,
201
202    /// Total size of all processed dirs
203    pub total_dirsize_processed: u64,
204
205    /// Total number of data blobs added by this snapshot
206    pub data_blobs: u64,
207
208    /// Total number of tree blobs added by this snapshot
209    pub tree_blobs: u64,
210
211    /// Total uncompressed bytes added by this snapshot
212    pub data_added: u64,
213
214    /// Total bytes added to the repository by this snapshot
215    pub data_added_packed: u64,
216
217    /// Total uncompressed bytes (new/changed files) added by this snapshot
218    pub data_added_files: u64,
219
220    /// Total bytes for new/changed files added to the repository by this snapshot
221    pub data_added_files_packed: u64,
222
223    /// Total uncompressed bytes (new/changed directories) added by this snapshot
224    pub data_added_trees: u64,
225
226    /// Total bytes (new/changed directories) added to the repository by this snapshot
227    pub data_added_trees_packed: u64,
228
229    /// The command used to make this backup
230    pub command: String,
231
232    /// Start time of the backup.
233    ///
234    /// # Note
235    ///
236    /// This may differ from the snapshot `time`.
237    #[serde_as(as = "RusticTime")]
238    pub backup_start: Zoned,
239
240    /// The time that the backup has been finished.
241    #[serde_as(as = "RusticTime")]
242    pub backup_end: Zoned,
243
244    /// Total duration of the backup in seconds, i.e. the time between `backup_start` and `backup_end`
245    pub backup_duration: f64,
246
247    /// Total duration that the rustic command ran in seconds
248    pub total_duration: f64,
249}
250
251impl Default for SnapshotSummary {
252    fn default() -> Self {
253        Self {
254            files_new: Default::default(),
255            files_changed: Default::default(),
256            files_unmodified: Default::default(),
257            total_files_processed: Default::default(),
258            total_bytes_processed: Default::default(),
259            dirs_new: Default::default(),
260            dirs_changed: Default::default(),
261            dirs_unmodified: Default::default(),
262            total_dirs_processed: Default::default(),
263            total_dirsize_processed: Default::default(),
264            data_blobs: Default::default(),
265            tree_blobs: Default::default(),
266            data_added: Default::default(),
267            data_added_packed: Default::default(),
268            data_added_files: Default::default(),
269            data_added_files_packed: Default::default(),
270            data_added_trees: Default::default(),
271            data_added_trees_packed: Default::default(),
272            command: String::default(),
273            backup_start: Zoned::now(),
274            backup_end: Zoned::now(),
275            backup_duration: Default::default(),
276            total_duration: Default::default(),
277        }
278    }
279}
280
281impl SnapshotSummary {
282    /// Create a new [`SnapshotSummary`].
283    ///
284    /// # Arguments
285    ///
286    /// * `snap_time` - The time of the snapshot
287    ///
288    /// # Errors
289    ///
290    /// * If the time is not in the range of `Local::now()`
291    pub(crate) fn finalize(&mut self, snap_time: &Zoned) {
292        let end_time = Zoned::now();
293        self.backup_duration = end_time
294            .since(&self.backup_start)
295            .and_then(|span| span.total(Unit::Second))
296            .inspect_err(|err| warn!("ignoring Datetime error: {err}"))
297            .unwrap_or_default();
298        self.total_duration = end_time
299            .since(snap_time)
300            .and_then(|span| span.total(Unit::Second))
301            .inspect_err(|err| warn!("ignoring Datetime error: {err}"))
302            .unwrap_or_default();
303        self.backup_end = end_time;
304    }
305}
306
307/// Options for deleting snapshots.
308#[derive(Serialize, Default, Deserialize, Debug, Clone, PartialEq, Eq)]
309pub enum DeleteOption {
310    /// No delete option set.
311    #[default]
312    NotSet,
313    /// This snapshot should be never deleted (remove-protection).
314    Never,
315    /// Remove this snapshot after the given timestamp, but prevent removing it before.
316    After(Zoned),
317}
318
319impl DeleteOption {
320    /// Returns whether the delete option is set to `NotSet`.
321    const fn is_not_set(&self) -> bool {
322        matches!(self, Self::NotSet)
323    }
324}
325
326impl_repofile!(SnapshotId, FileType::Snapshot, SnapshotFile);
327
328#[serde_as]
329#[skip_serializing_none]
330#[derive(Debug, Clone, Serialize, Deserialize)]
331/// A [`SnapshotFile`] is the repository representation of the snapshot metadata saved in a repository.
332///
333/// It is usually saved in the repository under `snapshot/<ID>`
334///
335/// # Note
336///
337/// [`SnapshotFile`] implements [`Eq`], [`PartialEq`], [`Ord`], [`PartialOrd`] by comparing only the `time` field.
338/// If you need another ordering, you have to implement that yourself.
339pub struct SnapshotFile {
340    /// Timestamp of this snapshot
341    #[serde_as(as = "RusticTime")]
342    pub time: Zoned,
343
344    /// Program identifier and its version that have been used to create this snapshot.
345    #[serde(default, skip_serializing_if = "String::is_empty")]
346    pub program_version: String,
347
348    /// The Id of the first parent snapshot that this snapshot has been based on
349    pub parent: Option<SnapshotId>,
350
351    /// The Ids of all parent snapshots that this snapshot has been based on
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub parents: Vec<SnapshotId>,
354
355    /// The tree blob id where the contents of this snapshot are stored
356    pub tree: TreeId,
357
358    /// Label for the snapshot
359    #[serde(default, skip_serializing_if = "String::is_empty")]
360    pub label: String,
361
362    /// The list of paths contained in this snapshot
363    pub paths: StringList,
364
365    /// The hostname of the device on which the snapshot has been created
366    #[serde(default)]
367    pub hostname: String,
368
369    /// The username that started the backup run
370    #[serde(default)]
371    pub username: String,
372
373    /// The uid of the username that started the backup run
374    #[serde(default)]
375    pub uid: u32,
376
377    /// The gid of the username that started the backup run
378    #[serde(default)]
379    pub gid: u32,
380
381    /// A list of tags for this snapshot
382    #[serde(default)]
383    pub tags: StringList,
384
385    /// The original Id of this snapshot. This is stored when the snapshot is modified.
386    pub original: Option<SnapshotId>,
387
388    /// Options for deletion of the snapshot
389    #[serde(default, skip_serializing_if = "DeleteOption::is_not_set")]
390    pub delete: DeleteOption,
391
392    /// Summary information about the backup run
393    pub summary: Option<SnapshotSummary>,
394
395    /// A description of what is contained in this snapshot
396    pub description: Option<String>,
397
398    /// The snapshot Id (not stored within the JSON)
399    #[serde(default, skip_serializing_if = "Id::is_null")]
400    pub id: SnapshotId,
401}
402
403impl Default for SnapshotFile {
404    fn default() -> Self {
405        Self {
406            time: Zoned::now(),
407            program_version: {
408                let project_version =
409                    option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
410                format!("rustic {project_version}")
411            },
412            parent: Option::default(),
413            parents: Vec::default(),
414            tree: TreeId::default(),
415            label: String::default(),
416            paths: StringList::default(),
417            hostname: String::default(),
418            username: String::default(),
419            uid: Default::default(),
420            gid: Default::default(),
421            tags: StringList::default(),
422            original: Option::default(),
423            delete: DeleteOption::default(),
424            summary: Option::default(),
425            description: Option::default(),
426            id: SnapshotId::default(),
427        }
428    }
429}
430
431enum SnapshotRequest {
432    Latest(usize),
433    StartsWith(String),
434    Id(SnapshotId),
435}
436
437impl FromStr for SnapshotRequest {
438    type Err = Box<RusticError>;
439    fn from_str(s: &str) -> Result<Self, Self::Err> {
440        let err = || {
441            RusticError::new(
442                    ErrorKind::InvalidInput,
443                    "Invalid snapshot identifier \"{input}\". Expected either a snapshot id: \"01a2b3c4\" or \"latest\" or \"latest~N\" (N >= 0).",
444                )
445                .attach_context("input", s)
446        };
447
448        let result = match s.strip_prefix("latest") {
449            Some(suffix) => {
450                if suffix.is_empty() {
451                    Self::Latest(0)
452                } else {
453                    let latest_index = suffix.strip_prefix("~").ok_or_else(err)?;
454                    let n = latest_index.parse::<usize>().map_err(|_| err())?;
455                    Self::Latest(n)
456                }
457            }
458            None => {
459                if s.len() < HEX_LEN {
460                    Self::StartsWith(s.to_string())
461                } else {
462                    Self::Id(s.parse()?)
463                }
464            }
465        };
466        Ok(result)
467    }
468}
469
470struct SnapshotRequests {
471    requests: Vec<SnapshotRequest>,
472    max_n_latest: Option<usize>,
473    starts_with: Vec<String>,
474    ids: Vec<SnapshotId>,
475}
476
477impl SnapshotRequests {
478    fn from_strs<S: AsRef<str>>(strings: &[S]) -> RusticResult<Self> {
479        let requests: Vec<SnapshotRequest> = strings
480            .iter()
481            .map(|s| s.as_ref().parse())
482            .collect::<RusticResult<_>>()?;
483
484        let mut max_n_latest: Option<usize> = None;
485        let mut starts_with = Vec::new();
486        let mut ids = Vec::new();
487        for r in &requests {
488            match r {
489                SnapshotRequest::Latest(n) => {
490                    max_n_latest = Some(max_n_latest.unwrap_or_default().max(*n));
491                }
492                SnapshotRequest::StartsWith(s) => starts_with.push(s.clone()),
493                SnapshotRequest::Id(id) => ids.push(*id),
494            }
495        }
496        Ok(Self {
497            requests,
498            max_n_latest,
499            starts_with,
500            ids,
501        })
502    }
503
504    fn map_results<T: Clone>(
505        self,
506        latest: &[T],
507        vec_ids_starts_with: Vec<T>,
508        vec_ids: Vec<T>,
509    ) -> Vec<T> {
510        let mut snaps_ids = vec_ids.into_iter();
511        let mut snaps_ids_start_with = vec_ids_starts_with.into_iter();
512        self.requests
513            .into_iter()
514            .map(|r| match r {
515                SnapshotRequest::Latest(n) => latest[n].clone(),
516                SnapshotRequest::StartsWith(..) => snaps_ids_start_with.next().unwrap(),
517                SnapshotRequest::Id(..) => snaps_ids.next().unwrap(),
518            })
519            .collect()
520    }
521}
522
523impl SnapshotFile {
524    /// Create a [`SnapshotFile`] from [`SnapshotOptions`].
525    ///
526    /// # Arguments
527    ///
528    /// * `opts` - The [`SnapshotOptions`] to use
529    ///
530    /// # Errors
531    ///
532    /// * If the hostname is not valid unicode
533    /// * If the delete time is not in the range of `Local::now()`
534    /// * If the description file could not be read
535    ///
536    /// # Note
537    ///
538    /// This is the preferred way to create a new [`SnapshotFile`] to be used within [`crate::Repository::backup`].
539    pub fn from_options(opts: &SnapshotOptions) -> RusticResult<Self> {
540        let hostname = if let Some(host) = &opts.host {
541            host.clone()
542        } else {
543            let hostname = gethostname();
544            hostname
545                .to_str()
546                .ok_or_else(|| {
547                    RusticError::new(
548                        ErrorKind::InvalidInput,
549                        "Failed to convert hostname `{hostname}` to string. The value must be a valid unicode string.",
550                    )
551                    .attach_context("hostname", hostname.to_string_lossy().to_string())
552                })?
553                .to_string()
554        };
555
556        let time = opts.time.clone().unwrap_or_else(Zoned::now);
557
558        let delete = match (opts.delete_never, opts.delete_after) {
559            (true, _) => DeleteOption::Never,
560            (_, Some(duration)) => DeleteOption::After(time.saturating_add(duration)),
561            (false, None) => DeleteOption::NotSet,
562        };
563
564        let command: String = opts.command.as_ref().map_or_else(
565            || {
566                std::env::args_os()
567                    .map(|s| s.to_string_lossy().to_string())
568                    .collect::<Vec<_>>()
569                    .join(" ")
570            },
571            Clone::clone,
572        );
573
574        let mut snap = Self {
575            time,
576            hostname,
577            label: opts.label.clone().unwrap_or_default(),
578            delete,
579            summary: Some(SnapshotSummary {
580                command,
581                ..Default::default()
582            }),
583            description: opts.description.clone(),
584            ..Default::default()
585        };
586
587        // use description from description file if it is given
588        if let Some(ref path) = opts.description_from {
589            snap.description = Some(std::fs::read_to_string(path).map_err(|err| {
590                RusticError::with_source(
591                    ErrorKind::InvalidInput,
592                    "Failed to read description file `{path}`. Please make sure the file exists and is readable.",
593                    err,
594                )
595                .attach_context("path", path.to_string_lossy().to_string())
596            })?);
597        }
598
599        _ = snap.set_tags(opts.tags.clone());
600
601        Ok(snap)
602    }
603
604    /// Create a [`SnapshotFile`] from a given [`Id`] and [`RepoFile`].
605    ///
606    /// # Arguments
607    ///
608    /// * `tuple` - A tuple of the [`Id`] and the [`RepoFile`] to use
609    fn set_id(tuple: (SnapshotId, Self)) -> Self {
610        let (id, mut snap) = tuple;
611        snap.id = id;
612        _ = snap.original.get_or_insert(id);
613        snap
614    }
615
616    /// Get a [`SnapshotFile`] from the backend
617    ///
618    /// # Arguments
619    ///
620    /// * `be` - The backend to use
621    /// * `id` - The id of the snapshot
622    fn from_backend<B: DecryptReadBackend>(be: &B, id: &SnapshotId) -> RusticResult<Self> {
623        Ok(Self::set_id((*id, be.get_file(id)?)))
624    }
625
626    /// Get a [`SnapshotFile`] from the backend by (part of the) Id
627    ///
628    /// Works with a snapshot `Id` or a `latest` indexed syntax: `latest` or `latest~N` with N >= 0
629    ///
630    /// # Arguments
631    ///
632    /// * `be` - The backend to use
633    /// * `string` - The (part of the) id of the snapshot
634    /// * `predicate` - A predicate to filter the snapshots
635    /// * `p` - A progress bar to use
636    ///
637    /// # Errors
638    ///
639    /// * If the string is not a valid hexadecimal string
640    /// * If no id could be found.
641    /// * If the id is not unique.
642    /// * If the `latest` syntax is "detected" but inexact
643    pub(crate) fn from_str<B: DecryptReadBackend>(
644        be: &B,
645        string: &str,
646        predicate: impl FnMut(&Self) -> bool + Send + Sync,
647        p: &Progress,
648    ) -> RusticResult<Self> {
649        match string.parse()? {
650            SnapshotRequest::Latest(n) => Self::latest_n(be, predicate, p, n),
651            SnapshotRequest::StartsWith(id) => Self::from_id(be, &id),
652            SnapshotRequest::Id(id) => Self::from_backend(be, &id),
653        }
654    }
655
656    /// Get a [`Vec<SnapshotFile>`] from the backend by (part of the) Ids
657    ///
658    /// Works with a snapshot `Id` or a `latest` indexed syntax: `latest` or `latest~N` with N >= 0
659    ///
660    /// # Arguments
661    ///
662    /// * `be` - The backend to use
663    /// * `string` - The (part of the) id of the snapshot
664    /// * `predicate` - A predicate to filter the snapshots
665    /// * `p` - A progress bar to use
666    ///
667    /// # Errors
668    ///
669    /// * If the string is not a valid hexadecimal string
670    /// * If no id could be found.
671    /// * If the id is not unique.
672    /// * If the `latest` syntax is "detected" but inexact
673    pub(crate) fn from_strs<B: DecryptReadBackend, S: AsRef<str>>(
674        be: &B,
675        strings: &[S],
676        predicate: impl FnMut(&Self) -> bool + Send + Sync,
677        p: &Progress,
678    ) -> RusticResult<Vec<Self>> {
679        let requests = SnapshotRequests::from_strs(strings)?;
680
681        match requests.max_n_latest {
682            None => {
683                //  specialize for only start_with and ids
684                let ids_starts_with = if requests.starts_with.is_empty() {
685                    Vec::new()
686                } else {
687                    be.list(FileType::Snapshot)?
688                        .into_iter()
689                        .find_unique_multiple(
690                            |id, v| id.to_hex().starts_with(v),
691                            &requests.starts_with,
692                        )
693                        .assert_found(&requests.starts_with)?
694                };
695
696                let ids: Vec<Id> = requests.ids.iter().map(|sn| **sn).collect();
697                let all_ids = requests.map_results(&[], ids_starts_with, ids);
698
699                Self::fill_missing(be, Vec::new(), all_ids.as_slice(), |_| true, p)
700            }
701            Some(max_n) => {
702                let ids: BTreeMap<_, _> = requests
703                    .ids
704                    .iter()
705                    .enumerate()
706                    .map(|(num, r)| (r, num))
707                    .collect();
708                let mut vec_ids = vec![Self::default(); ids.len()];
709                let mut ids_starts_with = FindUniqueResults::new(&requests.starts_with);
710
711                // search for id names while iterating snapshots to get latest ones
712                let iter = Self::iter_all_from_backend(be, predicate, p)?.inspect(|sn| {
713                    if let Some(idx) = ids.get(&sn.id) {
714                        vec_ids[*idx] = sn.clone();
715                    }
716                    ids_starts_with.add_item(
717                        sn.clone(),
718                        |sn, v| sn.id.to_hex().starts_with(v),
719                        &requests.starts_with,
720                    );
721                });
722                let latest = Self::latest_n_from_iter(max_n, iter)?;
723                let vec_ids_starts_with = ids_starts_with.assert_found(&requests.starts_with)?;
724                Ok(requests.map_results(&latest, vec_ids_starts_with, vec_ids))
725            }
726        }
727    }
728
729    /// Get the latest [`SnapshotFile`] from the backend
730    ///
731    /// # Arguments
732    ///
733    /// * `be` - The backend to use
734    /// * `predicate` - A predicate to filter the snapshots
735    /// * `p` - A progress bar to use
736    ///
737    /// # Errors
738    ///
739    /// * If no snapshots are found
740    pub(crate) fn latest<B: DecryptReadBackend>(
741        be: &B,
742        predicate: impl FnMut(&Self) -> bool + Send + Sync,
743        p: &Progress,
744    ) -> RusticResult<Self> {
745        Self::latest_n(be, predicate, p, 0)
746    }
747
748    fn latest_n_from_iter(
749        n: usize,
750        iter: impl IntoIterator<Item = Self>,
751    ) -> RusticResult<Vec<Self>> {
752        let latest: Vec<_> = iter
753            .into_iter()
754            // find n+1 smallest elements when sorting in decreasing time order
755            .k_smallest_by(n + 1, |s1, s2| s2.time.cmp(&s1.time))
756            .collect();
757
758        if latest.len() > n {
759            Ok(latest)
760        } else if n == 0 {
761            Err(RusticError::new(
762                ErrorKind::Repository,
763                "No snapshots found. Please make sure there are snapshots in the repository.",
764            ))
765        } else {
766            Err(RusticError::new(
767                ErrorKind::Repository,
768                "No snapshots found for latest~{n}. Please make sure there are more than {n} snapshots in the repository.",
769            ).attach_context("n", n.to_string()))
770        }
771    }
772
773    /// Get the latest [`SnapshotFile`] from the backend
774    ///
775    /// # Arguments
776    ///
777    /// * `be` - The backend to use
778    /// * `predicate` - A predicate to filter the snapshots
779    /// * `p` - A progress bar to use
780    /// * `n` - The n-latest index to go back for snapshot
781    ///
782    /// # Errors
783    ///
784    /// * If no snapshots are found
785    pub(crate) fn latest_n<B: DecryptReadBackend>(
786        be: &B,
787        predicate: impl FnMut(&Self) -> bool + Send + Sync,
788        p: &Progress,
789        n: usize,
790    ) -> RusticResult<Self> {
791        if n == 0 {
792            p.set_title("getting latest snapshot...");
793        } else {
794            p.set_title("getting latest~N snapshot...");
795        }
796        let mut snapshots =
797            Self::latest_n_from_iter(n, Self::iter_all_from_backend(be, predicate, p)?)?;
798
799        p.finish();
800        Ok(snapshots.pop().unwrap()) // we want the latest element if we found n+1 snapshots
801    }
802
803    /// Get a [`SnapshotFile`] from the backend by (part of the) id
804    ///
805    /// # Arguments
806    ///
807    /// * `be` - The backend to use
808    /// * `id` - The (part of the) id of the snapshot
809    ///
810    /// # Errors
811    ///
812    /// * If the string is not a valid hexadecimal string
813    /// * If no id could be found.
814    /// * If the id is not unique.
815    pub(crate) fn from_id<B: DecryptReadBackend>(be: &B, id: &str) -> RusticResult<Self> {
816        info!("getting snapshot ...");
817        let id = be.find_id(FileType::Snapshot, id)?;
818        Self::from_backend(be, &SnapshotId::from(id))
819    }
820
821    /// Get a list of [`SnapshotFile`]s from the backend by supplying a list of/parts of their Ids
822    ///
823    /// # Arguments
824    ///
825    /// * `be` - The backend to use
826    /// * `ids` - The list of (parts of the) ids of the snapshots
827    /// * `p` - A progress bar to use
828    ///
829    /// # Errors
830    ///
831    /// * If the string is not a valid hexadecimal string
832    /// * If no id could be found.
833    /// * If the id is not unique.
834    pub(crate) fn from_ids<B: DecryptReadBackend, T: AsRef<str>>(
835        be: &B,
836        ids: &[T],
837        p: &Progress,
838    ) -> RusticResult<Vec<Self>> {
839        Self::update_from_ids(be, Vec::new(), ids, p)
840    }
841
842    /// Update a list of [`SnapshotFile`]s from the backend by supplying a list of/parts of their Ids
843    ///
844    /// # Arguments
845    ///
846    /// * `be` - The backend to use
847    /// * `ids` - The list of (parts of the) ids of the snapshots
848    /// * `p` - A progress bar to use
849    ///
850    /// # Errors
851    ///
852    /// * If the string is not a valid hexadecimal string
853    /// * If no id could be found.
854    /// * If the id is not unique.
855    pub(crate) fn update_from_ids<B: DecryptReadBackend, T: AsRef<str>>(
856        be: &B,
857        current: Vec<Self>,
858        ids: &[T],
859        p: &Progress,
860    ) -> RusticResult<Vec<Self>> {
861        let ids = be.find_ids(FileType::Snapshot, ids)?;
862        Self::fill_missing(be, current, &ids, |_| true, p)
863    }
864
865    // helper func
866    fn fill_missing<B, F>(
867        be: &B,
868        current: Vec<Self>,
869        ids: &[Id],
870        mut filter: F,
871        p: &Progress,
872    ) -> RusticResult<Vec<Self>>
873    where
874        B: DecryptReadBackend,
875        F: FnMut(&Self) -> bool,
876    {
877        let mut snaps: BTreeMap<_, _> = current.into_iter().map(|snap| (snap.id, snap)).collect();
878        let missing_ids: Vec<_> = ids
879            .iter()
880            .map(|id| SnapshotId::from(*id))
881            .filter(|id| !snaps.contains_key(id))
882            .collect();
883        for res in be.stream_list::<Self>(&missing_ids, p)? {
884            let (id, snap) = res?;
885            if filter(&snap) {
886                let _ = snaps.insert(id, snap);
887            }
888        }
889        // sort back to original order + handle duplicates
890        Ok(ids
891            .iter()
892            .filter_map(|id| {
893                let id = SnapshotId::from(*id);
894                snaps.get(&id).map(|sn| Self::set_id((id, sn.clone())))
895            })
896            .collect())
897    }
898
899    // TODO: add documentation!
900    pub(crate) fn iter_all_from_backend<B, F>(
901        be: &B,
902        filter: F,
903        p: &Progress,
904    ) -> RusticResult<impl Iterator<Item = Self>>
905    where
906        B: DecryptReadBackend,
907        F: FnMut(&Self) -> bool,
908    {
909        Ok(be
910            .stream_all::<Self>(p)?
911            .into_iter()
912            .map(|item| item.inspect_err(|err| warn!("Error reading snapshot: {err}")))
913            .filter_map(Result::ok)
914            .map(Self::set_id)
915            .filter(filter))
916    }
917
918    // TODO: add documentation!
919    pub(crate) fn update_from_backend<B, F>(
920        be: &B,
921        current: Vec<Self>,
922        filter: F,
923        p: &Progress,
924    ) -> RusticResult<Vec<Self>>
925    where
926        B: DecryptReadBackend,
927        F: FnMut(&Self) -> bool,
928    {
929        let ids = be.list(FileType::Snapshot)?;
930        Self::fill_missing(be, current, &ids, filter, p)
931    }
932
933    /// Add tag lists to snapshot.
934    ///
935    /// # Arguments
936    ///
937    /// * `tag_lists` - The tag lists to add
938    ///
939    /// # Returns
940    ///
941    /// Returns whether snapshot was changed.
942    pub fn add_tags(&mut self, tag_lists: Vec<StringList>) -> bool {
943        let old_tags = self.tags.clone();
944        self.tags.add_all(tag_lists);
945
946        old_tags != self.tags
947    }
948
949    /// Set tag lists to snapshot.
950    ///
951    /// # Arguments
952    ///
953    /// * `tag_lists` - The tag lists to set
954    ///
955    /// # Returns
956    ///
957    /// Returns whether snapshot was changed.
958    pub fn set_tags(&mut self, tag_lists: Vec<StringList>) -> bool {
959        let old_tags = std::mem::take(&mut self.tags);
960        self.tags.add_all(tag_lists);
961
962        old_tags != self.tags
963    }
964
965    /// Remove tag lists from snapshot.
966    ///
967    /// # Arguments
968    ///
969    /// * `tag_lists` - The tag lists to remove
970    ///
971    /// # Returns
972    ///
973    /// Returns whether snapshot was changed.
974    pub fn remove_tags(&mut self, tag_lists: &[StringList]) -> bool {
975        let old_tags = self.tags.clone();
976        self.tags.remove_all(tag_lists);
977
978        old_tags != self.tags
979    }
980
981    /// Returns whether a snapshot must be deleted now
982    ///
983    /// # Arguments
984    ///
985    /// * `now` - The current time
986    #[must_use]
987    pub fn must_delete(&self, now: &Zoned) -> bool {
988        matches!(&self.delete, DeleteOption::After(time) if time < now)
989    }
990
991    /// Returns whether a snapshot must be kept now
992    ///
993    /// # Arguments
994    ///
995    /// * `now` - The current time
996    #[must_use]
997    pub fn must_keep(&self, now: &Zoned) -> bool {
998        match &self.delete {
999            DeleteOption::Never => true,
1000            DeleteOption::After(time) if time >= now => true,
1001            _ => false,
1002        }
1003    }
1004
1005    /// Modifies the snapshot according to a [`SnapshotModification`].
1006    ///
1007    /// # Arguments
1008    ///
1009    /// * `modification` - The modification(s) to make
1010    ///
1011    /// # Returns
1012    ///
1013    /// `true` if the snapshot was changed.
1014    ///
1015    /// # Errors
1016    /// if reading a description from a file failed
1017    pub fn modify(&mut self, modification: &SnapshotModification) -> RusticResult<bool> {
1018        modification.apply_to(self)
1019    }
1020
1021    /// Clear ids which are not saved by the copy command (and not compared when checking if snapshots already exist in the copy target)
1022    ///
1023    /// # Arguments
1024    ///
1025    /// * `sn` - The snapshot to clear the ids from
1026    #[must_use]
1027    pub(crate) fn clear_ids(mut sn: Self) -> Self {
1028        sn.id = SnapshotId::default();
1029        sn.parent = None;
1030        sn.parents = Vec::new();
1031        sn
1032    }
1033
1034    /// Convenience method to get parent snapshots which are stored in the `parent` or `parents` field.
1035    #[must_use]
1036    pub fn get_parents(&self) -> &[SnapshotId] {
1037        if self.parents.is_empty() {
1038            self.parent.as_slice()
1039        } else {
1040            &self.parents
1041        }
1042    }
1043}
1044
1045impl PartialEq<Self> for SnapshotFile {
1046    fn eq(&self, other: &Self) -> bool {
1047        self.time.eq(&other.time)
1048    }
1049}
1050
1051impl Eq for SnapshotFile {}
1052
1053impl PartialOrd for SnapshotFile {
1054    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1055        Some(self.cmp(other))
1056    }
1057}
1058impl Ord for SnapshotFile {
1059    fn cmp(&self, other: &Self) -> Ordering {
1060        self.time.cmp(&other.time)
1061    }
1062}
1063
1064/// `StringList` is a rustic-internal list of Strings. It is used within [`SnapshotFile`]
1065#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
1066pub struct StringList(pub(crate) BTreeSet<String>);
1067
1068impl FromStr for StringList {
1069    type Err = SnapshotFileErrorKind;
1070    fn from_str(s: &str) -> SnapshotFileResult<Self> {
1071        Ok(Self(s.split(',').map(ToString::to_string).collect()))
1072    }
1073}
1074
1075impl Display for StringList {
1076    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1077        write!(f, "{}", self.0.iter().join(","))?;
1078        Ok(())
1079    }
1080}
1081
1082impl StringList {
1083    /// Returns whether a [`StringList`] contains a given String.
1084    ///
1085    /// # Arguments
1086    ///
1087    /// * `s` - The String to check
1088    #[must_use]
1089    pub fn contains(&self, s: &str) -> bool {
1090        self.0.contains(s)
1091    }
1092
1093    /// Returns whether a [`StringList`] contains all Strings of another [`StringList`].
1094    ///
1095    /// # Arguments
1096    ///
1097    /// * `sl` - The [`StringList`] to check
1098    #[must_use]
1099    pub fn contains_all(&self, sl: &Self) -> bool {
1100        sl.0.is_subset(&self.0)
1101    }
1102
1103    /// Returns whether a [`StringList`] matches a list of [`StringList`]s,
1104    /// i.e. whether it contains all Strings of one the given [`StringList`]s.
1105    ///
1106    /// # Arguments
1107    ///
1108    /// * `sls` - The list of [`StringList`]s to check
1109    #[must_use]
1110    pub fn matches(&self, sls: &[Self]) -> bool {
1111        sls.is_empty() || sls.iter().any(|sl| self.contains_all(sl))
1112    }
1113
1114    /// Add a String to a [`StringList`].
1115    ///
1116    /// # Arguments
1117    ///
1118    /// * `s` - The String to add
1119    pub fn add(&mut self, s: String) {
1120        _ = self.0.insert(s);
1121    }
1122
1123    /// Add all Strings from another [`StringList`] to this [`StringList`].
1124    ///
1125    /// # Arguments
1126    ///
1127    /// * `sl` - The [`StringList`] to add
1128    pub fn add_list(&mut self, mut sl: Self) {
1129        self.0.append(&mut sl.0);
1130    }
1131
1132    /// Add all Strings from all given [`StringList`]s to this [`StringList`].
1133    ///
1134    /// # Arguments
1135    ///
1136    /// * `string_lists` - The [`StringList`]s to add
1137    pub fn add_all(&mut self, string_lists: Vec<Self>) {
1138        for sl in string_lists {
1139            self.add_list(sl);
1140        }
1141    }
1142
1143    /// Adds the given Paths as Strings to this [`StringList`].
1144    ///
1145    /// # Arguments
1146    ///
1147    /// * `paths` - The Paths to add
1148    ///
1149    /// # Errors
1150    ///
1151    /// * If a path is not valid unicode
1152    pub(crate) fn set_paths<T: AsRef<Path>>(&mut self, paths: &[T]) -> SnapshotFileResult<()> {
1153        self.0 = paths
1154            .iter()
1155            .map(|p| {
1156                Ok(p.as_ref()
1157                    .to_str()
1158                    .ok_or_else(|| SnapshotFileErrorKind::NonUnicodePath(p.as_ref().to_path_buf()))?
1159                    .to_string())
1160            })
1161            .collect::<SnapshotFileResult<BTreeSet<_>>>()?;
1162        Ok(())
1163    }
1164
1165    /// Remove all Strings from all given [`StringList`]s from this [`StringList`].
1166    ///
1167    /// # Arguments
1168    ///
1169    /// * `string_lists` - The [`StringList`]s to remove
1170    pub fn remove_all(&mut self, string_lists: &[Self]) {
1171        for sl in string_lists {
1172            self.0 = &self.0 - &sl.0;
1173        }
1174    }
1175
1176    #[allow(clippy::needless_pass_by_ref_mut)]
1177    #[deprecated(note = "StringLists are now automatically sorted")]
1178    /// Sort the Strings in the [`StringList`]
1179    pub fn sort(&mut self) {}
1180
1181    /// Format this [`StringList`] using newlines
1182    #[must_use]
1183    pub fn formatln(&self) -> String {
1184        self.0.iter().join("\n")
1185    }
1186
1187    /// Turn this [`StringList`] into an Iterator
1188    pub fn iter(&self) -> impl Iterator<Item = &String> {
1189        self.0.iter()
1190    }
1191}
1192
1193impl<'str> IntoIterator for &'str StringList {
1194    type Item = &'str String;
1195    type IntoIter = std::collections::btree_set::Iter<'str, String>;
1196
1197    fn into_iter(self) -> Self::IntoIter {
1198        self.0.iter()
1199    }
1200}
1201
1202/// `PathList` is a rustic-internal list of `PathBuf`s. It is used in the [`crate::Repository::backup`] command.
1203#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
1204pub struct PathList(Vec<PathBuf>);
1205
1206impl Display for PathList {
1207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1208        self.0
1209            .iter()
1210            .map(|p| p.to_string_lossy())
1211            .format(",")
1212            .fmt(f)
1213    }
1214}
1215
1216impl<T: Into<PathBuf>> FromIterator<T> for PathList {
1217    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
1218        Self(iter.into_iter().map(T::into).collect())
1219    }
1220}
1221
1222impl PathList {
1223    /// Create a `PathList` from a String containing a single path
1224    /// Note: for multiple paths, use `PathList::from_iter`.
1225    ///
1226    /// # Arguments
1227    ///
1228    /// * `source` - The String to parse
1229    ///
1230    /// # Errors
1231    ///
1232    /// * no errors can occur here
1233    /// * [`RusticResult`] is used for consistency and future compatibility
1234    pub fn from_string(source: &str) -> RusticResult<Self> {
1235        Ok(Self(vec![source.into()]))
1236    }
1237
1238    /// Number of paths in the `PathList`.
1239    #[must_use]
1240    pub fn len(&self) -> usize {
1241        self.0.len()
1242    }
1243
1244    /// Returns whether the `PathList` is empty.
1245    #[must_use]
1246    pub fn is_empty(&self) -> bool {
1247        self.0.len() == 0
1248    }
1249
1250    /// Clone the internal `Vec<PathBuf>`.
1251    #[must_use]
1252    pub(crate) fn paths(&self) -> Vec<PathBuf> {
1253        self.0.clone()
1254    }
1255
1256    /// Sanitize paths: Parse dots, absolutize if needed and merge paths.
1257    ///
1258    /// # Errors
1259    ///
1260    /// * If removing dots from path failed
1261    /// * If canonicalizing path failed
1262    pub fn sanitize(mut self) -> SnapshotFileResult<Self> {
1263        for path in &mut self.0 {
1264            *path = sanitize_dot(path)?;
1265        }
1266        if self.0.iter().any(|p| p.is_absolute()) {
1267            self.0 = self
1268                .0
1269                .into_iter()
1270                .map(|p| canonicalize(p).map_err(SnapshotFileErrorKind::CanonicalizingPathFailed))
1271                .collect::<Result<_, _>>()?;
1272        }
1273        Ok(self.merge())
1274    }
1275
1276    /// Sort paths and filters out subpaths of already existing paths.
1277    #[must_use]
1278    pub fn merge(self) -> Self {
1279        let mut paths = self.0;
1280        // sort paths
1281        paths.sort_unstable();
1282
1283        let mut root_path = None;
1284
1285        // filter out subpaths
1286        paths.retain(|path| match &root_path {
1287            Some(root_path) if path.starts_with(root_path) => false,
1288            _ => {
1289                root_path = Some(path.clone());
1290                true
1291            }
1292        });
1293
1294        Self(paths)
1295    }
1296}
1297
1298// helper function to sanitize paths containing dots
1299fn sanitize_dot(path: &Path) -> SnapshotFileResult<PathBuf> {
1300    if path == Path::new(".") || path == Path::new("./") {
1301        return Ok(PathBuf::from("."));
1302    }
1303
1304    let path = if path.starts_with("./") {
1305        path.strip_prefix("./").unwrap()
1306    } else {
1307        path
1308    };
1309
1310    let path = path
1311        .parse_dot()
1312        .map_err(SnapshotFileErrorKind::RemovingDotsFromPathFailed)?
1313        .to_path_buf();
1314
1315    Ok(path)
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use std::{collections::HashMap, sync::Arc};
1321
1322    use super::*;
1323    use crate::{
1324        backend::{
1325            MockBackend,
1326            decrypt::{DecryptBackend, DecryptWriteBackend},
1327        },
1328        crypto::{CryptoKey, aespoly1305::Key},
1329        progress::NoProgress,
1330    };
1331    use anyhow::Result;
1332    use bytes::Bytes;
1333    use jiff::{Timestamp, tz::TimeZone};
1334    use rstest::rstest;
1335
1336    #[rstest]
1337    #[case(".", ".")]
1338    #[case("./", ".")]
1339    #[case("test", "test")]
1340    #[case("test/", "test")]
1341    #[case("./test", "test")]
1342    #[case("./test/", "test")]
1343    fn sanitize_dot_cases(#[case] input: &str, #[case] expected: &str) {
1344        let path = Path::new(input);
1345        let expected = PathBuf::from(expected);
1346
1347        assert_eq!(expected, sanitize_dot(path).unwrap());
1348    }
1349
1350    #[rstest]
1351    #[case("abc", vec!["abc".to_string()])]
1352    #[case("abc,def", vec!["abc".to_string(), "def".to_string()])]
1353    #[case("abc,abc", vec!["abc".to_string()])]
1354    fn test_set_tags(#[case] tag: &str, #[case] expected: Vec<String>) -> Result<()> {
1355        let mut snap = SnapshotFile::from_options(&SnapshotOptions::default())?;
1356        let tags = StringList::from_str(tag)?;
1357        let expected = StringList(expected.into_iter().collect());
1358        assert!(snap.set_tags(vec![tags]));
1359        assert_eq!(snap.tags, expected);
1360        Ok(())
1361    }
1362
1363    #[test]
1364    fn test_add_tags() -> Result<()> {
1365        let tags = vec![StringList::from_str("abc")?];
1366        let mut snap = SnapshotFile::from_options(&SnapshotOptions::default().tags(tags))?;
1367        let tags = StringList::from_str("def,abc")?;
1368        assert!(snap.add_tags(vec![tags]));
1369        let expected = StringList::from_str("abc,def")?;
1370        assert_eq!(snap.tags, expected);
1371        Ok(())
1372    }
1373
1374    #[rstest]
1375    #[case(vec![], "")]
1376    #[case(vec!["test"], "test")]
1377    #[case(vec!["test", "test", "test"], "test,test,test")]
1378    fn test_display_path_list_passes(#[case] input: Vec<&str>, #[case] expected: &str) {
1379        let path_list = PathList::from_iter(input);
1380        let result = path_list.to_string();
1381        assert_eq!(expected, &result);
1382    }
1383
1384    fn fake_snapshot_file_with_id_time(
1385        id_time_vec: Vec<(Id, Zoned)>,
1386        key: &Key,
1387    ) -> HashMap<Id, Bytes> {
1388        let mut res = HashMap::new();
1389        for (id, time) in id_time_vec {
1390            let snapshot_file = SnapshotFile {
1391                id: SnapshotId(id),
1392                time,
1393                ..Default::default()
1394            };
1395            let encrypted = Bytes::from(
1396                key.encrypt_data(serde_json::to_string(&snapshot_file).unwrap().as_bytes())
1397                    .unwrap(),
1398            );
1399            let _ = res.insert(id, encrypted);
1400        }
1401        res
1402    }
1403
1404    fn setup_mock_backend() -> (DecryptBackend<Key>, [Id; 3]) {
1405        let key = Key::new();
1406
1407        let id1 = Id::from_str("0011223344556677001122334455667700112233445566770000000000000001")
1408            .unwrap();
1409        let id2 = Id::from_str("0021223344556677001122334455667700112233445566770000000000000002")
1410            .unwrap();
1411        let id3 = Id::from_str("0031223344556677001122334455667700112233445566770000000000000003")
1412            .unwrap();
1413
1414        let snapshot_files = fake_snapshot_file_with_id_time(
1415            vec![
1416                (
1417                    id1,
1418                    Timestamp::from_second(1_752_483_600)
1419                        .unwrap()
1420                        .to_zoned(TimeZone::UTC),
1421                ),
1422                (
1423                    id2,
1424                    Timestamp::from_second(1_752_483_700)
1425                        .unwrap()
1426                        .to_zoned(TimeZone::UTC),
1427                ),
1428                // this is the latest
1429                (
1430                    id3,
1431                    Timestamp::from_second(1_752_483_800)
1432                        .unwrap()
1433                        .to_zoned(TimeZone::UTC),
1434                ),
1435            ],
1436            &key,
1437        );
1438        let mut back = MockBackend::new();
1439        let _ = back.expect_list_with_size().returning(move |_| {
1440            // unordered ids
1441            Ok(vec![(id2, 0), (id3, 0), (id1, 0)])
1442        });
1443        let _ = back
1444            .expect_read_full()
1445            .returning(move |_tpe, id| Ok(snapshot_files.get(id).unwrap().clone()));
1446
1447        let mut be = DecryptBackend::new(Arc::new(back), key);
1448        be.set_zstd(None);
1449
1450        (be, [id1, id2, id3])
1451    }
1452
1453    #[rstest]
1454    fn test_snapshot_file_latest() {
1455        let p = Progress::new(NoProgress);
1456        let (be, [id1, id2, id3]) = setup_mock_backend();
1457        let latest = SnapshotFile::latest(&be, |_sn| true, &p).unwrap();
1458        assert_eq!(latest.id, SnapshotId(id3));
1459
1460        let latest_n0 = SnapshotFile::latest_n(&be, |_sn| true, &p, 0).unwrap();
1461        assert_eq!(latest_n0, latest);
1462
1463        let latest_n1 = SnapshotFile::latest_n(&be, |_sn| true, &p, 1).unwrap();
1464        assert_eq!(latest_n1.id, SnapshotId(id2));
1465
1466        let latest_n2 = SnapshotFile::latest_n(&be, |_sn| true, &p, 2).unwrap();
1467        assert_eq!(latest_n2.id, SnapshotId(id1));
1468
1469        let latest_n3 = SnapshotFile::latest_n(&be, |_sn| true, &p, 3);
1470        let latest_n3_err = latest_n3.unwrap_err().to_string();
1471        let expected = "No snapshots found for latest~3.";
1472        assert!(
1473            latest_n3_err.contains(expected),
1474            "Err is: {latest_n3_err}\n\nShould contain: {expected}",
1475        );
1476    }
1477
1478    #[rstest]
1479    fn test_snapshot_file_from_str() {
1480        let p = Progress::new(NoProgress);
1481        let (be, [id1, id2, id3]) = setup_mock_backend();
1482
1483        let latest = SnapshotFile::from_str(&be, "latest", |_sn| true, &p).unwrap();
1484        assert_eq!(latest.id, SnapshotId(id3));
1485
1486        let latest_n0 = SnapshotFile::from_str(&be, "latest~0", |_sn| true, &p).unwrap();
1487        assert_eq!(latest_n0, latest);
1488
1489        let snap_id3 = SnapshotFile::from_str(
1490            &be,
1491            "0031223344556677001122334455667700112233445566770000000000000003",
1492            |_sn| true,
1493            &p,
1494        )
1495        .unwrap();
1496        assert_eq!(latest, snap_id3);
1497
1498        let snap_id3 = SnapshotFile::from_str(&be, "003", |_sn| true, &p).unwrap();
1499        assert_eq!(latest, snap_id3);
1500
1501        let latest_n1 = SnapshotFile::from_str(&be, "latest~1", |_sn| true, &p).unwrap();
1502        assert_eq!(latest_n1.id, SnapshotId(id2));
1503
1504        let latest_n2 = SnapshotFile::from_str(&be, "latest~2", |_sn| true, &p).unwrap();
1505        assert_eq!(latest_n2.id, SnapshotId(id1));
1506
1507        let latest_n3 = SnapshotFile::from_str(&be, "latest~3", |_sn| true, &p);
1508        let latest_n3_err = latest_n3.unwrap_err().to_string();
1509        let expected = "No snapshots found for latest~3.";
1510        assert!(
1511            latest_n3_err.contains(expected),
1512            "Err is: {latest_n3_err}\n\nShould contain: {expected}",
1513        );
1514
1515        let latest_syntax_err = SnapshotFile::from_str(&be, "laztet~1", |_sn| true, &p)
1516            .unwrap_err()
1517            .to_string();
1518        let expected = "No suitable id found for `laztet~1`.";
1519        assert!(
1520            latest_syntax_err.contains(expected),
1521            "Err is: {latest_syntax_err}\n\nShould contain: {expected}",
1522        );
1523    }
1524
1525    #[rstest]
1526    fn test_snapshot_file_from_strs() {
1527        let p = Progress::new(NoProgress);
1528        let (be, [id1, id2, id3]) = setup_mock_backend();
1529
1530        // all kind of requests mixed
1531        let snaps = SnapshotFile::from_strs(
1532            &be,
1533            &[
1534                "latest~2",
1535                "002",
1536                "0031223344556677001122334455667700112233445566770000000000000003",
1537            ],
1538            |_sn| true,
1539            &p,
1540        )
1541        .unwrap();
1542        let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
1543        assert_eq!(ids, vec![id1, id2, id3]);
1544
1545        // all kind of requests mixed, with duplicates
1546        let snaps = SnapshotFile::from_strs(
1547            &be,
1548            &[
1549                "0021223344556677001122334455667700112233445566770000000000000002",
1550                "latest~1",
1551                "001",
1552            ],
1553            |_sn| true,
1554            &p,
1555        )
1556        .unwrap();
1557        let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
1558        assert_eq!(ids, vec![id2, id2, id1]);
1559
1560        // typical "last two" request
1561        let snaps = SnapshotFile::from_strs(&be, &["latest", "latest~1"], |_sn| true, &p).unwrap();
1562        let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
1563        assert_eq!(ids, vec![id3, id2]);
1564
1565        // not enough latest snapshots
1566        let latest_n3 = SnapshotFile::from_strs(&be, &["003", "latest~3"], |_sn| true, &p);
1567        let latest_n3_err = latest_n3.unwrap_err().to_string();
1568        let expected = "No snapshots found for latest~3.";
1569        assert!(
1570            latest_n3_err.contains(expected),
1571            "Err is: {latest_n3_err}\n\nShould contain: {expected}",
1572        );
1573
1574        // only (parts of) ids
1575        let snaps = SnapshotFile::from_strs(
1576            &be,
1577            &[
1578                "0031223344556677001122334455667700112233445566770000000000000003",
1579                "001",
1580            ],
1581            |_sn| true,
1582            &p,
1583        )
1584        .unwrap();
1585        let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
1586        assert_eq!(ids, vec![id3, id1]);
1587
1588        // only full ids
1589        let snaps = SnapshotFile::from_strs(
1590            &be,
1591            &[
1592                "0031223344556677001122334455667700112233445566770000000000000003",
1593                "0011223344556677001122334455667700112233445566770000000000000001",
1594                "0031223344556677001122334455667700112233445566770000000000000003",
1595            ],
1596            |_sn| true,
1597            &p,
1598        )
1599        .unwrap();
1600        let ids: Vec<_> = snaps.iter().map(|sn| *sn.id).collect();
1601        assert_eq!(ids, vec![id3, id1, id3]);
1602    }
1603}