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