Skip to main content

canic_backup/plan/
mod.rs

1mod build;
2mod types;
3
4pub use build::{BackupPlanBuildInput, build_backup_plan, resolve_backup_selector};
5pub use types::*;
6
7use crate::discovery::DiscoveryError;
8#[cfg(test)]
9use crate::discovery::RegistryEntry;
10#[cfg(test)]
11use crate::manifest::IdentityMode;
12use candid::Principal;
13use std::{collections::BTreeMap, collections::BTreeSet, str::FromStr};
14use thiserror::Error as ThisError;
15
16impl BackupPlan {
17    /// Validate the backup plan as a dry-run/planning artifact.
18    pub fn validate(&self) -> Result<(), BackupPlanError> {
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("network", &self.network)?;
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    /// Validate execution-only preflight receipts before mutation starts.
67    pub fn validate_execution_preflight_receipts(
68        &self,
69        topology_receipt: &TopologyPreflightReceipt,
70        quiescence_receipt: &QuiescencePreflightReceipt,
71        preflight_id: &str,
72        as_of: &str,
73    ) -> Result<(), BackupPlanError> {
74        self.validate_for_execution()?;
75        validate_preflight_id(preflight_id)?;
76        validate_preflight_timestamp("preflight.as_of", as_of)?;
77        validate_topology_preflight_receipt(self, topology_receipt, preflight_id, as_of)?;
78        validate_quiescence_preflight_receipt(self, quiescence_receipt, preflight_id, as_of)
79    }
80
81    /// Apply and validate the full execution preflight receipt bundle.
82    pub fn apply_execution_preflight_receipts(
83        &mut self,
84        receipts: &BackupExecutionPreflightReceipts,
85        as_of: &str,
86    ) -> Result<(), BackupPlanError> {
87        validate_execution_preflight_bundle(self, receipts, as_of)?;
88        self.apply_authority_preflight_receipts(
89            &receipts.preflight_id,
90            &receipts.control_authority,
91            &receipts.snapshot_read_authority,
92            as_of,
93        )?;
94        self.validate_execution_preflight_receipts(
95            &receipts.topology,
96            &receipts.quiescence,
97            &receipts.preflight_id,
98            as_of,
99        )
100    }
101
102    /// Apply proven authority receipts produced by execution preflights.
103    pub fn apply_authority_preflight_receipts(
104        &mut self,
105        preflight_id: &str,
106        control_receipts: &[ControlAuthorityReceipt],
107        snapshot_read_receipts: &[SnapshotReadAuthorityReceipt],
108        as_of: &str,
109    ) -> Result<(), BackupPlanError> {
110        self.apply_control_authority_receipts(preflight_id, control_receipts, as_of)?;
111        self.apply_snapshot_read_authority_receipts(preflight_id, snapshot_read_receipts, as_of)
112    }
113
114    /// Apply proven control authority receipts for every selected target.
115    pub fn apply_control_authority_receipts(
116        &mut self,
117        preflight_id: &str,
118        receipts: &[ControlAuthorityReceipt],
119        as_of: &str,
120    ) -> Result<(), BackupPlanError> {
121        let mut receipts =
122            control_receipt_map(&self.plan_id, preflight_id, as_of, &self.targets, receipts)?;
123        let mut updates = Vec::with_capacity(self.targets.len());
124        for target in &self.targets {
125            let receipt = receipts.remove(&target.canister_id).ok_or_else(|| {
126                BackupPlanError::MissingControlAuthorityReceipt(target.canister_id.clone())
127            })?;
128            if !receipt.authority.is_proven() {
129                return Err(BackupPlanError::UnprovenControlAuthority(
130                    target.canister_id.clone(),
131                ));
132            }
133            if self.requires_root_controller
134                && target.canister_id != self.root_canister_id
135                && !receipt.authority.is_proven_root_controller()
136            {
137                return Err(BackupPlanError::MissingRootController(
138                    target.canister_id.clone(),
139                ));
140            }
141            updates.push((target.canister_id.clone(), receipt.authority));
142        }
143
144        for (target_id, authority) in updates {
145            let target = self
146                .targets
147                .iter_mut()
148                .find(|target| target.canister_id == target_id)
149                .expect("validated update target should exist");
150            target.control_authority = authority;
151        }
152        Ok(())
153    }
154
155    /// Apply proven snapshot read authority receipts for every selected target.
156    pub fn apply_snapshot_read_authority_receipts(
157        &mut self,
158        preflight_id: &str,
159        receipts: &[SnapshotReadAuthorityReceipt],
160        as_of: &str,
161    ) -> Result<(), BackupPlanError> {
162        let mut receipts =
163            snapshot_read_receipt_map(&self.plan_id, preflight_id, as_of, &self.targets, receipts)?;
164        let mut updates = Vec::with_capacity(self.targets.len());
165        for target in &self.targets {
166            let receipt = receipts.remove(&target.canister_id).ok_or_else(|| {
167                BackupPlanError::MissingSnapshotReadAuthorityReceipt(target.canister_id.clone())
168            })?;
169            if !receipt.authority.is_proven() {
170                return Err(BackupPlanError::UnprovenTargetSnapshotReadAuthority(
171                    target.canister_id.clone(),
172                ));
173            }
174            updates.push((target.canister_id.clone(), receipt.authority));
175        }
176
177        for (target_id, authority) in updates {
178            let target = self
179                .targets
180                .iter_mut()
181                .find(|target| target.canister_id == target_id)
182                .expect("validated update target should exist");
183            target.snapshot_read_authority = authority;
184        }
185        Ok(())
186    }
187
188    /// Build the typed control-authority preflight request for this plan.
189    #[must_use]
190    pub fn control_authority_preflight_request(&self) -> ControlAuthorityPreflightRequest {
191        ControlAuthorityPreflightRequest {
192            plan_id: self.plan_id.clone(),
193            run_id: self.run_id.clone(),
194            fleet: self.fleet.clone(),
195            network: self.network.clone(),
196            root_canister_id: self.root_canister_id.clone(),
197            requires_root_controller: self.requires_root_controller,
198            targets: self
199                .targets
200                .iter()
201                .map(ControlAuthorityPreflightTarget::from)
202                .collect(),
203        }
204    }
205
206    /// Build the typed snapshot-read preflight request for this plan.
207    #[must_use]
208    pub fn snapshot_read_authority_preflight_request(
209        &self,
210    ) -> SnapshotReadAuthorityPreflightRequest {
211        SnapshotReadAuthorityPreflightRequest {
212            plan_id: self.plan_id.clone(),
213            run_id: self.run_id.clone(),
214            fleet: self.fleet.clone(),
215            network: self.network.clone(),
216            root_canister_id: self.root_canister_id.clone(),
217            targets: self
218                .targets
219                .iter()
220                .map(SnapshotReadAuthorityPreflightTarget::from)
221                .collect(),
222        }
223    }
224
225    /// Build the typed topology preflight request for this plan.
226    #[must_use]
227    pub fn topology_preflight_request(&self) -> TopologyPreflightRequest {
228        TopologyPreflightRequest {
229            plan_id: self.plan_id.clone(),
230            run_id: self.run_id.clone(),
231            fleet: self.fleet.clone(),
232            network: self.network.clone(),
233            root_canister_id: self.root_canister_id.clone(),
234            selected_subtree_root: self.selected_subtree_root.clone(),
235            selected_scope_kind: self.selected_scope_kind.clone(),
236            topology_hash_before_quiesce: self.topology_hash_before_quiesce.clone(),
237            targets: self
238                .targets
239                .iter()
240                .map(TopologyPreflightTarget::from)
241                .collect(),
242        }
243    }
244
245    /// Build the typed quiescence preflight request for this plan.
246    #[must_use]
247    pub fn quiescence_preflight_request(&self) -> QuiescencePreflightRequest {
248        QuiescencePreflightRequest {
249            plan_id: self.plan_id.clone(),
250            run_id: self.run_id.clone(),
251            fleet: self.fleet.clone(),
252            network: self.network.clone(),
253            root_canister_id: self.root_canister_id.clone(),
254            selected_subtree_root: self.selected_subtree_root.clone(),
255            quiescence_policy: self.quiescence_policy.clone(),
256            targets: self
257                .targets
258                .iter()
259                .map(QuiescencePreflightTarget::from)
260                .collect(),
261        }
262    }
263}
264
265///
266/// BackupPlanError
267///
268
269#[derive(Debug, ThisError)]
270pub enum BackupPlanError {
271    #[error("field {0} must not be empty")]
272    EmptyField(&'static str),
273
274    #[error("field {field} must be a valid principal: {value}")]
275    InvalidPrincipal { field: &'static str, value: String },
276
277    #[error("field {field} must be a 64-character hex topology hash: {value}")]
278    InvalidTopologyHash { field: &'static str, value: String },
279
280    #[error("field {field} must be a unix timestamp marker: {value}")]
281    InvalidTimestamp { field: &'static str, value: String },
282
283    #[error("backup plan has no targets")]
284    EmptyTargets,
285
286    #[error("backup plan has no phases")]
287    EmptyPhases,
288
289    #[error("duplicate backup target {0}")]
290    DuplicateTarget(String),
291
292    #[error("duplicate backup operation id {0}")]
293    DuplicateOperationId(String),
294
295    #[error("operation {operation_id} has order {order}, expected {expected}")]
296    OperationOrderMismatch {
297        operation_id: String,
298        order: u32,
299        expected: u32,
300    },
301
302    #[error("normal backup scope must not include root")]
303    RootIncludedWithoutMaintenance,
304
305    #[error("maintenance root scope must include root")]
306    MaintenanceRootExcludesRoot,
307
308    #[error("selected scope root {0} is not present in plan targets")]
309    SelectedRootNotInTargets(String),
310
311    #[error("non-root-fleet scope must not declare a selected subtree root")]
312    NonRootFleetHasSelectedRoot,
313
314    #[error("target {0} has no proven control authority")]
315    UnprovenControlAuthority(String),
316
317    #[error("target {0} has no proven snapshot read authority")]
318    UnprovenTargetSnapshotReadAuthority(String),
319
320    #[error("target {0} must be controllable by root for this plan")]
321    MissingRootController(String),
322
323    #[error("target {0} has no control authority receipt")]
324    MissingControlAuthorityReceipt(String),
325
326    #[error("target {0} has no snapshot read authority receipt")]
327    MissingSnapshotReadAuthorityReceipt(String),
328
329    #[error("authority receipt targets unknown canister {0}")]
330    UnknownAuthorityReceiptTarget(String),
331
332    #[error("duplicate authority receipt for target {0}")]
333    DuplicateAuthorityReceipt(String),
334
335    #[error("authority receipt plan id {actual} does not match plan {expected}")]
336    AuthorityReceiptPlanMismatch { expected: String, actual: String },
337
338    #[error("authority receipt preflight id {actual} does not match preflight {expected}")]
339    AuthorityReceiptPreflightMismatch { expected: String, actual: String },
340
341    #[error("preflight receipt plan id {actual} does not match plan {expected}")]
342    PreflightReceiptPlanMismatch { expected: String, actual: String },
343
344    #[error("preflight receipt id {actual} does not match preflight {expected}")]
345    PreflightReceiptIdMismatch { expected: String, actual: String },
346
347    #[error(
348        "preflight receipt {preflight_id} is not valid yet at {as_of}; validated at {validated_at}"
349    )]
350    PreflightReceiptNotYetValid {
351        preflight_id: String,
352        validated_at: String,
353        as_of: String,
354    },
355
356    #[error("preflight receipt {preflight_id} expired at {expires_at}; checked at {as_of}")]
357    PreflightReceiptExpired {
358        preflight_id: String,
359        expires_at: String,
360        as_of: String,
361    },
362
363    #[error("preflight receipt {preflight_id} has invalid validity window")]
364    PreflightReceiptInvalidWindow { preflight_id: String },
365
366    #[error("topology preflight hash drifted from {expected} to {actual}")]
367    TopologyPreflightHashMismatch { expected: String, actual: String },
368
369    #[error("topology preflight targets do not match selected plan targets")]
370    TopologyPreflightTargetsMismatch,
371
372    #[error("quiescence preflight policy does not match plan")]
373    QuiescencePolicyMismatch,
374
375    #[error("quiescence preflight was not accepted")]
376    QuiescencePreflightRejected,
377
378    #[error("quiescence preflight targets do not match selected plan targets")]
379    QuiescencePreflightTargetsMismatch,
380
381    #[error("operation {operation_id} targets unknown canister {target_canister_id}")]
382    UnknownOperationTarget {
383        operation_id: String,
384        target_canister_id: String,
385    },
386
387    #[error("backup selector {0} did not match a live topology node")]
388    UnknownSelector(String),
389
390    #[error("backup selector {selector} matched multiple canisters: {matches:?}")]
391    AmbiguousSelector {
392        selector: String,
393        matches: Vec<String>,
394    },
395
396    #[error("required preflight operation {0} is missing")]
397    MissingPreflight(&'static str),
398
399    #[error("mutating operation {operation_id} appears before required preflights")]
400    MutationBeforePreflight { operation_id: String },
401
402    #[error(transparent)]
403    Discovery(#[from] DiscoveryError),
404}
405
406fn validate_root_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
407    if plan.selected_scope_kind == BackupScopeKind::MaintenanceRoot {
408        if plan.root_included {
409            return Ok(());
410        }
411        return Err(BackupPlanError::MaintenanceRootExcludesRoot);
412    }
413
414    if plan.root_included {
415        return Err(BackupPlanError::RootIncludedWithoutMaintenance);
416    }
417
418    Ok(())
419}
420
421fn validate_targets(plan: &BackupPlan) -> Result<(), BackupPlanError> {
422    if plan.targets.is_empty() {
423        return Err(BackupPlanError::EmptyTargets);
424    }
425
426    let mut target_ids = BTreeSet::new();
427    for target in &plan.targets {
428        validate_principal("targets[].canister_id", &target.canister_id)?;
429        validate_optional_principal(
430            "targets[].parent_canister_id",
431            target.parent_canister_id.as_deref(),
432        )?;
433        validate_optional_nonempty("targets[].role", target.role.as_deref())?;
434        validate_optional_nonempty(
435            "targets[].expected_module_hash",
436            target.expected_module_hash.as_deref(),
437        )?;
438        validate_control_authority(&target.control_authority)?;
439
440        if !target_ids.insert(target.canister_id.clone()) {
441            return Err(BackupPlanError::DuplicateTarget(target.canister_id.clone()));
442        }
443        if !plan.root_included && target.canister_id == plan.root_canister_id {
444            return Err(BackupPlanError::RootIncludedWithoutMaintenance);
445        }
446    }
447
448    validate_operation_targets(&plan.phases, &target_ids)
449}
450
451fn validate_control_authority(authority: &ControlAuthority) -> Result<(), BackupPlanError> {
452    match &authority.source {
453        ControlAuthoritySource::Unknown
454        | ControlAuthoritySource::RootController
455        | ControlAuthoritySource::OperatorController => Ok(()),
456        ControlAuthoritySource::AlternateController { controller, reason } => {
457            validate_principal("targets[].control_authority.controller", controller)?;
458            validate_nonempty("targets[].control_authority.reason", reason)
459        }
460    }
461}
462
463fn control_receipt_map(
464    plan_id: &str,
465    preflight_id: &str,
466    as_of: &str,
467    targets: &[BackupTarget],
468    receipts: &[ControlAuthorityReceipt],
469) -> Result<BTreeMap<String, ControlAuthorityReceipt>, BackupPlanError> {
470    let target_ids = targets
471        .iter()
472        .map(|target| target.canister_id.as_str())
473        .collect::<BTreeSet<_>>();
474    let mut receipt_map = BTreeMap::new();
475
476    for receipt in receipts {
477        validate_authority_receipt_header(AuthorityReceiptHeaderInput {
478            expected_plan_id: plan_id,
479            expected_preflight_id: preflight_id,
480            as_of,
481            target_ids: &target_ids,
482            actual_plan_id: &receipt.plan_id,
483            actual_preflight_id: &receipt.preflight_id,
484            target_canister_id: &receipt.target_canister_id,
485            validated_at: &receipt.validated_at,
486            expires_at: &receipt.expires_at,
487            message: receipt.message.as_deref(),
488        })?;
489        validate_control_authority(&receipt.authority)?;
490        if receipt_map
491            .insert(receipt.target_canister_id.clone(), receipt.clone())
492            .is_some()
493        {
494            return Err(BackupPlanError::DuplicateAuthorityReceipt(
495                receipt.target_canister_id.clone(),
496            ));
497        }
498    }
499
500    Ok(receipt_map)
501}
502
503fn snapshot_read_receipt_map(
504    plan_id: &str,
505    preflight_id: &str,
506    as_of: &str,
507    targets: &[BackupTarget],
508    receipts: &[SnapshotReadAuthorityReceipt],
509) -> Result<BTreeMap<String, SnapshotReadAuthorityReceipt>, BackupPlanError> {
510    let target_ids = targets
511        .iter()
512        .map(|target| target.canister_id.as_str())
513        .collect::<BTreeSet<_>>();
514    let mut receipt_map = BTreeMap::new();
515
516    for receipt in receipts {
517        validate_authority_receipt_header(AuthorityReceiptHeaderInput {
518            expected_plan_id: plan_id,
519            expected_preflight_id: preflight_id,
520            as_of,
521            target_ids: &target_ids,
522            actual_plan_id: &receipt.plan_id,
523            actual_preflight_id: &receipt.preflight_id,
524            target_canister_id: &receipt.target_canister_id,
525            validated_at: &receipt.validated_at,
526            expires_at: &receipt.expires_at,
527            message: receipt.message.as_deref(),
528        })?;
529        if receipt_map
530            .insert(receipt.target_canister_id.clone(), receipt.clone())
531            .is_some()
532        {
533            return Err(BackupPlanError::DuplicateAuthorityReceipt(
534                receipt.target_canister_id.clone(),
535            ));
536        }
537    }
538
539    Ok(receipt_map)
540}
541
542struct AuthorityReceiptHeaderInput<'a> {
543    expected_plan_id: &'a str,
544    expected_preflight_id: &'a str,
545    as_of: &'a str,
546    target_ids: &'a BTreeSet<&'a str>,
547    actual_plan_id: &'a str,
548    actual_preflight_id: &'a str,
549    target_canister_id: &'a str,
550    validated_at: &'a str,
551    expires_at: &'a str,
552    message: Option<&'a str>,
553}
554
555fn validate_authority_receipt_header(
556    input: AuthorityReceiptHeaderInput<'_>,
557) -> Result<(), BackupPlanError> {
558    validate_nonempty("authority_receipts[].plan_id", input.actual_plan_id)?;
559    validate_preflight_id(input.actual_preflight_id)?;
560    validate_principal(
561        "authority_receipts[].target_canister_id",
562        input.target_canister_id,
563    )?;
564    validate_optional_nonempty("authority_receipts[].message", input.message)?;
565    validate_preflight_window(
566        input.actual_preflight_id,
567        input.validated_at,
568        input.expires_at,
569        input.as_of,
570    )?;
571
572    if input.actual_plan_id != input.expected_plan_id {
573        return Err(BackupPlanError::AuthorityReceiptPlanMismatch {
574            expected: input.expected_plan_id.to_string(),
575            actual: input.actual_plan_id.to_string(),
576        });
577    }
578    if input.actual_preflight_id != input.expected_preflight_id {
579        return Err(BackupPlanError::AuthorityReceiptPreflightMismatch {
580            expected: input.expected_preflight_id.to_string(),
581            actual: input.actual_preflight_id.to_string(),
582        });
583    }
584    if !input.target_ids.contains(input.target_canister_id) {
585        return Err(BackupPlanError::UnknownAuthorityReceiptTarget(
586            input.target_canister_id.to_string(),
587        ));
588    }
589
590    Ok(())
591}
592
593fn validate_execution_preflight_bundle(
594    plan: &BackupPlan,
595    receipts: &BackupExecutionPreflightReceipts,
596    as_of: &str,
597) -> Result<(), BackupPlanError> {
598    validate_nonempty("preflight_receipts.plan_id", &receipts.plan_id)?;
599    validate_preflight_id(&receipts.preflight_id)?;
600    validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
601    validate_preflight_window(
602        &receipts.preflight_id,
603        &receipts.validated_at,
604        &receipts.expires_at,
605        as_of,
606    )?;
607
608    if receipts.plan_id != plan.plan_id {
609        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
610            expected: plan.plan_id.clone(),
611            actual: receipts.plan_id.clone(),
612        });
613    }
614
615    Ok(())
616}
617
618fn validate_topology_preflight_receipt(
619    plan: &BackupPlan,
620    receipt: &TopologyPreflightReceipt,
621    preflight_id: &str,
622    as_of: &str,
623) -> Result<(), BackupPlanError> {
624    validate_nonempty("topology_receipt.plan_id", &receipt.plan_id)?;
625    validate_preflight_id(&receipt.preflight_id)?;
626    validate_required_hash(
627        "topology_receipt.topology_hash_before_quiesce",
628        &receipt.topology_hash_before_quiesce,
629    )?;
630    validate_required_hash(
631        "topology_receipt.topology_hash_at_preflight",
632        &receipt.topology_hash_at_preflight,
633    )?;
634    validate_optional_nonempty("topology_receipt.message", receipt.message.as_deref())?;
635    validate_preflight_window(
636        &receipt.preflight_id,
637        &receipt.validated_at,
638        &receipt.expires_at,
639        as_of,
640    )?;
641
642    if receipt.plan_id != plan.plan_id {
643        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
644            expected: plan.plan_id.clone(),
645            actual: receipt.plan_id.clone(),
646        });
647    }
648    if receipt.preflight_id != preflight_id {
649        return Err(BackupPlanError::PreflightReceiptIdMismatch {
650            expected: preflight_id.to_string(),
651            actual: receipt.preflight_id.clone(),
652        });
653    }
654    if receipt.topology_hash_before_quiesce != plan.topology_hash_before_quiesce {
655        return Err(BackupPlanError::TopologyPreflightHashMismatch {
656            expected: plan.topology_hash_before_quiesce.clone(),
657            actual: receipt.topology_hash_before_quiesce.clone(),
658        });
659    }
660    if receipt.topology_hash_at_preflight != plan.topology_hash_before_quiesce {
661        return Err(BackupPlanError::TopologyPreflightHashMismatch {
662            expected: plan.topology_hash_before_quiesce.clone(),
663            actual: receipt.topology_hash_at_preflight.clone(),
664        });
665    }
666    if receipt.targets != plan.topology_preflight_request().targets {
667        return Err(BackupPlanError::TopologyPreflightTargetsMismatch);
668    }
669
670    Ok(())
671}
672
673fn validate_quiescence_preflight_receipt(
674    plan: &BackupPlan,
675    receipt: &QuiescencePreflightReceipt,
676    preflight_id: &str,
677    as_of: &str,
678) -> Result<(), BackupPlanError> {
679    validate_nonempty("quiescence_receipt.plan_id", &receipt.plan_id)?;
680    validate_preflight_id(&receipt.preflight_id)?;
681    validate_optional_nonempty("quiescence_receipt.message", receipt.message.as_deref())?;
682    validate_preflight_window(
683        &receipt.preflight_id,
684        &receipt.validated_at,
685        &receipt.expires_at,
686        as_of,
687    )?;
688
689    if receipt.plan_id != plan.plan_id {
690        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
691            expected: plan.plan_id.clone(),
692            actual: receipt.plan_id.clone(),
693        });
694    }
695    if receipt.preflight_id != preflight_id {
696        return Err(BackupPlanError::PreflightReceiptIdMismatch {
697            expected: preflight_id.to_string(),
698            actual: receipt.preflight_id.clone(),
699        });
700    }
701    if receipt.quiescence_policy != plan.quiescence_policy {
702        return Err(BackupPlanError::QuiescencePolicyMismatch);
703    }
704    if !receipt.accepted {
705        return Err(BackupPlanError::QuiescencePreflightRejected);
706    }
707    if receipt.targets != plan.quiescence_preflight_request().targets {
708        return Err(BackupPlanError::QuiescencePreflightTargetsMismatch);
709    }
710
711    Ok(())
712}
713
714fn validate_selected_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
715    match plan.selected_scope_kind {
716        BackupScopeKind::NonRootFleet => {
717            if plan.selected_subtree_root.is_some() {
718                return Err(BackupPlanError::NonRootFleetHasSelectedRoot);
719            }
720            Ok(())
721        }
722        BackupScopeKind::Member | BackupScopeKind::Subtree | BackupScopeKind::MaintenanceRoot => {
723            let Some(selected_root) = &plan.selected_subtree_root else {
724                return Err(BackupPlanError::EmptyField("selected_subtree_root"));
725            };
726            if plan
727                .targets
728                .iter()
729                .any(|target| &target.canister_id == selected_root)
730            {
731                Ok(())
732            } else {
733                Err(BackupPlanError::SelectedRootNotInTargets(
734                    selected_root.clone(),
735                ))
736            }
737        }
738    }
739}
740
741fn validate_operation_targets(
742    phases: &[BackupOperation],
743    target_ids: &BTreeSet<String>,
744) -> Result<(), BackupPlanError> {
745    if phases.is_empty() {
746        return Err(BackupPlanError::EmptyPhases);
747    }
748
749    let mut operation_ids = BTreeSet::new();
750    for (index, phase) in phases.iter().enumerate() {
751        validate_nonempty("phases[].operation_id", &phase.operation_id)?;
752        let expected = u32::try_from(index).unwrap_or(u32::MAX);
753        if phase.order != expected {
754            return Err(BackupPlanError::OperationOrderMismatch {
755                operation_id: phase.operation_id.clone(),
756                order: phase.order,
757                expected,
758            });
759        }
760        if !operation_ids.insert(phase.operation_id.clone()) {
761            return Err(BackupPlanError::DuplicateOperationId(
762                phase.operation_id.clone(),
763            ));
764        }
765        if let Some(target) = &phase.target_canister_id {
766            validate_principal("phases[].target_canister_id", target)?;
767            if !target_ids.contains(target) {
768                return Err(BackupPlanError::UnknownOperationTarget {
769                    operation_id: phase.operation_id.clone(),
770                    target_canister_id: target.clone(),
771                });
772            }
773        }
774    }
775
776    Ok(())
777}
778
779fn validate_phase_order(phases: &[BackupOperation]) -> Result<(), BackupPlanError> {
780    let topology = preflight_position(phases, BackupOperationKind::ValidateTopology, "topology")?;
781    let control = preflight_position(
782        phases,
783        BackupOperationKind::ValidateControlAuthority,
784        "control_authority",
785    )?;
786    let read = preflight_position(
787        phases,
788        BackupOperationKind::ValidateSnapshotReadAuthority,
789        "snapshot_read_authority",
790    )?;
791    let quiescence = preflight_position(
792        phases,
793        BackupOperationKind::ValidateQuiescencePolicy,
794        "quiescence_policy",
795    )?;
796    let preflight_cutoff = [topology, control, read, quiescence]
797        .into_iter()
798        .max()
799        .expect("non-empty preflight positions");
800
801    for (index, phase) in phases.iter().enumerate() {
802        if index < preflight_cutoff && phase.kind.is_mutating() {
803            return Err(BackupPlanError::MutationBeforePreflight {
804                operation_id: phase.operation_id.clone(),
805            });
806        }
807    }
808
809    Ok(())
810}
811
812fn preflight_position(
813    phases: &[BackupOperation],
814    kind: BackupOperationKind,
815    label: &'static str,
816) -> Result<usize, BackupPlanError> {
817    phases
818        .iter()
819        .position(|phase| phase.kind == kind)
820        .ok_or(BackupPlanError::MissingPreflight(label))
821}
822
823impl BackupOperationKind {
824    const fn is_mutating(&self) -> bool {
825        matches!(
826            self,
827            Self::Stop | Self::CreateSnapshot | Self::Start | Self::DownloadSnapshot
828        )
829    }
830}
831
832fn validate_nonempty(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
833    if value.trim().is_empty() {
834        Err(BackupPlanError::EmptyField(field))
835    } else {
836        Ok(())
837    }
838}
839
840fn validate_optional_nonempty(
841    field: &'static str,
842    value: Option<&str>,
843) -> Result<(), BackupPlanError> {
844    match value {
845        Some(value) => validate_nonempty(field, value),
846        None => Ok(()),
847    }
848}
849
850fn validate_principal(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
851    Principal::from_str(value)
852        .map(|_| ())
853        .map_err(|_| BackupPlanError::InvalidPrincipal {
854            field,
855            value: value.to_string(),
856        })
857}
858
859fn validate_required_hash(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
860    validate_nonempty(field, value)?;
861    if value.len() == 64 && value.chars().all(|char| char.is_ascii_hexdigit()) {
862        Ok(())
863    } else {
864        Err(BackupPlanError::InvalidTopologyHash {
865            field,
866            value: value.to_string(),
867        })
868    }
869}
870
871fn validate_preflight_id(value: &str) -> Result<(), BackupPlanError> {
872    validate_nonempty("preflight_id", value)
873}
874
875fn validate_preflight_window(
876    preflight_id: &str,
877    validated_at: &str,
878    expires_at: &str,
879    as_of: &str,
880) -> Result<(), BackupPlanError> {
881    let validated_at_seconds =
882        validate_preflight_timestamp("preflight_receipts[].validated_at", validated_at)?;
883    let expires_at_seconds =
884        validate_preflight_timestamp("preflight_receipts[].expires_at", expires_at)?;
885    let as_of_seconds = validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
886
887    if validated_at_seconds >= expires_at_seconds {
888        return Err(BackupPlanError::PreflightReceiptInvalidWindow {
889            preflight_id: preflight_id.to_string(),
890        });
891    }
892    if as_of_seconds < validated_at_seconds {
893        return Err(BackupPlanError::PreflightReceiptNotYetValid {
894            preflight_id: preflight_id.to_string(),
895            validated_at: validated_at.to_string(),
896            as_of: as_of.to_string(),
897        });
898    }
899    if as_of_seconds >= expires_at_seconds {
900        return Err(BackupPlanError::PreflightReceiptExpired {
901            preflight_id: preflight_id.to_string(),
902            expires_at: expires_at.to_string(),
903            as_of: as_of.to_string(),
904        });
905    }
906
907    Ok(())
908}
909
910fn validate_preflight_timestamp(field: &'static str, value: &str) -> Result<u64, BackupPlanError> {
911    validate_nonempty(field, value)?;
912    value
913        .strip_prefix("unix:")
914        .and_then(|seconds| seconds.parse::<u64>().ok())
915        .ok_or_else(|| BackupPlanError::InvalidTimestamp {
916            field,
917            value: value.to_string(),
918        })
919}
920
921fn validate_optional_principal(
922    field: &'static str,
923    value: Option<&str>,
924) -> Result<(), BackupPlanError> {
925    match value {
926        Some(value) => validate_principal(field, value),
927        None => Ok(()),
928    }
929}
930
931#[cfg(test)]
932mod tests;