1use std::{sync::Arc, time::Duration};
10
11use candid::{CandidType, Principal};
12use ic_agent::{
13 Agent, AgentError, Identity,
14 identity::{BasicIdentity, Prime256v1Identity, Secp256k1Identity},
15};
16use serde::{Deserialize, de::DeserializeOwned};
17use thiserror::Error as ThisError;
18
19use super::{
20 model::IcpCli,
21 run::{run_json, run_secret_output},
22};
23
24const MANAGEMENT_CANISTER_STATUS: &str = "canister_status";
25const MANAGEMENT_INGRESS_EXPIRY: Duration = Duration::from_mins(4);
26
27#[derive(Debug, Deserialize)]
28struct IcpNetworkStatus {
29 api_url: String,
30 root_key: String,
31}
32
33#[derive(Debug, ThisError)]
36pub enum IcpManagementCallError {
37 #[error("failed to encode the management-canister argument: {0}")]
38 CandidEncode(#[source] candid::Error),
39
40 #[error("failed to decode the management-canister response: {0}")]
41 CandidResponse(#[source] candid::Error),
42
43 #[error("failed to build the management-canister agent: {0}")]
44 AgentBuild(#[source] AgentError),
45
46 #[error("the effective-ID-correct management-canister call failed: {0}")]
47 AgentCall(#[source] AgentError),
48
49 #[error("failed to create the management-canister async runtime: {0}")]
50 AsyncRuntime(#[source] std::io::Error),
51
52 #[error(transparent)]
53 Icp(#[from] super::IcpCommandError),
54
55 #[error("ICP CLI reported an invalid network root key: {0}")]
56 NetworkRootKey(#[source] canic_core::cdk::utils::hash::DecodeHexError),
57
58 #[error("the selected ICP identity cannot be represented by a supported exported PEM")]
59 UnsupportedExportedIdentity,
60
61 #[error("failed to resolve the Principal of the exported ICP identity: {message}")]
62 ExportedIdentityPrincipal { message: String },
63
64 #[error(
65 "the exported ICP identity Principal {exported} conflicts with the active ICP identity {active}"
66 )]
67 ExportedIdentityConflict { active: String, exported: String },
68
69 #[error("an ICP environment is required for the management-canister call")]
70 MissingEnvironment,
71}
72
73impl IcpCli {
74 #[must_use]
76 pub(crate) fn uses_direct_local_replica(&self) -> bool {
77 self.local_replica
78 .as_ref()
79 .is_some_and(|target| self.environment.as_deref() == Some(target.environment.as_str()))
80 }
81
82 pub(crate) fn management_canister_status_candid<I, O>(
85 &self,
86 effective_canister_id: Principal,
87 input: &I,
88 ) -> Result<O, IcpManagementCallError>
89 where
90 I: CandidType,
91 O: CandidType + DeserializeOwned,
92 {
93 self.measure_request(
94 crate::icp::IcpRequestKind::ManagementStatus,
95 Some(&effective_canister_id.to_text()),
96 Some(MANAGEMENT_CANISTER_STATUS),
97 || {
98 let argument =
99 candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
100 let agent = self.authenticated_agent()?;
101 self.record_remote_call();
102 let response = call_management_update(
103 &LiveAgentUpdateBoundary { agent: &agent },
104 effective_canister_id,
105 MANAGEMENT_CANISTER_STATUS,
106 argument,
107 )?;
108 candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
109 },
110 )
111 }
112
113 pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
117 self.build_authenticated_agent(None)
118 }
119
120 pub(crate) fn authenticated_agent_with_response_limit(
122 &self,
123 maximum: usize,
124 ) -> Result<Agent, IcpManagementCallError> {
125 self.build_authenticated_agent(Some(maximum))
126 }
127
128 fn build_authenticated_agent(
129 &self,
130 maximum: Option<usize>,
131 ) -> Result<Agent, IcpManagementCallError> {
132 let environment = self
133 .environment
134 .as_deref()
135 .ok_or(IcpManagementCallError::MissingEnvironment)?;
136 let network = self.network_status(environment)?;
137 let identity = self.exported_active_identity()?;
138 let mut builder = Agent::builder()
139 .with_url(&network.api_url)
140 .with_arc_identity(identity)
141 .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY);
142 if let Some(maximum) = maximum {
143 builder = builder.with_max_response_body_size(maximum);
144 }
145 let agent = builder
146 .build()
147 .map_err(IcpManagementCallError::AgentBuild)?;
148 let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
149 .map_err(IcpManagementCallError::NetworkRootKey)?;
150 agent.set_root_key(root_key);
151 Ok(agent)
152 }
153
154 fn network_status(
155 &self,
156 environment: &str,
157 ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
158 if let Some(target) = &self.local_replica
159 && environment == target.environment
160 {
161 return Ok(IcpNetworkStatus {
162 api_url: target.url.clone(),
163 root_key: target.root_key.clone(),
164 });
165 }
166 let mut command = self.request_command();
167 command.args(["network", "status", "--environment", environment, "--json"]);
168 run_json(&mut command, self).map_err(Into::into)
169 }
170
171 fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
172 let identity_name = self.selected_identity_name()?;
173
174 let mut export_command = self.request_command();
175 export_command.args(["identity", "export", &identity_name]);
176 if let Some(password_file) = self.identity_password_file.as_deref() {
177 export_command.arg("--password-file").arg(password_file);
178 }
179 let mut pem = run_secret_output(&mut export_command, self)?;
180 let identity = parse_exported_identity(&pem);
181 pem.fill(0);
182 let identity = identity?;
183 let exported = identity
184 .sender()
185 .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
186 let active = self.identity_principal_text()?;
187 if exported.to_text() != active {
188 return Err(IcpManagementCallError::ExportedIdentityConflict {
189 active,
190 exported: exported.to_text(),
191 });
192 }
193 Ok(identity)
194 }
195}
196
197fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
198 if let Ok(identity) = BasicIdentity::from_pem(pem) {
199 return Ok(Arc::new(identity));
200 }
201 if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
202 return Ok(Arc::new(identity));
203 }
204 if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
205 return Ok(Arc::new(identity));
206 }
207 Err(IcpManagementCallError::UnsupportedExportedIdentity)
208}
209
210#[derive(Debug, Eq, PartialEq)]
211struct ManagementUpdateRequest {
212 argument: Vec<u8>,
213 canister_id: Principal,
214 effective_canister_id: Principal,
215 method: String,
216}
217
218trait AgentUpdateBoundary {
219 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
220}
221
222struct LiveAgentUpdateBoundary<'a> {
223 agent: &'a Agent,
224}
225
226impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
227 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
228 let runtime = tokio::runtime::Builder::new_current_thread()
229 .enable_all()
230 .build()
231 .map_err(IcpManagementCallError::AsyncRuntime)?;
232 runtime
233 .block_on(
234 self.agent
235 .update(&request.canister_id, request.method)
236 .with_effective_canister_id(request.effective_canister_id)
237 .with_arg(request.argument)
238 .call_and_wait(),
239 )
240 .map_err(IcpManagementCallError::AgentCall)
241 }
242}
243
244fn call_management_update(
245 boundary: &impl AgentUpdateBoundary,
246 effective_canister_id: Principal,
247 method: &str,
248 argument: Vec<u8>,
249) -> Result<Vec<u8>, IcpManagementCallError> {
250 boundary.update(ManagementUpdateRequest {
251 argument,
252 canister_id: Principal::management_canister(),
253 effective_canister_id,
254 method: method.to_string(),
255 })
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::icp::LocalReplicaTarget;
262 use std::cell::RefCell;
263
264 struct RecordingAgentBoundary {
265 request: RefCell<Option<ManagementUpdateRequest>>,
266 }
267
268 impl AgentUpdateBoundary for RecordingAgentBoundary {
269 fn update(
270 &self,
271 request: ManagementUpdateRequest,
272 ) -> Result<Vec<u8>, IcpManagementCallError> {
273 self.request.replace(Some(request));
274 Ok(vec![1, 2, 3])
275 }
276 }
277
278 #[test]
279 fn management_status_routes_through_the_target_effective_canister_id() {
280 let target =
281 Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
282 let boundary = RecordingAgentBoundary {
283 request: RefCell::new(None),
284 };
285 let response =
286 call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
287 .expect("record routed management update");
288
289 assert_eq!(response, vec![1, 2, 3]);
290 assert_eq!(
291 boundary.request.into_inner(),
292 Some(ManagementUpdateRequest {
293 argument: vec![7, 8, 9],
294 canister_id: Principal::management_canister(),
295 effective_canister_id: target,
296 method: MANAGEMENT_CANISTER_STATUS.to_string(),
297 })
298 );
299 }
300
301 #[test]
302 fn explicit_local_replica_owns_management_network_resolution() {
303 let target = LocalReplicaTarget {
304 environment: "local-qualification".into(),
305 root_key: "010203".to_string(),
306 url: "http://127.0.0.1:4943/".to_string(),
307 };
308 let icp = IcpCli::new("missing-icp", Some(target.environment.clone()))
309 .with_local_replica(Some(target.clone()));
310
311 let status = icp
312 .network_status(&target.environment)
313 .expect("resolve explicit local replica without invoking ICP CLI");
314
315 assert_eq!(status.api_url, target.url);
316 assert_eq!(status.root_key, target.root_key);
317 assert!(icp.uses_direct_local_replica());
318 let mut direct = std::process::Command::new("icp");
319 icp.add_target_args(&mut direct);
320 assert_eq!(
321 direct.get_args().collect::<Vec<_>>(),
322 ["-n", &target.url, "-k", &target.root_key].map(std::ffi::OsStr::new)
323 );
324 let other = IcpCli::new("missing-icp", Some("ic".into())).with_local_replica(Some(target));
325 assert!(!other.uses_direct_local_replica());
326 let mut named = std::process::Command::new("icp");
327 other.add_target_args(&mut named);
328 assert_eq!(
329 named.get_args().collect::<Vec<_>>(),
330 ["-e", "ic"].map(std::ffi::OsStr::new)
331 );
332 }
333}