1pub mod inspection;
9
10use crate::{
11 icp::{
12 IcpCli, IcpCommandError, IcpJsonResponseError, decode_json_result_response,
13 write_candid_argument_file,
14 },
15 protocol_binding::ResolvedProtocolBinding,
16};
17use candid::{CandidType, Principal};
18use canic_core::diagnostics::RegisteredDiagnosticCode;
19use serde::de::DeserializeOwned;
20use std::{fs, io};
21use thiserror::Error as ThisError;
22
23const ICP_JSON_OUTPUT: &str = "json";
24
25#[derive(Clone, Copy)]
26enum ProtocolCallMode {
27 Query,
28 Update,
29}
30
31#[derive(Debug, ThisError)]
32pub enum CanisterProtocolError {
33 #[error("read-only query {method} on Canister {canister} failed: {source}")]
34 ReadOnlyQuery {
35 canister: Principal,
36 method: &'static str,
37 #[source]
38 source: crate::icp::IcpQueryError,
39 },
40
41 #[error("invalid bound inspection contract for Root {caller}: {detail}")]
42 InspectionContract { caller: Principal, detail: String },
43
44 #[error("Root {} inspection preflight for {} observed native={} cycles, liquid={} cycles, required outbound reserve={} cycles. Inspection was not attempted. Review Root funding or freezing reserve and retry with a fresh observation.", .0.caller, .0.canister_id, .0.native_cycles, .0.available_liquid_cycles, .0.required_liquid_cycles)]
45 InspectionPreflightReserve(Box<canic_core::dto::canister::CanisterInspectionReserveResponse>),
46
47 #[error("{}: Root {} cannot inspect {}: native={} cycles, liquid={} cycles, required outbound reserve={} cycles. Review Root funding or freezing reserve before retrying.", canic_core::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES, .0.caller, .0.canister_id, .0.native_cycles, .0.available_liquid_cycles, .0.required_liquid_cycles)]
48 InspectionReserve(Box<canic_core::dto::canister::CanisterInspectionReserveResponse>),
49
50 #[error("Root {caller} returned invalid reserve evidence for inspection of {target}")]
51 InvalidInspectionReserve {
52 caller: Principal,
53 target: Principal,
54 },
55
56 #[error("failed to encode Candid arguments for {method} on Canister {canister}: {source}")]
57 ArgumentEncoding {
58 canister: Principal,
59 method: &'static str,
60 #[source]
61 source: candid::Error,
62 },
63
64 #[error(
65 "failed to manage binary Candid arguments for {method} on Canister {canister}: {source}"
66 )]
67 ArgumentFile {
68 canister: Principal,
69 method: &'static str,
70 #[source]
71 source: io::Error,
72 },
73
74 #[error("failed to invoke {method} on Canister {canister}: {source}")]
75 Invocation {
76 canister: Principal,
77 method: &'static str,
78 #[source]
79 source: IcpCommandError,
80 },
81
82 #[error("invalid {method} response from Canister {canister}: {source}")]
83 Response {
84 canister: Principal,
85 method: &'static str,
86 #[source]
87 source: IcpJsonResponseError,
88 },
89}
90
91impl CanisterProtocolError {
92 pub(crate) fn inspection_reserve(
93 caller: Principal,
94 target: Principal,
95 evidence: canic_core::dto::canister::CanisterInspectionReserveResponse,
96 ) -> Self {
97 if evidence.caller != caller
98 || evidence.canister_id != target
99 || evidence.available_liquid_cycles >= evidence.required_liquid_cycles
100 || evidence.available_liquid_cycles > evidence.native_cycles
101 {
102 return Self::InvalidInspectionReserve { caller, target };
103 }
104 Self::InspectionReserve(Box::new(evidence))
105 }
106
107 pub(crate) fn is_rejected_with(&self, code: RegisteredDiagnosticCode) -> bool {
108 if matches!(self, Self::InspectionReserve(_)) {
109 return code == canic_core::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES;
110 }
111 matches!(
112 self,
113 Self::Response {
114 source: IcpJsonResponseError::Rejected(error),
115 ..
116 } if error.code() == code.raw_code()
117 )
118 }
119}
120
121pub fn call_with_arg<I, O>(
122 icp: &IcpCli,
123 binding: &ResolvedProtocolBinding,
124 canister: Principal,
125 method: &'static str,
126 input: &I,
127) -> Result<O, CanisterProtocolError>
128where
129 I: CandidType,
130 O: CandidType + DeserializeOwned,
131{
132 invoke_with_candid(
133 icp,
134 &binding.candid_path,
135 canister,
136 method,
137 input,
138 ProtocolCallMode::Update,
139 )
140}
141
142pub fn query_with_arg<I, O>(
143 icp: &IcpCli,
144 binding: &ResolvedProtocolBinding,
145 canister: Principal,
146 method: &'static str,
147 input: &I,
148) -> Result<O, CanisterProtocolError>
149where
150 I: CandidType,
151 O: CandidType + DeserializeOwned,
152{
153 invoke_with_candid(
154 icp,
155 &binding.candid_path,
156 canister,
157 method,
158 input,
159 ProtocolCallMode::Query,
160 )
161}
162
163pub fn call_with_candid<I, O>(
166 icp: &IcpCli,
167 candid_path: &std::path::Path,
168 canister: Principal,
169 method: &'static str,
170 input: &I,
171) -> Result<O, CanisterProtocolError>
172where
173 I: CandidType,
174 O: CandidType + DeserializeOwned,
175{
176 invoke_with_candid(
177 icp,
178 candid_path,
179 canister,
180 method,
181 input,
182 ProtocolCallMode::Update,
183 )
184}
185
186pub fn query_with_candid<I, O>(
189 icp: &IcpCli,
190 candid_path: &std::path::Path,
191 canister: Principal,
192 method: &'static str,
193 input: &I,
194) -> Result<O, CanisterProtocolError>
195where
196 I: CandidType,
197 O: CandidType + DeserializeOwned,
198{
199 invoke_with_candid(
200 icp,
201 candid_path,
202 canister,
203 method,
204 input,
205 ProtocolCallMode::Query,
206 )
207}
208
209pub fn query_authenticated<I: CandidType, O: CandidType + DeserializeOwned>(
211 icp: &IcpCli,
212 canister: Principal,
213 method: &'static str,
214 input: &I,
215) -> Result<O, CanisterProtocolError> {
216 let response: Result<O, canic_core::dto::error::Error> = icp
217 .query_candid_readonly(canister, method, input)
218 .map_err(|source| CanisterProtocolError::ReadOnlyQuery {
219 canister,
220 method,
221 source,
222 })?;
223 response.map_err(|error| CanisterProtocolError::Response {
224 canister,
225 method,
226 source: IcpJsonResponseError::Rejected(error),
227 })
228}
229
230fn invoke_with_candid<I, O>(
231 icp: &IcpCli,
232 candid_path: &std::path::Path,
233 canister: Principal,
234 method: &'static str,
235 input: &I,
236 mode: ProtocolCallMode,
237) -> Result<O, CanisterProtocolError>
238where
239 I: CandidType,
240 O: CandidType + DeserializeOwned,
241{
242 let output = invoke_output(icp, Some(candid_path), canister, method, input, mode)?;
243 decode_response(canister, method, &output)
244}
245
246fn invoke_output<I>(
247 icp: &IcpCli,
248 candid_path: Option<&std::path::Path>,
249 canister: Principal,
250 method: &'static str,
251 input: &I,
252 mode: ProtocolCallMode,
253) -> Result<String, CanisterProtocolError>
254where
255 I: CandidType,
256{
257 let bytes =
258 candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
259 canister,
260 method,
261 source,
262 })?;
263 let args_path = write_candid_argument_file(&bytes).map_err(|source| {
264 CanisterProtocolError::ArgumentFile {
265 canister,
266 method,
267 source,
268 }
269 })?;
270 let canister_text = canister.to_text();
271 let output = match mode {
272 ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
273 &canister_text,
274 method,
275 &args_path,
276 Some(ICP_JSON_OUTPUT),
277 candid_path,
278 ),
279 ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
280 &canister_text,
281 method,
282 &args_path,
283 Some(ICP_JSON_OUTPUT),
284 candid_path,
285 ),
286 };
287 let cleanup = fs::remove_file(&args_path);
288 let output = output.map_err(|source| CanisterProtocolError::Invocation {
289 canister,
290 method,
291 source,
292 })?;
293 cleanup.map_err(|source| CanisterProtocolError::ArgumentFile {
294 canister,
295 method,
296 source,
297 })?;
298 Ok(output)
299}
300
301fn decode_response<O>(
302 canister: Principal,
303 method: &'static str,
304 output: &str,
305) -> Result<O, CanisterProtocolError>
306where
307 O: CandidType + DeserializeOwned,
308{
309 decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
310 canister,
311 method,
312 source,
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use serde::Deserialize;
320
321 #[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
322 struct EmptyVectorArgument {
323 values: Vec<u64>,
324 }
325
326 #[test]
327 fn inspection_reserve_requires_exact_authority_and_retains_numeric_cause() {
328 use canic_core::{diagnostics::codes, dto::canister::CanisterInspectionReserveResponse};
329 let root = Principal::from_slice(&[1]);
330 let target = Principal::from_slice(&[2]);
331 let evidence = CanisterInspectionReserveResponse {
332 caller: root,
333 canister_id: target,
334 native_cycles: 1000,
335 available_liquid_cycles: 50,
336 required_liquid_cycles: 100,
337 };
338 let error = CanisterProtocolError::inspection_reserve(root, target, evidence.clone());
339 assert!(error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
340 assert!(!error.is_rejected_with(codes::PLATFORM_UNAVAILABLE));
341 let CanisterProtocolError::InspectionReserve(actual) = &error else {
342 panic!("reserve evidence");
343 };
344 assert_eq!(**actual, evidence);
345 assert!(error.to_string().contains(
346 "native=1000 cycles, liquid=50 cycles, required outbound reserve=100 cycles"
347 ));
348 for invalid in [
349 CanisterInspectionReserveResponse {
350 caller: target,
351 ..evidence.clone()
352 },
353 CanisterInspectionReserveResponse {
354 canister_id: root,
355 ..evidence.clone()
356 },
357 CanisterInspectionReserveResponse {
358 available_liquid_cycles: 100,
359 ..evidence.clone()
360 },
361 CanisterInspectionReserveResponse {
362 native_cycles: 49,
363 ..evidence
364 },
365 ] {
366 let error = CanisterProtocolError::inspection_reserve(root, target, invalid);
367 assert!(matches!(
368 error,
369 CanisterProtocolError::InvalidInspectionReserve { .. }
370 ));
371 assert!(!error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
372 }
373 }
374
375 #[test]
376 fn typed_arguments_are_written_to_private_binary_files() {
377 let argument = EmptyVectorArgument { values: Vec::new() };
378 let path =
379 write_candid_argument_file(&candid::encode_one(&argument).expect("encode argument"))
380 .expect("write argument file");
381
382 let decoded: EmptyVectorArgument =
383 candid::decode_one(&fs::read(&path).expect("read argument file"))
384 .expect("decode argument");
385 assert_eq!(decoded, argument);
386 #[cfg(unix)]
387 {
388 use std::os::unix::fs::PermissionsExt;
389
390 assert_eq!(
391 fs::metadata(&path)
392 .expect("argument metadata")
393 .permissions()
394 .mode()
395 & 0o777,
396 0o600
397 );
398 }
399 fs::remove_file(path).expect("remove argument file");
400 }
401}