use candid::Principal;
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
str::FromStr,
};
use thiserror::Error as ThisError;
const SUPPORTED_MANIFEST_VERSION: u16 = 1;
const SHA256_ALGORITHM: &str = "sha256";
const DESIGN_V1: &str = "0.30-design-v1";
const TOPOLOGY_HASH_INPUT_V1: &str = "sorted(pid,parent_pid,role,module_hash)";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FleetBackupManifest {
pub manifest_version: u16,
pub backup_id: String,
pub created_at: String,
pub tool: ToolMetadata,
pub source: SourceMetadata,
pub consistency: ConsistencySection,
pub fleet: FleetSection,
pub verification: VerificationPlan,
}
impl FleetBackupManifest {
pub fn validate(&self) -> Result<(), ManifestValidationError> {
validate_manifest_version(self.manifest_version)?;
validate_nonempty("backup_id", &self.backup_id)?;
validate_nonempty("created_at", &self.created_at)?;
self.tool.validate()?;
self.source.validate()?;
self.consistency.validate()?;
self.fleet.validate()?;
self.verification.validate()?;
validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
validate_verification_against_fleet(&self.verification, &self.fleet)?;
Ok(())
}
#[must_use]
pub fn design_conformance_report(&self) -> ManifestDesignConformanceReport {
ManifestDesignConformanceReport::from_manifest(self)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ManifestDesignConformanceReport {
pub design_version: String,
pub design_v1_ready: bool,
pub topology: TopologyConformance,
pub backup_units: BackupUnitConformance,
pub quiescence: QuiescenceConformance,
pub verification: VerificationConformance,
pub identity: IdentityConformance,
pub snapshot_provenance: SnapshotProvenanceConformance,
pub restore_order: RestoreOrderConformance,
}
impl ManifestDesignConformanceReport {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let topology = TopologyConformance::from_manifest(manifest);
let backup_units = BackupUnitConformance::from_manifest(manifest);
let quiescence = QuiescenceConformance::from_manifest(manifest);
let verification = VerificationConformance::from_manifest(manifest);
let identity = IdentityConformance::from_manifest(manifest);
let snapshot_provenance = SnapshotProvenanceConformance::from_manifest(manifest);
let restore_order = RestoreOrderConformance::from_manifest(manifest);
let design_v1_ready = topology.design_v1_ready
&& backup_units.design_v1_ready
&& quiescence.design_v1_ready
&& verification.design_v1_ready
&& snapshot_provenance.design_v1_ready
&& restore_order.design_v1_ready;
Self {
design_version: DESIGN_V1.to_string(),
design_v1_ready,
topology,
backup_units,
quiescence,
verification,
identity,
snapshot_provenance,
restore_order,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct TopologyConformance {
pub design_v1_ready: bool,
pub algorithm_sha256: bool,
pub canonical_input: bool,
pub discovery_matches_pre_snapshot: bool,
pub accepted_matches_discovery: bool,
}
impl TopologyConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let algorithm_sha256 = manifest.fleet.topology_hash_algorithm == SHA256_ALGORITHM;
let canonical_input = manifest.fleet.topology_hash_input == TOPOLOGY_HASH_INPUT_V1;
let discovery_matches_pre_snapshot =
manifest.fleet.discovery_topology_hash == manifest.fleet.pre_snapshot_topology_hash;
let accepted_matches_discovery =
manifest.fleet.topology_hash == manifest.fleet.discovery_topology_hash;
let design_v1_ready = algorithm_sha256
&& canonical_input
&& discovery_matches_pre_snapshot
&& accepted_matches_discovery;
Self {
design_v1_ready,
algorithm_sha256,
canonical_input,
discovery_matches_pre_snapshot,
accepted_matches_discovery,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct BackupUnitConformance {
pub design_v1_ready: bool,
pub unit_count: usize,
pub all_units_have_roles: bool,
pub all_units_have_topology_validation: bool,
pub all_roles_covered: bool,
pub flat_units: usize,
pub flat_units_with_reason: usize,
pub subtree_units: usize,
pub subtree_units_declared_closed: usize,
}
impl BackupUnitConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let unit_count = manifest.consistency.backup_units.len();
let all_units_have_roles = manifest
.consistency
.backup_units
.iter()
.all(|unit| !unit.roles.is_empty());
let all_units_have_topology_validation = manifest
.consistency
.backup_units
.iter()
.all(|unit| !unit.topology_validation.trim().is_empty());
let all_roles_covered = all_fleet_roles_covered(manifest);
let flat_units = manifest
.consistency
.backup_units
.iter()
.filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
.count();
let flat_units_with_reason = manifest
.consistency
.backup_units
.iter()
.filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
.filter(|unit| {
unit.consistency_reason
.as_deref()
.is_some_and(|reason| !reason.trim().is_empty())
})
.count();
let subtree_units = manifest
.consistency
.backup_units
.iter()
.filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
.count();
let subtree_units_declared_closed = manifest
.consistency
.backup_units
.iter()
.filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
.filter(|unit| unit.topology_validation == "subtree-closed")
.count();
let design_v1_ready = unit_count > 0
&& all_units_have_roles
&& all_units_have_topology_validation
&& all_roles_covered
&& flat_units == flat_units_with_reason
&& subtree_units == subtree_units_declared_closed;
Self {
design_v1_ready,
unit_count,
all_units_have_roles,
all_units_have_topology_validation,
all_roles_covered,
flat_units,
flat_units_with_reason,
subtree_units,
subtree_units_declared_closed,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct QuiescenceConformance {
pub design_v1_ready: bool,
pub mode: ConsistencyMode,
pub quiescence_required: bool,
pub unit_count: usize,
pub units_with_strategy: usize,
pub all_required_units_have_strategy: bool,
}
impl QuiescenceConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let quiescence_required =
matches!(manifest.consistency.mode, ConsistencyMode::QuiescedUnit);
let unit_count = manifest.consistency.backup_units.len();
let units_with_strategy = manifest
.consistency
.backup_units
.iter()
.filter(|unit| {
unit.quiescence_strategy
.as_deref()
.is_some_and(|strategy| !strategy.trim().is_empty())
})
.count();
let all_required_units_have_strategy =
!quiescence_required || units_with_strategy == unit_count;
let design_v1_ready = all_required_units_have_strategy;
Self {
design_v1_ready,
mode: manifest.consistency.mode.clone(),
quiescence_required,
unit_count,
units_with_strategy,
all_required_units_have_strategy,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VerificationConformance {
pub design_v1_ready: bool,
pub member_count: usize,
pub members_with_checks: usize,
pub all_members_have_checks: bool,
pub fleet_check_count: usize,
pub role_check_group_count: usize,
}
impl VerificationConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let member_count = manifest.fleet.members.len();
let members_with_checks = manifest
.fleet
.members
.iter()
.filter(|member| !member.verification_checks.is_empty())
.count();
let all_members_have_checks = member_count == members_with_checks;
let fleet_check_count = manifest.verification.fleet_checks.len();
let role_check_group_count = manifest.verification.member_checks.len();
let design_v1_ready = member_count > 0 && all_members_have_checks;
Self {
design_v1_ready,
member_count,
members_with_checks,
all_members_have_checks,
fleet_check_count,
role_check_group_count,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IdentityConformance {
pub fixed_members: usize,
pub relocatable_members: usize,
}
impl IdentityConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let fixed_members = manifest
.fleet
.members
.iter()
.filter(|member| matches!(member.identity_mode, IdentityMode::Fixed))
.count();
let relocatable_members = manifest
.fleet
.members
.iter()
.filter(|member| matches!(member.identity_mode, IdentityMode::Relocatable))
.count();
Self {
fixed_members,
relocatable_members,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct SnapshotProvenanceConformance {
pub design_v1_ready: bool,
pub member_count: usize,
pub members_with_snapshot_id: usize,
pub members_with_checksum: usize,
pub members_with_module_hash: usize,
pub members_with_wasm_hash: usize,
pub members_with_code_version: usize,
pub all_members_have_snapshot_id: bool,
pub all_members_have_checksum: bool,
}
impl SnapshotProvenanceConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let member_count = manifest.fleet.members.len();
let members_with_snapshot_id = manifest
.fleet
.members
.iter()
.filter(|member| !member.source_snapshot.snapshot_id.trim().is_empty())
.count();
let members_with_checksum = manifest
.fleet
.members
.iter()
.filter(|member| member.source_snapshot.checksum.is_some())
.count();
let members_with_module_hash = manifest
.fleet
.members
.iter()
.filter(|member| member.source_snapshot.module_hash.is_some())
.count();
let members_with_wasm_hash = manifest
.fleet
.members
.iter()
.filter(|member| member.source_snapshot.wasm_hash.is_some())
.count();
let members_with_code_version = manifest
.fleet
.members
.iter()
.filter(|member| member.source_snapshot.code_version.is_some())
.count();
let all_members_have_snapshot_id = member_count == members_with_snapshot_id;
let all_members_have_checksum = member_count == members_with_checksum;
let design_v1_ready =
member_count > 0 && all_members_have_snapshot_id && all_members_have_checksum;
Self {
design_v1_ready,
member_count,
members_with_snapshot_id,
members_with_checksum,
members_with_module_hash,
members_with_wasm_hash,
members_with_code_version,
all_members_have_snapshot_id,
all_members_have_checksum,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RestoreOrderConformance {
pub design_v1_ready: bool,
pub parent_relationships: usize,
pub parent_group_violations: Vec<RestoreGroupViolation>,
}
impl RestoreOrderConformance {
fn from_manifest(manifest: &FleetBackupManifest) -> Self {
let members_by_id = manifest
.fleet
.members
.iter()
.map(|member| (member.canister_id.as_str(), member))
.collect::<BTreeMap<_, _>>();
let mut parent_relationships = 0;
let mut parent_group_violations = Vec::new();
for member in &manifest.fleet.members {
let Some(parent_id) = member.parent_canister_id.as_deref() else {
continue;
};
let Some(parent) = members_by_id.get(parent_id) else {
continue;
};
parent_relationships += 1;
if parent.restore_group > member.restore_group {
parent_group_violations.push(RestoreGroupViolation {
parent_canister_id: parent.canister_id.clone(),
child_canister_id: member.canister_id.clone(),
parent_restore_group: parent.restore_group,
child_restore_group: member.restore_group,
});
}
}
let design_v1_ready = parent_group_violations.is_empty();
Self {
design_v1_ready,
parent_relationships,
parent_group_violations,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RestoreGroupViolation {
pub parent_canister_id: String,
pub child_canister_id: String,
pub parent_restore_group: u16,
pub child_restore_group: u16,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ToolMetadata {
pub name: String,
pub version: String,
}
impl ToolMetadata {
pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty("tool.name", &self.name)?;
validate_nonempty("tool.version", &self.version)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SourceMetadata {
pub environment: String,
pub root_canister: String,
}
impl SourceMetadata {
fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty("source.environment", &self.environment)?;
validate_principal("source.root_canister", &self.root_canister)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ConsistencySection {
pub mode: ConsistencyMode,
pub backup_units: Vec<BackupUnit>,
}
impl ConsistencySection {
fn validate(&self) -> Result<(), ManifestValidationError> {
if self.backup_units.is_empty() {
return Err(ManifestValidationError::EmptyCollection(
"consistency.backup_units",
));
}
let mut unit_ids = BTreeSet::new();
for unit in &self.backup_units {
unit.validate(&self.mode)?;
if !unit_ids.insert(unit.unit_id.clone()) {
return Err(ManifestValidationError::DuplicateBackupUnitId(
unit.unit_id.clone(),
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConsistencyMode {
CrashConsistent,
QuiescedUnit,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BackupUnit {
pub unit_id: String,
pub kind: BackupUnitKind,
pub roles: Vec<String>,
pub consistency_reason: Option<String>,
pub dependency_closure: Vec<String>,
pub topology_validation: String,
pub quiescence_strategy: Option<String>,
}
impl BackupUnit {
fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
validate_nonempty(
"consistency.backup_units[].topology_validation",
&self.topology_validation,
)?;
if self.roles.is_empty() {
return Err(ManifestValidationError::EmptyCollection(
"consistency.backup_units[].roles",
));
}
for role in &self.roles {
validate_nonempty("consistency.backup_units[].roles[]", role)?;
}
validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
ManifestValidationError::DuplicateBackupUnitRole {
unit_id: self.unit_id.clone(),
role: role.to_string(),
}
})?;
for dependency in &self.dependency_closure {
validate_nonempty(
"consistency.backup_units[].dependency_closure[]",
dependency,
)?;
}
validate_unique_values(
"consistency.backup_units[].dependency_closure[]",
&self.dependency_closure,
|dependency| ManifestValidationError::DuplicateBackupUnitDependency {
unit_id: self.unit_id.clone(),
dependency: dependency.to_string(),
},
)?;
if matches!(self.kind, BackupUnitKind::Flat) {
validate_required_option(
"consistency.backup_units[].consistency_reason",
self.consistency_reason.as_deref(),
)?;
}
if matches!(mode, ConsistencyMode::QuiescedUnit) {
validate_required_option(
"consistency.backup_units[].quiescence_strategy",
self.quiescence_strategy.as_deref(),
)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BackupUnitKind {
WholeFleet,
ControlPlaneSubset,
SubtreeRooted,
Flat,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FleetSection {
pub topology_hash_algorithm: String,
pub topology_hash_input: String,
pub discovery_topology_hash: String,
pub pre_snapshot_topology_hash: String,
pub topology_hash: String,
pub members: Vec<FleetMember>,
}
impl FleetSection {
pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty(
"fleet.topology_hash_algorithm",
&self.topology_hash_algorithm,
)?;
if self.topology_hash_algorithm != SHA256_ALGORITHM {
return Err(ManifestValidationError::UnsupportedHashAlgorithm(
self.topology_hash_algorithm.clone(),
));
}
validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
validate_hash(
"fleet.discovery_topology_hash",
&self.discovery_topology_hash,
)?;
validate_hash(
"fleet.pre_snapshot_topology_hash",
&self.pre_snapshot_topology_hash,
)?;
validate_hash("fleet.topology_hash", &self.topology_hash)?;
if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
return Err(ManifestValidationError::TopologyHashMismatch {
discovery: self.discovery_topology_hash.clone(),
pre_snapshot: self.pre_snapshot_topology_hash.clone(),
});
}
if self.topology_hash != self.discovery_topology_hash {
return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
accepted: self.topology_hash.clone(),
discovery: self.discovery_topology_hash.clone(),
});
}
if self.members.is_empty() {
return Err(ManifestValidationError::EmptyCollection("fleet.members"));
}
let mut canister_ids = BTreeSet::new();
for member in &self.members {
member.validate()?;
if !canister_ids.insert(member.canister_id.clone()) {
return Err(ManifestValidationError::DuplicateCanisterId(
member.canister_id.clone(),
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FleetMember {
pub role: String,
pub canister_id: String,
pub parent_canister_id: Option<String>,
pub subnet_canister_id: Option<String>,
pub controller_hint: Option<String>,
pub identity_mode: IdentityMode,
pub restore_group: u16,
pub verification_class: String,
pub verification_checks: Vec<VerificationCheck>,
pub source_snapshot: SourceSnapshot,
}
impl FleetMember {
fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty("fleet.members[].role", &self.role)?;
validate_principal("fleet.members[].canister_id", &self.canister_id)?;
validate_optional_principal(
"fleet.members[].parent_canister_id",
self.parent_canister_id.as_deref(),
)?;
validate_optional_principal(
"fleet.members[].subnet_canister_id",
self.subnet_canister_id.as_deref(),
)?;
validate_optional_principal(
"fleet.members[].controller_hint",
self.controller_hint.as_deref(),
)?;
validate_nonempty(
"fleet.members[].verification_class",
&self.verification_class,
)?;
if self.verification_checks.is_empty() {
return Err(ManifestValidationError::MissingMemberVerificationChecks(
self.canister_id.clone(),
));
}
for check in &self.verification_checks {
check.validate()?;
}
self.source_snapshot.validate()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdentityMode {
Fixed,
Relocatable,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SourceSnapshot {
pub snapshot_id: String,
pub module_hash: Option<String>,
pub wasm_hash: Option<String>,
pub code_version: Option<String>,
pub artifact_path: String,
pub checksum_algorithm: String,
#[serde(default)]
pub checksum: Option<String>,
}
impl SourceSnapshot {
fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty(
"fleet.members[].source_snapshot.snapshot_id",
&self.snapshot_id,
)?;
validate_optional_nonempty(
"fleet.members[].source_snapshot.module_hash",
self.module_hash.as_deref(),
)?;
validate_optional_nonempty(
"fleet.members[].source_snapshot.wasm_hash",
self.wasm_hash.as_deref(),
)?;
validate_optional_nonempty(
"fleet.members[].source_snapshot.code_version",
self.code_version.as_deref(),
)?;
validate_nonempty(
"fleet.members[].source_snapshot.artifact_path",
&self.artifact_path,
)?;
validate_nonempty(
"fleet.members[].source_snapshot.checksum_algorithm",
&self.checksum_algorithm,
)?;
if self.checksum_algorithm != SHA256_ALGORITHM {
return Err(ManifestValidationError::UnsupportedHashAlgorithm(
self.checksum_algorithm.clone(),
));
}
validate_optional_hash(
"fleet.members[].source_snapshot.checksum",
self.checksum.as_deref(),
)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct VerificationPlan {
pub fleet_checks: Vec<VerificationCheck>,
pub member_checks: Vec<MemberVerificationChecks>,
}
impl VerificationPlan {
fn validate(&self) -> Result<(), ManifestValidationError> {
for check in &self.fleet_checks {
check.validate()?;
}
for member in &self.member_checks {
member.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MemberVerificationChecks {
pub role: String,
pub checks: Vec<VerificationCheck>,
}
impl MemberVerificationChecks {
fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty("verification.member_checks[].role", &self.role)?;
if self.checks.is_empty() {
return Err(ManifestValidationError::EmptyCollection(
"verification.member_checks[].checks",
));
}
for check in &self.checks {
check.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VerificationCheck {
pub kind: String,
pub method: Option<String>,
pub roles: Vec<String>,
}
impl VerificationCheck {
fn validate(&self) -> Result<(), ManifestValidationError> {
validate_nonempty("verification.check.kind", &self.kind)?;
validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
for role in &self.roles {
validate_nonempty("verification.check.roles[]", role)?;
}
validate_unique_values("verification.check.roles[]", &self.roles, |role| {
ManifestValidationError::DuplicateVerificationCheckRole {
kind: self.kind.clone(),
role: role.to_string(),
}
})?;
Ok(())
}
}
#[derive(Debug, ThisError)]
pub enum ManifestValidationError {
#[error("unsupported manifest version {0}")]
UnsupportedManifestVersion(u16),
#[error("field {0} must not be empty")]
EmptyField(&'static str),
#[error("collection {0} must not be empty")]
EmptyCollection(&'static str),
#[error("field {field} must be a valid principal: {value}")]
InvalidPrincipal { field: &'static str, value: String },
#[error("field {0} must be a non-empty sha256 hex string")]
InvalidHash(&'static str),
#[error("unsupported hash algorithm {0}")]
UnsupportedHashAlgorithm(String),
#[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
TopologyHashMismatch {
discovery: String,
pre_snapshot: String,
},
#[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
AcceptedTopologyHashMismatch { accepted: String, discovery: String },
#[error("duplicate canister id {0}")]
DuplicateCanisterId(String),
#[error("duplicate backup unit id {0}")]
DuplicateBackupUnitId(String),
#[error("backup unit {unit_id} repeats role {role}")]
DuplicateBackupUnitRole { unit_id: String, role: String },
#[error("backup unit {unit_id} repeats dependency {dependency}")]
DuplicateBackupUnitDependency { unit_id: String, dependency: String },
#[error("fleet member {0} has no concrete verification checks")]
MissingMemberVerificationChecks(String),
#[error("backup unit {unit_id} references unknown role {role}")]
UnknownBackupUnitRole { unit_id: String, role: String },
#[error("backup unit {unit_id} references unknown dependency {dependency}")]
UnknownBackupUnitDependency { unit_id: String, dependency: String },
#[error("fleet role {role} is not covered by any backup unit")]
BackupUnitCoverageMissingRole { role: String },
#[error("verification plan references unknown role {role}")]
UnknownVerificationRole { role: String },
#[error("duplicate member verification role {0}")]
DuplicateMemberVerificationRole(String),
#[error("verification check {kind} repeats role {role}")]
DuplicateVerificationCheckRole { kind: String, role: String },
#[error("whole-fleet backup unit {unit_id} omits fleet role {role}")]
WholeFleetUnitMissingRole { unit_id: String, role: String },
#[error("subtree backup unit {unit_id} is not connected")]
SubtreeBackupUnitNotConnected { unit_id: String },
#[error(
"subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
)]
SubtreeBackupUnitMissingDescendant {
unit_id: String,
parent: String,
descendant: String,
},
}
fn all_fleet_roles_covered(manifest: &FleetBackupManifest) -> bool {
let fleet_roles = manifest
.fleet
.members
.iter()
.map(|member| member.role.as_str())
.collect::<BTreeSet<_>>();
let covered_roles = manifest
.consistency
.backup_units
.iter()
.flat_map(|unit| unit.roles.iter().map(String::as_str))
.collect::<BTreeSet<_>>();
fleet_roles.iter().all(|role| covered_roles.contains(role))
}
fn validate_consistency_against_fleet(
consistency: &ConsistencySection,
fleet: &FleetSection,
) -> Result<(), ManifestValidationError> {
let fleet_roles = fleet
.members
.iter()
.map(|member| member.role.as_str())
.collect::<BTreeSet<_>>();
let mut covered_roles = BTreeSet::new();
for unit in &consistency.backup_units {
for role in &unit.roles {
if !fleet_roles.contains(role.as_str()) {
return Err(ManifestValidationError::UnknownBackupUnitRole {
unit_id: unit.unit_id.clone(),
role: role.clone(),
});
}
covered_roles.insert(role.as_str());
}
for dependency in &unit.dependency_closure {
if !fleet_roles.contains(dependency.as_str()) {
return Err(ManifestValidationError::UnknownBackupUnitDependency {
unit_id: unit.unit_id.clone(),
dependency: dependency.clone(),
});
}
}
validate_backup_unit_topology(unit, fleet, &fleet_roles)?;
}
for role in &fleet_roles {
if !covered_roles.contains(role) {
return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
role: (*role).to_string(),
});
}
}
Ok(())
}
fn validate_verification_against_fleet(
verification: &VerificationPlan,
fleet: &FleetSection,
) -> Result<(), ManifestValidationError> {
let fleet_roles = fleet
.members
.iter()
.map(|member| member.role.as_str())
.collect::<BTreeSet<_>>();
for check in &verification.fleet_checks {
validate_verification_check_roles(check, &fleet_roles)?;
}
for member in &fleet.members {
for check in &member.verification_checks {
validate_verification_check_roles(check, &fleet_roles)?;
}
}
let mut member_check_roles = BTreeSet::new();
for member in &verification.member_checks {
if !fleet_roles.contains(member.role.as_str()) {
return Err(ManifestValidationError::UnknownVerificationRole {
role: member.role.clone(),
});
}
if !member_check_roles.insert(member.role.as_str()) {
return Err(ManifestValidationError::DuplicateMemberVerificationRole(
member.role.clone(),
));
}
for check in &member.checks {
validate_verification_check_roles(check, &fleet_roles)?;
}
}
Ok(())
}
fn validate_verification_check_roles(
check: &VerificationCheck,
fleet_roles: &BTreeSet<&str>,
) -> Result<(), ManifestValidationError> {
for role in &check.roles {
if !fleet_roles.contains(role.as_str()) {
return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
}
}
Ok(())
}
fn validate_backup_unit_topology(
unit: &BackupUnit,
fleet: &FleetSection,
fleet_roles: &BTreeSet<&str>,
) -> Result<(), ManifestValidationError> {
match &unit.kind {
BackupUnitKind::WholeFleet => validate_whole_fleet_unit(unit, fleet_roles),
BackupUnitKind::SubtreeRooted => validate_subtree_unit(unit, fleet),
BackupUnitKind::ControlPlaneSubset | BackupUnitKind::Flat => Ok(()),
}
}
fn validate_whole_fleet_unit(
unit: &BackupUnit,
fleet_roles: &BTreeSet<&str>,
) -> Result<(), ManifestValidationError> {
let unit_roles = unit
.roles
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>();
for role in fleet_roles {
if !unit_roles.contains(role) {
return Err(ManifestValidationError::WholeFleetUnitMissingRole {
unit_id: unit.unit_id.clone(),
role: (*role).to_string(),
});
}
}
Ok(())
}
fn validate_subtree_unit(
unit: &BackupUnit,
fleet: &FleetSection,
) -> Result<(), ManifestValidationError> {
let unit_roles = unit
.roles
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>();
let members_by_id = fleet
.members
.iter()
.map(|member| (member.canister_id.as_str(), member))
.collect::<BTreeMap<_, _>>();
let unit_member_ids = fleet
.members
.iter()
.filter(|member| unit_roles.contains(member.role.as_str()))
.map(|member| member.canister_id.as_str())
.collect::<BTreeSet<_>>();
let root_count = fleet
.members
.iter()
.filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
.filter(|member| {
member
.parent_canister_id
.as_deref()
.is_none_or(|parent| !unit_member_ids.contains(parent))
})
.count();
if root_count != 1 {
return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
unit_id: unit.unit_id.clone(),
});
}
for member in &fleet.members {
if unit_member_ids.contains(member.canister_id.as_str()) {
continue;
}
if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
return Err(
ManifestValidationError::SubtreeBackupUnitMissingDescendant {
unit_id: unit.unit_id.clone(),
parent: parent.to_string(),
descendant: member.canister_id.clone(),
},
);
}
}
Ok(())
}
fn first_unit_ancestor<'a>(
member: &'a FleetMember,
members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
unit_member_ids: &BTreeSet<&'a str>,
) -> Option<&'a str> {
let mut visited = BTreeSet::new();
let mut parent = member.parent_canister_id.as_deref();
while let Some(parent_id) = parent {
if unit_member_ids.contains(parent_id) {
return Some(parent_id);
}
if !visited.insert(parent_id) {
return None;
}
parent = members_by_id
.get(parent_id)
.and_then(|ancestor| ancestor.parent_canister_id.as_deref());
}
None
}
const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
if version == SUPPORTED_MANIFEST_VERSION {
Ok(())
} else {
Err(ManifestValidationError::UnsupportedManifestVersion(version))
}
}
fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
if value.trim().is_empty() {
Err(ManifestValidationError::EmptyField(field))
} else {
Ok(())
}
}
fn validate_optional_nonempty(
field: &'static str,
value: Option<&str>,
) -> Result<(), ManifestValidationError> {
if let Some(value) = value {
validate_nonempty(field, value)?;
}
Ok(())
}
fn validate_required_option(
field: &'static str,
value: Option<&str>,
) -> Result<(), ManifestValidationError> {
match value {
Some(value) => validate_nonempty(field, value),
None => Err(ManifestValidationError::EmptyField(field)),
}
}
fn validate_unique_values<F>(
field: &'static str,
values: &[String],
error: F,
) -> Result<(), ManifestValidationError>
where
F: Fn(&str) -> ManifestValidationError,
{
let mut seen = BTreeSet::new();
for value in values {
validate_nonempty(field, value)?;
if !seen.insert(value.as_str()) {
return Err(error(value));
}
}
Ok(())
}
fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
validate_nonempty(field, value)?;
Principal::from_str(value)
.map(|_| ())
.map_err(|_| ManifestValidationError::InvalidPrincipal {
field,
value: value.to_string(),
})
}
fn validate_optional_principal(
field: &'static str,
value: Option<&str>,
) -> Result<(), ManifestValidationError> {
if let Some(value) = value {
validate_principal(field, value)?;
}
Ok(())
}
fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
const SHA256_HEX_LEN: usize = 64;
validate_nonempty(field, value)?;
if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(())
} else {
Err(ManifestValidationError::InvalidHash(field))
}
}
fn validate_optional_hash(
field: &'static str,
value: Option<&str>,
) -> Result<(), ManifestValidationError> {
if let Some(value) = value {
validate_hash(field, value)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const ROOT: &str = "aaaaa-aa";
const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn valid_manifest() -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: "fbk_test_001".to_string(),
created_at: "2026-04-10T12:00:00Z".to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "v1".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
mode: ConsistencyMode::QuiescedUnit,
backup_units: vec![BackupUnit {
unit_id: "core".to_string(),
kind: BackupUnitKind::Flat,
roles: vec!["root".to_string(), "app".to_string()],
consistency_reason: Some("root and app state are coordinated".to_string()),
dependency_closure: vec!["root".to_string(), "app".to_string()],
topology_validation: "operator-declared-flat".to_string(),
quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
}],
},
fleet: FleetSection {
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
topology_hash: HASH.to_string(),
members: vec![
fleet_member("root", ROOT, None, IdentityMode::Fixed),
fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
],
},
verification: VerificationPlan {
fleet_checks: vec![VerificationCheck {
kind: "root_ready".to_string(),
method: None,
roles: Vec::new(),
}],
member_checks: Vec::new(),
},
}
}
#[test]
fn valid_manifest_passes_validation() {
let manifest = valid_manifest();
manifest.validate().expect("manifest should validate");
}
#[test]
fn invalid_snapshot_checksum_fails_validation() {
let mut manifest = valid_manifest();
manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
let err = manifest
.validate()
.expect_err("invalid snapshot checksum should fail");
assert!(matches!(
err,
ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
));
}
fn fleet_member(
role: &str,
canister_id: &str,
parent_canister_id: Option<&str>,
identity_mode: IdentityMode,
) -> FleetMember {
FleetMember {
role: role.to_string(),
canister_id: canister_id.to_string(),
parent_canister_id: parent_canister_id.map(str::to_string),
subnet_canister_id: Some(CHILD.to_string()),
controller_hint: Some(ROOT.to_string()),
identity_mode,
restore_group: 1,
verification_class: "basic".to_string(),
verification_checks: vec![VerificationCheck {
kind: "call".to_string(),
method: Some("canic_ready".to_string()),
roles: Vec::new(),
}],
source_snapshot: SourceSnapshot {
snapshot_id: format!("snap-{role}"),
module_hash: Some(HASH.to_string()),
wasm_hash: Some(HASH.to_string()),
code_version: Some("v0.30.0".to_string()),
artifact_path: format!("artifacts/{role}"),
checksum_algorithm: "sha256".to_string(),
checksum: Some(HASH.to_string()),
},
}
}
#[test]
fn topology_hash_mismatch_fails_validation() {
let mut manifest = valid_manifest();
manifest.fleet.pre_snapshot_topology_hash =
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
let err = manifest.validate().expect_err("mismatch should fail");
assert!(matches!(
err,
ManifestValidationError::TopologyHashMismatch { .. }
));
}
#[test]
fn missing_member_verification_checks_fail_validation() {
let mut manifest = valid_manifest();
manifest.fleet.members[0].verification_checks.clear();
let err = manifest
.validate()
.expect_err("missing member checks should fail");
assert!(matches!(
err,
ManifestValidationError::MissingMemberVerificationChecks(_)
));
}
#[test]
fn quiesced_unit_requires_quiescence_strategy() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0].quiescence_strategy = None;
let err = manifest
.validate()
.expect_err("missing quiescence strategy should fail");
assert!(matches!(err, ManifestValidationError::EmptyField(_)));
}
#[test]
fn backup_unit_roles_must_exist_in_fleet() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0]
.roles
.push("missing-role".to_string());
let err = manifest
.validate()
.expect_err("unknown backup unit role should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownBackupUnitRole { .. }
));
}
#[test]
fn backup_unit_dependencies_must_exist_in_fleet() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0]
.dependency_closure
.push("missing-dependency".to_string());
let err = manifest
.validate()
.expect_err("unknown backup unit dependency should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownBackupUnitDependency { .. }
));
}
#[test]
fn backup_unit_ids_must_be_unique() {
let mut manifest = valid_manifest();
manifest
.consistency
.backup_units
.push(manifest.consistency.backup_units[0].clone());
let err = manifest
.validate()
.expect_err("duplicate unit IDs should fail");
assert!(matches!(
err,
ManifestValidationError::DuplicateBackupUnitId(_)
));
}
#[test]
fn backup_unit_roles_must_be_unique() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0]
.roles
.push("root".to_string());
let err = manifest
.validate()
.expect_err("duplicate backup unit role should fail");
assert!(matches!(
err,
ManifestValidationError::DuplicateBackupUnitRole { .. }
));
}
#[test]
fn backup_unit_dependencies_must_be_unique() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0]
.dependency_closure
.push("root".to_string());
let err = manifest
.validate()
.expect_err("duplicate backup unit dependency should fail");
assert!(matches!(
err,
ManifestValidationError::DuplicateBackupUnitDependency { .. }
));
}
#[test]
fn every_fleet_role_must_be_covered_by_a_backup_unit() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
manifest.consistency.backup_units[0].dependency_closure = vec!["root".to_string()];
let err = manifest
.validate()
.expect_err("uncovered app role should fail");
assert!(matches!(
err,
ManifestValidationError::BackupUnitCoverageMissingRole { .. }
));
}
#[test]
fn fleet_verification_roles_must_exist_in_fleet() {
let mut manifest = valid_manifest();
manifest.verification.fleet_checks[0]
.roles
.push("missing-role".to_string());
let err = manifest
.validate()
.expect_err("unknown fleet verification role should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownVerificationRole { .. }
));
}
#[test]
fn member_verification_check_roles_must_exist_in_fleet() {
let mut manifest = valid_manifest();
manifest.fleet.members[0].verification_checks[0]
.roles
.push("missing-role".to_string());
let err = manifest
.validate()
.expect_err("unknown member verification check role should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownVerificationRole { .. }
));
}
#[test]
fn verification_check_roles_must_be_unique() {
let mut manifest = valid_manifest();
manifest.verification.fleet_checks[0]
.roles
.push("root".to_string());
manifest.verification.fleet_checks[0]
.roles
.push("root".to_string());
let err = manifest
.validate()
.expect_err("duplicate verification role filter should fail");
assert!(matches!(
err,
ManifestValidationError::DuplicateVerificationCheckRole { .. }
));
}
#[test]
fn member_verification_group_roles_must_exist_in_fleet() {
let mut manifest = valid_manifest();
manifest
.verification
.member_checks
.push(MemberVerificationChecks {
role: "missing-role".to_string(),
checks: vec![VerificationCheck {
kind: "ready".to_string(),
method: None,
roles: Vec::new(),
}],
});
let err = manifest
.validate()
.expect_err("unknown member verification role should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownVerificationRole { .. }
));
}
#[test]
fn member_verification_group_roles_must_be_unique() {
let mut manifest = valid_manifest();
manifest
.verification
.member_checks
.push(member_verification_checks("root"));
manifest
.verification
.member_checks
.push(member_verification_checks("root"));
let err = manifest
.validate()
.expect_err("duplicate member verification role should fail");
assert!(matches!(
err,
ManifestValidationError::DuplicateMemberVerificationRole(_)
));
}
#[test]
fn nested_member_verification_roles_must_exist_in_fleet() {
let mut manifest = valid_manifest();
let mut checks = member_verification_checks("root");
checks.checks[0].roles.push("missing-role".to_string());
manifest.verification.member_checks.push(checks);
let err = manifest
.validate()
.expect_err("unknown nested verification role should fail");
assert!(matches!(
err,
ManifestValidationError::UnknownVerificationRole { .. }
));
}
#[test]
fn whole_fleet_unit_must_cover_all_roles() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0].kind = BackupUnitKind::WholeFleet;
manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
manifest.consistency.backup_units[0].consistency_reason = None;
let err = manifest
.validate()
.expect_err("whole-fleet unit missing app role should fail");
assert!(matches!(
err,
ManifestValidationError::WholeFleetUnitMissingRole { .. }
));
}
#[test]
fn subtree_unit_must_be_closed_under_descendants() {
let mut manifest = valid_manifest();
manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
manifest.consistency.backup_units[0].consistency_reason = None;
let err = manifest
.validate()
.expect_err("subtree unit omitting app child should fail");
assert!(matches!(
err,
ManifestValidationError::SubtreeBackupUnitMissingDescendant { .. }
));
}
#[test]
fn subtree_unit_must_be_connected() {
let mut manifest = valid_manifest();
manifest.fleet.members.push(fleet_member(
"worker",
"r7inp-6aaaa-aaaaa-aaabq-cai",
None,
IdentityMode::Relocatable,
));
manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
manifest.consistency.backup_units[0].roles = vec!["app".to_string(), "worker".to_string()];
manifest.consistency.backup_units[0].consistency_reason = None;
manifest.consistency.backup_units[0]
.dependency_closure
.push("worker".to_string());
let err = manifest
.validate()
.expect_err("disconnected subtree unit should fail");
assert!(matches!(
err,
ManifestValidationError::SubtreeBackupUnitNotConnected { .. }
));
}
#[test]
fn design_conformance_report_accepts_ready_manifest() {
let manifest = valid_manifest();
let report = manifest.design_conformance_report();
assert!(report.design_v1_ready);
assert_eq!(report.design_version, DESIGN_V1);
assert!(report.topology.design_v1_ready);
assert!(report.topology.canonical_input);
assert!(report.backup_units.design_v1_ready);
assert_eq!(report.backup_units.flat_units, 1);
assert_eq!(report.backup_units.flat_units_with_reason, 1);
assert!(report.quiescence.design_v1_ready);
assert!(report.quiescence.quiescence_required);
assert_eq!(report.verification.members_with_checks, 2);
assert_eq!(report.identity.fixed_members, 1);
assert_eq!(report.identity.relocatable_members, 1);
assert!(report.snapshot_provenance.all_members_have_checksum);
assert!(report.restore_order.design_v1_ready);
}
#[test]
fn design_conformance_report_flags_soft_gaps() {
let mut manifest = valid_manifest();
manifest.fleet.topology_hash_input = "legacy-input".to_string();
manifest.fleet.members[0].source_snapshot.checksum = None;
manifest.fleet.members[0].restore_group = 2;
manifest.fleet.members[1].restore_group = 1;
let report = manifest.design_conformance_report();
assert!(!report.design_v1_ready);
assert!(!report.topology.canonical_input);
assert!(!report.snapshot_provenance.all_members_have_checksum);
assert_eq!(report.restore_order.parent_group_violations.len(), 1);
assert_eq!(
report.restore_order.parent_group_violations[0].parent_canister_id,
ROOT
);
}
#[test]
fn manifest_round_trips_through_json() {
let manifest = valid_manifest();
let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
let decoded: FleetBackupManifest =
serde_json::from_str(&encoded).expect("deserialize manifest");
decoded
.validate()
.expect("decoded manifest should validate");
}
fn member_verification_checks(role: &str) -> MemberVerificationChecks {
MemberVerificationChecks {
role: role.to_string(),
checks: vec![VerificationCheck {
kind: "ready".to_string(),
method: None,
roles: Vec::new(),
}],
}
}
}