Skip to main content

canic_backup/discovery/
mod.rs

1use crate::{
2    manifest::{FleetMember, FleetSection, IdentityMode, SourceSnapshot, VerificationCheck},
3    topology::{TopologyHasher, TopologyRecord},
4};
5use std::collections::BTreeSet;
6use thiserror::Error as ThisError;
7
8///
9/// DiscoveredFleet
10///
11
12#[derive(Clone, Debug)]
13pub struct DiscoveredFleet {
14    pub topology_records: Vec<TopologyRecord>,
15    pub members: Vec<DiscoveredMember>,
16}
17
18impl DiscoveredFleet {
19    /// Convert discovered topology and member policy into a manifest fleet section.
20    pub fn into_fleet_section(self) -> Result<FleetSection, DiscoveryError> {
21        validate_discovered_members(&self.members)?;
22
23        let topology_hash = TopologyHasher::hash(&self.topology_records);
24        let members = self
25            .members
26            .into_iter()
27            .map(DiscoveredMember::into_fleet_member)
28            .collect();
29
30        Ok(FleetSection {
31            topology_hash_algorithm: topology_hash.algorithm,
32            topology_hash_input: topology_hash.input,
33            discovery_topology_hash: topology_hash.hash.clone(),
34            pre_snapshot_topology_hash: topology_hash.hash.clone(),
35            topology_hash: topology_hash.hash,
36            members,
37        })
38    }
39}
40
41///
42/// DiscoveredMember
43///
44
45#[derive(Clone, Debug)]
46pub struct DiscoveredMember {
47    pub role: String,
48    pub canister_id: String,
49    pub parent_canister_id: Option<String>,
50    pub subnet_canister_id: Option<String>,
51    pub controller_hint: Option<String>,
52    pub identity_mode: IdentityMode,
53    pub restore_group: u16,
54    pub verification_class: String,
55    pub verification_checks: Vec<VerificationCheck>,
56    pub snapshot_plan: SnapshotPlan,
57}
58
59impl DiscoveredMember {
60    /// Project this discovery member into the manifest restore contract.
61    fn into_fleet_member(self) -> FleetMember {
62        FleetMember {
63            role: self.role,
64            canister_id: self.canister_id,
65            parent_canister_id: self.parent_canister_id,
66            subnet_canister_id: self.subnet_canister_id,
67            controller_hint: self.controller_hint,
68            identity_mode: self.identity_mode,
69            restore_group: self.restore_group,
70            verification_class: self.verification_class,
71            verification_checks: self.verification_checks,
72            source_snapshot: SourceSnapshot {
73                snapshot_id: self.snapshot_plan.snapshot_id,
74                module_hash: self.snapshot_plan.module_hash,
75                wasm_hash: self.snapshot_plan.wasm_hash,
76                code_version: self.snapshot_plan.code_version,
77                artifact_path: self.snapshot_plan.artifact_path,
78                checksum_algorithm: self.snapshot_plan.checksum_algorithm,
79            },
80        }
81    }
82}
83
84///
85/// SnapshotPlan
86///
87
88#[derive(Clone, Debug)]
89pub struct SnapshotPlan {
90    pub snapshot_id: String,
91    pub module_hash: Option<String>,
92    pub wasm_hash: Option<String>,
93    pub code_version: Option<String>,
94    pub artifact_path: String,
95    pub checksum_algorithm: String,
96}
97
98///
99/// DiscoveryError
100///
101
102#[derive(Debug, ThisError)]
103pub enum DiscoveryError {
104    #[error("discovered fleet has no members")]
105    EmptyFleet,
106
107    #[error("duplicate discovered canister id {0}")]
108    DuplicateCanisterId(String),
109
110    #[error("discovered member {0} has no verification checks")]
111    MissingVerificationChecks(String),
112}
113
114// Validate discovery output before building a manifest projection.
115fn validate_discovered_members(members: &[DiscoveredMember]) -> Result<(), DiscoveryError> {
116    if members.is_empty() {
117        return Err(DiscoveryError::EmptyFleet);
118    }
119
120    let mut canister_ids = BTreeSet::new();
121    for member in members {
122        if !canister_ids.insert(member.canister_id.clone()) {
123            return Err(DiscoveryError::DuplicateCanisterId(
124                member.canister_id.clone(),
125            ));
126        }
127        if member.verification_checks.is_empty() {
128            return Err(DiscoveryError::MissingVerificationChecks(
129                member.canister_id.clone(),
130            ));
131        }
132    }
133
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use candid::Principal;
141
142    const ROOT: Principal = Principal::from_slice(&[]);
143    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
144
145    // Build a deterministic non-root principal for discovery tests.
146    fn p(id: u8) -> Principal {
147        Principal::from_slice(&[id; 29])
148    }
149
150    // Ensure discovery projections produce valid manifest fleet sections.
151    #[test]
152    fn discovery_projects_to_valid_fleet_section() {
153        let fleet = DiscoveredFleet {
154            topology_records: vec![
155                topology_record(ROOT, None, "root"),
156                topology_record(p(2), Some(ROOT), "app"),
157            ],
158            members: vec![
159                discovered_member("root", ROOT.to_string(), None),
160                discovered_member("app", p(2).to_string(), Some(ROOT.to_string())),
161            ],
162        };
163
164        let section = fleet
165            .into_fleet_section()
166            .expect("discovery should project");
167
168        section.validate().expect("fleet section should validate");
169        assert_eq!(section.discovery_topology_hash, section.topology_hash);
170        assert_eq!(section.members.len(), 2);
171    }
172
173    // Ensure duplicate canisters are rejected before manifest projection.
174    #[test]
175    fn discovery_rejects_duplicate_canisters() {
176        let fleet = DiscoveredFleet {
177            topology_records: vec![topology_record(ROOT, None, "root")],
178            members: vec![
179                discovered_member("root", ROOT.to_string(), None),
180                discovered_member("root", ROOT.to_string(), None),
181            ],
182        };
183
184        let err = fleet
185            .into_fleet_section()
186            .expect_err("duplicate canisters should fail");
187
188        assert!(matches!(err, DiscoveryError::DuplicateCanisterId(_)));
189    }
190
191    // Ensure discovery requires concrete member verification.
192    #[test]
193    fn discovery_requires_verification_checks() {
194        let mut member = discovered_member("root", ROOT.to_string(), None);
195        member.verification_checks.clear();
196        let fleet = DiscoveredFleet {
197            topology_records: vec![topology_record(ROOT, None, "root")],
198            members: vec![member],
199        };
200
201        let err = fleet
202            .into_fleet_section()
203            .expect_err("missing verification should fail");
204
205        assert!(matches!(err, DiscoveryError::MissingVerificationChecks(_)));
206    }
207
208    // Build one topology record for tests.
209    fn topology_record(
210        pid: Principal,
211        parent_pid: Option<Principal>,
212        role: &str,
213    ) -> TopologyRecord {
214        TopologyRecord {
215            pid,
216            parent_pid,
217            role: role.to_string(),
218            module_hash: None,
219        }
220    }
221
222    // Build one discovered member for tests.
223    fn discovered_member(
224        role: &str,
225        canister_id: String,
226        parent_canister_id: Option<String>,
227    ) -> DiscoveredMember {
228        DiscoveredMember {
229            role: role.to_string(),
230            canister_id,
231            parent_canister_id,
232            subnet_canister_id: None,
233            controller_hint: Some(ROOT.to_string()),
234            identity_mode: IdentityMode::Fixed,
235            restore_group: 1,
236            verification_class: "basic".to_string(),
237            verification_checks: vec![VerificationCheck {
238                kind: "call".to_string(),
239                method: Some("canic_ready".to_string()),
240                roles: Vec::new(),
241            }],
242            snapshot_plan: SnapshotPlan {
243                snapshot_id: format!("snap-{role}"),
244                module_hash: Some(HASH.to_string()),
245                wasm_hash: Some(HASH.to_string()),
246                code_version: Some("v0.30.0".to_string()),
247                artifact_path: format!("artifacts/{role}"),
248                checksum_algorithm: "sha256".to_string(),
249            },
250        }
251    }
252}