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 wasm_hash: Option<String>,
350    pub code_version: Option<String>,
351    pub artifact_path: String,
352    pub checksum_algorithm: String,
353    #[serde(default)]
354    pub checksum: Option<String>,
355}
356
357impl SourceSnapshot {
358    /// Validate source snapshot provenance and artifact checksum metadata.
359    fn validate(&self) -> Result<(), ManifestValidationError> {
360        validate_nonempty(
361            "fleet.members[].source_snapshot.snapshot_id",
362            &self.snapshot_id,
363        )?;
364        validate_optional_nonempty(
365            "fleet.members[].source_snapshot.module_hash",
366            self.module_hash.as_deref(),
367        )?;
368        validate_optional_nonempty(
369            "fleet.members[].source_snapshot.wasm_hash",
370            self.wasm_hash.as_deref(),
371        )?;
372        validate_optional_nonempty(
373            "fleet.members[].source_snapshot.code_version",
374            self.code_version.as_deref(),
375        )?;
376        validate_nonempty(
377            "fleet.members[].source_snapshot.artifact_path",
378            &self.artifact_path,
379        )?;
380        validate_nonempty(
381            "fleet.members[].source_snapshot.checksum_algorithm",
382            &self.checksum_algorithm,
383        )?;
384        if self.checksum_algorithm != SHA256_ALGORITHM {
385            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
386                self.checksum_algorithm.clone(),
387            ));
388        }
389        validate_optional_hash(
390            "fleet.members[].source_snapshot.checksum",
391            self.checksum.as_deref(),
392        )?;
393        Ok(())
394    }
395}
396
397///
398/// VerificationPlan
399///
400
401#[derive(Clone, Debug, Default, Deserialize, Serialize)]
402pub struct VerificationPlan {
403    pub fleet_checks: Vec<VerificationCheck>,
404    pub member_checks: Vec<MemberVerificationChecks>,
405}
406
407impl VerificationPlan {
408    /// Validate all declarative verification checks.
409    fn validate(&self) -> Result<(), ManifestValidationError> {
410        for check in &self.fleet_checks {
411            check.validate()?;
412        }
413        for member in &self.member_checks {
414            member.validate()?;
415        }
416        Ok(())
417    }
418}
419
420///
421/// MemberVerificationChecks
422///
423
424#[derive(Clone, Debug, Deserialize, Serialize)]
425pub struct MemberVerificationChecks {
426    pub role: String,
427    pub checks: Vec<VerificationCheck>,
428}
429
430impl MemberVerificationChecks {
431    /// Validate one role-scoped verification check group.
432    fn validate(&self) -> Result<(), ManifestValidationError> {
433        validate_nonempty("verification.member_checks[].role", &self.role)?;
434        if self.checks.is_empty() {
435            return Err(ManifestValidationError::EmptyCollection(
436                "verification.member_checks[].checks",
437            ));
438        }
439        for check in &self.checks {
440            check.validate()?;
441        }
442        Ok(())
443    }
444}
445
446///
447/// VerificationCheck
448///
449
450#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
451pub struct VerificationCheck {
452    pub kind: String,
453    pub roles: Vec<String>,
454}
455
456impl VerificationCheck {
457    /// Validate one concrete verification check.
458    fn validate(&self) -> Result<(), ManifestValidationError> {
459        validate_nonempty("verification.check.kind", &self.kind)?;
460        if self.kind != "status" {
461            return Err(ManifestValidationError::UnsupportedVerificationKind(
462                self.kind.clone(),
463            ));
464        }
465        for role in &self.roles {
466            validate_nonempty("verification.check.roles[]", role)?;
467        }
468        validate_unique_values("verification.check.roles[]", &self.roles, |role| {
469            ManifestValidationError::DuplicateVerificationCheckRole {
470                kind: self.kind.clone(),
471                role: role.to_string(),
472            }
473        })?;
474        Ok(())
475    }
476}
477
478///
479/// ManifestValidationError
480///
481
482#[derive(Debug, ThisError)]
483pub enum ManifestValidationError {
484    #[error("unsupported manifest version {0}")]
485    UnsupportedManifestVersion(u16),
486
487    #[error("field {0} must not be empty")]
488    EmptyField(&'static str),
489
490    #[error("collection {0} must not be empty")]
491    EmptyCollection(&'static str),
492
493    #[error("field {field} must be a valid principal: {value}")]
494    InvalidPrincipal { field: &'static str, value: String },
495
496    #[error("field {0} must be a non-empty sha256 hex string")]
497    InvalidHash(&'static str),
498
499    #[error("unsupported hash algorithm {0}")]
500    UnsupportedHashAlgorithm(String),
501
502    #[error("unsupported verification kind {0}")]
503    UnsupportedVerificationKind(String),
504
505    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
506    TopologyHashMismatch {
507        discovery: String,
508        pre_snapshot: String,
509    },
510
511    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
512    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
513
514    #[error("duplicate canister id {0}")]
515    DuplicateCanisterId(String),
516
517    #[error("duplicate backup unit id {0}")]
518    DuplicateBackupUnitId(String),
519
520    #[error("backup unit {unit_id} repeats role {role}")]
521    DuplicateBackupUnitRole { unit_id: String, role: String },
522
523    #[error("fleet member {0} has no concrete verification checks")]
524    MissingMemberVerificationChecks(String),
525
526    #[error("backup unit {unit_id} references unknown role {role}")]
527    UnknownBackupUnitRole { unit_id: String, role: String },
528
529    #[error("fleet role {role} is not covered by any backup unit")]
530    BackupUnitCoverageMissingRole { role: String },
531
532    #[error("verification plan references unknown role {role}")]
533    UnknownVerificationRole { role: String },
534
535    #[error("duplicate member verification role {0}")]
536    DuplicateMemberVerificationRole(String),
537
538    #[error("verification check {kind} repeats role {role}")]
539    DuplicateVerificationCheckRole { kind: String, role: String },
540
541    #[error("subtree backup unit {unit_id} is not connected")]
542    SubtreeBackupUnitNotConnected { unit_id: String },
543
544    #[error(
545        "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
546    )]
547    SubtreeBackupUnitMissingDescendant {
548        unit_id: String,
549        parent: String,
550        descendant: String,
551    },
552}
553
554// Validate cross-section backup unit references after local section checks pass.
555fn validate_consistency_against_fleet(
556    consistency: &ConsistencySection,
557    fleet: &FleetSection,
558) -> Result<(), ManifestValidationError> {
559    let fleet_roles = fleet
560        .members
561        .iter()
562        .map(|member| member.role.as_str())
563        .collect::<BTreeSet<_>>();
564    let mut covered_roles = BTreeSet::new();
565
566    for unit in &consistency.backup_units {
567        for role in &unit.roles {
568            if !fleet_roles.contains(role.as_str()) {
569                return Err(ManifestValidationError::UnknownBackupUnitRole {
570                    unit_id: unit.unit_id.clone(),
571                    role: role.clone(),
572                });
573            }
574            covered_roles.insert(role.as_str());
575        }
576
577        validate_backup_unit_topology(unit, fleet)?;
578    }
579
580    for role in &fleet_roles {
581        if !covered_roles.contains(role) {
582            return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
583                role: (*role).to_string(),
584            });
585        }
586    }
587
588    Ok(())
589}
590
591// Validate verification role references after fleet roles are known.
592fn validate_verification_against_fleet(
593    verification: &VerificationPlan,
594    fleet: &FleetSection,
595) -> Result<(), ManifestValidationError> {
596    let fleet_roles = fleet
597        .members
598        .iter()
599        .map(|member| member.role.as_str())
600        .collect::<BTreeSet<_>>();
601
602    for check in &verification.fleet_checks {
603        validate_verification_check_roles(check, &fleet_roles)?;
604    }
605
606    for member in &fleet.members {
607        for check in &member.verification_checks {
608            validate_verification_check_roles(check, &fleet_roles)?;
609        }
610    }
611
612    let mut member_check_roles = BTreeSet::new();
613    for member in &verification.member_checks {
614        if !fleet_roles.contains(member.role.as_str()) {
615            return Err(ManifestValidationError::UnknownVerificationRole {
616                role: member.role.clone(),
617            });
618        }
619        if !member_check_roles.insert(member.role.as_str()) {
620            return Err(ManifestValidationError::DuplicateMemberVerificationRole(
621                member.role.clone(),
622            ));
623        }
624        for check in &member.checks {
625            validate_verification_check_roles(check, &fleet_roles)?;
626        }
627    }
628
629    Ok(())
630}
631
632// Validate every role filter in one verification check.
633fn validate_verification_check_roles(
634    check: &VerificationCheck,
635    fleet_roles: &BTreeSet<&str>,
636) -> Result<(), ManifestValidationError> {
637    for role in &check.roles {
638        if !fleet_roles.contains(role.as_str()) {
639            return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
640        }
641    }
642
643    Ok(())
644}
645
646// Validate backup unit topology promises against manifest membership.
647fn validate_backup_unit_topology(
648    unit: &BackupUnit,
649    fleet: &FleetSection,
650) -> Result<(), ManifestValidationError> {
651    match &unit.kind {
652        BackupUnitKind::Subtree => validate_subtree_unit(unit, fleet),
653        BackupUnitKind::Single => Ok(()),
654    }
655}
656
657// Ensure subtree units are connected and closed under descendants.
658fn validate_subtree_unit(
659    unit: &BackupUnit,
660    fleet: &FleetSection,
661) -> Result<(), ManifestValidationError> {
662    let unit_roles = unit
663        .roles
664        .iter()
665        .map(String::as_str)
666        .collect::<BTreeSet<_>>();
667    let members_by_id = fleet
668        .members
669        .iter()
670        .map(|member| (member.canister_id.as_str(), member))
671        .collect::<BTreeMap<_, _>>();
672    let unit_member_ids = fleet
673        .members
674        .iter()
675        .filter(|member| unit_roles.contains(member.role.as_str()))
676        .map(|member| member.canister_id.as_str())
677        .collect::<BTreeSet<_>>();
678
679    let root_count = fleet
680        .members
681        .iter()
682        .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
683        .filter(|member| {
684            member
685                .parent_canister_id
686                .as_deref()
687                .is_none_or(|parent| !unit_member_ids.contains(parent))
688        })
689        .count();
690    if root_count != 1 {
691        return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
692            unit_id: unit.unit_id.clone(),
693        });
694    }
695
696    for member in &fleet.members {
697        if unit_member_ids.contains(member.canister_id.as_str()) {
698            continue;
699        }
700
701        if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
702            return Err(
703                ManifestValidationError::SubtreeBackupUnitMissingDescendant {
704                    unit_id: unit.unit_id.clone(),
705                    parent: parent.to_string(),
706                    descendant: member.canister_id.clone(),
707                },
708            );
709        }
710    }
711
712    Ok(())
713}
714
715// Return the nearest selected ancestor for a member outside a subtree unit.
716fn first_unit_ancestor<'a>(
717    member: &'a FleetMember,
718    members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
719    unit_member_ids: &BTreeSet<&'a str>,
720) -> Option<&'a str> {
721    let mut visited = BTreeSet::new();
722    let mut parent = member.parent_canister_id.as_deref();
723    while let Some(parent_id) = parent {
724        if unit_member_ids.contains(parent_id) {
725            return Some(parent_id);
726        }
727        if !visited.insert(parent_id) {
728            return None;
729        }
730        parent = members_by_id
731            .get(parent_id)
732            .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
733    }
734
735    None
736}
737
738// Validate the manifest format version before checking nested fields.
739const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
740    if version == SUPPORTED_MANIFEST_VERSION {
741        Ok(())
742    } else {
743        Err(ManifestValidationError::UnsupportedManifestVersion(version))
744    }
745}
746
747// Validate required string fields after trimming whitespace.
748fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
749    if value.trim().is_empty() {
750        Err(ManifestValidationError::EmptyField(field))
751    } else {
752        Ok(())
753    }
754}
755
756// Validate optional string fields only when present.
757fn validate_optional_nonempty(
758    field: &'static str,
759    value: Option<&str>,
760) -> Result<(), ManifestValidationError> {
761    if let Some(value) = value {
762        validate_nonempty(field, value)?;
763    }
764    Ok(())
765}
766
767// Validate that a string list does not repeat values.
768fn validate_unique_values<F>(
769    field: &'static str,
770    values: &[String],
771    error: F,
772) -> Result<(), ManifestValidationError>
773where
774    F: Fn(&str) -> ManifestValidationError,
775{
776    let mut seen = BTreeSet::new();
777    for value in values {
778        validate_nonempty(field, value)?;
779        if !seen.insert(value.as_str()) {
780            return Err(error(value));
781        }
782    }
783
784    Ok(())
785}
786
787// Validate textual principal fields used in JSON manifests.
788fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
789    validate_nonempty(field, value)?;
790    Principal::from_str(value)
791        .map(|_| ())
792        .map_err(|_| ManifestValidationError::InvalidPrincipal {
793            field,
794            value: value.to_string(),
795        })
796}
797
798// Validate optional textual principal fields used in JSON manifests.
799fn validate_optional_principal(
800    field: &'static str,
801    value: Option<&str>,
802) -> Result<(), ManifestValidationError> {
803    if let Some(value) = value {
804        validate_principal(field, value)?;
805    }
806    Ok(())
807}
808
809// Validate SHA-256 hex values used for topology and artifact compatibility.
810fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
811    const SHA256_HEX_LEN: usize = 64;
812    validate_nonempty(field, value)?;
813    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
814        Ok(())
815    } else {
816        Err(ManifestValidationError::InvalidHash(field))
817    }
818}
819
820// Validate optional SHA-256 hex values only when present.
821fn validate_optional_hash(
822    field: &'static str,
823    value: Option<&str>,
824) -> Result<(), ManifestValidationError> {
825    if let Some(value) = value {
826        validate_hash(field, value)?;
827    }
828    Ok(())
829}
830
831#[cfg(test)]
832mod tests;