Skip to main content

canic_backup/manifest/
mod.rs

1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    str::FromStr,
6};
7use thiserror::Error as ThisError;
8
9const SUPPORTED_MANIFEST_VERSION: u16 = 1;
10const SHA256_ALGORITHM: &str = "sha256";
11
12///
13/// FleetBackupManifest
14///
15
16#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct FleetBackupManifest {
18    pub manifest_version: u16,
19    pub backup_id: String,
20    pub created_at: String,
21    pub tool: ToolMetadata,
22    pub source: SourceMetadata,
23    pub consistency: ConsistencySection,
24    pub fleet: FleetSection,
25    pub verification: VerificationPlan,
26}
27
28impl FleetBackupManifest {
29    /// Validate the manifest-level contract before backup finalization or restore planning.
30    pub fn validate(&self) -> Result<(), ManifestValidationError> {
31        validate_manifest_version(self.manifest_version)?;
32        validate_nonempty("backup_id", &self.backup_id)?;
33        validate_nonempty("created_at", &self.created_at)?;
34        self.tool.validate()?;
35        self.source.validate()?;
36        self.consistency.validate()?;
37        self.fleet.validate()?;
38        self.verification.validate()?;
39        validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
40        validate_verification_against_fleet(&self.verification, &self.fleet)?;
41        Ok(())
42    }
43}
44
45///
46/// ToolMetadata
47///
48
49#[derive(Clone, Debug, Deserialize, Serialize)]
50pub struct ToolMetadata {
51    pub name: String,
52    pub version: String,
53}
54
55impl ToolMetadata {
56    /// Validate that the manifest names the tool that produced it.
57    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
58        validate_nonempty("tool.name", &self.name)?;
59        validate_nonempty("tool.version", &self.version)
60    }
61}
62
63///
64/// SourceMetadata
65///
66
67#[derive(Clone, Debug, Deserialize, Serialize)]
68pub struct SourceMetadata {
69    pub environment: String,
70    pub root_canister: String,
71}
72
73impl SourceMetadata {
74    /// Validate the source environment and root canister identity.
75    fn validate(&self) -> Result<(), ManifestValidationError> {
76        validate_nonempty("source.environment", &self.environment)?;
77        validate_principal("source.root_canister", &self.root_canister)
78    }
79}
80
81///
82/// ConsistencySection
83///
84
85#[derive(Clone, Debug, Deserialize, Serialize)]
86pub struct ConsistencySection {
87    pub mode: ConsistencyMode,
88    pub backup_units: Vec<BackupUnit>,
89}
90
91impl ConsistencySection {
92    /// Validate consistency mode and every declared backup unit.
93    fn validate(&self) -> Result<(), ManifestValidationError> {
94        if self.backup_units.is_empty() {
95            return Err(ManifestValidationError::EmptyCollection(
96                "consistency.backup_units",
97            ));
98        }
99
100        let mut unit_ids = BTreeSet::new();
101        for unit in &self.backup_units {
102            unit.validate(&self.mode)?;
103            if !unit_ids.insert(unit.unit_id.clone()) {
104                return Err(ManifestValidationError::DuplicateBackupUnitId(
105                    unit.unit_id.clone(),
106                ));
107            }
108        }
109
110        Ok(())
111    }
112}
113
114///
115/// ConsistencyMode
116///
117
118#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum ConsistencyMode {
121    CrashConsistent,
122    QuiescedUnit,
123}
124
125///
126/// BackupUnit
127///
128
129#[derive(Clone, Debug, Deserialize, Serialize)]
130pub struct BackupUnit {
131    pub unit_id: String,
132    pub kind: BackupUnitKind,
133    pub roles: Vec<String>,
134    pub consistency_reason: Option<String>,
135    pub dependency_closure: Vec<String>,
136    pub topology_validation: String,
137    pub quiescence_strategy: Option<String>,
138}
139
140impl BackupUnit {
141    /// Validate the declared unit boundary and quiescence metadata.
142    fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
143        validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
144        validate_nonempty(
145            "consistency.backup_units[].topology_validation",
146            &self.topology_validation,
147        )?;
148
149        if self.roles.is_empty() {
150            return Err(ManifestValidationError::EmptyCollection(
151                "consistency.backup_units[].roles",
152            ));
153        }
154
155        for role in &self.roles {
156            validate_nonempty("consistency.backup_units[].roles[]", role)?;
157        }
158        validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
159            ManifestValidationError::DuplicateBackupUnitRole {
160                unit_id: self.unit_id.clone(),
161                role: role.to_string(),
162            }
163        })?;
164
165        for dependency in &self.dependency_closure {
166            validate_nonempty(
167                "consistency.backup_units[].dependency_closure[]",
168                dependency,
169            )?;
170        }
171        validate_unique_values(
172            "consistency.backup_units[].dependency_closure[]",
173            &self.dependency_closure,
174            |dependency| ManifestValidationError::DuplicateBackupUnitDependency {
175                unit_id: self.unit_id.clone(),
176                dependency: dependency.to_string(),
177            },
178        )?;
179
180        if matches!(self.kind, BackupUnitKind::Flat) {
181            validate_required_option(
182                "consistency.backup_units[].consistency_reason",
183                self.consistency_reason.as_deref(),
184            )?;
185        }
186
187        if matches!(mode, ConsistencyMode::QuiescedUnit) {
188            validate_required_option(
189                "consistency.backup_units[].quiescence_strategy",
190                self.quiescence_strategy.as_deref(),
191            )?;
192        }
193
194        Ok(())
195    }
196}
197
198///
199/// BackupUnitKind
200///
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "kebab-case")]
204pub enum BackupUnitKind {
205    WholeFleet,
206    ControlPlaneSubset,
207    SubtreeRooted,
208    Flat,
209}
210
211///
212/// FleetSection
213///
214
215#[derive(Clone, Debug, Deserialize, Serialize)]
216pub struct FleetSection {
217    pub topology_hash_algorithm: String,
218    pub topology_hash_input: String,
219    pub discovery_topology_hash: String,
220    pub pre_snapshot_topology_hash: String,
221    pub topology_hash: String,
222    pub members: Vec<FleetMember>,
223}
224
225impl FleetSection {
226    /// Validate topology hash invariants and member uniqueness.
227    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
228        validate_nonempty(
229            "fleet.topology_hash_algorithm",
230            &self.topology_hash_algorithm,
231        )?;
232        if self.topology_hash_algorithm != SHA256_ALGORITHM {
233            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
234                self.topology_hash_algorithm.clone(),
235            ));
236        }
237
238        validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
239        validate_hash(
240            "fleet.discovery_topology_hash",
241            &self.discovery_topology_hash,
242        )?;
243        validate_hash(
244            "fleet.pre_snapshot_topology_hash",
245            &self.pre_snapshot_topology_hash,
246        )?;
247        validate_hash("fleet.topology_hash", &self.topology_hash)?;
248
249        if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
250            return Err(ManifestValidationError::TopologyHashMismatch {
251                discovery: self.discovery_topology_hash.clone(),
252                pre_snapshot: self.pre_snapshot_topology_hash.clone(),
253            });
254        }
255
256        if self.topology_hash != self.discovery_topology_hash {
257            return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
258                accepted: self.topology_hash.clone(),
259                discovery: self.discovery_topology_hash.clone(),
260            });
261        }
262
263        if self.members.is_empty() {
264            return Err(ManifestValidationError::EmptyCollection("fleet.members"));
265        }
266
267        let mut canister_ids = BTreeSet::new();
268        for member in &self.members {
269            member.validate()?;
270            if !canister_ids.insert(member.canister_id.clone()) {
271                return Err(ManifestValidationError::DuplicateCanisterId(
272                    member.canister_id.clone(),
273                ));
274            }
275        }
276
277        Ok(())
278    }
279}
280
281///
282/// FleetMember
283///
284
285#[derive(Clone, Debug, Deserialize, Serialize)]
286pub struct FleetMember {
287    pub role: String,
288    pub canister_id: String,
289    pub parent_canister_id: Option<String>,
290    pub subnet_canister_id: Option<String>,
291    pub controller_hint: Option<String>,
292    pub identity_mode: IdentityMode,
293    pub restore_group: u16,
294    pub verification_class: String,
295    pub verification_checks: Vec<VerificationCheck>,
296    pub source_snapshot: SourceSnapshot,
297}
298
299impl FleetMember {
300    /// Validate one restore member projection from the manifest.
301    fn validate(&self) -> Result<(), ManifestValidationError> {
302        validate_nonempty("fleet.members[].role", &self.role)?;
303        validate_principal("fleet.members[].canister_id", &self.canister_id)?;
304        validate_optional_principal(
305            "fleet.members[].parent_canister_id",
306            self.parent_canister_id.as_deref(),
307        )?;
308        validate_optional_principal(
309            "fleet.members[].subnet_canister_id",
310            self.subnet_canister_id.as_deref(),
311        )?;
312        validate_optional_principal(
313            "fleet.members[].controller_hint",
314            self.controller_hint.as_deref(),
315        )?;
316        validate_nonempty(
317            "fleet.members[].verification_class",
318            &self.verification_class,
319        )?;
320
321        if self.verification_checks.is_empty() {
322            return Err(ManifestValidationError::MissingMemberVerificationChecks(
323                self.canister_id.clone(),
324            ));
325        }
326
327        for check in &self.verification_checks {
328            check.validate()?;
329        }
330
331        self.source_snapshot.validate()
332    }
333}
334
335///
336/// IdentityMode
337///
338
339#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
340#[serde(rename_all = "kebab-case")]
341pub enum IdentityMode {
342    Fixed,
343    Relocatable,
344}
345
346///
347/// SourceSnapshot
348///
349
350#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351pub struct SourceSnapshot {
352    pub snapshot_id: String,
353    pub module_hash: Option<String>,
354    pub wasm_hash: Option<String>,
355    pub code_version: Option<String>,
356    pub artifact_path: String,
357    pub checksum_algorithm: String,
358    #[serde(default)]
359    pub checksum: Option<String>,
360}
361
362impl SourceSnapshot {
363    /// Validate source snapshot provenance and artifact checksum metadata.
364    fn validate(&self) -> Result<(), ManifestValidationError> {
365        validate_nonempty(
366            "fleet.members[].source_snapshot.snapshot_id",
367            &self.snapshot_id,
368        )?;
369        validate_optional_nonempty(
370            "fleet.members[].source_snapshot.module_hash",
371            self.module_hash.as_deref(),
372        )?;
373        validate_optional_nonempty(
374            "fleet.members[].source_snapshot.wasm_hash",
375            self.wasm_hash.as_deref(),
376        )?;
377        validate_optional_nonempty(
378            "fleet.members[].source_snapshot.code_version",
379            self.code_version.as_deref(),
380        )?;
381        validate_nonempty(
382            "fleet.members[].source_snapshot.artifact_path",
383            &self.artifact_path,
384        )?;
385        validate_nonempty(
386            "fleet.members[].source_snapshot.checksum_algorithm",
387            &self.checksum_algorithm,
388        )?;
389        if self.checksum_algorithm != SHA256_ALGORITHM {
390            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
391                self.checksum_algorithm.clone(),
392            ));
393        }
394        validate_optional_hash(
395            "fleet.members[].source_snapshot.checksum",
396            self.checksum.as_deref(),
397        )?;
398        Ok(())
399    }
400}
401
402///
403/// VerificationPlan
404///
405
406#[derive(Clone, Debug, Default, Deserialize, Serialize)]
407pub struct VerificationPlan {
408    pub fleet_checks: Vec<VerificationCheck>,
409    pub member_checks: Vec<MemberVerificationChecks>,
410}
411
412impl VerificationPlan {
413    /// Validate all declarative verification checks.
414    fn validate(&self) -> Result<(), ManifestValidationError> {
415        for check in &self.fleet_checks {
416            check.validate()?;
417        }
418        for member in &self.member_checks {
419            member.validate()?;
420        }
421        Ok(())
422    }
423}
424
425///
426/// MemberVerificationChecks
427///
428
429#[derive(Clone, Debug, Deserialize, Serialize)]
430pub struct MemberVerificationChecks {
431    pub role: String,
432    pub checks: Vec<VerificationCheck>,
433}
434
435impl MemberVerificationChecks {
436    /// Validate one role-scoped verification check group.
437    fn validate(&self) -> Result<(), ManifestValidationError> {
438        validate_nonempty("verification.member_checks[].role", &self.role)?;
439        if self.checks.is_empty() {
440            return Err(ManifestValidationError::EmptyCollection(
441                "verification.member_checks[].checks",
442            ));
443        }
444        for check in &self.checks {
445            check.validate()?;
446        }
447        Ok(())
448    }
449}
450
451///
452/// VerificationCheck
453///
454
455#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
456pub struct VerificationCheck {
457    pub kind: String,
458    pub method: Option<String>,
459    pub roles: Vec<String>,
460}
461
462impl VerificationCheck {
463    /// Validate one concrete verification check.
464    fn validate(&self) -> Result<(), ManifestValidationError> {
465        validate_nonempty("verification.check.kind", &self.kind)?;
466        validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
467        for role in &self.roles {
468            validate_nonempty("verification.check.roles[]", role)?;
469        }
470        validate_unique_values("verification.check.roles[]", &self.roles, |role| {
471            ManifestValidationError::DuplicateVerificationCheckRole {
472                kind: self.kind.clone(),
473                role: role.to_string(),
474            }
475        })?;
476        Ok(())
477    }
478}
479
480///
481/// ManifestValidationError
482///
483
484#[derive(Debug, ThisError)]
485pub enum ManifestValidationError {
486    #[error("unsupported manifest version {0}")]
487    UnsupportedManifestVersion(u16),
488
489    #[error("field {0} must not be empty")]
490    EmptyField(&'static str),
491
492    #[error("collection {0} must not be empty")]
493    EmptyCollection(&'static str),
494
495    #[error("field {field} must be a valid principal: {value}")]
496    InvalidPrincipal { field: &'static str, value: String },
497
498    #[error("field {0} must be a non-empty sha256 hex string")]
499    InvalidHash(&'static str),
500
501    #[error("unsupported hash algorithm {0}")]
502    UnsupportedHashAlgorithm(String),
503
504    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
505    TopologyHashMismatch {
506        discovery: String,
507        pre_snapshot: String,
508    },
509
510    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
511    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
512
513    #[error("duplicate canister id {0}")]
514    DuplicateCanisterId(String),
515
516    #[error("duplicate backup unit id {0}")]
517    DuplicateBackupUnitId(String),
518
519    #[error("backup unit {unit_id} repeats role {role}")]
520    DuplicateBackupUnitRole { unit_id: String, role: String },
521
522    #[error("backup unit {unit_id} repeats dependency {dependency}")]
523    DuplicateBackupUnitDependency { unit_id: String, dependency: String },
524
525    #[error("fleet member {0} has no concrete verification checks")]
526    MissingMemberVerificationChecks(String),
527
528    #[error("backup unit {unit_id} references unknown role {role}")]
529    UnknownBackupUnitRole { unit_id: String, role: String },
530
531    #[error("backup unit {unit_id} references unknown dependency {dependency}")]
532    UnknownBackupUnitDependency { unit_id: String, dependency: String },
533
534    #[error("fleet role {role} is not covered by any backup unit")]
535    BackupUnitCoverageMissingRole { role: String },
536
537    #[error("verification plan references unknown role {role}")]
538    UnknownVerificationRole { role: String },
539
540    #[error("duplicate member verification role {0}")]
541    DuplicateMemberVerificationRole(String),
542
543    #[error("verification check {kind} repeats role {role}")]
544    DuplicateVerificationCheckRole { kind: String, role: String },
545
546    #[error("whole-fleet backup unit {unit_id} omits fleet role {role}")]
547    WholeFleetUnitMissingRole { unit_id: String, role: String },
548
549    #[error("subtree backup unit {unit_id} is not connected")]
550    SubtreeBackupUnitNotConnected { unit_id: String },
551
552    #[error(
553        "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
554    )]
555    SubtreeBackupUnitMissingDescendant {
556        unit_id: String,
557        parent: String,
558        descendant: String,
559    },
560}
561
562// Validate cross-section backup unit references after local section checks pass.
563fn validate_consistency_against_fleet(
564    consistency: &ConsistencySection,
565    fleet: &FleetSection,
566) -> Result<(), ManifestValidationError> {
567    let fleet_roles = fleet
568        .members
569        .iter()
570        .map(|member| member.role.as_str())
571        .collect::<BTreeSet<_>>();
572    let mut covered_roles = BTreeSet::new();
573
574    for unit in &consistency.backup_units {
575        for role in &unit.roles {
576            if !fleet_roles.contains(role.as_str()) {
577                return Err(ManifestValidationError::UnknownBackupUnitRole {
578                    unit_id: unit.unit_id.clone(),
579                    role: role.clone(),
580                });
581            }
582            covered_roles.insert(role.as_str());
583        }
584
585        for dependency in &unit.dependency_closure {
586            if !fleet_roles.contains(dependency.as_str()) {
587                return Err(ManifestValidationError::UnknownBackupUnitDependency {
588                    unit_id: unit.unit_id.clone(),
589                    dependency: dependency.clone(),
590                });
591            }
592        }
593
594        validate_backup_unit_topology(unit, fleet, &fleet_roles)?;
595    }
596
597    for role in &fleet_roles {
598        if !covered_roles.contains(role) {
599            return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
600                role: (*role).to_string(),
601            });
602        }
603    }
604
605    Ok(())
606}
607
608// Validate verification role references after fleet roles are known.
609fn validate_verification_against_fleet(
610    verification: &VerificationPlan,
611    fleet: &FleetSection,
612) -> Result<(), ManifestValidationError> {
613    let fleet_roles = fleet
614        .members
615        .iter()
616        .map(|member| member.role.as_str())
617        .collect::<BTreeSet<_>>();
618
619    for check in &verification.fleet_checks {
620        validate_verification_check_roles(check, &fleet_roles)?;
621    }
622
623    for member in &fleet.members {
624        for check in &member.verification_checks {
625            validate_verification_check_roles(check, &fleet_roles)?;
626        }
627    }
628
629    let mut member_check_roles = BTreeSet::new();
630    for member in &verification.member_checks {
631        if !fleet_roles.contains(member.role.as_str()) {
632            return Err(ManifestValidationError::UnknownVerificationRole {
633                role: member.role.clone(),
634            });
635        }
636        if !member_check_roles.insert(member.role.as_str()) {
637            return Err(ManifestValidationError::DuplicateMemberVerificationRole(
638                member.role.clone(),
639            ));
640        }
641        for check in &member.checks {
642            validate_verification_check_roles(check, &fleet_roles)?;
643        }
644    }
645
646    Ok(())
647}
648
649// Validate every role filter in one verification check.
650fn validate_verification_check_roles(
651    check: &VerificationCheck,
652    fleet_roles: &BTreeSet<&str>,
653) -> Result<(), ManifestValidationError> {
654    for role in &check.roles {
655        if !fleet_roles.contains(role.as_str()) {
656            return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
657        }
658    }
659
660    Ok(())
661}
662
663// Validate backup unit topology promises against manifest membership.
664fn validate_backup_unit_topology(
665    unit: &BackupUnit,
666    fleet: &FleetSection,
667    fleet_roles: &BTreeSet<&str>,
668) -> Result<(), ManifestValidationError> {
669    match &unit.kind {
670        BackupUnitKind::WholeFleet => validate_whole_fleet_unit(unit, fleet_roles),
671        BackupUnitKind::SubtreeRooted => validate_subtree_unit(unit, fleet),
672        BackupUnitKind::ControlPlaneSubset | BackupUnitKind::Flat => Ok(()),
673    }
674}
675
676// Ensure whole-fleet units cover every role declared by the fleet.
677fn validate_whole_fleet_unit(
678    unit: &BackupUnit,
679    fleet_roles: &BTreeSet<&str>,
680) -> Result<(), ManifestValidationError> {
681    let unit_roles = unit
682        .roles
683        .iter()
684        .map(String::as_str)
685        .collect::<BTreeSet<_>>();
686    for role in fleet_roles {
687        if !unit_roles.contains(role) {
688            return Err(ManifestValidationError::WholeFleetUnitMissingRole {
689                unit_id: unit.unit_id.clone(),
690                role: (*role).to_string(),
691            });
692        }
693    }
694
695    Ok(())
696}
697
698// Ensure subtree units are connected and closed under descendants.
699fn validate_subtree_unit(
700    unit: &BackupUnit,
701    fleet: &FleetSection,
702) -> Result<(), ManifestValidationError> {
703    let unit_roles = unit
704        .roles
705        .iter()
706        .map(String::as_str)
707        .collect::<BTreeSet<_>>();
708    let members_by_id = fleet
709        .members
710        .iter()
711        .map(|member| (member.canister_id.as_str(), member))
712        .collect::<BTreeMap<_, _>>();
713    let unit_member_ids = fleet
714        .members
715        .iter()
716        .filter(|member| unit_roles.contains(member.role.as_str()))
717        .map(|member| member.canister_id.as_str())
718        .collect::<BTreeSet<_>>();
719
720    let root_count = fleet
721        .members
722        .iter()
723        .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
724        .filter(|member| {
725            member
726                .parent_canister_id
727                .as_deref()
728                .is_none_or(|parent| !unit_member_ids.contains(parent))
729        })
730        .count();
731    if root_count != 1 {
732        return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
733            unit_id: unit.unit_id.clone(),
734        });
735    }
736
737    for member in &fleet.members {
738        if unit_member_ids.contains(member.canister_id.as_str()) {
739            continue;
740        }
741
742        if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
743            return Err(
744                ManifestValidationError::SubtreeBackupUnitMissingDescendant {
745                    unit_id: unit.unit_id.clone(),
746                    parent: parent.to_string(),
747                    descendant: member.canister_id.clone(),
748                },
749            );
750        }
751    }
752
753    Ok(())
754}
755
756// Return the nearest selected ancestor for a member outside a subtree unit.
757fn first_unit_ancestor<'a>(
758    member: &'a FleetMember,
759    members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
760    unit_member_ids: &BTreeSet<&'a str>,
761) -> Option<&'a str> {
762    let mut visited = BTreeSet::new();
763    let mut parent = member.parent_canister_id.as_deref();
764    while let Some(parent_id) = parent {
765        if unit_member_ids.contains(parent_id) {
766            return Some(parent_id);
767        }
768        if !visited.insert(parent_id) {
769            return None;
770        }
771        parent = members_by_id
772            .get(parent_id)
773            .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
774    }
775
776    None
777}
778
779// Validate the manifest format version before checking nested fields.
780const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
781    if version == SUPPORTED_MANIFEST_VERSION {
782        Ok(())
783    } else {
784        Err(ManifestValidationError::UnsupportedManifestVersion(version))
785    }
786}
787
788// Validate required string fields after trimming whitespace.
789fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
790    if value.trim().is_empty() {
791        Err(ManifestValidationError::EmptyField(field))
792    } else {
793        Ok(())
794    }
795}
796
797// Validate optional string fields only when present.
798fn validate_optional_nonempty(
799    field: &'static str,
800    value: Option<&str>,
801) -> Result<(), ManifestValidationError> {
802    if let Some(value) = value {
803        validate_nonempty(field, value)?;
804    }
805    Ok(())
806}
807
808// Validate required string fields that are represented as optional manifest fields.
809fn validate_required_option(
810    field: &'static str,
811    value: Option<&str>,
812) -> Result<(), ManifestValidationError> {
813    match value {
814        Some(value) => validate_nonempty(field, value),
815        None => Err(ManifestValidationError::EmptyField(field)),
816    }
817}
818
819// Validate that a string list does not repeat values.
820fn validate_unique_values<F>(
821    field: &'static str,
822    values: &[String],
823    error: F,
824) -> Result<(), ManifestValidationError>
825where
826    F: Fn(&str) -> ManifestValidationError,
827{
828    let mut seen = BTreeSet::new();
829    for value in values {
830        validate_nonempty(field, value)?;
831        if !seen.insert(value.as_str()) {
832            return Err(error(value));
833        }
834    }
835
836    Ok(())
837}
838
839// Validate textual principal fields used in JSON manifests.
840fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
841    validate_nonempty(field, value)?;
842    Principal::from_str(value)
843        .map(|_| ())
844        .map_err(|_| ManifestValidationError::InvalidPrincipal {
845            field,
846            value: value.to_string(),
847        })
848}
849
850// Validate optional textual principal fields used in JSON manifests.
851fn validate_optional_principal(
852    field: &'static str,
853    value: Option<&str>,
854) -> Result<(), ManifestValidationError> {
855    if let Some(value) = value {
856        validate_principal(field, value)?;
857    }
858    Ok(())
859}
860
861// Validate SHA-256 hex values used for topology and artifact compatibility.
862fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
863    const SHA256_HEX_LEN: usize = 64;
864    validate_nonempty(field, value)?;
865    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
866        Ok(())
867    } else {
868        Err(ManifestValidationError::InvalidHash(field))
869    }
870}
871
872// Validate optional SHA-256 hex values only when present.
873fn validate_optional_hash(
874    field: &'static str,
875    value: Option<&str>,
876) -> Result<(), ManifestValidationError> {
877    if let Some(value) = value {
878        validate_hash(field, value)?;
879    }
880    Ok(())
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    const ROOT: &str = "aaaaa-aa";
888    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
889    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
890
891    // Build one valid manifest for validation tests.
892    fn valid_manifest() -> FleetBackupManifest {
893        FleetBackupManifest {
894            manifest_version: 1,
895            backup_id: "fbk_test_001".to_string(),
896            created_at: "2026-04-10T12:00:00Z".to_string(),
897            tool: ToolMetadata {
898                name: "canic".to_string(),
899                version: "v1".to_string(),
900            },
901            source: SourceMetadata {
902                environment: "local".to_string(),
903                root_canister: ROOT.to_string(),
904            },
905            consistency: ConsistencySection {
906                mode: ConsistencyMode::QuiescedUnit,
907                backup_units: vec![BackupUnit {
908                    unit_id: "core".to_string(),
909                    kind: BackupUnitKind::Flat,
910                    roles: vec!["root".to_string(), "app".to_string()],
911                    consistency_reason: Some("root and app state are coordinated".to_string()),
912                    dependency_closure: vec!["root".to_string(), "app".to_string()],
913                    topology_validation: "operator-declared-flat".to_string(),
914                    quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
915                }],
916            },
917            fleet: FleetSection {
918                topology_hash_algorithm: "sha256".to_string(),
919                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
920                discovery_topology_hash: HASH.to_string(),
921                pre_snapshot_topology_hash: HASH.to_string(),
922                topology_hash: HASH.to_string(),
923                members: vec![
924                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
925                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
926                ],
927            },
928            verification: VerificationPlan {
929                fleet_checks: vec![VerificationCheck {
930                    kind: "root_ready".to_string(),
931                    method: None,
932                    roles: Vec::new(),
933                }],
934                member_checks: Vec::new(),
935            },
936        }
937    }
938
939    #[test]
940    fn valid_manifest_passes_validation() {
941        let manifest = valid_manifest();
942
943        manifest.validate().expect("manifest should validate");
944    }
945
946    // Ensure snapshot checksum provenance stays canonical when present.
947    #[test]
948    fn invalid_snapshot_checksum_fails_validation() {
949        let mut manifest = valid_manifest();
950        manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
951
952        let err = manifest
953            .validate()
954            .expect_err("invalid snapshot checksum should fail");
955
956        assert!(matches!(
957            err,
958            ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
959        ));
960    }
961
962    // Build one valid fleet member for manifest validation tests.
963    fn fleet_member(
964        role: &str,
965        canister_id: &str,
966        parent_canister_id: Option<&str>,
967        identity_mode: IdentityMode,
968    ) -> FleetMember {
969        FleetMember {
970            role: role.to_string(),
971            canister_id: canister_id.to_string(),
972            parent_canister_id: parent_canister_id.map(str::to_string),
973            subnet_canister_id: Some(CHILD.to_string()),
974            controller_hint: Some(ROOT.to_string()),
975            identity_mode,
976            restore_group: 1,
977            verification_class: "basic".to_string(),
978            verification_checks: vec![VerificationCheck {
979                kind: "call".to_string(),
980                method: Some("canic_ready".to_string()),
981                roles: Vec::new(),
982            }],
983            source_snapshot: SourceSnapshot {
984                snapshot_id: format!("snap-{role}"),
985                module_hash: Some(HASH.to_string()),
986                wasm_hash: Some(HASH.to_string()),
987                code_version: Some("v0.30.0".to_string()),
988                artifact_path: format!("artifacts/{role}"),
989                checksum_algorithm: "sha256".to_string(),
990                checksum: Some(HASH.to_string()),
991            },
992        }
993    }
994
995    #[test]
996    fn topology_hash_mismatch_fails_validation() {
997        let mut manifest = valid_manifest();
998        manifest.fleet.pre_snapshot_topology_hash =
999            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
1000
1001        let err = manifest.validate().expect_err("mismatch should fail");
1002
1003        assert!(matches!(
1004            err,
1005            ManifestValidationError::TopologyHashMismatch { .. }
1006        ));
1007    }
1008
1009    #[test]
1010    fn missing_member_verification_checks_fail_validation() {
1011        let mut manifest = valid_manifest();
1012        manifest.fleet.members[0].verification_checks.clear();
1013
1014        let err = manifest
1015            .validate()
1016            .expect_err("missing member checks should fail");
1017
1018        assert!(matches!(
1019            err,
1020            ManifestValidationError::MissingMemberVerificationChecks(_)
1021        ));
1022    }
1023
1024    #[test]
1025    fn quiesced_unit_requires_quiescence_strategy() {
1026        let mut manifest = valid_manifest();
1027        manifest.consistency.backup_units[0].quiescence_strategy = None;
1028
1029        let err = manifest
1030            .validate()
1031            .expect_err("missing quiescence strategy should fail");
1032
1033        assert!(matches!(err, ManifestValidationError::EmptyField(_)));
1034    }
1035
1036    #[test]
1037    fn backup_unit_roles_must_exist_in_fleet() {
1038        let mut manifest = valid_manifest();
1039        manifest.consistency.backup_units[0]
1040            .roles
1041            .push("missing-role".to_string());
1042
1043        let err = manifest
1044            .validate()
1045            .expect_err("unknown backup unit role should fail");
1046
1047        assert!(matches!(
1048            err,
1049            ManifestValidationError::UnknownBackupUnitRole { .. }
1050        ));
1051    }
1052
1053    #[test]
1054    fn backup_unit_dependencies_must_exist_in_fleet() {
1055        let mut manifest = valid_manifest();
1056        manifest.consistency.backup_units[0]
1057            .dependency_closure
1058            .push("missing-dependency".to_string());
1059
1060        let err = manifest
1061            .validate()
1062            .expect_err("unknown backup unit dependency should fail");
1063
1064        assert!(matches!(
1065            err,
1066            ManifestValidationError::UnknownBackupUnitDependency { .. }
1067        ));
1068    }
1069
1070    #[test]
1071    fn backup_unit_ids_must_be_unique() {
1072        let mut manifest = valid_manifest();
1073        manifest
1074            .consistency
1075            .backup_units
1076            .push(manifest.consistency.backup_units[0].clone());
1077
1078        let err = manifest
1079            .validate()
1080            .expect_err("duplicate unit IDs should fail");
1081
1082        assert!(matches!(
1083            err,
1084            ManifestValidationError::DuplicateBackupUnitId(_)
1085        ));
1086    }
1087
1088    #[test]
1089    fn backup_unit_roles_must_be_unique() {
1090        let mut manifest = valid_manifest();
1091        manifest.consistency.backup_units[0]
1092            .roles
1093            .push("root".to_string());
1094
1095        let err = manifest
1096            .validate()
1097            .expect_err("duplicate backup unit role should fail");
1098
1099        assert!(matches!(
1100            err,
1101            ManifestValidationError::DuplicateBackupUnitRole { .. }
1102        ));
1103    }
1104
1105    #[test]
1106    fn backup_unit_dependencies_must_be_unique() {
1107        let mut manifest = valid_manifest();
1108        manifest.consistency.backup_units[0]
1109            .dependency_closure
1110            .push("root".to_string());
1111
1112        let err = manifest
1113            .validate()
1114            .expect_err("duplicate backup unit dependency should fail");
1115
1116        assert!(matches!(
1117            err,
1118            ManifestValidationError::DuplicateBackupUnitDependency { .. }
1119        ));
1120    }
1121
1122    #[test]
1123    fn every_fleet_role_must_be_covered_by_a_backup_unit() {
1124        let mut manifest = valid_manifest();
1125        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1126        manifest.consistency.backup_units[0].dependency_closure = vec!["root".to_string()];
1127
1128        let err = manifest
1129            .validate()
1130            .expect_err("uncovered app role should fail");
1131
1132        assert!(matches!(
1133            err,
1134            ManifestValidationError::BackupUnitCoverageMissingRole { .. }
1135        ));
1136    }
1137
1138    #[test]
1139    fn fleet_verification_roles_must_exist_in_fleet() {
1140        let mut manifest = valid_manifest();
1141        manifest.verification.fleet_checks[0]
1142            .roles
1143            .push("missing-role".to_string());
1144
1145        let err = manifest
1146            .validate()
1147            .expect_err("unknown fleet verification role should fail");
1148
1149        assert!(matches!(
1150            err,
1151            ManifestValidationError::UnknownVerificationRole { .. }
1152        ));
1153    }
1154
1155    #[test]
1156    fn member_verification_check_roles_must_exist_in_fleet() {
1157        let mut manifest = valid_manifest();
1158        manifest.fleet.members[0].verification_checks[0]
1159            .roles
1160            .push("missing-role".to_string());
1161
1162        let err = manifest
1163            .validate()
1164            .expect_err("unknown member verification check role should fail");
1165
1166        assert!(matches!(
1167            err,
1168            ManifestValidationError::UnknownVerificationRole { .. }
1169        ));
1170    }
1171
1172    #[test]
1173    fn verification_check_roles_must_be_unique() {
1174        let mut manifest = valid_manifest();
1175        manifest.verification.fleet_checks[0]
1176            .roles
1177            .push("root".to_string());
1178        manifest.verification.fleet_checks[0]
1179            .roles
1180            .push("root".to_string());
1181
1182        let err = manifest
1183            .validate()
1184            .expect_err("duplicate verification role filter should fail");
1185
1186        assert!(matches!(
1187            err,
1188            ManifestValidationError::DuplicateVerificationCheckRole { .. }
1189        ));
1190    }
1191
1192    #[test]
1193    fn member_verification_group_roles_must_exist_in_fleet() {
1194        let mut manifest = valid_manifest();
1195        manifest
1196            .verification
1197            .member_checks
1198            .push(MemberVerificationChecks {
1199                role: "missing-role".to_string(),
1200                checks: vec![VerificationCheck {
1201                    kind: "ready".to_string(),
1202                    method: None,
1203                    roles: Vec::new(),
1204                }],
1205            });
1206
1207        let err = manifest
1208            .validate()
1209            .expect_err("unknown member verification role should fail");
1210
1211        assert!(matches!(
1212            err,
1213            ManifestValidationError::UnknownVerificationRole { .. }
1214        ));
1215    }
1216
1217    #[test]
1218    fn member_verification_group_roles_must_be_unique() {
1219        let mut manifest = valid_manifest();
1220        manifest
1221            .verification
1222            .member_checks
1223            .push(member_verification_checks("root"));
1224        manifest
1225            .verification
1226            .member_checks
1227            .push(member_verification_checks("root"));
1228
1229        let err = manifest
1230            .validate()
1231            .expect_err("duplicate member verification role should fail");
1232
1233        assert!(matches!(
1234            err,
1235            ManifestValidationError::DuplicateMemberVerificationRole(_)
1236        ));
1237    }
1238
1239    #[test]
1240    fn nested_member_verification_roles_must_exist_in_fleet() {
1241        let mut manifest = valid_manifest();
1242        let mut checks = member_verification_checks("root");
1243        checks.checks[0].roles.push("missing-role".to_string());
1244        manifest.verification.member_checks.push(checks);
1245
1246        let err = manifest
1247            .validate()
1248            .expect_err("unknown nested verification role should fail");
1249
1250        assert!(matches!(
1251            err,
1252            ManifestValidationError::UnknownVerificationRole { .. }
1253        ));
1254    }
1255
1256    #[test]
1257    fn whole_fleet_unit_must_cover_all_roles() {
1258        let mut manifest = valid_manifest();
1259        manifest.consistency.backup_units[0].kind = BackupUnitKind::WholeFleet;
1260        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1261        manifest.consistency.backup_units[0].consistency_reason = None;
1262
1263        let err = manifest
1264            .validate()
1265            .expect_err("whole-fleet unit missing app role should fail");
1266
1267        assert!(matches!(
1268            err,
1269            ManifestValidationError::WholeFleetUnitMissingRole { .. }
1270        ));
1271    }
1272
1273    #[test]
1274    fn subtree_unit_must_be_closed_under_descendants() {
1275        let mut manifest = valid_manifest();
1276        manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1277        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1278        manifest.consistency.backup_units[0].consistency_reason = None;
1279
1280        let err = manifest
1281            .validate()
1282            .expect_err("subtree unit omitting app child should fail");
1283
1284        assert!(matches!(
1285            err,
1286            ManifestValidationError::SubtreeBackupUnitMissingDescendant { .. }
1287        ));
1288    }
1289
1290    #[test]
1291    fn subtree_unit_must_be_connected() {
1292        let mut manifest = valid_manifest();
1293        manifest.fleet.members.push(fleet_member(
1294            "worker",
1295            "r7inp-6aaaa-aaaaa-aaabq-cai",
1296            None,
1297            IdentityMode::Relocatable,
1298        ));
1299        manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1300        manifest.consistency.backup_units[0].roles = vec!["app".to_string(), "worker".to_string()];
1301        manifest.consistency.backup_units[0].consistency_reason = None;
1302        manifest.consistency.backup_units[0]
1303            .dependency_closure
1304            .push("worker".to_string());
1305
1306        let err = manifest
1307            .validate()
1308            .expect_err("disconnected subtree unit should fail");
1309
1310        assert!(matches!(
1311            err,
1312            ManifestValidationError::SubtreeBackupUnitNotConnected { .. }
1313        ));
1314    }
1315
1316    #[test]
1317    fn manifest_round_trips_through_json() {
1318        let manifest = valid_manifest();
1319
1320        let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
1321        let decoded: FleetBackupManifest =
1322            serde_json::from_str(&encoded).expect("deserialize manifest");
1323
1324        decoded
1325            .validate()
1326            .expect("decoded manifest should validate");
1327    }
1328
1329    // Build one role-scoped verification group for validation tests.
1330    fn member_verification_checks(role: &str) -> MemberVerificationChecks {
1331        MemberVerificationChecks {
1332            role: role.to_string(),
1333            checks: vec![VerificationCheck {
1334                kind: "ready".to_string(),
1335                method: None,
1336                roles: Vec::new(),
1337            }],
1338        }
1339    }
1340}