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
8use crate::{
9    icp::{IcpCli, IcpCommandError, IcpJsonResponseError, decode_json_result_response},
10    protocol_binding::ResolvedProtocolBinding,
11};
12use candid::{CandidType, Principal};
13use canic_core::diagnostics::RegisteredDiagnosticCode;
14use serde::de::DeserializeOwned;
15#[cfg(unix)]
16use std::os::unix::fs::OpenOptionsExt;
17use std::{
18    fs::{self, OpenOptions},
19    io::{self, Write},
20    path::PathBuf,
21    sync::atomic::{AtomicU64, Ordering},
22};
23use thiserror::Error as ThisError;
24
25const ICP_JSON_OUTPUT: &str = "json";
26const MAX_ARGUMENT_FILE_ATTEMPTS: usize = 32;
27static NEXT_ARGUMENT_FILE: AtomicU64 = AtomicU64::new(0);
28
29#[derive(Clone, Copy)]
30enum ProtocolCallMode {
31    Query,
32    Update,
33}
34
35#[derive(Debug, ThisError)]
36pub enum CanisterProtocolError {
37    #[error("failed to encode Candid arguments for {method} on Canister {canister}: {source}")]
38    ArgumentEncoding {
39        canister: Principal,
40        method: &'static str,
41        #[source]
42        source: candid::Error,
43    },
44
45    #[error(
46        "failed to manage binary Candid arguments for {method} on Canister {canister}: {source}"
47    )]
48    ArgumentFile {
49        canister: Principal,
50        method: &'static str,
51        #[source]
52        source: io::Error,
53    },
54
55    #[error("failed to invoke {method} on Canister {canister}: {source}")]
56    Invocation {
57        canister: Principal,
58        method: &'static str,
59        #[source]
60        source: IcpCommandError,
61    },
62
63    #[error("invalid {method} response from Canister {canister}: {source}")]
64    Response {
65        canister: Principal,
66        method: &'static str,
67        #[source]
68        source: IcpJsonResponseError,
69    },
70}
71
72impl CanisterProtocolError {
73    pub(crate) fn is_rejected_with(&self, code: RegisteredDiagnosticCode) -> bool {
74        matches!(
75            self,
76            Self::Response {
77                source: IcpJsonResponseError::Rejected(error),
78                ..
79            } if error.code() == code.raw_code()
80        )
81    }
82}
83
84pub fn call_with_arg<I, O>(
85    icp: &IcpCli,
86    binding: &ResolvedProtocolBinding,
87    canister: Principal,
88    method: &'static str,
89    input: &I,
90) -> Result<O, CanisterProtocolError>
91where
92    I: CandidType,
93    O: CandidType + DeserializeOwned,
94{
95    invoke_with_candid(
96        icp,
97        &binding.candid_path,
98        canister,
99        method,
100        input,
101        ProtocolCallMode::Update,
102    )
103}
104
105pub fn query_with_arg<I, O>(
106    icp: &IcpCli,
107    binding: &ResolvedProtocolBinding,
108    canister: Principal,
109    method: &'static str,
110    input: &I,
111) -> Result<O, CanisterProtocolError>
112where
113    I: CandidType,
114    O: CandidType + DeserializeOwned,
115{
116    invoke_with_candid(
117        icp,
118        &binding.candid_path,
119        canister,
120        method,
121        input,
122        ProtocolCallMode::Query,
123    )
124}
125
126/// Invoke one typed current-generation call after its exact Candid bytes were
127/// independently bound into the reviewed Fleet action.
128pub fn call_with_candid<I, O>(
129    icp: &IcpCli,
130    candid_path: &std::path::Path,
131    canister: Principal,
132    method: &'static str,
133    input: &I,
134) -> Result<O, CanisterProtocolError>
135where
136    I: CandidType,
137    O: CandidType + DeserializeOwned,
138{
139    invoke_with_candid(
140        icp,
141        candid_path,
142        canister,
143        method,
144        input,
145        ProtocolCallMode::Update,
146    )
147}
148
149/// Query one typed current-generation status after its exact Candid bytes were
150/// independently bound into the reviewed Fleet action.
151pub fn query_with_candid<I, O>(
152    icp: &IcpCli,
153    candid_path: &std::path::Path,
154    canister: Principal,
155    method: &'static str,
156    input: &I,
157) -> Result<O, CanisterProtocolError>
158where
159    I: CandidType,
160    O: CandidType + DeserializeOwned,
161{
162    invoke_with_candid(
163        icp,
164        candid_path,
165        canister,
166        method,
167        input,
168        ProtocolCallMode::Query,
169    )
170}
171
172fn invoke_with_candid<I, O>(
173    icp: &IcpCli,
174    candid_path: &std::path::Path,
175    canister: Principal,
176    method: &'static str,
177    input: &I,
178    mode: ProtocolCallMode,
179) -> Result<O, CanisterProtocolError>
180where
181    I: CandidType,
182    O: CandidType + DeserializeOwned,
183{
184    let output = invoke_output(icp, Some(candid_path), canister, method, input, mode)?;
185    decode_response(canister, method, &output)
186}
187
188fn invoke_output<I>(
189    icp: &IcpCli,
190    candid_path: Option<&std::path::Path>,
191    canister: Principal,
192    method: &'static str,
193    input: &I,
194    mode: ProtocolCallMode,
195) -> Result<String, CanisterProtocolError>
196where
197    I: CandidType,
198{
199    let bytes =
200        candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
201            canister,
202            method,
203            source,
204        })?;
205    let args_path =
206        write_argument_file(&bytes).map_err(|source| CanisterProtocolError::ArgumentFile {
207            canister,
208            method,
209            source,
210        })?;
211    let canister_text = canister.to_text();
212    let output = match mode {
213        ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
214            &canister_text,
215            method,
216            &args_path,
217            Some(ICP_JSON_OUTPUT),
218            candid_path,
219        ),
220        ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
221            &canister_text,
222            method,
223            &args_path,
224            Some(ICP_JSON_OUTPUT),
225            candid_path,
226        ),
227    };
228    let cleanup = fs::remove_file(&args_path);
229    let output = output.map_err(|source| CanisterProtocolError::Invocation {
230        canister,
231        method,
232        source,
233    })?;
234    cleanup.map_err(|source| CanisterProtocolError::ArgumentFile {
235        canister,
236        method,
237        source,
238    })?;
239    Ok(output)
240}
241
242fn write_argument_file(bytes: &[u8]) -> io::Result<PathBuf> {
243    let directory = std::env::temp_dir();
244    for _ in 0..MAX_ARGUMENT_FILE_ATTEMPTS {
245        let sequence = NEXT_ARGUMENT_FILE.fetch_add(1, Ordering::Relaxed);
246        let path = directory.join(format!(
247            "canic-candid-args-{}-{sequence}.bin",
248            std::process::id()
249        ));
250        match create_argument_file(&path, bytes) {
251            Ok(()) => return Ok(path),
252            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
253            Err(source) => return Err(source),
254        }
255    }
256    Err(io::Error::new(
257        io::ErrorKind::AlreadyExists,
258        "could not allocate a unique Candid argument file",
259    ))
260}
261
262fn create_argument_file(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
263    let mut options = OpenOptions::new();
264    options.write(true).create_new(true);
265    #[cfg(unix)]
266    options.mode(0o600);
267    let mut file = options.open(path)?;
268    if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) {
269        drop(file);
270        let _ = fs::remove_file(path);
271        return Err(source);
272    }
273    Ok(())
274}
275
276fn decode_response<O>(
277    canister: Principal,
278    method: &'static str,
279    output: &str,
280) -> Result<O, CanisterProtocolError>
281where
282    O: CandidType + DeserializeOwned,
283{
284    decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
285        canister,
286        method,
287        source,
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde::Deserialize;
295
296    #[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
297    struct EmptyVectorArgument {
298        values: Vec<u64>,
299    }
300
301    #[test]
302    fn typed_arguments_are_written_to_private_binary_files() {
303        let argument = EmptyVectorArgument { values: Vec::new() };
304        let path = write_argument_file(&candid::encode_one(&argument).expect("encode argument"))
305            .expect("write argument file");
306
307        let decoded: EmptyVectorArgument =
308            candid::decode_one(&fs::read(&path).expect("read argument file"))
309                .expect("decode argument");
310        assert_eq!(decoded, argument);
311        #[cfg(unix)]
312        {
313            use std::os::unix::fs::PermissionsExt;
314
315            assert_eq!(
316                fs::metadata(&path)
317                    .expect("argument metadata")
318                    .permissions()
319                    .mode()
320                    & 0o777,
321                0o600
322            );
323        }
324        fs::remove_file(path).expect("remove argument file");
325    }
326}