Skip to main content

canic_host/icp/
candid.rs

1//! Module: icp::candid
2//!
3//! Responsibility: perform one typed Candid update through the maintained ICP CLI process.
4//! Does not own: domain retry policy, authority, or durable effect intent.
5//! Boundary: callers persist authority before invoking this mechanical transport adapter.
6
7use crate::icp::{IcpCli, IcpCommandError, IcpJsonResponseError};
8use candid::CandidType;
9use serde::de::DeserializeOwned;
10#[cfg(unix)]
11use std::os::unix::fs::OpenOptionsExt;
12use std::{
13    fs::{self, OpenOptions},
14    io::{self, Write},
15    path::{Path, PathBuf},
16    sync::atomic::{AtomicU64, Ordering},
17};
18use thiserror::Error as ThisError;
19
20const MAX_ARGUMENT_FILE_ATTEMPTS: usize = 32;
21static NEXT_ARGUMENT_FILE: AtomicU64 = AtomicU64::new(0);
22
23/// Typed transport or codec failure for a raw Candid update.
24
25#[derive(Debug, ThisError)]
26pub enum IcpCandidCallError {
27    #[error("failed to encode Candid call argument: {0}")]
28    Encode(#[source] candid::Error),
29
30    #[error("failed to manage Candid call argument file: {0}")]
31    File(#[source] io::Error),
32
33    #[error(transparent)]
34    Icp(#[from] IcpCommandError),
35
36    #[error("failed to decode Candid call response: {0}")]
37    Response(#[source] IcpJsonResponseError),
38}
39
40impl IcpCli {
41    /// Perform one typed Candid update and return its exact decoded response.
42    pub fn canister_call_candid<I, O>(
43        &self,
44        canister: &str,
45        method: &str,
46        input: &I,
47        candid_path: Option<&Path>,
48    ) -> Result<O, IcpCandidCallError>
49    where
50        I: CandidType,
51        O: CandidType + DeserializeOwned,
52    {
53        self.canister_candid(canister, method, input, candid_path, false)
54    }
55
56    /// Perform one typed Candid query and return its exact decoded response.
57    pub fn canister_query_candid<I, O>(
58        &self,
59        canister: &str,
60        method: &str,
61        input: &I,
62        candid_path: Option<&Path>,
63    ) -> Result<O, IcpCandidCallError>
64    where
65        I: CandidType,
66        O: CandidType + DeserializeOwned,
67    {
68        self.canister_candid(canister, method, input, candid_path, true)
69    }
70
71    fn canister_candid<I, O>(
72        &self,
73        canister: &str,
74        method: &str,
75        input: &I,
76        candid_path: Option<&Path>,
77        query: bool,
78    ) -> Result<O, IcpCandidCallError>
79    where
80        I: CandidType,
81        O: CandidType + DeserializeOwned,
82    {
83        let bytes = candid::encode_one(input).map_err(IcpCandidCallError::Encode)?;
84        let path = write_candid_argument_file(&bytes).map_err(IcpCandidCallError::File)?;
85        let output = if query {
86            self.canister_query_binary_args_output_with_candid(
87                canister,
88                method,
89                &path,
90                Some("json"),
91                candid_path,
92            )
93        } else {
94            self.canister_call_binary_args_output_with_candid(
95                canister,
96                method,
97                &path,
98                Some("json"),
99                candid_path,
100            )
101        };
102        let cleanup = fs::remove_file(&path);
103        let output = output?;
104        cleanup.map_err(IcpCandidCallError::File)?;
105        crate::icp::response::decode_json_response(&output).map_err(IcpCandidCallError::Response)
106    }
107}
108
109/// Write private invocation scratch, closing it before the child opens it.
110///
111/// Completed writes provide process visibility without a durable disk flush.
112/// Recovery recreates these arguments from caller-owned durable intent.
113pub fn write_candid_argument_file(bytes: &[u8]) -> io::Result<PathBuf> {
114    for _ in 0..MAX_ARGUMENT_FILE_ATTEMPTS {
115        let sequence = NEXT_ARGUMENT_FILE.fetch_add(1, Ordering::Relaxed);
116        let path = std::env::temp_dir().join(format!(
117            "canic-raw-candid-{}-{sequence}.bin",
118            std::process::id()
119        ));
120        let mut options = OpenOptions::new();
121        options.write(true).create_new(true);
122        #[cfg(unix)]
123        options.mode(0o600);
124        match options.open(&path) {
125            Ok(mut file) => {
126                if let Err(source) = file.write_all(bytes) {
127                    drop(file);
128                    let _ = fs::remove_file(&path);
129                    return Err(source);
130                }
131                return Ok(path);
132            }
133            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
134            Err(source) => return Err(source),
135        }
136    }
137    Err(io::Error::new(
138        io::ErrorKind::AlreadyExists,
139        "could not allocate a unique Candid argument file",
140    ))
141}
142
143// -----------------------------------------------------------------------------
144// Tests
145// -----------------------------------------------------------------------------
146
147#[cfg(all(test, unix))]
148mod tests {
149    use super::*;
150    use canic_core::cdk::utils::hash::hex_bytes;
151    use std::{collections::BTreeSet, os::unix::fs::PermissionsExt};
152
153    #[test]
154    fn child_reads_complete_arguments_and_transport_always_removes_scratch() {
155        let root = crate::test_support::temp_dir("candid-child-arguments");
156        fs::create_dir_all(&root).unwrap();
157        let executable = root.join("icp");
158        fs::write(
159            &executable,
160            r#"#!/bin/sh
161if [ "$1" = --version ]; then echo 'icp 1.5.0'; exit 0; fi
162query=false
163while [ "$#" -gt 0 ]; do
164  case "$1" in
165    --args-file) shift; argument=$1 ;;
166    --query) query=true ;;
167  esac
168  shift
169done
170printf '%s\n' "$argument" > argument-path
171printf '%s\n' "$query" > query-mode
172cp "$argument" received || exit 91
173cat response
174exit "$(cat exit-code)"
175"#,
176        )
177        .unwrap();
178        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
179        let icp = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
180        let payload = vec![0xa5_u8; 1024 * 1024];
181        let encoded = candid::encode_one(&payload).unwrap();
182        let response = serde_json::json!({
183            "response_bytes": hex_bytes(candid::encode_one(42_u64).unwrap()),
184        });
185        let mut paths = BTreeSet::new();
186        for query in [false, true] {
187            for outcome in 0..3 {
188                fs::write(root.join("exit-code"), if outcome == 1 { "1" } else { "0" }).unwrap();
189                fs::write(
190                    root.join("response"),
191                    if outcome == 2 {
192                        "{}".to_string()
193                    } else {
194                        response.to_string()
195                    },
196                )
197                .unwrap();
198                let result =
199                    icp.canister_candid::<_, u64>("aaaaa-aa", "probe", &payload, None, query);
200                match outcome {
201                    0 => assert_eq!(result.unwrap(), 42),
202                    1 => assert!(matches!(result, Err(IcpCandidCallError::Icp(_)))),
203                    _ => assert!(matches!(result, Err(IcpCandidCallError::Response(_)))),
204                }
205                assert_eq!(fs::read(root.join("received")).unwrap(), encoded);
206                assert_eq!(
207                    fs::read_to_string(root.join("query-mode")).unwrap().trim(),
208                    query.to_string()
209                );
210                let path = PathBuf::from(
211                    fs::read_to_string(root.join("argument-path"))
212                        .unwrap()
213                        .trim(),
214                );
215                assert!(!path.exists());
216                assert!(paths.insert(path));
217            }
218        }
219        fs::remove_dir_all(root).unwrap();
220    }
221}