1pub mod contract;
9pub mod inspection;
10
11use crate::{
12 icp::{
13 IcpCli, IcpCommandError, IcpJsonResponseError, cleanup_candid_argument_file,
14 decode_json_result_response, write_candid_argument_file,
15 },
16 protocol_binding::ResolvedProtocolBinding,
17};
18use candid::{CandidType, Principal};
19use canic_contracts::diagnostics::RegisteredDiagnosticCode;
20use serde::de::DeserializeOwned;
21use std::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(
47 Box<canic_contracts::dto::canister::CanisterInspectionReserveResponse>,
48 ),
49
50 #[error("{}: Root {} cannot inspect {}: native={} cycles, liquid={} cycles, required outbound reserve={} cycles. Review Root funding or freezing reserve before retrying.", canic_contracts::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES, .0.caller, .0.canister_id, .0.native_cycles, .0.available_liquid_cycles, .0.required_liquid_cycles)]
51 InspectionReserve(Box<canic_contracts::dto::canister::CanisterInspectionReserveResponse>),
52
53 #[error("Root {caller} returned invalid reserve evidence for inspection of {target}")]
54 InvalidInspectionReserve {
55 caller: Principal,
56 target: Principal,
57 },
58
59 #[error("failed to encode Candid arguments for {method} on Canister {canister}: {source}")]
60 ArgumentEncoding {
61 canister: Principal,
62 method: &'static str,
63 #[source]
64 source: candid::Error,
65 },
66
67 #[error(
68 "failed to manage binary Candid arguments for {method} on Canister {canister}: {source}"
69 )]
70 ArgumentFile {
71 canister: Principal,
72 method: &'static str,
73 #[source]
74 source: io::Error,
75 },
76
77 #[error("failed to invoke {method} on Canister {canister}: {source}")]
78 Invocation {
79 canister: Principal,
80 method: &'static str,
81 #[source]
82 source: IcpCommandError,
83 },
84
85 #[error("invalid {method} response from Canister {canister}: {source}")]
86 Response {
87 canister: Principal,
88 method: &'static str,
89 #[source]
90 source: IcpJsonResponseError,
91 },
92}
93
94impl CanisterProtocolError {
95 pub(crate) fn inspection_reserve(
96 caller: Principal,
97 target: Principal,
98 evidence: canic_contracts::dto::canister::CanisterInspectionReserveResponse,
99 ) -> Self {
100 if evidence.caller != caller
101 || evidence.canister_id != target
102 || evidence.available_liquid_cycles >= evidence.required_liquid_cycles
103 || evidence.available_liquid_cycles > evidence.native_cycles
104 {
105 return Self::InvalidInspectionReserve { caller, target };
106 }
107 Self::InspectionReserve(Box::new(evidence))
108 }
109
110 pub(crate) fn is_rejected_with(&self, code: RegisteredDiagnosticCode) -> bool {
111 if matches!(self, Self::InspectionReserve(_)) {
112 return code
113 == canic_contracts::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES;
114 }
115 matches!(
116 self,
117 Self::Response {
118 source: IcpJsonResponseError::Rejected(error),
119 ..
120 } if error.code() == code.raw_code()
121 )
122 }
123}
124
125pub fn call_with_arg<I, O>(
126 icp: &IcpCli,
127 binding: &ResolvedProtocolBinding,
128 canister: Principal,
129 method: &'static str,
130 input: &I,
131) -> Result<O, CanisterProtocolError>
132where
133 I: CandidType,
134 O: CandidType + DeserializeOwned,
135{
136 invoke_with_candid(
137 icp,
138 &binding.candid_path,
139 canister,
140 method,
141 input,
142 ProtocolCallMode::Update,
143 )
144}
145
146pub fn query_with_arg<I, O>(
147 icp: &IcpCli,
148 binding: &ResolvedProtocolBinding,
149 canister: Principal,
150 method: &'static str,
151 input: &I,
152) -> Result<O, CanisterProtocolError>
153where
154 I: CandidType,
155 O: CandidType + DeserializeOwned,
156{
157 invoke_with_candid(
158 icp,
159 &binding.candid_path,
160 canister,
161 method,
162 input,
163 ProtocolCallMode::Query,
164 )
165}
166
167pub fn call_with_candid<I, O>(
170 icp: &IcpCli,
171 candid_path: &std::path::Path,
172 canister: Principal,
173 method: &'static str,
174 input: &I,
175) -> Result<O, CanisterProtocolError>
176where
177 I: CandidType,
178 O: CandidType + DeserializeOwned,
179{
180 invoke_with_candid(
181 icp,
182 candid_path,
183 canister,
184 method,
185 input,
186 ProtocolCallMode::Update,
187 )
188}
189
190pub fn query_with_candid<I, O>(
193 icp: &IcpCli,
194 candid_path: &std::path::Path,
195 canister: Principal,
196 method: &'static str,
197 input: &I,
198) -> Result<O, CanisterProtocolError>
199where
200 I: CandidType,
201 O: CandidType + DeserializeOwned,
202{
203 invoke_with_candid(
204 icp,
205 candid_path,
206 canister,
207 method,
208 input,
209 ProtocolCallMode::Query,
210 )
211}
212
213pub fn query_authenticated<I: CandidType, O: CandidType + DeserializeOwned>(
215 icp: &IcpCli,
216 canister: Principal,
217 method: &'static str,
218 input: &I,
219) -> Result<O, CanisterProtocolError> {
220 let response: Result<O, canic_contracts::dto::error::Error> = icp
221 .query_candid_readonly(canister, method, input)
222 .map_err(|source| CanisterProtocolError::ReadOnlyQuery {
223 canister,
224 method,
225 source,
226 })?;
227 response.map_err(|error| CanisterProtocolError::Response {
228 canister,
229 method,
230 source: IcpJsonResponseError::Rejected(error),
231 })
232}
233
234fn invoke_with_candid<I, O>(
235 icp: &IcpCli,
236 candid_path: &std::path::Path,
237 canister: Principal,
238 method: &'static str,
239 input: &I,
240 mode: ProtocolCallMode,
241) -> Result<O, CanisterProtocolError>
242where
243 I: CandidType,
244 O: CandidType + DeserializeOwned,
245{
246 let output = invoke_output(icp, Some(candid_path), canister, method, input, mode)?;
247 decode_response(canister, method, &output)
248}
249
250fn invoke_output<I>(
251 icp: &IcpCli,
252 candid_path: Option<&std::path::Path>,
253 canister: Principal,
254 method: &'static str,
255 input: &I,
256 mode: ProtocolCallMode,
257) -> Result<String, CanisterProtocolError>
258where
259 I: CandidType,
260{
261 let bytes =
262 candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
263 canister,
264 method,
265 source,
266 })?;
267 let args_path = write_candid_argument_file(&bytes).map_err(|source| {
268 CanisterProtocolError::ArgumentFile {
269 canister,
270 method,
271 source,
272 }
273 })?;
274 let canister_text = canister.to_text();
275 let output = match mode {
276 ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
277 &canister_text,
278 method,
279 &args_path,
280 Some(ICP_JSON_OUTPUT),
281 candid_path,
282 ),
283 ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
284 &canister_text,
285 method,
286 &args_path,
287 Some(ICP_JSON_OUTPUT),
288 candid_path,
289 ),
290 };
291 cleanup_candid_argument_file(&args_path);
292 let output = output.map_err(|source| CanisterProtocolError::Invocation {
293 canister,
294 method,
295 source,
296 })?;
297 Ok(output)
298}
299
300fn decode_response<O>(
301 canister: Principal,
302 method: &'static str,
303 output: &str,
304) -> Result<O, CanisterProtocolError>
305where
306 O: CandidType + DeserializeOwned,
307{
308 decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
309 canister,
310 method,
311 source,
312 })
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use serde::Deserialize;
319 use std::fs;
320
321 #[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
322 struct EmptyVectorArgument {
323 values: Vec<u64>,
324 }
325
326 #[cfg(unix)]
327 #[test]
328 fn cleanup_failure_preserves_typed_update_and_query_outcomes() {
329 use canic_core::cdk::utils::hash::hex_bytes;
330 use std::{os::unix::fs::PermissionsExt, path::PathBuf};
331
332 let root = crate::test_support::temp_dir("canister-protocol-cleanup");
333 fs::create_dir_all(&root).unwrap();
334 let executable = root.join("icp");
335 fs::write(
336 &executable,
337 crate::test_support::tool_script(
338 r#"#!/bin/sh
339if [ "$1" = --version ]; then echo 'icp @ICP_VERSION@'; exit 0; fi
340while [ "$#" -gt 0 ]; do
341 case "$1" in --args-file) shift; argument=$1 ;; esac
342 shift
343done
344printf '%s\n' "$argument" > argument-path
345rm "$argument"; mkdir "$argument"
346cat response
347exit "$(cat exit-code)"
348"#,
349 ),
350 )
351 .unwrap();
352 fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
353 let icp = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
354 let reply: Result<u64, canic_contracts::dto::error::Error> = Ok(42);
355 let response = serde_json::json!({
356 "response_bytes": hex_bytes(candid::encode_one(reply).unwrap()),
357 });
358 for mode in [ProtocolCallMode::Update, ProtocolCallMode::Query] {
359 for outcome in 0..3 {
360 fs::write(root.join("exit-code"), if outcome == 1 { "1" } else { "0" }).unwrap();
361 fs::write(
362 root.join("response"),
363 if outcome == 2 {
364 "{}".to_string()
365 } else {
366 response.to_string()
367 },
368 )
369 .unwrap();
370 let result = invoke_with_candid::<_, u64>(
371 &icp,
372 &root.join("probe.did"),
373 Principal::from_slice(&[1]),
374 "probe",
375 &(),
376 mode,
377 );
378 match outcome {
379 0 => assert_eq!(result.unwrap(), 42),
380 1 => assert!(matches!(
381 result,
382 Err(CanisterProtocolError::Invocation { .. })
383 )),
384 _ => assert!(matches!(
385 result,
386 Err(CanisterProtocolError::Response { .. })
387 )),
388 }
389 let argument_path = PathBuf::from(
390 fs::read_to_string(root.join("argument-path"))
391 .unwrap()
392 .trim(),
393 );
394 assert!(argument_path.is_dir());
395 fs::remove_dir(argument_path).unwrap();
396 }
397 }
398 fs::remove_dir_all(root).unwrap();
399 }
400
401 #[test]
402 fn inspection_reserve_requires_exact_authority_and_retains_numeric_cause() {
403 use canic_contracts::{
404 diagnostics::codes, dto::canister::CanisterInspectionReserveResponse,
405 };
406 let root = Principal::from_slice(&[1]);
407 let target = Principal::from_slice(&[2]);
408 let evidence = CanisterInspectionReserveResponse {
409 caller: root,
410 canister_id: target,
411 native_cycles: 1000,
412 available_liquid_cycles: 50,
413 required_liquid_cycles: 100,
414 };
415 let error = CanisterProtocolError::inspection_reserve(root, target, evidence.clone());
416 assert!(error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
417 assert!(!error.is_rejected_with(codes::PLATFORM_UNAVAILABLE));
418 let CanisterProtocolError::InspectionReserve(actual) = &error else {
419 panic!("reserve evidence");
420 };
421 assert_eq!(**actual, evidence);
422 assert!(error.to_string().contains(
423 "native=1000 cycles, liquid=50 cycles, required outbound reserve=100 cycles"
424 ));
425 for invalid in [
426 CanisterInspectionReserveResponse {
427 caller: target,
428 ..evidence.clone()
429 },
430 CanisterInspectionReserveResponse {
431 canister_id: root,
432 ..evidence.clone()
433 },
434 CanisterInspectionReserveResponse {
435 available_liquid_cycles: 100,
436 ..evidence.clone()
437 },
438 CanisterInspectionReserveResponse {
439 native_cycles: 49,
440 ..evidence
441 },
442 ] {
443 let error = CanisterProtocolError::inspection_reserve(root, target, invalid);
444 assert!(matches!(
445 error,
446 CanisterProtocolError::InvalidInspectionReserve { .. }
447 ));
448 assert!(!error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
449 }
450 }
451
452 #[test]
453 fn typed_arguments_are_written_to_private_binary_files() {
454 let argument = EmptyVectorArgument { values: Vec::new() };
455 let path =
456 write_candid_argument_file(&candid::encode_one(&argument).expect("encode argument"))
457 .expect("write argument file");
458
459 let decoded: EmptyVectorArgument =
460 candid::decode_one(&fs::read(&path).expect("read argument file"))
461 .expect("decode argument");
462 assert_eq!(decoded, argument);
463 #[cfg(unix)]
464 {
465 use std::os::unix::fs::PermissionsExt;
466
467 assert_eq!(
468 fs::metadata(&path)
469 .expect("argument metadata")
470 .permissions()
471 .mode()
472 & 0o777,
473 0o600
474 );
475 }
476 fs::remove_file(path).expect("remove argument file");
477 }
478}