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