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