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#[derive(Clone, Debug, Deserialize, Serialize)]
14pub struct DownloadJournal {
15 pub journal_version: u16,
16 pub backup_id: String,
17 pub artifacts: Vec<ArtifactJournalEntry>,
18}
19
20impl DownloadJournal {
21 pub fn validate(&self) -> Result<(), JournalValidationError> {
23 validate_journal_version(self.journal_version)?;
24 validate_nonempty("backup_id", &self.backup_id)?;
25
26 if self.artifacts.is_empty() {
27 return Err(JournalValidationError::EmptyCollection("artifacts"));
28 }
29
30 let mut keys = BTreeSet::new();
31 for artifact in &self.artifacts {
32 artifact.validate()?;
33 let key = (artifact.canister_id.clone(), artifact.snapshot_id.clone());
34 if !keys.insert(key) {
35 return Err(JournalValidationError::DuplicateArtifact {
36 canister_id: artifact.canister_id.clone(),
37 snapshot_id: artifact.snapshot_id.clone(),
38 });
39 }
40 }
41
42 Ok(())
43 }
44}
45
46#[derive(Clone, Debug, Deserialize, Serialize)]
51pub struct ArtifactJournalEntry {
52 pub canister_id: String,
53 pub snapshot_id: String,
54 pub state: ArtifactState,
55 pub temp_path: Option<String>,
56 pub artifact_path: String,
57 pub checksum_algorithm: String,
58 pub checksum: Option<String>,
59 pub updated_at: String,
60}
61
62impl ArtifactJournalEntry {
63 #[must_use]
65 pub const fn resume_action(&self) -> ResumeAction {
66 match self.state {
67 ArtifactState::Created => ResumeAction::Download,
68 ArtifactState::Downloaded => ResumeAction::VerifyChecksum,
69 ArtifactState::ChecksumVerified => ResumeAction::Finalize,
70 ArtifactState::Durable => ResumeAction::Skip,
71 }
72 }
73
74 pub fn advance_to(
76 &mut self,
77 next_state: ArtifactState,
78 updated_at: String,
79 ) -> Result<(), JournalValidationError> {
80 if !self.state.can_advance_to(next_state) {
81 return Err(JournalValidationError::InvalidStateTransition {
82 from: self.state,
83 to: next_state,
84 });
85 }
86
87 self.state = next_state;
88 self.updated_at = updated_at;
89 Ok(())
90 }
91
92 fn validate(&self) -> Result<(), JournalValidationError> {
94 validate_principal("artifacts[].canister_id", &self.canister_id)?;
95 validate_nonempty("artifacts[].snapshot_id", &self.snapshot_id)?;
96 validate_nonempty("artifacts[].artifact_path", &self.artifact_path)?;
97 validate_nonempty("artifacts[].checksum_algorithm", &self.checksum_algorithm)?;
98 validate_nonempty("artifacts[].updated_at", &self.updated_at)?;
99
100 if self.checksum_algorithm != SHA256_ALGORITHM {
101 return Err(JournalValidationError::UnsupportedHashAlgorithm(
102 self.checksum_algorithm.clone(),
103 ));
104 }
105
106 if matches!(
107 self.state,
108 ArtifactState::Downloaded | ArtifactState::ChecksumVerified
109 ) {
110 validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
111 }
112
113 if matches!(
114 self.state,
115 ArtifactState::ChecksumVerified | ArtifactState::Durable
116 ) {
117 validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
118 }
119
120 Ok(())
121 }
122}
123
124#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
129#[serde(rename_all = "PascalCase")]
130pub enum ArtifactState {
131 Created,
132 Downloaded,
133 ChecksumVerified,
134 Durable,
135}
136
137impl ArtifactState {
138 #[must_use]
140 pub const fn can_advance_to(self, next: Self) -> bool {
141 self.as_order() <= next.as_order()
142 }
143
144 const fn as_order(self) -> u8 {
146 match self {
147 Self::Created => 0,
148 Self::Downloaded => 1,
149 Self::ChecksumVerified => 2,
150 Self::Durable => 3,
151 }
152 }
153}
154
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160pub enum ResumeAction {
161 Download,
162 VerifyChecksum,
163 Finalize,
164 Skip,
165}
166
167#[derive(Debug, ThisError)]
172pub enum JournalValidationError {
173 #[error("unsupported journal version {0}")]
174 UnsupportedJournalVersion(u16),
175
176 #[error("field {0} must not be empty")]
177 EmptyField(&'static str),
178
179 #[error("collection {0} must not be empty")]
180 EmptyCollection(&'static str),
181
182 #[error("field {field} must be a valid principal: {value}")]
183 InvalidPrincipal { field: &'static str, value: String },
184
185 #[error("field {0} must be a non-empty sha256 hex string")]
186 InvalidHash(&'static str),
187
188 #[error("unsupported hash algorithm {0}")]
189 UnsupportedHashAlgorithm(String),
190
191 #[error("duplicate artifact entry for canister {canister_id} snapshot {snapshot_id}")]
192 DuplicateArtifact {
193 canister_id: String,
194 snapshot_id: String,
195 },
196
197 #[error("invalid journal transition from {from:?} to {to:?}")]
198 InvalidStateTransition {
199 from: ArtifactState,
200 to: ArtifactState,
201 },
202}
203
204const fn validate_journal_version(version: u16) -> Result<(), JournalValidationError> {
206 if version == SUPPORTED_JOURNAL_VERSION {
207 Ok(())
208 } else {
209 Err(JournalValidationError::UnsupportedJournalVersion(version))
210 }
211}
212
213fn validate_nonempty(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
215 if value.trim().is_empty() {
216 Err(JournalValidationError::EmptyField(field))
217 } else {
218 Ok(())
219 }
220}
221
222fn validate_required_option(
224 field: &'static str,
225 value: Option<&str>,
226) -> Result<(), JournalValidationError> {
227 match value {
228 Some(value) => validate_nonempty(field, value),
229 None => Err(JournalValidationError::EmptyField(field)),
230 }
231}
232
233fn validate_principal(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
235 validate_nonempty(field, value)?;
236 Principal::from_str(value)
237 .map(|_| ())
238 .map_err(|_| JournalValidationError::InvalidPrincipal {
239 field,
240 value: value.to_string(),
241 })
242}
243
244fn validate_required_hash(
246 field: &'static str,
247 value: Option<&str>,
248) -> Result<(), JournalValidationError> {
249 match value {
250 Some(value) => validate_hash(field, value),
251 None => Err(JournalValidationError::EmptyField(field)),
252 }
253}
254
255fn validate_hash(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
257 const SHA256_HEX_LEN: usize = 64;
258 validate_nonempty(field, value)?;
259 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
260 Ok(())
261 } else {
262 Err(JournalValidationError::InvalidHash(field))
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 const ROOT: &str = "aaaaa-aa";
271 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
272
273 fn valid_journal() -> DownloadJournal {
275 DownloadJournal {
276 journal_version: 1,
277 backup_id: "fbk_test_001".to_string(),
278 artifacts: vec![ArtifactJournalEntry {
279 canister_id: ROOT.to_string(),
280 snapshot_id: "snap-1".to_string(),
281 state: ArtifactState::Durable,
282 temp_path: None,
283 artifact_path: "artifacts/root".to_string(),
284 checksum_algorithm: "sha256".to_string(),
285 checksum: Some(HASH.to_string()),
286 updated_at: "2026-04-10T12:00:00Z".to_string(),
287 }],
288 }
289 }
290
291 #[test]
293 fn valid_journal_passes_validation() {
294 let journal = valid_journal();
295
296 journal.validate().expect("journal should validate");
297 }
298
299 #[test]
301 fn resume_action_matches_artifact_state() {
302 let mut entry = valid_journal().artifacts.remove(0);
303
304 entry.state = ArtifactState::Created;
305 assert_eq!(entry.resume_action(), ResumeAction::Download);
306
307 entry.state = ArtifactState::Downloaded;
308 assert_eq!(entry.resume_action(), ResumeAction::VerifyChecksum);
309
310 entry.state = ArtifactState::ChecksumVerified;
311 assert_eq!(entry.resume_action(), ResumeAction::Finalize);
312
313 entry.state = ArtifactState::Durable;
314 assert_eq!(entry.resume_action(), ResumeAction::Skip);
315 }
316
317 #[test]
319 fn state_transitions_are_monotonic() {
320 let mut entry = valid_journal().artifacts.remove(0);
321
322 let err = entry
323 .advance_to(
324 ArtifactState::Downloaded,
325 "2026-04-10T12:01:00Z".to_string(),
326 )
327 .expect_err("durable cannot move back to downloaded");
328
329 assert!(matches!(
330 err,
331 JournalValidationError::InvalidStateTransition { .. }
332 ));
333 }
334
335 #[test]
337 fn durable_artifact_requires_checksum() {
338 let mut journal = valid_journal();
339 journal.artifacts[0].checksum = None;
340
341 let err = journal
342 .validate()
343 .expect_err("durable artifact without checksum should fail");
344
345 assert!(matches!(err, JournalValidationError::EmptyField(_)));
346 }
347
348 #[test]
350 fn duplicate_artifacts_fail_validation() {
351 let mut journal = valid_journal();
352 journal.artifacts.push(journal.artifacts[0].clone());
353
354 let err = journal
355 .validate()
356 .expect_err("duplicate artifact should fail");
357
358 assert!(matches!(
359 err,
360 JournalValidationError::DuplicateArtifact { .. }
361 ));
362 }
363
364 #[test]
366 fn journal_round_trips_through_json() {
367 let journal = valid_journal();
368
369 let encoded = serde_json::to_string(&journal).expect("serialize journal");
370 let decoded: DownloadJournal = serde_json::from_str(&encoded).expect("deserialize journal");
371
372 decoded.validate().expect("decoded journal should validate");
373 }
374}