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