1use std::{sync::Arc, time::Duration};
10
11use candid::{CandidType, Principal};
12use ic_agent::{
13 Agent, AgentError, Identity,
14 agent::AgentBuilder,
15 identity::{BasicIdentity, Prime256v1Identity, Secp256k1Identity},
16};
17use serde::{Deserialize, de::DeserializeOwned};
18use thiserror::Error as ThisError;
19
20use super::{
21 model::IcpCli,
22 run::{run_json, run_secret_output},
23};
24
25const MANAGEMENT_CANISTER_STATUS: &str = "canister_status";
26const MANAGEMENT_INGRESS_EXPIRY: Duration = Duration::from_mins(4);
27pub const SNAPSHOT_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
28
29#[derive(CandidType, Deserialize)]
30struct ManagementSnapshot {
31 id: Vec<u8>,
32}
33
34#[derive(Debug, Deserialize)]
35struct IcpNetworkStatus {
36 api_url: String,
37 root_key: String,
38}
39
40#[derive(Debug, ThisError)]
43pub enum IcpManagementCallError {
44 #[error("management snapshot read for {canister} exceeded its deadline")]
45 SnapshotDeadline { canister: Principal },
46
47 #[error("management snapshot response has {actual} bytes; maximum is {maximum}")]
48 SnapshotResponseTooLarge { actual: usize, maximum: usize },
49
50 #[error("failed to encode the management-canister argument: {0}")]
51 CandidEncode(#[source] candid::Error),
52
53 #[error("failed to decode the management-canister response: {0}")]
54 CandidResponse(#[source] candid::Error),
55
56 #[error("failed to build the management-canister agent: {0}")]
57 AgentBuild(#[source] AgentError),
58
59 #[error("the effective-ID-correct management-canister call failed: {0}")]
60 AgentCall(#[source] AgentError),
61
62 #[error("failed to create the management-canister async runtime: {0}")]
63 AsyncRuntime(#[source] std::io::Error),
64
65 #[error(transparent)]
66 Icp(#[from] super::IcpCommandError),
67
68 #[error("ICP CLI reported an invalid network root key: {0}")]
69 NetworkRootKey(#[source] canic_core::cdk::utils::hash::DecodeHexError),
70
71 #[error("the selected ICP identity cannot be represented by a supported exported PEM")]
72 UnsupportedExportedIdentity,
73
74 #[error("failed to resolve the Principal of the exported ICP identity: {message}")]
75 ExportedIdentityPrincipal { message: String },
76
77 #[error(
78 "the exported ICP identity Principal {exported} conflicts with the active ICP identity {active}"
79 )]
80 ExportedIdentityConflict { active: String, exported: String },
81
82 #[error("an ICP environment is required for the management-canister call")]
83 MissingEnvironment,
84}
85
86impl IcpCli {
87 pub(crate) fn management_snapshot_ids(
89 &self,
90 canister_id: Principal,
91 ) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
92 self.measure_request(
93 crate::icp::IcpRequestKind::Update,
94 Some(&canister_id.to_text()),
95 Some("list_canister_snapshots"),
96 || {
97 let agent =
98 self.authenticated_agent_with_response_limit(SNAPSHOT_RESPONSE_BYTES)?;
99 let runtime = tokio::runtime::Builder::new_current_thread()
100 .enable_all()
101 .build()
102 .map_err(IcpManagementCallError::AsyncRuntime)?;
103 self.record_remote_call();
104 runtime.block_on(read_snapshot_ids(&agent, canister_id))
105 },
106 )
107 }
108
109 pub(crate) fn delete_canister_snapshot(
111 &self,
112 canister_id: Principal,
113 snapshot_id: Vec<u8>,
114 ) -> Result<(), IcpManagementCallError> {
115 #[derive(CandidType)]
116 struct Request {
117 canister_id: Principal,
118 snapshot_id: Vec<u8>,
119 }
120 self.snapshot_update(
121 canister_id,
122 "delete_canister_snapshot",
123 &Request {
124 canister_id,
125 snapshot_id,
126 },
127 )
128 }
129
130 fn snapshot_update<I, O>(
131 &self,
132 canister_id: Principal,
133 method: &str,
134 input: &I,
135 ) -> Result<O, IcpManagementCallError>
136 where
137 I: CandidType,
138 O: for<'de> candid::utils::ArgumentDecoder<'de>,
139 {
140 self.measure_request(
141 crate::icp::IcpRequestKind::Update,
142 Some(&canister_id.to_text()),
143 Some(method),
144 || {
145 let argument =
146 candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
147 let agent = self.authenticated_agent()?;
148 self.record_remote_call();
149 let response = call_management_update(
150 &LiveAgentUpdateBoundary { agent: &agent },
151 canister_id,
152 method,
153 argument,
154 )?;
155 candid::decode_args(&response).map_err(IcpManagementCallError::CandidResponse)
156 },
157 )
158 }
159
160 #[must_use]
162 pub(crate) fn uses_direct_local_replica(&self) -> bool {
163 self.local_replica
164 .as_ref()
165 .is_some_and(|target| self.environment.as_deref() == Some(target.environment.as_str()))
166 }
167
168 pub(crate) fn management_canister_status_candid<I, O>(
171 &self,
172 effective_canister_id: Principal,
173 input: &I,
174 ) -> Result<O, IcpManagementCallError>
175 where
176 I: CandidType,
177 O: CandidType + DeserializeOwned,
178 {
179 self.measure_request(
180 crate::icp::IcpRequestKind::ManagementStatus,
181 Some(&effective_canister_id.to_text()),
182 Some(MANAGEMENT_CANISTER_STATUS),
183 || {
184 let argument =
185 candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
186 let agent = self.authenticated_agent()?;
187 self.record_remote_call();
188 let response = call_management_update(
189 &LiveAgentUpdateBoundary { agent: &agent },
190 effective_canister_id,
191 MANAGEMENT_CANISTER_STATUS,
192 argument,
193 )?;
194 candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
195 },
196 )
197 }
198
199 pub(crate) fn uninstall_canister(
202 &self,
203 canister_id: Principal,
204 ) -> Result<(), IcpManagementCallError> {
205 #[derive(CandidType)]
206 struct UninstallRequest {
207 canister_id: Principal,
208 sender_canister_version: Option<u64>,
209 }
210 self.measure_request(
211 crate::icp::IcpRequestKind::Update,
212 Some(&canister_id.to_text()),
213 Some("uninstall_code"),
214 || {
215 let argument = candid::encode_one(UninstallRequest {
216 canister_id,
217 sender_canister_version: None,
218 })
219 .map_err(IcpManagementCallError::CandidEncode)?;
220 let agent = self.authenticated_agent()?;
221 self.record_remote_call();
222 let response = call_management_update(
223 &LiveAgentUpdateBoundary { agent: &agent },
224 canister_id,
225 "uninstall_code",
226 argument,
227 )?;
228 candid::decode_args::<()>(&response).map_err(IcpManagementCallError::CandidResponse)
229 },
230 )
231 }
232
233 pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
237 self.build_authenticated_agent(Agent::builder())
238 }
239
240 pub(crate) fn authenticated_agent_with_response_limit(
242 &self,
243 maximum: usize,
244 ) -> Result<Agent, IcpManagementCallError> {
245 self.build_authenticated_agent(Agent::builder().with_max_response_body_size(maximum))
246 }
247
248 pub(super) fn build_authenticated_agent(
249 &self,
250 builder: AgentBuilder,
251 ) -> Result<Agent, IcpManagementCallError> {
252 let environment = self
253 .environment
254 .as_deref()
255 .ok_or(IcpManagementCallError::MissingEnvironment)?;
256 let network = self.network_status(environment)?;
257 let identity = self.exported_active_identity()?;
258 let builder = builder
259 .with_url(&network.api_url)
260 .with_arc_identity(identity)
261 .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY);
262 let agent = builder
263 .build()
264 .map_err(IcpManagementCallError::AgentBuild)?;
265 let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
266 .map_err(IcpManagementCallError::NetworkRootKey)?;
267 agent.set_root_key(root_key);
268 Ok(agent)
269 }
270
271 fn network_status(
272 &self,
273 environment: &str,
274 ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
275 if let Some(target) = &self.local_replica
276 && environment == target.environment
277 {
278 return Ok(IcpNetworkStatus {
279 api_url: target.url.clone(),
280 root_key: target.root_key.clone(),
281 });
282 }
283 let mut command = self.request_command();
284 command.args(["network", "status", "--environment", environment, "--json"]);
285 run_json(&mut command, self).map_err(Into::into)
286 }
287
288 fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
289 let identity_name = self.selected_identity_name()?;
290
291 let mut export_command = self.request_command();
292 export_command.args(["identity", "export", &identity_name]);
293 if let Some(password_file) = self.identity_password_file.as_deref() {
294 export_command.arg("--password-file").arg(password_file);
295 }
296 let mut pem = run_secret_output(&mut export_command, self)?;
297 let identity = parse_exported_identity(&pem);
298 pem.fill(0);
299 let identity = identity?;
300 let exported = identity
301 .sender()
302 .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
303 let active = self.identity_principal_text()?;
304 if exported.to_text() != active {
305 return Err(IcpManagementCallError::ExportedIdentityConflict {
306 active,
307 exported: exported.to_text(),
308 });
309 }
310 Ok(identity)
311 }
312}
313
314pub async fn read_snapshot_ids(
316 agent: &Agent,
317 canister: Principal,
318) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
319 #[derive(CandidType)]
320 struct Request {
321 canister_id: Principal,
322 }
323 let argument = candid::encode_one(Request {
324 canister_id: canister,
325 })
326 .map_err(IcpManagementCallError::CandidEncode)?;
327 let bytes = tokio::time::timeout(
328 Duration::from_secs(45),
329 agent
330 .update(&Principal::management_canister(), "list_canister_snapshots")
331 .with_effective_canister_id(canister)
332 .with_arg(argument)
333 .call_and_wait(),
334 )
335 .await
336 .map_err(|_| IcpManagementCallError::SnapshotDeadline { canister })?
337 .map_err(IcpManagementCallError::AgentCall)?;
338 decode_snapshot_ids(&bytes)
339}
340
341fn decode_snapshot_ids(bytes: &[u8]) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
342 if bytes.len() > SNAPSHOT_RESPONSE_BYTES {
343 return Err(IcpManagementCallError::SnapshotResponseTooLarge {
344 actual: bytes.len(),
345 maximum: SNAPSHOT_RESPONSE_BYTES,
346 });
347 }
348 let mut config = candid::de::DecoderConfig::new();
349 config
350 .set_decoding_quota(SNAPSHOT_RESPONSE_BYTES * 64)
351 .set_skipping_quota(SNAPSHOT_RESPONSE_BYTES * 64);
352 let snapshots: Vec<ManagementSnapshot> = candid::utils::decode_one_with_config(bytes, &config)
353 .map_err(IcpManagementCallError::CandidResponse)?;
354 Ok(snapshots.into_iter().map(|snapshot| snapshot.id).collect())
355}
356
357fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
358 if let Ok(identity) = BasicIdentity::from_pem(pem) {
359 return Ok(Arc::new(identity));
360 }
361 if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
362 return Ok(Arc::new(identity));
363 }
364 if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
365 return Ok(Arc::new(identity));
366 }
367 Err(IcpManagementCallError::UnsupportedExportedIdentity)
368}
369
370#[derive(Debug, Eq, PartialEq)]
371struct ManagementUpdateRequest {
372 argument: Vec<u8>,
373 canister_id: Principal,
374 effective_canister_id: Principal,
375 method: String,
376}
377
378trait AgentUpdateBoundary {
379 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
380}
381
382struct LiveAgentUpdateBoundary<'a> {
383 agent: &'a Agent,
384}
385
386impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
387 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
388 let runtime = tokio::runtime::Builder::new_current_thread()
389 .enable_all()
390 .build()
391 .map_err(IcpManagementCallError::AsyncRuntime)?;
392 runtime
393 .block_on(
394 self.agent
395 .update(&request.canister_id, request.method)
396 .with_effective_canister_id(request.effective_canister_id)
397 .with_arg(request.argument)
398 .call_and_wait(),
399 )
400 .map_err(IcpManagementCallError::AgentCall)
401 }
402}
403
404fn call_management_update(
405 boundary: &impl AgentUpdateBoundary,
406 effective_canister_id: Principal,
407 method: &str,
408 argument: Vec<u8>,
409) -> Result<Vec<u8>, IcpManagementCallError> {
410 boundary.update(ManagementUpdateRequest {
411 argument,
412 canister_id: Principal::management_canister(),
413 effective_canister_id,
414 method: method.to_string(),
415 })
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::icp::LocalReplicaTarget;
422 use std::cell::RefCell;
423
424 #[test]
425 fn snapshot_inventory_decoder_preserves_ids_and_refuses_oversized_or_malformed_replies() {
426 let bytes = candid::encode_one(vec![ManagementSnapshot { id: vec![1, 2, 3] }]).unwrap();
427 assert_eq!(decode_snapshot_ids(&bytes).unwrap(), [vec![1, 2, 3]]);
428 assert!(matches!(
429 decode_snapshot_ids(&vec![0; SNAPSHOT_RESPONSE_BYTES + 1]),
430 Err(IcpManagementCallError::SnapshotResponseTooLarge { .. })
431 ));
432 assert!(matches!(
433 decode_snapshot_ids(b"DIDL"),
434 Err(IcpManagementCallError::CandidResponse(_))
435 ));
436 }
437
438 struct RecordingAgentBoundary {
439 request: RefCell<Option<ManagementUpdateRequest>>,
440 }
441
442 impl AgentUpdateBoundary for RecordingAgentBoundary {
443 fn update(
444 &self,
445 request: ManagementUpdateRequest,
446 ) -> Result<Vec<u8>, IcpManagementCallError> {
447 self.request.replace(Some(request));
448 Ok(vec![1, 2, 3])
449 }
450 }
451
452 #[test]
453 fn management_status_routes_through_the_target_effective_canister_id() {
454 let target =
455 Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
456 let boundary = RecordingAgentBoundary {
457 request: RefCell::new(None),
458 };
459 let response =
460 call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
461 .expect("record routed management update");
462
463 assert_eq!(response, vec![1, 2, 3]);
464 assert_eq!(
465 boundary.request.into_inner(),
466 Some(ManagementUpdateRequest {
467 argument: vec![7, 8, 9],
468 canister_id: Principal::management_canister(),
469 effective_canister_id: target,
470 method: MANAGEMENT_CANISTER_STATUS.to_string(),
471 })
472 );
473 }
474
475 #[test]
476 fn explicit_local_replica_owns_management_network_resolution() {
477 let target = LocalReplicaTarget {
478 environment: "local-qualification".into(),
479 root_key: "010203".to_string(),
480 url: "http://127.0.0.1:4943/".to_string(),
481 };
482 let icp = IcpCli::new("missing-icp", Some(target.environment.clone()))
483 .with_local_replica(Some(target.clone()));
484
485 let status = icp
486 .network_status(&target.environment)
487 .expect("resolve explicit local replica without invoking ICP CLI");
488
489 assert_eq!(status.api_url, target.url);
490 assert_eq!(status.root_key, target.root_key);
491 assert!(icp.uses_direct_local_replica());
492 let mut direct = std::process::Command::new("icp");
493 icp.add_target_args(&mut direct);
494 assert_eq!(
495 direct.get_args().collect::<Vec<_>>(),
496 ["-n", &target.url, "-k", &target.root_key].map(std::ffi::OsStr::new)
497 );
498 let other = IcpCli::new("missing-icp", Some("ic".into())).with_local_replica(Some(target));
499 assert!(!other.uses_direct_local_replica());
500 let mut named = std::process::Command::new("icp");
501 other.add_target_args(&mut named);
502 assert_eq!(
503 named.get_args().collect::<Vec<_>>(),
504 ["-e", "ic"].map(std::ffi::OsStr::new)
505 );
506 }
507}