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_arg(
96        icp,
97        binding,
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_arg(
117        icp,
118        binding,
119        canister,
120        method,
121        input,
122        ProtocolCallMode::Query,
123    )
124}
125
126fn invoke_with_arg<I, O>(
127    icp: &IcpCli,
128    binding: &ResolvedProtocolBinding,
129    canister: Principal,
130    method: &'static str,
131    input: &I,
132    mode: ProtocolCallMode,
133) -> Result<O, CanisterProtocolError>
134where
135    I: CandidType,
136    O: CandidType + DeserializeOwned,
137{
138    let bytes =
139        candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
140            canister,
141            method,
142            source,
143        })?;
144    let args_path =
145        write_argument_file(&bytes).map_err(|source| CanisterProtocolError::ArgumentFile {
146            canister,
147            method,
148            source,
149        })?;
150    let canister_text = canister.to_text();
151    let output = match mode {
152        ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
153            &canister_text,
154            method,
155            &args_path,
156            Some(ICP_JSON_OUTPUT),
157            Some(&binding.candid_path),
158        ),
159        ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
160            &canister_text,
161            method,
162            &args_path,
163            Some(ICP_JSON_OUTPUT),
164            Some(&binding.candid_path),
165        ),
166    };
167    let cleanup = fs::remove_file(&args_path);
168    let output = output.map_err(|source| CanisterProtocolError::Invocation {
169        canister,
170        method,
171        source,
172    })?;
173    cleanup.map_err(|source| CanisterProtocolError::ArgumentFile {
174        canister,
175        method,
176        source,
177    })?;
178    decode_response(canister, method, &output)
179}
180
181fn write_argument_file(bytes: &[u8]) -> io::Result<PathBuf> {
182    let directory = std::env::temp_dir();
183    for _ in 0..MAX_ARGUMENT_FILE_ATTEMPTS {
184        let sequence = NEXT_ARGUMENT_FILE.fetch_add(1, Ordering::Relaxed);
185        let path = directory.join(format!(
186            "canic-candid-args-{}-{sequence}.bin",
187            std::process::id()
188        ));
189        match create_argument_file(&path, bytes) {
190            Ok(()) => return Ok(path),
191            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
192            Err(source) => return Err(source),
193        }
194    }
195    Err(io::Error::new(
196        io::ErrorKind::AlreadyExists,
197        "could not allocate a unique Candid argument file",
198    ))
199}
200
201fn create_argument_file(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
202    let mut options = OpenOptions::new();
203    options.write(true).create_new(true);
204    #[cfg(unix)]
205    options.mode(0o600);
206    let mut file = options.open(path)?;
207    if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) {
208        drop(file);
209        let _ = fs::remove_file(path);
210        return Err(source);
211    }
212    Ok(())
213}
214
215fn decode_response<O>(
216    canister: Principal,
217    method: &'static str,
218    output: &str,
219) -> Result<O, CanisterProtocolError>
220where
221    O: CandidType + DeserializeOwned,
222{
223    decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
224        canister,
225        method,
226        source,
227    })
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use serde::Deserialize;
234
235    #[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
236    struct EmptyVectorArgument {
237        values: Vec<u64>,
238    }
239
240    #[test]
241    fn typed_arguments_are_written_to_private_binary_files() {
242        let argument = EmptyVectorArgument { values: Vec::new() };
243        let path = write_argument_file(&candid::encode_one(&argument).expect("encode argument"))
244            .expect("write argument file");
245
246        let decoded: EmptyVectorArgument =
247            candid::decode_one(&fs::read(&path).expect("read argument file"))
248                .expect("decode argument");
249        assert_eq!(decoded, argument);
250        #[cfg(unix)]
251        {
252            use std::os::unix::fs::PermissionsExt;
253
254            assert_eq!(
255                fs::metadata(&path)
256                    .expect("argument metadata")
257                    .permissions()
258                    .mode()
259                    & 0o777,
260                0o600
261            );
262        }
263        fs::remove_file(path).expect("remove argument file");
264    }
265}