Skip to main content

rustferry_apple/
command.rs

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
19/// Default deadline for one external Apple build-tool invocation.
20pub 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
40/// Run a closure with cooperative cancellation enabled for Apple commands on this thread.
41///
42/// Nested bindings restore the previous token, including when the closure unwinds. Bindings on
43/// other threads are independent.
44pub 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/// One safely tokenized external command.
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
64pub struct CommandSpec {
65    /// Human-readable build stage.
66    pub stage: String,
67    /// Executable path; never a shell expression.
68    pub program: Utf8PathBuf,
69    /// Individual process arguments.
70    pub args: Vec<String>,
71    /// Working directory.
72    pub current_dir: Utf8PathBuf,
73    /// Additional environment variables.
74    pub environment: BTreeMap<String, String>,
75    /// Argument positions hidden from rendered plans and logs.
76    pub redacted_args: BTreeSet<usize>,
77    /// Environment names hidden from rendered plans and logs.
78    pub redacted_environment: BTreeSet<String>,
79    /// Maximum runtime in seconds.
80    pub timeout_seconds: u64,
81}
82
83impl CommandSpec {
84    /// Construct a command with no arguments or environment overrides.
85    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    /// Return the executable and arguments with sensitive values removed.
103    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    /// Return environment overrides with sensitive values removed.
116    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/// Captured output from a successful external command.
132#[derive(Debug)]
133pub struct CommandOutput {
134    /// Standard output bytes.
135    pub stdout: Vec<u8>,
136    /// Standard error bytes.
137    pub stderr: Vec<u8>,
138    /// Exit status.
139    pub status: ExitStatus,
140}
141
142/// Run one command without a shell, capture both streams, enforce a timeout, and optionally log it.
143///
144/// # Errors
145///
146/// Returns [`AppleError`] when the command cannot start, times out, exits
147/// unsuccessfully, its output cannot be read, or its redacted log cannot be written.
148pub 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}