1#![allow(dead_code)]
11
12use std::io::Read as _;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::Duration;
16
17use thiserror::Error;
18use wait_timeout::ChildExt;
19
20const MAX_OUTPUT_BYTES: u64 = 65_536; const GATE_RUNNER_TIMEOUT_SECS: u64 = 60;
26
27#[derive(Debug, Error)]
29pub enum Error {
30 #[error("gate runner failed (exit {exit_code}):\n{stdout}\n{stderr}")]
32 GateRunnerFailed {
33 exit_code: i32,
34 stdout: String,
35 stderr: String,
36 },
37
38 #[error("gate runner timed out after {elapsed_seconds} seconds")]
40 GateRunnerTimedOut { elapsed_seconds: u64 },
41
42 #[error("failed to invoke interpreter: {0}")]
44 InterpreterInvocation(#[from] std::io::Error),
45}
46
47#[cfg(windows)]
52const CANDIDATES: &[&str] = &["py.exe", "python.exe", "python3.exe"];
53
54#[cfg(not(windows))]
55const CANDIDATES: &[&str] = &["python3", "python"];
56
57pub fn find_interpreter() -> Option<PathBuf> {
62 find_interpreter_from(CANDIDATES)
63}
64
65pub(crate) fn find_interpreter_from(candidates: &[&str]) -> Option<PathBuf> {
67 for &name in candidates {
68 if let Ok(path) = which::which(name) {
69 if health_check(&path) {
71 return Some(path);
72 }
73 }
74 }
75 None
76}
77
78fn health_check(interpreter: &Path) -> bool {
80 let child = Command::new(interpreter)
81 .arg("--version")
82 .stdout(Stdio::null())
83 .stderr(Stdio::null())
84 .spawn();
85
86 match child {
87 Ok(mut child) => {
88 let timeout = Duration::from_secs(5);
89 match child.wait_timeout(timeout) {
90 Ok(Some(status)) => status.success(),
91 Ok(None) => {
92 let _ = child.kill();
94 let _ = child.wait();
95 false
96 }
97 Err(_) => false,
98 }
99 }
100 Err(_) => false,
101 }
102}
103
104pub fn missing_python_warning() -> &'static str {
106 "Warning: no Python interpreter found on PATH.\n\
107 The gate runner check has been skipped.\n\
108 To install Python, use one of:\n\
109 - uv: https://docs.astral.sh/uv/\n\
110 - System package manager (apt install python3, winget install Python.Python.3, etc.)"
111}
112
113pub fn run_gate_runner(interpreter: &Path, script: &Path, image_dir: &Path) -> Result<(), Error> {
120 let mut child = Command::new(interpreter)
121 .arg(script)
122 .arg(image_dir)
123 .stdout(Stdio::piped())
124 .stderr(Stdio::piped())
125 .spawn()?;
126
127 let timeout = Duration::from_secs(GATE_RUNNER_TIMEOUT_SECS);
128 match child.wait_timeout(timeout)? {
129 Some(status) => {
130 let stdout = read_bounded(child.stdout.take());
132 let stderr = read_bounded(child.stderr.take());
133
134 if status.success() {
135 Ok(())
136 } else {
137 Err(Error::GateRunnerFailed {
138 exit_code: status.code().unwrap_or(-1),
139 stdout,
140 stderr,
141 })
142 }
143 }
144 None => {
145 let _ = child.kill();
147 let _ = child.wait();
148 Err(Error::GateRunnerTimedOut {
149 elapsed_seconds: GATE_RUNNER_TIMEOUT_SECS,
150 })
151 }
152 }
153}
154
155fn read_bounded<R: std::io::Read>(stream: Option<R>) -> String {
157 let Some(stream) = stream else {
158 return String::new();
159 };
160 let mut buf = Vec::new();
161 let _ = stream.take(MAX_OUTPUT_BYTES).read_to_end(&mut buf);
162 String::from_utf8_lossy(&buf).into_owned()
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use tempfile::TempDir;
169
170 #[test]
173 fn gate_runner_failed_error_contains_exit_code() {
174 let err = Error::GateRunnerFailed {
175 exit_code: 1,
176 stdout: "fail: bad config".to_string(),
177 stderr: String::new(),
178 };
179 let display = format!("{err}");
180 assert!(
181 display.contains("exit 1"),
182 "Should contain exit code: {display}"
183 );
184 assert!(
185 display.contains("fail: bad config"),
186 "Should contain stdout: {display}"
187 );
188 }
189
190 #[test]
191 fn gate_runner_timed_out_error_contains_seconds() {
192 let err = Error::GateRunnerTimedOut {
193 elapsed_seconds: 60,
194 };
195 let display = format!("{err}");
196 assert!(
197 display.contains("60"),
198 "Should contain timeout seconds: {display}"
199 );
200 }
201
202 #[test]
203 fn interpreter_invocation_error_is_transparent() {
204 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
205 let err = Error::InterpreterInvocation(io_err);
206 let display = format!("{err}");
207 assert!(
208 display.contains("not found"),
209 "Should pass through IO error: {display}"
210 );
211 }
212
213 #[test]
216 fn missing_python_warning_mentions_uv() {
217 let warning = missing_python_warning();
218 assert!(!warning.is_empty());
219 assert!(warning.contains("uv"), "Should mention uv");
220 assert!(warning.contains("install"), "Should mention install");
221 }
222
223 #[test]
226 fn find_interpreter_from_empty_candidates_returns_none() {
227 let result = find_interpreter_from(&[]);
228 assert!(result.is_none());
229 }
230
231 #[test]
232 fn find_interpreter_from_nonexistent_candidates_returns_none() {
233 let result = find_interpreter_from(&[
234 "definitely_not_a_real_interpreter_abc123",
235 "also_not_real_xyz789",
236 ]);
237 assert!(result.is_none());
238 }
239
240 fn create_synthetic_script(dir: &Path, name: &str, exit_code: i32) -> PathBuf {
245 #[cfg(unix)]
246 {
247 use std::os::unix::fs::PermissionsExt;
248 let script_path = dir.join(name);
249 let content = format!("#!/bin/sh\nexit {exit_code}\n");
250 std::fs::write(&script_path, content).unwrap();
251 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
252 script_path
253 }
254 #[cfg(windows)]
255 {
256 let script_path = dir.join(format!("{name}.cmd"));
257 let content = format!("@echo off\r\nexit /b {exit_code}\r\n");
258 std::fs::write(&script_path, content).unwrap();
259 script_path
260 }
261 }
262
263 fn create_script_with_output(
265 dir: &Path,
266 name: &str,
267 stdout_text: &str,
268 exit_code: i32,
269 ) -> PathBuf {
270 #[cfg(unix)]
271 {
272 use std::os::unix::fs::PermissionsExt;
273 let script_path = dir.join(name);
274 let content = format!("#!/bin/sh\necho '{}'\nexit {}\n", stdout_text, exit_code);
275 std::fs::write(&script_path, content).unwrap();
276 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
277 script_path
278 }
279 #[cfg(windows)]
280 {
281 let script_path = dir.join(format!("{name}.cmd"));
282 let content = format!(
283 "@echo off\r\necho {}\r\nexit /b {}\r\n",
284 stdout_text, exit_code
285 );
286 std::fs::write(&script_path, content).unwrap();
287 script_path
288 }
289 }
290
291 fn test_interpreter(dir: &Path) -> PathBuf {
298 #[cfg(unix)]
299 {
300 let _ = dir; PathBuf::from("/bin/sh")
302 }
303 #[cfg(windows)]
304 {
305 let wrapper = dir.join("run.cmd");
306 std::fs::write(&wrapper, "@echo off\r\ncmd /c %~1 %2 %3 %4\r\n").unwrap();
308 wrapper
309 }
310 }
311
312 #[test]
313 fn run_gate_runner_success_with_exit_zero() {
314 let tmp = TempDir::new().unwrap();
315 let script = create_synthetic_script(tmp.path(), "gate", 0);
316 let image_dir = tmp.path();
317
318 let interpreter = test_interpreter(tmp.path());
319 let result = run_gate_runner(&interpreter, &script, image_dir);
320 assert!(result.is_ok(), "Expected Ok, got: {result:?}");
321 }
322
323 #[test]
324 fn run_gate_runner_failure_with_nonzero_exit() {
325 let tmp = TempDir::new().unwrap();
326 let script = create_script_with_output(tmp.path(), "gate_fail", "fail: reason", 1);
327 let image_dir = tmp.path();
328
329 let interpreter = test_interpreter(tmp.path());
330 let err = run_gate_runner(&interpreter, &script, image_dir).unwrap_err();
331 match err {
332 Error::GateRunnerFailed {
333 exit_code, stdout, ..
334 } => {
335 assert_eq!(exit_code, 1);
336 assert!(
337 stdout.contains("fail: reason"),
338 "stdout should contain output: {stdout}"
339 );
340 }
341 other => panic!("Expected GateRunnerFailed, got: {other:?}"),
342 }
343 }
344
345 #[test]
346 fn run_gate_runner_nonexistent_interpreter_returns_error() {
347 let tmp = TempDir::new().unwrap();
348 let script = create_synthetic_script(tmp.path(), "gate", 0);
349 let image_dir = tmp.path();
350
351 let fake_interp = PathBuf::from("definitely_not_a_real_interpreter_abc123");
352 let err = run_gate_runner(&fake_interp, &script, image_dir).unwrap_err();
353 assert!(
354 matches!(err, Error::InterpreterInvocation(_)),
355 "Expected InterpreterInvocation, got: {err:?}"
356 );
357 }
358
359 #[test]
362 fn candidate_list_is_nonempty() {
363 assert!(CANDIDATES.len() >= 2);
366 }
367
368 #[cfg(windows)]
369 #[test]
370 fn windows_candidates_start_with_py_exe() {
371 assert_eq!(CANDIDATES[0], "py.exe");
372 }
373
374 #[cfg(unix)]
375 #[test]
376 fn unix_candidates_start_with_python3() {
377 assert_eq!(CANDIDATES[0], "python3");
378 }
379}