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#[derive(Debug, ThisError)]
24pub enum InstallRootModuleVerificationError {
25 #[error("installed root status does not contain a module hash")]
27 Missing,
28
29 #[error("installed root status contains an invalid module hash: {observed}")]
31 Invalid { observed: String },
32
33 #[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#[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}