Skip to main content

canic_backup/plan/
validation.rs

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