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