Skip to main content

canic_backup/restore/apply/
validation.rs

1//! Module: restore::apply::validation
2//!
3//! Responsibility: validate serialized restore apply dry-runs before durable conversion.
4//! Does not own: artifact filesystem verification, journal transitions, or command execution.
5//! Boundary: rejects inconsistent dry-run versions and projections before journal creation.
6
7use crate::{
8    artifacts::{ArtifactChecksum, ArtifactChecksumError},
9    restore::apply::{
10        RestoreApplyArtifactCheck, RestoreApplyDryRun, RestoreApplyDryRunOperation,
11        RestoreApplyOperationKind, RestoreApplyOperationKindCounts,
12    },
13};
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use thiserror::Error as ThisError;
18
19const SUPPORTED_DRY_RUN_VERSION: u16 = 1;
20
21///
22/// RestoreApplyDryRunValidationError
23///
24/// Typed failure returned before a serialized dry-run can create durable state.
25/// Owned by restore apply validation and preserved by journal construction.
26///
27
28#[derive(Debug, ThisError)]
29pub enum RestoreApplyDryRunValidationError {
30    #[error("restore apply dry-run field {field} has invalid artifact checksum")]
31    ArtifactChecksum {
32        field: &'static str,
33        #[source]
34        source: ArtifactChecksumError,
35    },
36
37    #[error("restore apply dry-run artifact check duplicates source canister {0}")]
38    DuplicateArtifactSource(String),
39
40    #[error("restore apply dry-run has duplicate operation sequence {0}")]
41    DuplicateSequence(usize),
42
43    #[error("restore apply dry-run field {0} is required")]
44    MissingField(&'static str),
45
46    #[error("restore apply dry-run is missing operation sequence {0}")]
47    MissingSequence(usize),
48
49    #[error("restore apply dry-run projection {0} does not match its concrete operations")]
50    ProjectionMismatch(&'static str),
51
52    #[error("restore apply dry-run ready state does not match readiness reasons")]
53    ReadinessMismatch,
54
55    #[error("unsupported restore apply dry-run version {0}")]
56    UnsupportedVersion(u16),
57}
58
59impl RestoreApplyDryRun {
60    /// Validate one serialized dry-run before it creates a durable journal.
61    pub fn validate(&self) -> Result<(), RestoreApplyDryRunValidationError> {
62        if self.dry_run_version != SUPPORTED_DRY_RUN_VERSION {
63            return Err(RestoreApplyDryRunValidationError::UnsupportedVersion(
64                self.dry_run_version,
65            ));
66        }
67        validate_nonempty("backup_id", &self.backup_id)?;
68        validate_readiness(self)?;
69        validate_operation_projections(self)?;
70        validate_operation_sequences(&self.operations)?;
71        for operation in &self.operations {
72            validate_operation_identity(operation)?;
73        }
74        validate_artifact_projection(self)
75    }
76}
77
78fn validate_readiness(
79    dry_run: &RestoreApplyDryRun,
80) -> Result<(), RestoreApplyDryRunValidationError> {
81    if dry_run.ready != dry_run.readiness_reasons.is_empty() {
82        return Err(RestoreApplyDryRunValidationError::ReadinessMismatch);
83    }
84
85    let mut reasons = BTreeSet::new();
86    for reason in &dry_run.readiness_reasons {
87        validate_nonempty("readiness_reasons[]", reason)?;
88        if !reasons.insert(reason) {
89            return Err(RestoreApplyDryRunValidationError::ProjectionMismatch(
90                "readiness_reasons",
91            ));
92        }
93    }
94    Ok(())
95}
96
97fn validate_operation_projections(
98    dry_run: &RestoreApplyDryRun,
99) -> Result<(), RestoreApplyDryRunValidationError> {
100    let counts = RestoreApplyOperationKindCounts::from_dry_run_operations(&dry_run.operations);
101    require_projection("operation_counts", dry_run.operation_counts == counts)?;
102    require_count(
103        "member_count.canister_stops",
104        dry_run.member_count,
105        counts.canister_stops,
106    )?;
107    require_count(
108        "member_count.canister_starts",
109        dry_run.member_count,
110        counts.canister_starts,
111    )?;
112    require_count(
113        "member_count.snapshot_uploads",
114        dry_run.member_count,
115        counts.snapshot_uploads,
116    )?;
117    require_count(
118        "member_count.snapshot_loads",
119        dry_run.member_count,
120        counts.snapshot_loads,
121    )?;
122    require_count(
123        "planned_canister_stops",
124        dry_run.planned_canister_stops,
125        counts.canister_stops,
126    )?;
127    require_count(
128        "planned_canister_starts",
129        dry_run.planned_canister_starts,
130        counts.canister_starts,
131    )?;
132    require_count(
133        "planned_snapshot_uploads",
134        dry_run.planned_snapshot_uploads,
135        counts.snapshot_uploads,
136    )?;
137    require_count(
138        "planned_snapshot_loads",
139        dry_run.planned_snapshot_loads,
140        counts.snapshot_loads,
141    )?;
142    require_count(
143        "planned_verification_checks",
144        dry_run.planned_verification_checks,
145        counts.verification_operations,
146    )?;
147    require_count(
148        "planned_operations",
149        dry_run.planned_operations,
150        dry_run.operations.len(),
151    )?;
152    require_count(
153        "rendered_operations",
154        dry_run.rendered_operations,
155        dry_run.operations.len(),
156    )
157}
158
159fn validate_operation_sequences(
160    operations: &[RestoreApplyDryRunOperation],
161) -> Result<(), RestoreApplyDryRunValidationError> {
162    let mut sequences = BTreeSet::new();
163    for operation in operations {
164        if !sequences.insert(operation.sequence) {
165            return Err(RestoreApplyDryRunValidationError::DuplicateSequence(
166                operation.sequence,
167            ));
168        }
169    }
170    for expected in 0..operations.len() {
171        if !sequences.contains(&expected) {
172            return Err(RestoreApplyDryRunValidationError::MissingSequence(expected));
173        }
174    }
175    Ok(())
176}
177
178fn validate_operation_identity(
179    operation: &RestoreApplyDryRunOperation,
180) -> Result<(), RestoreApplyDryRunValidationError> {
181    validate_nonempty("operations[].source_canister", &operation.source_canister)?;
182    validate_nonempty("operations[].target_canister", &operation.target_canister)?;
183    validate_nonempty("operations[].role", &operation.role)
184}
185
186fn validate_artifact_projection(
187    dry_run: &RestoreApplyDryRun,
188) -> Result<(), RestoreApplyDryRunValidationError> {
189    let Some(validation) = &dry_run.artifact_validation else {
190        return Ok(());
191    };
192
193    validate_nonempty("artifact_validation.backup_root", &validation.backup_root)?;
194    require_count(
195        "artifact_validation.checked_members",
196        validation.checked_members,
197        validation.checks.len(),
198    )?;
199    require_count(
200        "artifact_validation.checked_members",
201        validation.checked_members,
202        dry_run.member_count,
203    )?;
204
205    let uploads = upload_operations(&dry_run.operations)?;
206    require_count(
207        "artifact_validation.upload_operations",
208        uploads.len(),
209        dry_run.member_count,
210    )?;
211
212    let mut sources = BTreeSet::new();
213    for check in &validation.checks {
214        validate_artifact_check(check)?;
215        if !sources.insert(check.source_canister.as_str()) {
216            return Err(RestoreApplyDryRunValidationError::DuplicateArtifactSource(
217                check.source_canister.clone(),
218            ));
219        }
220        let upload = uploads.get(check.source_canister.as_str()).ok_or(
221            RestoreApplyDryRunValidationError::ProjectionMismatch(
222                "artifact_validation.checks[].source_canister",
223            ),
224        )?;
225        require_projection(
226            "artifact_validation.checks[]",
227            artifact_check_matches_upload(check, upload),
228        )?;
229    }
230
231    let artifacts_present = validation.checks.iter().all(|check| check.exists);
232    require_projection(
233        "artifact_validation.artifacts_present",
234        validation.artifacts_present == artifacts_present,
235    )?;
236    let expected_checksums = validation
237        .checks
238        .iter()
239        .filter(|check| check.checksum_expected.is_some())
240        .count();
241    require_count(
242        "artifact_validation.members_with_expected_checksums",
243        validation.members_with_expected_checksums,
244        expected_checksums,
245    )?;
246    let checksums_verified = expected_checksums == dry_run.member_count
247        && validation
248            .checks
249            .iter()
250            .all(|check| check.checksum_verified);
251    require_projection(
252        "artifact_validation.checksums_verified",
253        validation.checksums_verified == checksums_verified,
254    )
255}
256
257fn upload_operations(
258    operations: &[RestoreApplyDryRunOperation],
259) -> Result<BTreeMap<&str, &RestoreApplyDryRunOperation>, RestoreApplyDryRunValidationError> {
260    let mut uploads = BTreeMap::new();
261    for operation in operations
262        .iter()
263        .filter(|operation| operation.operation == RestoreApplyOperationKind::UploadSnapshot)
264    {
265        if uploads
266            .insert(operation.source_canister.as_str(), operation)
267            .is_some()
268        {
269            return Err(RestoreApplyDryRunValidationError::ProjectionMismatch(
270                "operations[].source_canister",
271            ));
272        }
273    }
274    Ok(uploads)
275}
276
277fn validate_artifact_check(
278    check: &RestoreApplyArtifactCheck,
279) -> Result<(), RestoreApplyDryRunValidationError> {
280    validate_nonempty(
281        "artifact_validation.checks[].source_canister",
282        &check.source_canister,
283    )?;
284    validate_nonempty(
285        "artifact_validation.checks[].target_canister",
286        &check.target_canister,
287    )?;
288    validate_nonempty(
289        "artifact_validation.checks[].snapshot_id",
290        &check.snapshot_id,
291    )?;
292    validate_nonempty(
293        "artifact_validation.checks[].artifact_path",
294        &check.artifact_path,
295    )?;
296    validate_nonempty(
297        "artifact_validation.checks[].resolved_path",
298        &check.resolved_path,
299    )?;
300    validate_checksum_algorithm(
301        "artifact_validation.checks[].checksum_algorithm",
302        &check.checksum_algorithm,
303    )?;
304    if let Some(expected) = &check.checksum_expected {
305        validate_checksum_hash("artifact_validation.checks[].checksum_expected", expected)?;
306    }
307    if let Some(actual) = &check.checksum_actual {
308        validate_checksum_hash("artifact_validation.checks[].checksum_actual", actual)?;
309    }
310    let checksum_verified = check.exists
311        && check.checksum_expected.is_some()
312        && check.checksum_expected == check.checksum_actual;
313    require_projection(
314        "artifact_validation.checks[].checksum_verified",
315        check.checksum_verified == checksum_verified,
316    )
317}
318
319fn artifact_check_matches_upload(
320    check: &RestoreApplyArtifactCheck,
321    upload: &RestoreApplyDryRunOperation,
322) -> bool {
323    let checksum_matches = match &upload.artifact_checksum {
324        Some(checksum) => {
325            checksum.algorithm == check.checksum_algorithm
326                && Some(checksum.hash.as_str()) == check.checksum_expected.as_deref()
327        }
328        None => check.checksum_expected.is_none(),
329    };
330
331    check.target_canister == upload.target_canister
332        && upload.snapshot_id.as_deref() == Some(check.snapshot_id.as_str())
333        && upload.artifact_path.as_deref() == Some(check.artifact_path.as_str())
334        && checksum_matches
335}
336
337fn validate_nonempty(
338    field: &'static str,
339    value: &str,
340) -> Result<(), RestoreApplyDryRunValidationError> {
341    require_projection(field, !value.trim().is_empty())
342        .map_err(|_| RestoreApplyDryRunValidationError::MissingField(field))
343}
344
345fn validate_checksum_algorithm(
346    field: &'static str,
347    algorithm: &str,
348) -> Result<(), RestoreApplyDryRunValidationError> {
349    ArtifactChecksum::validate_algorithm(algorithm)
350        .map_err(|source| RestoreApplyDryRunValidationError::ArtifactChecksum { field, source })
351}
352
353fn validate_checksum_hash(
354    field: &'static str,
355    hash: &str,
356) -> Result<(), RestoreApplyDryRunValidationError> {
357    ArtifactChecksum::validate_hash(hash)
358        .map_err(|source| RestoreApplyDryRunValidationError::ArtifactChecksum { field, source })
359}
360
361const fn require_count(
362    field: &'static str,
363    reported: usize,
364    actual: usize,
365) -> Result<(), RestoreApplyDryRunValidationError> {
366    require_projection(field, reported == actual)
367}
368
369const fn require_projection(
370    field: &'static str,
371    matches: bool,
372) -> Result<(), RestoreApplyDryRunValidationError> {
373    if matches {
374        Ok(())
375    } else {
376        Err(RestoreApplyDryRunValidationError::ProjectionMismatch(field))
377    }
378}