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 #[serde(default)]
18 pub discovery_topology_hash: Option<String>,
19 #[serde(default)]
20 pub pre_snapshot_topology_hash: Option<String>,
21 pub artifacts: Vec<ArtifactJournalEntry>,
22}
23
24impl DownloadJournal {
25 pub fn validate(&self) -> Result<(), JournalValidationError> {
27 validate_journal_version(self.journal_version)?;
28 validate_nonempty("backup_id", &self.backup_id)?;
29 validate_optional_hash(
30 "discovery_topology_hash",
31 self.discovery_topology_hash.as_deref(),
32 )?;
33 validate_optional_hash(
34 "pre_snapshot_topology_hash",
35 self.pre_snapshot_topology_hash.as_deref(),
36 )?;
37
38 if self.artifacts.is_empty() {
39 return Err(JournalValidationError::EmptyCollection("artifacts"));
40 }
41
42 let mut keys = BTreeSet::new();
43 for artifact in &self.artifacts {
44 artifact.validate()?;
45 let key = (artifact.canister_id.clone(), artifact.snapshot_id.clone());
46 if !keys.insert(key) {
47 return Err(JournalValidationError::DuplicateArtifact {
48 canister_id: artifact.canister_id.clone(),
49 snapshot_id: artifact.snapshot_id.clone(),
50 });
51 }
52 }
53
54 Ok(())
55 }
56
57 #[must_use]
59 pub fn resume_report(&self) -> JournalResumeReport {
60 let mut counts = JournalStateCounts::default();
61 let mut artifacts = Vec::with_capacity(self.artifacts.len());
62
63 for artifact in &self.artifacts {
64 counts.record(artifact.state, artifact.resume_action());
65 artifacts.push(ArtifactResumeReport {
66 canister_id: artifact.canister_id.clone(),
67 snapshot_id: artifact.snapshot_id.clone(),
68 state: artifact.state,
69 resume_action: artifact.resume_action(),
70 artifact_path: artifact.artifact_path.clone(),
71 temp_path: artifact.temp_path.clone(),
72 updated_at: artifact.updated_at.clone(),
73 });
74 }
75
76 JournalResumeReport {
77 backup_id: self.backup_id.clone(),
78 discovery_topology_hash: self.discovery_topology_hash.clone(),
79 pre_snapshot_topology_hash: self.pre_snapshot_topology_hash.clone(),
80 total_artifacts: self.artifacts.len(),
81 is_complete: counts.skip == self.artifacts.len(),
82 pending_artifacts: self.artifacts.len() - counts.skip,
83 counts,
84 artifacts,
85 }
86 }
87}
88
89#[derive(Clone, Debug, Deserialize, Serialize)]
94pub struct ArtifactJournalEntry {
95 pub canister_id: String,
96 pub snapshot_id: String,
97 pub state: ArtifactState,
98 pub temp_path: Option<String>,
99 pub artifact_path: String,
100 pub checksum_algorithm: String,
101 pub checksum: Option<String>,
102 pub updated_at: String,
103}
104
105impl ArtifactJournalEntry {
106 #[must_use]
108 pub const fn resume_action(&self) -> ResumeAction {
109 match self.state {
110 ArtifactState::Created => ResumeAction::Download,
111 ArtifactState::Downloaded => ResumeAction::VerifyChecksum,
112 ArtifactState::ChecksumVerified => ResumeAction::Finalize,
113 ArtifactState::Durable => ResumeAction::Skip,
114 }
115 }
116
117 pub fn advance_to(
119 &mut self,
120 next_state: ArtifactState,
121 updated_at: String,
122 ) -> Result<(), JournalValidationError> {
123 if !self.state.can_advance_to(next_state) {
124 return Err(JournalValidationError::InvalidStateTransition {
125 from: self.state,
126 to: next_state,
127 });
128 }
129
130 self.state = next_state;
131 self.updated_at = updated_at;
132 Ok(())
133 }
134
135 fn validate(&self) -> Result<(), JournalValidationError> {
137 validate_principal("artifacts[].canister_id", &self.canister_id)?;
138 validate_nonempty("artifacts[].snapshot_id", &self.snapshot_id)?;
139 validate_nonempty("artifacts[].artifact_path", &self.artifact_path)?;
140 validate_nonempty("artifacts[].checksum_algorithm", &self.checksum_algorithm)?;
141 validate_nonempty("artifacts[].updated_at", &self.updated_at)?;
142
143 if self.checksum_algorithm != SHA256_ALGORITHM {
144 return Err(JournalValidationError::UnsupportedHashAlgorithm(
145 self.checksum_algorithm.clone(),
146 ));
147 }
148
149 if matches!(
150 self.state,
151 ArtifactState::Downloaded | ArtifactState::ChecksumVerified
152 ) {
153 validate_required_option("artifacts[].temp_path", self.temp_path.as_deref())?;
154 }
155
156 if matches!(
157 self.state,
158 ArtifactState::ChecksumVerified | ArtifactState::Durable
159 ) {
160 validate_required_hash("artifacts[].checksum", self.checksum.as_deref())?;
161 }
162
163 Ok(())
164 }
165}
166
167#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
172#[serde(rename_all = "PascalCase")]
173pub enum ArtifactState {
174 Created,
175 Downloaded,
176 ChecksumVerified,
177 Durable,
178}
179
180impl ArtifactState {
181 #[must_use]
183 pub const fn can_advance_to(self, next: Self) -> bool {
184 self.as_order() <= next.as_order()
185 }
186
187 const fn as_order(self) -> u8 {
189 match self {
190 Self::Created => 0,
191 Self::Downloaded => 1,
192 Self::ChecksumVerified => 2,
193 Self::Durable => 3,
194 }
195 }
196}
197
198#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "PascalCase")]
204pub enum ResumeAction {
205 Download,
206 VerifyChecksum,
207 Finalize,
208 Skip,
209}
210
211#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
216pub struct JournalResumeReport {
217 pub backup_id: String,
218 pub discovery_topology_hash: Option<String>,
219 pub pre_snapshot_topology_hash: Option<String>,
220 pub total_artifacts: usize,
221 pub is_complete: bool,
222 pub pending_artifacts: usize,
223 pub counts: JournalStateCounts,
224 pub artifacts: Vec<ArtifactResumeReport>,
225}
226
227#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
232pub struct JournalStateCounts {
233 pub created: usize,
234 pub downloaded: usize,
235 pub checksum_verified: usize,
236 pub durable: usize,
237 pub download: usize,
238 pub verify_checksum: usize,
239 pub finalize: usize,
240 pub skip: usize,
241}
242
243impl JournalStateCounts {
244 const fn record(&mut self, state: ArtifactState, action: ResumeAction) {
246 match state {
247 ArtifactState::Created => self.created += 1,
248 ArtifactState::Downloaded => self.downloaded += 1,
249 ArtifactState::ChecksumVerified => self.checksum_verified += 1,
250 ArtifactState::Durable => self.durable += 1,
251 }
252
253 match action {
254 ResumeAction::Download => self.download += 1,
255 ResumeAction::VerifyChecksum => self.verify_checksum += 1,
256 ResumeAction::Finalize => self.finalize += 1,
257 ResumeAction::Skip => self.skip += 1,
258 }
259 }
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub struct ArtifactResumeReport {
268 pub canister_id: String,
269 pub snapshot_id: String,
270 pub state: ArtifactState,
271 pub resume_action: ResumeAction,
272 pub artifact_path: String,
273 pub temp_path: Option<String>,
274 pub updated_at: String,
275}
276
277#[derive(Debug, ThisError)]
282pub enum JournalValidationError {
283 #[error("unsupported journal version {0}")]
284 UnsupportedJournalVersion(u16),
285
286 #[error("field {0} must not be empty")]
287 EmptyField(&'static str),
288
289 #[error("collection {0} must not be empty")]
290 EmptyCollection(&'static str),
291
292 #[error("field {field} must be a valid principal: {value}")]
293 InvalidPrincipal { field: &'static str, value: String },
294
295 #[error("field {0} must be a non-empty sha256 hex string")]
296 InvalidHash(&'static str),
297
298 #[error("unsupported hash algorithm {0}")]
299 UnsupportedHashAlgorithm(String),
300
301 #[error("duplicate artifact entry for canister {canister_id} snapshot {snapshot_id}")]
302 DuplicateArtifact {
303 canister_id: String,
304 snapshot_id: String,
305 },
306
307 #[error("invalid journal transition from {from:?} to {to:?}")]
308 InvalidStateTransition {
309 from: ArtifactState,
310 to: ArtifactState,
311 },
312}
313
314const fn validate_journal_version(version: u16) -> Result<(), JournalValidationError> {
316 if version == SUPPORTED_JOURNAL_VERSION {
317 Ok(())
318 } else {
319 Err(JournalValidationError::UnsupportedJournalVersion(version))
320 }
321}
322
323fn validate_nonempty(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
325 if value.trim().is_empty() {
326 Err(JournalValidationError::EmptyField(field))
327 } else {
328 Ok(())
329 }
330}
331
332fn validate_required_option(
334 field: &'static str,
335 value: Option<&str>,
336) -> Result<(), JournalValidationError> {
337 match value {
338 Some(value) => validate_nonempty(field, value),
339 None => Err(JournalValidationError::EmptyField(field)),
340 }
341}
342
343fn validate_principal(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
345 validate_nonempty(field, value)?;
346 Principal::from_str(value)
347 .map(|_| ())
348 .map_err(|_| JournalValidationError::InvalidPrincipal {
349 field,
350 value: value.to_string(),
351 })
352}
353
354fn validate_required_hash(
356 field: &'static str,
357 value: Option<&str>,
358) -> Result<(), JournalValidationError> {
359 match value {
360 Some(value) => validate_hash(field, value),
361 None => Err(JournalValidationError::EmptyField(field)),
362 }
363}
364
365fn validate_optional_hash(
367 field: &'static str,
368 value: Option<&str>,
369) -> Result<(), JournalValidationError> {
370 if let Some(value) = value {
371 validate_hash(field, value)?;
372 }
373 Ok(())
374}
375
376fn validate_hash(field: &'static str, value: &str) -> Result<(), JournalValidationError> {
378 const SHA256_HEX_LEN: usize = 64;
379 validate_nonempty(field, value)?;
380 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
381 Ok(())
382 } else {
383 Err(JournalValidationError::InvalidHash(field))
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 const ROOT: &str = "aaaaa-aa";
392 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
393
394 fn valid_journal() -> DownloadJournal {
396 DownloadJournal {
397 journal_version: 1,
398 backup_id: "fbk_test_001".to_string(),
399 discovery_topology_hash: Some(HASH.to_string()),
400 pre_snapshot_topology_hash: Some(HASH.to_string()),
401 artifacts: vec![ArtifactJournalEntry {
402 canister_id: ROOT.to_string(),
403 snapshot_id: "snap-1".to_string(),
404 state: ArtifactState::Durable,
405 temp_path: None,
406 artifact_path: "artifacts/root".to_string(),
407 checksum_algorithm: "sha256".to_string(),
408 checksum: Some(HASH.to_string()),
409 updated_at: "2026-04-10T12:00:00Z".to_string(),
410 }],
411 }
412 }
413
414 #[test]
416 fn valid_journal_passes_validation() {
417 let journal = valid_journal();
418
419 journal.validate().expect("journal should validate");
420 }
421
422 #[test]
424 fn resume_action_matches_artifact_state() {
425 let mut entry = valid_journal().artifacts.remove(0);
426
427 entry.state = ArtifactState::Created;
428 assert_eq!(entry.resume_action(), ResumeAction::Download);
429
430 entry.state = ArtifactState::Downloaded;
431 assert_eq!(entry.resume_action(), ResumeAction::VerifyChecksum);
432
433 entry.state = ArtifactState::ChecksumVerified;
434 assert_eq!(entry.resume_action(), ResumeAction::Finalize);
435
436 entry.state = ArtifactState::Durable;
437 assert_eq!(entry.resume_action(), ResumeAction::Skip);
438 }
439
440 #[test]
442 fn resume_report_counts_states_and_actions() {
443 let mut journal = valid_journal();
444 journal.artifacts[0].state = ArtifactState::Created;
445 journal.artifacts[0].checksum = None;
446 let mut downloaded = journal.artifacts[0].clone();
447 downloaded.snapshot_id = "snap-2".to_string();
448 downloaded.state = ArtifactState::Downloaded;
449 downloaded.temp_path = Some("artifacts/root.tmp".to_string());
450 let mut durable = valid_journal().artifacts.remove(0);
451 durable.snapshot_id = "snap-3".to_string();
452 journal.artifacts.push(downloaded);
453 journal.artifacts.push(durable);
454
455 let report = journal.resume_report();
456
457 assert_eq!(report.total_artifacts, 3);
458 assert_eq!(report.discovery_topology_hash.as_deref(), Some(HASH));
459 assert_eq!(report.pre_snapshot_topology_hash.as_deref(), Some(HASH));
460 assert!(!report.is_complete);
461 assert_eq!(report.pending_artifacts, 2);
462 assert_eq!(report.counts.created, 1);
463 assert_eq!(report.counts.downloaded, 1);
464 assert_eq!(report.counts.durable, 1);
465 assert_eq!(report.counts.download, 1);
466 assert_eq!(report.counts.verify_checksum, 1);
467 assert_eq!(report.counts.skip, 1);
468 assert_eq!(report.artifacts[0].resume_action, ResumeAction::Download);
469 }
470
471 #[test]
473 fn state_transitions_are_monotonic() {
474 let mut entry = valid_journal().artifacts.remove(0);
475
476 let err = entry
477 .advance_to(
478 ArtifactState::Downloaded,
479 "2026-04-10T12:01:00Z".to_string(),
480 )
481 .expect_err("durable cannot move back to downloaded");
482
483 assert!(matches!(
484 err,
485 JournalValidationError::InvalidStateTransition { .. }
486 ));
487 }
488
489 #[test]
491 fn durable_artifact_requires_checksum() {
492 let mut journal = valid_journal();
493 journal.artifacts[0].checksum = None;
494
495 let err = journal
496 .validate()
497 .expect_err("durable artifact without checksum should fail");
498
499 assert!(matches!(err, JournalValidationError::EmptyField(_)));
500 }
501
502 #[test]
504 fn duplicate_artifacts_fail_validation() {
505 let mut journal = valid_journal();
506 journal.artifacts.push(journal.artifacts[0].clone());
507
508 let err = journal
509 .validate()
510 .expect_err("duplicate artifact should fail");
511
512 assert!(matches!(
513 err,
514 JournalValidationError::DuplicateArtifact { .. }
515 ));
516 }
517
518 #[test]
520 fn journal_round_trips_through_json() {
521 let journal = valid_journal();
522
523 let encoded = serde_json::to_string(&journal).expect("serialize journal");
524 let decoded: DownloadJournal = serde_json::from_str(&encoded).expect("deserialize journal");
525
526 decoded.validate().expect("decoded journal should validate");
527 }
528}