Skip to main content

canic_host/canister_protocol/
mod.rs

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