Skip to main content

canic_backup/journal/
mod.rs

1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeSet, str::FromStr};
4use thiserror::Error as ThisError;
5
6const SUPPORTED_JOURNAL_VERSION: u16 = 1;
7const SHA256_ALGORITHM: &str = "sha256";
8
9///
10/// DownloadJournal
11///
12
13#[derive(Clone, Debug, Deserialize, Serialize)]
14pub struct DownloadJournal {
15    pub journal_version: u16,
16    pub backup_id: String,
17    pub artifacts: Vec<ArtifactJournalEntry>,
18}
19
20impl DownloadJournal {
21    /// Validate resumable artifact state for one backup run.
22    pub fn validate(&self) -> Result<(), JournalValidationError> {
23        validate_journal_version(self.journal_version)?;
24        validate_nonempty("backup_id", &self.backup_id)?;
25
26        if self.artifacts.is_empty() {
27            return Err(JournalValidationError::EmptyCollection("artifacts"));
28        }
29
30        let mut keys = BTreeSet::new();
31        for artifact in &self.artifacts {
32            artifact.validate()?;
33            let key = (artifact.canister_id.clone(), artifact.snapshot_id.clone());
34            if !keys.insert(key) {
35                return Err(JournalValidationError::DuplicateArtifact {
36                    canister_id: artifact.canister_id.clone(),
37                    snapshot_id: artifact.snapshot_id.clone(),
38                });
39            }
40        }
41
42        Ok(())
43    }
44}
45
46///
47/// ArtifactJournalEntry
48///
49
50#[derive(Clone, Debug, Deserialize, Serialize)]
51pub struct ArtifactJournalEntry {
52    pub canister_id: String,
53    pub snapshot_id: String,
54    pub state: ArtifactState,
55    pub temp_path: Option<String>,
56    pub artifact_path: String,
57    pub checksum_algorithm: String,
58    pub checksum: Option<String>,
59    pub updated_at: String,
60}
61
62impl ArtifactJournalEntry {
63    /// Return the idempotent action needed to resume this artifact.
64    #[must_use]
65    pub const fn resume_action(&self) -> ResumeAction {
66        match self.state {
67            ArtifactState::Created => ResumeAction::Download,
68            ArtifactState::Downloaded => ResumeAction::VerifyChecksum,
69            ArtifactState::ChecksumVerified => ResumeAction::Finalize,
70            ArtifactState::Durable => ResumeAction::Skip,
71        }
72    }
73
74    /// Advance this artifact to a later journal state.
75    pub fn advance_to(
76        &mut self,
77        next_state: ArtifactState,
78        updated_at: String,
79    ) -> Result<(), JournalValidationError> {
80        if !self.state.can_advance_to(next_state) {
81            return Err(JournalValidationError::InvalidStateTransition {
82                from: self.state,
83                to: next_state,
84            });
85        }
86
87        self.state = next_state;
88        self.updated_at = updated_at;
89        Ok(())
90    }
91
92    /// Validate one artifact's resumable state.
93    fn validate(&self) -> Result<(), JournalValidationError> {
94        validate_principal("artifacts[].canister_id", &self.canister_id)?;
95        validate_nonempty("artifacts[].snapshot_id", &self.snapshot_id)?;
96        validate_nonempty("artifacts[].artifact_path", &self.artifact_path)?;
97        validate_nonempty("artifacts[].checksum_algorithm", &self.checksum_algorithm)?;
98        validate_nonempty("artifacts[].updated_at", &self.updated_at)?;
99
100        if self.checksum_algorithm != SHA256_ALGORITHM {
101            return Err(JournalValidationError::UnsupportedHashAlgorithm(
102                self.checksum_algorithm.clone(),
103            ));
104        }
105
106        if matches!(
107            self.state,
108            ArtifactState::Downloaded | ArtifactState::ChecksumVerified
109        ) {
110            validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
111        }
112
113        if matches!(
114            self.state,
115            ArtifactState::ChecksumVerified | ArtifactState::Durable
116        ) {
117            validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
118        }
119
120        Ok(())
121    }
122}
123
124///
125/// ArtifactState
126///
127
128#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
129#[serde(rename_all = "PascalCase")]
130pub enum ArtifactState {
131    Created,
132    Downloaded,
133    ChecksumVerified,
134    Durable,
135}
136
137impl ArtifactState {
138    /// Return whether this state can advance monotonically to `next`.
139    #[must_use]
140    pub const fn can_advance_to(self, next: Self) -> bool {
141        self.as_order() <= next.as_order()
142    }
143
144    /// Return the stable ordering used by the journal state machine.
145    const fn as_order(self) -> u8 {
146        match self {
147            Self::Created => 0,
148            Self::Downloaded => 1,
149            Self::ChecksumVerified => 2,
150            Self::Durable => 3,
151        }
152    }
153}
154
155///
156/// ResumeAction
157///
158
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160pub enum ResumeAction {
161    Download,
162    VerifyChecksum,
163    Finalize,
164    Skip,
165}
166
167///
168/// JournalValidationError
169///
170
171#[derive(Debug, ThisError)]
172pub enum JournalValidationError {
173    #[error("unsupported journal version {0}")]
174    UnsupportedJournalVersion(u16),
175
176    #[error("field {0} must not be empty")]
177    EmptyField(&'static str),
178
179    #[error("collection {0} must not be empty")]
180    EmptyCollection(&'static str),
181
182    #[error("field {field} must be a valid principal: {value}")]
183    InvalidPrincipal { field: &'static str, value: String },
184
185    #[error("field {0} must be a non-empty sha256 hex string")]
186    InvalidHash(&'static str),
187
188    #[error("unsupported hash algorithm {0}")]
189    UnsupportedHashAlgorithm(String),
190
191    #[error("duplicate artifact entry for canister {canister_id} snapshot {snapshot_id}")]
192    DuplicateArtifact {
193        canister_id: String,
194        snapshot_id: String,
195    },
196
197    #[error("invalid journal transition from {from:?} to {to:?}")]
198    InvalidStateTransition {
199        from: ArtifactState,
200        to: ArtifactState,
201    },
202}
203
204// Validate the journal format version before checking nested entries.
205const fn validate_journal_version(version: u16) -> Result<(), JournalValidationError> {
206    if version == SUPPORTED_JOURNAL_VERSION {
207        Ok(())
208    } else {
209        Err(JournalValidationError::UnsupportedJournalVersion(version))
210    }
211}
212
213// Validate required string fields after trimming whitespace.
214fn validate_nonempty(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
215    if value.trim().is_empty() {
216        Err(JournalValidationError::EmptyField(field))
217    } else {
218        Ok(())
219    }
220}
221
222// Validate required string fields represented as optional journal fields.
223fn validate_required_option(
224    field: &'static str,
225    value: Option<&str>,
226) -> Result<(), JournalValidationError> {
227    match value {
228        Some(value) => validate_nonempty(field, value),
229        None => Err(JournalValidationError::EmptyField(field)),
230    }
231}
232
233// Validate textual principal fields used in JSON journals.
234fn validate_principal(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
235    validate_nonempty(field, value)?;
236    Principal::from_str(value)
237        .map(|_| ())
238        .map_err(|_| JournalValidationError::InvalidPrincipal {
239            field,
240            value: value.to_string(),
241        })
242}
243
244// Validate required SHA-256 hex fields represented as optional journal fields.
245fn validate_required_hash(
246    field: &'static str,
247    value: Option<&str>,
248) -> Result<(), JournalValidationError> {
249    match value {
250        Some(value) => validate_hash(field, value),
251        None => Err(JournalValidationError::EmptyField(field)),
252    }
253}
254
255// Validate SHA-256 hex values used for downloaded artifacts.
256fn validate_hash(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
257    const SHA256_HEX_LEN: usize = 64;
258    validate_nonempty(field, value)?;
259    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
260        Ok(())
261    } else {
262        Err(JournalValidationError::InvalidHash(field))
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    const ROOT: &str = "aaaaa-aa";
271    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
272
273    // Build one valid durable journal for validation tests.
274    fn valid_journal() -> DownloadJournal {
275        DownloadJournal {
276            journal_version: 1,
277            backup_id: "fbk_test_001".to_string(),
278            artifacts: vec![ArtifactJournalEntry {
279                canister_id: ROOT.to_string(),
280                snapshot_id: "snap-1".to_string(),
281                state: ArtifactState::Durable,
282                temp_path: None,
283                artifact_path: "artifacts/root".to_string(),
284                checksum_algorithm: "sha256".to_string(),
285                checksum: Some(HASH.to_string()),
286                updated_at: "2026-04-10T12:00:00Z".to_string(),
287            }],
288        }
289    }
290
291    // Ensure durable artifact journals validate.
292    #[test]
293    fn valid_journal_passes_validation() {
294        let journal = valid_journal();
295
296        journal.validate().expect("journal should validate");
297    }
298
299    // Ensure state determines the next idempotent resume action.
300    #[test]
301    fn resume_action_matches_artifact_state() {
302        let mut entry = valid_journal().artifacts.remove(0);
303
304        entry.state = ArtifactState::Created;
305        assert_eq!(entry.resume_action(), ResumeAction::Download);
306
307        entry.state = ArtifactState::Downloaded;
308        assert_eq!(entry.resume_action(), ResumeAction::VerifyChecksum);
309
310        entry.state = ArtifactState::ChecksumVerified;
311        assert_eq!(entry.resume_action(), ResumeAction::Finalize);
312
313        entry.state = ArtifactState::Durable;
314        assert_eq!(entry.resume_action(), ResumeAction::Skip);
315    }
316
317    // Ensure journal transitions cannot move backward.
318    #[test]
319    fn state_transitions_are_monotonic() {
320        let mut entry = valid_journal().artifacts.remove(0);
321
322        let err = entry
323            .advance_to(
324                ArtifactState::Downloaded,
325                "2026-04-10T12:01:00Z".to_string(),
326            )
327            .expect_err("durable cannot move back to downloaded");
328
329        assert!(matches!(
330            err,
331            JournalValidationError::InvalidStateTransition { .. }
332        ));
333    }
334
335    // Ensure checksum is required once an artifact is durable.
336    #[test]
337    fn durable_artifact_requires_checksum() {
338        let mut journal = valid_journal();
339        journal.artifacts[0].checksum = None;
340
341        let err = journal
342            .validate()
343            .expect_err("durable artifact without checksum should fail");
344
345        assert!(matches!(err, JournalValidationError::EmptyField(_)));
346    }
347
348    // Ensure duplicate canister/snapshot rows are rejected.
349    #[test]
350    fn duplicate_artifacts_fail_validation() {
351        let mut journal = valid_journal();
352        journal.artifacts.push(journal.artifacts[0].clone());
353
354        let err = journal
355            .validate()
356            .expect_err("duplicate artifact should fail");
357
358        assert!(matches!(
359            err,
360            JournalValidationError::DuplicateArtifact { .. }
361        ));
362    }
363
364    // Ensure journals round-trip through the JSON format.
365    #[test]
366    fn journal_round_trips_through_json() {
367        let journal = valid_journal();
368
369        let encoded = serde_json::to_string(&journal).expect("serialize journal");
370        let decoded: DownloadJournal = serde_json::from_str(&encoded).expect("deserialize journal");
371
372        decoded.validate().expect("decoded journal should validate");
373    }
374}