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 with the default executable.
185#[must_use]
186pub fn default_command() -> Command {
187    IcpCli::new("icp", None).command()
188}
189
190/// Build a base `icp` command rooted at one workspace directory.
191#[must_use]
192pub fn default_command_in(cwd: &Path) -> Command {
193    IcpCli::new("icp", None).command_in(cwd)
194}
195
196/// Add the selected ICP environment through ICP CLI's named-environment selector.
197pub fn add_target_args(
198    command: &mut Command,
199    environment: Option<&str>,
200    local_replica: Option<&LocalReplicaTarget>,
201) {
202    if let Some(environment) = environment {
203        if environment == LOCAL_ICP_TARGET
204            && let Some(local_replica) = local_replica
205        {
206            command.env_remove("ICP_ENVIRONMENT");
207            command
208                .arg("-n")
209                .arg(&local_replica.url)
210                .arg("-k")
211                .arg(&local_replica.root_key);
212            return;
213        }
214        command.args(["-e", environment]);
215    }
216}
217
218/// Add ICP CLI output formatting, handling JSON as its own flag.
219pub fn add_output_arg(command: &mut Command, output: &str) {
220    if output == "json" {
221        command.arg("--json");
222    } else {
223        command.args(["--output", output]);
224    }
225}
226
227/// Add an ICP CLI local Candid interface path when one is available.
228pub fn add_candid_arg(command: &mut Command, candid_path: Option<&Path>) {
229    if let Some(candid_path) = candid_path {
230        command.arg("--candid").arg(candid_path);
231    }
232}
233
234/// Return Canic's local ICP CLI Candid sidecar path for one role.
235#[must_use]
236pub fn local_canister_candid_path(
237    icp_root: &Path,
238    artifact_environment: &str,
239    role: &str,
240) -> PathBuf {
241    artifact_root_path(icp_root, artifact_environment)
242        .join(role)
243        .join(format!("{role}.did"))
244}
245
246/// Return the local Candid sidecar path only when it exists on disk.
247#[must_use]
248pub fn existing_local_canister_candid_path(
249    icp_root: &Path,
250    artifact_environment: &str,
251    role: &str,
252) -> Option<PathBuf> {
253    let path = local_canister_candid_path(icp_root, artifact_environment, role);
254    path.is_file().then_some(path)
255}
256
257/// Add ICP CLI debug logging when requested.
258pub fn add_debug_arg(command: &mut Command, debug: bool) {
259    if debug {
260        command.arg("--debug");
261    }
262}
263
264/// Ensure a command points at a supported ICP CLI executable before spawning it.
265pub fn ensure_command_compatible(command: &Command) -> Result<(), IcpCommandError> {
266    let executable = command.get_program().to_string_lossy();
267    compatible_version_output(executable.as_ref(), command.get_current_dir()).map(|_| ())
268}
269
270fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
271    command.arg("--project-root-override").arg(cwd);
272}
273
274/// Render a command for diagnostics and dry-run previews.
275#[must_use]
276pub fn command_display(command: &Command) -> String {
277    let mut parts = vec![command.get_program().to_string_lossy().to_string()];
278    parts.extend(
279        command
280            .get_args()
281            .map(|arg| arg.to_string_lossy().to_string()),
282    );
283    parts.join(" ")
284}