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 Ok(index) = self
134            .restores
135            .binary_search_by(|entry| entry.journal.cmp(&reference.journal))
136        else {
137            return Ok(false);
138        };
139        if self.restores[index] != *reference {
140            return Err(RestoreReferenceError::AuthorityConflict {
141                journal: reference.journal.clone(),
142            });
143        }
144        self.restores.remove(index);
145        Ok(true)
146    }
147
148    pub(crate) fn retain(
149        &mut self,
150        reference: RestoreReferenceRecord,
151    ) -> Result<bool, RestoreReferenceError> {
152        let index = match self
153            .restores
154            .binary_search_by(|entry| entry.journal.cmp(&reference.journal))
155        {
156            Ok(index) => {
157                if self.restores[index] != reference {
158                    return Err(RestoreReferenceError::AuthorityConflict {
159                        journal: reference.journal,
160                    });
161                }
162                return Ok(false);
163            }
164            Err(index) => index,
165        };
166        if self.restores.len() == MAX_RESTORE_REFERENCES {
167            return Err(RestoreReferenceError::TooManyReferences {
168                limit: MAX_RESTORE_REFERENCES,
169            });
170        }
171        self.restores.insert(index, reference);
172        Ok(true)
173    }
174}
175
176fn read_bounded_references<'de, D: Deserializer<'de>>(
177    deserializer: D,
178) -> Result<Vec<RestoreReferenceRecord>, D::Error> {
179    bounded_sequence::deserialize::<_, RestoreReferenceRecord, MAX_RESTORE_REFERENCES>(
180        deserializer,
181        "a bounded list of restore references",
182        "restore reference count exceeds limit",
183    )
184}
185
186/// Typed schema or immutable-reference transition failure.
187#[derive(Debug, Error)]
188pub enum RestoreReferenceError {
189    /// The journal path is not an exact bounded absolute UTF-8 location.
190    #[error("invalid restore journal location: {journal:?}")]
191    InvalidJournal {
192        /// Rejected journal location.
193        journal: PathBuf,
194    },
195    /// An intent digest is malformed.
196    #[error(transparent)]
197    Checksum(#[from] ChecksumError),
198    /// The persisted reference generation is not maintained.
199    #[error("unsupported restore references version {0}")]
200    UnsupportedVersion(u16),
201    /// A decoded record repeats one journal location.
202    #[error("duplicate restore journal location: {journal:?}")]
203    DuplicateJournal {
204        /// Repeated journal location.
205        journal: PathBuf,
206    },
207    /// An existing journal location is bound to different intent bytes.
208    #[error("restore authority conflict at {journal:?}")]
209    AuthorityConflict {
210        /// Retained journal location.
211        journal: PathBuf,
212    },
213    /// Retaining another dependency would exceed the maintained count bound.
214    #[error("restore reference count exceeds {limit}")]
215    TooManyReferences {
216        /// Maximum admitted entries.
217        limit: usize,
218    },
219}
220
221#[cfg(test)]
222mod tests;