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    #[serde(default)]
335    pub checksum: Option<String>,
336}
337
338impl SourceSnapshot {
339    /// Validate source snapshot provenance and artifact checksum metadata.
340    fn validate(&self) -> Result<(), ManifestValidationError> {
341        validate_nonempty(
342            "fleet.members[].source_snapshot.snapshot_id",
343            &self.snapshot_id,
344        )?;
345        validate_optional_nonempty(
346            "fleet.members[].source_snapshot.module_hash",
347            self.module_hash.as_deref(),
348        )?;
349        validate_optional_nonempty(
350            "fleet.members[].source_snapshot.wasm_hash",
351            self.wasm_hash.as_deref(),
352        )?;
353        validate_optional_nonempty(
354            "fleet.members[].source_snapshot.code_version",
355            self.code_version.as_deref(),
356        )?;
357        validate_nonempty(
358            "fleet.members[].source_snapshot.artifact_path",
359            &self.artifact_path,
360        )?;
361        validate_nonempty(
362            "fleet.members[].source_snapshot.checksum_algorithm",
363            &self.checksum_algorithm,
364        )?;
365        if self.checksum_algorithm != SHA256_ALGORITHM {
366            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
367                self.checksum_algorithm.clone(),
368            ));
369        }
370        validate_optional_hash(
371            "fleet.members[].source_snapshot.checksum",
372            self.checksum.as_deref(),
373        )?;
374        Ok(())
375    }
376}
377
378///
379/// VerificationPlan
380///
381
382#[derive(Clone, Debug, Default, Deserialize, Serialize)]
383pub struct VerificationPlan {
384    pub fleet_checks: Vec<VerificationCheck>,
385    pub member_checks: Vec<MemberVerificationChecks>,
386}
387
388impl VerificationPlan {
389    /// Validate all declarative verification checks.
390    fn validate(&self) -> Result<(), ManifestValidationError> {
391        for check in &self.fleet_checks {
392            check.validate()?;
393        }
394        for member in &self.member_checks {
395            member.validate()?;
396        }
397        Ok(())
398    }
399}
400
401///
402/// MemberVerificationChecks
403///
404
405#[derive(Clone, Debug, Deserialize, Serialize)]
406pub struct MemberVerificationChecks {
407    pub role: String,
408    pub checks: Vec<VerificationCheck>,
409}
410
411impl MemberVerificationChecks {
412    /// Validate one role-scoped verification check group.
413    fn validate(&self) -> Result<(), ManifestValidationError> {
414        validate_nonempty("verification.member_checks[].role", &self.role)?;
415        if self.checks.is_empty() {
416            return Err(ManifestValidationError::EmptyCollection(
417                "verification.member_checks[].checks",
418            ));
419        }
420        for check in &self.checks {
421            check.validate()?;
422        }
423        Ok(())
424    }
425}
426
427///
428/// VerificationCheck
429///
430
431#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
432pub struct VerificationCheck {
433    pub kind: String,
434    pub method: Option<String>,
435    pub roles: Vec<String>,
436}
437
438impl VerificationCheck {
439    /// Validate one concrete verification check.
440    fn validate(&self) -> Result<(), ManifestValidationError> {
441        validate_nonempty("verification.check.kind", &self.kind)?;
442        validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
443        for role in &self.roles {
444            validate_nonempty("verification.check.roles[]", role)?;
445        }
446        Ok(())
447    }
448}
449
450///
451/// ManifestValidationError
452///
453
454#[derive(Debug, ThisError)]
455pub enum ManifestValidationError {
456    #[error("unsupported manifest version {0}")]
457    UnsupportedManifestVersion(u16),
458
459    #[error("field {0} must not be empty")]
460    EmptyField(&'static str),
461
462    #[error("collection {0} must not be empty")]
463    EmptyCollection(&'static str),
464
465    #[error("field {field} must be a valid principal: {value}")]
466    InvalidPrincipal { field: &'static str, value: String },
467
468    #[error("field {0} must be a non-empty sha256 hex string")]
469    InvalidHash(&'static str),
470
471    #[error("unsupported hash algorithm {0}")]
472    UnsupportedHashAlgorithm(String),
473
474    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
475    TopologyHashMismatch {
476        discovery: String,
477        pre_snapshot: String,
478    },
479
480    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
481    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
482
483    #[error("duplicate canister id {0}")]
484    DuplicateCanisterId(String),
485
486    #[error("fleet member {0} has no concrete verification checks")]
487    MissingMemberVerificationChecks(String),
488
489    #[error("backup unit {unit_id} references unknown role {role}")]
490    UnknownBackupUnitRole { unit_id: String, role: String },
491
492    #[error("backup unit {unit_id} references unknown dependency {dependency}")]
493    UnknownBackupUnitDependency { unit_id: String, dependency: String },
494}
495
496// Validate cross-section backup unit references after local section checks pass.
497fn validate_consistency_against_fleet(
498    consistency: &ConsistencySection,
499    fleet: &FleetSection,
500) -> Result<(), ManifestValidationError> {
501    let fleet_roles = fleet
502        .members
503        .iter()
504        .map(|member| member.role.as_str())
505        .collect::<BTreeSet<_>>();
506
507    for unit in &consistency.backup_units {
508        for role in &unit.roles {
509            if !fleet_roles.contains(role.as_str()) {
510                return Err(ManifestValidationError::UnknownBackupUnitRole {
511                    unit_id: unit.unit_id.clone(),
512                    role: role.clone(),
513                });
514            }
515        }
516
517        for dependency in &unit.dependency_closure {
518            if !fleet_roles.contains(dependency.as_str()) {
519                return Err(ManifestValidationError::UnknownBackupUnitDependency {
520                    unit_id: unit.unit_id.clone(),
521                    dependency: dependency.clone(),
522                });
523            }
524        }
525    }
526
527    Ok(())
528}
529
530// Validate the manifest format version before checking nested fields.
531const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
532    if version == SUPPORTED_MANIFEST_VERSION {
533        Ok(())
534    } else {
535        Err(ManifestValidationError::UnsupportedManifestVersion(version))
536    }
537}
538
539// Validate required string fields after trimming whitespace.
540fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
541    if value.trim().is_empty() {
542        Err(ManifestValidationError::EmptyField(field))
543    } else {
544        Ok(())
545    }
546}
547
548// Validate optional string fields only when present.
549fn validate_optional_nonempty(
550    field: &'static str,
551    value: Option<&str>,
552) -> Result<(), ManifestValidationError> {
553    if let Some(value) = value {
554        validate_nonempty(field, value)?;
555    }
556    Ok(())
557}
558
559// Validate required string fields that are represented as optional manifest fields.
560fn validate_required_option(
561    field: &'static str,
562    value: Option<&str>,
563) -> Result<(), ManifestValidationError> {
564    match value {
565        Some(value) => validate_nonempty(field, value),
566        None => Err(ManifestValidationError::EmptyField(field)),
567    }
568}
569
570// Validate textual principal fields used in JSON manifests.
571fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
572    validate_nonempty(field, value)?;
573    Principal::from_str(value)
574        .map(|_| ())
575        .map_err(|_| ManifestValidationError::InvalidPrincipal {
576            field,
577            value: value.to_string(),
578        })
579}
580
581// Validate optional textual principal fields used in JSON manifests.
582fn validate_optional_principal(
583    field: &'static str,
584    value: Option<&str>,
585) -> Result<(), ManifestValidationError> {
586    if let Some(value) = value {
587        validate_principal(field, value)?;
588    }
589    Ok(())
590}
591
592// Validate SHA-256 hex values used for topology and artifact compatibility.
593fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
594    const SHA256_HEX_LEN: usize = 64;
595    validate_nonempty(field, value)?;
596    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
597        Ok(())
598    } else {
599        Err(ManifestValidationError::InvalidHash(field))
600    }
601}
602
603// Validate optional SHA-256 hex values only when present.
604fn validate_optional_hash(
605    field: &'static str,
606    value: Option<&str>,
607) -> Result<(), ManifestValidationError> {
608    if let Some(value) = value {
609        validate_hash(field, value)?;
610    }
611    Ok(())
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    const ROOT: &str = "aaaaa-aa";
619    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
620    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
621
622    // Build one valid manifest for validation tests.
623    fn valid_manifest() -> FleetBackupManifest {
624        FleetBackupManifest {
625            manifest_version: 1,
626            backup_id: "fbk_test_001".to_string(),
627            created_at: "2026-04-10T12:00:00Z".to_string(),
628            tool: ToolMetadata {
629                name: "canic".to_string(),
630                version: "v1".to_string(),
631            },
632            source: SourceMetadata {
633                environment: "local".to_string(),
634                root_canister: ROOT.to_string(),
635            },
636            consistency: ConsistencySection {
637                mode: ConsistencyMode::QuiescedUnit,
638                backup_units: vec![BackupUnit {
639                    unit_id: "core".to_string(),
640                    kind: BackupUnitKind::Flat,
641                    roles: vec!["root".to_string(), "app".to_string()],
642                    consistency_reason: Some("root and app state are coordinated".to_string()),
643                    dependency_closure: vec!["root".to_string(), "app".to_string()],
644                    topology_validation: "operator-declared-flat".to_string(),
645                    quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
646                }],
647            },
648            fleet: FleetSection {
649                topology_hash_algorithm: "sha256".to_string(),
650                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
651                discovery_topology_hash: HASH.to_string(),
652                pre_snapshot_topology_hash: HASH.to_string(),
653                topology_hash: HASH.to_string(),
654                members: vec![
655                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
656                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
657                ],
658            },
659            verification: VerificationPlan {
660                fleet_checks: vec![VerificationCheck {
661                    kind: "root_ready".to_string(),
662                    method: None,
663                    roles: Vec::new(),
664                }],
665                member_checks: Vec::new(),
666            },
667        }
668    }
669
670    #[test]
671    fn valid_manifest_passes_validation() {
672        let manifest = valid_manifest();
673
674        manifest.validate().expect("manifest should validate");
675    }
676
677    // Ensure snapshot checksum provenance stays canonical when present.
678    #[test]
679    fn invalid_snapshot_checksum_fails_validation() {
680        let mut manifest = valid_manifest();
681        manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
682
683        let err = manifest
684            .validate()
685            .expect_err("invalid snapshot checksum should fail");
686
687        assert!(matches!(
688            err,
689            ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
690        ));
691    }
692
693    // Build one valid fleet member for manifest validation tests.
694    fn fleet_member(
695        role: &str,
696        canister_id: &str,
697        parent_canister_id: Option<&str>,
698        identity_mode: IdentityMode,
699    ) -> FleetMember {
700        FleetMember {
701            role: role.to_string(),
702            canister_id: canister_id.to_string(),
703            parent_canister_id: parent_canister_id.map(str::to_string),
704            subnet_canister_id: Some(CHILD.to_string()),
705            controller_hint: Some(ROOT.to_string()),
706            identity_mode,
707            restore_group: 1,
708            verification_class: "basic".to_string(),
709            verification_checks: vec![VerificationCheck {
710                kind: "call".to_string(),
711                method: Some("canic_ready".to_string()),
712                roles: Vec::new(),
713            }],
714            source_snapshot: SourceSnapshot {
715                snapshot_id: format!("snap-{role}"),
716                module_hash: Some(HASH.to_string()),
717                wasm_hash: Some(HASH.to_string()),
718                code_version: Some("v0.30.0".to_string()),
719                artifact_path: format!("artifacts/{role}"),
720                checksum_algorithm: "sha256".to_string(),
721                checksum: Some(HASH.to_string()),
722            },
723        }
724    }
725
726    #[test]
727    fn topology_hash_mismatch_fails_validation() {
728        let mut manifest = valid_manifest();
729        manifest.fleet.pre_snapshot_topology_hash =
730            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
731
732        let err = manifest.validate().expect_err("mismatch should fail");
733
734        assert!(matches!(
735            err,
736            ManifestValidationError::TopologyHashMismatch { .. }
737        ));
738    }
739
740    #[test]
741    fn missing_member_verification_checks_fail_validation() {
742        let mut manifest = valid_manifest();
743        manifest.fleet.members[0].verification_checks.clear();
744
745        let err = manifest
746            .validate()
747            .expect_err("missing member checks should fail");
748
749        assert!(matches!(
750            err,
751            ManifestValidationError::MissingMemberVerificationChecks(_)
752        ));
753    }
754
755    #[test]
756    fn quiesced_unit_requires_quiescence_strategy() {
757        let mut manifest = valid_manifest();
758        manifest.consistency.backup_units[0].quiescence_strategy = None;
759
760        let err = manifest
761            .validate()
762            .expect_err("missing quiescence strategy should fail");
763
764        assert!(matches!(err, ManifestValidationError::EmptyField(_)));
765    }
766
767    #[test]
768    fn backup_unit_roles_must_exist_in_fleet() {
769        let mut manifest = valid_manifest();
770        manifest.consistency.backup_units[0]
771            .roles
772            .push("missing-role".to_string());
773
774        let err = manifest
775            .validate()
776            .expect_err("unknown backup unit role should fail");
777
778        assert!(matches!(
779            err,
780            ManifestValidationError::UnknownBackupUnitRole { .. }
781        ));
782    }
783
784    #[test]
785    fn backup_unit_dependencies_must_exist_in_fleet() {
786        let mut manifest = valid_manifest();
787        manifest.consistency.backup_units[0]
788            .dependency_closure
789            .push("missing-dependency".to_string());
790
791        let err = manifest
792            .validate()
793            .expect_err("unknown backup unit dependency should fail");
794
795        assert!(matches!(
796            err,
797            ManifestValidationError::UnknownBackupUnitDependency { .. }
798        ));
799    }
800
801    #[test]
802    fn manifest_round_trips_through_json() {
803        let manifest = valid_manifest();
804
805        let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
806        let decoded: FleetBackupManifest =
807            serde_json::from_str(&encoded).expect("deserialize manifest");
808
809        decoded
810            .validate()
811            .expect("decoded manifest should validate");
812    }
813}