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
16pub 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#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CommandSpec {
24 pub stage: String,
26 pub program: Utf8PathBuf,
28 pub args: Vec<String>,
30 pub current_dir: Utf8PathBuf,
32 pub environment: BTreeMap<String, String>,
34 pub redacted_args: BTreeSet<usize>,
36 pub timeout: Duration,
38}
39
40impl CommandSpec {
41 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 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#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct CommandOutput {
75 pub stdout: Vec<u8>,
77 pub stderr: Vec<u8>,
79 pub status: ExitStatus,
81}
82
83pub 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}