Skip to main content

canic_backup/plan/
validation.rs

1use super::{
2    BackupOperation, BackupOperationKind, BackupPlan, BackupPlanError, BackupScopeKind,
3    ControlAuthority, ControlAuthoritySource,
4};
5use candid::Principal;
6use std::{collections::BTreeSet, str::FromStr};
7
8impl BackupPlan {
9    /// Validate the backup plan as a dry-run/planning artifact.
10    pub fn validate(&self) -> Result<(), BackupPlanError> {
11        validate_nonempty("plan_id", &self.plan_id)?;
12        validate_nonempty("run_id", &self.run_id)?;
13        validate_nonempty("fleet", &self.fleet)?;
14        validate_nonempty("network", &self.network)?;
15        validate_principal("root_canister_id", &self.root_canister_id)?;
16        validate_optional_principal(
17            "selected_subtree_root",
18            self.selected_subtree_root.as_deref(),
19        )?;
20        validate_nonempty(
21            "topology_hash_before_quiesce",
22            &self.topology_hash_before_quiesce,
23        )?;
24        validate_root_scope(self)?;
25        validate_targets(self)?;
26        validate_selected_scope(self)?;
27        validate_phase_order(&self.phases)
28    }
29
30    /// Validate the backup plan before any live mutation can run.
31    pub fn validate_for_execution(&self) -> Result<(), BackupPlanError> {
32        self.validate()?;
33
34        for target in &self.targets {
35            if !target.control_authority.is_proven() {
36                return Err(BackupPlanError::UnprovenControlAuthority(
37                    target.canister_id.clone(),
38                ));
39            }
40            if !target.snapshot_read_authority.is_proven() {
41                return Err(BackupPlanError::UnprovenTargetSnapshotReadAuthority(
42                    target.canister_id.clone(),
43                ));
44            }
45            if self.requires_root_controller
46                && target.canister_id != self.root_canister_id
47                && !target.control_authority.is_proven_root_controller()
48            {
49                return Err(BackupPlanError::MissingRootController(
50                    target.canister_id.clone(),
51                ));
52            }
53        }
54
55        Ok(())
56    }
57}
58
59fn validate_root_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
60    if plan.selected_scope_kind == BackupScopeKind::MaintenanceRoot {
61        if plan.root_included {
62            return Ok(());
63        }
64        return Err(BackupPlanError::MaintenanceRootExcludesRoot);
65    }
66
67    if plan.root_included {
68        return Err(BackupPlanError::RootIncludedWithoutMaintenance);
69    }
70
71    Ok(())
72}
73
74fn validate_targets(plan: &BackupPlan) -> Result<(), BackupPlanError> {
75    if plan.targets.is_empty() {
76        return Err(BackupPlanError::EmptyTargets);
77    }
78
79    let mut target_ids = BTreeSet::new();
80    for target in &plan.targets {
81        validate_principal("targets[].canister_id", &target.canister_id)?;
82        validate_optional_principal(
83            "targets[].parent_canister_id",
84            target.parent_canister_id.as_deref(),
85        )?;
86        validate_optional_nonempty("targets[].role", target.role.as_deref())?;
87        validate_optional_nonempty(
88            "targets[].expected_module_hash",
89            target.expected_module_hash.as_deref(),
90        )?;
91        validate_control_authority(&target.control_authority)?;
92
93        if !target_ids.insert(target.canister_id.clone()) {
94            return Err(BackupPlanError::DuplicateTarget(target.canister_id.clone()));
95        }
96        if !plan.root_included && target.canister_id == plan.root_canister_id {
97            return Err(BackupPlanError::RootIncludedWithoutMaintenance);
98        }
99    }
100
101    validate_operation_targets(&plan.phases, &target_ids)
102}
103
104pub(super) fn validate_control_authority(
105    authority: &ControlAuthority,
106) -> Result<(), BackupPlanError> {
107    match &authority.source {
108        ControlAuthoritySource::Unknown
109        | ControlAuthoritySource::RootController
110        | ControlAuthoritySource::OperatorController => Ok(()),
111        ControlAuthoritySource::AlternateController { controller, reason } => {
112            validate_principal("targets[].control_authority.controller", controller)?;
113            validate_nonempty("targets[].control_authority.reason", reason)
114        }
115    }
116}
117
118fn validate_selected_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
119    match plan.selected_scope_kind {
120        BackupScopeKind::NonRootFleet => {
121            if plan.selected_subtree_root.is_some() {
122                return Err(BackupPlanError::NonRootFleetHasSelectedRoot);
123            }
124            Ok(())
125        }
126        BackupScopeKind::Member | BackupScopeKind::Subtree | BackupScopeKind::MaintenanceRoot => {
127            let Some(selected_root) = &plan.selected_subtree_root else {
128                return Err(BackupPlanError::EmptyField("selected_subtree_root"));
129            };
130            if plan
131                .targets
132                .iter()
133                .any(|target| &target.canister_id == selected_root)
134            {
135                Ok(())
136            } else {
137                Err(BackupPlanError::SelectedRootNotInTargets(
138                    selected_root.clone(),
139                ))
140            }
141        }
142    }
143}
144
145fn validate_operation_targets(
146    phases: &[BackupOperation],
147    target_ids: &BTreeSet<String>,
148) -> Result<(), BackupPlanError> {
149    if phases.is_empty() {
150        return Err(BackupPlanError::EmptyPhases);
151    }
152
153    let mut operation_ids = BTreeSet::new();
154    for (index, phase) in phases.iter().enumerate() {
155        validate_nonempty("phases[].operation_id", &phase.operation_id)?;
156        let expected = u32::try_from(index).unwrap_or(u32::MAX);
157        if phase.order != expected {
158            return Err(BackupPlanError::OperationOrderMismatch {
159                operation_id: phase.operation_id.clone(),
160                order: phase.order,
161                expected,
162            });
163        }
164        if !operation_ids.insert(phase.operation_id.clone()) {
165            return Err(BackupPlanError::DuplicateOperationId(
166                phase.operation_id.clone(),
167            ));
168        }
169        if let Some(target) = &phase.target_canister_id {
170            validate_principal("phases[].target_canister_id", target)?;
171            if !target_ids.contains(target) {
172                return Err(BackupPlanError::UnknownOperationTarget {
173                    operation_id: phase.operation_id.clone(),
174                    target_canister_id: target.clone(),
175                });
176            }
177        }
178    }
179
180    Ok(())
181}
182
183fn validate_phase_order(phases: &[BackupOperation]) -> Result<(), BackupPlanError> {
184    let topology = preflight_position(phases, BackupOperationKind::ValidateTopology, "topology")?;
185    let control = preflight_position(
186        phases,
187        BackupOperationKind::ValidateControlAuthority,
188        "control_authority",
189    )?;
190    let read = preflight_position(
191        phases,
192        BackupOperationKind::ValidateSnapshotReadAuthority,
193        "snapshot_read_authority",
194    )?;
195    let quiescence = preflight_position(
196        phases,
197        BackupOperationKind::ValidateQuiescencePolicy,
198        "quiescence_policy",
199    )?;
200    let preflight_cutoff = [topology, control, read, quiescence]
201        .into_iter()
202        .max()
203        .expect("non-empty preflight positions");
204
205    for (index, phase) in phases.iter().enumerate() {
206        if index < preflight_cutoff && phase.kind.is_mutating() {
207            return Err(BackupPlanError::MutationBeforePreflight {
208                operation_id: phase.operation_id.clone(),
209            });
210        }
211    }
212
213    Ok(())
214}
215
216fn preflight_position(
217    phases: &[BackupOperation],
218    kind: BackupOperationKind,
219    label: &'static str,
220) -> Result<usize, BackupPlanError> {
221    phases
222        .iter()
223        .position(|phase| phase.kind == kind)
224        .ok_or(BackupPlanError::MissingPreflight(label))
225}
226
227impl BackupOperationKind {
228    const fn is_mutating(&self) -> bool {
229        matches!(
230            self,
231            Self::Stop | Self::CreateSnapshot | Self::Start | Self::DownloadSnapshot
232        )
233    }
234}
235
236pub(super) fn validate_nonempty(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
237    if value.trim().is_empty() {
238        Err(BackupPlanError::EmptyField(field))
239    } else {
240        Ok(())
241    }
242}
243
244pub(super) fn validate_optional_nonempty(
245    field: &'static str,
246    value: Option<&str>,
247) -> Result<(), BackupPlanError> {
248    match value {
249        Some(value) => validate_nonempty(field, value),
250        None => Ok(()),
251    }
252}
253
254pub(super) fn validate_principal(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
255    Principal::from_str(value)
256        .map(|_| ())
257        .map_err(|_| BackupPlanError::InvalidPrincipal {
258            field,
259            value: value.to_string(),
260        })
261}
262
263pub(super) fn validate_required_hash(
264    field: &'static str,
265    value: &str,
266) -> Result<(), BackupPlanError> {
267    validate_nonempty(field, value)?;
268    if value.len() == 64 && value.chars().all(|char| char.is_ascii_hexdigit()) {
269        Ok(())
270    } else {
271        Err(BackupPlanError::InvalidTopologyHash {
272            field,
273            value: value.to_string(),
274        })
275    }
276}
277
278pub(super) fn validate_preflight_id(value: &str) -> Result<(), BackupPlanError> {
279    validate_nonempty("preflight_id", value)
280}
281
282pub(super) fn validate_preflight_window(
283    preflight_id: &str,
284    validated_at: &str,
285    expires_at: &str,
286    as_of: &str,
287) -> Result<(), BackupPlanError> {
288    let validated_at_seconds =
289        validate_preflight_timestamp("preflight_receipts[].validated_at", validated_at)?;
290    let expires_at_seconds =
291        validate_preflight_timestamp("preflight_receipts[].expires_at", expires_at)?;
292    let as_of_seconds = validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
293
294    if validated_at_seconds >= expires_at_seconds {
295        return Err(BackupPlanError::PreflightReceiptInvalidWindow {
296            preflight_id: preflight_id.to_string(),
297        });
298    }
299    if as_of_seconds < validated_at_seconds {
300        return Err(BackupPlanError::PreflightReceiptNotYetValid {
301            preflight_id: preflight_id.to_string(),
302            validated_at: validated_at.to_string(),
303            as_of: as_of.to_string(),
304        });
305    }
306    if as_of_seconds >= expires_at_seconds {
307        return Err(BackupPlanError::PreflightReceiptExpired {
308            preflight_id: preflight_id.to_string(),
309            expires_at: expires_at.to_string(),
310            as_of: as_of.to_string(),
311        });
312    }
313
314    Ok(())
315}
316
317pub(super) fn validate_preflight_timestamp(
318    field: &'static str,
319    value: &str,
320) -> Result<u64, BackupPlanError> {
321    validate_nonempty(field, value)?;
322    value
323        .strip_prefix("unix:")
324        .and_then(|seconds| seconds.parse::<u64>().ok())
325        .ok_or_else(|| BackupPlanError::InvalidTimestamp {
326            field,
327            value: value.to_string(),
328        })
329}
330
331fn validate_optional_principal(
332    field: &'static str,
333    value: Option<&str>,
334) -> Result<(), BackupPlanError> {
335    match value {
336        Some(value) => validate_principal(field, value),
337        None => Ok(()),
338    }
339}