Skip to main content

canic_backup/manifest/
mod.rs

1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeSet, str::FromStr};
4use thiserror::Error as ThisError;
5
6const SUPPORTED_MANIFEST_VERSION: u16 = 1;
7const SHA256_ALGORITHM: &str = "sha256";
8
9///
10/// FleetBackupManifest
11///
12
13#[derive(Clone, Debug, Deserialize, Serialize)]
14pub struct FleetBackupManifest {
15    pub manifest_version: u16,
16    pub backup_id: String,
17    pub created_at: String,
18    pub tool: ToolMetadata,
19    pub source: SourceMetadata,
20    pub consistency: ConsistencySection,
21    pub fleet: FleetSection,
22    pub verification: VerificationPlan,
23}
24
25impl FleetBackupManifest {
26    /// Validate the manifest-level contract before backup finalization or restore planning.
27    pub fn validate(&self) -> Result<(), ManifestValidationError> {
28        validate_manifest_version(self.manifest_version)?;
29        validate_nonempty("backup_id", &self.backup_id)?;
30        validate_nonempty("created_at", &self.created_at)?;
31        self.tool.validate()?;
32        self.source.validate()?;
33        self.consistency.validate()?;
34        self.fleet.validate()?;
35        self.verification.validate()?;
36        validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
37        Ok(())
38    }
39}
40
41///
42/// ToolMetadata
43///
44
45#[derive(Clone, Debug, Deserialize, Serialize)]
46pub struct ToolMetadata {
47    pub name: String,
48    pub version: String,
49}
50
51impl ToolMetadata {
52    /// Validate that the manifest names the tool that produced it.
53    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
54        validate_nonempty("tool.name", &self.name)?;
55        validate_nonempty("tool.version", &self.version)
56    }
57}
58
59///
60/// SourceMetadata
61///
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
64pub struct SourceMetadata {
65    pub environment: String,
66    pub root_canister: String,
67}
68
69impl SourceMetadata {
70    /// Validate the source environment and root canister identity.
71    fn validate(&self) -> Result<(), ManifestValidationError> {
72        validate_nonempty("source.environment", &self.environment)?;
73        validate_principal("source.root_canister", &self.root_canister)
74    }
75}
76
77///
78/// ConsistencySection
79///
80
81#[derive(Clone, Debug, Deserialize, Serialize)]
82pub struct ConsistencySection {
83    pub mode: ConsistencyMode,
84    pub backup_units: Vec<BackupUnit>,
85}
86
87impl ConsistencySection {
88    /// Validate consistency mode and every declared backup unit.
89    fn validate(&self) -> Result<(), ManifestValidationError> {
90        if self.backup_units.is_empty() {
91            return Err(ManifestValidationError::EmptyCollection(
92                "consistency.backup_units",
93            ));
94        }
95
96        for unit in &self.backup_units {
97            unit.validate(&self.mode)?;
98        }
99
100        Ok(())
101    }
102}
103
104///
105/// ConsistencyMode
106///
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum ConsistencyMode {
111    CrashConsistent,
112    QuiescedUnit,
113}
114
115///
116/// BackupUnit
117///
118
119#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct BackupUnit {
121    pub unit_id: String,
122    pub kind: BackupUnitKind,
123    pub roles: Vec<String>,
124    pub consistency_reason: Option<String>,
125    pub dependency_closure: Vec<String>,
126    pub topology_validation: String,
127    pub quiescence_strategy: Option<String>,
128}
129
130impl BackupUnit {
131    /// Validate the declared unit boundary and quiescence metadata.
132    fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
133        validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
134        validate_nonempty(
135            "consistency.backup_units[].topology_validation",
136            &self.topology_validation,
137        )?;
138
139        if self.roles.is_empty() {
140            return Err(ManifestValidationError::EmptyCollection(
141                "consistency.backup_units[].roles",
142            ));
143        }
144
145        for role in &self.roles {
146            validate_nonempty("consistency.backup_units[].roles[]", role)?;
147        }
148
149        for dependency in &self.dependency_closure {
150            validate_nonempty(
151                "consistency.backup_units[].dependency_closure[]",
152                dependency,
153            )?;
154        }
155
156        if matches!(self.kind, BackupUnitKind::Flat) {
157            validate_required_option(
158                "consistency.backup_units[].consistency_reason",
159                self.consistency_reason.as_deref(),
160            )?;
161        }
162
163        if matches!(mode, ConsistencyMode::QuiescedUnit) {
164            validate_required_option(
165                "consistency.backup_units[].quiescence_strategy",
166                self.quiescence_strategy.as_deref(),
167            )?;
168        }
169
170        Ok(())
171    }
172}
173
174///
175/// BackupUnitKind
176///
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum BackupUnitKind {
181    WholeFleet,
182    ControlPlaneSubset,
183    SubtreeRooted,
184    Flat,
185}
186
187///
188/// FleetSection
189///
190
191#[derive(Clone, Debug, Deserialize, Serialize)]
192pub struct FleetSection {
193    pub topology_hash_algorithm: String,
194    pub topology_hash_input: String,
195    pub discovery_topology_hash: String,
196    pub pre_snapshot_topology_hash: String,
197    pub topology_hash: String,
198    pub members: Vec<FleetMember>,
199}
200
201impl FleetSection {
202    /// Validate topology hash invariants and member uniqueness.
203    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
204        validate_nonempty(
205            "fleet.topology_hash_algorithm",
206            &self.topology_hash_algorithm,
207        )?;
208        if self.topology_hash_algorithm != SHA256_ALGORITHM {
209            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
210                self.topology_hash_algorithm.clone(),
211            ));
212        }
213
214        validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
215        validate_hash(
216            "fleet.discovery_topology_hash",
217            &self.discovery_topology_hash,
218        )?;
219        validate_hash(
220            "fleet.pre_snapshot_topology_hash",
221            &self.pre_snapshot_topology_hash,
222        )?;
223        validate_hash("fleet.topology_hash", &self.topology_hash)?;
224
225        if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
226            return Err(ManifestValidationError::TopologyHashMismatch {
227                discovery: self.discovery_topology_hash.clone(),
228                pre_snapshot: self.pre_snapshot_topology_hash.clone(),
229            });
230        }
231
232        if self.topology_hash != self.discovery_topology_hash {
233            return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
234                accepted: self.topology_hash.clone(),
235                discovery: self.discovery_topology_hash.clone(),
236            });
237        }
238
239        if self.members.is_empty() {
240            return Err(ManifestValidationError::EmptyCollection("fleet.members"));
241        }
242
243        let mut canister_ids = BTreeSet::new();
244        for member in &self.members {
245            member.validate()?;
246            if !canister_ids.insert(member.canister_id.clone()) {
247                return Err(ManifestValidationError::DuplicateCanisterId(
248                    member.canister_id.clone(),
249                ));
250            }
251        }
252
253        Ok(())
254    }
255}
256
257///
258/// FleetMember
259///
260
261#[derive(Clone, Debug, Deserialize, Serialize)]
262pub struct FleetMember {
263    pub role: String,
264    pub canister_id: String,
265    pub parent_canister_id: Option<String>,
266    pub subnet_canister_id: Option<String>,
267    pub controller_hint: Option<String>,
268    pub identity_mode: IdentityMode,
269    pub restore_group: u16,
270    pub verification_class: String,
271    pub verification_checks: Vec<VerificationCheck>,
272    pub source_snapshot: SourceSnapshot,
273}
274
275impl FleetMember {
276    /// Validate one restore member projection from the manifest.
277    fn validate(&self) -> Result<(), ManifestValidationError> {
278        validate_nonempty("fleet.members[].role", &self.role)?;
279        validate_principal("fleet.members[].canister_id", &self.canister_id)?;
280        validate_optional_principal(
281            "fleet.members[].parent_canister_id",
282            self.parent_canister_id.as_deref(),
283        )?;
284        validate_optional_principal(
285            "fleet.members[].subnet_canister_id",
286            self.subnet_canister_id.as_deref(),
287        )?;
288        validate_optional_principal(
289            "fleet.members[].controller_hint",
290            self.controller_hint.as_deref(),
291        )?;
292        validate_nonempty(
293            "fleet.members[].verification_class",
294            &self.verification_class,
295        )?;
296
297        if self.verification_checks.is_empty() {
298            return Err(ManifestValidationError::MissingMemberVerificationChecks(
299                self.canister_id.clone(),
300            ));
301        }
302
303        for check in &self.verification_checks {
304            check.validate()?;
305        }
306
307        self.source_snapshot.validate()
308    }
309}
310
311///
312/// IdentityMode
313///
314
315#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
316#[serde(rename_all = "kebab-case")]
317pub enum IdentityMode {
318    Fixed,
319    Relocatable,
320}
321
322///
323/// SourceSnapshot
324///
325
326#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
327pub struct SourceSnapshot {
328    pub snapshot_id: String,
329    pub module_hash: Option<String>,
330    pub wasm_hash: Option<String>,
331    pub code_version: Option<String>,
332    pub artifact_path: String,
333    pub checksum_algorithm: String,
334}
335
336impl SourceSnapshot {
337    /// Validate source snapshot provenance and artifact checksum metadata.
338    fn validate(&self) -> Result<(), ManifestValidationError> {
339        validate_nonempty(
340            "fleet.members[].source_snapshot.snapshot_id",
341            &self.snapshot_id,
342        )?;
343        validate_optional_nonempty(
344            "fleet.members[].source_snapshot.module_hash",
345            self.module_hash.as_deref(),
346        )?;
347        validate_optional_nonempty(
348            "fleet.members[].source_snapshot.wasm_hash",
349            self.wasm_hash.as_deref(),
350        )?;
351        validate_optional_nonempty(
352            "fleet.members[].source_snapshot.code_version",
353            self.code_version.as_deref(),
354        )?;
355        validate_nonempty(
356            "fleet.members[].source_snapshot.artifact_path",
357            &self.artifact_path,
358        )?;
359        validate_nonempty(
360            "fleet.members[].source_snapshot.checksum_algorithm",
361            &self.checksum_algorithm,
362        )?;
363        if self.checksum_algorithm != SHA256_ALGORITHM {
364            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
365                self.checksum_algorithm.clone(),
366            ));
367        }
368        Ok(())
369    }
370}
371
372///
373/// VerificationPlan
374///
375
376#[derive(Clone, Debug, Default, Deserialize, Serialize)]
377pub struct VerificationPlan {
378    pub fleet_checks: Vec<VerificationCheck>,
379    pub member_checks: Vec<MemberVerificationChecks>,
380}
381
382impl VerificationPlan {
383    /// Validate all declarative verification checks.
384    fn validate(&self) -> Result<(), ManifestValidationError> {
385        for check in &self.fleet_checks {
386            check.validate()?;
387        }
388        for member in &self.member_checks {
389            member.validate()?;
390        }
391        Ok(())
392    }
393}
394
395///
396/// MemberVerificationChecks
397///
398
399#[derive(Clone, Debug, Deserialize, Serialize)]
400pub struct MemberVerificationChecks {
401    pub role: String,
402    pub checks: Vec<VerificationCheck>,
403}
404
405impl MemberVerificationChecks {
406    /// Validate one role-scoped verification check group.
407    fn validate(&self) -> Result<(), ManifestValidationError> {
408        validate_nonempty("verification.member_checks[].role", &self.role)?;
409        if self.checks.is_empty() {
410            return Err(ManifestValidationError::EmptyCollection(
411                "verification.member_checks[].checks",
412            ));
413        }
414        for check in &self.checks {
415            check.validate()?;
416        }
417        Ok(())
418    }
419}
420
421///
422/// VerificationCheck
423///
424
425#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
426pub struct VerificationCheck {
427    pub kind: String,
428    pub method: Option<String>,
429    pub roles: Vec<String>,
430}
431
432impl VerificationCheck {
433    /// Validate one concrete verification check.
434    fn validate(&self) -> Result<(), ManifestValidationError> {
435        validate_nonempty("verification.check.kind", &self.kind)?;
436        validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
437        for role in &self.roles {
438            validate_nonempty("verification.check.roles[]", role)?;
439        }
440        Ok(())
441    }
442}
443
444///
445/// ManifestValidationError
446///
447
448#[derive(Debug, ThisError)]
449pub enum ManifestValidationError {
450    #[error("unsupported manifest version {0}")]
451    UnsupportedManifestVersion(u16),
452
453    #[error("field {0} must not be empty")]
454    EmptyField(&'static str),
455
456    #[error("collection {0} must not be empty")]
457    EmptyCollection(&'static str),
458
459    #[error("field {field} must be a valid principal: {value}")]
460    InvalidPrincipal { field: &'static str, value: String },
461
462    #[error("field {0} must be a non-empty sha256 hex string")]
463    InvalidHash(&'static str),
464
465    #[error("unsupported hash algorithm {0}")]
466    UnsupportedHashAlgorithm(String),
467
468    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
469    TopologyHashMismatch {
470        discovery: String,
471        pre_snapshot: String,
472    },
473
474    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
475    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
476
477    #[error("duplicate canister id {0}")]
478    DuplicateCanisterId(String),
479
480    #[error("fleet member {0} has no concrete verification checks")]
481    MissingMemberVerificationChecks(String),
482
483    #[error("backup unit {unit_id} references unknown role {role}")]
484    UnknownBackupUnitRole { unit_id: String, role: String },
485
486    #[error("backup unit {unit_id} references unknown dependency {dependency}")]
487    UnknownBackupUnitDependency { unit_id: String, dependency: String },
488}
489
490// Validate cross-section backup unit references after local section checks pass.
491fn validate_consistency_against_fleet(
492    consistency: &ConsistencySection,
493    fleet: &FleetSection,
494) -> Result<(), ManifestValidationError> {
495    let fleet_roles = fleet
496        .members
497        .iter()
498        .map(|member| member.role.as_str())
499        .collect::<BTreeSet<_>>();
500
501    for unit in &consistency.backup_units {
502        for role in &unit.roles {
503            if !fleet_roles.contains(role.as_str()) {
504                return Err(ManifestValidationError::UnknownBackupUnitRole {
505                    unit_id: unit.unit_id.clone(),
506                    role: role.clone(),
507                });
508            }
509        }
510
511        for dependency in &unit.dependency_closure {
512            if !fleet_roles.contains(dependency.as_str()) {
513                return Err(ManifestValidationError::UnknownBackupUnitDependency {
514                    unit_id: unit.unit_id.clone(),
515                    dependency: dependency.clone(),
516                });
517            }
518        }
519    }
520
521    Ok(())
522}
523
524// Validate the manifest format version before checking nested fields.
525const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
526    if version == SUPPORTED_MANIFEST_VERSION {
527        Ok(())
528    } else {
529        Err(ManifestValidationError::UnsupportedManifestVersion(version))
530    }
531}
532
533// Validate required string fields after trimming whitespace.
534fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
535    if value.trim().is_empty() {
536        Err(ManifestValidationError::EmptyField(field))
537    } else {
538        Ok(())
539    }
540}
541
542// Validate optional string fields only when present.
543fn validate_optional_nonempty(
544    field: &'static str,
545    value: Option<&str>,
546) -> Result<(), ManifestValidationError> {
547    if let Some(value) = value {
548        validate_nonempty(field, value)?;
549    }
550    Ok(())
551}
552
553// Validate required string fields that are represented as optional manifest fields.
554fn validate_required_option(
555    field: &'static str,
556    value: Option<&str>,
557) -> Result<(), ManifestValidationError> {
558    match value {
559        Some(value) => validate_nonempty(field, value),
560        None => Err(ManifestValidationError::EmptyField(field)),
561    }
562}
563
564// Validate textual principal fields used in JSON manifests.
565fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
566    validate_nonempty(field, value)?;
567    Principal::from_str(value)
568        .map(|_| ())
569        .map_err(|_| ManifestValidationError::InvalidPrincipal {
570            field,
571            value: value.to_string(),
572        })
573}
574
575// Validate optional textual principal fields used in JSON manifests.
576fn validate_optional_principal(
577    field: &'static str,
578    value: Option<&str>,
579) -> Result<(), ManifestValidationError> {
580    if let Some(value) = value {
581        validate_principal(field, value)?;
582    }
583    Ok(())
584}
585
586// Validate SHA-256 hex values used for topology and artifact compatibility.
587fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
588    const SHA256_HEX_LEN: usize = 64;
589    validate_nonempty(field, value)?;
590    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
591        Ok(())
592    } else {
593        Err(ManifestValidationError::InvalidHash(field))
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    const ROOT: &str = "aaaaa-aa";
602    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
603    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
604
605    // Build one valid manifest for validation tests.
606    fn valid_manifest() -> FleetBackupManifest {
607        FleetBackupManifest {
608            manifest_version: 1,
609            backup_id: "fbk_test_001".to_string(),
610            created_at: "2026-04-10T12:00:00Z".to_string(),
611            tool: ToolMetadata {
612                name: "canic".to_string(),
613                version: "v1".to_string(),
614            },
615            source: SourceMetadata {
616                environment: "local".to_string(),
617                root_canister: ROOT.to_string(),
618            },
619            consistency: ConsistencySection {
620                mode: ConsistencyMode::QuiescedUnit,
621                backup_units: vec![BackupUnit {
622                    unit_id: "core".to_string(),
623                    kind: BackupUnitKind::Flat,
624                    roles: vec!["root".to_string(), "app".to_string()],
625                    consistency_reason: Some("root and app state are coordinated".to_string()),
626                    dependency_closure: vec!["root".to_string(), "app".to_string()],
627                    topology_validation: "operator-declared-flat".to_string(),
628                    quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
629                }],
630            },
631            fleet: FleetSection {
632                topology_hash_algorithm: "sha256".to_string(),
633                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
634                discovery_topology_hash: HASH.to_string(),
635                pre_snapshot_topology_hash: HASH.to_string(),
636                topology_hash: HASH.to_string(),
637                members: vec![
638                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
639                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
640                ],
641            },
642            verification: VerificationPlan {
643                fleet_checks: vec![VerificationCheck {
644                    kind: "root_ready".to_string(),
645                    method: None,
646                    roles: Vec::new(),
647                }],
648                member_checks: Vec::new(),
649            },
650        }
651    }
652
653    #[test]
654    fn valid_manifest_passes_validation() {
655        let manifest = valid_manifest();
656
657        manifest.validate().expect("manifest should validate");
658    }
659
660    // Build one valid fleet member for manifest validation tests.
661    fn fleet_member(
662        role: &str,
663        canister_id: &str,
664        parent_canister_id: Option<&str>,
665        identity_mode: IdentityMode,
666    ) -> FleetMember {
667        FleetMember {
668            role: role.to_string(),
669            canister_id: canister_id.to_string(),
670            parent_canister_id: parent_canister_id.map(str::to_string),
671            subnet_canister_id: Some(CHILD.to_string()),
672            controller_hint: Some(ROOT.to_string()),
673            identity_mode,
674            restore_group: 1,
675            verification_class: "basic".to_string(),
676            verification_checks: vec![VerificationCheck {
677                kind: "call".to_string(),
678                method: Some("canic_ready".to_string()),
679                roles: Vec::new(),
680            }],
681            source_snapshot: SourceSnapshot {
682                snapshot_id: format!("snap-{role}"),
683                module_hash: Some(HASH.to_string()),
684                wasm_hash: Some(HASH.to_string()),
685                code_version: Some("v0.30.0".to_string()),
686                artifact_path: format!("artifacts/{role}"),
687                checksum_algorithm: "sha256".to_string(),
688            },
689        }
690    }
691
692    #[test]
693    fn topology_hash_mismatch_fails_validation() {
694        let mut manifest = valid_manifest();
695        manifest.fleet.pre_snapshot_topology_hash =
696            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
697
698        let err = manifest.validate().expect_err("mismatch should fail");
699
700        assert!(matches!(
701            err,
702            ManifestValidationError::TopologyHashMismatch { .. }
703        ));
704    }
705
706    #[test]
707    fn missing_member_verification_checks_fail_validation() {
708        let mut manifest = valid_manifest();
709        manifest.fleet.members[0].verification_checks.clear();
710
711        let err = manifest
712            .validate()
713            .expect_err("missing member checks should fail");
714
715        assert!(matches!(
716            err,
717            ManifestValidationError::MissingMemberVerificationChecks(_)
718        ));
719    }
720
721    #[test]
722    fn quiesced_unit_requires_quiescence_strategy() {
723        let mut manifest = valid_manifest();
724        manifest.consistency.backup_units[0].quiescence_strategy = None;
725
726        let err = manifest
727            .validate()
728            .expect_err("missing quiescence strategy should fail");
729
730        assert!(matches!(err, ManifestValidationError::EmptyField(_)));
731    }
732
733    #[test]
734    fn backup_unit_roles_must_exist_in_fleet() {
735        let mut manifest = valid_manifest();
736        manifest.consistency.backup_units[0]
737            .roles
738            .push("missing-role".to_string());
739
740        let err = manifest
741            .validate()
742            .expect_err("unknown backup unit role should fail");
743
744        assert!(matches!(
745            err,
746            ManifestValidationError::UnknownBackupUnitRole { .. }
747        ));
748    }
749
750    #[test]
751    fn backup_unit_dependencies_must_exist_in_fleet() {
752        let mut manifest = valid_manifest();
753        manifest.consistency.backup_units[0]
754            .dependency_closure
755            .push("missing-dependency".to_string());
756
757        let err = manifest
758            .validate()
759            .expect_err("unknown backup unit dependency should fail");
760
761        assert!(matches!(
762            err,
763            ManifestValidationError::UnknownBackupUnitDependency { .. }
764        ));
765    }
766
767    #[test]
768    fn manifest_round_trips_through_json() {
769        let manifest = valid_manifest();
770
771        let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
772        let decoded: FleetBackupManifest =
773            serde_json::from_str(&encoded).expect("deserialize manifest");
774
775        decoded
776            .validate()
777            .expect("decoded manifest should validate");
778    }
779}