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