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    /// Build a resumability report from the current journal state.
46    #[must_use]
47    pub fn resume_report(&self) -> JournalResumeReport {
48        let mut counts = JournalStateCounts::default();
49        let mut artifacts = Vec::with_capacity(self.artifacts.len());
50
51        for artifact in &self.artifacts {
52            counts.record(artifact.state, artifact.resume_action());
53            artifacts.push(ArtifactResumeReport {
54                canister_id: artifact.canister_id.clone(),
55                snapshot_id: artifact.snapshot_id.clone(),
56                state: artifact.state,
57                resume_action: artifact.resume_action(),
58                artifact_path: artifact.artifact_path.clone(),
59                temp_path: artifact.temp_path.clone(),
60                updated_at: artifact.updated_at.clone(),
61            });
62        }
63
64        JournalResumeReport {
65            backup_id: self.backup_id.clone(),
66            total_artifacts: self.artifacts.len(),
67            is_complete: counts.skip == self.artifacts.len(),
68            pending_artifacts: self.artifacts.len() - counts.skip,
69            counts,
70            artifacts,
71        }
72    }
73}
74
75///
76/// ArtifactJournalEntry
77///
78
79#[derive(Clone, Debug, Deserialize, Serialize)]
80pub struct ArtifactJournalEntry {
81    pub canister_id: String,
82    pub snapshot_id: String,
83    pub state: ArtifactState,
84    pub temp_path: Option<String>,
85    pub artifact_path: String,
86    pub checksum_algorithm: String,
87    pub checksum: Option<String>,
88    pub updated_at: String,
89}
90
91impl ArtifactJournalEntry {
92    /// Return the idempotent action needed to resume this artifact.
93    #[must_use]
94    pub const fn resume_action(&self) -> ResumeAction {
95        match self.state {
96            ArtifactState::Created => ResumeAction::Download,
97            ArtifactState::Downloaded => ResumeAction::VerifyChecksum,
98            ArtifactState::ChecksumVerified => ResumeAction::Finalize,
99            ArtifactState::Durable => ResumeAction::Skip,
100        }
101    }
102
103    /// Advance this artifact to a later journal state.
104    pub fn advance_to(
105        &mut self,
106        next_state: ArtifactState,
107        updated_at: String,
108    ) -> Result<(), JournalValidationError> {
109        if !self.state.can_advance_to(next_state) {
110            return Err(JournalValidationError::InvalidStateTransition {
111                from: self.state,
112                to: next_state,
113            });
114        }
115
116        self.state = next_state;
117        self.updated_at = updated_at;
118        Ok(())
119    }
120
121    /// Validate one artifact's resumable state.
122    fn validate(&self) -> Result<(), JournalValidationError> {
123        validate_principal("artifacts[].canister_id", &self.canister_id)?;
124        validate_nonempty("artifacts[].snapshot_id", &self.snapshot_id)?;
125        validate_nonempty("artifacts[].artifact_path", &self.artifact_path)?;
126        validate_nonempty("artifacts[].checksum_algorithm", &self.checksum_algorithm)?;
127        validate_nonempty("artifacts[].updated_at", &self.updated_at)?;
128
129        if self.checksum_algorithm != SHA256_ALGORITHM {
130            return Err(JournalValidationError::UnsupportedHashAlgorithm(
131                self.checksum_algorithm.clone(),
132            ));
133        }
134
135        if matches!(
136            self.state,
137            ArtifactState::Downloaded | ArtifactState::ChecksumVerified
138        ) {
139            validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
140        }
141
142        if matches!(
143            self.state,
144            ArtifactState::ChecksumVerified | ArtifactState::Durable
145        ) {
146            validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
147        }
148
149        Ok(())
150    }
151}
152
153///
154/// ArtifactState
155///
156
157#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
158#[serde(rename_all = "PascalCase")]
159pub enum ArtifactState {
160    Created,
161    Downloaded,
162    ChecksumVerified,
163    Durable,
164}
165
166impl ArtifactState {
167    /// Return whether this state can advance monotonically to `next`.
168    #[must_use]
169    pub const fn can_advance_to(self, next: Self) -> bool {
170        self.as_order() <= next.as_order()
171    }
172
173    /// Return the stable ordering used by the journal state machine.
174    const fn as_order(self) -> u8 {
175        match self {
176            Self::Created => 0,
177            Self::Downloaded => 1,
178            Self::ChecksumVerified => 2,
179            Self::Durable => 3,
180        }
181    }
182}
183
184///
185/// ResumeAction
186///
187
188#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(rename_all = "PascalCase")]
190pub enum ResumeAction {
191    Download,
192    VerifyChecksum,
193    Finalize,
194    Skip,
195}
196
197///
198/// JournalResumeReport
199///
200
201#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
202pub struct JournalResumeReport {
203    pub backup_id: String,
204    pub total_artifacts: usize,
205    pub is_complete: bool,
206    pub pending_artifacts: usize,
207    pub counts: JournalStateCounts,
208    pub artifacts: Vec<ArtifactResumeReport>,
209}
210
211///
212/// JournalStateCounts
213///
214
215#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
216pub struct JournalStateCounts {
217    pub created: usize,
218    pub downloaded: usize,
219    pub checksum_verified: usize,
220    pub durable: usize,
221    pub download: usize,
222    pub verify_checksum: usize,
223    pub finalize: usize,
224    pub skip: usize,
225}
226
227impl JournalStateCounts {
228    // Record one artifact's state and next idempotent resume action.
229    const fn record(&mut self, state: ArtifactState, action: ResumeAction) {
230        match state {
231            ArtifactState::Created => self.created += 1,
232            ArtifactState::Downloaded => self.downloaded += 1,
233            ArtifactState::ChecksumVerified => self.checksum_verified += 1,
234            ArtifactState::Durable => self.durable += 1,
235        }
236
237        match action {
238            ResumeAction::Download => self.download += 1,
239            ResumeAction::VerifyChecksum => self.verify_checksum += 1,
240            ResumeAction::Finalize => self.finalize += 1,
241            ResumeAction::Skip => self.skip += 1,
242        }
243    }
244}
245
246///
247/// ArtifactResumeReport
248///
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
251pub struct ArtifactResumeReport {
252    pub canister_id: String,
253    pub snapshot_id: String,
254    pub state: ArtifactState,
255    pub resume_action: ResumeAction,
256    pub artifact_path: String,
257    pub temp_path: Option<String>,
258    pub updated_at: String,
259}
260
261///
262/// JournalValidationError
263///
264
265#[derive(Debug, ThisError)]
266pub enum JournalValidationError {
267    #[error("unsupported journal version {0}")]
268    UnsupportedJournalVersion(u16),
269
270    #[error("field {0} must not be empty")]
271    EmptyField(&'static str),
272
273    #[error("collection {0} must not be empty")]
274    EmptyCollection(&'static str),
275
276    #[error("field {field} must be a valid principal: {value}")]
277    InvalidPrincipal { field: &'static str, value: String },
278
279    #[error("field {0} must be a non-empty sha256 hex string")]
280    InvalidHash(&'static str),
281
282    #[error("unsupported hash algorithm {0}")]
283    UnsupportedHashAlgorithm(String),
284
285    #[error("duplicate artifact entry for canister {canister_id} snapshot {snapshot_id}")]
286    DuplicateArtifact {
287        canister_id: String,
288        snapshot_id: String,
289    },
290
291    #[error("invalid journal transition from {from:?} to {to:?}")]
292    InvalidStateTransition {
293        from: ArtifactState,
294        to: ArtifactState,
295    },
296}
297
298// Validate the journal format version before checking nested entries.
299const fn validate_journal_version(version: u16) -> Result<(), JournalValidationError> {
300    if version == SUPPORTED_JOURNAL_VERSION {
301        Ok(())
302    } else {
303        Err(JournalValidationError::UnsupportedJournalVersion(version))
304    }
305}
306
307// Validate required string fields after trimming whitespace.
308fn validate_nonempty(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
309    if value.trim().is_empty() {
310        Err(JournalValidationError::EmptyField(field))
311    } else {
312        Ok(())
313    }
314}
315
316// Validate required string fields represented as optional journal fields.
317fn validate_required_option(
318    field: &'static str,
319    value: Option<&str>,
320) -> Result<(), JournalValidationError> {
321    match value {
322        Some(value) => validate_nonempty(field, value),
323        None => Err(JournalValidationError::EmptyField(field)),
324    }
325}
326
327// Validate textual principal fields used in JSON journals.
328fn validate_principal(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
329    validate_nonempty(field, value)?;
330    Principal::from_str(value)
331        .map(|_| ())
332        .map_err(|_| JournalValidationError::InvalidPrincipal {
333            field,
334            value: value.to_string(),
335        })
336}
337
338// Validate required SHA-256 hex fields represented as optional journal fields.
339fn validate_required_hash(
340    field: &'static str,
341    value: Option<&str>,
342) -> Result<(), JournalValidationError> {
343    match value {
344        Some(value) => validate_hash(field, value),
345        None => Err(JournalValidationError::EmptyField(field)),
346    }
347}
348
349// Validate SHA-256 hex values used for downloaded artifacts.
350fn validate_hash(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
351    const SHA256_HEX_LEN: usize = 64;
352    validate_nonempty(field, value)?;
353    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
354        Ok(())
355    } else {
356        Err(JournalValidationError::InvalidHash(field))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    const ROOT: &str = "aaaaa-aa";
365    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
366
367    // Build one valid durable journal for validation tests.
368    fn valid_journal() -> DownloadJournal {
369        DownloadJournal {
370            journal_version: 1,
371            backup_id: "fbk_test_001".to_string(),
372            artifacts: vec![ArtifactJournalEntry {
373                canister_id: ROOT.to_string(),
374                snapshot_id: "snap-1".to_string(),
375                state: ArtifactState::Durable,
376                temp_path: None,
377                artifact_path: "artifacts/root".to_string(),
378                checksum_algorithm: "sha256".to_string(),
379                checksum: Some(HASH.to_string()),
380                updated_at: "2026-04-10T12:00:00Z".to_string(),
381            }],
382        }
383    }
384
385    // Ensure durable artifact journals validate.
386    #[test]
387    fn valid_journal_passes_validation() {
388        let journal = valid_journal();
389
390        journal.validate().expect("journal should validate");
391    }
392
393    // Ensure state determines the next idempotent resume action.
394    #[test]
395    fn resume_action_matches_artifact_state() {
396        let mut entry = valid_journal().artifacts.remove(0);
397
398        entry.state = ArtifactState::Created;
399        assert_eq!(entry.resume_action(), ResumeAction::Download);
400
401        entry.state = ArtifactState::Downloaded;
402        assert_eq!(entry.resume_action(), ResumeAction::VerifyChecksum);
403
404        entry.state = ArtifactState::ChecksumVerified;
405        assert_eq!(entry.resume_action(), ResumeAction::Finalize);
406
407        entry.state = ArtifactState::Durable;
408        assert_eq!(entry.resume_action(), ResumeAction::Skip);
409    }
410
411    // Ensure resume reports summarize states and next idempotent actions.
412    #[test]
413    fn resume_report_counts_states_and_actions() {
414        let mut journal = valid_journal();
415        journal.artifacts[0].state = ArtifactState::Created;
416        journal.artifacts[0].checksum = None;
417        let mut downloaded = journal.artifacts[0].clone();
418        downloaded.snapshot_id = "snap-2".to_string();
419        downloaded.state = ArtifactState::Downloaded;
420        downloaded.temp_path = Some("artifacts/root.tmp".to_string());
421        let mut durable = valid_journal().artifacts.remove(0);
422        durable.snapshot_id = "snap-3".to_string();
423        journal.artifacts.push(downloaded);
424        journal.artifacts.push(durable);
425
426        let report = journal.resume_report();
427
428        assert_eq!(report.total_artifacts, 3);
429        assert!(!report.is_complete);
430        assert_eq!(report.pending_artifacts, 2);
431        assert_eq!(report.counts.created, 1);
432        assert_eq!(report.counts.downloaded, 1);
433        assert_eq!(report.counts.durable, 1);
434        assert_eq!(report.counts.download, 1);
435        assert_eq!(report.counts.verify_checksum, 1);
436        assert_eq!(report.counts.skip, 1);
437        assert_eq!(report.artifacts[0].resume_action, ResumeAction::Download);
438    }
439
440    // Ensure journal transitions cannot move backward.
441    #[test]
442    fn state_transitions_are_monotonic() {
443        let mut entry = valid_journal().artifacts.remove(0);
444
445        let err = entry
446            .advance_to(
447                ArtifactState::Downloaded,
448                "2026-04-10T12:01:00Z".to_string(),
449            )
450            .expect_err("durable cannot move back to downloaded");
451
452        assert!(matches!(
453            err,
454            JournalValidationError::InvalidStateTransition { .. }
455        ));
456    }
457
458    // Ensure checksum is required once an artifact is durable.
459    #[test]
460    fn durable_artifact_requires_checksum() {
461        let mut journal = valid_journal();
462        journal.artifacts[0].checksum = None;
463
464        let err = journal
465            .validate()
466            .expect_err("durable artifact without checksum should fail");
467
468        assert!(matches!(err, JournalValidationError::EmptyField(_)));
469    }
470
471    // Ensure duplicate canister/snapshot rows are rejected.
472    #[test]
473    fn duplicate_artifacts_fail_validation() {
474        let mut journal = valid_journal();
475        journal.artifacts.push(journal.artifacts[0].clone());
476
477        let err = journal
478            .validate()
479            .expect_err("duplicate artifact should fail");
480
481        assert!(matches!(
482            err,
483            JournalValidationError::DuplicateArtifact { .. }
484        ));
485    }
486
487    // Ensure journals round-trip through the JSON format.
488    #[test]
489    fn journal_round_trips_through_json() {
490        let journal = valid_journal();
491
492        let encoded = serde_json::to_string(&journal).expect("serialize journal");
493        let decoded: DownloadJournal = serde_json::from_str(&encoded).expect("deserialize journal");
494
495        decoded.validate().expect("decoded journal should validate");
496    }
497}