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 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 if let Some(pgid) = pgid {
142 let _ = killpg(pgid, Signal::SIGKILL);
143 }
144 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 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#[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 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
208const OURS: &[&str] = &["CI", "GIT_TERMINAL_PROMPT", "CARGO_TERM_COLOR", "NO_COLOR"];
210
211const BUILD_ELSEWHERE: &[&str] = &["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"];
214
215const FORCES_COLOUR: &[&str] = &["FORCE_COLOR", "CLICOLOR_FORCE", "PY_COLORS"];
217
218#[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}