use std::ffi::CString;
use std::io::Read;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use libc::{
c_char, c_int, pid_t, EACCES, ENOENT, ENOEXEC, SIGHUP, SIGINT, SIGKILL, SIGTERM, WNOHANG,
};
use zeroize::{Zeroize, Zeroizing};
use crate::error::{SecretshError, SpawnError};
use crate::redact::Redactor;
struct ZeroizingCString(Option<CString>);
impl ZeroizingCString {
fn new(cs: CString) -> Self {
Self(Some(cs))
}
fn as_ptr(&self) -> *const c_char {
self.0
.as_ref()
.expect("ZeroizingCString already consumed")
.as_ptr()
}
}
impl Drop for ZeroizingCString {
fn drop(&mut self) {
if let Some(cs) = self.0.take() {
let mut bytes = cs.into_bytes_with_nul();
bytes.zeroize();
}
}
}
const DEFAULT_TIMEOUT_SECS: u64 = 300;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 50 * 1024 * 1024;
const DEFAULT_MAX_STDERR_BYTES: usize = 1024 * 1024;
const SIGKILL_GRACE_SECS: u64 = 5;
const EXIT_TIMEOUT: i32 = 124;
const POLL_INTERVAL_MS: u64 = 50;
const READ_CHUNK: usize = 65_536;
static CHILD_PID: AtomicI32 = AtomicI32::new(0);
#[derive(Debug, Clone)]
pub struct SpawnConfig {
pub timeout_secs: u64,
pub max_output_bytes: usize,
pub max_stderr_bytes: usize,
}
impl Default for SpawnConfig {
fn default() -> Self {
Self {
timeout_secs: DEFAULT_TIMEOUT_SECS,
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
max_stderr_bytes: DEFAULT_MAX_STDERR_BYTES,
}
}
}
#[derive(Debug)]
pub struct SpawnResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timed_out: bool,
}
struct Pipe {
read_fd: c_int,
write_fd: c_int,
}
impl Pipe {
fn new() -> Result<Self, SecretshError> {
let mut fds: [c_int; 2] = [-1, -1];
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
if rc != 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(Self {
read_fd: fds[0],
write_fd: fds[1],
})
}
fn close_read(&self) {
unsafe { libc::close(self.read_fd) };
}
fn close_write(&self) {
unsafe { libc::close(self.write_fd) };
}
fn set_cloexec(&self) {
unsafe {
libc::fcntl(self.read_fd, libc::F_SETFD, libc::FD_CLOEXEC);
libc::fcntl(self.write_fd, libc::F_SETFD, libc::FD_CLOEXEC);
}
}
}
extern "C" fn forward_signal(sig: c_int) {
let pid = CHILD_PID.load(Ordering::Relaxed);
if pid > 0 {
unsafe { libc::kill(pid as pid_t, sig) };
}
}
unsafe fn install_signal_forwarders() -> [libc::sigaction; 3] {
let mut new_action: libc::sigaction = std::mem::zeroed();
new_action.sa_sigaction = forward_signal as *const () as libc::sighandler_t;
new_action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut new_action.sa_mask);
let mut old_actions: [libc::sigaction; 3] = [std::mem::zeroed(); 3];
let signals = [SIGINT, SIGTERM, SIGHUP];
for (i, &sig) in signals.iter().enumerate() {
libc::sigaction(sig, &new_action, &mut old_actions[i]);
}
old_actions
}
unsafe fn restore_signal_handlers(old_actions: &[libc::sigaction; 3]) {
let signals = [SIGINT, SIGTERM, SIGHUP];
for (i, &sig) in signals.iter().enumerate() {
libc::sigaction(sig, &old_actions[i], std::ptr::null_mut());
}
}
fn kill_with_escalation(pid: pid_t, grace_secs: u64) {
unsafe { libc::kill(pid, SIGTERM) };
let deadline = Instant::now() + Duration::from_secs(grace_secs);
loop {
let rc = unsafe { libc::waitpid(pid, std::ptr::null_mut(), WNOHANG) };
if rc == pid || rc < 0 {
return;
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
}
unsafe { libc::kill(pid, SIGKILL) };
unsafe { libc::waitpid(pid, std::ptr::null_mut(), 0) };
}
fn decode_wait_status(status: c_int) -> i32 {
if libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else if libc::WIFSIGNALED(status) {
128 + libc::WTERMSIG(status)
} else {
-1
}
}
#[derive(Default)]
struct ReaderState {
buf: Vec<u8>,
limit_exceeded: bool,
done: bool,
}
fn spawn_reader_thread(
read_fd: c_int,
limit: usize,
state: Arc<Mutex<ReaderState>>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut file =
unsafe { <std::fs::File as std::os::unix::io::FromRawFd>::from_raw_fd(read_fd) };
let mut chunk = vec![0u8; READ_CHUNK];
loop {
match file.read(&mut chunk) {
Ok(0) => {
let mut st = state.lock().unwrap();
st.done = true;
break;
}
Ok(n) => {
let mut st = state.lock().unwrap();
let remaining = limit.saturating_sub(st.buf.len());
if remaining == 0 {
st.limit_exceeded = true;
st.done = true;
break;
}
let to_take = n.min(remaining);
st.buf.extend_from_slice(&chunk[..to_take]);
if to_take < n {
st.limit_exceeded = true;
st.done = true;
break;
}
}
Err(_) => {
let mut st = state.lock().unwrap();
st.done = true;
break;
}
}
}
})
}
pub fn spawn_child(
argv: Vec<Zeroizing<Vec<u8>>>,
redactor: &Redactor,
config: &SpawnConfig,
) -> Result<SpawnResult, SecretshError> {
assert!(!argv.is_empty(), "spawn_child: argv must not be empty");
let command_name: String = {
let raw = argv[0].as_slice();
let without_nul = raw.strip_suffix(b"\0").unwrap_or(raw);
String::from_utf8_lossy(without_nul).into_owned()
};
let mut cstrings: Vec<ZeroizingCString> = Vec::with_capacity(argv.len());
for arg in &argv {
let bytes = arg.as_slice().to_vec();
let cs = CString::from_vec_with_nul(bytes).map_err(|_| {
SecretshError::Spawn(SpawnError::ForkExecFailed {
command: command_name.clone(),
reason: "argv element contains interior NUL byte".into(),
})
})?;
cstrings.push(ZeroizingCString::new(cs));
}
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|cs| cs.as_ptr()).collect();
argv_ptrs.push(std::ptr::null());
let stdout_pipe = Pipe::new()?;
let stderr_pipe = Pipe::new()?;
stdout_pipe.set_cloexec();
stderr_pipe.set_cloexec();
let mut file_actions: libc::posix_spawn_file_actions_t = unsafe { std::mem::zeroed() };
unsafe {
let rc = libc::posix_spawn_file_actions_init(&mut file_actions);
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc).into());
}
libc::posix_spawn_file_actions_addclose(&mut file_actions, stdout_pipe.read_fd);
libc::posix_spawn_file_actions_addclose(&mut file_actions, stderr_pipe.read_fd);
libc::posix_spawn_file_actions_adddup2(&mut file_actions, stdout_pipe.write_fd, 1);
libc::posix_spawn_file_actions_adddup2(&mut file_actions, stderr_pipe.write_fd, 2);
libc::posix_spawn_file_actions_addclose(&mut file_actions, stdout_pipe.write_fd);
libc::posix_spawn_file_actions_addclose(&mut file_actions, stderr_pipe.write_fd);
}
let mut spawnattr: libc::posix_spawnattr_t = unsafe { std::mem::zeroed() };
unsafe {
let rc = libc::posix_spawnattr_init(&mut spawnattr);
if rc != 0 {
libc::posix_spawn_file_actions_destroy(&mut file_actions);
return Err(std::io::Error::from_raw_os_error(rc).into());
}
}
let mut child_pid: pid_t = 0;
let spawn_rc = unsafe {
libc::posix_spawnp(
&mut child_pid,
argv_ptrs[0], &file_actions,
&spawnattr,
argv_ptrs.as_ptr() as *const *mut c_char,
std::ptr::null(), )
};
unsafe {
libc::posix_spawn_file_actions_destroy(&mut file_actions);
libc::posix_spawnattr_destroy(&mut spawnattr);
}
if spawn_rc != 0 {
stdout_pipe.close_read();
stdout_pipe.close_write();
stderr_pipe.close_read();
stderr_pipe.close_write();
drop(cstrings);
drop(argv);
return Err(SecretshError::Spawn(match spawn_rc {
ENOENT => SpawnError::NotFound {
command: command_name,
},
EACCES | ENOEXEC => SpawnError::NotExecutable {
command: command_name,
},
_ => SpawnError::ForkExecFailed {
command: command_name,
reason: std::io::Error::from_raw_os_error(spawn_rc).to_string(),
},
}));
}
stdout_pipe.close_write();
stderr_pipe.close_write();
drop(cstrings);
drop(argv);
CHILD_PID.store(child_pid as i32, Ordering::Relaxed);
let old_signal_handlers = unsafe { install_signal_forwarders() };
let stdout_state = Arc::new(Mutex::new(ReaderState::default()));
let stderr_state = Arc::new(Mutex::new(ReaderState::default()));
let stdout_thread = spawn_reader_thread(
stdout_pipe.read_fd,
config.max_output_bytes,
Arc::clone(&stdout_state),
);
let stderr_thread = spawn_reader_thread(
stderr_pipe.read_fd,
config.max_stderr_bytes,
Arc::clone(&stderr_state),
);
let deadline = Instant::now() + Duration::from_secs(config.timeout_secs);
let mut timed_out = false;
let mut limit_exceeded = false;
let mut final_status: c_int = 0;
let exit_code: i32;
'wait: loop {
{
let out_exceeded = stdout_state.lock().unwrap().limit_exceeded;
let err_exceeded = stderr_state.lock().unwrap().limit_exceeded;
if out_exceeded || err_exceeded {
limit_exceeded = true;
kill_with_escalation(child_pid, SIGKILL_GRACE_SECS);
exit_code = EXIT_TIMEOUT;
break 'wait;
}
}
let rc = unsafe { libc::waitpid(child_pid, &mut final_status, WNOHANG) };
if rc == child_pid {
let out_exceeded = stdout_state.lock().unwrap().limit_exceeded;
let err_exceeded = stderr_state.lock().unwrap().limit_exceeded;
if out_exceeded || err_exceeded {
limit_exceeded = true;
exit_code = EXIT_TIMEOUT;
} else {
exit_code = decode_wait_status(final_status);
}
break 'wait;
} else if rc < 0 {
exit_code = 1;
break 'wait;
}
if Instant::now() >= deadline {
timed_out = true;
kill_with_escalation(child_pid, SIGKILL_GRACE_SECS);
exit_code = EXIT_TIMEOUT;
break 'wait;
}
std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
}
CHILD_PID.store(0, Ordering::Relaxed);
unsafe { restore_signal_handlers(&old_signal_handlers) };
let _ = stdout_thread.join();
let _ = stderr_thread.join();
let stdout_bytes = {
let st = stdout_state.lock().unwrap();
st.buf.clone()
};
let stderr_bytes = {
let st = stderr_state.lock().unwrap();
st.buf.clone()
};
let stdout_redacted = redactor.redact_str(&String::from_utf8_lossy(&stdout_bytes));
let stderr_redacted = redactor.redact_str(&String::from_utf8_lossy(&stderr_bytes));
let _ = limit_exceeded;
Ok(SpawnResult {
stdout: stdout_redacted,
stderr: stderr_redacted,
exit_code,
timed_out,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn arg(s: &str) -> Zeroizing<Vec<u8>> {
let mut v = s.as_bytes().to_vec();
v.push(0); Zeroizing::new(v)
}
fn noop_redactor() -> Redactor {
Redactor::new(&[]).expect("empty Redactor should always succeed")
}
#[test]
fn spawn_config_default_values() {
let cfg = SpawnConfig::default();
assert_eq!(cfg.timeout_secs, 300, "default timeout should be 300 s");
assert_eq!(
cfg.max_output_bytes,
50 * 1024 * 1024,
"default max_output_bytes should be 50 MiB"
);
assert_eq!(
cfg.max_stderr_bytes,
1024 * 1024,
"default max_stderr_bytes should be 1 MiB"
);
}
#[test]
fn echo_hello_stdout() {
let argv = vec![arg("echo"), arg("hello")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let result = spawn_child(argv, &redactor, &config).expect("echo hello should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
assert_eq!(result.stdout.trim(), "hello");
assert!(result.stderr.is_empty());
}
#[test]
fn exit_code_passthrough() {
let argv = vec![arg("false")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let result =
spawn_child(argv, &redactor, &config).expect("false should spawn successfully");
assert_eq!(result.exit_code, 1);
assert!(!result.timed_out);
}
#[test]
fn command_not_found_returns_error() {
let argv = vec![arg("__secretsh_nonexistent_binary_xyz__")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let err = spawn_child(argv, &redactor, &config)
.expect_err("nonexistent binary should return an error");
assert!(
matches!(err, SecretshError::Spawn(SpawnError::NotFound { .. })),
"expected SpawnError::NotFound, got: {err:?}"
);
assert_eq!(err.exit_code(), 127);
}
#[test]
fn stderr_is_captured() {
let argv = vec![arg("sh"), arg("-c"), arg("echo error_output >&2")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let result = spawn_child(argv, &redactor, &config).expect("sh -c should succeed");
assert_eq!(result.exit_code, 0);
assert!(result.stdout.is_empty() || result.stdout.trim().is_empty());
assert!(
result.stderr.contains("error_output"),
"stderr should contain 'error_output', got: {:?}",
result.stderr
);
}
#[test]
fn secret_in_stdout_is_redacted() {
let secret = b"supersecret42";
let redactor = Redactor::new(&[("MY_KEY", secret)]).expect("Redactor::new should succeed");
let argv = vec![arg("echo"), arg("supersecret42")];
let config = SpawnConfig::default();
let result = spawn_child(argv, &redactor, &config).expect("echo should succeed");
assert_eq!(result.exit_code, 0);
assert!(
!result.stdout.contains("supersecret42"),
"stdout should not contain the raw secret"
);
assert!(
result.stdout.contains("[REDACTED_MY_KEY]"),
"stdout should contain the redaction label, got: {:?}",
result.stdout
);
}
#[test]
fn timeout_kills_child_and_sets_flag() {
let argv = vec![arg("sleep"), arg("60")];
let redactor = noop_redactor();
let config = SpawnConfig {
timeout_secs: 1,
..SpawnConfig::default()
};
let result = spawn_child(argv, &redactor, &config)
.expect("spawn should succeed even when child is killed");
assert!(result.timed_out, "timed_out should be true");
assert_eq!(result.exit_code, 124, "exit_code should be 124 on timeout");
}
#[test]
fn output_limit_kills_child() {
let argv = vec![arg("yes")];
let redactor = noop_redactor();
let config = SpawnConfig {
max_output_bytes: 1024,
timeout_secs: 10,
..SpawnConfig::default()
};
let result = spawn_child(argv, &redactor, &config)
.expect("spawn should succeed even when output limit is hit");
assert_eq!(
result.exit_code, 124,
"exit_code should be 124 when output limit exceeded"
);
assert!(
result.stdout.len() <= 1024,
"stdout should be at most 1024 bytes, got {}",
result.stdout.len()
);
}
#[test]
fn multiple_args_passed_correctly() {
let argv = vec![arg("printf"), arg("%s %s\\n"), arg("foo"), arg("bar")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let result = spawn_child(argv, &redactor, &config).expect("printf should succeed");
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.contains("foo") && result.stdout.contains("bar"),
"stdout should contain both args, got: {:?}",
result.stdout
);
}
#[test]
fn true_exits_zero() {
let argv = vec![arg("true")];
let redactor = noop_redactor();
let config = SpawnConfig::default();
let result = spawn_child(argv, &redactor, &config).expect("true should succeed");
assert_eq!(result.exit_code, 0);
assert!(!result.timed_out);
assert!(result.stdout.is_empty());
assert!(result.stderr.is_empty());
}
}