use std::{
io,
os::fd::BorrowedFd,
path::{Path, PathBuf},
process::Command,
};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[cfg(unix)]
use rustix::io::{FdFlags, fcntl_getfd, fcntl_setfd};
use crate::release_set::artifact_root_path;
use super::{
error::IcpCommandError,
model::{IcpCli, LOCAL_ICP_TARGET, LocalReplicaTarget},
version::compatible_version_output,
};
impl IcpCli {
#[must_use]
pub fn new(executable: impl Into<String>, environment: Option<String>) -> Self {
Self {
executable: executable.into(),
environment,
cwd: None,
local_replica: None,
inherited_fd: None,
}
}
#[must_use]
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
#[must_use]
pub fn with_local_replica(mut self, target: Option<LocalReplicaTarget>) -> Self {
self.local_replica = target;
self
}
#[must_use]
pub const fn with_inherited_fd(mut self, inherited_fd: Option<i32>) -> Self {
self.inherited_fd = inherited_fd;
self
}
#[must_use]
pub fn environment(&self) -> Option<&str> {
self.environment.as_deref()
}
#[must_use]
pub fn command(&self) -> Command {
let mut command = Command::new(&self.executable);
if let Some(cwd) = &self.cwd {
command.current_dir(cwd);
add_project_root_override_arg(&mut command, cwd);
}
configure_inherited_fd(&mut command, self.inherited_fd);
command
}
#[must_use]
pub fn command_in(&self, cwd: &Path) -> Command {
let mut command = Command::new(&self.executable);
command.current_dir(cwd);
add_project_root_override_arg(&mut command, cwd);
configure_inherited_fd(&mut command, self.inherited_fd);
command
}
#[must_use]
pub fn canister_command(&self) -> Command {
let mut command = self.command();
command.arg("canister");
command
}
pub(super) fn add_target_args(&self, command: &mut Command) {
add_target_args(command, self.environment(), self.local_replica.as_ref());
}
}
pub(super) fn configure_inherited_fd(command: &mut Command, inherited_fd: Option<i32>) {
let Some(inherited_fd) = inherited_fd else {
return;
};
#[cfg(unix)]
unsafe {
command.pre_exec(move || {
let fd = BorrowedFd::borrow_raw(inherited_fd);
let mut flags = fcntl_getfd(fd).map_err(errno_to_io)?;
flags.remove(FdFlags::CLOEXEC);
fcntl_setfd(fd, flags).map_err(errno_to_io)
});
}
#[cfg(not(unix))]
let _ = (command, inherited_fd);
}
#[cfg(unix)]
fn errno_to_io(error: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(error.raw_os_error())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::test_support::temp_dir;
use rustix::fs::{FlockOperation, flock};
use std::{fs, time::Duration};
#[test]
fn configured_descriptor_lives_through_command_descendants_only() {
let root = temp_dir("canic-icp-command-inherited-fd");
fs::create_dir_all(&root).expect("create temp root");
let lock_path = root.join("command.lock");
let owner = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.expect("open owner lock");
flock(&owner, FlockOperation::NonBlockingLockExclusive).expect("lock owner file");
let mut command = Command::new("sh");
command.args(["-c", "sleep 0.2 & wait"]);
configure_inherited_fd(&mut command, Some(std::os::fd::AsRawFd::as_raw_fd(&owner)));
let mut child = command.spawn().expect("spawn inherited command tree");
drop(owner);
let contender = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.expect("open contender lock");
assert_eq!(
flock(&contender, FlockOperation::NonBlockingLockExclusive),
Err(rustix::io::Errno::WOULDBLOCK)
);
child.wait().expect("wait for command tree");
for _ in 0..50 {
match flock(&contender, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {
fs::remove_dir_all(root).expect("remove temp root");
return;
}
Err(rustix::io::Errno::WOULDBLOCK) => {
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("unexpected contender lock error: {error}"),
}
}
panic!("inherited descriptor survived the command tree");
}
}
pub(super) fn add_local_network_target(command: &mut Command) {
command.arg(LOCAL_ICP_TARGET);
}
#[must_use]
pub fn default_command() -> Command {
IcpCli::new("icp", None).command()
}
#[must_use]
pub fn default_command_in(cwd: &Path) -> Command {
IcpCli::new("icp", None).command_in(cwd)
}
pub fn add_target_args(
command: &mut Command,
environment: Option<&str>,
local_replica: Option<&LocalReplicaTarget>,
) {
if let Some(environment) = environment {
if environment == LOCAL_ICP_TARGET
&& let Some(local_replica) = local_replica
{
command.env_remove("ICP_ENVIRONMENT");
command
.arg("-n")
.arg(&local_replica.url)
.arg("-k")
.arg(&local_replica.root_key);
return;
}
command.args(["-e", environment]);
}
}
pub fn add_output_arg(command: &mut Command, output: &str) {
if output == "json" {
command.arg("--json");
} else {
command.args(["--output", output]);
}
}
pub fn add_candid_arg(command: &mut Command, candid_path: Option<&Path>) {
if let Some(candid_path) = candid_path {
command.arg("--candid").arg(candid_path);
}
}
#[must_use]
pub fn local_canister_candid_path(
icp_root: &Path,
artifact_environment: &str,
role: &str,
) -> PathBuf {
artifact_root_path(icp_root, artifact_environment)
.join(role)
.join(format!("{role}.did"))
}
#[must_use]
pub fn existing_local_canister_candid_path(
icp_root: &Path,
artifact_environment: &str,
role: &str,
) -> Option<PathBuf> {
let path = local_canister_candid_path(icp_root, artifact_environment, role);
path.is_file().then_some(path)
}
pub fn add_debug_arg(command: &mut Command, debug: bool) {
if debug {
command.arg("--debug");
}
}
pub fn ensure_command_compatible(command: &Command) -> Result<(), IcpCommandError> {
let executable = command.get_program().to_string_lossy();
compatible_version_output(executable.as_ref(), command.get_current_dir()).map(|_| ())
}
fn add_project_root_override_arg(command: &mut Command, cwd: &Path) {
command.arg("--project-root-override").arg(cwd);
}
#[must_use]
pub fn command_display(command: &Command) -> String {
let mut parts = vec![command.get_program().to_string_lossy().to_string()];
parts.extend(
command
.get_args()
.map(|arg| arg.to_string_lossy().to_string()),
);
parts.join(" ")
}