Skip to main content

canic_host/canister_protocol/
mod.rs

1//! Module: canister_protocol
2//!
3//! Responsibility: invoke typed Canic Candid methods through ICP-selected transports.
4//! Does not own: domain sequencing, endpoint authorization, or management-Canister effects.
5//! Boundary: domain workflows supply exact Canister, method, and arguments through explicit
6//! query or update operations.
7
8pub 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
167/// Invoke one typed current-generation call after its exact Candid bytes were
168/// independently bound into the reviewed Fleet action.
169pub 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
190/// Query one typed current-generation status after its exact Candid bytes were
191/// independently bound into the reviewed Fleet action.
192pub 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
213/// Read a compiled Canic status contract while preserving typed transport failures.
214pub 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}