//! ref: composer/src/Composer/Util/ProcessExecutor.php use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::io::io_interface; use crate::signal::SignalSubscription; use crate::util::GitHub; use crate::util::Platform; use indexmap::IndexMap; use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ LogicException, PHP_EOL, PhpMixed, PregMatches, RuntimeException, array_intersect, array_map, escapeshellarg, explode, implode, in_array_strict, is_dir, is_numeric, php_regex, preg_is_match, preg_match, preg_replace, preg_replace_callback, preg_replace2, preg_split, rtrim, str_replace, strcspn, strlen, strpbrk, strtolower, strtr_array, substr_replace, trim, }; use shirabe_symfony_process::ExecutableFinder; use shirabe_symfony_process::Process; use shirabe_symfony_process::ProcessMock; use shirabe_symfony_process::exception::ProcessSignaledException; use shirabe_symfony_process::exception::RuntimeException as SymfonyProcessRuntimeException; use std::sync::{LazyLock, Mutex}; static EXECUTABLES: LazyLock>> = LazyLock::new(|| Mutex::new(IndexMap::new())); static TIMEOUT: LazyLock> = LazyLock::new(|| Mutex::new(300)); #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandLine { Shell(String), Args(Vec), } #[derive(Debug)] pub struct ProcessExecutor { /// @var bool capture_output: bool, /// @var string error_output: String, /// @var ?IOInterface io: Option>>, /// @var int max_jobs: i64, /// PHP throttles async jobs through the $jobs queue and $maxJobs; here concurrent /// `execute_async`/`execute_async_php` calls hold a permit for the duration of the /// child process instead (same design as HttpDownloader). semaphore: std::rc::Rc, /// @var bool allow_async: bool, /// Test-only mock state. `None` in production; set via [`ProcessExecutor::__expects`] in tests. /// Mirrors `composer/tests/Composer/Test/Mock/ProcessExecutorMock.php`. Wrapped in a `RefCell` /// so `execute_async`'s `&self` receiver can still consume the expectation queue. mock: Option>, } /// Test-only state for the ProcessExecutorMock behaviour (cf. /// `composer/tests/Composer/Test/Mock/ProcessExecutorMock.php`). Held in /// [`ProcessExecutor::mock`]; always `None` in production builds. #[derive(Debug)] pub struct ProcessExecutorMockState { /// `null` until configured via `expects`; once set, an empty list means "no more calls". pub expectations: Option>, pub strict: bool, pub default_handler: MockHandler, pub log: Vec, } /// A single expected command (`array{cmd, return, stdout, stderr, callback}` in PHP). pub struct MockExpectation { pub cmd: CommandLine, pub r#return: i64, pub stdout: String, pub stderr: String, /// Optional `callable` fired when the expectation is consumed. Rare in Composer's suite. pub callback: Option>, } impl std::fmt::Debug for MockExpectation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MockExpectation") .field("cmd", &self.cmd) .field("return", &self.r#return) .field("stdout", &self.stdout) .field("stderr", &self.stderr) .field("callback", &self.callback.is_some()) .finish() } } impl MockExpectation { /// Builds an expectation from just a command (string or list), defaulting the rest, matching /// PHP's handling of bare `string`/`list` entries in `expects`. pub fn from_cmd(cmd: CommandLine) -> Self { Self { cmd, r#return: 0, stdout: String::new(), stderr: String::new(), callback: None, } } } /// The `{return, stdout, stderr}` default-handler triple used for unmatched commands in non-strict /// mode (`$defaultHandler` in PHP). #[derive(Debug, Clone, Default)] pub struct MockHandler { pub r#return: i64, pub stdout: String, pub stderr: String, } /// Output target marking the "no `$output` argument" case of `ProcessExecutor::execute`, where the /// child's output is forwarded to STDOUT/STDERR (or the IO) instead of captured. Use the /// [`ProcessExecutor::FORWARD_OUTPUT`] constant rather than constructing this directly. pub struct ProcessForwardOutput; impl ProcessExecutor { pub const FORWARD_OUTPUT: ProcessForwardOutput = ProcessForwardOutput; const BUILTIN_CMD_COMMANDS: [&'static str; 47] = [ "assoc", "break", "call", "cd", "chdir", "cls", "color", "copy", "date", "del", "dir", "echo", "endlocal", "erase", "exit", "for", "ftype", "goto", "help", "if", "label", "md", "mkdir", "mklink", "move", "path", "pause", "popd", "prompt", "pushd", "rd", "rem", "ren", "rename", "rmdir", "set", "setlocal", "shift", "start", "time", "title", "type", "ver", "vol", // unused slots to make 47 above explicit "", "", "", ]; const GIT_CMDS_NEED_GIT_DIR: &'static [&'static [&'static str]] = &[&["show"], &["log"], &["branch"], &["remote", "set-url"]]; pub fn new(io: Option>>) -> Self { let mut this = Self { capture_output: false, error_output: String::new(), io, max_jobs: 10, semaphore: std::rc::Rc::new(tokio::sync::Semaphore::new(10)), allow_async: false, mock: None, }; this.reset_max_jobs(); this } /// runs a process on the commandline pub fn execute<'o, C, O>( &mut self, command: C, output: O, cwd: Option<&str>, ) -> anyhow::Result where C: IntoExecCommand, O: IntoExecOutput<'o>, { let command = command.into_exec_command(); self.do_execute(command, cwd, false, output) } /// runs a process on the commandline in TTY mode pub fn execute_tty(&mut self, command: C, cwd: Option<&str>) -> anyhow::Result where C: IntoExecCommand, { let command = command.into_exec_command(); if Platform::is_tty(None) { return self.do_execute(command, cwd, true, Self::FORWARD_OUTPUT); } self.do_execute(command, cwd, false, Self::FORWARD_OUTPUT) } fn run_process<'o, O>( &mut self, command: CommandLine, cwd: Option<&str>, env: Option>, tty: bool, output: O, ) -> anyhow::Result> where O: IntoExecOutput<'o>, { // On Windows, we don't rely on the OS to find the executable if possible to avoid lookups // in the current directory which could be untrusted. Instead we use the ExecutableFinder. let mut process: Process = match command { CommandLine::Shell(mut command) => { if Platform::is_windows() && let Some(m) = preg_match(php_regex!(r"{^([^:/\\]++) }"), &command) { let m1 = m.get(1).unwrap_or_default().to_string(); command = substr_replace( &command, &Self::escape(&Self::get_executable(&m1)), 0, Some(strlen(&m1)), ); } Process::from_shell_commandline( &command, cwd, env, PhpMixed::Null, Some(Self::get_timeout() as f64), )? } CommandLine::Args(mut command) => { if Platform::is_windows() && strlen(&command[0]) == strcspn(&command[0], ":/\\") as i64 { command[0] = Self::get_executable(&command[0]); } Process::new( command, cwd.map(String::from), env, PhpMixed::Null, Some(Self::get_timeout() as f64), )? } }; if !Platform::is_windows() && tty { // PHP: try { $process->setTty(true); } catch (RuntimeException $e) { /* ignore */ } if let Err(e) = process.set_tty(true) && !e.is_instanceof::() { return Err(e); } // ignore TTY enabling errors } let signals = SignalSubscription::new(); let result: anyhow::Result<()> = (|| -> anyhow::Result<()> { match output.to_callback() { Ok(callback) => { process.run(Some(callback), IndexMap::new())?; } Err(mut output) => { let capture_output = self.capture_output; let mut io = self.io.clone(); let callback = move |r#type: &str, buffer: &str| { Self::output_handler(capture_output, &mut io, r#type, buffer); false }; process.run(Some(Box::new(callback)), IndexMap::new())?; if self.capture_output { output.write_back(process.get_output()?); } } } self.error_output = process.get_error_output()?; Ok(()) })(); if signals.is_triggered() && let Some(io) = &self.io { io.write_error3( &format!( "Received {}, aborting when child process is done", signals.last_signal().as_str() ), true, io_interface::DEBUG, ); } let final_result: anyhow::Result<()> = match result { Ok(()) => Ok(()), Err(e) => { if let Some(pse) = e.catch::() { if signals.is_triggered() { // exiting as we were signaled and the child process exited too due to the signal signals.exit_with_last_signal(); } let _ = pse; Ok(()) } else { return Err(e); } } }; final_result?; Ok(process.get_exit_code()) } fn do_execute<'o, O>( &mut self, command: CommandLine, cwd: Option<&str>, tty: bool, output: O, ) -> anyhow::Result where O: IntoExecOutput<'o>, { if self.mock.is_some() { return self.mock_do_execute(command, cwd, output); } self.output_command_run(&command, cwd, false); self.capture_output = output.capture_output(); self.error_output = String::new(); let mut env: Option> = None; let requires_git_dir_env = self.requires_git_dir_env(&command); if let Some(cwd) = cwd && requires_git_dir_env { let is_bare_repository = !is_dir(format!("{}/.git", rtrim(cwd, Some("/")))); if is_bare_repository { let mut config_value = String::new(); let mut git_env: IndexMap = IndexMap::new(); git_env.insert("GIT_DIR".to_string(), cwd.to_string()); self.run_process( CommandLine::Args(vec![ "git".to_string(), "config".to_string(), "safe.bareRepository".to_string(), ]), Some(cwd), Some(git_env.clone()), tty, &mut config_value, )?; let trimmed = trim(&config_value, None); if trimmed == "explicit" { env = Some(git_env); } } } Ok(self .run_process(command, cwd, env, tty, output)? .unwrap_or(0)) } /// Shared expectation-matching logic behind the mock branches of `do_execute` and /// `execute_async` (cf. `ProcessExecutorMock::doExecute`). Logs the command, matches it /// against the head of the expectation queue (exact `===`), pops on match (firing the /// optional callback), falls back to the default handler in non-strict mode, or returns an /// error in strict mode (cf. PHPUnit's `AssertionFailedError`, itself a catchable /// `\RuntimeException` — callers such as `Git::get_mirror_default_branch` rely on being able /// to catch a strict-mode mismatch rather than have it abort the process). Returns `(stdout, /// stderr, return)`. Takes `&self`: the expectation queue is wrapped in a `RefCell` so /// `execute_async`'s `&self` receiver can still consume it. fn mock_match( &self, command: &CommandLine, cwd: Option<&str>, ) -> anyhow::Result<(String, String, i64)> { let command_string = match command { CommandLine::Args(args) => implode(" ", args), CommandLine::Shell(command) => command.clone(), }; let mut mock = self.mock.as_ref().unwrap().borrow_mut(); mock.log.push(command_string); let matched = mock .expectations .as_ref() .map(|exps| !exps.is_empty() && exps[0].cmd == *command) .unwrap_or(false); let (stdout, stderr, r#return); let mut callback = None; if matched { let mut expect = mock.expectations.as_mut().unwrap().remove(0); stdout = expect.stdout.clone(); stderr = expect.stderr.clone(); r#return = expect.r#return; callback = expect.callback.take(); } else if !mock.strict { stdout = mock.default_handler.stdout.clone(); stderr = mock.default_handler.stderr.clone(); r#return = mock.default_handler.r#return; } else { let expected = mock .expectations .as_ref() .filter(|exps| !exps.is_empty()) .map(|exps| format!("Expected {:?} at this point.", exps[0].cmd)) .unwrap_or_else(|| "Expected no more calls at this point.".to_string()); let received = mock.log[..mock.log.len().saturating_sub(1)].join(PHP_EOL); // PHPUnit's `AssertionFailedError` (thrown by `ProcessExecutorMock::doExecute` on a // strict-mode mismatch) extends `\RuntimeException`, so PHP call sites that // `catch (\RuntimeException $e)` around a mock-driven git/hg/svn call (e.g. // `GitDriver::supports`) treat a mismatch as an ordinary recoverable failure. Using // the same `RuntimeException` type here keeps `catch::()` checks // working the same way against a mismatch. return Err(RuntimeException::new(format!( "Received unexpected command {:?} in \"{}\"{}{}{}Received calls:{}{}", command, cwd.unwrap_or(""), PHP_EOL, expected, PHP_EOL, PHP_EOL, received )) .into()); } // Release the RefMut before firing the callback: a callback that re-enters the same // executor (e.g. via a cloned `Rc>`) would otherwise hit // `already mutably borrowed` here even before reaching the outer RefCell. drop(mock); if let Some(mut callback) = callback { callback(); } Ok((stdout, stderr, r#return)) } /// Mock replacement for `do_execute` when [`Self::mock`] is set (cf. /// `ProcessExecutorMock::doExecute`). Delegates the matching to [`Self::mock_match`], then /// emits stdout/stderr through the output target and records `error_output`. fn mock_do_execute<'o, O>( &mut self, command: CommandLine, cwd: Option<&str>, output: O, ) -> anyhow::Result where O: IntoExecOutput<'o>, { let capture_output = output.capture_output(); self.capture_output = capture_output; self.error_output = String::new(); let (stdout, stderr, r#return) = self.mock_match(&command, cwd)?; // Feed stdout/stderr through the output target, mirroring the PHP `$callback(...)` calls. match output.to_callback() { Ok(mut callback) => { if !stdout.is_empty() { callback(Process::OUT, &stdout); } if !stderr.is_empty() { callback(Process::ERR, &stderr); } } Err(mut out) => { let mut io = self.io.clone(); if !stdout.is_empty() { Self::output_handler(capture_output, &mut io, Process::OUT, &stdout); } if !stderr.is_empty() { Self::output_handler(capture_output, &mut io, Process::ERR, &stderr); } if capture_output { out.write_back(stdout.clone()); } } } self.error_output = stderr; Ok(r#return) } /// For testing only. Configures the mock expectation queue (cf. `ProcessExecutorMock::expects`). /// Activates the mock branch in `do_execute`/`execute_async`. pub fn __expects( &mut self, expectations: Vec, strict: bool, default_handler: MockHandler, ) { self.mock = Some(std::cell::RefCell::new(ProcessExecutorMockState { expectations: Some(expectations), strict, default_handler, log: Vec::new(), })); } /// For testing only. Asserts all configured expectations were consumed (cf. /// `ProcessExecutorMock::assertComplete`). Panics with the remaining/received commands otherwise. pub fn __assert_complete(&self) { let Some(mock) = self.mock.as_ref() else { return; }; let mock = mock.borrow(); // Not configured to expect anything, so no need to react here. let Some(expectations) = mock.expectations.as_ref() else { return; }; if !expectations.is_empty() { let remaining: Vec = expectations .iter() .map(|expect| match &expect.cmd { CommandLine::Args(args) => implode(" ", args), CommandLine::Shell(command) => command.clone(), }) .collect(); panic!( "There are still {} expected process calls which have not been consumed:{}{}{}{}Received calls:{}{}", expectations.len(), PHP_EOL, remaining.join(PHP_EOL), PHP_EOL, PHP_EOL, PHP_EOL, mock.log.join(PHP_EOL) ); } } /// starts a process on the commandline in async mode, for callers within Rust-ported Composer /// code (i.e. everything except a plugin holding a `ProcessExecutor` RPC handle — see /// `execute_async_php` and docs/dev/plugin-class-classification.md, "Process: dual /// instantiation split by caller"). This is the direct 1:1 port of PHP's `executeAsync()`: /// almost all calls go through here, spawning in Rust with no PHP child involved. /// /// The returned future does NOT borrow the executor: everything it needs is captured up /// front, so callers can drop their `Ref`/`RefMut` on the shared `Rc>` /// before awaiting (`let fut = pe.borrow_mut().execute_async(...); fut.await`). Holding /// a borrow across the await would panic as soon as a sibling future or a sync `execute()` /// call touches the same executor. The max_jobs throttle is enforced by the semaphore, shared /// with `execute_async_php`. /// /// Takes `&mut self` (unlike the `&self` used while this only read `self.mock`) so the mock /// branch can update `error_output`/`capture_output` before returning, mirroring /// `ProcessExecutorMock::executeAsync` — which resolves through the same `doExecute` the sync /// path uses, so it updates the executor's cached error output too (cf. `mock_do_execute`). pub fn execute_async( &mut self, command: C, cwd: Option<&str>, ) -> std::pin::Pin>>> where C: IntoExecCommand, { let command = command.into_exec_command(); if self.mock.is_some() { // PHP resolves the promise with a Process mock whose getOutput/isSuccessful/getExitCode // reflect the doExecute result; reuse the same expectation matching as the sync mock // branch and fabricate the resolved Process via `Process::__mock`, a test seam mirroring // `ZipArchive::__mock`. let matched = self.mock_match(&command, cwd); if let Ok((_, ref stderr, _)) = matched { self.capture_output = true; self.error_output = stderr.clone(); } return Box::pin(async move { let (stdout, stderr, r#return) = matched?; Ok(Process::__mock(ProcessMock { exit_code: r#return, stdout, stderr, })) }); } let allow_async = self.allow_async; let semaphore = self.semaphore.clone(); let io = self.io.clone(); let cwd = cwd.map(ToOwned::to_owned); Box::pin(async move { if !allow_async { return Err(LogicException::new("You must use the ProcessExecutor instance which is part of a Composer\\Loop instance to be able to run async processes".to_string()) .into()); } // PHP queues the job and only startJob()s it once runningJobs < maxJobs; the permit is // the equivalent gate, so everything below (including the "Executing async command" // debug line PHP prints from startJob) happens only once a slot is free. let _permit = semaphore .acquire() .await .expect("the semaphore is never closed"); Self::output_command_run_with(&io, &command, cwd.as_deref(), true); // PHP: $job['reject']($e) on process construction/start failure — surfaced as Err here. let mut process = match command { CommandLine::Shell(command) => Process::from_shell_commandline( &command, cwd.as_deref(), None, PhpMixed::Null, Some(Self::get_timeout() as f64), )?, CommandLine::Args(command) => Process::new( command, cwd.clone(), None, PhpMixed::Null, Some(Self::get_timeout() as f64), )?, }; process.start(None, IndexMap::new())?; // PHP's countActiveJobs tick: pump the process until it exits, checking the timeout // each round. The async sleep yields to the reactor so sibling jobs genuinely overlap. while process.is_running() { process.check_timeout()?; tokio::time::sleep(std::time::Duration::from_millis(1)).await; } // PHP resolves the promise with the Process regardless of its exit status; callers // inspect is_successful() themselves. Ok(process) }) } fn output_handler( capture_output: bool, io: &mut Option>>, r#type: &str, buffer: &str, ) { if capture_output { return; } if io.is_none() { print!("{}", buffer); return; } if Process::ERR == r#type { io.as_mut() .unwrap() .write_error_raw3(buffer, false, io_interface::NORMAL); } else { io.as_mut() .unwrap() .write_raw3(buffer, false, io_interface::NORMAL); } } pub fn set_max_jobs(&mut self, max_jobs: i64) { self.max_jobs = max_jobs; self.semaphore = std::rc::Rc::new(tokio::sync::Semaphore::new(max_jobs as usize)); } pub fn reset_max_jobs(&mut self) { let max_jobs_env = Platform::get_env("COMPOSER_MAX_PARALLEL_PROCESSES"); let max_jobs_env_mixed = match &max_jobs_env { Some(s) => PhpMixed::String(s.clone()), None => PhpMixed::Null, }; if is_numeric(&max_jobs_env_mixed) { self.max_jobs = max_jobs_env .as_deref() .unwrap_or("0") .parse() .unwrap_or(0) .clamp(1, 50); } else { self.max_jobs = 10; } self.semaphore = std::rc::Rc::new(tokio::sync::Semaphore::new(self.max_jobs as usize)); } /// @internal pub fn enable_async(&mut self) { self.allow_async = true; } pub fn split_lines(&self, output: &str) -> Vec { let output = trim(output, None); if output.is_empty() { vec![] } else { preg_split(php_regex!(r"{\r?\n}"), &output) } } /// Get any error output from the last command pub fn get_error_output(&self) -> &str { &self.error_output } /// @return int the timeout in seconds pub fn get_timeout() -> i64 { *TIMEOUT.lock().unwrap() } /// @param int $timeout the timeout in seconds pub fn set_timeout(timeout: T) { *TIMEOUT.lock().unwrap() = timeout.to_timeout_seconds(); } /// Escapes a string to be used as a shell argument. pub fn escape(argument: &str) -> String { Self::escape_argument(argument) } fn output_command_run(&self, command: &CommandLine, cwd: Option<&str>, r#async: bool) { Self::output_command_run_with(&self.io, command, cwd, r#async); } /// `output_command_run` body as an associated fn so the `execute_async` future can carry a /// clone of the io handle instead of borrowing the executor. fn output_command_run_with( io: &Option>>, command: &CommandLine, cwd: Option<&str>, r#async: bool, ) { if io.is_none() || !io.as_ref().unwrap().is_debug() { return; } let command_string = match command { CommandLine::Shell(command) => command.clone(), CommandLine::Args(args) => implode(" ", &array_map(|arg| Self::escape(arg), args)), }; let safe_command = preg_replace_callback( php_regex!(r"{://(?P[^:/\s]+):(?P[^@\s/]+)@}i"), |m: &PregMatches| -> anyhow::Result { // if the username looks like a long (12char+) hex string, or a modern github token (e.g. ghp_xxx, github_pat_xxx) we obfuscate that if preg_is_match( GitHub::GITHUB_TOKEN_REGEX, m.name("user").unwrap_or_default(), ) { return Ok("://***:***@".to_string()); } if preg_is_match(r"{^[a-f0-9]{12,}$}", m.name("user").unwrap_or_default()) { return Ok("://***:***@".to_string()); } Ok(format!("://{}:***@", m.name("user").unwrap_or_default())) }, &command_string, ) .expect("the replacement callback cannot fail"); let safe_command = preg_replace( php_regex!(r"{--password (.*[^\\]') }"), "--password '***' ", &safe_command, ); io.as_ref().unwrap().write_error(&format!( "Executing{} command ({}): {}", if r#async { " async" } else { "" }, cwd.unwrap_or("CWD"), safe_command )); } /// Escapes a string to be used as a shell argument for Symfony Process. fn escape_argument(argument: &str) -> String { let mut argument = argument.to_string(); if argument.is_empty() { return escapeshellarg(&argument); } if !Platform::is_windows() { return format!("'{}'", str_replace("'", "'\\''", &argument)); } // New lines break cmd.exe command parsing // and special chars like the fullwidth quote can be used to break out // of parameter encoding via "Best Fit" encoding conversion let mut translation: IndexMap = IndexMap::new(); translation.insert("\n".to_string(), " ".to_string()); translation.insert("\u{ff02}".to_string(), "\"".to_string()); translation.insert("\u{02ba}".to_string(), "\"".to_string()); translation.insert("\u{301d}".to_string(), "\"".to_string()); translation.insert("\u{301e}".to_string(), "\"".to_string()); translation.insert("\u{030e}".to_string(), "\"".to_string()); translation.insert("\u{ff1a}".to_string(), ":".to_string()); translation.insert("\u{0589}".to_string(), ":".to_string()); translation.insert("\u{2236}".to_string(), ":".to_string()); translation.insert("\u{ff0f}".to_string(), "/".to_string()); translation.insert("\u{2044}".to_string(), "/".to_string()); translation.insert("\u{2215}".to_string(), "/".to_string()); translation.insert("\u{00b4}".to_string(), "/".to_string()); argument = strtr_array(&argument, &translation); // In addition to whitespace, commas need quoting to preserve paths let mut quote = strpbrk(&argument, " \t,").is_some(); let mut dquotes: usize = 0; // PHP: Preg::replace('/(\\\\*)"/', '$1$1\\"', $argument, -1, $dquotes) argument = preg_replace2( php_regex!(r#"/(\\*)"/"#), r#"$1$1\""#, &argument, -1, Some(&mut dquotes), ); let meta = dquotes > 0 || preg_is_match(php_regex!(r"/%[^%]+%|![^!]+!/"), &argument); if !meta && !quote { quote = strpbrk(&argument, "^&|<>()").is_some(); } if quote { argument = format!("\"{}\"", preg_replace(r"/(\\*)$/", "$1$1", &argument)); } if meta { argument = preg_replace(php_regex!(r#"/(["^&|<>()%])/"#), "^$1", &argument); argument = preg_replace(php_regex!(r"/(!)/"), "^^$1", &argument); } argument } pub fn requires_git_dir_env(&self, command: &CommandLine) -> bool { let cmd: Vec = match command { CommandLine::Shell(command) => explode(" ", command), CommandLine::Args(args) => args.clone(), }; if cmd.first().map(|s| s.as_str()) != Some("git") { return false; } // TODO(php-semantics): the shim's array_intersect returns a Vec and drops the keys PHP // keeps from its first argument, so this compares values where PHP's `===` compares keys // as well. PHP answers false for every pattern below, the early return above leaving `git` // at index 0 so the intersection can never start where the pattern does; this answers // true, which is what the patterns were written for. for git_cmd in Self::GIT_CMDS_NEED_GIT_DIR.iter() { let cmd_strs: Vec = cmd.clone(); let git_cmd_strs: Vec = git_cmd.iter().map(|s| s.to_string()).collect(); if array_intersect(&cmd_strs, &git_cmd_strs) == git_cmd_strs { return true; } } false } /// Resolves executable paths on Windows fn get_executable(name: &str) -> String { if in_array_strict( strtolower(name), &Self::BUILTIN_CMD_COMMANDS .iter() .map(|s| PhpMixed::String(s.to_string())) .collect::>(), ) { return name.to_string(); } let mut executables = EXECUTABLES.lock().unwrap(); if !executables.contains_key(name) { let path = ExecutableFinder::new().find(name, Some(name), &[]); if let Some(p) = path { executables.insert(name.to_string(), p); } } executables .get(name) .cloned() .unwrap_or_else(|| name.to_string()) } } /// Helper trait: convert various command argument forms into a [`CommandLine`]. pub trait IntoExecCommand { fn into_exec_command(self) -> CommandLine; } impl IntoExecCommand for CommandLine { fn into_exec_command(self) -> CommandLine { self } } impl IntoExecCommand for &CommandLine { fn into_exec_command(self) -> CommandLine { self.clone() } } impl IntoExecCommand for &str { fn into_exec_command(self) -> CommandLine { CommandLine::Shell(self.to_string()) } } impl IntoExecCommand for String { fn into_exec_command(self) -> CommandLine { CommandLine::Shell(self) } } impl IntoExecCommand for &String { fn into_exec_command(self) -> CommandLine { CommandLine::Shell(self.clone()) } } impl IntoExecCommand for Vec { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self) } } impl IntoExecCommand for &Vec { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self.clone()) } } impl IntoExecCommand for &[&str; N] { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self.iter().map(|s| s.to_string()).collect()) } } impl IntoExecCommand for &[&str] { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self.iter().map(|s| s.to_string()).collect()) } } impl IntoExecCommand for &[String] { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self.to_vec()) } } impl IntoExecCommand for &[String; N] { fn into_exec_command(self) -> CommandLine { CommandLine::Args(self.to_vec()) } } /// Models the `mixed &$output` parameter of `ProcessExecutor::execute` (cf. /// `composer/src/Composer/Util/ProcessExecutor.php`). In PHP the behaviour is selected by /// `func_num_args()` and `is_callable($output)`; here each behaviour is a distinct implementing type: /// /// | PHP call site | meaning | implementor | `capture_output` | /// |---|---|---|---| /// | `execute($cmd)` | forward child output to STDOUT/STDERR (or the IO) | [`ProcessForwardOutput`] | `false` | /// | `execute($cmd, $out)` | assign captured output back to `$out` | `&mut String` / `&mut PhpMixed` | `true` | /// | `execute($cmd, $out)` where `$out` is unused | capture (suppress output) but discard it | `()` | `true` | /// | `execute($cmd, $out)` from a plugin | capture, recording whether it was assigned | `&mut Option` | `true` | /// | `execute($cmd, $cb)` | drive the child through the callback | `Box bool>` | `false` | /// /// `capture_output` maps to PHP's `$this->captureOutput` (`func_num_args() > 3` in `doExecute`): when /// `true`, `outputHandler` swallows the child output instead of echoing it, and the full output is /// read back via `Process::getOutput()` afterwards. pub trait IntoExecOutput<'a>: Sized { /// Whether the child's output is captured rather than forwarded to the terminal/IO. /// /// Mirrors `$this->captureOutput`: `true` makes `output_handler` swallow the stream so the buffer /// can be retrieved via `Process::get_output` (and handed to [`write_back`](Self::write_back)), /// `false` lets it pass through to STDOUT/STDERR or the IO. fn capture_output(&self) -> bool; /// Splits the output target into the two PHP cases handled by `is_callable($output)`: /// `Ok(callback)` for a user-supplied output handler (passed straight to `Process::run`), /// `Err(self)` for the capture/forward cases (a default handler is used and `self` is returned /// so the captured output can still be written back). fn to_callback(self) -> anyhow::Result bool>, Self>; /// Assigns the captured output back to the by-reference target, mirroring PHP's /// `$output = $process->getOutput()`. Only meaningful when [`capture_output`](Self::capture_output) /// is `true`; a no-op for the forwarding and discarding variants. fn write_back(&mut self, value: String); } /// `execute($cmd, $out)` where the caller never reads `$out` (e.g. `HomeCommand::openBrowser`): /// output is captured (so the terminal stays quiet) but thrown away. impl<'a> IntoExecOutput<'a> for () { fn capture_output(&self) -> bool { true } fn to_callback(self) -> anyhow::Result bool>, Self> { Err(self) } fn write_back(&mut self, _value: String) {} } /// `execute($cmd)` with no output argument: the child's output is forwarded to STDOUT/STDERR /// (or the IO). Obtained via [`ProcessExecutor::FORWARD_OUTPUT`]. impl<'a> IntoExecOutput<'a> for ProcessForwardOutput { fn capture_output(&self) -> bool { false } fn to_callback(self) -> anyhow::Result bool>, Self> { Err(self) } fn write_back(&mut self, _value: String) {} } /// `execute($cmd, $out)` where `$out` holds a `mixed`/string value: output is captured and assigned /// back as a [`PhpMixed::String`]. impl<'a> IntoExecOutput<'a> for &'a mut PhpMixed { fn capture_output(&self) -> bool { true } fn to_callback(self) -> anyhow::Result bool>, Self> { Err(self) } fn write_back(&mut self, value: String) { **self = PhpMixed::String(value); } } /// `execute($cmd, $out)` where `$out` is consumed as a string: output is captured and assigned back /// directly. impl<'a> IntoExecOutput<'a> for &'a mut String { fn capture_output(&self) -> bool { true } fn to_callback(self) -> anyhow::Result bool>, Self> { Err(self) } fn write_back(&mut self, value: String) { **self = value; } } /// `execute($cmd, $out)` reached over the plugin RPC boundary, where the caller has to know whether /// the output was assigned at all rather than just what it is: PHP leaves `$output` untouched when /// the child is signaled, and `None` reproduces that by sending no value back across the boundary. impl<'a> IntoExecOutput<'a> for &'a mut Option { fn capture_output(&self) -> bool { true } fn to_callback(self) -> anyhow::Result bool>, Self> { Err(self) } fn write_back(&mut self, value: String) { **self = Some(value); } } /// `execute($cmd, $cb)` where `$cb` is callable: the callback is passed straight to `Process::run` /// as the output handler, so the caller drives the child's output itself (e.g. `Svn`'s streaming /// handler). The `bool` return mirrors Symfony's ignored callback return value. impl<'a> IntoExecOutput<'a> for Box bool> { fn capture_output(&self) -> bool { false } fn to_callback(self) -> anyhow::Result bool>, Self> { Ok(self) } fn write_back(&mut self, _value: String) {} } /// Helper: accept either `i64` or `PhpMixed` for `set_timeout`. pub trait ToTimeoutSeconds { fn to_timeout_seconds(self) -> i64; } impl ToTimeoutSeconds for i64 { fn to_timeout_seconds(self) -> i64 { self } } impl ToTimeoutSeconds for PhpMixed { fn to_timeout_seconds(self) -> i64 { self.as_int().unwrap_or(0) } }