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!(
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}