1use std::io::{BufRead, BufReader};
17use std::path::Path;
18use std::process::{Child, Command, ExitStatus, Stdio};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::mpsc;
22use std::time::{Duration, Instant};
23
24use speccoder_core::{CommandResult, CommandStatus};
25
26pub const TIMEOUT: Duration = Duration::from_secs(600);
28
29const GRACE: Duration = Duration::from_secs(5);
31
32const DRAIN: Duration = Duration::from_millis(500);
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Stream {
39 Out,
41 Err,
43}
44
45impl Stream {
46 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::Out => "stdout",
51 Self::Err => "stderr",
52 }
53 }
54}
55
56#[must_use]
58pub fn shown(argvs: &[Vec<String>]) -> String {
59 speccoder_core::step::render(argvs)
60}
61
62enum End {
64 Exited(ExitStatus),
65 Stopped,
66 OutOfTime,
67 Broken(String),
68}
69
70fn signal(child: &Child, sig: nix::sys::signal::Signal) {
72 if let Ok(pid) = i32::try_from(child.id()) {
73 let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid), sig);
74 }
75}
76
77fn end_group(child: &mut Child, first: nix::sys::signal::Signal) {
79 signal(child, first);
80 let until = Instant::now() + GRACE;
81 while Instant::now() < until {
82 if matches!(child.try_wait(), Ok(Some(_))) {
83 break;
84 }
85 std::thread::sleep(Duration::from_millis(50));
86 }
87 signal(child, nix::sys::signal::Signal::SIGKILL);
88 let _ = child.wait();
89}
90
91fn spawn(mut cmd: Command, root: &Path) -> std::io::Result<Child> {
93 cmd.current_dir(root)
94 .stdin(Stdio::null())
95 .stdout(Stdio::piped())
96 .stderr(Stdio::piped());
97 std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
98 cmd.spawn()
99}
100
101fn follow(
105 child: &mut Child,
106 cancel: &Arc<AtomicBool>,
107 deadline: Option<Instant>,
108 stop: nix::sys::signal::Signal,
109 line: &mut dyn FnMut(Stream, &str),
110) -> End {
111 let (tx, rx) = mpsc::channel::<(Stream, String)>();
112 for (stream, pipe) in [
113 (
114 Stream::Out,
115 child
116 .stdout
117 .take()
118 .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
119 ),
120 (
121 Stream::Err,
122 child
123 .stderr
124 .take()
125 .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
126 ),
127 ] {
128 if let Some(pipe) = pipe {
129 let tx = tx.clone();
130 std::thread::spawn(move || {
131 for l in BufReader::new(pipe).lines().map_while(Result::ok) {
132 if tx.send((stream, l)).is_err() {
133 break;
134 }
135 }
136 });
137 }
138 }
139 drop(tx);
140 let mut exited: Option<(ExitStatus, Instant)> = None;
141 let mut open = true;
142 loop {
143 if open {
144 match rx.recv_timeout(Duration::from_millis(50)) {
145 Ok((s, l)) => line(s, &crate::secrets::mask(&l)),
146 Err(mpsc::RecvTimeoutError::Timeout) => {}
147 Err(mpsc::RecvTimeoutError::Disconnected) => open = false,
148 }
149 } else {
150 std::thread::sleep(Duration::from_millis(50));
151 }
152 if !open && let Some((s, _)) = exited {
154 return End::Exited(s);
155 }
156 if exited.is_none() {
157 match child.try_wait() {
158 Ok(Some(s)) => exited = Some((s, Instant::now())),
159 Ok(None) => {}
160 Err(e) => {
161 end_group(child, nix::sys::signal::Signal::SIGKILL);
162 return End::Broken(e.to_string());
163 }
164 }
165 }
166 if let Some((s, at)) = exited
167 && at.elapsed() >= DRAIN
168 {
169 signal(child, nix::sys::signal::Signal::SIGKILL);
171 return End::Exited(s);
172 }
173 if cancel.load(Ordering::SeqCst) {
174 end_group(child, stop);
175 return End::Stopped;
176 }
177 if deadline.is_some_and(|d| Instant::now() >= d) {
178 end_group(child, nix::sys::signal::Signal::SIGTERM);
179 return End::OutOfTime;
180 }
181 }
182}
183
184type Env = Vec<(String, String)>;
186
187fn program(
191 name: &str,
192 root: &Path,
193 env: &[(String, String)],
194) -> Result<std::path::PathBuf, String> {
195 if name.contains('/') {
196 return Err(format!("`{name}` is a path, not a program on PATH"));
197 }
198 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
199 let path = env
200 .iter()
201 .find(|(k, _)| k == "PATH")
202 .map_or("/usr/bin:/bin", |(_, v)| v.as_str());
203 path.split(':')
204 .map(Path::new)
205 .filter(|d| d.is_absolute())
206 .filter_map(|d| std::fs::canonicalize(d).ok())
207 .filter(|d| !d.starts_with(&root))
208 .find_map(|d| {
209 crate::sandbox::resolve_executable(
210 name,
211 &d,
212 &[("PATH".to_owned(), d.display().to_string())],
213 )
214 })
215 .filter(|p| !p.starts_with(&root))
216 .ok_or_else(|| format!("`{name}` is not on PATH outside the project"))
217}
218
219fn confined(
226 argv: &[String],
227 root: &Path,
228 env: &[(String, String)],
229) -> Result<(Vec<String>, Env), String> {
230 let (name, args) = argv.split_first().ok_or("an empty command")?;
231 let mut full = vec![program(name, root, env)?.display().to_string()];
232 full.extend(args.iter().cloned());
233 let mut env: Vec<(String, String)> = env
234 .iter()
235 .filter(|(k, _)| !crate::verify::process::is_secret(k))
236 .cloned()
237 .collect();
238 if speccoder_core::step::outside(argv).is_some() {
239 return Ok((full, env));
240 }
241 let Ok(backend) = crate::sandbox::backend() else {
242 return Ok((full, env));
243 };
244 let home = env
245 .iter()
246 .find(|(k, _)| k == "HOME")
247 .map(|(_, v)| std::path::PathBuf::from(v));
248 let profile =
249 crate::sandbox::Profile::new(root, &crate::sandbox::Config::default(), home.as_deref())
250 .with_network(crate::sandbox::Network::On);
251 std::fs::create_dir_all(&profile.tmp)
252 .map_err(|e| format!("the sandbox's temporary folder: {e}"))?;
253 let tmp = profile.tmp.display().to_string();
254 env.retain(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"));
255 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
256 Ok((crate::sandbox::wrap(&profile, &backend, &full)?, env))
257}
258
259#[must_use]
261pub fn sandboxed() -> bool {
262 crate::sandbox::backend().is_ok()
263}
264
265#[must_use]
269pub fn run(
270 root: &Path,
271 argvs: &[Vec<String>],
272 timeout: Duration,
273 cancel: &Arc<AtomicBool>,
274 line: &dyn Fn(Stream, &str),
275) -> CommandResult {
276 let env: Vec<(String, String)> = std::env::vars().collect();
277 run_in(root, argvs, &env, timeout, cancel, line)
278}
279
280fn run_in(
281 root: &Path,
282 argvs: &[Vec<String>],
283 env: &[(String, String)],
284 timeout: Duration,
285 cancel: &Arc<AtomicBool>,
286 line: &dyn Fn(Stream, &str),
287) -> CommandResult {
288 let what = shown(argvs);
289 let failed = |message: String| {
290 CommandResult::new("fix", CommandStatus::Error, &crate::secrets::mask(&message))
291 .with_code("FIX-FAILED")
292 };
293 let deadline = Instant::now() + timeout;
294 for argv in argvs {
295 let one = shown(std::slice::from_ref(argv));
296 let (start, vars) = match confined(argv, root, env) {
297 Ok(x) => x,
298 Err(e) => return failed(format!("`{one}` could not start: {e}")),
299 };
300 let Some((program, args)) = start.split_first() else {
301 return failed("an empty command".to_owned());
302 };
303 let mut cmd = Command::new(program);
304 cmd.args(args).env_clear().envs(vars);
305 let mut child = match spawn(cmd, root) {
306 Ok(c) => c,
307 Err(e) => return failed(format!("`{one}` could not start: {e}")),
308 };
309 match follow(
310 &mut child,
311 cancel,
312 Some(deadline),
313 nix::sys::signal::Signal::SIGTERM,
314 &mut |s, l| line(s, l),
315 ) {
316 End::Exited(s) if s.success() => {}
317 End::Exited(s) => {
318 let code = s
319 .code()
320 .map_or_else(|| "a signal".to_owned(), |c| c.to_string());
321 return failed(format!("`{one}` exited with {code}"));
322 }
323 End::Stopped => return failed(format!("`{what}` was stopped")),
324 End::OutOfTime => return failed(format!("`{what}` ran out of time")),
325 End::Broken(e) => return failed(format!("`{what}`: {e}")),
326 }
327 }
328 CommandResult::ok("fix", &crate::secrets::mask(&format!("`{what}` finished")))
329}
330
331pub fn speccoder(
340 root: &Path,
341 args: &[String],
342 cancel: &Arc<AtomicBool>,
343 line: &dyn Fn(Stream, &str),
344) -> Result<serde_json::Value, String> {
345 let exe =
346 std::env::current_exe().map_err(|e| format!("speccoder could not find itself: {e}"))?;
347 let mut cmd = Command::new(exe);
348 cmd.args(args).arg("--json");
349 let mut child = spawn(cmd, root).map_err(|e| format!("speccoder could not start: {e}"))?;
350 let mut out = String::new();
351 let end = follow(
352 &mut child,
353 cancel,
354 None,
355 nix::sys::signal::Signal::SIGINT,
356 &mut |s, l| match s {
357 Stream::Out => {
358 out.push_str(l);
359 out.push('\n');
360 }
361 Stream::Err => line(s, l),
362 },
363 );
364 let result = serde_json::from_str::<serde_json::Value>(out.trim()).ok();
365 match (end, result) {
366 (_, Some(v)) if v.is_object() => Ok(v),
367 (End::Stopped, _) => Err("it was stopped".to_owned()),
368 (End::Broken(e), _) => Err(e),
369 _ => Err("it gave no result".to_owned()),
370 }
371}
372
373pub fn open_terminal(root: &Path) -> Result<(), String> {
380 let started = if cfg!(target_os = "macos") {
381 Command::new("open")
382 .args(["-a", "Terminal"])
383 .arg(root)
384 .status()
385 .is_ok_and(|s| s.success())
386 } else {
387 let program = std::env::var("TERMINAL")
388 .ok()
389 .filter(|t| !t.trim().is_empty())
390 .unwrap_or_else(|| "x-terminal-emulator".to_owned());
391 Command::new(program)
392 .current_dir(root)
393 .stdin(Stdio::null())
394 .stdout(Stdio::null())
395 .stderr(Stdio::null())
396 .spawn()
397 .is_ok()
398 };
399 if started {
400 Ok(())
401 } else {
402 Err("no terminal could be opened here".to_owned())
403 }
404}
405
406#[cfg(test)]
407#[cfg(unix)]
408mod tests {
409 use std::sync::Mutex;
410
411 use super::*;
412
413 fn s(v: &[&str]) -> Vec<String> {
414 v.iter().map(|w| (*w).to_owned()).collect()
415 }
416
417 #[test]
418 fn it_runs_in_the_folder_streams_and_stops_at_the_first_failure() {
419 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
420 let lines = Mutex::new(Vec::new());
421 let r = run(
422 dir.path(),
423 &[
424 s(&["sh", "-c", "echo here; pwd; echo oops >&2"]),
425 s(&["false"]),
426 s(&["touch", "never"]),
427 ],
428 TIMEOUT,
429 &Arc::new(AtomicBool::new(false)),
430 &|stream, l| {
431 lines
432 .lock()
433 .unwrap_or_else(|e| e.into_inner())
434 .push((stream, l.to_owned()))
435 },
436 );
437 assert_eq!(r.status(), CommandStatus::Error);
438 assert_eq!(r.code().map(|c| c.name), Some("FIX-FAILED"));
439 assert_eq!(r.message(), "`false` exited with 1");
440 assert!(!dir.path().join("never").exists());
441 let lines = lines.into_inner().unwrap_or_default();
442 assert!(
443 lines.contains(&(Stream::Out, "here".to_owned())),
444 "{lines:?}"
445 );
446 assert!(
447 lines.contains(&(Stream::Err, "oops".to_owned())),
448 "{lines:?}"
449 );
450 let canonical = std::fs::canonicalize(dir.path()).unwrap_or_default();
451 assert!(
452 lines
453 .iter()
454 .any(|(_, l)| Path::new(l) == canonical || Path::new(l) == dir.path()),
455 "{lines:?}"
456 );
457 let ok = run(
458 dir.path(),
459 &[s(&["true"])],
460 TIMEOUT,
461 &Arc::new(AtomicBool::new(false)),
462 &|_, _| {},
463 );
464 assert_eq!(
465 (ok.status(), ok.message()),
466 (CommandStatus::Ok, "`true` finished")
467 );
468 }
469
470 #[test]
471 fn it_can_be_stopped_and_runs_out_of_time() {
472 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
473 let cancel = Arc::new(AtomicBool::new(false));
474 let flag = Arc::clone(&cancel);
475 std::thread::spawn(move || {
476 std::thread::sleep(Duration::from_millis(200));
477 flag.store(true, Ordering::SeqCst);
478 });
479 let started = Instant::now();
480 let r = run(
481 dir.path(),
482 &[s(&["sleep", "30"])],
483 TIMEOUT,
484 &cancel,
485 &|_, _| {},
486 );
487 assert_eq!(r.message(), "`sleep 30` was stopped");
488 assert!(started.elapsed() < Duration::from_secs(10));
489 let r = run(
490 dir.path(),
491 &[s(&["sleep", "30"])],
492 Duration::from_millis(300),
493 &Arc::new(AtomicBool::new(false)),
494 &|_, _| {},
495 );
496 assert_eq!(r.message(), "`sleep 30` ran out of time");
497 }
498
499 #[test]
500 fn its_output_is_masked() {
501 crate::secrets::register("PERSON_TEST_TOKEN", "person-run-Qx7 value");
502 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
503 let lines = Mutex::new(Vec::new());
504 let _ = run(
505 dir.path(),
506 &[s(&["echo", "token person-run-Qx7 value"])],
507 TIMEOUT,
508 &Arc::new(AtomicBool::new(false)),
509 &|_, l| {
510 lines
511 .lock()
512 .unwrap_or_else(|e| e.into_inner())
513 .push(l.to_owned())
514 },
515 );
516 let lines = lines.into_inner().unwrap_or_default();
517 assert_eq!(lines, ["token [redacted:PERSON_TEST_TOKEN]"]);
518 }
519
520 #[test]
521 fn what_it_shows_is_what_a_person_types() {
522 assert_eq!(
523 shown(&[
524 s(&["git", "add", "speccoder.toml"]),
525 s(&["git", "commit", "-m", "speccoder: config"])
526 ]),
527 "git add speccoder.toml && git commit -m 'speccoder: config'"
528 );
529 }
530
531 fn alive(pid: &str) -> bool {
532 std::process::Command::new("kill")
533 .args(["-0", pid])
534 .status()
535 .is_ok_and(|s| s.success())
536 }
537
538 #[test]
539 fn a_stop_ends_everything_it_started_and_the_job_ends() {
540 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
541 let cancel = Arc::new(AtomicBool::new(false));
542 let flag = Arc::clone(&cancel);
543 std::thread::spawn(move || {
544 std::thread::sleep(Duration::from_millis(400));
545 flag.store(true, Ordering::SeqCst);
546 });
547 let pids = Mutex::new(Vec::new());
548 let started = Instant::now();
549 let r = run(
550 dir.path(),
551 &[s(&["sh", "-c", "sleep 30 & echo $!; sleep 30"])],
552 TIMEOUT,
553 &cancel,
554 &|_, l| {
555 pids.lock()
556 .unwrap_or_else(|e| e.into_inner())
557 .push(l.to_owned())
558 },
559 );
560 assert!(r.message().ends_with("was stopped"), "{}", r.message());
561 assert!(
562 started.elapsed() < Duration::from_secs(10),
563 "{:?}",
564 started.elapsed()
565 );
566 let pids = pids.into_inner().unwrap_or_default();
567 let background = pids.first().cloned().unwrap_or_default();
568 assert!(!background.is_empty());
569 std::thread::sleep(Duration::from_millis(200));
570 assert!(!alive(&background), "{background} survived the stop");
571 }
572
573 #[test]
574 fn it_runs_confined_to_the_project_where_there_is_a_sandbox() {
575 if !sandboxed() {
576 return;
577 }
578 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
579 let outside = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
580 let escaped = outside.path().join("escaped");
581 let r = run(
582 dir.path(),
583 &[s(&[
584 "sh",
585 "-c",
586 &format!("touch inside && touch '{}'", escaped.display()),
587 ])],
588 TIMEOUT,
589 &Arc::new(AtomicBool::new(false)),
590 &|_, _| {},
591 );
592 assert_eq!(r.status(), CommandStatus::Error, "{}", r.message());
593 assert!(dir.path().join("inside").exists());
594 assert!(!escaped.exists(), "a write outside the project got through");
595 }
596
597 #[test]
598 fn its_program_is_never_one_the_project_brings() {
599 use std::os::unix::fs::PermissionsExt as _;
600 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
601 let bin = dir.path().join("node_modules/.bin");
602 std::fs::create_dir_all(&bin).unwrap_or_else(|e| panic!("{e}"));
603 let fake = bin.join("true");
604 std::fs::write(&fake, "#!/bin/sh\ntouch planted\n").unwrap_or_else(|e| panic!("{e}"));
605 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755))
606 .unwrap_or_else(|e| panic!("{e}"));
607 std::fs::copy(&fake, dir.path().join("true")).unwrap_or_else(|e| panic!("{e}"));
608 let env = vec![(
609 "PATH".to_owned(),
610 format!(".:node_modules/.bin:{}:/usr/bin:/bin", bin.display()),
611 )];
612 let r = run_in(
613 dir.path(),
614 &[s(&["true"])],
615 &env,
616 TIMEOUT,
617 &Arc::new(AtomicBool::new(false)),
618 &|_, _| {},
619 );
620 assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
621 assert!(!dir.path().join("planted").exists());
622 let only = vec![("PATH".to_owned(), format!(".:{}", bin.display()))];
624 let r = run_in(
625 dir.path(),
626 &[s(&["true"])],
627 &only,
628 TIMEOUT,
629 &Arc::new(AtomicBool::new(false)),
630 &|_, _| {},
631 );
632 assert!(
633 r.message().contains("not on PATH outside the project"),
634 "{}",
635 r.message()
636 );
637 assert!(!dir.path().join("planted").exists());
638 }
639
640 #[test]
641 fn what_it_leaves_running_does_not_hold_the_job() {
642 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
643 let started = Instant::now();
644 let r = run(
645 dir.path(),
646 &[s(&["sh", "-c", "sleep 30 & echo started"])],
647 TIMEOUT,
648 &Arc::new(AtomicBool::new(false)),
649 &|_, _| {},
650 );
651 assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
652 assert!(
653 started.elapsed() < Duration::from_secs(10),
654 "{:?}",
655 started.elapsed()
656 );
657 }
658}