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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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#[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;