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, 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
164/// Invoke one typed current-generation call after its exact Candid bytes were
165/// independently bound into the reviewed Fleet action.
166pub 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
187/// Query one typed current-generation status after its exact Candid bytes were
188/// independently bound into the reviewed Fleet action.
189pub 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
210/// Read a compiled Canic status contract while preserving typed transport failures.
211pub 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}