use std::process::Stdio;
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use crate::vortix_core::ports::process::{
CommandOutcome, CommandRunner as Trait, CommandSpec, DetachedHandle, ExitStatusInfo, Kind,
PrivilegeReq, ProcessError,
};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::runtime::Runtime;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct RealRunner {
runtime: Arc<Runtime>,
}
impl Default for RealRunner {
fn default() -> Self {
Self::new()
}
}
impl RealRunner {
#[must_use]
pub fn new() -> Self {
Self::try_new().expect("tokio runtime should be constructible at startup")
}
pub fn try_new() -> std::io::Result<Self> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("vortix-subprocess")
.build()?;
Ok(Self {
runtime: Arc::new(runtime),
})
}
#[must_use]
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
pub fn run_blocking(&self, spec: CommandSpec) -> Result<CommandOutcome, ProcessError> {
self.runtime.block_on(<Self as Trait>::run(self, spec))
}
pub fn spawn_detached_blocking(
&self,
spec: CommandSpec,
) -> Result<DetachedHandle, ProcessError> {
self.runtime
.block_on(<Self as Trait>::spawn_detached(self, spec))
}
fn check_privilege(spec: &CommandSpec) -> Result<(), ProcessError> {
if spec.requires_privilege == PrivilegeReq::Root && !is_root() {
return Err(ProcessError::PrivilegeDenied {
program: spec.program.clone(),
});
}
Ok(())
}
}
impl Trait for RealRunner {
#[allow(clippy::too_many_lines)]
async fn run(&self, spec: CommandSpec) -> Result<CommandOutcome, ProcessError> {
Self::check_privilege(&spec)?;
let started_at = SystemTime::now();
let start = Instant::now();
let redacted_args = redact_args(&spec.args, &spec.redact_in_audit);
info!(
target: "vortix::process",
program = %spec.program,
args = ?redacted_args,
requires_privilege = ?spec.requires_privilege,
kind = ?spec.kind,
"subprocess.start"
);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args);
if spec.env_clear {
cmd.env_clear();
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
if let Some(cwd) = &spec.cwd {
cmd.current_dir(cwd);
}
cmd.stdin(if spec.stdin_bytes.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ProcessError::ProgramNotFound {
program: spec.program.clone(),
}
} else {
ProcessError::IoError {
program: spec.program.clone(),
source: e,
}
}
})?;
if let Some(stdin_bytes) = &spec.stdin_bytes {
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_bytes)
.await
.map_err(|e| ProcessError::IoError {
program: spec.program.clone(),
source: e,
})?;
drop(stdin);
}
}
let output = if let Some(timeout) = spec.timeout {
let Ok(result) = tokio::time::timeout(timeout, child.wait_with_output()).await else {
warn!(
target: "vortix::process",
program = %spec.program,
duration_ms = %timeout.as_millis(),
"subprocess.timeout"
);
return Err(ProcessError::Timeout {
program: spec.program.clone(),
duration: timeout,
});
};
result.map_err(|e| ProcessError::IoError {
program: spec.program.clone(),
source: e,
})?
} else {
child
.wait_with_output()
.await
.map_err(|e| ProcessError::IoError {
program: spec.program.clone(),
source: e,
})?
};
let duration = start.elapsed();
let exit_status = ExitStatusInfo {
code: output.status.code(),
signal: signal_from_status(output.status),
success: output.status.success(),
};
info!(
target: "vortix::process",
program = %spec.program,
success = %exit_status.success,
code = ?exit_status.code,
duration_ms = %duration.as_millis(),
"subprocess.end"
);
Ok(CommandOutcome {
stdout: output.stdout,
stderr: output.stderr,
exit_status,
duration,
started_at,
})
}
async fn spawn_detached(&self, spec: CommandSpec) -> Result<DetachedHandle, ProcessError> {
Self::check_privilege(&spec)?;
if spec.kind != Kind::DetachedSpawn {
debug!(
target: "vortix::process",
"spawn_detached called on a OneShot spec; treating as detached anyway"
);
}
let spawned_at = SystemTime::now();
let redacted_args = redact_args(&spec.args, &spec.redact_in_audit);
info!(
target: "vortix::process",
program = %spec.program,
args = ?redacted_args,
requires_privilege = ?spec.requires_privilege,
"subprocess.spawn_detached"
);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args);
if spec.env_clear {
cmd.env_clear();
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
if let Some(cwd) = &spec.cwd {
cmd.current_dir(cwd);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ProcessError::ProgramNotFound {
program: spec.program.clone(),
}
} else {
ProcessError::IoError {
program: spec.program.clone(),
source: e,
}
}
})?;
let pid = child.id().ok_or_else(|| ProcessError::IoError {
program: spec.program.clone(),
source: std::io::Error::other("no pid available for spawned child"),
})?;
drop(child);
Ok(DetachedHandle { pid, spawned_at })
}
}
fn is_root() -> bool {
#[cfg(unix)]
{
#[allow(unsafe_code)]
unsafe {
libc::geteuid() == 0
}
}
#[cfg(not(unix))]
{
false
}
}
#[cfg(unix)]
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
status.signal()
}
#[cfg(not(unix))]
fn signal_from_status(_status: std::process::ExitStatus) -> Option<i32> {
None
}
fn redact_args(args: &[String], redact_indices: &[usize]) -> Vec<String> {
args.iter()
.enumerate()
.map(|(i, a)| {
if redact_indices.contains(&i) {
"***REDACTED***".to_string()
} else {
a.clone()
}
})
.collect()
}