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        // A little past the cap: a value straddling it is masked whole
114        // before the cut.
115        let keep = cap.saturating_add(crate::secrets::longest());
116        let out = child
117            .stdout
118            .take()
119            .map(|s| thread::spawn(move || capture(s, keep)));
120        let err = child
121            .stderr
122            .take()
123            .map(|s| thread::spawn(move || capture(s, keep)));
124
125        let mut timed_out = false;
126        let status = loop {
127            match child.try_wait() {
128                Ok(Some(status)) => break Some(status),
129                Ok(None) if clock.elapsed() >= inv.timeout => {
130                    timed_out = true;
131                    if let Some(pgid) = pgid {
132                        let _ = killpg(pgid, Signal::SIGKILL);
133                    }
134                    break child.wait().ok();
135                }
136                Ok(None) => thread::sleep(Duration::from_millis(10)),
137                Err(_) => break None,
138            }
139        };
140        // Stragglers that outlived the leader (daemons started by tests) go too.
141        if let Some(pgid) = pgid {
142            let _ = killpg(pgid, Signal::SIGKILL);
143        }
144        // What a command printed of a value copied from the person's secret
145        // files is masked before anything reads it ([`crate::secrets`]).
146        let join = |h: Option<thread::JoinHandle<Captured>>| {
147            let mut c: Captured = h.and_then(|h| h.join().ok()).unwrap_or_default();
148            let cut = c.total > cap;
149            c.kept = crate::secrets::mask_bytes(&c.kept);
150            c.kept.truncate(cap);
151            // Truncated only when the output was longer than the cap.
152            if !cut {
153                c.total = c.kept.len();
154            }
155            c
156        };
157        Outcome {
158            exit_code: if timed_out {
159                None
160            } else {
161                status.and_then(|s| s.code())
162            },
163            timed_out,
164            spawn_error: None,
165            started,
166            duration: clock.elapsed(),
167            stdout: join(out),
168            stderr: join(err),
169        }
170    }
171}
172
173fn capture(mut stream: impl Read, cap: usize) -> Captured {
174    let mut captured = Captured::default();
175    let mut buf = [0u8; 8192];
176    while let Ok(n) = stream.read(&mut buf) {
177        if n == 0 {
178            break;
179        }
180        captured.total += n;
181        let room = cap.saturating_sub(captured.kept.len());
182        captured.kept.extend_from_slice(&buf[..n.min(room)]);
183    }
184    captured
185}
186
187/// The child environment: everything except secret-looking variables and
188/// build-output redirections, plus `CI=1` and `GIT_TERMINAL_PROMPT=0`. Not a security boundary — that is
189/// the OS sandbox (`crate::sandbox`).
190#[must_use]
191pub fn scrubbed_env(vars: impl IntoIterator<Item = (String, String)>) -> Vec<(String, String)> {
192    let mut env: Vec<(String, String)> = vars
193        .into_iter()
194        .filter(|(k, _)| !is_secret(k))
195        .filter(|(k, _)| !OURS.contains(&k.as_str()) && !FORCES_COLOUR.contains(&k.as_str()))
196        .filter(|(k, _)| !BUILD_ELSEWHERE.contains(&k.as_str()))
197        .collect();
198    env.push(("CI".to_owned(), "1".to_owned()));
199    env.push(("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()));
200    // Output is parsed, not shown: colour would hide a compile error's code
201    // from the summary and turn a red test into an unexplained failure.
202    env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
203    env.push(("NO_COLOR".to_owned(), "1".to_owned()));
204    env.sort();
205    env
206}
207
208/// Set by [`scrubbed_env`] itself.
209const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
210
211/// Variables that send build output outside the worktree, where the sandbox
212/// lets nothing write (and where it would mix with the checkout's builds).
213const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
214
215/// Variables that force colour on whatever the output goes to.
216const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
217
218/// Whether a variable name looks like a credential.
219#[must_use]
220pub fn is_secret(name: &str) -> bool {
221    let n = name.to_ascii_uppercase();
222    n.ends_with("_API_KEY")
223        || n.ends_with("_TOKEN")
224        || n.contains("SECRET")
225        || n.contains("PASSWORD")
226        || matches!(n.as_str(), "SSH_AUTH_SOCK" | "GIT_ASKPASS" | "SSH_ASKPASS")
227        || n.starts_with("AWS_")
228        || n.starts_with("ANTHROPIC_")
229        || n.starts_with("OPENAI_")
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn scrubs_secret_patterns_and_keeps_the_rest() {
238        let vars = [
239            ("PATH", "/bin"),
240            ("HOME", "/h"),
241            ("ANTHROPIC_API_KEY", "k"),
242            ("GITHUB_TOKEN", "t"),
243            ("NPM_TOKEN", "t"),
244            ("DB_PASSWORD", "p"),
245            ("MY_SECRET_THING", "s"),
246            ("AWS_PROFILE", "x"),
247            ("SSH_AUTH_SOCK", "/s"),
248            ("CI", "false"),
249            ("RUSTUP_HOME", "/r"),
250            ("CARGO_TERM_COLOR", "always"),
251            ("FORCE_COLOR", "1"),
252            ("CARGO_TARGET_DIR", "/elsewhere"),
253            ("CARGO_BUILD_TARGET_DIR", "/elsewhere"),
254        ]
255        .map(|(k, v)| (k.to_owned(), v.to_owned()));
256        let keys: Vec<_> = scrubbed_env(vars)
257            .into_iter()
258            .map(|(k, v)| format!("{k}={v}"))
259            .collect();
260        assert_eq!(
261            keys,
262            [
263                "CARGO_TERM_COLOR=never",
264                "CI=1",
265                "GIT_TERMINAL_PROMPT=0",
266                "HOME=/h",
267                "NO_COLOR=1",
268                "PATH=/bin",
269                "RUSTUP_HOME=/r"
270            ]
271        );
272    }
273
274    #[test]
275    fn a_value_straddling_the_cap_is_masked_before_the_cut() {
276        let value = "straddle-Pq7r-the-cut";
277        crate::secrets::register("STRADDLE", value);
278        let out = LocalRunner.run(&Invocation {
279            argv: vec![
280                "sh".to_owned(),
281                "-c".to_owned(),
282                format!("printf '%s' 0123456789{value}"),
283            ],
284            cwd: std::env::temp_dir(),
285            env: Vec::new(),
286            timeout: Duration::from_secs(10),
287            output_cap: 16,
288        });
289        let kept = String::from_utf8_lossy(&out.stdout.kept).into_owned();
290        assert_eq!(kept, "0123456789[redac");
291        assert!(out.stdout.truncated());
292    }
293}