Skip to main content

canic_backup/restore/apply/
mod.rs

1use super::{RestorePhase, RestorePlan, RestorePlanMember, RestoreStatus};
2use crate::{
3    artifacts::{ArtifactChecksum, ArtifactChecksumError},
4    manifest::VerificationCheck,
5};
6use serde::{Deserialize, Serialize};
7use std::path::{Component, Path, PathBuf};
8use thiserror::Error as ThisError;
9
10mod journal;
11
12pub use journal::{
13    RestoreApplyCommandConfig, RestoreApplyCommandOutput, RestoreApplyCommandOutputPair,
14    RestoreApplyCommandPreview, RestoreApplyJournal, RestoreApplyJournalError,
15    RestoreApplyJournalOperation, RestoreApplyJournalReport, RestoreApplyJournalStatus,
16    RestoreApplyNextOperation, RestoreApplyOperationKind, RestoreApplyOperationKindCounts,
17    RestoreApplyOperationReceipt, RestoreApplyOperationReceiptOutcome, RestoreApplyOperationState,
18    RestoreApplyPendingSummary, RestoreApplyProgressSummary, RestoreApplyReportOperation,
19    RestoreApplyReportOutcome, RestoreApplyRunnerCommand,
20};
21
22///
23/// RestoreApplyDryRun
24///
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct RestoreApplyDryRun {
28    pub dry_run_version: u16,
29    pub backup_id: String,
30    pub ready: bool,
31    pub readiness_reasons: Vec<String>,
32    pub member_count: usize,
33    pub phase_count: usize,
34    pub status_supplied: bool,
35    #[serde(default)]
36    pub planned_snapshot_uploads: usize,
37    pub planned_snapshot_loads: usize,
38    pub planned_code_reinstalls: usize,
39    pub planned_verification_checks: usize,
40    #[serde(default)]
41    pub planned_operations: usize,
42    pub rendered_operations: usize,
43    #[serde(default)]
44    pub operation_counts: RestoreApplyOperationKindCounts,
45    pub artifact_validation: Option<RestoreApplyArtifactValidation>,
46    pub phases: Vec<RestoreApplyDryRunPhase>,
47}
48
49impl RestoreApplyDryRun {
50    /// Build a no-mutation apply dry-run after validating optional status identity.
51    pub fn try_from_plan(
52        plan: &RestorePlan,
53        status: Option<&RestoreStatus>,
54    ) -> Result<Self, RestoreApplyDryRunError> {
55        if let Some(status) = status {
56            validate_restore_status_matches_plan(plan, status)?;
57        }
58
59        Ok(Self::from_validated_plan(plan, status))
60    }
61
62    /// Build an apply dry-run and verify all referenced artifacts under a backup root.
63    pub fn try_from_plan_with_artifacts(
64        plan: &RestorePlan,
65        status: Option<&RestoreStatus>,
66        backup_root: &Path,
67    ) -> Result<Self, RestoreApplyDryRunError> {
68        let mut dry_run = Self::try_from_plan(plan, status)?;
69        dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
70        Ok(dry_run)
71    }
72
73    // Build a no-mutation apply dry-run after any supplied status is validated.
74    fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
75        let mut next_sequence = 0;
76        let phases = plan
77            .phases
78            .iter()
79            .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
80            .collect::<Vec<_>>();
81        let mut phases = phases;
82        append_fleet_verification_operations(plan, &mut phases, &mut next_sequence);
83        let rendered_operations = phases
84            .iter()
85            .map(|phase| phase.operations.len())
86            .sum::<usize>();
87        let operation_counts = RestoreApplyOperationKindCounts::from_dry_run_phases(&phases);
88
89        Self {
90            dry_run_version: 1,
91            backup_id: plan.backup_id.clone(),
92            ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
93            readiness_reasons: status.map_or_else(
94                || plan.readiness_summary.reasons.clone(),
95                |status| status.readiness_reasons.clone(),
96            ),
97            member_count: plan.member_count,
98            phase_count: plan.ordering_summary.phase_count,
99            status_supplied: status.is_some(),
100            planned_snapshot_uploads: plan
101                .operation_summary
102                .effective_planned_snapshot_uploads(plan.member_count),
103            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
104            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
105            planned_verification_checks: plan.operation_summary.planned_verification_checks,
106            planned_operations: plan
107                .operation_summary
108                .effective_planned_operations(plan.member_count),
109            rendered_operations,
110            operation_counts,
111            artifact_validation: None,
112            phases,
113        }
114    }
115}
116
117// Verify every planned restore artifact against one backup directory root.
118fn validate_restore_apply_artifacts(
119    plan: &RestorePlan,
120    backup_root: &Path,
121) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
122    let mut checks = Vec::new();
123
124    for member in plan.ordered_members() {
125        checks.push(validate_restore_apply_artifact(member, backup_root)?);
126    }
127
128    let members_with_expected_checksums = checks
129        .iter()
130        .filter(|check| check.checksum_expected.is_some())
131        .count();
132    let artifacts_present = checks.iter().all(|check| check.exists);
133    let checksums_verified = members_with_expected_checksums == plan.member_count
134        && checks.iter().all(|check| check.checksum_verified);
135
136    Ok(RestoreApplyArtifactValidation {
137        backup_root: backup_root.to_string_lossy().to_string(),
138        checked_members: checks.len(),
139        artifacts_present,
140        checksums_verified,
141        members_with_expected_checksums,
142        checks,
143    })
144}
145
146// Verify one planned restore artifact path and checksum.
147fn validate_restore_apply_artifact(
148    member: &RestorePlanMember,
149    backup_root: &Path,
150) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
151    let artifact_path = safe_restore_artifact_path(
152        &member.source_canister,
153        &member.source_snapshot.artifact_path,
154    )?;
155    let resolved_path = backup_root.join(&artifact_path);
156
157    if !resolved_path.exists() {
158        return Err(RestoreApplyDryRunError::ArtifactMissing {
159            source_canister: member.source_canister.clone(),
160            artifact_path: member.source_snapshot.artifact_path.clone(),
161            resolved_path: resolved_path.to_string_lossy().to_string(),
162        });
163    }
164
165    let (checksum_actual, checksum_verified) =
166        if let Some(expected) = &member.source_snapshot.checksum {
167            let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
168                RestoreApplyDryRunError::ArtifactChecksum {
169                    source_canister: member.source_canister.clone(),
170                    artifact_path: member.source_snapshot.artifact_path.clone(),
171                    source,
172                }
173            })?;
174            checksum.verify(expected).map_err(|source| {
175                RestoreApplyDryRunError::ArtifactChecksum {
176                    source_canister: member.source_canister.clone(),
177                    artifact_path: member.source_snapshot.artifact_path.clone(),
178                    source,
179                }
180            })?;
181            (Some(checksum.hash), true)
182        } else {
183            (None, false)
184        };
185
186    Ok(RestoreApplyArtifactCheck {
187        source_canister: member.source_canister.clone(),
188        target_canister: member.target_canister.clone(),
189        snapshot_id: member.source_snapshot.snapshot_id.clone(),
190        artifact_path: member.source_snapshot.artifact_path.clone(),
191        resolved_path: resolved_path.to_string_lossy().to_string(),
192        exists: true,
193        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
194        checksum_expected: member.source_snapshot.checksum.clone(),
195        checksum_actual,
196        checksum_verified,
197    })
198}
199
200// Reject absolute paths and parent traversal before joining with the backup root.
201fn safe_restore_artifact_path(
202    source_canister: &str,
203    artifact_path: &str,
204) -> Result<PathBuf, RestoreApplyDryRunError> {
205    let path = Path::new(artifact_path);
206    let is_safe = path
207        .components()
208        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
209
210    if is_safe {
211        return Ok(path.to_path_buf());
212    }
213
214    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
215        source_canister: source_canister.to_string(),
216        artifact_path: artifact_path.to_string(),
217    })
218}
219
220// Validate that a supplied restore status belongs to the restore plan.
221fn validate_restore_status_matches_plan(
222    plan: &RestorePlan,
223    status: &RestoreStatus,
224) -> Result<(), RestoreApplyDryRunError> {
225    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
226    validate_status_string_field(
227        "source_environment",
228        &plan.source_environment,
229        &status.source_environment,
230    )?;
231    validate_status_string_field(
232        "source_root_canister",
233        &plan.source_root_canister,
234        &status.source_root_canister,
235    )?;
236    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
237    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
238    validate_status_usize_field(
239        "phase_count",
240        plan.ordering_summary.phase_count,
241        status.phase_count,
242    )?;
243    Ok(())
244}
245
246// Validate one string field shared by restore plan and status.
247fn validate_status_string_field(
248    field: &'static str,
249    plan: &str,
250    status: &str,
251) -> Result<(), RestoreApplyDryRunError> {
252    if plan == status {
253        return Ok(());
254    }
255
256    Err(RestoreApplyDryRunError::StatusPlanMismatch {
257        field,
258        plan: plan.to_string(),
259        status: status.to_string(),
260    })
261}
262
263// Validate one numeric field shared by restore plan and status.
264const fn validate_status_usize_field(
265    field: &'static str,
266    plan: usize,
267    status: usize,
268) -> Result<(), RestoreApplyDryRunError> {
269    if plan == status {
270        return Ok(());
271    }
272
273    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
274        field,
275        plan,
276        status,
277    })
278}
279
280///
281/// RestoreApplyArtifactValidation
282///
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
285pub struct RestoreApplyArtifactValidation {
286    pub backup_root: String,
287    pub checked_members: usize,
288    pub artifacts_present: bool,
289    pub checksums_verified: bool,
290    pub members_with_expected_checksums: usize,
291    pub checks: Vec<RestoreApplyArtifactCheck>,
292}
293
294///
295/// RestoreApplyArtifactCheck
296///
297
298#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
299pub struct RestoreApplyArtifactCheck {
300    pub source_canister: String,
301    pub target_canister: String,
302    pub snapshot_id: String,
303    pub artifact_path: String,
304    pub resolved_path: String,
305    pub exists: bool,
306    pub checksum_algorithm: String,
307    pub checksum_expected: Option<String>,
308    pub checksum_actual: Option<String>,
309    pub checksum_verified: bool,
310}
311
312///
313/// RestoreApplyDryRunPhase
314///
315
316#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
317pub struct RestoreApplyDryRunPhase {
318    pub restore_group: u16,
319    pub operations: Vec<RestoreApplyDryRunOperation>,
320}
321
322impl RestoreApplyDryRunPhase {
323    // Build one dry-run phase from one restore plan phase.
324    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
325        let mut operations = Vec::new();
326
327        for member in &phase.members {
328            push_member_operation(
329                &mut operations,
330                next_sequence,
331                RestoreApplyOperationKind::UploadSnapshot,
332                member,
333                None,
334            );
335            push_member_operation(
336                &mut operations,
337                next_sequence,
338                RestoreApplyOperationKind::LoadSnapshot,
339                member,
340                None,
341            );
342
343            for check in &member.verification_checks {
344                push_member_operation(
345                    &mut operations,
346                    next_sequence,
347                    RestoreApplyOperationKind::VerifyMember,
348                    member,
349                    Some(check),
350                );
351            }
352        }
353
354        Self {
355            restore_group: phase.restore_group,
356            operations,
357        }
358    }
359}
360
361// Append one member-level dry-run operation using the current phase order.
362fn push_member_operation(
363    operations: &mut Vec<RestoreApplyDryRunOperation>,
364    next_sequence: &mut usize,
365    operation: RestoreApplyOperationKind,
366    member: &RestorePlanMember,
367    check: Option<&VerificationCheck>,
368) {
369    let sequence = *next_sequence;
370    *next_sequence += 1;
371
372    operations.push(RestoreApplyDryRunOperation {
373        sequence,
374        operation,
375        restore_group: member.restore_group,
376        phase_order: member.phase_order,
377        source_canister: member.source_canister.clone(),
378        target_canister: member.target_canister.clone(),
379        role: member.role.clone(),
380        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
381        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
382        verification_kind: check.map(|check| check.kind.clone()),
383        verification_method: check.and_then(|check| check.method.clone()),
384    });
385}
386
387// Append fleet-level verification checks after all member operations.
388fn append_fleet_verification_operations(
389    plan: &RestorePlan,
390    phases: &mut [RestoreApplyDryRunPhase],
391    next_sequence: &mut usize,
392) {
393    if plan.fleet_verification_checks.is_empty() {
394        return;
395    }
396
397    let Some(phase) = phases.last_mut() else {
398        return;
399    };
400    let root = plan
401        .phases
402        .iter()
403        .flat_map(|phase| phase.members.iter())
404        .find(|member| member.source_canister == plan.source_root_canister);
405    let source_canister = root.map_or_else(
406        || plan.source_root_canister.clone(),
407        |member| member.source_canister.clone(),
408    );
409    let target_canister = root.map_or_else(
410        || plan.source_root_canister.clone(),
411        |member| member.target_canister.clone(),
412    );
413    let restore_group = phase.restore_group;
414
415    for check in &plan.fleet_verification_checks {
416        push_fleet_operation(
417            &mut phase.operations,
418            next_sequence,
419            restore_group,
420            &source_canister,
421            &target_canister,
422            check,
423        );
424    }
425}
426
427// Append one fleet-level dry-run verification operation.
428fn push_fleet_operation(
429    operations: &mut Vec<RestoreApplyDryRunOperation>,
430    next_sequence: &mut usize,
431    restore_group: u16,
432    source_canister: &str,
433    target_canister: &str,
434    check: &VerificationCheck,
435) {
436    let sequence = *next_sequence;
437    *next_sequence += 1;
438    let phase_order = operations.len();
439
440    operations.push(RestoreApplyDryRunOperation {
441        sequence,
442        operation: RestoreApplyOperationKind::VerifyFleet,
443        restore_group,
444        phase_order,
445        source_canister: source_canister.to_string(),
446        target_canister: target_canister.to_string(),
447        role: "fleet".to_string(),
448        snapshot_id: None,
449        artifact_path: None,
450        verification_kind: Some(check.kind.clone()),
451        verification_method: check.method.clone(),
452    });
453}
454
455///
456/// RestoreApplyDryRunOperation
457///
458
459#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
460pub struct RestoreApplyDryRunOperation {
461    pub sequence: usize,
462    pub operation: RestoreApplyOperationKind,
463    pub restore_group: u16,
464    pub phase_order: usize,
465    pub source_canister: String,
466    pub target_canister: String,
467    pub role: String,
468    pub snapshot_id: Option<String>,
469    pub artifact_path: Option<String>,
470    pub verification_kind: Option<String>,
471    pub verification_method: Option<String>,
472}
473
474///
475/// RestoreApplyDryRunError
476///
477
478#[derive(Debug, ThisError)]
479pub enum RestoreApplyDryRunError {
480    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
481    StatusPlanMismatch {
482        field: &'static str,
483        plan: String,
484        status: String,
485    },
486
487    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
488    StatusPlanCountMismatch {
489        field: &'static str,
490        plan: usize,
491        status: usize,
492    },
493
494    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
495    ArtifactPathEscapesBackup {
496        source_canister: String,
497        artifact_path: String,
498    },
499
500    #[error(
501        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
502    )]
503    ArtifactMissing {
504        source_canister: String,
505        artifact_path: String,
506        resolved_path: String,
507    },
508
509    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
510    ArtifactChecksum {
511        source_canister: String,
512        artifact_path: String,
513        #[source]
514        source: ArtifactChecksumError,
515    },
516}