speccoder_runtime/verify/
process.rs1use 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
15pub const OUTPUT_CAP: usize = 1024 * 1024;
17
18#[derive(Debug, Clone)]
20pub struct Invocation {
21 pub argv: Vec<String>,
23 pub cwd: PathBuf,
25 pub env: Vec<(String, String)>,
27 pub timeout: Duration,
29 pub output_cap: usize,
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct Captured {
36 pub kept: Vec<u8>,
38 pub total: usize,
40}
41
42impl Captured {
43 #[must_use]
45 pub fn truncated(&self) -> bool {
46 self.total > self.kept.len()
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct Outcome {
53 pub exit_code: Option<i32>,
55 pub timed_out: bool,
57 pub spawn_error: Option<String>,
59 pub started: SystemTime,
61 pub duration: Duration,
63 pub stdout: Captured,
65 pub stderr: Captured,
67}
68
69pub trait CommandRunner: Send + Sync {
71 fn run(&self, inv: &Invocation) -> Outcome;
73}
74
75#[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 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#[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 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
195const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
197
198const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
201
202const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
204
205#[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}