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    /// What the sandbox denied, read only when the command failed.
68    pub sandbox: speccoder_core::verify::Denials,
69}
70
71/// Runs invocations. The verifier depends only on this trait.
72pub trait CommandRunner: Send + Sync {
73    /// Runs `inv` to completion or deadline.
74    fn run(&self, inv: &Invocation) -> Outcome;
75}
76
77/// Runs processes on this machine.
78#[derive(Debug, Default, Clone, Copy)]
79pub struct LocalRunner;
80
81impl CommandRunner for LocalRunner {
82    fn run(&self, inv: &Invocation) -> Outcome {
83        let started = SystemTime::now();
84        let clock = Instant::now();
85        let failed = |e: String| Outcome {
86            exit_code: None,
87            timed_out: false,
88            spawn_error: Some(e),
89            started,
90            duration: clock.elapsed(),
91            stdout: Captured::default(),
92            stderr: Captured::default(),
93            sandbox: Default::default(),
94        };
95        let Some((program, args)) = inv.argv.split_first() else {
96            return failed("empty argv".to_owned());
97        };
98
99        let mut cmd = Command::new(program);
100        cmd.args(args)
101            .current_dir(&inv.cwd)
102            .env_clear()
103            .envs(inv.env.iter().map(|(k, v)| (k, v)))
104            .stdin(Stdio::null())
105            .stdout(Stdio::piped())
106            .stderr(Stdio::piped());
107        #[cfg(unix)]
108        std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
109
110        let child = match cmd.spawn() {
111            Ok(c) => c,
112            Err(e) => return failed(format!("could not start `{program}`: {e}")),
113        };
114        let pgid = i32::try_from(child.id()).ok().map(Pid::from_raw);
115        supervise(&mut Grouped { child, pgid }, inv, started, clock)
116    }
117}
118
119/// A started command [`supervise`] waits for.
120pub(crate) trait Supervised {
121    /// The process.
122    fn child(&mut self) -> &mut std::process::Child;
123    /// Ends what the command started: at the deadline, and again once it
124    /// ended (stragglers that outlived the leader, such as daemons started
125    /// by tests).
126    fn kill(&mut self);
127}
128
129/// A child leading its own process group.
130struct Grouped {
131    child: std::process::Child,
132    pgid: Option<Pid>,
133}
134
135impl Supervised for Grouped {
136    fn child(&mut self) -> &mut std::process::Child {
137        &mut self.child
138    }
139
140    fn kill(&mut self) {
141        if let Some(pgid) = self.pgid {
142            let _ = killpg(pgid, Signal::SIGKILL);
143        }
144    }
145}
146
147/// Waits for `s` (started at `started`, timed by `clock`) until it exits or
148/// `inv`'s deadline passes, capturing its output.
149pub(crate) fn supervise(
150    s: &mut impl Supervised,
151    inv: &Invocation,
152    started: SystemTime,
153    clock: Instant,
154) -> Outcome {
155    let child = s.child();
156    let cap = inv.output_cap;
157    // A little past the cap: a value straddling it is masked whole
158    // before the cut.
159    let keep = cap.saturating_add(crate::secrets::longest());
160    let out = child
161        .stdout
162        .take()
163        .map(|s| thread::spawn(move || capture(s, keep)));
164    let err = child
165        .stderr
166        .take()
167        .map(|s| thread::spawn(move || capture(s, keep)));
168
169    let mut timed_out = false;
170    let status = loop {
171        match s.child().try_wait() {
172            Ok(Some(status)) => break Some(status),
173            Ok(None) if clock.elapsed() >= inv.timeout => {
174                timed_out = true;
175                s.kill();
176                break s.child().wait().ok();
177            }
178            Ok(None) => thread::sleep(Duration::from_millis(10)),
179            Err(_) => break None,
180        }
181    };
182    s.kill();
183    // What a command printed of a value copied from the person's secret
184    // files is masked before anything reads it ([`crate::secrets`]).
185    let join = |h: Option<thread::JoinHandle<Captured>>| {
186        let mut c: Captured = h.and_then(|h| h.join().ok()).unwrap_or_default();
187        let cut = c.total > cap;
188        c.kept = crate::secrets::mask_bytes(&c.kept);
189        c.kept.truncate(cap);
190        // Truncated only when the output was longer than the cap.
191        if !cut {
192            c.total = c.kept.len();
193        }
194        c
195    };
196    Outcome {
197        exit_code: if timed_out {
198            None
199        } else {
200            status.and_then(|s| s.code())
201        },
202        timed_out,
203        spawn_error: None,
204        started,
205        duration: clock.elapsed(),
206        stdout: join(out),
207        stderr: join(err),
208        sandbox: Default::default(),
209    }
210}
211
212fn capture(mut stream: impl Read, cap: usize) -> Captured {
213    let mut captured = Captured::default();
214    let mut buf = [0u8; 8192];
215    while let Ok(n) = stream.read(&mut buf) {
216        if n == 0 {
217            break;
218        }
219        captured.total += n;
220        let room = cap.saturating_sub(captured.kept.len());
221        captured.kept.extend_from_slice(&buf[..n.min(room)]);
222    }
223    captured
224}
225
226/// The child environment: everything except secret-looking variables and
227/// build-output redirections, plus `CI=1` and `GIT_TERMINAL_PROMPT=0`. Not a security boundary — that is
228/// the OS sandbox (`crate::sandbox`).
229#[must_use]
230pub fn scrubbed_env(vars: impl IntoIterator<Item = (String, String)>) -> Vec<(String, String)> {
231    let mut env: Vec<(String, String)> = vars
232        .into_iter()
233        .filter(|(k, _)| !is_secret(k))
234        .filter(|(k, _)| !OURS.contains(&k.as_str()) && !FORCES_COLOUR.contains(&k.as_str()))
235        .filter(|(k, _)| !BUILD_ELSEWHERE.contains(&k.as_str()))
236        .collect();
237    env.push(("CI".to_owned(), "1".to_owned()));
238    env.push(("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()));
239    // Output is parsed, not shown: colour would hide a compile error's code
240    // from the summary and turn a red test into an unexplained failure.
241    env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
242    env.push(("NO_COLOR".to_owned(), "1".to_owned()));
243    env.sort();
244    env
245}
246
247/// Set by [`scrubbed_env`] itself.
248const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
249
250/// Variables that send build output outside the worktree, where the sandbox
251/// lets nothing write (and where it would mix with the checkout's builds).
252const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
253
254/// Variables that force colour on whatever the output goes to.
255const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
256
257/// Whether a variable name looks like a credential.
258#[must_use]
259pub fn is_secret(name: &str) -> bool {
260    let n = name.to_ascii_uppercase();
261    n.ends_with("_API_KEY")
262        || n.ends_with("_TOKEN")
263        || n.contains("SECRET")
264        || n.contains("PASSWORD")
265        || matches!(n.as_str(), "SSH_AUTH_SOCK" | "GIT_ASKPASS" | "SSH_ASKPASS")
266        || n.starts_with("AWS_")
267        || n.starts_with("ANTHROPIC_")
268        || n.starts_with("OPENAI_")
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn scrubs_secret_patterns_and_keeps_the_rest() {
277        let vars = [
278            ("PATH", "/bin"),
279            ("HOME", "/h"),
280            ("ANTHROPIC_API_KEY", "k"),
281            ("GITHUB_TOKEN", "t"),
282            ("NPM_TOKEN", "t"),
283            ("DB_PASSWORD", "p"),
284            ("MY_SECRET_THING", "s"),
285            ("AWS_PROFILE", "x"),
286            ("SSH_AUTH_SOCK", "/s"),
287            ("CI", "false"),
288            ("RUSTUP_HOME", "/r"),
289            ("CARGO_TERM_COLOR", "always"),
290            ("FORCE_COLOR", "1"),
291            ("CARGO_TARGET_DIR", "/elsewhere"),
292            ("CARGO_BUILD_TARGET_DIR", "/elsewhere"),
293        ]
294        .map(|(k, v)| (k.to_owned(), v.to_owned()));
295        let keys: Vec<_> = scrubbed_env(vars)
296            .into_iter()
297            .map(|(k, v)| format!("{k}={v}"))
298            .collect();
299        assert_eq!(
300            keys,
301            [
302                "CARGO_TERM_COLOR=never",
303                "CI=1",
304                "GIT_TERMINAL_PROMPT=0",
305                "HOME=/h",
306                "NO_COLOR=1",
307                "PATH=/bin",
308                "RUSTUP_HOME=/r"
309            ]
310        );
311    }
312
313    #[test]
314    fn a_value_straddling_the_cap_is_masked_before_the_cut() {
315        let value = "straddle-Pq7r-the-cut";
316        crate::secrets::register("STRADDLE", value);
317        let out = LocalRunner.run(&Invocation {
318            argv: vec![
319                "sh".to_owned(),
320                "-c".to_owned(),
321                format!("printf '%s' 0123456789{value}"),
322            ],
323            cwd: std::env::temp_dir(),
324            env: Vec::new(),
325            timeout: Duration::from_secs(10),
326            output_cap: 16,
327        });
328        let kept = String::from_utf8_lossy(&out.stdout.kept).into_owned();
329        assert_eq!(kept, "0123456789[redac");
330        assert!(out.stdout.truncated());
331    }
332}