1use std::{
2 cell::RefCell,
3 collections::{BTreeMap, BTreeSet},
4 fs,
5 process::{Child, Command, ExitStatus, Stdio},
6 thread,
7 time::{Duration, Instant},
8};
9
10use camino::{Utf8Path, Utf8PathBuf};
11use rustferry_core::process_control::{
12 BoundedOutputCapture, DEFAULT_PROCESS_OUTPUT_LIMIT, OutputCaptureStatus,
13};
14use rustferry_remote::CancellationToken;
15use serde::{Deserialize, Serialize};
16
17use crate::{AppleError, error::io_error};
18
19pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_mins(30);
21const OUTPUT_POLL_INTERVAL: Duration = Duration::from_millis(25);
22const OUTPUT_DRAIN_GRACE: Duration = Duration::from_secs(1);
23
24thread_local! {
25 static COMMAND_CANCELLATION: RefCell<Option<CancellationToken>> = const { RefCell::new(None) };
26}
27
28struct CommandCancellationBinding {
29 previous: Option<CancellationToken>,
30}
31
32impl Drop for CommandCancellationBinding {
33 fn drop(&mut self) {
34 COMMAND_CANCELLATION.with(|binding| {
35 binding.replace(self.previous.take());
36 });
37 }
38}
39
40pub fn with_command_cancellation<T>(
45 cancellation: &CancellationToken,
46 operation: impl FnOnce() -> T,
47) -> T {
48 let previous = COMMAND_CANCELLATION.with(|binding| binding.replace(Some(cancellation.clone())));
49 let _binding = CommandCancellationBinding { previous };
50 operation()
51}
52
53fn command_cancellation_requested() -> bool {
54 COMMAND_CANCELLATION.with(|binding| {
55 binding
56 .borrow()
57 .as_ref()
58 .is_some_and(CancellationToken::is_cancelled)
59 })
60}
61
62#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct CommandSpec {
65 pub stage: String,
67 pub program: Utf8PathBuf,
69 pub args: Vec<String>,
71 pub current_dir: Utf8PathBuf,
73 pub environment: BTreeMap<String, String>,
75 pub redacted_args: BTreeSet<usize>,
77 pub redacted_environment: BTreeSet<String>,
79 pub timeout_seconds: u64,
81}
82
83impl CommandSpec {
84 pub fn new(
86 stage: impl Into<String>,
87 program: impl Into<Utf8PathBuf>,
88 current_dir: impl Into<Utf8PathBuf>,
89 ) -> Self {
90 Self {
91 stage: stage.into(),
92 program: program.into(),
93 args: Vec::new(),
94 current_dir: current_dir.into(),
95 environment: BTreeMap::new(),
96 redacted_args: BTreeSet::new(),
97 redacted_environment: BTreeSet::new(),
98 timeout_seconds: DEFAULT_COMMAND_TIMEOUT.as_secs(),
99 }
100 }
101
102 pub fn redacted_argv(&self) -> Vec<String> {
104 std::iter::once(self.program.to_string())
105 .chain(self.args.iter().enumerate().map(|(index, argument)| {
106 if self.redacted_args.contains(&index) {
107 "<redacted>".to_owned()
108 } else {
109 argument.clone()
110 }
111 }))
112 .collect()
113 }
114
115 pub fn redacted_environment(&self) -> BTreeMap<String, String> {
117 self.environment
118 .iter()
119 .map(|(name, value)| {
120 let rendered = if self.redacted_environment.contains(name) {
121 "<redacted>".to_owned()
122 } else {
123 value.clone()
124 };
125 (name.clone(), rendered)
126 })
127 .collect()
128 }
129}
130
131#[derive(Debug)]
133pub struct CommandOutput {
134 pub stdout: Vec<u8>,
136 pub stderr: Vec<u8>,
138 pub status: ExitStatus,
140}
141
142pub fn run_command(
149 spec: &CommandSpec,
150 log_path: Option<&Utf8Path>,
151) -> Result<CommandOutput, AppleError> {
152 run_command_with_output_limit(spec, log_path, DEFAULT_PROCESS_OUTPUT_LIMIT)
153}
154
155#[allow(clippy::too_many_lines)]
156fn run_command_with_output_limit(
157 spec: &CommandSpec,
158 log_path: Option<&Utf8Path>,
159 output_limit: usize,
160) -> Result<CommandOutput, AppleError> {
161 if let Some(path) = log_path
162 && let Some(parent) = path.parent()
163 {
164 fs::create_dir_all(parent)
165 .map_err(|source| io_error("create command log directory", parent, source))?;
166 }
167
168 let mut command = Command::new(&spec.program);
169 command
170 .args(&spec.args)
171 .current_dir(&spec.current_dir)
172 .envs(&spec.environment)
173 .stdin(Stdio::null())
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped());
176 configure_process_group(&mut command);
177
178 let mut child = command.spawn().map_err(|source| AppleError::CommandSpawn {
179 stage: spec.stage.clone(),
180 program: spec.program.clone(),
181 source,
182 })?;
183 let process_group = child.id();
184 let _process_group_guard = match rustferry_core::process_control::track_child(&child) {
185 Ok(guard) => guard,
186 Err(source) => {
187 terminate_process_tree(&mut child, process_group);
188 return Err(AppleError::CommandSpawn {
189 stage: spec.stage.clone(),
190 program: spec.program.clone(),
191 source,
192 });
193 }
194 };
195 let mut capture = match capture_output(&mut child, spec, output_limit) {
196 Ok(capture) => capture,
197 Err(error) => {
198 terminate_process_tree(&mut child, process_group);
199 return Err(error);
200 }
201 };
202
203 let started = Instant::now();
204 let timeout = Duration::from_secs(spec.timeout_seconds.max(1));
205 let mut status = None;
206 loop {
207 if rustferry_core::process_control::interrupt_requested()
208 || command_cancellation_requested()
209 {
210 terminate_process_tree(&mut child, process_group);
211 drain_after_termination(&mut capture);
212 return Err(AppleError::CommandInterrupted {
213 stage: spec.stage.clone(),
214 program: spec.program.clone(),
215 });
216 }
217 let capture_status = match capture.poll() {
218 Ok(capture_status) => capture_status,
219 Err(source) => {
220 terminate_process_tree(&mut child, process_group);
221 return Err(reader_error("read command output", log_path, source));
222 }
223 };
224 if let OutputCaptureStatus::LimitExceeded(stream) = capture_status {
225 terminate_process_tree(&mut child, process_group);
226 drain_after_termination(&mut capture);
227 let output = capture.into_partial_output();
228 if let Some(path) = log_path {
229 write_log(spec, path, &output.stdout, &output.stderr)?;
230 }
231 return Err(AppleError::ProcessOutputTooLarge {
232 stage: spec.stage.clone(),
233 program: spec.program.clone(),
234 stream: stream.to_string(),
235 limit_bytes: output_limit,
236 log: log_path.map(Utf8Path::to_owned),
237 });
238 }
239 if status.is_none() {
240 match child.try_wait() {
241 Ok(Some(exit_status)) => status = Some(exit_status),
242 Ok(None) => {}
243 Err(source) => {
244 terminate_process_tree(&mut child, process_group);
245 return Err(AppleError::CommandSpawn {
246 stage: spec.stage.clone(),
247 program: spec.program.clone(),
248 source,
249 });
250 }
251 }
252 }
253 if status.is_some() && capture.is_complete() {
254 break;
255 }
256 let remaining = timeout.saturating_sub(started.elapsed());
257 if remaining.is_zero() {
258 terminate_process_tree(&mut child, process_group);
259 drain_after_termination(&mut capture);
260 let output = capture.into_partial_output();
261 if let Some(path) = log_path {
262 write_log(spec, path, &output.stdout, &output.stderr)?;
263 }
264 return Err(AppleError::CommandTimedOut {
265 stage: spec.stage.clone(),
266 program: spec.program.clone(),
267 log: log_path.map(Utf8Path::to_owned),
268 });
269 }
270 if capture.is_complete() {
271 thread::sleep(remaining.min(OUTPUT_POLL_INTERVAL));
272 } else if let Err(source) = capture.wait_timeout(remaining.min(OUTPUT_POLL_INTERVAL)) {
273 terminate_process_tree(&mut child, process_group);
274 return Err(reader_error("read command output", log_path, source));
275 }
276 }
277
278 let output = capture.into_partial_output();
279 if let Some(path) = log_path {
280 write_log(spec, path, &output.stdout, &output.stderr)?;
281 }
282 let status = status.expect("completed capture requires a reaped child");
283 if !status.success() {
284 return Err(AppleError::CommandFailed {
285 stage: spec.stage.clone(),
286 program: spec.program.clone(),
287 status: status.code().map_or_else(
288 || "terminated by signal".to_owned(),
289 |code| code.to_string(),
290 ),
291 summary: command_summary(&output.stdout, &output.stderr),
292 log: log_path.map(|path| Box::new(path.to_owned())),
293 });
294 }
295 Ok(CommandOutput {
296 stdout: output.stdout,
297 stderr: output.stderr,
298 status,
299 })
300}
301
302fn capture_output(
303 child: &mut Child,
304 spec: &CommandSpec,
305 output_limit: usize,
306) -> Result<BoundedOutputCapture, AppleError> {
307 let stdout = child.stdout.take().ok_or_else(|| {
308 io_error(
309 "capture command stdout",
310 &spec.program,
311 std::io::Error::other("spawned process has no piped stdout"),
312 )
313 })?;
314 let stderr = child.stderr.take().ok_or_else(|| {
315 io_error(
316 "capture command stderr",
317 &spec.program,
318 std::io::Error::other("spawned process has no piped stderr"),
319 )
320 })?;
321 BoundedOutputCapture::spawn(stdout, stderr, output_limit)
322 .map_err(|source| io_error("start command output readers", &spec.program, source))
323}
324
325fn drain_after_termination(capture: &mut BoundedOutputCapture) {
326 let started = Instant::now();
327 while !capture.is_complete() {
328 let remaining = OUTPUT_DRAIN_GRACE.saturating_sub(started.elapsed());
329 if remaining.is_zero()
330 || capture
331 .wait_timeout(remaining.min(OUTPUT_POLL_INTERVAL))
332 .is_err()
333 {
334 break;
335 }
336 }
337}
338
339fn reader_error(
340 operation: &'static str,
341 log_path: Option<&Utf8Path>,
342 source: std::io::Error,
343) -> AppleError {
344 let error_path =
345 log_path.map_or_else(|| Utf8PathBuf::from("<command-output>"), Utf8Path::to_owned);
346 io_error(operation, error_path, source)
347}
348
349#[cfg(unix)]
350fn configure_process_group(command: &mut Command) {
351 use std::os::unix::process::CommandExt as _;
352 command.process_group(0);
353}
354
355#[cfg(not(unix))]
356fn configure_process_group(_command: &mut Command) {}
357
358fn terminate_process_tree(child: &mut Child, process_group: u32) {
359 #[cfg(unix)]
360 {
361 let _ = Command::new("/bin/kill")
362 .args(["-KILL", &format!("-{process_group}")])
363 .stdin(Stdio::null())
364 .stdout(Stdio::null())
365 .stderr(Stdio::null())
366 .status();
367 }
368 #[cfg(windows)]
369 {
370 let _ = Command::new("taskkill.exe")
371 .args(["/PID", &process_group.to_string(), "/T", "/F"])
372 .stdin(Stdio::null())
373 .stdout(Stdio::null())
374 .stderr(Stdio::null())
375 .status();
376 }
377 let _ = child.kill();
378 let _ = child.wait();
379}
380
381fn write_log(
382 spec: &CommandSpec,
383 path: &Utf8Path,
384 stdout: &[u8],
385 stderr: &[u8],
386) -> Result<(), AppleError> {
387 let argv = spec
388 .redacted_argv()
389 .into_iter()
390 .map(|argument| format!("{argument:?}"))
391 .collect::<Vec<_>>()
392 .join(" ");
393 let environment = spec
394 .redacted_environment()
395 .into_iter()
396 .map(|(name, value)| format!("{name}={value:?}"))
397 .collect::<Vec<_>>()
398 .join("\n");
399 let contents = format!(
400 "stage: {}\ncwd: {}\ncommand: {}\nenvironment:\n{}\n\n[stdout]\n{}\n\n[stderr]\n{}\n",
401 spec.stage,
402 spec.current_dir,
403 argv,
404 environment,
405 String::from_utf8_lossy(stdout),
406 String::from_utf8_lossy(stderr)
407 );
408 fs::write(path, contents).map_err(|source| io_error("write command log", path, source))
409}
410
411fn command_summary(stdout: &[u8], stderr: &[u8]) -> String {
412 let preferred = if stderr.is_empty() { stdout } else { stderr };
413 let text = String::from_utf8_lossy(preferred);
414 let lines = text
415 .lines()
416 .filter(|line| !line.trim().is_empty())
417 .rev()
418 .take(4)
419 .collect::<Vec<_>>();
420 let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
421 if summary.len() > 800 {
422 summary.truncate(800);
423 summary.push('…');
424 }
425 if summary.is_empty() {
426 "the tool produced no diagnostic output".to_owned()
427 } else {
428 summary
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[cfg(unix)]
437 const TEST_OUTPUT_LIMIT: usize = 64 * 1024;
438
439 #[test]
440 fn rendered_command_redacts_arguments_and_environment() {
441 let mut command = CommandSpec::new("sign", "/usr/bin/codesign", "/project");
442 command.args = vec!["--sign".into(), "secret-identity".into(), "App.app".into()];
443 command.redacted_args.insert(1);
444 command.environment.insert("TOKEN".into(), "secret".into());
445 command.redacted_environment.insert("TOKEN".into());
446 assert_eq!(
447 command.redacted_argv(),
448 ["/usr/bin/codesign", "--sign", "<redacted>", "App.app"]
449 );
450 assert_eq!(command.redacted_environment()["TOKEN"], "<redacted>");
451 }
452
453 #[cfg(unix)]
454 #[test]
455 fn timeout_is_not_held_open_by_descendant_pipes() {
456 let temporary = tempfile::tempdir().unwrap();
457 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
458 let mut command = CommandSpec::new("descendant timeout", "/bin/sh", &root);
459 command.args = vec!["-c".into(), "sleep 10 & exit 0".into()];
460 command.timeout_seconds = 1;
461 let started = Instant::now();
462 let error = run_command(&command, Some(&root.join("command.log"))).unwrap_err();
463 assert!(matches!(error, AppleError::CommandTimedOut { .. }));
464 assert!(started.elapsed() < Duration::from_secs(4));
465 }
466
467 #[cfg(unix)]
468 #[test]
469 fn cancellation_stops_subprocess_group_within_a_bounded_time() {
470 let temporary = tempfile::tempdir().unwrap();
471 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
472 let log_path = root.join("command.log");
473 let mut command = CommandSpec::new("cancel subprocess", "/bin/sh", &root);
474 command.args = vec!["-c".into(), "sleep 10 & wait".into()];
475 command.timeout_seconds = 10;
476 let cancellation = CancellationToken::new();
477 let cancellation_request = cancellation.clone();
478 let request = thread::spawn(move || {
479 thread::sleep(Duration::from_millis(100));
480 cancellation_request.cancel();
481 });
482
483 let started = Instant::now();
484 let error =
485 with_command_cancellation(&cancellation, || run_command(&command, Some(&log_path)))
486 .unwrap_err();
487 request.join().unwrap();
488
489 assert!(matches!(error, AppleError::CommandInterrupted { .. }));
490 assert!(started.elapsed() < Duration::from_secs(4));
491 assert!(!log_path.exists());
492 }
493
494 #[test]
495 fn cancellation_scope_restores_nested_binding_after_unwind() {
496 let outer = CancellationToken::new();
497 outer.cancel();
498 let inner = CancellationToken::new();
499
500 assert!(!command_cancellation_requested());
501 with_command_cancellation(&outer, || {
502 assert!(command_cancellation_requested());
503 assert!(
504 !thread::spawn(command_cancellation_requested)
505 .join()
506 .unwrap()
507 );
508 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
509 with_command_cancellation(&inner, || {
510 assert!(!command_cancellation_requested());
511 panic!("exercise cancellation binding unwind");
512 });
513 }));
514 assert!(unwind.is_err());
515 assert!(command_cancellation_requested());
516 });
517 assert!(!command_cancellation_requested());
518 }
519
520 #[cfg(unix)]
521 #[test]
522 fn oversized_stderr_is_typed_and_log_is_bounded() {
523 let temporary = tempfile::tempdir().unwrap();
524 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
525 let log_path = root.join("command.log");
526 let mut command = CommandSpec::new("oversized stderr", "/bin/sh", &root);
527 command.args = vec!["-c".into(), "exec /usr/bin/yes overflow >&2".into()];
528 command.timeout_seconds = 5;
529 let started = Instant::now();
530 let error = run_command_with_output_limit(&command, Some(&log_path), TEST_OUTPUT_LIMIT)
531 .unwrap_err();
532 assert!(matches!(
533 error,
534 AppleError::ProcessOutputTooLarge {
535 ref stream,
536 limit_bytes: TEST_OUTPUT_LIMIT,
537 ..
538 } if stream == "stderr"
539 ));
540 assert!(started.elapsed() < Duration::from_secs(4));
541 assert!(fs::metadata(log_path).unwrap().len() <= (TEST_OUTPUT_LIMIT + 4096) as u64);
542 }
543}