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