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