Skip to main content

canic_host/install_root/operations/
activation.rs

1use super::super::commands::{
2    add_icp_environment_target, icp_canister_command, root_init_args, run_command,
3};
4use super::phase::{InstallPhaseLabel, InstallPhaseOperation};
5use crate::icp::{
6    IcpCanisterStatusReport, IcpCli, LocalReplicaTarget, decode_json_result_response,
7};
8use canic_core::{
9    dto::fleet_activation::{
10        CurrentRootInstallIdentity, FleetActivationIdentity, FleetActivationPhase,
11        FleetActivationStatusResponse,
12    },
13    protocol,
14};
15use sha2::{Digest, Sha256};
16use std::{
17    fs,
18    path::{Path, PathBuf},
19};
20use thiserror::Error as ThisError;
21
22/// Typed failure while proving the module installed on the root Canister.
23#[derive(Debug, ThisError)]
24pub enum InstallRootModuleVerificationError {
25    /// ICP status returned no installed module identity.
26    #[error("installed root status does not contain a module hash")]
27    Missing,
28
29    /// ICP status returned a value that is not one 32-byte hexadecimal digest.
30    #[error("installed root status contains an invalid module hash: {observed}")]
31    Invalid { observed: String },
32
33    /// The installed module identity differs from the exact Wasm supplied to ICP.
34    #[error("installed root module hash {observed} does not match expected Wasm hash {expected}")]
35    Mismatch { expected: String, observed: String },
36}
37
38#[derive(Debug, ThisError)]
39#[error(
40    "root install command failed and the exact Prepared postcondition could not be reconciled: operation={operation}; reconciliation={reconciliation}"
41)]
42pub struct InstallRootExecutionReconciliationError {
43    #[source]
44    operation: Box<dyn std::error::Error>,
45    reconciliation: Box<dyn std::error::Error>,
46}
47
48impl InstallRootExecutionReconciliationError {
49    #[must_use]
50    pub fn operation_error(&self) -> &(dyn std::error::Error + 'static) {
51        self.operation.as_ref()
52    }
53
54    #[must_use]
55    pub fn reconciliation_error(&self) -> &(dyn std::error::Error + 'static) {
56        self.reconciliation.as_ref()
57    }
58}
59
60/// Typed rejection for a root status that cannot prove the exact initial Prepared state.
61#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
62pub enum InstallRootActivationStatusError {
63    #[error("installed root Fleet activation phase is {observed:?}, expected Prepared")]
64    Phase { observed: FleetActivationPhase },
65
66    #[error("installed root Fleet activation identity differs from the journalled identity")]
67    Identity,
68
69    #[error("installed root initial Prepared state already contains activation evidence")]
70    Evidence,
71}
72
73pub(in crate::install_root) struct InstallRootWasmOperation<'a> {
74    icp_root: &'a Path,
75    environment: &'a str,
76    root_canister_id: &'a str,
77    root_wasm: PathBuf,
78    expected_module_hash: [u8; 32],
79    init_identity: CurrentRootInstallIdentity,
80    local_replica: Option<&'a LocalReplicaTarget>,
81}
82
83impl<'a> InstallRootWasmOperation<'a> {
84    pub(in crate::install_root) fn new(
85        icp_root: &'a Path,
86        environment: &'a str,
87        root_canister_id: &'a str,
88        root_wasm: PathBuf,
89        activation_identity: &FleetActivationIdentity,
90        local_replica: Option<&'a LocalReplicaTarget>,
91    ) -> Result<Self, Box<dyn std::error::Error>> {
92        let expected_module_hash = Sha256::digest(fs::read(&root_wasm)?).into();
93        let init_identity = CurrentRootInstallIdentity {
94            fleet: activation_identity.fleet.clone(),
95            install_id: activation_identity.operation_id,
96            release_build_id: activation_identity.release_build_id,
97            expected_module_hash: Some(expected_module_hash),
98        };
99        Ok(Self {
100            icp_root,
101            environment,
102            root_canister_id,
103            root_wasm,
104            expected_module_hash,
105            init_identity,
106            local_replica,
107        })
108    }
109
110    fn icp(&self) -> IcpCli {
111        IcpCli::new("icp", Some(self.environment.to_string()))
112            .with_cwd(self.icp_root)
113            .with_local_replica(self.local_replica.cloned())
114    }
115
116    fn observed_module_hash(&self) -> Result<Option<[u8; 32]>, Box<dyn std::error::Error>> {
117        let report = self.icp().canister_status_report(self.root_canister_id)?;
118        report
119            .module_hash
120            .as_deref()
121            .map(|observed| {
122                parse_module_hash(observed).ok_or_else(|| {
123                    InstallRootModuleVerificationError::Invalid {
124                        observed: observed.to_string(),
125                    }
126                    .into()
127                })
128            })
129            .transpose()
130    }
131
132    fn observe_exact_prepared(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
133        let report = self.icp().canister_status_report(self.root_canister_id)?;
134        let mut evidence = verified_root_module_evidence(
135            self.root_canister_id,
136            &self.root_wasm,
137            self.expected_module_hash,
138            &report,
139        )?;
140        let output = self.icp().canister_query_output_with_candid(
141            self.root_canister_id,
142            protocol::CANIC_FLEET_ACTIVATION_STATUS,
143            Some("json"),
144            None,
145        )?;
146        let status = decode_json_result_response::<FleetActivationStatusResponse>(&output)?;
147        validate_initial_prepared_status(&self.init_identity, &status)?;
148        evidence.extend([
149            format!(
150                "canonical_network_id:{}",
151                status.identity.fleet.fleet.canonical_network_id
152            ),
153            format!("app:{}", status.identity.fleet.app),
154            format!("fleet_id:{}", status.identity.fleet.fleet.fleet_id),
155            format!(
156                "activation_operation_id:{}",
157                digest_text(status.identity.operation_id)
158            ),
159            format!("release_build_id:{}", status.identity.release_build_id),
160            "fleet_activation_phase:prepared".to_string(),
161        ]);
162        Ok(evidence)
163    }
164}
165
166impl InstallPhaseOperation for InstallRootWasmOperation<'_> {
167    fn phase(&self) -> InstallPhaseLabel {
168        InstallPhaseLabel::INSTALL_ROOT
169    }
170
171    fn attempted_action(&self) -> &'static str {
172        "install root wasm"
173    }
174
175    fn evidence(&self) -> Vec<String> {
176        vec![
177            format!("root_canister:{}", self.root_canister_id),
178            format!("root_wasm:{}", self.root_wasm.display()),
179            format!(
180                "expected_module_hash:{}",
181                module_hash_text(self.expected_module_hash)
182            ),
183        ]
184    }
185
186    fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
187        reinstall_root_wasm(
188            self.icp_root,
189            self.environment,
190            self.root_canister_id,
191            &self.root_wasm,
192            &self.init_identity,
193            self.local_replica,
194        )
195    }
196
197    fn verified_evidence(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
198        self.observe_exact_prepared()
199    }
200
201    fn execute_and_verify(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
202        if self.observed_module_hash()? == Some(self.expected_module_hash) {
203            return self.observe_exact_prepared();
204        }
205
206        match self.execute() {
207            Ok(()) => self.observe_exact_prepared(),
208            Err(operation) => match self.observe_exact_prepared() {
209                Ok(evidence) => Ok(evidence),
210                Err(reconciliation) => Err(Box::new(InstallRootExecutionReconciliationError {
211                    operation,
212                    reconciliation,
213                })),
214            },
215        }
216    }
217}
218
219fn verified_root_module_evidence(
220    root_canister_id: &str,
221    root_wasm: &Path,
222    expected_module_hash: [u8; 32],
223    report: &IcpCanisterStatusReport,
224) -> Result<Vec<String>, InstallRootModuleVerificationError> {
225    let observed_text = report
226        .module_hash
227        .as_deref()
228        .ok_or(InstallRootModuleVerificationError::Missing)?;
229    let observed_module_hash = parse_module_hash(observed_text).ok_or_else(|| {
230        InstallRootModuleVerificationError::Invalid {
231            observed: observed_text.to_string(),
232        }
233    })?;
234    if observed_module_hash != expected_module_hash {
235        return Err(InstallRootModuleVerificationError::Mismatch {
236            expected: module_hash_text(expected_module_hash),
237            observed: module_hash_text(observed_module_hash),
238        });
239    }
240
241    Ok(vec![
242        format!("root_canister:{root_canister_id}"),
243        format!("root_wasm:{}", root_wasm.display()),
244        format!(
245            "expected_module_hash:{}",
246            module_hash_text(expected_module_hash)
247        ),
248        format!(
249            "observed_module_hash:{}",
250            module_hash_text(observed_module_hash)
251        ),
252    ])
253}
254
255fn parse_module_hash(value: &str) -> Option<[u8; 32]> {
256    let value = value
257        .strip_prefix("0x")
258        .or_else(|| value.strip_prefix("0X"))
259        .unwrap_or(value);
260    if value.len() != 64 {
261        return None;
262    }
263    let mut bytes = [0; 32];
264    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
265        bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
266    }
267    Some(bytes)
268}
269
270const fn hex_nibble(byte: u8) -> Option<u8> {
271    match byte {
272        b'0'..=b'9' => Some(byte - b'0'),
273        b'a'..=b'f' => Some(byte - b'a' + 10),
274        b'A'..=b'F' => Some(byte - b'A' + 10),
275        _ => None,
276    }
277}
278
279fn module_hash_text(bytes: [u8; 32]) -> String {
280    digest_text(bytes)
281}
282
283fn digest_text(bytes: [u8; 32]) -> String {
284    bytes
285        .iter()
286        .fold(String::with_capacity(64), |mut text, byte| {
287            use std::fmt::Write as _;
288            let _ = write!(text, "{byte:02x}");
289            text
290        })
291}
292
293fn reinstall_root_wasm(
294    icp_root: &Path,
295    environment: &str,
296    root_canister: &str,
297    root_wasm: &Path,
298    init_identity: &CurrentRootInstallIdentity,
299    local_replica: Option<&LocalReplicaTarget>,
300) -> Result<(), Box<dyn std::error::Error>> {
301    let mut install = icp_canister_command(icp_root);
302    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
303    install.arg(root_wasm);
304    install.args(["--args", &root_init_args(init_identity)?]);
305    add_icp_environment_target(&mut install, environment, local_replica);
306    run_command(&mut install)
307}
308
309fn validate_initial_prepared_status(
310    expected: &CurrentRootInstallIdentity,
311    status: &FleetActivationStatusResponse,
312) -> Result<(), InstallRootActivationStatusError> {
313    if status.phase != FleetActivationPhase::Prepared {
314        return Err(InstallRootActivationStatusError::Phase {
315            observed: status.phase,
316        });
317    }
318    if status.identity.fleet != expected.fleet
319        || status.identity.operation_id != expected.install_id
320        || status.identity.release_build_id != expected.release_build_id
321    {
322        return Err(InstallRootActivationStatusError::Identity);
323    }
324    if status.cascade.is_some()
325        || status.cascade_manifest.is_some()
326        || status.credential.is_some()
327        || status.credential_manifest.is_some()
328        || status.activated_at_ns.is_some()
329    {
330        return Err(InstallRootActivationStatusError::Evidence);
331    }
332    Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use canic_core::ids::{
339        AppId, CanonicalNetworkId, FleetBinding, FleetId, FleetKey, ReleaseBuildId,
340        ReleaseBuildNonce,
341    };
342
343    fn status(module_hash: Option<String>) -> IcpCanisterStatusReport {
344        IcpCanisterStatusReport {
345            id: "aaaaa-aa".to_string(),
346            name: Some("root".to_string()),
347            status: "running".to_string(),
348            settings: None,
349            module_hash,
350            memory_size: None,
351            cycles: None,
352            reserved_cycles: None,
353            idle_cycles_burned_per_day: None,
354        }
355    }
356
357    fn install_identity() -> CurrentRootInstallIdentity {
358        CurrentRootInstallIdentity {
359            fleet: FleetBinding {
360                fleet: FleetKey {
361                    canonical_network_id: CanonicalNetworkId::public_ic(),
362                    fleet_id: FleetId::from_generated_bytes([1; 32]),
363                },
364                app: AppId::from("demo"),
365            },
366            install_id: [2; 32],
367            release_build_id: ReleaseBuildId::from_nonce(ReleaseBuildNonce::from_random_bytes(
368                [3; 32],
369            )),
370            expected_module_hash: Some([4; 32]),
371        }
372    }
373
374    fn prepared_status(identity: &CurrentRootInstallIdentity) -> FleetActivationStatusResponse {
375        FleetActivationStatusResponse {
376            phase: FleetActivationPhase::Prepared,
377            identity: FleetActivationIdentity {
378                fleet: identity.fleet.clone(),
379                operation_id: identity.install_id,
380                release_build_id: identity.release_build_id,
381            },
382            cascade: None,
383            cascade_manifest: None,
384            credential: None,
385            credential_manifest: None,
386            activated_at_ns: None,
387        }
388    }
389
390    #[test]
391    fn root_module_postcondition_records_only_an_exact_observed_hash() {
392        let expected = [0xab; 32];
393        let evidence = verified_root_module_evidence(
394            "aaaaa-aa",
395            Path::new("/tmp/root.wasm"),
396            expected,
397            &status(Some(format!("0x{}", module_hash_text(expected)))),
398        )
399        .expect("exact installed module");
400
401        assert_eq!(
402            evidence,
403            [
404                "root_canister:aaaaa-aa".to_string(),
405                "root_wasm:/tmp/root.wasm".to_string(),
406                format!("expected_module_hash:{}", module_hash_text(expected)),
407                format!("observed_module_hash:{}", module_hash_text(expected)),
408            ]
409        );
410        assert!(matches!(
411            verified_root_module_evidence(
412                "aaaaa-aa",
413                Path::new("/tmp/root.wasm"),
414                expected,
415                &status(None),
416            ),
417            Err(InstallRootModuleVerificationError::Missing)
418        ));
419        assert!(matches!(
420            verified_root_module_evidence(
421                "aaaaa-aa",
422                Path::new("/tmp/root.wasm"),
423                expected,
424                &status(Some(module_hash_text([0xcd; 32]))),
425            ),
426            Err(InstallRootModuleVerificationError::Mismatch { .. })
427        ));
428    }
429
430    #[test]
431    fn root_install_reconciliation_accepts_only_exact_empty_prepared_state() {
432        let identity = install_identity();
433        validate_initial_prepared_status(&identity, &prepared_status(&identity))
434            .expect("exact initial Prepared state");
435
436        let mut active = prepared_status(&identity);
437        active.phase = FleetActivationPhase::Active;
438        assert_eq!(
439            validate_initial_prepared_status(&identity, &active),
440            Err(InstallRootActivationStatusError::Phase {
441                observed: FleetActivationPhase::Active,
442            })
443        );
444
445        let mut different_operation = prepared_status(&identity);
446        different_operation.identity.operation_id = [5; 32];
447        assert_eq!(
448            validate_initial_prepared_status(&identity, &different_operation),
449            Err(InstallRootActivationStatusError::Identity)
450        );
451
452        let mut advanced = prepared_status(&identity);
453        advanced.activated_at_ns = Some(6);
454        assert_eq!(
455            validate_initial_prepared_status(&identity, &advanced),
456            Err(InstallRootActivationStatusError::Evidence)
457        );
458    }
459}