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