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!("fleet_id:{}", status.identity.fleet.fleet.fleet_id),
150            format!(
151                "activation_operation_id:{}",
152                digest_text(status.identity.operation_id)
153            ),
154            format!("release_build_id:{}", status.identity.release_build_id),
155            "fleet_activation_phase:prepared".to_string(),
156        ]);
157        Ok(evidence)
158    }
159}
160
161impl InstallPhaseOperation for InstallRootWasmOperation<'_> {
162    fn phase(&self) -> InstallPhaseLabel {
163        InstallPhaseLabel::INSTALL_ROOT
164    }
165
166    fn attempted_action(&self) -> &'static str {
167        "install root wasm"
168    }
169
170    fn evidence(&self) -> Vec<String> {
171        vec![
172            format!("root_canister:{}", self.root_canister_id),
173            format!("root_wasm:{}", self.root_wasm.display()),
174            format!(
175                "expected_module_hash:{}",
176                module_hash_text(self.expected_module_hash)
177            ),
178        ]
179    }
180
181    fn execute(&self) -> Result<(), Box<dyn std::error::Error>> {
182        reinstall_root_wasm(
183            self.icp_root,
184            self.environment,
185            self.root_canister_id,
186            &self.root_wasm,
187            &self.init_identity,
188            self.local_replica,
189        )
190    }
191
192    fn verified_evidence(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
193        self.observe_exact_prepared()
194    }
195
196    fn execute_and_verify(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
197        if self.observed_module_hash()? == Some(self.expected_module_hash) {
198            return self.observe_exact_prepared();
199        }
200
201        match self.execute() {
202            Ok(()) => self.observe_exact_prepared(),
203            Err(operation) => match self.observe_exact_prepared() {
204                Ok(evidence) => Ok(evidence),
205                Err(reconciliation) => Err(Box::new(InstallRootExecutionReconciliationError {
206                    operation,
207                    reconciliation,
208                })),
209            },
210        }
211    }
212}
213
214fn verified_root_module_evidence(
215    root_canister_id: &str,
216    root_wasm: &Path,
217    expected_module_hash: [u8; 32],
218    report: &IcpCanisterStatusReport,
219) -> Result<Vec<String>, InstallRootModuleVerificationError> {
220    let observed_text = report
221        .module_hash
222        .as_deref()
223        .ok_or(InstallRootModuleVerificationError::Missing)?;
224    let observed_module_hash = parse_module_hash(observed_text).ok_or_else(|| {
225        InstallRootModuleVerificationError::Invalid {
226            observed: observed_text.to_string(),
227        }
228    })?;
229    if observed_module_hash != expected_module_hash {
230        return Err(InstallRootModuleVerificationError::Mismatch {
231            expected: module_hash_text(expected_module_hash),
232            observed: module_hash_text(observed_module_hash),
233        });
234    }
235
236    Ok(vec![
237        format!("root_canister:{root_canister_id}"),
238        format!("root_wasm:{}", root_wasm.display()),
239        format!(
240            "expected_module_hash:{}",
241            module_hash_text(expected_module_hash)
242        ),
243        format!(
244            "observed_module_hash:{}",
245            module_hash_text(observed_module_hash)
246        ),
247    ])
248}
249
250fn parse_module_hash(value: &str) -> Option<[u8; 32]> {
251    let value = value
252        .strip_prefix("0x")
253        .or_else(|| value.strip_prefix("0X"))
254        .unwrap_or(value);
255    if value.len() != 64 {
256        return None;
257    }
258    let mut bytes = [0; 32];
259    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
260        bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
261    }
262    Some(bytes)
263}
264
265const fn hex_nibble(byte: u8) -> Option<u8> {
266    match byte {
267        b'0'..=b'9' => Some(byte - b'0'),
268        b'a'..=b'f' => Some(byte - b'a' + 10),
269        b'A'..=b'F' => Some(byte - b'A' + 10),
270        _ => None,
271    }
272}
273
274fn module_hash_text(bytes: [u8; 32]) -> String {
275    digest_text(bytes)
276}
277
278fn digest_text(bytes: [u8; 32]) -> String {
279    bytes
280        .iter()
281        .fold(String::with_capacity(64), |mut text, byte| {
282            use std::fmt::Write as _;
283            let _ = write!(text, "{byte:02x}");
284            text
285        })
286}
287
288fn reinstall_root_wasm(
289    icp_root: &Path,
290    environment: &str,
291    root_canister: &str,
292    root_wasm: &Path,
293    init_identity: &CurrentRootInstallIdentity,
294    local_replica: Option<&LocalReplicaTarget>,
295) -> Result<(), Box<dyn std::error::Error>> {
296    let mut install = icp_canister_command(icp_root);
297    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
298    install.arg(root_wasm);
299    install.args(["--args", &root_init_args(init_identity)?]);
300    add_icp_environment_target(&mut install, environment, local_replica);
301    run_command(&mut install)
302}
303
304fn validate_initial_prepared_status(
305    expected: &CurrentRootInstallIdentity,
306    status: &FleetActivationStatusResponse,
307) -> Result<(), InstallRootActivationStatusError> {
308    if status.phase != FleetActivationPhase::Prepared {
309        return Err(InstallRootActivationStatusError::Phase {
310            observed: status.phase,
311        });
312    }
313    if status.identity.fleet != expected.fleet
314        || status.identity.operation_id != expected.install_id
315        || status.identity.release_build_id != expected.release_build_id
316    {
317        return Err(InstallRootActivationStatusError::Identity);
318    }
319    if status.cascade.is_some()
320        || status.cascade_manifest.is_some()
321        || status.credential.is_some()
322        || status.credential_manifest.is_some()
323        || status.activated_at_ns.is_some()
324    {
325        return Err(InstallRootActivationStatusError::Evidence);
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use canic_core::ids::{
334        AppId, CanonicalNetworkId, FleetBinding, FleetId, FleetKey, ReleaseBuildId,
335        ReleaseBuildNonce,
336    };
337
338    fn status(module_hash: Option<String>) -> IcpCanisterStatusReport {
339        IcpCanisterStatusReport {
340            id: "aaaaa-aa".to_string(),
341            name: Some("root".to_string()),
342            status: "running".to_string(),
343            settings: None,
344            module_hash,
345            memory_size: None,
346            cycles: None,
347            reserved_cycles: None,
348            idle_cycles_burned_per_day: None,
349        }
350    }
351
352    fn install_identity() -> CurrentRootInstallIdentity {
353        CurrentRootInstallIdentity {
354            fleet: FleetBinding {
355                fleet: FleetKey {
356                    network: CanonicalNetworkId::public_ic(),
357                    fleet_id: FleetId::from_generated_bytes([1; 32]),
358                },
359                app: AppId::from("demo"),
360            },
361            install_id: [2; 32],
362            release_build_id: ReleaseBuildId::from_nonce(ReleaseBuildNonce::from_random_bytes(
363                [3; 32],
364            )),
365            expected_module_hash: Some([4; 32]),
366        }
367    }
368
369    fn prepared_status(identity: &CurrentRootInstallIdentity) -> FleetActivationStatusResponse {
370        FleetActivationStatusResponse {
371            phase: FleetActivationPhase::Prepared,
372            identity: FleetActivationIdentity {
373                fleet: identity.fleet.clone(),
374                operation_id: identity.install_id,
375                release_build_id: identity.release_build_id,
376            },
377            cascade: None,
378            cascade_manifest: None,
379            credential: None,
380            credential_manifest: None,
381            activated_at_ns: None,
382        }
383    }
384
385    #[test]
386    fn root_module_postcondition_records_only_an_exact_observed_hash() {
387        let expected = [0xab; 32];
388        let evidence = verified_root_module_evidence(
389            "aaaaa-aa",
390            Path::new("/tmp/root.wasm"),
391            expected,
392            &status(Some(format!("0x{}", module_hash_text(expected)))),
393        )
394        .expect("exact installed module");
395
396        assert_eq!(
397            evidence,
398            [
399                "root_canister:aaaaa-aa".to_string(),
400                "root_wasm:/tmp/root.wasm".to_string(),
401                format!("expected_module_hash:{}", module_hash_text(expected)),
402                format!("observed_module_hash:{}", module_hash_text(expected)),
403            ]
404        );
405        assert!(matches!(
406            verified_root_module_evidence(
407                "aaaaa-aa",
408                Path::new("/tmp/root.wasm"),
409                expected,
410                &status(None),
411            ),
412            Err(InstallRootModuleVerificationError::Missing)
413        ));
414        assert!(matches!(
415            verified_root_module_evidence(
416                "aaaaa-aa",
417                Path::new("/tmp/root.wasm"),
418                expected,
419                &status(Some(module_hash_text([0xcd; 32]))),
420            ),
421            Err(InstallRootModuleVerificationError::Mismatch { .. })
422        ));
423    }
424
425    #[test]
426    fn root_install_reconciliation_accepts_only_exact_empty_prepared_state() {
427        let identity = install_identity();
428        validate_initial_prepared_status(&identity, &prepared_status(&identity))
429            .expect("exact initial Prepared state");
430
431        let mut active = prepared_status(&identity);
432        active.phase = FleetActivationPhase::Active;
433        assert_eq!(
434            validate_initial_prepared_status(&identity, &active),
435            Err(InstallRootActivationStatusError::Phase {
436                observed: FleetActivationPhase::Active,
437            })
438        );
439
440        let mut different_operation = prepared_status(&identity);
441        different_operation.identity.operation_id = [5; 32];
442        assert_eq!(
443            validate_initial_prepared_status(&identity, &different_operation),
444            Err(InstallRootActivationStatusError::Identity)
445        );
446
447        let mut advanced = prepared_status(&identity);
448        advanced.activated_at_ns = Some(6);
449        assert_eq!(
450            validate_initial_prepared_status(&identity, &advanced),
451            Err(InstallRootActivationStatusError::Evidence)
452        );
453    }
454}