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 pub sandbox: speccoder_core::verify::Denials,
69}
70
71pub trait CommandRunner: Send + Sync {
73 fn run(&self, inv: &Invocation) -> Outcome;
75}
76
77#[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
119pub(crate) trait Supervised {
121 fn child(&mut self) -> &mut std::process::Child;
123 fn kill(&mut self);
127}
128
129struct 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
147pub(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 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 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 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#[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 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
247const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
249
250const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
253
254const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
256
257#[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}