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