1use 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_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 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#[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}