Skip to main content

canic_host/icp/
command.rs

1use std::{
2    io,
3    os::fd::BorrowedFd,
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8#[cfg(unix)]
9use std::os::unix::process::CommandExt;
10
11#[cfg(unix)]
12use rustix::io::{FdFlags, fcntl_getfd, fcntl_setfd};
13
14use crate::release_set::artifact_root_path;
15
16use super::{
17    error::IcpCommandError,
18    model::{IcpCli, LOCAL_ICP_TARGET, LocalReplicaTarget},
19    version::compatible_version_output,
20};
21
22impl IcpCli {
23    /// Build an ICP CLI command context from an executable path and optional ICP environment.
24    #[must_use]
25    pub fn new(executable: impl Into<String>, environment: Option<String>) -> Self {
26        Self {
27            executable: executable.into(),
28            environment,
29            cwd: None,
30            local_replica: None,
31            inherited_fd: None,
32        }
33    }
34
35    /// Return a copy of this ICP CLI context rooted at one project directory.
36    #[must_use]
37    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
38        self.cwd = Some(cwd.into());
39        self
40    }
41
42    /// Return a copy using an explicit direct local replica target.
43    #[must_use]
44    pub fn with_local_replica(mut self, target: Option<LocalReplicaTarget>) -> Self {
45        self.local_replica = target;
46        self
47    }
48
49    /// Keep one caller-owned descriptor open in commands spawned by this context.
50    #[must_use]
51    pub const fn with_inherited_fd(mut self, inherited_fd: Option<i32>) -> Self {
52        self.inherited_fd = inherited_fd;
53        self
54    }
55
56    /// Return the optional ICP environment carried by this command context.
57    #[must_use]
58    pub fn environment(&self) -> Option<&str> {
59        self.environment.as_deref()
60    }
61
62    /// Build a base ICP CLI command from this context.
63    #[must_use]
64    pub fn command(&self) -> Command {
65        let mut command = Command::new(&self.executable);
66        if let Some(cwd) = &self.cwd {
67            command.current_dir(cwd);
68            add_project_root_override_arg(&mut command, cwd);
69        }
70        configure_inherited_fd(&mut command, self.inherited_fd);
71        command
72    }
73
74    /// Build a base ICP CLI command rooted at one workspace directory.
75    #[must_use]
76    pub fn command_in(&self, cwd: &Path) -> Command {
77        let mut command = Command::new(&self.executable);
78        command.current_dir(cwd);
79        add_project_root_override_arg(&mut command, cwd);
80        configure_inherited_fd(&mut command, self.inherited_fd);
81        command
82    }
83
84    /// Build an `icp canister ...` command with optional environment selection applied.
85    #[must_use]
86    pub fn canister_command(&self) -> Command {
87        let mut command = self.command();
88        command.arg("canister");
89        command
90    }
91
92    pub(super) fn add_target_args(&self, command: &mut Command) {
93        add_target_args(command, self.environment(), self.local_replica.as_ref());
94    }
95}
96
97pub(super) fn configure_inherited_fd(command: &mut Command, inherited_fd: Option<i32>) {
98    let Some(inherited_fd) = inherited_fd else {
99        return;
100    };
101
102    #[cfg(unix)]
103    // SAFETY: the closure performs only fcntl operations on the caller-owned
104    // descriptor between fork and exec. The caller keeps that descriptor open
105    // until the synchronous command completes.
106    unsafe {
107        command.pre_exec(move || {
108            // SAFETY: the runner guarantees the raw descriptor remains valid
109            // for the duration of this child setup callback.
110            let fd = BorrowedFd::borrow_raw(inherited_fd);
111            let mut flags = fcntl_getfd(fd).map_err(errno_to_io)?;
112            flags.remove(FdFlags::CLOEXEC);
113            fcntl_setfd(fd, flags).map_err(errno_to_io)
114        });
115    }
116
117    #[cfg(not(unix))]
118    let _ = (command, inherited_fd);
119}
120
121#[cfg(unix)]
122fn errno_to_io(error: rustix::io::Errno) -> io::Error {
123    io::Error::from_raw_os_error(error.raw_os_error())
124}
125
126#[cfg(all(test, unix))]
127mod tests {
128    use super::*;
129    use crate::test_support::temp_dir;
130    use rustix::fs::{FlockOperation, flock};
131    use std::{fs, time::Duration};
132
133    #[test]
134    fn configured_descriptor_lives_through_command_descendants_only() {
135        let root = temp_dir("canic-icp-command-inherited-fd");
136        fs::create_dir_all(&root).expect("create temp root");
137        let lock_path = root.join("command.lock");
138        let owner = fs::OpenOptions::new()
139            .read(true)
140            .write(true)
141            .create(true)
142            .truncate(false)
143            .open(&lock_path)
144            .expect("open owner lock");
145        flock(&owner, FlockOperation::NonBlockingLockExclusive).expect("lock owner file");
146
147        let mut command = Command::new("sh");
148        command.args(["-c", "sleep 0.2 & wait"]);
149        configure_inherited_fd(&mut command, Some(std::os::fd::AsRawFd::as_raw_fd(&owner)));
150        let mut child = command.spawn().expect("spawn inherited command tree");
151        drop(owner);
152
153        let contender = fs::OpenOptions::new()
154            .read(true)
155            .write(true)
156            .open(&lock_path)
157            .expect("open contender lock");
158        assert_eq!(
159            flock(&contender, FlockOperation::NonBlockingLockExclusive),
160            Err(rustix::io::Errno::WOULDBLOCK)
161        );
162
163        child.wait().expect("wait for command tree");
164        for _ in 0..50 {
165            match flock(&contender, FlockOperation::NonBlockingLockExclusive) {
166                Ok(()) => {
167                    fs::remove_dir_all(root).expect("remove temp root");
168                    return;
169                }
170                Err(rustix::io::Errno::WOULDBLOCK) => {
171                    std::thread::sleep(Duration::from_millis(5));
172                }
173                Err(error) => panic!("unexpected contender lock error: {error}"),
174            }
175        }
176        panic!("inherited descriptor survived the command tree");
177    }
178}
179
180pub(super) fn add_local_network_target(command: &mut Command) {
181    command.arg(LOCAL_ICP_TARGET);
182}
183
184/// Build a base `icp` command rooted at one workspace directory.
185#[must_use]
186pub fn default_command_in(cwd: &Path) -> Command {
187    IcpCli::new("icp", None).command_in(cwd)
188}
189
190/// Add the selected ICP environment through ICP CLI's named-environment selector.
191pub fn add_target_args(
192    command: &mut Command,
193    environment: Option<&str>,
194    local_replica: Option<&LocalReplicaTarget>,
195) {
196    if let Some(environment) = environment {
197        if environment == LOCAL_ICP_TARGET
198            && let Some(local_replica) = local_replica
199        {
200            command.env_remove("ICP_ENVIRONMENT");
201            command
202                .arg("-n")
203                .arg(&local_replica.url)
204                .arg("-k")
205                .arg(&local_replica.root_key);
206            return;
207        }
208        command.args(["-e", environment]);
209    }
210}
211
212/// Add ICP CLI output formatting, handling JSON as its own flag.
213pub fn add_output_arg(command: &mut Command, output: &str) {
214    if output == "json" {
215        command.arg("--json");
216    } else {
217        command.args(["--output", output]);
218    }
219}
220
221/// Add an ICP CLI local Candid interface path when one is available.
222pub(super) fn add_candid_arg(command: &mut Command, candid_path: Option<&Path>) {
223    if let Some(candid_path) = candid_path {
224        command.arg("--candid").arg(candid_path);
225    }
226}
227
228/// Return Canic's local ICP CLI Candid sidecar path for one role.
229#[must_use]
230pub fn local_canister_candid_path(
231    icp_root: &Path,
232    artifact_environment: &str,
233    role: &str,
234) -> PathBuf {
235    artifact_root_path(icp_root, artifact_environment)
236        .join(role)
237        .join(format!("{role}.did"))
238}
239
240/// Return the local Candid sidecar path only when it exists on disk.
241#[must_use]
242pub fn existing_local_canister_candid_path(
243    icp_root: &Path,
244    artifact_environment: &str,
245    role: &str,
246) -> Option<PathBuf> {
247    let path = local_canister_candid_path(icp_root, artifact_environment, role);
248    path.is_file().then_some(path)
249}
250
251/// Add ICP CLI debug logging when requested.
252pub(super) fn add_debug_arg(command: &mut Command, debug: bool) {
253    if debug {
254        command.arg("--debug");
255    }
256}
257
258/// Ensure a command points at a supported ICP CLI executable before spawning it.
259pub fn ensure_command_compatible(command: &Command) -> Result<(), IcpCommandError> {
260    let executable = command.get_program().to_string_lossy();
261    compatible_version_output(executable.as_ref(), command.get_current_dir()).map(|_| ())
262}
263
264fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
265    command.arg("--project-root-override").arg(cwd);
266}
267
268/// Render a command for diagnostics and dry-run previews.
269#[must_use]
270pub fn command_display(command: &Command) -> String {
271    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
272    parts.extend(
273        command
274            .get_args()
275            .map(|arg| arg.to_string_lossy().to_string()),
276    );
277    parts.join(" ")
278}