Skip to main content

ic_backup/model/restore_references/
mod.rs

1//! Maintained v1 restore dependencies and model-owned retention transitions.
2
3use crate::model::artifacts::{ChecksumError, canonical_hash};
4use serde::{Deserialize, Deserializer, Serialize, de};
5use std::{
6    collections::BTreeSet,
7    fmt,
8    path::{Path, PathBuf},
9};
10use thiserror::Error;
11
12/// Maximum references admitted into one layout's retained dependency record.
13pub const MAX_RESTORE_REFERENCES: usize = 1024;
14/// Maximum UTF-8 bytes in a normalized absolute journal location.
15pub const MAX_JOURNAL_PATH_BYTES: usize = 4096;
16
17/// One exact journal location bound to immutable restore-intent digest bytes.
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(try_from = "ReferenceFields")]
20pub struct RestoreReferenceRecord {
21    journal: PathBuf,
22    authority: String,
23}
24
25#[derive(Deserialize)]
26#[serde(deny_unknown_fields)]
27struct ReferenceFields {
28    journal: PathBuf,
29    authority: String,
30}
31
32impl TryFrom<ReferenceFields> for RestoreReferenceRecord {
33    type Error = RestoreReferenceError;
34    fn try_from(fields: ReferenceFields) -> Result<Self, Self::Error> {
35        Self::new(fields.journal, &fields.authority)
36    }
37}
38
39impl RestoreReferenceRecord {
40    /// Bind a normalized absolute journal location to a SHA-256 intent digest.
41    ///
42    /// # Errors
43    /// Rejects relative, noncanonical, non-UTF-8 or excessive paths and invalid hashes.
44    pub fn new(journal: PathBuf, authority: &str) -> Result<Self, RestoreReferenceError> {
45        if !super::journal_path::is_canonical(&journal, MAX_JOURNAL_PATH_BYTES) {
46            return Err(RestoreReferenceError::InvalidJournal { journal });
47        }
48        let authority = canonical_hash(authority)?;
49        Ok(Self { journal, authority })
50    }
51
52    /// Return the resolved journal location, which need not currently exist.
53    #[must_use]
54    pub fn journal(&self) -> &Path {
55        &self.journal
56    }
57
58    /// Return the canonical immutable restore-intent digest.
59    #[must_use]
60    pub fn authority(&self) -> &str {
61        &self.authority
62    }
63}
64
65/// Durable unfinished restore dependencies for one backup layout.
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(try_from = "ReferencesFields")]
68pub struct RestoreReferencesRecord {
69    version: u16,
70    restores: Vec<RestoreReferenceRecord>,
71}
72
73#[derive(Deserialize)]
74#[serde(deny_unknown_fields)]
75struct ReferencesFields {
76    version: u16,
77    #[serde(deserialize_with = "read_bounded_references")]
78    restores: Vec<RestoreReferenceRecord>,
79}
80
81impl TryFrom<ReferencesFields> for RestoreReferencesRecord {
82    type Error = RestoreReferenceError;
83    fn try_from(mut fields: ReferencesFields) -> Result<Self, Self::Error> {
84        if fields.version != 1 {
85            return Err(RestoreReferenceError::UnsupportedVersion(fields.version));
86        }
87        let mut journals = BTreeSet::new();
88        for entry in &fields.restores {
89            if !journals.insert(entry.journal()) {
90                return Err(RestoreReferenceError::DuplicateJournal {
91                    journal: entry.journal.clone(),
92                });
93            }
94        }
95        fields
96            .restores
97            .sort_by(|left, right| left.journal.cmp(&right.journal));
98        Ok(Self {
99            version: 1,
100            restores: fields.restores,
101        })
102    }
103}
104
105impl RestoreReferencesRecord {
106    /// Construct an empty maintained-generation dependency record.
107    #[must_use]
108    pub const fn empty() -> Self {
109        Self {
110            version: 1,
111            restores: Vec::new(),
112        }
113    }
114
115    /// Read retained dependencies in canonical journal-path order.
116    #[must_use]
117    pub fn entries(&self) -> &[RestoreReferenceRecord] {
118        &self.restores
119    }
120
121    /// Report whether there are no retained restore dependencies.
122    #[must_use]
123    pub fn is_empty(&self) -> bool {
124        self.restores.is_empty()
125    }
126
127    pub(crate) fn retain(
128        &mut self,
129        reference: RestoreReferenceRecord,
130    ) -> Result<bool, RestoreReferenceError> {
131        if let Some(existing) = self
132            .restores
133            .iter()
134            .find(|entry| entry.journal == reference.journal)
135        {
136            if existing != &reference {
137                return Err(RestoreReferenceError::AuthorityConflict {
138                    journal: reference.journal,
139                });
140            }
141            return Ok(false);
142        }
143        if self.restores.len() == MAX_RESTORE_REFERENCES {
144            return Err(RestoreReferenceError::TooManyReferences {
145                limit: MAX_RESTORE_REFERENCES,
146            });
147        }
148        self.restores.push(reference);
149        self.restores
150            .sort_by(|left, right| left.journal.cmp(&right.journal));
151        Ok(true)
152    }
153}
154
155fn read_bounded_references<'de, D: Deserializer<'de>>(
156    deserializer: D,
157) -> Result<Vec<RestoreReferenceRecord>, D::Error> {
158    struct ReferencesVisitor;
159    impl<'de> de::Visitor<'de> for ReferencesVisitor {
160        type Value = Vec<RestoreReferenceRecord>;
161        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162            formatter.write_str("a bounded list of restore references")
163        }
164        fn visit_seq<A: de::SeqAccess<'de>>(
165            self,
166            mut sequence: A,
167        ) -> Result<Self::Value, A::Error> {
168            let mut entries = Vec::new();
169            while let Some(entry) = sequence.next_element()? {
170                if entries.len() == MAX_RESTORE_REFERENCES {
171                    return Err(de::Error::custom("restore reference count exceeds limit"));
172                }
173                entries.push(entry);
174            }
175            Ok(entries)
176        }
177    }
178    deserializer.deserialize_seq(ReferencesVisitor)
179}
180
181/// Typed schema or immutable-reference transition failure.
182#[derive(Debug, Error)]
183pub enum RestoreReferenceError {
184    /// The journal path is not an exact bounded absolute UTF-8 location.
185    #[error("invalid restore journal location: {journal:?}")]
186    InvalidJournal {
187        /// Rejected journal location.
188        journal: PathBuf,
189    },
190    /// An intent digest is malformed.
191    #[error(transparent)]
192    Checksum(#[from] ChecksumError),
193    /// The persisted reference generation is not maintained.
194    #[error("unsupported restore references version {0}")]
195    UnsupportedVersion(u16),
196    /// A decoded record repeats one journal location.
197    #[error("duplicate restore journal location: {journal:?}")]
198    DuplicateJournal {
199        /// Repeated journal location.
200        journal: PathBuf,
201    },
202    /// An existing journal location is bound to different intent bytes.
203    #[error("restore authority conflict at {journal:?}")]
204    AuthorityConflict {
205        /// Retained journal location.
206        journal: PathBuf,
207    },
208    /// Retaining another dependency would exceed the maintained count bound.
209    #[error("restore reference count exceeds {limit}")]
210    TooManyReferences {
211        /// Maximum admitted entries.
212        limit: usize,
213    },
214}
215
216#[cfg(test)]
217mod tests;