Skip to main content

speccoder_runtime/verify/
process.rs

1//! Running a check: argv only, no stdin, scrubbed environment, a deadline for
2//! the whole process group, capped output.
3//!
4//! [`CommandRunner`] is the seam the sandbox wraps and tests replace.
5
6use std::io::Read;
7use std::path::PathBuf;
8use std::process::{Command, Stdio};
9use std::thread;
10use std::time::{Duration, Instant, SystemTime};
11
12use nix::sys::signal::{Signal, killpg};
13use nix::unistd::Pid;
14
15/// Default cap per output stream.
16pub const OUTPUT_CAP: usize = 1024 * 1024;
17
18/// What to run.
19#[derive(Debug, Clone)]
20pub struct Invocation {
21    /// Program and arguments.
22    pub argv: Vec<String>,
23    /// Absolute working directory.
24    pub cwd: PathBuf,
25    /// The complete child environment.
26    pub env: Vec<(String, String)>,
27    /// Deadline for the process group.
28    pub timeout: Duration,
29    /// Bytes kept per stream.
30    pub output_cap: usize,
31}
32
33/// Captured output of one stream.
34#[derive(Debug, Clone, Default)]
35pub struct Captured {
36    /// The first `output_cap` bytes.
37    pub kept: Vec<u8>,
38    /// Total bytes written.
39    pub total: usize,
40}
41
42impl Captured {
43    /// Whether output was cut off.
44    #[must_use]
45    pub fn truncated(&self) -> bool {
46        self.total > self.kept.len()
47    }
48}
49
50/// What happened.
51#[derive(Debug, Clone)]
52pub struct Outcome {
53    /// Exit code, if the process exited normally.
54    pub exit_code: Option<i32>,
55    /// Killed at the deadline.
56    pub timed_out: bool,
57    /// The process could not be started.
58    pub spawn_error: Option<String>,
59    /// When it was started (for the stale-report rule).
60    pub started: SystemTime,
61    /// Wall-clock duration.
62    pub duration: Duration,
63    /// Standard output.
64    pub stdout: Captured,
65    /// Standard error.
66    pub stderr: Captured,
67}
68
69/// Runs invocations. The verifier depends only on this trait.
70pub trait CommandRunner: Send + Sync {
71    /// Runs `inv` to completion or deadline.
72    fn run(&self, inv: &Invocation) -> Outcome;
73}
74
75/// Runs processes on this machine.
76#[derive(Debug, Default, Clone, Copy)]
77pub struct LocalRunner;
78
79impl CommandRunner for LocalRunner {
80    fn run(&self, inv: &Invocation) -> Outcome {
81        let started = SystemTime::now();
82        let clock = Instant::now();
83        let failed = |e: String| Outcome {
84            exit_code: None,
85            timed_out: false,
86            spawn_error: Some(e),
87            started,
88            duration: clock.elapsed(),
89            stdout: Captured::default(),
90            stderr: Captured::default(),
91        };
92        let Some((program, args)) = inv.argv.split_first() else {
93            return failed("empty argv".to_owned());
94        };
95
96        let mut cmd = Command::new(program);
97        cmd.args(args)
98            .current_dir(&inv.cwd)
99            .env_clear()
100            .envs(inv.env.iter().map(|(k, v)| (k, v)))
101            .stdin(Stdio::null())
102            .stdout(Stdio::piped())
103            .stderr(Stdio::piped());
104        #[cfg(unix)]
105        std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
106
107        let mut child = match cmd.spawn() {
108            Ok(c) => c,
109            Err(e) => return failed(format!("could not start `{program}`: {e}")),
110        };
111        let pgid = i32::try_from(child.id()).ok().map(Pid::from_raw);
112        let cap = inv.output_cap;
113        let out = child
114            .stdout
115            .take()
116            .map(|s| thread::spawn(move || capture(s, cap)));
117        let err = child
118            .stderr
119            .take()
120            .map(|s| thread::spawn(move || capture(s, cap)));
121
122        let mut timed_out = false;
123        let status = loop {
124            match child.try_wait() {
125                Ok(Some(status)) => break Some(status),
126                Ok(None) if clock.elapsed() >= inv.timeout => {
127                    timed_out = true;
128                    if let Some(pgid) = pgid {
129                        let _ = killpg(pgid, Signal::SIGKILL);
130                    }
131                    break child.wait().ok();
132                }
133                Ok(None) => thread::sleep(Duration::from_millis(10)),
134                Err(_) => break None,
135            }
136        };
137        // Stragglers that outlived the leader (daemons started by tests) go too.
138        if let Some(pgid) = pgid {
139            let _ = killpg(pgid, Signal::SIGKILL);
140        }
141        let join = |h: Option<thread::JoinHandle<Captured>>| {
142            h.and_then(|h| h.join().ok()).unwrap_or_default()
143        };
144        Outcome {
145            exit_code: if timed_out {
146                None
147            } else {
148                status.and_then(|s| s.code())
149            },
150            timed_out,
151            spawn_error: None,
152            started,
153            duration: clock.elapsed(),
154            stdout: join(out),
155            stderr: join(err),
156        }
157    }
158}
159
160fn capture(mut stream: impl Read, cap: usize) -> Captured {
161    let mut captured = Captured::default();
162    let mut buf = [0u8; 8192];
163    while let Ok(n) = stream.read(&mut buf) {
164        if n == 0 {
165            break;
166        }
167        captured.total += n;
168        let room = cap.saturating_sub(captured.kept.len());
169        captured.kept.extend_from_slice(&buf[..n.min(room)]);
170    }
171    captured
172}
173
174/// The child environment: everything except secret-looking variables, plus
175/// `CI=1` and `GIT_TERMINAL_PROMPT=0`. Not a security boundary — that is
176/// the OS sandbox (`crate::sandbox`).
177#[must_use]
178pub fn scrubbed_env(vars: impl IntoIterator<Item = (String, String)>) -> Vec<(String, String)> {
179    let mut env: Vec<(String, String)> = vars
180        .into_iter()
181        .filter(|(k, _)| !is_secret(k))
182        .filter(|(k, _)| !OURS.contains(&k.as_str()) && !FORCES_COLOUR.contains(&k.as_str()))
183        .collect();
184    env.push(("CI".to_owned(), "1".to_owned()));
185    env.push(("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()));
186    // Output is parsed, not shown: colour would hide a compile error's code
187    // from the summary and turn a red test into an unexplained failure.
188    env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
189    env.push(("NO_COLOR".to_owned(), "1".to_owned()));
190    env.sort();
191    env
192}
193
194/// Set by [`scrubbed_env`] itself.
195const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
196
197/// Variables that force colour on whatever the output goes to.
198const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
199
200/// Whether a variable name looks like a credential.
201#[must_use]
202pub fn is_secret(name: &str) -> bool {
203    let n = name.to_ascii_uppercase();
204    n.ends_with("_API_KEY")
205        || n.ends_with("_TOKEN")
206        || n.contains("SECRET")
207        || n.contains("PASSWORD")
208        || matches!(n.as_str(), "SSH_AUTH_SOCK" | "GIT_ASKPASS" | "SSH_ASKPASS")
209        || n.starts_with("AWS_")
210        || n.starts_with("ANTHROPIC_")
211        || n.starts_with("OPENAI_")
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn scrubs_secret_patterns_and_keeps_the_rest() {
220        let vars = [
221            ("PATH", "/bin"),
222            ("HOME", "/h"),
223            ("ANTHROPIC_API_KEY", "k"),
224            ("GITHUB_TOKEN", "t"),
225            ("NPM_TOKEN", "t"),
226            ("DB_PASSWORD", "p"),
227            ("MY_SECRET_THING", "s"),
228            ("AWS_PROFILE", "x"),
229            ("SSH_AUTH_SOCK", "/s"),
230            ("CI", "false"),
231            ("RUSTUP_HOME", "/r"),
232            ("CARGO_TERM_COLOR", "always"),
233            ("FORCE_COLOR", "1"),
234        ]
235        .map(|(k, v)| (k.to_owned(), v.to_owned()));
236        let keys: Vec<_> = scrubbed_env(vars)
237            .into_iter()
238            .map(|(k, v)| format!("{k}={v}"))
239            .collect();
240        assert_eq!(
241            keys,
242            [
243                "CARGO_TERM_COLOR=never",
244                "CI=1",
245                "GIT_TERMINAL_PROMPT=0",
246                "HOME=/h",
247                "NO_COLOR=1",
248                "PATH=/bin",
249                "RUSTUP_HOME=/r"
250            ]
251        );
252    }
253}