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