1#[cfg(windows)]
11mod secret_windows;
12#[cfg(windows)]
13pub(crate) use secret_windows::read_secret_stdout;
14
15pub struct CapturedOutput {
16 pub output: std::process::Output,
17 pub timed_out: bool,
18 pub(crate) cancelled: bool,
19}
20
21pub fn run_with_output_limits(
25 command: &mut std::process::Command,
26 timeout: Option<std::time::Duration>,
27 stdout_limit: usize,
28 stderr_limit: usize,
29) -> Result<CapturedOutput, String> {
30 wait_for_capture_with_limits(
31 spawn_capture(command)?,
32 timeout,
33 Some((stdout_limit as u64, stderr_limit as u64)),
34 &mut || false,
35 )
36}
37
38pub(crate) fn run_with_output_limits_cancellable(
40 command: &mut std::process::Command,
41 timeout: std::time::Duration,
42 stdout_limit: usize,
43 stderr_limit: usize,
44 mut cancellation_requested: impl FnMut() -> bool,
45) -> Result<CapturedOutput, String> {
46 wait_for_capture_with_limits(
47 spawn_capture(command)?,
48 Some(timeout),
49 Some((stdout_limit as u64, stderr_limit as u64)),
50 &mut cancellation_requested,
51 )
52}
53
54pub(crate) struct CapturedChild {
55 child: std::process::Child,
56 stdout: std::fs::File,
57 stderr: std::fs::File,
58 bin: String,
59 cleanup: ProcessCleanup,
60 #[cfg(unix)]
63 guard: Option<crate::shell::containment::CallGuard>,
64}
65
66impl CapturedChild {
67 pub(crate) fn program(&self) -> &str {
68 &self.bin
69 }
70}
71
72#[derive(Default)]
73struct ProcessCleanup {
74 #[cfg(windows)]
75 job: Option<ProcessJob>,
76}
77
78#[cfg(windows)]
79struct ProcessJob(windows_sys::Win32::Foundation::HANDLE);
80
81#[cfg(windows)]
82impl Drop for ProcessJob {
83 fn drop(&mut self) {
84 unsafe {
86 windows_sys::Win32::Foundation::CloseHandle(self.0);
87 }
88 }
89}
90
91pub(crate) fn spawn_capture(command: &mut std::process::Command) -> Result<CapturedChild, String> {
92 use std::process::Stdio;
93
94 let program = command.get_program().to_string_lossy().into_owned();
95 let bin = program.as_str();
96 let stdout = tempfile::tempfile()
97 .map_err(|error| format!("failed to create command '{bin}' stdout capture: {error}"))?;
98 let stderr = tempfile::tempfile()
99 .map_err(|error| format!("failed to create command '{bin}' stderr capture: {error}"))?;
100 command
101 .stdout(Stdio::from(stdout.try_clone().map_err(|error| {
102 format!("failed to clone command '{bin}' stdout capture: {error}")
103 })?))
104 .stderr(Stdio::from(stderr.try_clone().map_err(|error| {
105 format!("failed to clone command '{bin}' stderr capture: {error}")
106 })?));
107 #[cfg(unix)]
108 {
109 use std::os::unix::process::CommandExt;
110 command.process_group(0);
111 }
112 #[cfg(windows)]
113 {
114 use std::os::windows::process::CommandExt;
115 command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
116 }
117 let spawn_error = |e: std::io::Error| {
118 if e.kind() == std::io::ErrorKind::NotFound {
119 format!("command '{bin}' not found in PATH")
120 } else {
121 format!("failed to run '{bin}': {e}")
122 }
123 };
124 #[cfg(unix)]
130 let (mut child, guard) = {
131 use crate::shell::containment::Refusal;
132 match crate::shell::containment::start_guarded_child(command) {
133 Ok(started) => started,
134 Err(Refusal::NotStarted(error)) => return Err(spawn_error(error)),
135 Err(Refusal::NotLeader) => {
136 return Err(format!(
137 "command '{bin}' could not be contained: the process does not lead a group of its own"
138 ));
139 }
140 Err(Refusal::NoGuard(reason)) => {
141 return Err(format!("command '{bin}' could not be contained: {reason}"));
142 }
143 }
144 };
145 #[cfg(not(unix))]
146 let mut child = command.spawn().map_err(spawn_error)?;
147
148 let cleanup = match process_cleanup(&child, bin) {
150 Ok(cleanup) => cleanup,
151 Err(error) => {
152 let _ = child.kill();
153 let _ = child.wait();
154 return Err(error);
155 }
156 };
157
158 Ok(CapturedChild {
159 child,
160 stdout,
161 stderr,
162 bin: bin.to_owned(),
163 cleanup,
164 #[cfg(unix)]
165 guard,
166 })
167}
168
169#[cfg(unix)]
173fn child_exited(child: &mut std::process::Child) -> std::io::Result<bool> {
174 crate::shell::exec::exited_unreaped(child)
175}
176
177#[cfg(not(unix))]
178fn child_exited(child: &mut std::process::Child) -> std::io::Result<bool> {
179 child.try_wait().map(|status| status.is_some())
180}
181
182#[cfg(windows)]
183fn process_cleanup(child: &std::process::Child, bin: &str) -> Result<ProcessCleanup, String> {
184 use std::os::windows::io::AsRawHandle;
185 use windows_sys::Win32::System::JobObjects::{
186 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
187 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
188 SetInformationJobObject,
189 };
190
191 let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
193 if handle.is_null() {
194 return Err(format!(
195 "failed to create child job for '{bin}': {}",
196 std::io::Error::last_os_error()
197 ));
198 }
199 let job = ProcessJob(handle);
200 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
201 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
202 let configured = unsafe {
204 SetInformationJobObject(
205 job.0,
206 JobObjectExtendedLimitInformation,
207 std::ptr::from_ref(&limits).cast(),
208 std::mem::size_of_val(&limits) as u32,
209 )
210 };
211 if configured == 0 {
212 return Err(format!(
213 "failed to configure child job for '{bin}': {}",
214 std::io::Error::last_os_error()
215 ));
216 }
217 let assigned = unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle().cast()) };
219 if assigned == 0 {
220 return Err(format!(
221 "failed to assign command '{bin}' to its job: {}",
222 std::io::Error::last_os_error()
223 ));
224 }
225 resume_process_threads(child.id(), bin)?;
226 Ok(ProcessCleanup { job: Some(job) })
227}
228
229#[cfg(windows)]
230fn resume_process_threads(process_id: u32, bin: &str) -> Result<(), String> {
231 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
232 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
233 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
234 };
235 use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
236
237 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
239 if snapshot == INVALID_HANDLE_VALUE {
240 return Err(format!(
241 "failed to inspect suspended command '{bin}' threads: {}",
242 std::io::Error::last_os_error()
243 ));
244 }
245 let mut entry = THREADENTRY32 {
246 dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
247 ..THREADENTRY32::default()
248 };
249 let mut has_entry = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
251 let mut resumed = false;
252 let result = loop {
253 if !has_entry {
254 break if resumed {
255 Ok(())
256 } else {
257 Err(format!(
258 "suspended command '{bin}' exposed no resumable thread"
259 ))
260 };
261 }
262 if entry.th32OwnerProcessID == process_id {
263 let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
265 if thread.is_null() {
266 break Err(format!(
267 "failed to open suspended command '{bin}' thread: {}",
268 std::io::Error::last_os_error()
269 ));
270 }
271 let resume_result = unsafe { ResumeThread(thread) };
273 unsafe { CloseHandle(thread) };
275 if resume_result == u32::MAX {
276 break Err(format!(
277 "failed to resume command '{bin}' thread: {}",
278 std::io::Error::last_os_error()
279 ));
280 }
281 resumed = true;
282 }
283 has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
285 };
286 unsafe { CloseHandle(snapshot) };
288 result
289}
290
291#[cfg(not(windows))]
292fn process_cleanup(_: &std::process::Child, _: &str) -> Result<ProcessCleanup, String> {
293 Ok(ProcessCleanup::default())
294}
295
296pub(crate) fn wait_for_capture(
297 child: CapturedChild,
298 timeout: std::time::Duration,
299) -> Result<CapturedOutput, String> {
300 wait_for_capture_with_limits(child, Some(timeout), None, &mut || false)
301}
302
303fn wait_for_capture_with_limits(
304 CapturedChild {
305 mut child,
306 mut stdout,
307 mut stderr,
308 bin,
309 mut cleanup,
310 #[cfg(unix)]
311 guard,
312 }: CapturedChild,
313 timeout: Option<std::time::Duration>,
314 limits: Option<(u64, u64)>,
315 cancellation_requested: &mut dyn FnMut() -> bool,
316) -> Result<CapturedOutput, String> {
317 const CLEANUP_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
318
319 let started = std::time::Instant::now();
320 let (status, timed_out, cancelled, cleanup_error) = loop {
321 let state = child_exited(&mut child)
324 .map_err(|error| format!("failed to wait for '{bin}': {error}"))
325 .and_then(|state| {
326 if let Some((stdout_limit, stderr_limit)) = limits {
327 capture_length(&stdout, &bin, "stdout", Some(stdout_limit))?;
328 capture_length(&stderr, &bin, "stderr", Some(stderr_limit))?;
329 }
330 Ok(state)
331 });
332 match state {
333 Err(wait_error) => {
334 let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup)
335 .err()
336 .map(|error| format!("cleanup failed: {error}"));
337 let reap_error = reap_child(&mut child, &bin, CLEANUP_GRACE)
338 .err()
339 .map(|error| format!("reap failed: {error}"));
340 let details = [cleanup_error, reap_error]
341 .into_iter()
342 .flatten()
343 .collect::<Vec<_>>()
344 .join("; ");
345 return Err(if details.is_empty() {
346 wait_error
347 } else {
348 format!("{wait_error}; {details}")
349 });
350 }
351 Ok(true) => {
352 let cleanup_error = stop_remaining_process_group(&child, &bin, &mut cleanup).err();
354 break (
355 reap_child(&mut child, &bin, CLEANUP_GRACE)?,
356 false,
357 false,
358 cleanup_error,
359 );
360 }
361 Ok(false) if cancellation_requested() => {
362 let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup).err();
363 break (
364 reap_child(&mut child, &bin, CLEANUP_GRACE)?,
365 false,
366 true,
367 cleanup_error,
368 );
369 }
370 Ok(false) if timeout.is_some_and(|timeout| started.elapsed() >= timeout) => {
371 let cleanup_error = terminate_process_tree(&mut child, &bin, &mut cleanup).err();
372 break (
373 reap_child(&mut child, &bin, CLEANUP_GRACE)?,
374 true,
375 false,
376 cleanup_error,
377 );
378 }
379 Ok(false) => std::thread::sleep(std::time::Duration::from_millis(10)),
380 }
381 };
382 #[cfg(unix)]
385 if let Some(guard) = guard {
386 let ended = guard.finish();
387 if ended != crate::shell::containment::GuardOutcome::Ended {
388 tracing::warn!("command '{bin}': the end of its process group is {ended:?}");
389 }
390 }
391 let stdout = read_capture(&mut stdout, &bin, "stdout", limits.map(|limits| limits.0))?;
392 let stderr = read_capture(&mut stderr, &bin, "stderr", limits.map(|limits| limits.1))?;
393
394 if let Some(error) = cleanup_error {
395 return Err(error);
396 }
397
398 Ok(CapturedOutput {
399 output: std::process::Output {
400 status,
401 stdout,
402 stderr,
403 },
404 timed_out,
405 cancelled,
406 })
407}
408
409fn read_capture(
410 capture: &mut std::fs::File,
411 bin: &str,
412 stream: &str,
413 limit: Option<u64>,
414) -> Result<Vec<u8>, String> {
415 use std::io::{Read, Seek};
416
417 capture
418 .rewind()
419 .map_err(|error| format!("failed to rewind command '{bin}' {stream}: {error}"))?;
420 let length = capture_length(capture, bin, stream, limit)?;
422 let mut bytes = Vec::new();
423 capture
424 .take(length)
425 .read_to_end(&mut bytes)
426 .map_err(|error| format!("failed to read command '{bin}' {stream}: {error}"))?;
427 Ok(bytes)
428}
429
430fn capture_length(
431 capture: &std::fs::File,
432 bin: &str,
433 stream: &str,
434 limit: Option<u64>,
435) -> Result<u64, String> {
436 let length = capture
437 .metadata()
438 .map_err(|error| format!("failed to inspect command '{bin}' {stream}: {error}"))?
439 .len();
440 if let Some(limit) = limit
441 && length > limit
442 {
443 return Err(format!(
444 "command '{bin}' {stream} output exceeded the capture bound ({limit} bytes)"
445 ));
446 }
447 Ok(length)
448}
449
450fn reap_child(
451 child: &mut std::process::Child,
452 bin: &str,
453 timeout: std::time::Duration,
454) -> Result<std::process::ExitStatus, String> {
455 let deadline = std::time::Instant::now() + timeout;
456 loop {
457 match child
458 .try_wait()
459 .map_err(|error| format!("failed to reap command '{bin}': {error}"))?
460 {
461 Some(status) => return Ok(status),
462 None if std::time::Instant::now() >= deadline => {
463 return Err(format!("command '{bin}' did not exit after termination"));
464 }
465 None => std::thread::sleep(std::time::Duration::from_millis(10)),
466 }
467 }
468}
469
470#[cfg(all(any(test, feature = "test-support"), unix))]
474thread_local! {
475 static GROUP_SIGNAL_LOG: std::cell::RefCell<Vec<bool>> =
476 const { std::cell::RefCell::new(Vec::new()) };
477}
478
479#[cfg(all(any(test, feature = "test-support"), unix))]
480pub(crate) fn take_group_signal_log() -> Vec<bool> {
481 GROUP_SIGNAL_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
482}
483
484#[cfg(all(any(test, feature = "test-support"), unix))]
485fn leader_unreaped(pid: libc::pid_t) -> bool {
486 unsafe {
489 let mut info: libc::siginfo_t = std::mem::zeroed();
490 libc::waitid(
491 libc::P_PID,
492 pid as libc::id_t,
493 &raw mut info,
494 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
495 ) == 0
496 }
497}
498
499#[cfg(unix)]
500fn stop_remaining_process_group(
501 child: &std::process::Child,
502 bin: &str,
503 _: &mut ProcessCleanup,
504) -> Result<(), String> {
505 let pgid = crate::ipc::process::group_target(child.id())
506 .map_err(|refusal| format!("failed to stop child process group '{bin}': {refusal}"))?;
507 #[cfg(any(test, feature = "test-support"))]
512 GROUP_SIGNAL_LOG.with(|log| log.borrow_mut().push(leader_unreaped(pgid)));
513 if unsafe { libc::killpg(pgid, libc::SIGKILL) } == 0 {
516 return Ok(());
517 }
518 let error = std::io::Error::last_os_error();
519 if error.raw_os_error() == Some(libc::ESRCH)
520 || (error.raw_os_error() == Some(libc::EPERM)
524 && crate::shell::containment::live_members(pgid) == Some(0))
525 {
526 Ok(())
527 } else {
528 Err(format!(
529 "failed to stop child process group '{bin}': {error}"
530 ))
531 }
532}
533
534#[cfg(windows)]
535fn stop_remaining_process_group(
536 _: &std::process::Child,
537 _: &str,
538 cleanup: &mut ProcessCleanup,
539) -> Result<(), String> {
540 drop(cleanup.job.take());
541 Ok(())
542}
543
544fn terminate_process_tree(
545 child: &mut std::process::Child,
546 bin: &str,
547 cleanup: &mut ProcessCleanup,
548) -> Result<(), String> {
549 #[cfg(unix)]
550 {
551 match stop_remaining_process_group(child, bin, cleanup) {
552 Ok(()) => return Ok(()),
553 Err(group_error) => {
554 child.kill().map_err(|error| {
555 format!("{group_error}; failed to stop direct formatter child '{bin}': {error}")
556 })?;
557 return Err(group_error);
558 }
559 }
560 }
561 #[cfg(windows)]
562 {
563 stop_remaining_process_group(child, bin, cleanup)?;
564 return Ok(());
565 }
566
567 #[allow(unreachable_code)]
568 child
569 .kill()
570 .map_err(|e| format!("failed to stop timed-out command '{bin}': {e}"))
571}
572
573#[cfg(test)]
574mod bounded_capture_tests {
575 use std::io::Write;
576
577 #[test]
578 fn capture_checks_length_before_reading_and_keeps_exact_boundary() {
579 let mut file = tempfile::tempfile().expect("capture file");
580 file.write_all(b"four").expect("capture bytes");
581 let error = super::read_capture(&mut file, "test", "stdout", Some(3))
582 .expect_err("oversized capture must be rejected");
583 assert!(error.contains("output exceeded"));
584 assert_eq!(
585 super::read_capture(&mut file, "test", "stdout", Some(4)).unwrap(),
586 b"four"
587 );
588 assert_eq!(
589 super::read_capture(&mut file, "test", "stdout", None).unwrap(),
590 b"four"
591 );
592 }
593
594 #[test]
595 fn capture_read_errors_are_not_silently_accepted_as_eof() {
596 let directory = tempfile::tempdir().expect("temporary directory");
597 let mut file =
598 std::fs::File::create(directory.path().join("write-only")).expect("write-only capture");
599 file.write_all(b"four").expect("capture bytes");
600 for limit in [None, Some(4)] {
601 let error = super::read_capture(&mut file, "test", "stderr", limit)
602 .expect_err("write-only file cannot supply readable output");
603 assert!(error.contains("failed to read"), "{error}");
604 }
605 }
606}