1use std::path::Path;
16use std::process::{Command, Stdio};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use super::parsers;
22use super::tools::{L1Finding, ToolConfig, ToolResult};
23
24fn kill_process_by_id(pid: u32) {
31 #[cfg(unix)]
32 {
33 unsafe {
37 libc::kill(pid as libc::pid_t, libc::SIGKILL);
38 }
39 }
40 #[cfg(windows)]
41 {
42 unsafe {
45 let handle = windows_sys::Win32::System::Threading::OpenProcess(
46 windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
47 0, pid,
49 );
50 if handle != 0 {
51 windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
52 windows_sys::Win32::Foundation::CloseHandle(handle);
53 }
54 }
55 }
56 #[cfg(not(any(unix, windows)))]
57 {
58 eprintln!("bugbot: cannot kill process {} on this platform", pid);
62 }
63}
64
65pub const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024; pub struct ToolRunner {
77 timeout_secs: u64,
79}
80
81impl ToolRunner {
82 pub fn new(timeout_secs: u64) -> Self {
84 Self { timeout_secs }
85 }
86
87 pub fn run_tool(&self, tool: &ToolConfig, project_path: &Path) -> (ToolResult, Vec<L1Finding>) {
98 let start = Instant::now();
99
100 let child = Command::new(tool.binary)
102 .args(tool.args)
103 .current_dir(project_path)
104 .stdout(Stdio::piped())
105 .stderr(Stdio::piped())
106 .spawn();
107
108 let child = match child {
109 Ok(c) => c,
110 Err(e) => {
111 return (
112 ToolResult {
113 name: tool.name.to_string(),
114 category: tool.category,
115 success: false,
116 duration_ms: start.elapsed().as_millis() as u64,
117 finding_count: 0,
118 error: Some(format!("Failed to spawn '{}': {}", tool.binary, e)),
119 exit_code: None,
120 },
121 vec![],
122 );
123 }
124 };
125
126 let timeout = Duration::from_secs(self.timeout_secs);
131 let child_id = child.id();
132 let timed_out = Arc::new(AtomicBool::new(false));
133 let timed_out_clone = timed_out.clone();
134
135 let _watchdog = std::thread::spawn(move || {
136 std::thread::sleep(timeout);
137 timed_out_clone.store(true, Ordering::SeqCst);
138 kill_process_by_id(child_id);
141 });
142
143 let output = child.wait_with_output();
145 let duration_ms = start.elapsed().as_millis() as u64;
146
147 if timed_out.load(Ordering::SeqCst) {
149 return (
150 ToolResult {
151 name: tool.name.to_string(),
152 category: tool.category,
153 success: false,
154 duration_ms,
155 finding_count: 0,
156 error: Some(format!("Timeout after {}s", self.timeout_secs)),
157 exit_code: None,
158 },
159 vec![],
160 );
161 }
162
163 let (stdout, stderr, exit_code) = match output {
166 Ok(o) => {
167 let raw_stdout = String::from_utf8_lossy(&o.stdout).to_string();
168 let raw_stderr = String::from_utf8_lossy(&o.stderr).to_string();
169 let stdout = if raw_stdout.len() > MAX_OUTPUT_BYTES {
170 let mut truncated = raw_stdout;
171 truncated.truncate(MAX_OUTPUT_BYTES);
172 if let Some(last_newline) = truncated.rfind('\n') {
174 truncated.truncate(last_newline + 1);
175 }
176 truncated
177 } else {
178 raw_stdout
179 };
180 let stderr = if raw_stderr.len() > MAX_OUTPUT_BYTES {
181 let mut truncated = raw_stderr;
182 truncated.truncate(MAX_OUTPUT_BYTES);
183 truncated
184 } else {
185 raw_stderr
186 };
187 (stdout, stderr, o.status.code())
188 }
189 Err(e) => {
190 return (
191 ToolResult {
192 name: tool.name.to_string(),
193 category: tool.category,
194 success: false,
195 duration_ms,
196 finding_count: 0,
197 error: Some(format!("Failed to read output: {}", e)),
198 exit_code: None,
199 },
200 vec![],
201 );
202 }
203 };
204
205 match parsers::parse_tool_output(tool.parser, &stdout) {
207 Ok(mut findings) => {
208 for f in &mut findings {
210 f.tool = tool.name.to_string();
211 }
212 let count = findings.len();
213 (
214 ToolResult {
215 name: tool.name.to_string(),
216 category: tool.category,
217 success: true,
218 duration_ms,
219 finding_count: count,
220 error: None,
221 exit_code,
222 },
223 findings,
224 )
225 }
226 Err(e) => {
227 let error_msg = if stderr.is_empty() {
229 format!("Parse error: {}", e)
230 } else {
231 let truncated = if stderr.len() > 200 {
232 &stderr[..200]
233 } else {
234 &stderr
235 };
236 format!("Parse error: {}. stderr: {}", e, truncated.trim())
237 };
238 (
239 ToolResult {
240 name: tool.name.to_string(),
241 category: tool.category,
242 success: false,
243 duration_ms,
244 finding_count: 0,
245 error: Some(error_msg),
246 exit_code,
247 },
248 vec![],
249 )
250 }
251 }
252 }
253
254 pub fn run_tools_parallel(
262 &self,
263 tools: &[&ToolConfig],
264 project_path: &Path,
265 ) -> (Vec<ToolResult>, Vec<L1Finding>) {
266 if tools.len() <= 1 {
267 return self.run_tools_sequential(tools, project_path);
268 }
269
270 let results: Vec<(usize, ToolResult, Vec<L1Finding>)> = std::thread::scope(|s| {
273 let handles: Vec<_> = tools
274 .iter()
275 .enumerate()
276 .map(|(i, tool)| {
277 let tool_name = tool.name;
278 let tool_category = tool.category;
279 let path = project_path;
280 let handle = s.spawn(move || {
281 let (result, findings) = self.run_tool(tool, path);
282 (i, result, findings)
283 });
284 (handle, i, tool_name, tool_category)
285 })
286 .collect();
287
288 handles
291 .into_iter()
292 .map(|(h, idx, name, category)| match h.join() {
293 Ok(result) => result,
294 Err(_) => {
295 eprintln!("bugbot: tool thread for '{}' panicked", name);
296 (
297 idx,
298 ToolResult {
299 name: name.to_string(),
300 category,
301 success: false,
302 duration_ms: 0,
303 finding_count: 0,
304 error: Some("Tool thread panicked".to_string()),
305 exit_code: None,
306 },
307 vec![],
308 )
309 }
310 })
311 .collect()
312 });
313
314 let mut sorted = results;
316 sorted.sort_by_key(|(i, _, _)| *i);
317
318 let mut all_results = Vec::new();
319 let mut all_findings = Vec::new();
320 for (_idx, result, findings) in sorted {
321 all_results.push(result);
322 all_findings.extend(findings);
323 }
324
325 (all_results, all_findings)
326 }
327
328 fn run_tools_sequential(
330 &self,
331 tools: &[&ToolConfig],
332 project_path: &Path,
333 ) -> (Vec<ToolResult>, Vec<L1Finding>) {
334 let mut all_results = Vec::new();
335 let mut all_findings = Vec::new();
336 for tool in tools {
337 let (result, findings) = self.run_tool(tool, project_path);
338 all_results.push(result);
339 all_findings.extend(findings);
340 }
341 (all_results, all_findings)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::commands::bugbot::tools::ToolCategory;
349
350 fn make_tool(
353 name: &'static str,
354 binary: &'static str,
355 args: &'static [&'static str],
356 parser: &'static str,
357 category: ToolCategory,
358 ) -> ToolConfig {
359 ToolConfig {
360 name,
361 binary,
362 detection_binary: binary,
363 args,
364 category,
365 parser,
366 }
367 }
368
369 #[test]
374 fn test_run_tool_binary_not_found() {
375 let runner = ToolRunner::new(10);
376 let tool = make_tool(
377 "missing-tool",
378 "nonexistent-binary-xyz-12345",
379 &[],
380 "cargo",
381 ToolCategory::Linter,
382 );
383
384 let (result, findings) = runner.run_tool(&tool, Path::new("."));
385
386 assert!(!result.success, "should fail for missing binary");
387 assert!(result.error.is_some(), "should have error message");
388 let err = result.error.as_ref().unwrap();
389 assert!(
390 err.contains("spawn") || err.contains("not found") || err.contains("No such file"),
391 "error should mention spawn failure, got: {}",
392 err
393 );
394 assert!(findings.is_empty(), "no findings for missing binary");
395 assert_eq!(result.name, "missing-tool");
396 assert_eq!(result.finding_count, 0);
397 assert!(result.exit_code.is_none());
398 }
399
400 #[test]
405 fn test_run_tool_timeout() {
406 let runner = ToolRunner::new(1); let tool = make_tool(
408 "sleeper",
409 "sleep",
410 &["10"], "cargo",
412 ToolCategory::Linter,
413 );
414
415 let start = Instant::now();
416 let (result, findings) = runner.run_tool(&tool, Path::new("."));
417 let elapsed = start.elapsed();
418
419 assert!(!result.success, "should fail on timeout");
420 assert!(
421 result.error.as_ref().unwrap().contains("imeout"),
422 "error should mention timeout, got: {:?}",
423 result.error
424 );
425 assert!(findings.is_empty(), "no findings on timeout");
426 assert!(
428 elapsed.as_secs() < 5,
429 "should have been killed within ~1s, took {:?}",
430 elapsed
431 );
432 assert!(
433 result.duration_ms >= 900,
434 "should have waited at least ~1s, got {}ms",
435 result.duration_ms
436 );
437 }
438
439 const SH_CMD_ONE_WARNING: &str = concat!(
449 "printf '%s\\n' '",
450 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
451 r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
452 r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
453 r#""spans":[{"byte_end":100,"byte_start":99,"column_end":10,"column_start":9,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":null,"line_end":10,"line_start":10,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
454 "'",
455 );
456
457 #[test]
458 fn test_run_tool_injects_tool_name() {
459 let runner = ToolRunner::new(10);
460 let tool = make_tool(
461 "test-clippy",
462 "sh",
463 &["-c", SH_CMD_ONE_WARNING],
464 "cargo",
465 ToolCategory::Linter,
466 );
467
468 let (result, findings) = runner.run_tool(&tool, Path::new("."));
469
470 assert!(
471 result.success,
472 "tool should succeed, error: {:?}",
473 result.error
474 );
475 assert_eq!(findings.len(), 1, "should have 1 finding");
476 assert_eq!(
477 findings[0].tool, "test-clippy",
478 "PM-6: tool name should be injected by runner, got: '{}'",
479 findings[0].tool
480 );
481 }
482
483 #[test]
488 fn test_run_tool_parse_error_captured() {
489 let runner = ToolRunner::new(10);
492 let tool = make_tool(
493 "bad-output",
494 "echo",
495 &["this is not valid json at all"],
496 "cargo-audit", ToolCategory::SecurityScanner,
498 );
499
500 let (result, findings) = runner.run_tool(&tool, Path::new("."));
501
502 assert!(!result.success, "should fail on parse error");
503 assert!(
504 result.error.as_ref().unwrap().contains("Parse error"),
505 "error should mention parse error, got: {:?}",
506 result.error
507 );
508 assert!(findings.is_empty(), "no findings on parse error");
509 }
510
511 #[test]
516 fn test_run_tools_parallel_failure_isolation() {
517 let runner = ToolRunner::new(10);
518
519 let tool_a = make_tool("tool-a", "echo", &[""], "cargo", ToolCategory::Linter);
521
522 let tool_b = make_tool(
524 "tool-b",
525 "nonexistent-binary-xyz-12345",
526 &[],
527 "cargo",
528 ToolCategory::Linter,
529 );
530
531 let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
532 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
533
534 assert_eq!(results.len(), 2, "should have 2 results");
535 assert!(
536 results[0].success,
537 "tool-a should succeed, error: {:?}",
538 results[0].error
539 );
540 assert!(!results[1].success, "tool-b should fail");
541 assert_eq!(results[0].name, "tool-a");
542 assert_eq!(results[1].name, "tool-b");
543 }
544
545 #[test]
550 fn test_run_tools_parallel_deterministic_order() {
551 let runner = ToolRunner::new(10);
552
553 let tool_alpha = make_tool("alpha", "echo", &[""], "cargo", ToolCategory::Linter);
554 let tool_beta = make_tool("beta", "echo", &[""], "cargo", ToolCategory::Linter);
555 let tool_gamma = make_tool("gamma", "echo", &[""], "cargo", ToolCategory::Linter);
556
557 let tools: Vec<&ToolConfig> = vec![&tool_alpha, &tool_beta, &tool_gamma];
558 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
559
560 assert_eq!(results.len(), 3);
561 assert_eq!(
562 results[0].name, "alpha",
563 "first result should be alpha, got {}",
564 results[0].name
565 );
566 assert_eq!(
567 results[1].name, "beta",
568 "second result should be beta, got {}",
569 results[1].name
570 );
571 assert_eq!(
572 results[2].name, "gamma",
573 "third result should be gamma, got {}",
574 results[2].name
575 );
576 }
577
578 #[test]
583 fn test_run_tools_sequential_for_single_tool() {
584 let runner = ToolRunner::new(10);
585 let tool = make_tool("solo", "echo", &[""], "cargo", ToolCategory::Linter);
586
587 let tools: Vec<&ToolConfig> = vec![&tool];
588 let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
589
590 assert_eq!(results.len(), 1);
591 assert!(results[0].success);
592 assert_eq!(results[0].name, "solo");
593 assert!(findings.is_empty(), "empty echo -> no cargo findings");
594 }
595
596 const SH_CMD_WARNING_EXIT1: &str = concat!(
602 "printf '%s\\n' '",
603 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
604 r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
605 r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
606 r#""spans":[{"byte_end":100,"byte_start":99,"column_end":10,"column_start":9,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":null,"line_end":10,"line_start":10,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
607 "'; exit 1",
608 );
609
610 #[test]
611 fn test_run_tool_nonzero_exit_with_parseable_output() {
612 let runner = ToolRunner::new(10);
614 let tool = make_tool(
615 "linter-with-findings",
616 "sh",
617 &["-c", SH_CMD_WARNING_EXIT1],
618 "cargo",
619 ToolCategory::Linter,
620 );
621
622 let (result, findings) = runner.run_tool(&tool, Path::new("."));
623
624 assert!(
625 result.success,
626 "non-zero exit with parseable output should be success, error: {:?}",
627 result.error
628 );
629 assert_eq!(
630 result.exit_code,
631 Some(1),
632 "exit code should be captured as 1"
633 );
634 assert_eq!(findings.len(), 1, "should have parsed 1 finding");
635 assert_eq!(
636 findings[0].tool, "linter-with-findings",
637 "tool name should be injected"
638 );
639 }
640
641 #[test]
646 fn test_run_tools_parallel_empty_list() {
647 let runner = ToolRunner::new(10);
648 let tools: Vec<&ToolConfig> = vec![];
649 let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
650
651 assert!(results.is_empty(), "no tools = no results");
652 assert!(findings.is_empty(), "no tools = no findings");
653 }
654
655 #[test]
660 fn test_run_tool_success_echo_empty_output() {
661 let runner = ToolRunner::new(10);
663 let tool = make_tool("echo-tool", "echo", &[""], "cargo", ToolCategory::Linter);
664
665 let (result, findings) = runner.run_tool(&tool, Path::new("."));
666
667 assert!(
668 result.success,
669 "echo should succeed, error: {:?}",
670 result.error
671 );
672 assert_eq!(result.finding_count, 0);
673 assert!(findings.is_empty());
674 assert!(result.error.is_none());
675 assert_eq!(result.name, "echo-tool");
676 }
677
678 #[test]
683 fn test_run_tool_tracks_duration() {
684 let runner = ToolRunner::new(10);
685 let tool = make_tool(
686 "timer-test",
687 "echo",
688 &["hello"],
689 "cargo",
690 ToolCategory::Linter,
691 );
692
693 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
694
695 assert!(
698 result.duration_ms < 5000,
699 "echo should complete in well under 5s, got {}ms",
700 result.duration_ms
701 );
702 }
703
704 #[test]
709 fn test_run_tool_preserves_category() {
710 let runner = ToolRunner::new(10);
711 let tool = make_tool(
712 "security-tool",
713 "echo",
714 &[""],
715 "cargo",
716 ToolCategory::SecurityScanner,
717 );
718
719 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
720
721 assert_eq!(
722 result.category,
723 ToolCategory::SecurityScanner,
724 "category should be preserved from ToolConfig"
725 );
726 }
727
728 #[test]
733 fn test_run_tool_build_finished_only() {
734 let runner = ToolRunner::new(10);
736 let tool = make_tool(
737 "cargo-noop",
738 "echo",
739 &[r#"{"reason":"build-finished","success":true}"#],
740 "cargo",
741 ToolCategory::Linter,
742 );
743
744 let (result, findings) = runner.run_tool(&tool, Path::new("."));
745
746 assert!(result.success, "valid output should succeed");
747 assert_eq!(result.finding_count, 0, "build-finished is not a finding");
748 assert!(findings.is_empty());
749 }
750
751 #[test]
756 fn test_run_tool_unknown_parser() {
757 let runner = ToolRunner::new(10);
758 let tool = make_tool(
759 "bad-parser",
760 "echo",
761 &["some output"],
762 "nonexistent-parser",
763 ToolCategory::Linter,
764 );
765
766 let (result, findings) = runner.run_tool(&tool, Path::new("."));
767
768 assert!(!result.success, "unknown parser should fail");
769 assert!(
770 result.error.as_ref().unwrap().contains("Parse error"),
771 "should mention parse error, got: {:?}",
772 result.error
773 );
774 assert!(findings.is_empty());
775 }
776
777 const SH_CMD_TWO_WARNINGS: &str = concat!(
783 "printf '%s\\n' '",
784 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"w","children":[],"code":{"code":"W1","explanation":null},"level":"warning","message":"warning one","spans":[{"byte_end":10,"byte_start":1,"column_end":5,"column_start":1,"expansion":null,"file_name":"src/a.rs","is_primary":true,"label":null,"line_end":1,"line_start":1,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
785 "'; printf '%s\\n' '",
786 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"e","children":[],"code":{"code":"E1","explanation":null},"level":"error","message":"error one","spans":[{"byte_end":20,"byte_start":11,"column_end":8,"column_start":3,"expansion":null,"file_name":"src/b.rs","is_primary":true,"label":null,"line_end":5,"line_start":5,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
787 "'; printf '%s\\n' '",
788 r#"{"reason":"build-finished","success":true}"#,
789 "'",
790 );
791
792 #[test]
793 fn test_run_tool_multiple_findings_all_have_tool_name() {
794 let runner = ToolRunner::new(10);
795 let tool = make_tool(
796 "multi-finder",
797 "sh",
798 &["-c", SH_CMD_TWO_WARNINGS],
799 "cargo",
800 ToolCategory::Linter,
801 );
802
803 let (result, findings) = runner.run_tool(&tool, Path::new("."));
804
805 assert!(result.success, "should succeed, error: {:?}", result.error);
806 assert_eq!(findings.len(), 2, "should have 2 findings");
807 for (i, f) in findings.iter().enumerate() {
808 assert_eq!(
809 f.tool, "multi-finder",
810 "PM-6: finding[{}].tool should be 'multi-finder', got '{}'",
811 i, f.tool
812 );
813 }
814 }
815
816 #[test]
821 fn test_max_output_bytes_constant_exists() {
822 let max_output_bytes = std::hint::black_box(super::MAX_OUTPUT_BYTES);
824 assert!(
825 max_output_bytes > 0,
826 "MAX_OUTPUT_BYTES should be a positive constant"
827 );
828 assert!(
829 max_output_bytes >= 1_000_000,
830 "MAX_OUTPUT_BYTES should be at least 1MB, got {}",
831 max_output_bytes
832 );
833 }
834
835 #[test]
836 fn test_large_stdout_is_truncated() {
837 let runner = ToolRunner::new(10);
840
841 let single_line = r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"w","children":[],"code":{"code":"W1","explanation":null},"level":"warning","message":"warning","spans":[{"byte_end":10,"byte_start":1,"column_end":5,"column_start":1,"expansion":null,"file_name":"src/a.rs","is_primary":true,"label":null,"line_end":1,"line_start":1,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#;
845
846 let line_count = (super::MAX_OUTPUT_BYTES / single_line.len()) + 100;
847
848 let sh_cmd = format!(
850 "for i in $(seq 1 {}); do printf '%s\\n' '{}'; done",
851 line_count, single_line
852 );
853
854 let tool = make_tool(
855 "large-output",
856 "sh",
857 Box::leak(Box::new(["-c", Box::leak(sh_cmd.into_boxed_str()) as &str])) as &[&str],
859 "cargo",
860 ToolCategory::Linter,
861 );
862
863 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
864
865 assert!(
867 result.success,
868 "should succeed even with truncated output, error: {:?}",
869 result.error
870 );
871 }
872
873 #[test]
878 fn test_thread_panic_does_not_propagate() {
879 let runner = ToolRunner::new(10);
885
886 let tool_a = make_tool("good-tool", "echo", &[""], "cargo", ToolCategory::Linter);
887
888 let tool_b = make_tool(
890 "bad-tool",
891 "nonexistent-binary-xyz-12345",
892 &[],
893 "cargo",
894 ToolCategory::Linter,
895 );
896
897 let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
898 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
899
900 assert_eq!(results.len(), 2, "should have results for both tools");
902 }
903}