//! ref: composer/src/Composer/Util/ProcessExecutor.php use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::io::io_interface; use crate::util::GitHub; use crate::util::Platform; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_external_packages::seld::signal::SignalHandler; use shirabe_external_packages::symfony::process::ExecutableFinder; use shirabe_external_packages::symfony::process::Process; use shirabe_external_packages::symfony::process::ProcessMock; use shirabe_external_packages::symfony::process::exception::ProcessSignaledException; use shirabe_external_packages::symfony::process::exception::RuntimeException as SymfonyProcessRuntimeException; use shirabe_php_shim::{ LogicException, PHP_EOL, PhpMixed, RuntimeException, array_intersect, array_map, escapeshellarg, explode, implode, in_array, is_array, is_dir, is_numeric, is_string, php_regex, rtrim, str_replace, strcspn, strlen, strpbrk, strtolower, strtr_array, substr_replace, trim, }; use std::sync::{LazyLock, Mutex}; static EXECUTABLES: LazyLock>> = LazyLock::new(|| Mutex::new(IndexMap::new())); static TIMEOUT: LazyLock> = LazyLock::new(|| Mutex::new(300)); #[derive(Debug)] pub struct ProcessExecutor { /// @var bool pub(crate) capture_output: bool, /// @var string pub(crate) error_output: String, /// @var ?IOInterface pub(crate) 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 { /// `string|list`: a `PhpMixed::String` or `PhpMixed::List`. pub cmd: PhpMixed, 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: PhpMixed) -> 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) } /// Convenience wrapper used by phase-A code that calls /// `process.execute(&[String], &mut String, Option<&str>) == 0`. /// Forwards to `execute`, returning the status code (1 on Err for compatibility) — this /// mirrors PHP call sites that check the `int` return of `execute()` without a surrounding /// `try`/`catch`, where an uncaught mock-mismatch exception would otherwise propagate. // TODO(phase-d): under a strict `ProcessExecutorMock`, an incomplete expectation list now // surfaces here as a swallowed "exit code 1" instead of the old `panic!`, so a future test // ported through this call site could silently take a wrong branch instead of failing loudly. // `ProcessExecutorMockGuard::__assert_complete` still catches unconsumed expectations at // scope exit, but not a mismatch that happened to consume nothing. Distinguishing "expectation // mismatch" from "real process failure" here would need a marker type incompatible with // `RuntimeException` (see `mock_match`'s doc comment) — deferred until a concrete test needs it. pub fn execute_args( &mut self, command: &[String], output: &mut String, cwd: Option<&str>, ) -> i64 { let cmd = PhpMixed::List( command .iter() .map(|s| PhpMixed::String(s.clone())) .collect(), ); let mut buf = PhpMixed::String(String::new()); let rc = self.execute(cmd, &mut buf, cwd).unwrap_or(1); *output = buf.as_string().unwrap_or("").to_string(); rc } /// 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: PhpMixed, 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; if is_string(&command) { let mut command_str = command.as_string().unwrap_or("").to_string(); if Platform::is_windows() { let mut m: IndexMap = IndexMap::new(); if Preg::is_match3(php_regex!(r"{^([^:/\\]++) }"), &command_str, Some(&mut m)) { let m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); command_str = substr_replace( &command_str, &Self::escape(&Self::get_executable(&m1)), 0, strlen(&m1) as usize, ); } } process = Process::from_shell_commandline( &command_str, cwd, env, PhpMixed::Null, Some(Self::get_timeout() as f64), )?; } else if let PhpMixed::List(ref list) = command { let mut cmd_vec: Vec = list .iter() .map(|v| v.as_string().unwrap_or("").to_string()) .collect(); if Platform::is_windows() && strlen(&cmd_vec[0]) == strcspn(&cmd_vec[0], ":/\\") as i64 { cmd_vec[0] = Self::get_executable(&cmd_vec[0]); } process = Process::new( cmd_vec, cwd.map(String::from), env, PhpMixed::Null, Some(Self::get_timeout() as f64), )?; } else { return Err(LogicException { message: "Invalid command type".to_string(), code: 0, } .into()); } if !Platform::is_windows() && tty { // PHP: try { $process->setTty(true); } catch (RuntimeException $e) { /* ignore */ } if let Err(e) = process.set_tty(true) && e.downcast_ref::().is_none() { return Err(e); } // ignore TTY enabling errors } let io_for_signal = self.io.clone(); let signal_handler = SignalHandler::create( vec![ SignalHandler::SIGINT.to_string(), SignalHandler::SIGTERM.to_string(), SignalHandler::SIGHUP.to_string(), ], Box::new(move |signal: String, _h: &SignalHandler| { if let Some(io) = &io_for_signal { io.write_error(&format!( "Received {}, aborting when child process is done", signal )); } }), ); 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(()) })(); let final_result: anyhow::Result<()> = match result { Ok(()) => Ok(()), Err(e) => { if let Some(pse) = e.downcast_ref::() { if signal_handler.is_triggered() { // exiting as we were signaled and the child process exited too due to the signal signal_handler.exit_with_last_signal(); } let _ = pse; Ok(()) } else { signal_handler.unregister(); return Err(e); } } }; signal_handler.unregister(); final_result?; Ok(process.get_exit_code()) } fn do_execute<'o, O>( &mut self, command: PhpMixed, 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 = PhpMixed::String(String::new()); let mut git_env: IndexMap = IndexMap::new(); git_env.insert("GIT_DIR".to_string(), cwd.to_string()); self.run_process( PhpMixed::List(vec![ PhpMixed::String("git".to_string()), PhpMixed::String("config".to_string()), PhpMixed::String("safe.bareRepository".to_string()), ]), Some(cwd), Some(git_env.clone()), tty, &mut config_value, )?; let trimmed = trim(config_value.as_string().unwrap_or(""), 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: &PhpMixed, cwd: Option<&str>, ) -> anyhow::Result<(String, String, i64)> { let command_string = if is_array(command) { match command { PhpMixed::List(l) => implode( " ", &l.iter() .map(|v| v.as_string().unwrap_or("").to_string()) .collect::>(), ), PhpMixed::Array(m) => implode( " ", &m.values() .map(|v| v.as_string().unwrap_or("").to_string()) .collect::>(), ), _ => String::new(), } } else { command.as_string().unwrap_or("").to_string() }; 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 `downcast_ref::()` // checks working the same way against a mismatch. return Err(RuntimeException { message: format!( "Received unexpected command {:?} in \"{}\"{}{}{}Received calls:{}{}", command, cwd.unwrap_or(""), PHP_EOL, expected, PHP_EOL, PHP_EOL, received ), code: 0, } .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: PhpMixed, 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| { if is_array(&expect.cmd) { match &expect.cmd { PhpMixed::List(l) => implode( " ", &l.iter() .map(|v| v.as_string().unwrap_or("").to_string()) .collect::>(), ), PhpMixed::Array(m) => implode( " ", &m.values() .map(|v| v.as_string().unwrap_or("").to_string()) .collect::>(), ), _ => String::new(), } } else { expect.cmd.as_string().unwrap_or("").to_string() } }) .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 { message: "You must use the ProcessExecutor instance which is part of a Composer\\Loop instance to be able to run async processes".to_string(), code: 0, } .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 = if is_string(&command) { Process::from_shell_commandline( command.as_string().unwrap_or(""), cwd.as_deref(), None, PhpMixed::Null, Some(Self::get_timeout() as f64), )? } else if let PhpMixed::List(ref list) = command { Process::new( list.iter() .map(|v| v.as_string().unwrap_or("").to_string()) .collect(), cwd.clone(), None, PhpMixed::Null, Some(Self::get_timeout() as f64), )? } else { return Err(LogicException { message: "Invalid command type".to_string(), code: 0, } .into()); }; 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) }) } /// Plugin-facing counterpart of `execute_async`. Reached only when the RPC dispatcher /// relays a plugin's `executeAsync()` call made on the `rust-proxy` `ProcessExecutor` stub /// (a plugin obtained the handle via `Loop::getProcessExecutor()`) — see /// docs/dev/plugin-class-classification.md, "Process: dual instantiation split by caller". /// /// A `Symfony\Component\Process\Process` cannot be reconstructed on the Rust side: its state /// (the `proc_open()` resource, the OS pipes) belongs to whichever process calls `start()`, /// and it refuses serialization outright. So unlike `execute_async`, this must not /// spawn in Rust: the real `Process::start()` has to run in the PHP child, and the plugin's /// `.then()` callback must receive that genuine PHP-side object. // TODO(plugin): once the plugin RPC channel exists, acquire a permit from `self.semaphore` // (shared with `execute_async`, so the combined job budget — including any shared cap // with HttpDownloader — stays correct regardless of which path runs a given job), then send // the spawn request to the PHP child over that channel instead of calling `Process::start()` // here. Release the permit on the child's completion notification, not by polling a // Rust-owned process handle. The return type below is provisional: the real deliverable is a // handle to the live PHP-side Process object, not a `shirabe_external_packages` `Process` // value, so this signature will need to change once the RPC plumbing exists. pub fn execute_async_php( &mut self, _command: C, _cwd: Option<&str>, ) -> std::pin::Pin>>> where C: IntoExecCommand, { todo!( "forward the spawn to the PHP child over the plugin RPC channel and await its completion notification" ) } 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: &PhpMixed, 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: &PhpMixed, cwd: Option<&str>, r#async: bool, ) { if io.is_none() || !io.as_ref().unwrap().is_debug() { return; } let command_string = if is_string(command) { command.as_string().unwrap_or("").to_string() } else if let PhpMixed::List(list) = command { let parts: Vec = array_map( |v| Self::escape(v.as_string().unwrap_or("")), &list.to_vec(), ); implode(" ", &parts) } else { String::new() }; let safe_command = Preg::replace_callback( php_regex!(r"{://(?P[^:/\s]+):(?P[^@\s/]+)@}i"), |m: &IndexMap| -> String { let user_key = CaptureKey::ByName("user".to_string()); // 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.get(&user_key).cloned().unwrap_or_default().as_str(), ) { return "://***:***@".to_string(); } if Preg::is_match( r"{^[a-f0-9]{12,}$}", m.get(&user_key).cloned().unwrap_or_default().as_str(), ) { return "://***:***@".to_string(); } format!("://{}:***@", m.get(&user_key).cloned().unwrap_or_default()) }, &command_string, ); 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::replace5( php_regex!(r#"/(\\*)"/"#), r#"$1$1\""#, &argument, -1, &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: &PhpMixed) -> bool { let cmd: Vec = if !is_array(command) { explode(" ", command.as_string().unwrap_or("")) } else { match command { PhpMixed::List(l) => l .iter() .map(|v| v.as_string().unwrap_or("").to_string()) .collect(), PhpMixed::Array(m) => m .values() .map(|v| v.as_string().unwrap_or("").to_string()) .collect(), _ => vec![], } }; if cmd.first().map(|s| s.as_str()) != Some("git") { return false; } 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( PhpMixed::String(strtolower(name)), &PhpMixed::List( Self::BUILTIN_CMD_COMMANDS .iter() .map(|s| PhpMixed::String(s.to_string())) .collect(), ), true, ) { 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 `PhpMixed`. pub trait IntoExecCommand { fn into_exec_command(self) -> PhpMixed; } impl IntoExecCommand for PhpMixed { fn into_exec_command(self) -> PhpMixed { self } } impl IntoExecCommand for &PhpMixed { fn into_exec_command(self) -> PhpMixed { self.clone() } } impl IntoExecCommand for &str { fn into_exec_command(self) -> PhpMixed { PhpMixed::String(self.to_string()) } } impl IntoExecCommand for String { fn into_exec_command(self) -> PhpMixed { PhpMixed::String(self) } } impl IntoExecCommand for &String { fn into_exec_command(self) -> PhpMixed { PhpMixed::String(self.clone()) } } impl IntoExecCommand for Vec { fn into_exec_command(self) -> PhpMixed { PhpMixed::List(self.into_iter().map(PhpMixed::String).collect()) } } impl IntoExecCommand for &Vec { fn into_exec_command(self) -> PhpMixed { PhpMixed::List(self.iter().map(|s| PhpMixed::String(s.clone())).collect()) } } impl IntoExecCommand for &[&str; N] { fn into_exec_command(self) -> PhpMixed { PhpMixed::List( self.iter() .map(|s| PhpMixed::String(s.to_string())) .collect(), ) } } impl IntoExecCommand for &[&str] { fn into_exec_command(self) -> PhpMixed { PhpMixed::List( self.iter() .map(|s| PhpMixed::String(s.to_string())) .collect(), ) } } impl IntoExecCommand for &[String] { fn into_exec_command(self) -> PhpMixed { PhpMixed::List(self.iter().map(|s| PhpMixed::String(s.clone())).collect()) } } /// 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, $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, $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) } }