Skip to main content

rustferry_android/
command.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    process::{Child, Command, ExitStatus, Stdio},
5    thread,
6    time::{Duration, Instant},
7};
8
9use camino::{Utf8Path, Utf8PathBuf};
10use rustferry_core::process_control::{
11    BoundedOutputCapture, DEFAULT_PROCESS_OUTPUT_LIMIT, OutputCaptureStatus,
12};
13
14use crate::{AndroidError, error::io_error};
15
16/// Default deadline for one external build-tool invocation.
17pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_mins(30);
18const OUTPUT_POLL_INTERVAL: Duration = Duration::from_millis(25);
19const OUTPUT_DRAIN_GRACE: Duration = Duration::from_secs(1);
20
21/// One safely tokenized external command.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CommandSpec {
24    /// Human-readable build stage.
25    pub stage: String,
26    /// Executable path; never a shell expression.
27    pub program: Utf8PathBuf,
28    /// Individual process arguments.
29    pub args: Vec<String>,
30    /// Working directory.
31    pub current_dir: Utf8PathBuf,
32    /// Additional environment variables.
33    pub environment: BTreeMap<String, String>,
34    /// Argument positions hidden from rendered plans and logs.
35    pub redacted_args: BTreeSet<usize>,
36    /// Maximum runtime.
37    pub timeout: Duration,
38}
39
40impl CommandSpec {
41    /// Construct a command with no arguments or environment overrides.
42    pub fn new(
43        stage: impl Into<String>,
44        program: impl Into<Utf8PathBuf>,
45        current_dir: impl Into<Utf8PathBuf>,
46    ) -> Self {
47        Self {
48            stage: stage.into(),
49            program: program.into(),
50            args: Vec::new(),
51            current_dir: current_dir.into(),
52            environment: BTreeMap::new(),
53            redacted_args: BTreeSet::new(),
54            timeout: DEFAULT_COMMAND_TIMEOUT,
55        }
56    }
57
58    /// Return the executable and arguments with sensitive values removed.
59    pub fn redacted_argv(&self) -> Vec<String> {
60        std::iter::once(self.program.to_string())
61            .chain(self.args.iter().enumerate().map(|(index, argument)| {
62                if self.redacted_args.contains(&index) {
63                    "<redacted>".to_owned()
64                } else {
65                    argument.clone()
66                }
67            }))
68            .collect()
69    }
70}
71
72/// Captured output from a successful external command.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct CommandOutput {
75    /// Standard output bytes.
76    pub stdout: Vec<u8>,
77    /// Standard error bytes.
78    pub stderr: Vec<u8>,
79    /// Exit status.
80    pub status: ExitStatus,
81}
82
83/// Run one command, capture both streams without pipe deadlock, and save a redacted log.
84///
85/// # Errors
86///
87/// Returns an error when spawning, stream capture, logging, timeout handling, or exit status
88/// indicates failure.
89pub fn run_command(spec: &CommandSpec, log_path: &Utf8Path) -> Result<CommandOutput, AndroidError> {
90    run_command_inner(spec, Some(log_path))
91}
92
93pub(crate) fn run_probe_command(spec: &CommandSpec) -> Result<CommandOutput, AndroidError> {
94    run_command_inner(spec, None)
95}
96
97#[allow(clippy::too_many_lines)]
98fn run_command_inner(
99    spec: &CommandSpec,
100    log_path: Option<&Utf8Path>,
101) -> Result<CommandOutput, AndroidError> {
102    run_command_with_output_limit(spec, log_path, DEFAULT_PROCESS_OUTPUT_LIMIT)
103}
104
105#[allow(clippy::too_many_lines)]
106fn run_command_with_output_limit(
107    spec: &CommandSpec,
108    log_path: Option<&Utf8Path>,
109    output_limit: usize,
110) -> Result<CommandOutput, AndroidError> {
111    if rustferry_core::process_control::interrupt_requested() {
112        return Err(AndroidError::CommandInterrupted {
113            stage: spec.stage.clone(),
114            program: spec.program.clone(),
115        });
116    }
117    if let Some(parent) = log_path.and_then(Utf8Path::parent) {
118        fs::create_dir_all(parent)
119            .map_err(|source| io_error("create log directory", parent, source))?;
120    }
121
122    let mut command = Command::new(&spec.program);
123    command
124        .args(&spec.args)
125        .current_dir(&spec.current_dir)
126        .envs(&spec.environment)
127        .stdin(Stdio::null())
128        .stdout(Stdio::piped())
129        .stderr(Stdio::piped());
130    configure_process_group(&mut command);
131
132    let mut child = command
133        .spawn()
134        .map_err(|source| AndroidError::CommandSpawn {
135            stage: spec.stage.clone(),
136            program: spec.program.clone(),
137            source,
138        })?;
139    let process_group = child.id();
140    let _process_group_guard = match rustferry_core::process_control::track_child(&child) {
141        Ok(guard) => guard,
142        Err(source) => {
143            terminate_process_tree(&mut child, process_group);
144            return Err(AndroidError::CommandSpawn {
145                stage: spec.stage.clone(),
146                program: spec.program.clone(),
147                source,
148            });
149        }
150    };
151    let mut capture = match capture_output(&mut child, spec, output_limit) {
152        Ok(capture) => capture,
153        Err(error) => {
154            terminate_process_tree(&mut child, process_group);
155            return Err(error);
156        }
157    };
158
159    let started = Instant::now();
160    let mut status = None;
161    loop {
162        if rustferry_core::process_control::interrupt_requested() {
163            terminate_process_tree(&mut child, process_group);
164            drain_after_termination(&mut capture);
165            return Err(AndroidError::CommandInterrupted {
166                stage: spec.stage.clone(),
167                program: spec.program.clone(),
168            });
169        }
170        let capture_status = match capture.poll() {
171            Ok(capture_status) => capture_status,
172            Err(source) => {
173                terminate_process_tree(&mut child, process_group);
174                return Err(output_read_error(spec, log_path, source));
175            }
176        };
177        if let OutputCaptureStatus::LimitExceeded(stream) = capture_status {
178            terminate_process_tree(&mut child, process_group);
179            drain_after_termination(&mut capture);
180            let output = capture.into_partial_output();
181            if let Some(path) = log_path {
182                write_log(spec, path, &output.stdout, &output.stderr)?;
183            }
184            return Err(AndroidError::ProcessOutputTooLarge {
185                stage: spec.stage.clone(),
186                program: spec.program.clone(),
187                stream: stream.to_string(),
188                limit_bytes: output_limit,
189                log: log_path.map(Utf8Path::to_owned),
190            });
191        }
192        if status.is_none() {
193            match child.try_wait() {
194                Ok(Some(exit_status)) => status = Some(exit_status),
195                Ok(None) => {}
196                Err(source) => {
197                    terminate_process_tree(&mut child, process_group);
198                    return Err(AndroidError::CommandSpawn {
199                        stage: spec.stage.clone(),
200                        program: spec.program.clone(),
201                        source,
202                    });
203                }
204            }
205        }
206        if status.is_some() && capture.is_complete() {
207            break;
208        }
209        let remaining = spec.timeout.saturating_sub(started.elapsed());
210        if remaining.is_zero() {
211            terminate_process_tree(&mut child, process_group);
212            drain_after_termination(&mut capture);
213            let output = capture.into_partial_output();
214            if let Some(path) = log_path {
215                write_log(spec, path, &output.stdout, &output.stderr)?;
216            }
217            return Err(AndroidError::CommandTimedOut {
218                stage: spec.stage.clone(),
219                program: spec.program.clone(),
220                log: log_path.map(Utf8Path::to_owned),
221            });
222        }
223        if capture.is_complete() {
224            thread::sleep(remaining.min(OUTPUT_POLL_INTERVAL));
225        } else if let Err(source) = capture.wait_timeout(remaining.min(OUTPUT_POLL_INTERVAL)) {
226            terminate_process_tree(&mut child, process_group);
227            return Err(output_read_error(spec, log_path, source));
228        }
229    }
230
231    let output = capture.into_partial_output();
232    if let Some(log_path) = log_path {
233        write_log(spec, log_path, &output.stdout, &output.stderr)?;
234    }
235    let status = status.expect("completed capture requires a reaped child");
236    if !status.success() {
237        return Err(AndroidError::CommandFailed {
238            stage: spec.stage.clone(),
239            program: spec.program.clone(),
240            status: status.code().map_or_else(
241                || "terminated by signal".to_owned(),
242                |code| code.to_string(),
243            ),
244            summary: Box::new(command_summary(&output.stdout, &output.stderr)),
245            log: log_path.map(Utf8Path::to_owned),
246        });
247    }
248    Ok(CommandOutput {
249        stdout: output.stdout,
250        stderr: output.stderr,
251        status,
252    })
253}
254
255fn capture_output(
256    child: &mut Child,
257    spec: &CommandSpec,
258    output_limit: usize,
259) -> Result<BoundedOutputCapture, AndroidError> {
260    let stdout = child
261        .stdout
262        .take()
263        .ok_or_else(|| AndroidError::CommandSpawn {
264            stage: spec.stage.clone(),
265            program: spec.program.clone(),
266            source: std::io::Error::other("spawned process did not expose piped stdout"),
267        })?;
268    let stderr = child
269        .stderr
270        .take()
271        .ok_or_else(|| AndroidError::CommandSpawn {
272            stage: spec.stage.clone(),
273            program: spec.program.clone(),
274            source: std::io::Error::other("spawned process did not expose piped stderr"),
275        })?;
276    BoundedOutputCapture::spawn(stdout, stderr, output_limit).map_err(|source| {
277        AndroidError::CommandSpawn {
278            stage: spec.stage.clone(),
279            program: spec.program.clone(),
280            source,
281        }
282    })
283}
284
285fn output_read_error(
286    spec: &CommandSpec,
287    log_path: Option<&Utf8Path>,
288    source: std::io::Error,
289) -> AndroidError {
290    io_error(
291        "read command output",
292        log_path.unwrap_or(&spec.program),
293        source,
294    )
295}
296
297fn drain_after_termination(capture: &mut BoundedOutputCapture) {
298    let started = Instant::now();
299    while !capture.is_complete() {
300        let remaining = OUTPUT_DRAIN_GRACE.saturating_sub(started.elapsed());
301        if remaining.is_zero()
302            || capture
303                .wait_timeout(remaining.min(OUTPUT_POLL_INTERVAL))
304                .is_err()
305        {
306            break;
307        }
308    }
309}
310
311#[cfg(unix)]
312fn configure_process_group(command: &mut Command) {
313    use std::os::unix::process::CommandExt as _;
314    command.process_group(0);
315}
316
317#[cfg(not(unix))]
318fn configure_process_group(_command: &mut Command) {}
319
320fn terminate_process_tree(child: &mut Child, process_group: u32) {
321    #[cfg(unix)]
322    {
323        let _ = Command::new("/bin/kill")
324            .args(["-KILL", &format!("-{process_group}")])
325            .stdin(Stdio::null())
326            .stdout(Stdio::null())
327            .stderr(Stdio::null())
328            .status();
329    }
330    #[cfg(windows)]
331    {
332        let _ = Command::new("taskkill.exe")
333            .args(["/PID", &process_group.to_string(), "/T", "/F"])
334            .stdin(Stdio::null())
335            .stdout(Stdio::null())
336            .stderr(Stdio::null())
337            .status();
338    }
339    let _ = child.kill();
340    let _ = child.wait();
341}
342
343pub(crate) fn external_tool_path_arg(path: &Utf8Path) -> Result<String, AndroidError> {
344    let value = path.as_str();
345    if value.chars().any(char::is_control) {
346        return Err(AndroidError::InvalidExternalToolPath {
347            reason: "control characters are not allowed",
348        });
349    }
350
351    #[cfg(windows)]
352    {
353        if strip_ascii_prefix(value, r"\\.\").is_some() {
354            return Err(AndroidError::InvalidExternalToolPath {
355                reason: "Windows device namespaces are not supported",
356            });
357        }
358        if let Some(verbatim) = strip_ascii_prefix(value, r"\\?\") {
359            if let Some(unc) = strip_ascii_prefix(verbatim, r"UNC\") {
360                let mut components = unc.split('\\');
361                if components.next().is_some_and(|part| !part.is_empty())
362                    && components.next().is_some_and(|part| !part.is_empty())
363                {
364                    return Ok(format!(r"\\{unc}"));
365                }
366                return Err(AndroidError::InvalidExternalToolPath {
367                    reason: "verbatim UNC paths require a server and share",
368                });
369            }
370            let bytes = verbatim.as_bytes();
371            if bytes.len() >= 3
372                && bytes[0].is_ascii_alphabetic()
373                && bytes[1] == b':'
374                && bytes[2] == b'\\'
375            {
376                return Ok(verbatim.to_owned());
377            }
378            return Err(AndroidError::InvalidExternalToolPath {
379                reason: "unsupported Windows verbatim namespace",
380            });
381        }
382    }
383
384    if !path.is_absolute() {
385        return Err(AndroidError::InvalidExternalToolPath {
386            reason: "path must be absolute",
387        });
388    }
389    Ok(value.to_owned())
390}
391
392#[cfg(windows)]
393fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
394    value
395        .get(..prefix.len())
396        .filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
397        .map(|_| &value[prefix.len()..])
398}
399
400fn write_log(
401    spec: &CommandSpec,
402    path: &Utf8Path,
403    stdout: &[u8],
404    stderr: &[u8],
405) -> Result<(), AndroidError> {
406    let argv = spec
407        .redacted_argv()
408        .into_iter()
409        .map(|argument| format!("{argument:?}"))
410        .collect::<Vec<_>>()
411        .join(" ");
412    let contents = format!(
413        "stage: {}\ncwd: {}\ncommand: {}\n\n[stdout]\n{}\n\n[stderr]\n{}\n",
414        spec.stage,
415        spec.current_dir,
416        argv,
417        String::from_utf8_lossy(stdout),
418        String::from_utf8_lossy(stderr)
419    );
420    fs::write(path, contents).map_err(|source| io_error("write command log", path, source))
421}
422
423fn command_summary(stdout: &[u8], stderr: &[u8]) -> String {
424    let preferred = if stderr.is_empty() { stdout } else { stderr };
425    let text = String::from_utf8_lossy(preferred);
426    let lines = text
427        .lines()
428        .filter(|line| !line.trim().is_empty())
429        .rev()
430        .take(4)
431        .collect::<Vec<_>>();
432    let mut summary = lines.into_iter().rev().collect::<Vec<_>>().join(" | ");
433    if summary.len() > 800 {
434        summary.truncate(800);
435        summary.push('…');
436    }
437    if summary.is_empty() {
438        "the tool produced no diagnostic output".to_owned()
439    } else {
440        summary
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[cfg(unix)]
449    const TEST_OUTPUT_LIMIT: usize = 64 * 1024;
450
451    #[test]
452    fn rendered_command_redacts_selected_arguments() {
453        let mut command = CommandSpec::new("sign", "/tools/apksigner", "/project");
454        command.args = vec!["--ks-pass".into(), "pass:secret".into(), "app.apk".into()];
455        command.redacted_args.insert(1);
456        assert_eq!(
457            command.redacted_argv(),
458            ["/tools/apksigner", "--ks-pass", "<redacted>", "app.apk"]
459        );
460    }
461
462    #[cfg(unix)]
463    #[test]
464    fn timeout_is_not_held_open_by_descendant_pipes() {
465        let temporary = tempfile::tempdir().unwrap();
466        let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
467        let mut command = CommandSpec::new("descendant timeout", "/bin/sh", &root);
468        command.args = vec!["-c".into(), "sleep 10 & exit 0".into()];
469        command.timeout = Duration::from_millis(200);
470        let started = Instant::now();
471        let error = run_command(&command, &root.join("command.log")).unwrap_err();
472        assert!(matches!(error, AndroidError::CommandTimedOut { .. }));
473        assert!(started.elapsed() < Duration::from_secs(4));
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn oversized_stdout_is_typed_and_log_is_bounded() {
479        let temporary = tempfile::tempdir().unwrap();
480        let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
481        let log_path = root.join("command.log");
482        let mut command = CommandSpec::new("oversized stdout", "/usr/bin/yes", &root);
483        command.args = vec!["overflow".into()];
484        command.timeout = Duration::from_secs(5);
485        let started = Instant::now();
486        let error = run_command_with_output_limit(&command, Some(&log_path), TEST_OUTPUT_LIMIT)
487            .unwrap_err();
488        assert!(matches!(
489            error,
490            AndroidError::ProcessOutputTooLarge {
491                ref stream,
492                limit_bytes: TEST_OUTPUT_LIMIT,
493                ..
494            } if stream == "stdout"
495        ));
496        assert!(started.elapsed() < Duration::from_secs(4));
497        assert!(fs::metadata(log_path).unwrap().len() <= (TEST_OUTPUT_LIMIT + 4096) as u64);
498    }
499}