use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
pub const OUTPUT_CAP: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct Invocation {
pub argv: Vec<String>,
pub cwd: PathBuf,
pub env: Vec<(String, String)>,
pub timeout: Duration,
pub output_cap: usize,
}
#[derive(Debug, Clone, Default)]
pub struct Captured {
pub kept: Vec<u8>,
pub total: usize,
}
impl Captured {
#[must_use]
pub fn truncated(&self) -> bool {
self.total > self.kept.len()
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub exit_code: Option<i32>,
pub timed_out: bool,
pub spawn_error: Option<String>,
pub started: SystemTime,
pub duration: Duration,
pub stdout: Captured,
pub stderr: Captured,
pub sandbox: speccoder_core::verify::Denials,
}
pub trait CommandRunner: Send + Sync {
fn run(&self, inv: &Invocation) -> Outcome;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LocalRunner;
impl CommandRunner for LocalRunner {
fn run(&self, inv: &Invocation) -> Outcome {
let started = SystemTime::now();
let clock = Instant::now();
let failed = |e: String| Outcome {
exit_code: None,
timed_out: false,
spawn_error: Some(e),
started,
duration: clock.elapsed(),
stdout: Captured::default(),
stderr: Captured::default(),
sandbox: Default::default(),
};
let Some((program, args)) = inv.argv.split_first() else {
return failed("empty argv".to_owned());
};
let mut cmd = Command::new(program);
cmd.args(args)
.current_dir(&inv.cwd)
.env_clear()
.envs(inv.env.iter().map(|(k, v)| (k, v)))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return failed(format!("could not start `{program}`: {e}")),
};
let pgid = i32::try_from(child.id()).ok().map(Pid::from_raw);
supervise(&mut Grouped { child, pgid }, inv, started, clock)
}
}
pub(crate) trait Supervised {
fn child(&mut self) -> &mut std::process::Child;
fn kill(&mut self);
}
struct Grouped {
child: std::process::Child,
pgid: Option<Pid>,
}
impl Supervised for Grouped {
fn child(&mut self) -> &mut std::process::Child {
&mut self.child
}
fn kill(&mut self) {
if let Some(pgid) = self.pgid {
let _ = killpg(pgid, Signal::SIGKILL);
}
}
}
pub(crate) fn supervise(
s: &mut impl Supervised,
inv: &Invocation,
started: SystemTime,
clock: Instant,
) -> Outcome {
let child = s.child();
let cap = inv.output_cap;
let keep = cap.saturating_add(crate::secrets::longest());
let out = child
.stdout
.take()
.map(|s| thread::spawn(move || capture(s, keep)));
let err = child
.stderr
.take()
.map(|s| thread::spawn(move || capture(s, keep)));
let mut timed_out = false;
let status = loop {
match s.child().try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if clock.elapsed() >= inv.timeout => {
timed_out = true;
s.kill();
break s.child().wait().ok();
}
Ok(None) => thread::sleep(Duration::from_millis(10)),
Err(_) => break None,
}
};
s.kill();
let join = |h: Option<thread::JoinHandle<Captured>>| {
let mut c: Captured = h.and_then(|h| h.join().ok()).unwrap_or_default();
let cut = c.total > cap;
c.kept = crate::secrets::mask_bytes(&c.kept);
c.kept.truncate(cap);
if !cut {
c.total = c.kept.len();
}
c
};
Outcome {
exit_code: if timed_out {
None
} else {
status.and_then(|s| s.code())
},
timed_out,
spawn_error: None,
started,
duration: clock.elapsed(),
stdout: join(out),
stderr: join(err),
sandbox: Default::default(),
}
}
fn capture(mut stream: impl Read, cap: usize) -> Captured {
let mut captured = Captured::default();
let mut buf = [0u8; 8192];
while let Ok(n) = stream.read(&mut buf) {
if n == 0 {
break;
}
captured.total += n;
let room = cap.saturating_sub(captured.kept.len());
captured.kept.extend_from_slice(&buf[..n.min(room)]);
}
captured
}
#[must_use]
pub fn scrubbed_env(vars: impl IntoIterator<Item = (String, String)>) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = vars
.into_iter()
.filter(|(k, _)| !is_secret(k))
.filter(|(k, _)| !OURS.contains(&k.as_str()) && !FORCES_COLOUR.contains(&k.as_str()))
.filter(|(k, _)| !BUILD_ELSEWHERE.contains(&k.as_str()))
.collect();
env.push(("CI".to_owned(), "1".to_owned()));
env.push(("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()));
env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
env.push(("NO_COLOR".to_owned(), "1".to_owned()));
env.sort();
env
}
const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
#[must_use]
pub fn is_secret(name: &str) -> bool {
let n = name.to_ascii_uppercase();
n.ends_with("_API_KEY")
|| n.ends_with("_TOKEN")
|| n.contains("SECRET")
|| n.contains("PASSWORD")
|| matches!(n.as_str(), "SSH_AUTH_SOCK" | "GIT_ASKPASS" | "SSH_ASKPASS")
|| n.starts_with("AWS_")
|| n.starts_with("ANTHROPIC_")
|| n.starts_with("OPENAI_")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scrubs_secret_patterns_and_keeps_the_rest() {
let vars = [
("PATH", "/bin"),
("HOME", "/h"),
("ANTHROPIC_API_KEY", "k"),
("GITHUB_TOKEN", "t"),
("NPM_TOKEN", "t"),
("DB_PASSWORD", "p"),
("MY_SECRET_THING", "s"),
("AWS_PROFILE", "x"),
("SSH_AUTH_SOCK", "/s"),
("CI", "false"),
("RUSTUP_HOME", "/r"),
("CARGO_TERM_COLOR", "always"),
("FORCE_COLOR", "1"),
("CARGO_TARGET_DIR", "/elsewhere"),
("CARGO_BUILD_TARGET_DIR", "/elsewhere"),
]
.map(|(k, v)| (k.to_owned(), v.to_owned()));
let keys: Vec<_> = scrubbed_env(vars)
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
assert_eq!(
keys,
[
"CARGO_TERM_COLOR=never",
"CI=1",
"GIT_TERMINAL_PROMPT=0",
"HOME=/h",
"NO_COLOR=1",
"PATH=/bin",
"RUSTUP_HOME=/r"
]
);
}
#[test]
fn a_value_straddling_the_cap_is_masked_before_the_cut() {
let value = "straddle-Pq7r-the-cut";
crate::secrets::register("STRADDLE", value);
let out = LocalRunner.run(&Invocation {
argv: vec![
"sh".to_owned(),
"-c".to_owned(),
format!("printf '%s' 0123456789{value}"),
],
cwd: std::env::temp_dir(),
env: Vec::new(),
timeout: Duration::from_secs(10),
output_cap: 16,
});
let kept = String::from_utf8_lossy(&out.stdout.kept).into_owned();
assert_eq!(kept, "0123456789[redac");
assert!(out.stdout.truncated());
}
}