Skip to main content

canic_backup/manifest/
mod.rs

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