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 bytes =
185        candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
186            canister,
187            method,
188            source,
189        })?;
190    let args_path =
191        write_argument_file(&bytes).map_err(|source| CanisterProtocolError::ArgumentFile {
192            canister,
193            method,
194            source,
195        })?;
196    let canister_text = canister.to_text();
197    let output = match mode {
198        ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
199            &canister_text,
200            method,
201            &args_path,
202            Some(ICP_JSON_OUTPUT),
203            Some(candid_path),
204        ),
205        ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
206            &canister_text,
207            method,
208            &args_path,
209            Some(ICP_JSON_OUTPUT),
210            Some(candid_path),
211        ),
212    };
213    let cleanup = fs::remove_file(&args_path);
214    let output = output.map_err(|source| CanisterProtocolError::Invocation {
215        canister,
216        method,
217        source,
218    })?;
219    cleanup.map_err(|source| CanisterProtocolError::ArgumentFile {
220        canister,
221        method,
222        source,
223    })?;
224    decode_response(canister, method, &output)
225}
226
227fn write_argument_file(bytes: &[u8]) -> io::Result<PathBuf> {
228    let directory = std::env::temp_dir();
229    for _ in 0..MAX_ARGUMENT_FILE_ATTEMPTS {
230        let sequence = NEXT_ARGUMENT_FILE.fetch_add(1, Ordering::Relaxed);
231        let path = directory.join(format!(
232            "canic-candid-args-{}-{sequence}.bin",
233            std::process::id()
234        ));
235        match create_argument_file(&path, bytes) {
236            Ok(()) => return Ok(path),
237            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
238            Err(source) => return Err(source),
239        }
240    }
241    Err(io::Error::new(
242        io::ErrorKind::AlreadyExists,
243        "could not allocate a unique Candid argument file",
244    ))
245}
246
247fn create_argument_file(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
248    let mut options = OpenOptions::new();
249    options.write(true).create_new(true);
250    #[cfg(unix)]
251    options.mode(0o600);
252    let mut file = options.open(path)?;
253    if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) {
254        drop(file);
255        let _ = fs::remove_file(path);
256        return Err(source);
257    }
258    Ok(())
259}
260
261fn decode_response<O>(
262    canister: Principal,
263    method: &'static str,
264    output: &str,
265) -> Result<O, CanisterProtocolError>
266where
267    O: CandidType + DeserializeOwned,
268{
269    decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
270        canister,
271        method,
272        source,
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use serde::Deserialize;
280
281    #[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
282    struct EmptyVectorArgument {
283        values: Vec<u64>,
284    }
285
286    #[test]
287    fn typed_arguments_are_written_to_private_binary_files() {
288        let argument = EmptyVectorArgument { values: Vec::new() };
289        let path = write_argument_file(&candid::encode_one(&argument).expect("encode argument"))
290            .expect("write argument file");
291
292        let decoded: EmptyVectorArgument =
293            candid::decode_one(&fs::read(&path).expect("read argument file"))
294                .expect("decode argument");
295        assert_eq!(decoded, argument);
296        #[cfg(unix)]
297        {
298            use std::os::unix::fs::PermissionsExt;
299
300            assert_eq!(
301                fs::metadata(&path)
302                    .expect("argument metadata")
303                    .permissions()
304                    .mode()
305                    & 0o777,
306                0o600
307            );
308        }
309        fs::remove_file(path).expect("remove argument file");
310    }
311}