speccoder-runtime 0.11.0

Application services of speccoder: the only crate that executes anything.
Documentation
//! Running a check: argv only, no stdin, scrubbed environment, a deadline for
//! the whole process group, capped output.
//!
//! [`CommandRunner`] is the seam the sandbox wraps and tests replace.

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;

/// Default cap per output stream.
pub const OUTPUT_CAP: usize = 1024 * 1024;

/// What to run.
#[derive(Debug, Clone)]
pub struct Invocation {
    /// Program and arguments.
    pub argv: Vec<String>,
    /// Absolute working directory.
    pub cwd: PathBuf,
    /// The complete child environment.
    pub env: Vec<(String, String)>,
    /// Deadline for the process group.
    pub timeout: Duration,
    /// Bytes kept per stream.
    pub output_cap: usize,
}

/// Captured output of one stream.
#[derive(Debug, Clone, Default)]
pub struct Captured {
    /// The first `output_cap` bytes.
    pub kept: Vec<u8>,
    /// Total bytes written.
    pub total: usize,
}

impl Captured {
    /// Whether output was cut off.
    #[must_use]
    pub fn truncated(&self) -> bool {
        self.total > self.kept.len()
    }
}

/// What happened.
#[derive(Debug, Clone)]
pub struct Outcome {
    /// Exit code, if the process exited normally.
    pub exit_code: Option<i32>,
    /// Killed at the deadline.
    pub timed_out: bool,
    /// The process could not be started.
    pub spawn_error: Option<String>,
    /// When it was started (for the stale-report rule).
    pub started: SystemTime,
    /// Wall-clock duration.
    pub duration: Duration,
    /// Standard output.
    pub stdout: Captured,
    /// Standard error.
    pub stderr: Captured,
    /// What the sandbox denied, read only when the command failed.
    pub sandbox: speccoder_core::verify::Denials,
}

/// Runs invocations. The verifier depends only on this trait.
pub trait CommandRunner: Send + Sync {
    /// Runs `inv` to completion or deadline.
    fn run(&self, inv: &Invocation) -> Outcome;
}

/// Runs processes on this machine.
#[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)
    }
}

/// A started command [`supervise`] waits for.
pub(crate) trait Supervised {
    /// The process.
    fn child(&mut self) -> &mut std::process::Child;
    /// Ends what the command started: at the deadline, and again once it
    /// ended (stragglers that outlived the leader, such as daemons started
    /// by tests).
    fn kill(&mut self);
}

/// A child leading its own process group.
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);
        }
    }
}

/// Waits for `s` (started at `started`, timed by `clock`) until it exits or
/// `inv`'s deadline passes, capturing its output.
pub(crate) fn supervise(
    s: &mut impl Supervised,
    inv: &Invocation,
    started: SystemTime,
    clock: Instant,
) -> Outcome {
    let child = s.child();
    let cap = inv.output_cap;
    // A little past the cap: a value straddling it is masked whole
    // before the cut.
    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();
    // What a command printed of a value copied from the person's secret
    // files is masked before anything reads it ([`crate::secrets`]).
    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);
        // Truncated only when the output was longer than the 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
}

/// The child environment: everything except secret-looking variables and
/// build-output redirections, plus `CI=1` and `GIT_TERMINAL_PROMPT=0`. Not a security boundary — that is
/// the OS sandbox (`crate::sandbox`).
#[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()));
    // Output is parsed, not shown: colour would hide a compile error's code
    // from the summary and turn a red test into an unexplained failure.
    env.push(("CARGO_TERM_COLOR".to_owned(), "never".to_owned()));
    env.push(("NO_COLOR".to_owned(), "1".to_owned()));
    env.sort();
    env
}

/// Set by [`scrubbed_env`] itself.
const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];

/// Variables that send build output outside the worktree, where the sandbox
/// lets nothing write (and where it would mix with the checkout's builds).
const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];

/// Variables that force colour on whatever the output goes to.
const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];

/// Whether a variable name looks like a credential.
#[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());
    }
}