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