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