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 and
175/// build-output redirections, plus `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        .filter(|(k, _)| !BUILD_ELSEWHERE.contains(&k.as_str()))
184        .collect();
185    env.push(("CI".to_owned(), "1".to_owned()));
186    env.push(("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()));
187    // Output is parsed, not shown: colour would hide a compile error's code
188    // from the summary and turn a red test into an unexplained failure.
189    env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
190    env.push(("NO_COLOR".to_owned(), "1".to_owned()));
191    env.sort();
192    env
193}
194
195/// Set by [`scrubbed_env`] itself.
196const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
197
198/// Variables that send build output outside the worktree, where the sandbox
199/// lets nothing write (and where it would mix with the checkout's builds).
200const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
201
202/// Variables that force colour on whatever the output goes to.
203const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
204
205/// Whether a variable name looks like a credential.
206#[must_use]
207pub fn is_secret(name: &str) -> bool {
208    let n = name.to_ascii_uppercase();
209    n.ends_with("_API_KEY")
210        || n.ends_with("_TOKEN")
211        || n.contains("SECRET")
212        || n.contains("PASSWORD")
213        || matches!(n.as_str(), "SSH_AUTH_SOCK" | "GIT_ASKPASS" | "SSH_ASKPASS")
214        || n.starts_with("AWS_")
215        || n.starts_with("ANTHROPIC_")
216        || n.starts_with("OPENAI_")
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn scrubs_secret_patterns_and_keeps_the_rest() {
225        let vars = [
226            ("PATH", "/bin"),
227            ("HOME", "/h"),
228            ("ANTHROPIC_API_KEY", "k"),
229            ("GITHUB_TOKEN", "t"),
230            ("NPM_TOKEN", "t"),
231            ("DB_PASSWORD", "p"),
232            ("MY_SECRET_THING", "s"),
233            ("AWS_PROFILE", "x"),
234            ("SSH_AUTH_SOCK", "/s"),
235            ("CI", "false"),
236            ("RUSTUP_HOME", "/r"),
237            ("CARGO_TERM_COLOR", "always"),
238            ("FORCE_COLOR", "1"),
239            ("CARGO_TARGET_DIR", "/elsewhere"),
240            ("CARGO_BUILD_TARGET_DIR", "/elsewhere"),
241        ]
242        .map(|(k, v)| (k.to_owned(), v.to_owned()));
243        let keys: Vec<_> = scrubbed_env(vars)
244            .into_iter()
245            .map(|(k, v)| format!("{k}={v}"))
246            .collect();
247        assert_eq!(
248            keys,
249            [
250                "CARGO_TERM_COLOR=never",
251                "CI=1",
252                "GIT_TERMINAL_PROMPT=0",
253                "HOME=/h",
254                "NO_COLOR=1",
255                "PATH=/bin",
256                "RUSTUP_HOME=/r"
257            ]
258        );
259    }
260}