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