Skip to main content

canic_backup/execution/
receipt.rs

1//! Module: execution::receipt
2//!
3//! Responsibility: construct and validate execution operation receipts.
4//! Does not own: journal transitions, plan construction, or artifact IO.
5//! Boundary: checks receipts against journal state before they are recorded.
6
7use super::{
8    BackupExecutionJournal, BackupExecutionJournalError, BackupExecutionJournalOperation,
9    BackupExecutionOperationReceipt, BackupExecutionOperationReceiptOutcome,
10    validation::{operation_kind_is_mutating, validate_nonempty, validate_optional_nonempty},
11};
12use crate::plan::BackupOperationKind;
13
14impl BackupExecutionOperationReceipt {
15    /// Build a completed operation receipt from one journal operation.
16    #[must_use]
17    pub fn completed(
18        journal: &BackupExecutionJournal,
19        operation: &BackupExecutionJournalOperation,
20        updated_at: Option<String>,
21    ) -> Self {
22        Self::from_operation(
23            journal,
24            operation,
25            BackupExecutionOperationReceiptOutcome::Completed,
26            updated_at,
27            None,
28        )
29    }
30
31    /// Build a failed operation receipt from one journal operation.
32    #[must_use]
33    pub fn failed(
34        journal: &BackupExecutionJournal,
35        operation: &BackupExecutionJournalOperation,
36        updated_at: Option<String>,
37        failure_reason: String,
38    ) -> Self {
39        Self::from_operation(
40            journal,
41            operation,
42            BackupExecutionOperationReceiptOutcome::Failed,
43            updated_at,
44            Some(failure_reason),
45        )
46    }
47
48    fn from_operation(
49        journal: &BackupExecutionJournal,
50        operation: &BackupExecutionJournalOperation,
51        outcome: BackupExecutionOperationReceiptOutcome,
52        updated_at: Option<String>,
53        failure_reason: Option<String>,
54    ) -> Self {
55        Self {
56            plan_id: journal.plan_id.clone(),
57            run_id: journal.run_id.clone(),
58            preflight_id: journal.preflight_id.clone(),
59            sequence: operation.sequence,
60            operation_id: operation.operation_id.clone(),
61            kind: operation.kind.clone(),
62            target_canister_id: operation.target_canister_id.clone(),
63            outcome,
64            updated_at,
65            snapshot_id: None,
66            snapshot_taken_at_timestamp: None,
67            snapshot_total_size_bytes: None,
68            artifact_path: None,
69            checksum: None,
70            failure_reason,
71        }
72    }
73
74    pub(super) fn validate_against(
75        &self,
76        journal: &BackupExecutionJournal,
77    ) -> Result<(), BackupExecutionJournalError> {
78        validate_nonempty("operation_receipts[].plan_id", &self.plan_id)?;
79        validate_nonempty("operation_receipts[].run_id", &self.run_id)?;
80        validate_nonempty("operation_receipts[].operation_id", &self.operation_id)?;
81        validate_nonempty(
82            "operation_receipts[].updated_at",
83            self.updated_at.as_deref().unwrap_or_default(),
84        )?;
85        validate_optional_nonempty(
86            "operation_receipts[].snapshot_id",
87            self.snapshot_id.as_deref(),
88        )?;
89        validate_optional_nonempty(
90            "operation_receipts[].artifact_path",
91            self.artifact_path.as_deref(),
92        )?;
93        validate_optional_nonempty("operation_receipts[].checksum", self.checksum.as_deref())?;
94
95        if self.plan_id != journal.plan_id || self.run_id != journal.run_id {
96            return Err(BackupExecutionJournalError::ReceiptJournalMismatch {
97                sequence: self.sequence,
98            });
99        }
100        let operation = journal
101            .operations
102            .iter()
103            .find(|operation| operation.sequence == self.sequence)
104            .ok_or(BackupExecutionJournalError::ReceiptOperationNotFound(
105                self.sequence,
106            ))?;
107        if operation.operation_id != self.operation_id
108            || operation.kind != self.kind
109            || operation.target_canister_id != self.target_canister_id
110        {
111            return Err(BackupExecutionJournalError::ReceiptOperationMismatch {
112                sequence: self.sequence,
113            });
114        }
115        if operation_kind_is_mutating(&operation.kind) && self.preflight_id != journal.preflight_id
116        {
117            return Err(BackupExecutionJournalError::ReceiptPreflightMismatch {
118                sequence: self.sequence,
119            });
120        }
121        if self.outcome == BackupExecutionOperationReceiptOutcome::Failed {
122            validate_nonempty(
123                "operation_receipts[].failure_reason",
124                self.failure_reason.as_deref().unwrap_or_default(),
125            )?;
126        }
127        if self.kind == BackupOperationKind::CreateSnapshot
128            && self.outcome == BackupExecutionOperationReceiptOutcome::Completed
129        {
130            validate_nonempty(
131                "operation_receipts[].snapshot_id",
132                self.snapshot_id.as_deref().unwrap_or_default(),
133            )?;
134        }
135        if self.kind == BackupOperationKind::DownloadSnapshot
136            && self.outcome == BackupExecutionOperationReceiptOutcome::Completed
137        {
138            validate_nonempty(
139                "operation_receipts[].artifact_path",
140                self.artifact_path.as_deref().unwrap_or_default(),
141            )?;
142        }
143        if self.kind == BackupOperationKind::VerifyArtifact
144            && self.outcome == BackupExecutionOperationReceiptOutcome::Completed
145        {
146            validate_nonempty(
147                "operation_receipts[].checksum",
148                self.checksum.as_deref().unwrap_or_default(),
149            )?;
150        }
151
152        Ok(())
153    }
154}