1use std::path::Path;
10use std::time::{Duration, Instant};
11
12use async_trait::async_trait;
13
14pub use effect_signature::{EffectSignature, LocatedEffect};
15pub use sandbox_workspace::{PatchedWorkspace, Workspace};
16
17#[derive(Debug, thiserror::Error)]
18pub enum RunnerError {
19 #[error("io error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("policy error: {0}")]
23 Policy(#[from] forge_policy::PolicyError),
24
25 #[error("workspace error: {0}")]
26 Workspace(#[from] sandbox_workspace::WorkspaceError),
27
28 #[error("command timeout after {timeout_secs}s: {command}")]
29 CommandTimeout { command: String, timeout_secs: u64 },
30
31 #[error("command failed (exit {exit_code}): {command}")]
32 CommandFailed { command: String, exit_code: i32 },
33
34 #[error("sealed mode unsupported for runtime: {runtime}")]
35 SealedModeUnsupported { runtime: String },
36
37 #[error("no container runtime found")]
38 NoContainerRuntime,
39
40 #[error("{0}")]
41 Other(String),
42}
43
44#[derive(Debug, Clone)]
45pub struct BackendConfig {
46 pub mode: String,
47 pub execution_backend_preference: String,
48 pub container_runtime_preference: String,
49 pub sealed_allow_host_backend: bool,
50 pub rust_image: String,
51 pub command_timeout_secs: u64,
52 pub memory_limit: String,
53 pub cpu_limit: String,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub enum CheckKind {
58 #[default]
59 Fmt,
60 Clippy,
61 Test,
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct ParsedCheckOutput {
66 pub check_kind: CheckKind,
67 pub exit_code: i32,
68 pub effects: Vec<LocatedEffect>,
69 pub raw_stdout: String,
70 pub raw_stderr: String,
71}
72
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct CommandTimings {
75 pub fmt_ms: u64,
76 pub clippy_ms: u64,
77 pub test_ms: u64,
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81pub struct LogBundle {
82 pub fmt_stdout: String,
83 pub fmt_stderr: String,
84 pub clippy_stdout: String,
85 pub clippy_stderr: String,
86 pub test_stdout: String,
87 pub test_stderr: String,
88 pub timings: CommandTimings,
89}
90
91#[derive(Debug, Clone)]
92pub struct CheckResult {
93 pub fmt_pass: bool,
94 pub clippy_pass: bool,
95 pub test_pass: bool,
96 pub fmt_output: ParsedCheckOutput,
97 pub clippy_output: ParsedCheckOutput,
98 pub test_output: ParsedCheckOutput,
99 pub total_duration_ms: u64,
100}
101
102impl CheckResult {
103 pub fn all_pass(&self) -> bool {
104 self.fmt_pass && self.clippy_pass && self.test_pass
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub enum ExecutionBackendKind {
110 Host,
111 Container,
112}
113
114#[derive(Debug, Clone)]
115pub struct CheckCommand {
116 pub kind: CheckKind,
117 pub program: String,
118 pub args: Vec<String>,
119 pub env: Vec<(String, String)>,
120}
121
122#[derive(Debug, Clone)]
123pub struct CommandOutput {
124 pub stdout: String,
125 pub stderr: String,
126 pub exit_code: i32,
127 pub duration_ms: u64,
128}
129
130#[async_trait]
131pub trait ExecutionBackend: Send + Sync {
132 fn kind(&self) -> ExecutionBackendKind;
133
134 async fn prepare_workspace(&self, fixture: &Path) -> Result<Workspace, RunnerError>;
135
136 async fn run_command(
137 &self,
138 workspace: &Path,
139 program: &str,
140 args: &[&str],
141 env: &[(&str, &str)],
142 timeout_secs: u64,
143 ) -> Result<CommandOutput, RunnerError>;
144
145 async fn collect_logs(
146 &self,
147 fmt: &CommandOutput,
148 clippy: &CommandOutput,
149 test: &CommandOutput,
150 ) -> Result<LogBundle, RunnerError>;
151}
152
153pub fn is_env_allowed(key: &str) -> bool {
154 forge_policy::is_env_allowed(key)
155}
156
157pub struct HostBackend {
158 timeout_secs: u64,
159}
160
161impl HostBackend {
162 pub fn new(config: &BackendConfig) -> Self {
163 Self {
164 timeout_secs: config.command_timeout_secs,
165 }
166 }
167}
168
169#[async_trait]
170impl ExecutionBackend for HostBackend {
171 fn kind(&self) -> ExecutionBackendKind {
172 ExecutionBackendKind::Host
173 }
174
175 async fn prepare_workspace(&self, fixture: &Path) -> Result<Workspace, RunnerError> {
176 Ok(sandbox_workspace::prepare_workspace(fixture)?)
177 }
178
179 async fn run_command(
180 &self,
181 workspace: &Path,
182 program: &str,
183 args: &[&str],
184 env: &[(&str, &str)],
185 timeout_secs: u64,
186 ) -> Result<CommandOutput, RunnerError> {
187 let timeout = if timeout_secs > 0 {
188 timeout_secs
189 } else {
190 self.timeout_secs
191 };
192
193 let start = Instant::now();
194 let mut command = tokio::process::Command::new(program);
195 command.args(args);
196 command.current_dir(workspace);
197 command.env_clear();
198
199 for (key, value) in std::env::vars() {
200 if is_env_allowed(&key) {
201 command.env(&key, &value);
202 }
203 }
204
205 command.env("CARGO_TERM_COLOR", "never");
206 command.env("RUST_BACKTRACE", "0");
207 for (key, value) in env {
208 command.env(key, value);
209 }
210
211 command.stdout(std::process::Stdio::piped());
212 command.stderr(std::process::Stdio::piped());
213 command.kill_on_drop(true);
214 #[cfg(unix)]
215 command.process_group(0);
216
217 let child = command
218 .spawn()
219 .map_err(|error| RunnerError::Other(format!("failed to spawn {program}: {error}")))?;
220 #[cfg(unix)]
221 let child_pid = child.id().map(|pid| pid as i32);
222 let wait = child.wait_with_output();
223 tokio::pin!(wait);
224 let output = tokio::time::timeout(Duration::from_secs(timeout), &mut wait).await;
225
226 let duration_ms = start.elapsed().as_millis() as u64;
227 match output {
228 Ok(Ok(output)) => Ok(CommandOutput {
229 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
230 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
231 exit_code: output.status.code().unwrap_or(-1),
232 duration_ms,
233 }),
234 Ok(Err(error)) => Err(RunnerError::Other(format!(
235 "command {program} failed: {error}"
236 ))),
237 Err(_) => {
238 #[allow(clippy::arc_with_non_send_sync)]
249 check_runner_sys::kill_process_group(
250 child_pid.expect("child PID must be Some immediately after spawn"),
251 );
252
253 Err(RunnerError::CommandTimeout {
254 command: format!("{program} {}", args.join(" ")),
255 timeout_secs: timeout,
256 })
257 }
258 }
259 }
260
261 async fn collect_logs(
262 &self,
263 fmt: &CommandOutput,
264 clippy: &CommandOutput,
265 test: &CommandOutput,
266 ) -> Result<LogBundle, RunnerError> {
267 Ok(LogBundle {
268 fmt_stdout: fmt.stdout.clone(),
269 fmt_stderr: fmt.stderr.clone(),
270 clippy_stdout: clippy.stdout.clone(),
271 clippy_stderr: clippy.stderr.clone(),
272 test_stdout: test.stdout.clone(),
273 test_stderr: test.stderr.clone(),
274 timings: CommandTimings {
275 fmt_ms: fmt.duration_ms,
276 clippy_ms: clippy.duration_ms,
277 test_ms: test.duration_ms,
278 },
279 })
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum ContainerRuntime {
285 Docker,
286 Podman,
287 Nerdctl,
288}
289
290#[cfg(feature = "container")]
291impl ContainerRuntime {
292 fn command(&self) -> &str {
293 match self {
294 Self::Docker => "docker",
295 Self::Podman => "podman",
296 Self::Nerdctl => "nerdctl",
297 }
298 }
299
300 fn network_flag(&self) -> &str {
301 match self {
302 Self::Docker | Self::Podman => "--network=none",
303 Self::Nerdctl => "--net=none",
304 }
305 }
306}
307
308#[cfg(feature = "container")]
309pub struct ContainerBackend {
310 runtime: ContainerRuntime,
311 rust_image: String,
312 timeout_secs: u64,
313 sealed: bool,
314 memory_limit: String,
315 cpu_limit: String,
316}
317
318#[cfg(feature = "container")]
319impl ContainerBackend {
320 pub fn new(config: &BackendConfig) -> Result<Self, RunnerError> {
321 let runtime = detect_runtime(&config.container_runtime_preference)?;
322 tracing::info!("container backend: detected runtime {:?}", runtime);
323
324 Ok(Self {
325 runtime,
326 rust_image: config.rust_image.clone(),
327 timeout_secs: config.command_timeout_secs,
328 sealed: config.mode == "sealed_local",
329 memory_limit: config.memory_limit.clone(),
330 cpu_limit: config.cpu_limit.clone(),
331 })
332 }
333
334 pub fn runtime(&self) -> ContainerRuntime {
335 self.runtime
336 }
337
338 fn build_run_args(
339 &self,
340 workspace: &Path,
341 command: &str,
342 env: &[(&str, &str)],
343 ) -> Result<Vec<String>, RunnerError> {
344 let canonical = workspace.canonicalize().map_err(|error| {
345 RunnerError::Other(format!("cannot canonicalize workspace: {error}"))
346 })?;
347 #[cfg(not(target_os = "windows"))]
348 if canonical.to_string_lossy().contains(':') {
349 return Err(RunnerError::Other(
350 "workspace path contains ':' which would corrupt docker -v syntax".into(),
351 ));
352 }
353
354 let mut args = vec!["run".to_string(), "--rm".to_string()];
355 args.push("-v".to_string());
356 args.push(format!("{}:/workspace:rw", canonical.display()));
357 args.push("-w".to_string());
358 args.push("/workspace".to_string());
359 args.push(format!("--memory={}", self.memory_limit));
360 args.push(format!("--cpus={}", self.cpu_limit));
361 if self.sealed {
362 args.push(self.runtime.network_flag().to_string());
363 }
364 for (key, value) in env {
365 args.push("-e".to_string());
366 args.push(format!("{key}={value}"));
367 }
368 args.push(self.rust_image.clone());
369 args.push("sh".to_string());
370 args.push("-c".to_string());
371 args.push(command.to_string());
372 Ok(args)
373 }
374}
375
376#[cfg(feature = "container")]
377#[async_trait]
378impl ExecutionBackend for ContainerBackend {
379 fn kind(&self) -> ExecutionBackendKind {
380 ExecutionBackendKind::Container
381 }
382
383 async fn prepare_workspace(&self, fixture: &Path) -> Result<Workspace, RunnerError> {
384 Ok(sandbox_workspace::prepare_workspace(fixture)?)
385 }
386
387 async fn run_command(
388 &self,
389 workspace: &Path,
390 program: &str,
391 args: &[&str],
392 env: &[(&str, &str)],
393 timeout_secs: u64,
394 ) -> Result<CommandOutput, RunnerError> {
395 let timeout = if timeout_secs > 0 {
396 timeout_secs
397 } else {
398 self.timeout_secs
399 };
400
401 let full_command = if args.is_empty() {
402 program.to_string()
403 } else {
404 format!("{} {}", program, args.join(" "))
405 };
406 let run_args = self.build_run_args(workspace, &full_command, env)?;
407
408 let start = Instant::now();
409 let child = tokio::process::Command::new(self.runtime.command())
410 .args(&run_args)
411 .stdout(std::process::Stdio::piped())
412 .stderr(std::process::Stdio::piped())
413 .kill_on_drop(true)
414 .spawn()
415 .map_err(|error| {
416 RunnerError::Other(format!(
417 "failed to spawn container runtime {}: {error}",
418 self.runtime.command()
419 ))
420 })?;
421
422 let output =
423 tokio::time::timeout(Duration::from_secs(timeout), child.wait_with_output()).await;
424 let duration_ms = start.elapsed().as_millis() as u64;
425
426 match output {
427 Ok(Ok(output)) => Ok(CommandOutput {
428 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
429 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
430 exit_code: output.status.code().unwrap_or(-1),
431 duration_ms,
432 }),
433 Ok(Err(error)) => Err(RunnerError::Other(format!(
434 "container command failed: {error}"
435 ))),
436 Err(_) => Err(RunnerError::CommandTimeout {
437 command: full_command,
438 timeout_secs: timeout,
439 }),
440 }
441 }
442
443 async fn collect_logs(
444 &self,
445 fmt: &CommandOutput,
446 clippy: &CommandOutput,
447 test: &CommandOutput,
448 ) -> Result<LogBundle, RunnerError> {
449 HostBackend {
450 timeout_secs: self.timeout_secs,
451 }
452 .collect_logs(fmt, clippy, test)
453 .await
454 }
455}
456
457#[cfg(feature = "container")]
458pub fn detect_runtime(preference: &str) -> Result<ContainerRuntime, RunnerError> {
459 match preference {
460 "docker" => probe_runtime("docker").ok_or(RunnerError::NoContainerRuntime),
461 "podman" => probe_runtime("podman").ok_or(RunnerError::NoContainerRuntime),
462 "nerdctl" => probe_runtime("nerdctl").ok_or(RunnerError::NoContainerRuntime),
463 _ => {
464 if let Some(runtime) = probe_runtime("docker") {
465 return Ok(runtime);
466 }
467 if let Some(runtime) = probe_runtime("podman") {
468 return Ok(runtime);
469 }
470 if let Some(runtime) = probe_runtime("nerdctl") {
471 return Ok(runtime);
472 }
473 Err(RunnerError::NoContainerRuntime)
474 }
475 }
476}
477
478#[cfg(feature = "container")]
479fn probe_runtime(name: &str) -> Option<ContainerRuntime> {
480 let result = std::process::Command::new(name)
481 .arg("version")
482 .stdout(std::process::Stdio::null())
483 .stderr(std::process::Stdio::null())
484 .status();
485
486 match result {
487 Ok(status) if status.success() => match name {
488 "docker" => Some(ContainerRuntime::Docker),
489 "podman" => Some(ContainerRuntime::Podman),
490 "nerdctl" => Some(ContainerRuntime::Nerdctl),
491 _ => None,
492 },
493 _ => None,
494 }
495}
496
497pub fn select_backend(config: &BackendConfig) -> Result<Box<dyn ExecutionBackend>, RunnerError> {
498 let sealed = config.mode == "sealed_local";
499
500 match config.execution_backend_preference.as_str() {
501 "host" => {
502 if sealed && !config.sealed_allow_host_backend {
503 return Err(RunnerError::SealedModeUnsupported {
504 runtime: "host backend requested in sealed_local mode".into(),
505 });
506 }
507 if sealed {
508 tracing::warn!("sealed_local mode with host backend — no network isolation");
509 }
510 Ok(Box::new(HostBackend::new(config)))
511 }
512 "container" => {
513 #[cfg(feature = "container")]
514 {
515 ContainerBackend::new(config)
516 .map(|backend| Box::new(backend) as Box<dyn ExecutionBackend>)
517 }
518 #[cfg(not(feature = "container"))]
519 {
520 Err(RunnerError::NoContainerRuntime)
521 }
522 }
523 _ => {
524 #[cfg(feature = "container")]
525 {
526 if let Ok(backend) = ContainerBackend::new(config) {
527 return Ok(Box::new(backend));
528 }
529 }
530
531 if sealed && !config.sealed_allow_host_backend {
532 return Err(RunnerError::SealedModeUnsupported {
533 runtime: "container unavailable and host fallback not allowed in sealed mode"
534 .into(),
535 });
536 }
537 if sealed {
538 tracing::warn!(
539 "sealed_local: container unavailable, falling back to host — isolation not guaranteed"
540 );
541 }
542 Ok(Box::new(HostBackend::new(config)))
543 }
544 }
545}
546
547#[cfg(test)]
548mod wave1_tests {
549 use super::*;
550
551 fn config() -> BackendConfig {
552 BackendConfig {
553 mode: "local".into(),
554 execution_backend_preference: "host".into(),
555 container_runtime_preference: "docker".into(),
556 sealed_allow_host_backend: false,
557 rust_image: "rust:1.75".into(),
558 command_timeout_secs: 30,
559 memory_limit: "1g".into(),
560 cpu_limit: "1.0".into(),
561 }
562 }
563
564 fn output(stdout: &str, stderr: &str, exit_code: i32, duration_ms: u64) -> CommandOutput {
565 CommandOutput {
566 stdout: stdout.into(),
567 stderr: stderr.into(),
568 exit_code,
569 duration_ms,
570 }
571 }
572
573 #[test]
574 fn check_result_all_pass_requires_every_check() {
575 let result = CheckResult {
576 fmt_pass: true,
577 clippy_pass: true,
578 test_pass: true,
579 fmt_output: ParsedCheckOutput::default(),
580 clippy_output: ParsedCheckOutput::default(),
581 test_output: ParsedCheckOutput::default(),
582 total_duration_ms: 0,
583 };
584 assert!(result.all_pass());
585
586 let failing = CheckResult {
587 test_pass: false,
588 ..result
589 };
590 assert!(!failing.all_pass());
591 }
592
593 #[test]
594 fn host_backend_collect_logs_preserves_outputs_and_timings() {
595 let backend = HostBackend::new(&config());
596 let runtime = tokio::runtime::Builder::new_current_thread()
597 .enable_time()
598 .build()
599 .unwrap();
600
601 let logs = runtime
602 .block_on(backend.collect_logs(
603 &output("fmt ok", "", 0, 12),
604 &output("", "clippy warn", 1, 34),
605 &output("tests", "", 0, 56),
606 ))
607 .unwrap();
608
609 assert_eq!(logs.fmt_stdout, "fmt ok");
610 assert_eq!(logs.clippy_stderr, "clippy warn");
611 assert_eq!(logs.timings.fmt_ms, 12);
612 assert_eq!(logs.timings.clippy_ms, 34);
613 assert_eq!(logs.timings.test_ms, 56);
614 }
615
616 #[test]
617 fn select_backend_rejects_unapproved_host_fallback_in_sealed_mode() {
618 let mut cfg = config();
619 cfg.mode = "sealed_local".into();
620 cfg.execution_backend_preference = "host".into();
621
622 let err = select_backend(&cfg).err().unwrap();
623 assert!(matches!(err, RunnerError::SealedModeUnsupported { .. }));
624 }
625
626 #[test]
627 #[ignore = "environment-dependent: fails when docker/podman is available because auto→container succeeds before host fallback"]
628 fn select_backend_falls_back_to_host_when_allowed() {
629 let mut cfg = config();
630 cfg.mode = "sealed_local".into();
631 cfg.execution_backend_preference = "auto".into();
632 cfg.sealed_allow_host_backend = true;
633 cfg.container_runtime_preference = "nonexistent_runtime".into();
636
637 let backend = select_backend(&cfg).unwrap();
638 assert_eq!(backend.kind(), ExecutionBackendKind::Host);
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::time::Duration;
646
647 fn sample_config() -> BackendConfig {
648 BackendConfig {
649 mode: "local".into(),
650 execution_backend_preference: "host".into(),
651 container_runtime_preference: "auto".into(),
652 sealed_allow_host_backend: false,
653 rust_image: "rust:1.75".into(),
654 command_timeout_secs: 30,
655 memory_limit: "1g".into(),
656 cpu_limit: "1.0".into(),
657 }
658 }
659
660 fn sample_output(kind: CheckKind, exit_code: i32, duration_ms: u64) -> ParsedCheckOutput {
661 ParsedCheckOutput {
662 check_kind: kind,
663 exit_code,
664 effects: vec![],
665 raw_stdout: format!("stdout-{duration_ms}"),
666 raw_stderr: format!("stderr-{duration_ms}"),
667 }
668 }
669
670 #[test]
671 fn env_allowlist_accepts_safe_prefixes_and_rejects_unknowns() {
672 assert!(is_env_allowed("PATH"));
673 assert!(is_env_allowed("CARGO_HOME"));
674 assert!(is_env_allowed("LC_ALL"));
675 assert!(!is_env_allowed("AWS_SECRET_ACCESS_KEY"));
676 }
677
678 #[test]
679 fn check_result_requires_all_checks_to_pass() {
680 let all_pass = CheckResult {
681 fmt_pass: true,
682 clippy_pass: true,
683 test_pass: true,
684 fmt_output: sample_output(CheckKind::Fmt, 0, 10),
685 clippy_output: sample_output(CheckKind::Clippy, 0, 20),
686 test_output: sample_output(CheckKind::Test, 0, 30),
687 total_duration_ms: 60,
688 };
689 assert!(all_pass.all_pass());
690
691 let not_all_pass = CheckResult {
692 clippy_pass: false,
693 ..all_pass
694 };
695 assert!(!not_all_pass.all_pass());
696 }
697
698 #[test]
699 fn select_backend_rejects_host_in_sealed_mode_without_opt_in() {
700 let mut config = sample_config();
701 config.mode = "sealed_local".into();
702
703 let error = select_backend(&config).err().unwrap();
704 assert!(matches!(error, RunnerError::SealedModeUnsupported { .. }));
705 }
706
707 #[test]
708 fn select_backend_returns_host_when_allowed() {
709 let mut config = sample_config();
710 config.mode = "sealed_local".into();
711 config.sealed_allow_host_backend = true;
712
713 let backend = select_backend(&config).unwrap();
714 assert_eq!(backend.kind(), ExecutionBackendKind::Host);
715 }
716
717 #[test]
718 fn collect_logs_preserves_outputs_and_timings() {
719 let runtime = tokio::runtime::Builder::new_current_thread()
720 .enable_io()
721 .enable_time()
722 .build()
723 .unwrap();
724
725 let config = sample_config();
726 let backend = HostBackend::new(&config);
727 let fmt = CommandOutput {
728 stdout: "fmt out".into(),
729 stderr: "fmt err".into(),
730 exit_code: 0,
731 duration_ms: 11,
732 };
733 let clippy = CommandOutput {
734 stdout: "clippy out".into(),
735 stderr: "clippy err".into(),
736 exit_code: 0,
737 duration_ms: 22,
738 };
739 let test = CommandOutput {
740 stdout: "test out".into(),
741 stderr: "test err".into(),
742 exit_code: 1,
743 duration_ms: 33,
744 };
745
746 let bundle = runtime
747 .block_on(backend.collect_logs(&fmt, &clippy, &test))
748 .unwrap();
749
750 assert_eq!(bundle.fmt_stdout, "fmt out");
751 assert_eq!(bundle.clippy_stderr, "clippy err");
752 assert_eq!(bundle.test_stdout, "test out");
753 assert_eq!(bundle.timings.fmt_ms, 11);
754 assert_eq!(bundle.timings.clippy_ms, 22);
755 assert_eq!(bundle.timings.test_ms, 33);
756 }
757
758 #[test]
759 fn host_backend_run_command_times_out() {
760 let runtime = tokio::runtime::Builder::new_current_thread()
761 .enable_io()
762 .enable_time()
763 .build()
764 .unwrap();
765 let config = sample_config();
766 let backend = HostBackend::new(&config);
767 let workspace = tempfile::tempdir().unwrap();
768
769 let err = runtime
770 .block_on(backend.run_command(workspace.path(), "sh", &["-c", "sleep 2"], &[], 1))
771 .unwrap_err();
772
773 assert!(matches!(
774 err,
775 RunnerError::CommandTimeout {
776 timeout_secs: 1,
777 ..
778 }
779 ));
780 }
781
782 #[cfg(unix)]
783 #[test]
784 fn host_backend_timeout_kills_spawned_child_process_group() {
785 let runtime = tokio::runtime::Builder::new_current_thread()
786 .enable_io()
787 .enable_time()
788 .build()
789 .unwrap();
790 let config = sample_config();
791 let backend = HostBackend::new(&config);
792 let workspace = tempfile::tempdir().unwrap();
793 let child_pid_file = workspace.path().join("child.pid");
794 let child_pid_file_arg = child_pid_file.display().to_string();
795
796 let err = runtime
797 .block_on(backend.run_command(
798 workspace.path(),
799 "sh",
800 &[
801 "-c",
802 &format!(
803 "sleep 30 & child=$!; echo $child > \"{child_pid_file_arg}\"; wait $child"
804 ),
805 ],
806 &[],
807 1,
808 ))
809 .unwrap_err();
810 assert!(matches!(err, RunnerError::CommandTimeout { .. }));
811
812 let child_pid: i32 = std::fs::read_to_string(&child_pid_file)
813 .unwrap()
814 .trim()
815 .parse()
816 .unwrap();
817 std::thread::sleep(Duration::from_millis(150));
818
819 let kill_result = check_runner_sys::process_exists(child_pid);
820 let errno = std::io::Error::last_os_error()
821 .raw_os_error()
822 .unwrap_or_default();
823 assert!(
826 !kill_result && errno == libc::ESRCH,
827 "timed-out child process should be gone, pid={child_pid}, kill_result={kill_result}, errno={errno}"
828 );
829 }
830
831 #[test]
832 fn host_backend_clears_disallowed_environment_variables() {
833 let runtime = tokio::runtime::Builder::new_current_thread()
834 .enable_io()
835 .enable_time()
836 .build()
837 .unwrap();
838 let config = sample_config();
839 let backend = HostBackend::new(&config);
840 let workspace = tempfile::tempdir().unwrap();
841
842 check_runner_sys::set_env("AWS_SECRET_ACCESS_KEY", "forbidden");
843 let output = runtime
844 .block_on(backend.run_command(
845 workspace.path(),
846 "sh",
847 &["-c", "printf '%s' \"${AWS_SECRET_ACCESS_KEY:-missing}\""],
848 &[],
849 5,
850 ))
851 .unwrap();
852 check_runner_sys::remove_env("AWS_SECRET_ACCESS_KEY");
853
854 assert_eq!(output.stdout, "missing");
855 }
856}