Skip to main content

canic_backup/journal/
validation.rs

1//! Module: journal::validation
2//!
3//! Responsibility: validate durable artifact journal records before resume.
4//! Does not own: journal persistence, download execution, or reporting.
5//! Boundary: returns typed validation errors for persisted journal input.
6
7use crate::journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal};
8
9use std::{
10    collections::BTreeSet,
11    path::{Component, PathBuf},
12    str::FromStr,
13};
14
15use candid::Principal;
16use thiserror::Error as ThisError;
17
18const SUPPORTED_JOURNAL_VERSION: u16 = 1;
19const SHA256_ALGORITHM: &str = "sha256";
20
21impl DownloadJournal {
22    /// Validate resumable artifact state for one backup run.
23    pub fn validate(&self) -> Result<(), JournalValidationError> {
24        validate_journal_version(self.journal_version)?;
25        validate_nonempty("backup_id", &self.backup_id)?;
26        validate_hash("discovery_topology_hash", &self.discovery_topology_hash)?;
27        validate_hash(
28            "pre_snapshot_topology_hash",
29            &self.pre_snapshot_topology_hash,
30        )?;
31
32        if self.artifacts.is_empty() {
33            return Err(JournalValidationError::EmptyCollection("artifacts"));
34        }
35
36        let mut keys = BTreeSet::new();
37        for artifact in &self.artifacts {
38            artifact.validate()?;
39            let key = (artifact.canister_id.clone(), artifact.snapshot_id.clone());
40            if !keys.insert(key) {
41                return Err(JournalValidationError::DuplicateArtifact {
42                    canister_id: artifact.canister_id.clone(),
43                    snapshot_id: artifact.snapshot_id.clone(),
44                });
45            }
46        }
47
48        Ok(())
49    }
50}
51
52impl ArtifactJournalEntry {
53    /// Validate one artifact's resumable state.
54    fn validate(&self) -> Result<(), JournalValidationError> {
55        validate_principal("artifacts[].canister_id", &self.canister_id)?;
56        validate_nonempty("artifacts[].snapshot_id", &self.snapshot_id)?;
57        validate_nonempty("artifacts[].artifact_path", &self.artifact_path)?;
58        validate_relative_artifact_path("artifacts[].artifact_path", &self.artifact_path)?;
59        validate_nonempty("artifacts[].checksum_algorithm", &self.checksum_algorithm)?;
60        validate_nonempty("artifacts[].updated_at", &self.updated_at)?;
61
62        if self.checksum_algorithm != SHA256_ALGORITHM {
63            return Err(JournalValidationError::UnsupportedHashAlgorithm(
64                self.checksum_algorithm.clone(),
65            ));
66        }
67
68        match self.state {
69            ArtifactState::Created => {
70                validate_absent("artifacts[].temp_path", self.temp_path.as_ref(), self.state)?;
71                validate_absent("artifacts[].checksum", self.checksum.as_ref(), self.state)?;
72            }
73            ArtifactState::Downloaded => {
74                validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
75                validate_absent("artifacts[].checksum", self.checksum.as_ref(), self.state)?;
76            }
77            ArtifactState::ChecksumVerified => {
78                validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
79                validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
80            }
81            ArtifactState::Durable => {
82                validate_absent("artifacts[].temp_path", self.temp_path.as_ref(), self.state)?;
83                validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
84            }
85        }
86
87        Ok(())
88    }
89}
90
91///
92/// JournalValidationError
93///
94/// Typed validation failure for durable artifact journals.
95/// Owned by backup journaling and returned before unsafe resume.
96///
97
98#[derive(Debug, ThisError)]
99pub enum JournalValidationError {
100    #[error("duplicate artifact entry for canister {canister_id} snapshot {snapshot_id}")]
101    DuplicateArtifact {
102        canister_id: String,
103        snapshot_id: String,
104    },
105
106    #[error("collection {0} must not be empty")]
107    EmptyCollection(&'static str),
108
109    #[error("field {0} must not be empty")]
110    EmptyField(&'static str),
111
112    #[error("field {field} must be absent when artifact state is {state:?}")]
113    UnexpectedField {
114        field: &'static str,
115        state: ArtifactState,
116    },
117
118    #[error("field {field} must be a relative artifact path under the backup root: {value}")]
119    InvalidArtifactPath { field: &'static str, value: String },
120
121    #[error("field {0} must be a non-empty sha256 hex string")]
122    InvalidHash(&'static str),
123
124    #[error("field {field} must be a valid principal: {value}")]
125    InvalidPrincipal { field: &'static str, value: String },
126
127    #[error("invalid journal transition from {from:?} to {to:?}")]
128    InvalidStateTransition {
129        from: ArtifactState,
130        to: ArtifactState,
131    },
132
133    #[error("unsupported hash algorithm {0}")]
134    UnsupportedHashAlgorithm(String),
135
136    #[error("unsupported journal version {0}")]
137    UnsupportedJournalVersion(u16),
138}
139
140const fn validate_journal_version(version: u16) -> Result<(), JournalValidationError> {
141    if version == SUPPORTED_JOURNAL_VERSION {
142        Ok(())
143    } else {
144        Err(JournalValidationError::UnsupportedJournalVersion(version))
145    }
146}
147
148fn validate_nonempty(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
149    if value.trim().is_empty() {
150        Err(JournalValidationError::EmptyField(field))
151    } else {
152        Ok(())
153    }
154}
155
156fn validate_relative_artifact_path(
157    field: &'static str,
158    value: &str,
159) -> Result<(), JournalValidationError> {
160    let path = PathBuf::from(value);
161    if path.is_absolute()
162        || !path
163            .components()
164            .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
165    {
166        return Err(JournalValidationError::InvalidArtifactPath {
167            field,
168            value: value.to_string(),
169        });
170    }
171    Ok(())
172}
173
174fn validate_required_option(
175    field: &'static str,
176    value: Option<&str>,
177) -> Result<(), JournalValidationError> {
178    match value {
179        Some(value) => validate_nonempty(field, value),
180        None => Err(JournalValidationError::EmptyField(field)),
181    }
182}
183
184const fn validate_absent<T>(
185    field: &'static str,
186    value: Option<&T>,
187    state: ArtifactState,
188) -> Result<(), JournalValidationError> {
189    if value.is_some() {
190        Err(JournalValidationError::UnexpectedField { field, state })
191    } else {
192        Ok(())
193    }
194}
195
196fn validate_principal(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
197    validate_nonempty(field, value)?;
198    Principal::from_str(value)
199        .map(|_| ())
200        .map_err(|_| JournalValidationError::InvalidPrincipal {
201            field,
202            value: value.to_string(),
203        })
204}
205
206fn validate_required_hash(
207    field: &'static str,
208    value: Option<&str>,
209) -> Result<(), JournalValidationError> {
210    match value {
211        Some(value) => validate_hash(field, value),
212        None => Err(JournalValidationError::EmptyField(field)),
213    }
214}
215
216fn validate_hash(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
217    const SHA256_HEX_LEN: usize = 64;
218    validate_nonempty(field, value)?;
219    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
220        Ok(())
221    } else {
222        Err(JournalValidationError::InvalidHash(field))
223    }
224}