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, LOCAL_ICP_TARGET},
21 run::{run_json, run_output, 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.environment.as_deref() == Some(LOCAL_ICP_TARGET) && self.local_replica.is_some()
78 }
79
80 pub(crate) fn management_canister_status_candid<I, O>(
83 &self,
84 effective_canister_id: Principal,
85 input: &I,
86 ) -> Result<O, IcpManagementCallError>
87 where
88 I: CandidType,
89 O: CandidType + DeserializeOwned,
90 {
91 let argument = candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
92 let agent = self.authenticated_agent()?;
93 self.record_remote_call();
94 let response = call_management_update(
95 &LiveAgentUpdateBoundary { agent: &agent },
96 effective_canister_id,
97 MANAGEMENT_CANISTER_STATUS,
98 argument,
99 )?;
100 candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
101 }
102
103 pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
107 let environment = self
108 .environment
109 .as_deref()
110 .ok_or(IcpManagementCallError::MissingEnvironment)?;
111 let network = self.network_status(environment)?;
112 let identity = self.exported_active_identity()?;
113 let agent = Agent::builder()
114 .with_url(&network.api_url)
115 .with_arc_identity(identity)
116 .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY)
117 .build()
118 .map_err(IcpManagementCallError::AgentBuild)?;
119 let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
120 .map_err(IcpManagementCallError::NetworkRootKey)?;
121 agent.set_root_key(root_key);
122 Ok(agent)
123 }
124
125 fn network_status(
126 &self,
127 environment: &str,
128 ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
129 if environment == LOCAL_ICP_TARGET
130 && let Some(target) = &self.local_replica
131 {
132 return Ok(IcpNetworkStatus {
133 api_url: target.url.clone(),
134 root_key: target.root_key.clone(),
135 });
136 }
137 let mut command = self.command();
138 command.args(["network", "status", "--environment", environment, "--json"]);
139 run_json(&mut command).map_err(Into::into)
140 }
141
142 fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
143 let mut default_command = self.command();
144 default_command.args(["identity", "default"]);
145 let identity_name = run_output(&mut default_command)?;
146
147 let mut export_command = self.command();
148 export_command.args(["identity", "export", &identity_name]);
149 if let Some(password_file) = self.identity_password_file.as_deref() {
150 export_command.arg("--password-file").arg(password_file);
151 }
152 let mut pem = run_secret_output(&mut export_command)?;
153 let identity = parse_exported_identity(&pem);
154 pem.fill(0);
155 let identity = identity?;
156 let exported = identity
157 .sender()
158 .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
159 let active = self.identity_principal_text()?;
160 if exported.to_text() != active {
161 return Err(IcpManagementCallError::ExportedIdentityConflict {
162 active,
163 exported: exported.to_text(),
164 });
165 }
166 Ok(identity)
167 }
168}
169
170fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
171 if let Ok(identity) = BasicIdentity::from_pem(pem) {
172 return Ok(Arc::new(identity));
173 }
174 if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
175 return Ok(Arc::new(identity));
176 }
177 if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
178 return Ok(Arc::new(identity));
179 }
180 Err(IcpManagementCallError::UnsupportedExportedIdentity)
181}
182
183#[derive(Debug, Eq, PartialEq)]
184struct ManagementUpdateRequest {
185 argument: Vec<u8>,
186 canister_id: Principal,
187 effective_canister_id: Principal,
188 method: String,
189}
190
191trait AgentUpdateBoundary {
192 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
193}
194
195struct LiveAgentUpdateBoundary<'a> {
196 agent: &'a Agent,
197}
198
199impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
200 fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
201 let runtime = tokio::runtime::Builder::new_current_thread()
202 .enable_all()
203 .build()
204 .map_err(IcpManagementCallError::AsyncRuntime)?;
205 runtime
206 .block_on(
207 self.agent
208 .update(&request.canister_id, request.method)
209 .with_effective_canister_id(request.effective_canister_id)
210 .with_arg(request.argument)
211 .call_and_wait(),
212 )
213 .map_err(IcpManagementCallError::AgentCall)
214 }
215}
216
217fn call_management_update(
218 boundary: &impl AgentUpdateBoundary,
219 effective_canister_id: Principal,
220 method: &str,
221 argument: Vec<u8>,
222) -> Result<Vec<u8>, IcpManagementCallError> {
223 boundary.update(ManagementUpdateRequest {
224 argument,
225 canister_id: Principal::management_canister(),
226 effective_canister_id,
227 method: method.to_string(),
228 })
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::icp::LocalReplicaTarget;
235 use std::cell::RefCell;
236
237 struct RecordingAgentBoundary {
238 request: RefCell<Option<ManagementUpdateRequest>>,
239 }
240
241 impl AgentUpdateBoundary for RecordingAgentBoundary {
242 fn update(
243 &self,
244 request: ManagementUpdateRequest,
245 ) -> Result<Vec<u8>, IcpManagementCallError> {
246 self.request.replace(Some(request));
247 Ok(vec![1, 2, 3])
248 }
249 }
250
251 #[test]
252 fn management_status_routes_through_the_target_effective_canister_id() {
253 let target =
254 Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
255 let boundary = RecordingAgentBoundary {
256 request: RefCell::new(None),
257 };
258 let response =
259 call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
260 .expect("record routed management update");
261
262 assert_eq!(response, vec![1, 2, 3]);
263 assert_eq!(
264 boundary.request.into_inner(),
265 Some(ManagementUpdateRequest {
266 argument: vec![7, 8, 9],
267 canister_id: Principal::management_canister(),
268 effective_canister_id: target,
269 method: MANAGEMENT_CANISTER_STATUS.to_string(),
270 })
271 );
272 }
273
274 #[test]
275 fn explicit_local_replica_owns_management_network_resolution() {
276 let target = LocalReplicaTarget {
277 root_key: "010203".to_string(),
278 url: "http://127.0.0.1:4943/".to_string(),
279 };
280 let icp = IcpCli::new("missing-icp", Some(LOCAL_ICP_TARGET.to_string()))
281 .with_local_replica(Some(target.clone()));
282
283 let status = icp
284 .network_status(LOCAL_ICP_TARGET)
285 .expect("resolve explicit local replica without invoking ICP CLI");
286
287 assert_eq!(status.api_url, target.url);
288 assert_eq!(status.root_key, target.root_key);
289 }
290}