Skip to main content

canic_backup/restore/apply/
mod.rs

1use super::{RestorePlan, RestorePlanError, RestorePlanMember};
2use crate::{
3    artifacts::{ArtifactChecksum, ArtifactChecksumError},
4    manifest::VerificationCheck,
5    persistence::resolve_backup_artifact_path,
6};
7use serde::{Deserialize, Serialize};
8use std::{
9    fs,
10    path::{Path, PathBuf},
11};
12use thiserror::Error as ThisError;
13
14mod journal;
15mod validation;
16
17pub(in crate::restore) use journal::RestoreApplyCommandOutputPair;
18pub(in crate::restore) use journal::RestoreApplyJournalReport;
19pub use journal::{
20    RestoreApplyCommandConfig, RestoreApplyCommandOutput, RestoreApplyCommandPreview,
21    RestoreApplyJournal, RestoreApplyJournalError, RestoreApplyJournalOperation,
22    RestoreApplyOperationKind, RestoreApplyOperationKindCounts, RestoreApplyOperationReceipt,
23    RestoreApplyOperationReceiptOutcome, RestoreApplyOperationState, RestoreApplyPendingSummary,
24    RestoreApplyProgressSummary, RestoreApplyReportOperation, RestoreApplyReportOutcome,
25    RestoreApplyRunnerCommand,
26};
27pub use validation::RestoreApplyDryRunValidationError;
28
29///
30/// RestoreApplyDryRun
31///
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct RestoreApplyDryRun {
36    pub dry_run_version: u16,
37    pub backup_id: String,
38    pub ready: bool,
39    pub readiness_reasons: Vec<String>,
40    pub member_count: usize,
41    pub planned_canister_stops: usize,
42    pub planned_canister_starts: usize,
43    pub planned_snapshot_uploads: usize,
44    pub planned_snapshot_loads: usize,
45    pub planned_verification_checks: usize,
46    pub planned_operations: usize,
47    pub rendered_operations: usize,
48    pub operation_counts: RestoreApplyOperationKindCounts,
49    #[serde(deserialize_with = "crate::serialization::required_option")]
50    pub artifact_validation: Option<RestoreApplyArtifactValidation>,
51    pub operations: Vec<RestoreApplyDryRunOperation>,
52}
53
54impl RestoreApplyDryRun {
55    /// Build a no-mutation apply dry-run from a restore plan.
56    pub fn from_plan(plan: &RestorePlan) -> Result<Self, RestorePlanError> {
57        plan.validate()?;
58        Ok(Self::from_validated_plan(plan))
59    }
60
61    /// Build an apply dry-run and verify all referenced artifacts under a backup root.
62    pub fn try_from_plan_with_artifacts(
63        plan: &RestorePlan,
64        backup_root: &Path,
65    ) -> Result<Self, RestoreApplyDryRunError> {
66        let mut dry_run = Self::from_plan(plan)?;
67        dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
68        Ok(dry_run)
69    }
70
71    // Build a no-mutation apply dry-run from a restore plan.
72    fn from_validated_plan(plan: &RestorePlan) -> Self {
73        let mut next_sequence = 0;
74        let ordered_members = plan.ordered_members();
75        let mut operations = Vec::new();
76        append_member_phase(
77            &mut operations,
78            &mut next_sequence,
79            RestoreApplyOperationKind::UploadSnapshot,
80            ordered_members.iter().copied(),
81        );
82        append_member_phase(
83            &mut operations,
84            &mut next_sequence,
85            RestoreApplyOperationKind::StopCanister,
86            ordered_members.iter().copied(),
87        );
88        append_member_phase(
89            &mut operations,
90            &mut next_sequence,
91            RestoreApplyOperationKind::LoadSnapshot,
92            ordered_members.iter().copied(),
93        );
94        append_member_phase(
95            &mut operations,
96            &mut next_sequence,
97            RestoreApplyOperationKind::StartCanister,
98            ordered_members.iter().rev().copied(),
99        );
100        append_member_verification_operations(
101            &mut operations,
102            &mut next_sequence,
103            &ordered_members,
104        );
105        append_deployment_verification_operations(plan, &mut operations, &mut next_sequence);
106        let rendered_operations = operations.len();
107        let operation_counts =
108            RestoreApplyOperationKindCounts::from_dry_run_operations(&operations);
109
110        Self {
111            dry_run_version: 1,
112            backup_id: plan.backup_id.clone(),
113            ready: plan.readiness_summary.ready,
114            readiness_reasons: plan.readiness_summary.reasons.clone(),
115            member_count: plan.member_count,
116            planned_canister_stops: plan.operation_summary.planned_canister_stops,
117            planned_canister_starts: plan.operation_summary.planned_canister_starts,
118            planned_snapshot_uploads: plan.operation_summary.planned_snapshot_uploads,
119            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
120            planned_verification_checks: plan.operation_summary.planned_verification_checks,
121            planned_operations: plan.operation_summary.planned_operations,
122            rendered_operations,
123            operation_counts,
124            artifact_validation: None,
125            operations,
126        }
127    }
128}
129
130// Verify every planned restore artifact against one backup directory root.
131fn validate_restore_apply_artifacts(
132    plan: &RestorePlan,
133    backup_root: &Path,
134) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
135    let backup_root = canonical_restore_backup_root(backup_root)?;
136    let mut checks = Vec::new();
137
138    for member in plan.ordered_members() {
139        checks.push(validate_restore_apply_artifact(member, &backup_root)?);
140    }
141
142    let members_with_expected_checksums = checks
143        .iter()
144        .filter(|check| check.checksum_expected.is_some())
145        .count();
146    let artifacts_present = checks.iter().all(|check| check.exists);
147    let checksums_verified = members_with_expected_checksums == plan.member_count
148        && checks.iter().all(|check| check.checksum_verified);
149
150    Ok(RestoreApplyArtifactValidation {
151        backup_root: backup_root.to_string_lossy().to_string(),
152        checked_members: checks.len(),
153        artifacts_present,
154        checksums_verified,
155        members_with_expected_checksums,
156        checks,
157    })
158}
159
160// Verify one planned restore artifact path and checksum.
161fn validate_restore_apply_artifact(
162    member: &RestorePlanMember,
163    backup_root: &Path,
164) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
165    let resolved_path = safe_restore_artifact_path(
166        backup_root,
167        &member.source_canister,
168        &member.source_snapshot.artifact_path,
169    )?;
170
171    validate_restore_artifact_type(
172        backup_root,
173        &member.source_canister,
174        &member.source_snapshot.artifact_path,
175        &resolved_path,
176    )?;
177
178    let (checksum_actual, checksum_verified) =
179        if let Some(expected) = &member.source_snapshot.checksum {
180            let checksum = ArtifactChecksum::from_relative_path_no_follow(
181                backup_root,
182                Path::new(&member.source_snapshot.artifact_path),
183            )
184            .map_err(|source| RestoreApplyDryRunError::ArtifactChecksum {
185                source_canister: member.source_canister.clone(),
186                artifact_path: member.source_snapshot.artifact_path.clone(),
187                source,
188            })?;
189            checksum.verify(expected).map_err(|source| {
190                RestoreApplyDryRunError::ArtifactChecksum {
191                    source_canister: member.source_canister.clone(),
192                    artifact_path: member.source_snapshot.artifact_path.clone(),
193                    source,
194                }
195            })?;
196            (Some(checksum.hash), true)
197        } else {
198            (None, false)
199        };
200
201    Ok(RestoreApplyArtifactCheck {
202        source_canister: member.source_canister.clone(),
203        target_canister: member.target_canister.clone(),
204        snapshot_id: member.source_snapshot.snapshot_id.clone(),
205        artifact_path: member.source_snapshot.artifact_path.clone(),
206        resolved_path: resolved_path.to_string_lossy().to_string(),
207        exists: true,
208        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
209        checksum_expected: member.source_snapshot.checksum.clone(),
210        checksum_actual,
211        checksum_verified,
212    })
213}
214
215fn canonical_restore_backup_root(backup_root: &Path) -> Result<PathBuf, RestoreApplyDryRunError> {
216    let metadata = fs::symlink_metadata(backup_root).map_err(|source| {
217        RestoreApplyDryRunError::ArtifactRootIo {
218            backup_root: backup_root.to_path_buf(),
219            source,
220        }
221    })?;
222    if metadata.file_type().is_symlink() || !metadata.is_dir() {
223        return Err(RestoreApplyDryRunError::ArtifactRootUnsafe {
224            backup_root: backup_root.to_path_buf(),
225        });
226    }
227    backup_root
228        .canonicalize()
229        .map_err(|source| RestoreApplyDryRunError::ArtifactRootIo {
230            backup_root: backup_root.to_path_buf(),
231            source,
232        })
233}
234
235fn validate_restore_artifact_type(
236    backup_root: &Path,
237    source_canister: &str,
238    artifact_path: &str,
239    resolved_path: &Path,
240) -> Result<(), RestoreApplyDryRunError> {
241    let mut current = backup_root.to_path_buf();
242    let mut artifact_metadata = None;
243    for component in Path::new(artifact_path).components() {
244        current.push(component);
245        let metadata = match fs::symlink_metadata(&current) {
246            Ok(metadata) => metadata,
247            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
248                return Err(RestoreApplyDryRunError::ArtifactMissing {
249                    source_canister: source_canister.to_string(),
250                    artifact_path: artifact_path.to_string(),
251                    resolved_path: resolved_path.to_string_lossy().to_string(),
252                });
253            }
254            Err(source) => {
255                return Err(RestoreApplyDryRunError::ArtifactChecksum {
256                    source_canister: source_canister.to_string(),
257                    artifact_path: artifact_path.to_string(),
258                    source: ArtifactChecksumError::Io(source),
259                });
260            }
261        };
262        if metadata.file_type().is_symlink() {
263            return Err(RestoreApplyDryRunError::ArtifactUnsafeType {
264                source_canister: source_canister.to_string(),
265                artifact_path: current.to_string_lossy().to_string(),
266                kind: "symlink".to_string(),
267            });
268        }
269        artifact_metadata = Some(metadata);
270    }
271    let metadata =
272        artifact_metadata.ok_or_else(|| RestoreApplyDryRunError::ArtifactPathEscapesBackup {
273            source_canister: source_canister.to_string(),
274            artifact_path: artifact_path.to_string(),
275        })?;
276    if metadata.is_file() || metadata.is_dir() {
277        Ok(())
278    } else {
279        Err(RestoreApplyDryRunError::ArtifactUnsafeType {
280            source_canister: source_canister.to_string(),
281            artifact_path: artifact_path.to_string(),
282            kind: "special".to_string(),
283        })
284    }
285}
286
287// Reject absolute paths and parent traversal before joining with the backup root.
288fn safe_restore_artifact_path(
289    backup_root: &Path,
290    source_canister: &str,
291    artifact_path: &str,
292) -> Result<PathBuf, RestoreApplyDryRunError> {
293    if let Some(path) = resolve_backup_artifact_path(backup_root, artifact_path) {
294        return Ok(path);
295    }
296
297    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
298        source_canister: source_canister.to_string(),
299        artifact_path: artifact_path.to_string(),
300    })
301}
302
303///
304/// RestoreApplyArtifactValidation
305///
306
307#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308#[serde(deny_unknown_fields)]
309pub struct RestoreApplyArtifactValidation {
310    pub backup_root: String,
311    pub checked_members: usize,
312    pub artifacts_present: bool,
313    pub checksums_verified: bool,
314    pub members_with_expected_checksums: usize,
315    pub checks: Vec<RestoreApplyArtifactCheck>,
316}
317
318///
319/// RestoreApplyArtifactCheck
320///
321
322#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(deny_unknown_fields)]
324pub struct RestoreApplyArtifactCheck {
325    pub source_canister: String,
326    pub target_canister: String,
327    pub snapshot_id: String,
328    pub artifact_path: String,
329    pub resolved_path: String,
330    pub exists: bool,
331    pub checksum_algorithm: String,
332    #[serde(deserialize_with = "crate::serialization::required_option")]
333    pub checksum_expected: Option<String>,
334    #[serde(deserialize_with = "crate::serialization::required_option")]
335    pub checksum_actual: Option<String>,
336    pub checksum_verified: bool,
337}
338
339// Append a full member operation phase across the selected topology.
340fn append_member_phase<'a>(
341    operations: &mut Vec<RestoreApplyDryRunOperation>,
342    next_sequence: &mut usize,
343    operation: RestoreApplyOperationKind,
344    members: impl IntoIterator<Item = &'a RestorePlanMember>,
345) {
346    for member in members {
347        push_member_operation(operations, next_sequence, operation.clone(), member, None);
348    }
349}
350
351// Append member verification checks after restore mutations complete.
352fn append_member_verification_operations(
353    operations: &mut Vec<RestoreApplyDryRunOperation>,
354    next_sequence: &mut usize,
355    members: &[&RestorePlanMember],
356) {
357    for member in members {
358        for check in &member.verification_checks {
359            push_member_operation(
360                operations,
361                next_sequence,
362                RestoreApplyOperationKind::VerifyMember,
363                member,
364                Some(check),
365            );
366        }
367    }
368}
369
370// Append one member-level dry-run operation using the member's restore order.
371fn push_member_operation(
372    operations: &mut Vec<RestoreApplyDryRunOperation>,
373    next_sequence: &mut usize,
374    operation: RestoreApplyOperationKind,
375    member: &RestorePlanMember,
376    check: Option<&VerificationCheck>,
377) {
378    let sequence = *next_sequence;
379    *next_sequence += 1;
380
381    let snapshot_id = operation_snapshot_id(&operation, member);
382    let artifact_path = operation_artifact_path(&operation, member);
383    let artifact_checksum = operation_artifact_checksum(&operation, member);
384
385    operations.push(RestoreApplyDryRunOperation {
386        sequence,
387        operation,
388        member_order: member.member_order,
389        source_canister: member.source_canister.clone(),
390        target_canister: member.target_canister.clone(),
391        role: member.role.clone(),
392        snapshot_id,
393        artifact_path,
394        artifact_checksum,
395        expected_module_hash: check.and_then(|_| member.source_snapshot.module_hash.clone()),
396        verification_kind: check.map(|check| check.kind.clone()),
397    });
398}
399
400fn operation_snapshot_id(
401    operation: &RestoreApplyOperationKind,
402    member: &RestorePlanMember,
403) -> Option<String> {
404    matches!(
405        operation,
406        RestoreApplyOperationKind::UploadSnapshot | RestoreApplyOperationKind::LoadSnapshot
407    )
408    .then(|| member.source_snapshot.snapshot_id.clone())
409}
410
411fn operation_artifact_path(
412    operation: &RestoreApplyOperationKind,
413    member: &RestorePlanMember,
414) -> Option<String> {
415    matches!(
416        operation,
417        RestoreApplyOperationKind::UploadSnapshot | RestoreApplyOperationKind::LoadSnapshot
418    )
419    .then(|| member.source_snapshot.artifact_path.clone())
420}
421
422fn operation_artifact_checksum(
423    operation: &RestoreApplyOperationKind,
424    member: &RestorePlanMember,
425) -> Option<ArtifactChecksum> {
426    matches!(
427        operation,
428        RestoreApplyOperationKind::UploadSnapshot | RestoreApplyOperationKind::LoadSnapshot
429    )
430    .then(|| {
431        member
432            .source_snapshot
433            .checksum
434            .as_ref()
435            .map(|hash| ArtifactChecksum {
436                algorithm: member.source_snapshot.checksum_algorithm.clone(),
437                hash: hash.clone(),
438            })
439    })
440    .flatten()
441}
442
443// Append deployment-level verification checks after all member operations.
444fn append_deployment_verification_operations(
445    plan: &RestorePlan,
446    operations: &mut Vec<RestoreApplyDryRunOperation>,
447    next_sequence: &mut usize,
448) {
449    if plan.deployment_verification_checks.is_empty() {
450        return;
451    }
452
453    let root = plan
454        .members
455        .iter()
456        .find(|member| member.source_canister == plan.source_root_canister);
457    let source_canister = root.map_or_else(
458        || plan.source_root_canister.clone(),
459        |member| member.source_canister.clone(),
460    );
461    let target_canister = root.map_or_else(
462        || plan.source_root_canister.clone(),
463        |member| member.target_canister.clone(),
464    );
465    let expected_module_hash = root.and_then(|member| member.source_snapshot.module_hash.clone());
466    for check in &plan.deployment_verification_checks {
467        push_deployment_operation(
468            operations,
469            next_sequence,
470            &source_canister,
471            &target_canister,
472            expected_module_hash.clone(),
473            check,
474        );
475    }
476}
477
478// Append one deployment-level dry-run verification operation.
479fn push_deployment_operation(
480    operations: &mut Vec<RestoreApplyDryRunOperation>,
481    next_sequence: &mut usize,
482    source_canister: &str,
483    target_canister: &str,
484    expected_module_hash: Option<String>,
485    check: &VerificationCheck,
486) {
487    let sequence = *next_sequence;
488    *next_sequence += 1;
489    let member_order = operations.len();
490
491    operations.push(RestoreApplyDryRunOperation {
492        sequence,
493        operation: RestoreApplyOperationKind::VerifyDeployment,
494        member_order,
495        source_canister: source_canister.to_string(),
496        target_canister: target_canister.to_string(),
497        role: "deployment".to_string(),
498        snapshot_id: None,
499        artifact_path: None,
500        artifact_checksum: None,
501        expected_module_hash,
502        verification_kind: Some(check.kind.clone()),
503    });
504}
505
506///
507/// RestoreApplyDryRunOperation
508///
509
510#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
511#[serde(deny_unknown_fields)]
512pub struct RestoreApplyDryRunOperation {
513    pub sequence: usize,
514    pub operation: RestoreApplyOperationKind,
515    pub member_order: usize,
516    pub source_canister: String,
517    pub target_canister: String,
518    pub role: String,
519    #[serde(deserialize_with = "crate::serialization::required_option")]
520    pub snapshot_id: Option<String>,
521    #[serde(deserialize_with = "crate::serialization::required_option")]
522    pub artifact_path: Option<String>,
523    #[serde(deserialize_with = "crate::serialization::required_option")]
524    pub artifact_checksum: Option<ArtifactChecksum>,
525    #[serde(deserialize_with = "crate::serialization::required_option")]
526    pub expected_module_hash: Option<String>,
527    #[serde(deserialize_with = "crate::serialization::required_option")]
528    pub verification_kind: Option<String>,
529}
530
531///
532/// RestoreApplyDryRunError
533///
534
535#[derive(Debug, ThisError)]
536pub enum RestoreApplyDryRunError {
537    #[error(transparent)]
538    InvalidPlan(#[from] RestorePlanError),
539
540    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
541    ArtifactPathEscapesBackup {
542        source_canister: String,
543        artifact_path: String,
544    },
545
546    #[error("restore backup artifact root IO failed at {backup_root}")]
547    ArtifactRootIo {
548        backup_root: PathBuf,
549        #[source]
550        source: std::io::Error,
551    },
552
553    #[error("restore backup artifact root is not a direct directory: {backup_root}")]
554    ArtifactRootUnsafe { backup_root: PathBuf },
555
556    #[error(
557        "restore artifact for {source_canister} has unsupported filesystem type {kind}: {artifact_path}"
558    )]
559    ArtifactUnsafeType {
560        source_canister: String,
561        artifact_path: String,
562        kind: String,
563    },
564
565    #[error(
566        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
567    )]
568    ArtifactMissing {
569        source_canister: String,
570        artifact_path: String,
571        resolved_path: String,
572    },
573
574    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
575    ArtifactChecksum {
576        source_canister: String,
577        artifact_path: String,
578        #[source]
579        source: ArtifactChecksumError,
580    },
581}