canic_backup/journal/
validation.rs1use 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 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 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#[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}