Skip to main content

canic_backup/plan/
preflight.rs

1use super::{
2    BackupExecutionPreflightReceipts, BackupPlan, BackupPlanError, BackupTarget,
3    ControlAuthorityPreflightRequest, ControlAuthorityPreflightTarget, ControlAuthorityReceipt,
4    QuiescencePreflightReceipt, QuiescencePreflightRequest, QuiescencePreflightTarget,
5    SnapshotReadAuthorityPreflightRequest, SnapshotReadAuthorityPreflightTarget,
6    SnapshotReadAuthorityReceipt, TopologyPreflightReceipt, TopologyPreflightRequest,
7    TopologyPreflightTarget,
8    validation::{
9        validate_control_authority, validate_nonempty, validate_optional_nonempty,
10        validate_preflight_id, validate_preflight_timestamp, validate_preflight_window,
11        validate_principal, validate_required_hash,
12    },
13};
14use std::collections::{BTreeMap, BTreeSet};
15
16impl BackupPlan {
17    /// Validate execution-only preflight receipts before mutation starts.
18    pub fn validate_execution_preflight_receipts(
19        &self,
20        topology_receipt: &TopologyPreflightReceipt,
21        quiescence_receipt: &QuiescencePreflightReceipt,
22        preflight_id: &str,
23        as_of: &str,
24    ) -> Result<(), BackupPlanError> {
25        self.validate_for_execution()?;
26        validate_preflight_id(preflight_id)?;
27        validate_preflight_timestamp("preflight.as_of", as_of)?;
28        validate_topology_preflight_receipt(self, topology_receipt, preflight_id, as_of)?;
29        validate_quiescence_preflight_receipt(self, quiescence_receipt, preflight_id, as_of)
30    }
31
32    /// Apply and validate the full execution preflight receipt bundle.
33    pub fn apply_execution_preflight_receipts(
34        &mut self,
35        receipts: &BackupExecutionPreflightReceipts,
36        as_of: &str,
37    ) -> Result<(), BackupPlanError> {
38        validate_execution_preflight_bundle(self, receipts, as_of)?;
39        self.apply_authority_preflight_receipts(
40            &receipts.preflight_id,
41            &receipts.control_authority,
42            &receipts.snapshot_read_authority,
43            as_of,
44        )?;
45        self.validate_execution_preflight_receipts(
46            &receipts.topology,
47            &receipts.quiescence,
48            &receipts.preflight_id,
49            as_of,
50        )
51    }
52
53    /// Apply proven authority receipts produced by execution preflights.
54    pub fn apply_authority_preflight_receipts(
55        &mut self,
56        preflight_id: &str,
57        control_receipts: &[ControlAuthorityReceipt],
58        snapshot_read_receipts: &[SnapshotReadAuthorityReceipt],
59        as_of: &str,
60    ) -> Result<(), BackupPlanError> {
61        self.apply_control_authority_receipts(preflight_id, control_receipts, as_of)?;
62        self.apply_snapshot_read_authority_receipts(preflight_id, snapshot_read_receipts, as_of)
63    }
64
65    /// Apply proven control authority receipts for every selected target.
66    pub fn apply_control_authority_receipts(
67        &mut self,
68        preflight_id: &str,
69        receipts: &[ControlAuthorityReceipt],
70        as_of: &str,
71    ) -> Result<(), BackupPlanError> {
72        let mut receipts =
73            control_receipt_map(&self.plan_id, preflight_id, as_of, &self.targets, receipts)?;
74        let mut updates = Vec::with_capacity(self.targets.len());
75        for target in &self.targets {
76            let receipt = receipts.remove(&target.canister_id).ok_or_else(|| {
77                BackupPlanError::MissingControlAuthorityReceipt(target.canister_id.clone())
78            })?;
79            if !receipt.authority.is_proven() {
80                return Err(BackupPlanError::UnprovenControlAuthority(
81                    target.canister_id.clone(),
82                ));
83            }
84            if self.requires_root_controller
85                && target.canister_id != self.root_canister_id
86                && !receipt.authority.is_proven_root_controller()
87            {
88                return Err(BackupPlanError::MissingRootController(
89                    target.canister_id.clone(),
90                ));
91            }
92            updates.push((target.canister_id.clone(), receipt.authority));
93        }
94
95        for (target_id, authority) in updates {
96            let target = self
97                .targets
98                .iter_mut()
99                .find(|target| target.canister_id == target_id)
100                .expect("validated update target should exist");
101            target.control_authority = authority;
102        }
103        Ok(())
104    }
105
106    /// Apply proven snapshot read authority receipts for every selected target.
107    pub fn apply_snapshot_read_authority_receipts(
108        &mut self,
109        preflight_id: &str,
110        receipts: &[SnapshotReadAuthorityReceipt],
111        as_of: &str,
112    ) -> Result<(), BackupPlanError> {
113        let mut receipts =
114            snapshot_read_receipt_map(&self.plan_id, preflight_id, as_of, &self.targets, receipts)?;
115        let mut updates = Vec::with_capacity(self.targets.len());
116        for target in &self.targets {
117            let receipt = receipts.remove(&target.canister_id).ok_or_else(|| {
118                BackupPlanError::MissingSnapshotReadAuthorityReceipt(target.canister_id.clone())
119            })?;
120            if !receipt.authority.is_proven() {
121                return Err(BackupPlanError::UnprovenTargetSnapshotReadAuthority(
122                    target.canister_id.clone(),
123                ));
124            }
125            updates.push((target.canister_id.clone(), receipt.authority));
126        }
127
128        for (target_id, authority) in updates {
129            let target = self
130                .targets
131                .iter_mut()
132                .find(|target| target.canister_id == target_id)
133                .expect("validated update target should exist");
134            target.snapshot_read_authority = authority;
135        }
136        Ok(())
137    }
138
139    /// Build the typed control-authority preflight request for this plan.
140    #[must_use]
141    pub fn control_authority_preflight_request(&self) -> ControlAuthorityPreflightRequest {
142        ControlAuthorityPreflightRequest {
143            plan_id: self.plan_id.clone(),
144            run_id: self.run_id.clone(),
145            fleet: self.fleet.clone(),
146            network: self.network.clone(),
147            root_canister_id: self.root_canister_id.clone(),
148            requires_root_controller: self.requires_root_controller,
149            targets: self
150                .targets
151                .iter()
152                .map(ControlAuthorityPreflightTarget::from)
153                .collect(),
154        }
155    }
156
157    /// Build the typed snapshot-read preflight request for this plan.
158    #[must_use]
159    pub fn snapshot_read_authority_preflight_request(
160        &self,
161    ) -> SnapshotReadAuthorityPreflightRequest {
162        SnapshotReadAuthorityPreflightRequest {
163            plan_id: self.plan_id.clone(),
164            run_id: self.run_id.clone(),
165            fleet: self.fleet.clone(),
166            network: self.network.clone(),
167            root_canister_id: self.root_canister_id.clone(),
168            targets: self
169                .targets
170                .iter()
171                .map(SnapshotReadAuthorityPreflightTarget::from)
172                .collect(),
173        }
174    }
175
176    /// Build the typed topology preflight request for this plan.
177    #[must_use]
178    pub fn topology_preflight_request(&self) -> TopologyPreflightRequest {
179        TopologyPreflightRequest {
180            plan_id: self.plan_id.clone(),
181            run_id: self.run_id.clone(),
182            fleet: self.fleet.clone(),
183            network: self.network.clone(),
184            root_canister_id: self.root_canister_id.clone(),
185            selected_subtree_root: self.selected_subtree_root.clone(),
186            selected_scope_kind: self.selected_scope_kind.clone(),
187            topology_hash_before_quiesce: self.topology_hash_before_quiesce.clone(),
188            targets: self
189                .targets
190                .iter()
191                .map(TopologyPreflightTarget::from)
192                .collect(),
193        }
194    }
195
196    /// Build the typed quiescence preflight request for this plan.
197    #[must_use]
198    pub fn quiescence_preflight_request(&self) -> QuiescencePreflightRequest {
199        QuiescencePreflightRequest {
200            plan_id: self.plan_id.clone(),
201            run_id: self.run_id.clone(),
202            fleet: self.fleet.clone(),
203            network: self.network.clone(),
204            root_canister_id: self.root_canister_id.clone(),
205            selected_subtree_root: self.selected_subtree_root.clone(),
206            quiescence_policy: self.quiescence_policy.clone(),
207            targets: self
208                .targets
209                .iter()
210                .map(QuiescencePreflightTarget::from)
211                .collect(),
212        }
213    }
214}
215
216fn control_receipt_map(
217    plan_id: &str,
218    preflight_id: &str,
219    as_of: &str,
220    targets: &[BackupTarget],
221    receipts: &[ControlAuthorityReceipt],
222) -> Result<BTreeMap<String, ControlAuthorityReceipt>, BackupPlanError> {
223    let target_ids = targets
224        .iter()
225        .map(|target| target.canister_id.as_str())
226        .collect::<BTreeSet<_>>();
227    let mut receipt_map = BTreeMap::new();
228
229    for receipt in receipts {
230        validate_authority_receipt_header(AuthorityReceiptHeaderInput {
231            expected_plan_id: plan_id,
232            expected_preflight_id: preflight_id,
233            as_of,
234            target_ids: &target_ids,
235            actual_plan_id: &receipt.plan_id,
236            actual_preflight_id: &receipt.preflight_id,
237            target_canister_id: &receipt.target_canister_id,
238            validated_at: &receipt.validated_at,
239            expires_at: &receipt.expires_at,
240            message: receipt.message.as_deref(),
241        })?;
242        validate_control_authority(&receipt.authority)?;
243        if receipt_map
244            .insert(receipt.target_canister_id.clone(), receipt.clone())
245            .is_some()
246        {
247            return Err(BackupPlanError::DuplicateAuthorityReceipt(
248                receipt.target_canister_id.clone(),
249            ));
250        }
251    }
252
253    Ok(receipt_map)
254}
255
256fn snapshot_read_receipt_map(
257    plan_id: &str,
258    preflight_id: &str,
259    as_of: &str,
260    targets: &[BackupTarget],
261    receipts: &[SnapshotReadAuthorityReceipt],
262) -> Result<BTreeMap<String, SnapshotReadAuthorityReceipt>, BackupPlanError> {
263    let target_ids = targets
264        .iter()
265        .map(|target| target.canister_id.as_str())
266        .collect::<BTreeSet<_>>();
267    let mut receipt_map = BTreeMap::new();
268
269    for receipt in receipts {
270        validate_authority_receipt_header(AuthorityReceiptHeaderInput {
271            expected_plan_id: plan_id,
272            expected_preflight_id: preflight_id,
273            as_of,
274            target_ids: &target_ids,
275            actual_plan_id: &receipt.plan_id,
276            actual_preflight_id: &receipt.preflight_id,
277            target_canister_id: &receipt.target_canister_id,
278            validated_at: &receipt.validated_at,
279            expires_at: &receipt.expires_at,
280            message: receipt.message.as_deref(),
281        })?;
282        if receipt_map
283            .insert(receipt.target_canister_id.clone(), receipt.clone())
284            .is_some()
285        {
286            return Err(BackupPlanError::DuplicateAuthorityReceipt(
287                receipt.target_canister_id.clone(),
288            ));
289        }
290    }
291
292    Ok(receipt_map)
293}
294
295struct AuthorityReceiptHeaderInput<'a> {
296    expected_plan_id: &'a str,
297    expected_preflight_id: &'a str,
298    as_of: &'a str,
299    target_ids: &'a BTreeSet<&'a str>,
300    actual_plan_id: &'a str,
301    actual_preflight_id: &'a str,
302    target_canister_id: &'a str,
303    validated_at: &'a str,
304    expires_at: &'a str,
305    message: Option<&'a str>,
306}
307
308fn validate_authority_receipt_header(
309    input: AuthorityReceiptHeaderInput<'_>,
310) -> Result<(), BackupPlanError> {
311    validate_nonempty("authority_receipts[].plan_id", input.actual_plan_id)?;
312    validate_preflight_id(input.actual_preflight_id)?;
313    validate_principal(
314        "authority_receipts[].target_canister_id",
315        input.target_canister_id,
316    )?;
317    validate_optional_nonempty("authority_receipts[].message", input.message)?;
318    validate_preflight_window(
319        input.actual_preflight_id,
320        input.validated_at,
321        input.expires_at,
322        input.as_of,
323    )?;
324
325    if input.actual_plan_id != input.expected_plan_id {
326        return Err(BackupPlanError::AuthorityReceiptPlanMismatch {
327            expected: input.expected_plan_id.to_string(),
328            actual: input.actual_plan_id.to_string(),
329        });
330    }
331    if input.actual_preflight_id != input.expected_preflight_id {
332        return Err(BackupPlanError::AuthorityReceiptPreflightMismatch {
333            expected: input.expected_preflight_id.to_string(),
334            actual: input.actual_preflight_id.to_string(),
335        });
336    }
337    if !input.target_ids.contains(input.target_canister_id) {
338        return Err(BackupPlanError::UnknownAuthorityReceiptTarget(
339            input.target_canister_id.to_string(),
340        ));
341    }
342
343    Ok(())
344}
345
346fn validate_execution_preflight_bundle(
347    plan: &BackupPlan,
348    receipts: &BackupExecutionPreflightReceipts,
349    as_of: &str,
350) -> Result<(), BackupPlanError> {
351    validate_nonempty("preflight_receipts.plan_id", &receipts.plan_id)?;
352    validate_preflight_id(&receipts.preflight_id)?;
353    validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
354    validate_preflight_window(
355        &receipts.preflight_id,
356        &receipts.validated_at,
357        &receipts.expires_at,
358        as_of,
359    )?;
360
361    if receipts.plan_id != plan.plan_id {
362        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
363            expected: plan.plan_id.clone(),
364            actual: receipts.plan_id.clone(),
365        });
366    }
367
368    Ok(())
369}
370
371fn validate_topology_preflight_receipt(
372    plan: &BackupPlan,
373    receipt: &TopologyPreflightReceipt,
374    preflight_id: &str,
375    as_of: &str,
376) -> Result<(), BackupPlanError> {
377    validate_nonempty("topology_receipt.plan_id", &receipt.plan_id)?;
378    validate_preflight_id(&receipt.preflight_id)?;
379    validate_required_hash(
380        "topology_receipt.topology_hash_before_quiesce",
381        &receipt.topology_hash_before_quiesce,
382    )?;
383    validate_required_hash(
384        "topology_receipt.topology_hash_at_preflight",
385        &receipt.topology_hash_at_preflight,
386    )?;
387    validate_optional_nonempty("topology_receipt.message", receipt.message.as_deref())?;
388    validate_preflight_window(
389        &receipt.preflight_id,
390        &receipt.validated_at,
391        &receipt.expires_at,
392        as_of,
393    )?;
394
395    if receipt.plan_id != plan.plan_id {
396        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
397            expected: plan.plan_id.clone(),
398            actual: receipt.plan_id.clone(),
399        });
400    }
401    if receipt.preflight_id != preflight_id {
402        return Err(BackupPlanError::PreflightReceiptIdMismatch {
403            expected: preflight_id.to_string(),
404            actual: receipt.preflight_id.clone(),
405        });
406    }
407    if receipt.topology_hash_before_quiesce != plan.topology_hash_before_quiesce {
408        return Err(BackupPlanError::TopologyPreflightHashMismatch {
409            expected: plan.topology_hash_before_quiesce.clone(),
410            actual: receipt.topology_hash_before_quiesce.clone(),
411        });
412    }
413    if receipt.topology_hash_at_preflight != plan.topology_hash_before_quiesce {
414        return Err(BackupPlanError::TopologyPreflightHashMismatch {
415            expected: plan.topology_hash_before_quiesce.clone(),
416            actual: receipt.topology_hash_at_preflight.clone(),
417        });
418    }
419    if receipt.targets != plan.topology_preflight_request().targets {
420        return Err(BackupPlanError::TopologyPreflightTargetsMismatch);
421    }
422
423    Ok(())
424}
425
426fn validate_quiescence_preflight_receipt(
427    plan: &BackupPlan,
428    receipt: &QuiescencePreflightReceipt,
429    preflight_id: &str,
430    as_of: &str,
431) -> Result<(), BackupPlanError> {
432    validate_nonempty("quiescence_receipt.plan_id", &receipt.plan_id)?;
433    validate_preflight_id(&receipt.preflight_id)?;
434    validate_optional_nonempty("quiescence_receipt.message", receipt.message.as_deref())?;
435    validate_preflight_window(
436        &receipt.preflight_id,
437        &receipt.validated_at,
438        &receipt.expires_at,
439        as_of,
440    )?;
441
442    if receipt.plan_id != plan.plan_id {
443        return Err(BackupPlanError::PreflightReceiptPlanMismatch {
444            expected: plan.plan_id.clone(),
445            actual: receipt.plan_id.clone(),
446        });
447    }
448    if receipt.preflight_id != preflight_id {
449        return Err(BackupPlanError::PreflightReceiptIdMismatch {
450            expected: preflight_id.to_string(),
451            actual: receipt.preflight_id.clone(),
452        });
453    }
454    if receipt.quiescence_policy != plan.quiescence_policy {
455        return Err(BackupPlanError::QuiescencePolicyMismatch);
456    }
457    if !receipt.accepted {
458        return Err(BackupPlanError::QuiescencePreflightRejected);
459    }
460    if receipt.targets != plan.quiescence_preflight_request().targets {
461        return Err(BackupPlanError::QuiescencePreflightTargetsMismatch);
462    }
463
464    Ok(())
465}