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::{
4    artifacts::{ChecksumError, canonical_hash},
5    bounded_sequence,
6};
7use serde::{Deserialize, Deserializer, Serialize};
8use std::{
9    collections::BTreeSet,
10    path::{Path, PathBuf},
11};
12use thiserror::Error;
13
14/// Maximum references admitted into one layout's retained dependency record.
15pub const MAX_RESTORE_REFERENCES: usize = 1024;
16/// Maximum UTF-8 bytes in a normalized absolute journal location.
17pub const MAX_JOURNAL_PATH_BYTES: usize = 4096;
18
19/// One exact journal location bound to immutable restore-intent digest bytes.
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(try_from = "ReferenceFields")]
22pub struct RestoreReferenceRecord {
23    journal: PathBuf,
24    authority: String,
25}
26
27#[derive(Deserialize)]
28#[serde(deny_unknown_fields)]
29struct ReferenceFields {
30    journal: PathBuf,
31    authority: String,
32}
33
34impl TryFrom<ReferenceFields> for RestoreReferenceRecord {
35    type Error = RestoreReferenceError;
36    fn try_from(fields: ReferenceFields) -> Result<Self, Self::Error> {
37        Self::new(fields.journal, &fields.authority)
38    }
39}
40
41impl RestoreReferenceRecord {
42    /// Bind a normalized absolute journal location to a SHA-256 intent digest.
43    ///
44    /// # Errors
45    /// Rejects relative, noncanonical, non-UTF-8 or excessive paths and invalid hashes.
46    pub fn new(journal: PathBuf, authority: &str) -> Result<Self, RestoreReferenceError> {
47        if !super::journal_path::is_canonical(&journal, MAX_JOURNAL_PATH_BYTES) {
48            return Err(RestoreReferenceError::InvalidJournal { journal });
49        }
50        let authority = canonical_hash(authority)?;
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 release(
130        &mut self,
131        reference: &RestoreReferenceRecord,
132    ) -> Result<bool, RestoreReferenceError> {
133        let Some(index) = self
134            .restores
135            .iter()
136            .position(|entry| entry.journal == reference.journal)
137        else {
138            return Ok(false);
139        };
140        if self.restores[index] != *reference {
141            return Err(RestoreReferenceError::AuthorityConflict {
142                journal: reference.journal.clone(),
143            });
144        }
145        self.restores.remove(index);
146        Ok(true)
147    }
148
149    pub(crate) fn retain(
150        &mut self,
151        reference: RestoreReferenceRecord,
152    ) -> Result<bool, RestoreReferenceError> {
153        if let Some(existing) = self
154            .restores
155            .iter()
156            .find(|entry| entry.journal == reference.journal)
157        {
158            if existing != &reference {
159                return Err(RestoreReferenceError::AuthorityConflict {
160                    journal: reference.journal,
161                });
162            }
163            return Ok(false);
164        }
165        if self.restores.len() == MAX_RESTORE_REFERENCES {
166            return Err(RestoreReferenceError::TooManyReferences {
167                limit: MAX_RESTORE_REFERENCES,
168            });
169        }
170        self.restores.push(reference);
171        self.restores
172            .sort_by(|left, right| left.journal.cmp(&right.journal));
173        Ok(true)
174    }
175}
176
177fn read_bounded_references<'de, D: Deserializer<'de>>(
178    deserializer: D,
179) -> Result<Vec<RestoreReferenceRecord>, D::Error> {
180    bounded_sequence::deserialize::<_, RestoreReferenceRecord, MAX_RESTORE_REFERENCES>(
181        deserializer,
182        "a bounded list of restore references",
183        "restore reference count exceeds limit",
184    )
185}
186
187/// Typed schema or immutable-reference transition failure.
188#[derive(Debug, Error)]
189pub enum RestoreReferenceError {
190    /// The journal path is not an exact bounded absolute UTF-8 location.
191    #[error("invalid restore journal location: {journal:?}")]
192    InvalidJournal {
193        /// Rejected journal location.
194        journal: PathBuf,
195    },
196    /// An intent digest is malformed.
197    #[error(transparent)]
198    Checksum(#[from] ChecksumError),
199    /// The persisted reference generation is not maintained.
200    #[error("unsupported restore references version {0}")]
201    UnsupportedVersion(u16),
202    /// A decoded record repeats one journal location.
203    #[error("duplicate restore journal location: {journal:?}")]
204    DuplicateJournal {
205        /// Repeated journal location.
206        journal: PathBuf,
207    },
208    /// An existing journal location is bound to different intent bytes.
209    #[error("restore authority conflict at {journal:?}")]
210    AuthorityConflict {
211        /// Retained journal location.
212        journal: PathBuf,
213    },
214    /// Retaining another dependency would exceed the maintained count bound.
215    #[error("restore reference count exceeds {limit}")]
216    TooManyReferences {
217        /// Maximum admitted entries.
218        limit: usize,
219    },
220}
221
222#[cfg(test)]
223mod tests;