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