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        cleanup_candid_argument_file(&path);
103        let output = output?;
104        crate::icp::response::decode_json_response(&output).map_err(IcpCandidCallError::Response)
105    }
106}
107
108/// Remove invocation scratch without changing the observed remote-call outcome.
109pub fn cleanup_candid_argument_file(path: &Path) {
110    if let Err(error) = fs::remove_file(path)
111        && error.kind() != io::ErrorKind::NotFound
112    {
113        eprintln!(
114            "warning: could not remove Candid argument file {}: {error}",
115            path.display()
116        );
117    }
118}
119
120/// Write private invocation scratch, closing it before the child opens it.
121///
122/// Completed writes provide process visibility without a durable disk flush.
123/// Recovery recreates these arguments from caller-owned durable intent.
124pub fn write_candid_argument_file(bytes: &[u8]) -> io::Result<PathBuf> {
125    for _ in 0..MAX_ARGUMENT_FILE_ATTEMPTS {
126        let sequence = NEXT_ARGUMENT_FILE.fetch_add(1, Ordering::Relaxed);
127        let path = std::env::temp_dir().join(format!(
128            "canic-raw-candid-{}-{sequence}.bin",
129            std::process::id()
130        ));
131        let mut options = OpenOptions::new();
132        options.write(true).create_new(true);
133        #[cfg(unix)]
134        options.mode(0o600);
135        match options.open(&path) {
136            Ok(mut file) => {
137                if let Err(source) = file.write_all(bytes) {
138                    drop(file);
139                    let _ = fs::remove_file(&path);
140                    return Err(source);
141                }
142                return Ok(path);
143            }
144            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
145            Err(source) => return Err(source),
146        }
147    }
148    Err(io::Error::new(
149        io::ErrorKind::AlreadyExists,
150        "could not allocate a unique Candid argument file",
151    ))
152}
153
154// -----------------------------------------------------------------------------
155// Tests
156// -----------------------------------------------------------------------------
157
158#[cfg(all(test, unix))]
159mod tests {
160    use super::*;
161    use canic_core::cdk::utils::hash::hex_bytes;
162    use std::{collections::BTreeSet, os::unix::fs::PermissionsExt};
163
164    #[test]
165    fn child_reads_complete_arguments_and_cleanup_preserves_transport_outcomes() {
166        let root = crate::test_support::temp_dir("candid-child-arguments");
167        fs::create_dir_all(&root).unwrap();
168        let executable = root.join("icp");
169        fs::write(
170            &executable,
171            crate::test_support::tool_script(
172                r#"#!/bin/sh
173if [ "$1" = --version ]; then echo 'icp @ICP_VERSION@'; exit 0; fi
174query=false
175while [ "$#" -gt 0 ]; do
176  case "$1" in
177    --args-file) shift; argument=$1 ;;
178    --query) query=true ;;
179  esac
180  shift
181done
182printf '%s\n' "$argument" > argument-path
183printf '%s\n' "$query" > query-mode
184cp "$argument" received || exit 91
185case "$(cat cleanup-mode)" in
186  missing) rm "$argument" ;;
187  directory) rm "$argument"; mkdir "$argument" ;;
188esac
189cat response
190exit "$(cat exit-code)"
191"#,
192            ),
193        )
194        .unwrap();
195        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
196        let icp = IcpCli::new(executable.to_str().unwrap(), None).with_cwd(&root);
197        let payload = vec![0xa5_u8; 1024 * 1024];
198        let encoded = candid::encode_one(&payload).unwrap();
199        let response = serde_json::json!({
200            "response_bytes": hex_bytes(candid::encode_one(42_u64).unwrap()),
201        });
202        let mut paths = BTreeSet::new();
203        for (query, cleanup_mode) in [
204            (false, "normal"),
205            (true, "normal"),
206            (false, "missing"),
207            (true, "missing"),
208            (false, "directory"),
209            (true, "directory"),
210        ] {
211            fs::write(root.join("cleanup-mode"), cleanup_mode).unwrap();
212            for outcome in 0..3 {
213                fs::write(root.join("exit-code"), if outcome == 1 { "1" } else { "0" }).unwrap();
214                fs::write(
215                    root.join("response"),
216                    if outcome == 2 {
217                        "{}".to_string()
218                    } else {
219                        response.to_string()
220                    },
221                )
222                .unwrap();
223                let result =
224                    icp.canister_candid::<_, u64>("aaaaa-aa", "probe", &payload, None, query);
225                match outcome {
226                    0 => assert_eq!(result.unwrap(), 42),
227                    1 => assert!(matches!(result, Err(IcpCandidCallError::Icp(_)))),
228                    _ => assert!(matches!(result, Err(IcpCandidCallError::Response(_)))),
229                }
230                assert_eq!(fs::read(root.join("received")).unwrap(), encoded);
231                assert_eq!(
232                    fs::read_to_string(root.join("query-mode")).unwrap().trim(),
233                    query.to_string()
234                );
235                let path = PathBuf::from(
236                    fs::read_to_string(root.join("argument-path"))
237                        .unwrap()
238                        .trim(),
239                );
240                if cleanup_mode == "directory" {
241                    assert!(path.is_dir());
242                    fs::remove_dir(&path).unwrap();
243                } else {
244                    assert!(!path.exists());
245                }
246                assert!(paths.insert(path));
247            }
248        }
249        fs::remove_dir_all(root).unwrap();
250    }
251}