//! ref: composer/src/Composer/Util/Git.php use crate::io::io_interface; use anyhow::Result; use indexmap::IndexMap; use std::sync::Mutex; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ InvalidArgumentException, PHP_EOL, PhpMixed, RuntimeException, array_map, array_merge_recursive, clearstatcache, count, explode, implode, in_array, is_array, is_callable, is_dir, preg_quote, rawurldecode, rawurlencode, str_contains, str_ends_with, str_replace, str_replace_array, strlen, strpos, substr, trim, version_compare, }; use crate::config::Config; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::util::Bitbucket; use crate::util::Filesystem; use crate::util::GitHub; use crate::util::GitLab; use crate::util::HttpDownloader; use crate::util::Platform; use crate::util::ProcessExecutor; use crate::util::Url; use crate::util::{AuthHelper, StoreAuth}; #[derive(Debug)] pub struct Git { pub(crate) io: std::rc::Rc>, pub(crate) config: std::rc::Rc>, pub(crate) process: std::rc::Rc>, pub(crate) filesystem: std::rc::Rc>, pub(crate) http_downloader: Option>>, } /// @var string|false|null static VERSION: Mutex>> = Mutex::new(None); impl Git { pub fn new( io: std::rc::Rc>, config: std::rc::Rc>, process: std::rc::Rc>, fs: std::rc::Rc>, ) -> Self { Self { io, config, process, filesystem: fs, http_downloader: None, } } /// @param IOInterface|null $io If present, a warning is output there instead of throwing, so pass this in only for cases where this is a soft failure pub fn check_for_repo_ownership_error( output: &str, path: &str, io: Option>>, ) -> Result<()> { if str_contains(output, "fatal: detected dubious ownership") { let msg = format!( "The repository at \"{}\" does not have the correct ownership and git refuses to use it:{}{}{}", path, PHP_EOL, PHP_EOL, output ); match io { None => { return Err(RuntimeException { message: msg, code: 0, } .into()); } Some(io) => { io.write_error3( &format!("{}", msg), true, io_interface::NORMAL, ); } } } Ok(()) } pub fn set_http_downloader( &mut self, http_downloader: std::rc::Rc>, ) { self.http_downloader = Some(http_downloader); } /// Runs a set of commands using the $url or a variation of it (with auth, ssh, ..) /// /// Commands should use %url% placeholders for the URL instead of inlining it to allow this function to do its job /// %sanitizedUrl% is also automatically replaced by the url without user/pass /// /// As soon as a single command fails it will halt, so assume the commands are run as && in bash /// /// @param non-empty-array> $commands /// @param mixed $commandOutput the output will be written into this var if passed by ref /// if a callable is passed it will be used as output handler pub fn run_commands( &mut self, commands: Vec>, url: &str, cwd: Option<&str>, initial_clone: bool, command_output: Option<&mut PhpMixed>, ) -> Result<()> { let mut callables: Vec Vec>> = vec![]; for cmd in commands { let cmd_clone = cmd.clone(); callables.push(Box::new(move |url: &str| -> Vec { let mut map: IndexMap = IndexMap::new(); map.insert("%url%".to_string(), url.to_string()); map.insert( "%sanitizedUrl%".to_string(), Preg::replace(r"{://([^@]+?):(.+?)@}", "://", &url).unwrap_or_default(), ); array_map( |value: &String| map.get(value).cloned().unwrap_or_else(|| value.clone()), &cmd_clone, ) })); } // @phpstan-ignore method.deprecated self.run_command(callables, url, cwd, initial_clone, command_output) } /// @param callable|array $commandCallable /// @param mixed $commandOutput the output will be written into this var if passed by ref /// if a callable is passed it will be used as output handler /// @deprecated Use runCommands with placeholders instead of callbacks for simplicity pub fn run_command( &mut self, command_callable: Vec Vec>>, url: &str, cwd: Option<&str>, initial_clone: bool, mut command_output: Option<&mut PhpMixed>, ) -> Result<()> { let command_callables = command_callable; let mut last_command: PhpMixed = PhpMixed::String(String::new()); // Ensure we are allowed to use this URL by config self.config.borrow_mut().prohibit_url_by_config( url, Some(self.io.clone()), &IndexMap::new(), )?; let orig_cwd: Option = if initial_clone { cwd.map(|s| s.to_string()) } else { None }; // TODO(phase-b): closure captures &mut self.process, &mut last_command, etc. // Inlined as a helper that returns (status, last_command, output) let cwd_string = cwd.map(|s| s.to_string()); // PHP closure: $runCommands = function ($url) use (...) { ... }; let mut run_commands_inline = |url_arg: &str, this_process: &mut ProcessExecutor, last_cmd: &mut PhpMixed, command_output: Option<&mut PhpMixed>| -> i64 { let collect_outputs = !command_output .as_ref() .map(|v| is_callable(v)) .unwrap_or(false); let mut outputs: Vec = vec![]; let mut status: i64 = 0; let mut counter: i64 = 0; for callable in &command_callables { let cmd = callable(url_arg); *last_cmd = PhpMixed::List( cmd.iter() .map(|s| Box::new(PhpMixed::String(s.clone()))) .collect(), ); let mut local_output = String::new(); let exec_cwd = if initial_clone && counter == 0 { None } else { cwd_string.clone() }; status = this_process.execute_args(&cmd, &mut local_output, exec_cwd); if collect_outputs { outputs.push(local_output); } if status != 0 { break; } counter += 1; } if collect_outputs { if let Some(out) = command_output { *out = PhpMixed::String(implode("", &outputs)); } } status }; if Preg::is_match(r"{^ssh://[^@]+@[^:]+:[^0-9]+}", url).unwrap_or(false) { return Err(InvalidArgumentException { message: format!( "The source URL {} is invalid, ssh URLs should have a port number after \":\".\nUse ssh://git@example.com:22/path or just git@example.com:path if you do not want to provide a password or custom port.", url ), code: 0, } .into()); } if !initial_clone { // capture username/password from URL if there is one and we have no auth configured yet let mut output = String::new(); self.process.borrow_mut().execute_args( &vec!["git".to_string(), "remote".to_string(), "-v".to_string()], &mut output, cwd.map(|s| s.to_string()), ); let mut m: IndexMap = IndexMap::new(); if Preg::is_match_strict_groups3( r"{^(?:composer|origin)\s+https?://(.+):(.+)@([^/]+)}im", &output, Some(&mut m), ) .unwrap_or(false) { let m3 = m.get(&CaptureKey::ByIndex(3)).cloned().unwrap_or_default(); if !self.io.has_authentication(&m3) { self.io.borrow_mut().set_authentication( m3.clone(), rawurldecode(&m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default()), Some(rawurldecode( &m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(), )), ); } } } let protocols = self.config.borrow_mut().get("github-protocols"); // public github, autoswitch protocols // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups let mut m: IndexMap = IndexMap::new(); if Preg::is_match_strict_groups3( &format!( "{{^(?:https?|git)://{}/(.*)}}", Self::get_github_domains_regex(&*self.config.borrow()) ), url, Some(&mut m), ) .unwrap_or(false) { let mut messages: Vec = vec![]; let protocols_list: Vec = match &protocols { PhpMixed::List(l) => l .iter() .filter_map(|v| v.as_string().map(|s| s.to_string())) .collect(), _ => vec![], }; for protocol in &protocols_list { let m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); let m2 = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); let proto_url = if protocol == "ssh" { format!("git@{}:{}", m1, m2) } else { format!("{}://{}/{}", protocol, m1, m2) }; if run_commands_inline( &proto_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } messages.push(format!( "- {}\n{}", proto_url, Preg::replace( r"#^#m", " ", &self.process.borrow().get_error_output().to_string() ) .unwrap_or_default() )); if initial_clone { if let Some(ref orig) = orig_cwd { self.filesystem.borrow_mut().remove_directory(orig); } } } // failed to checkout, first check git accessibility let m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); if !self.io.has_authentication(&m1) && !self.io.is_interactive() { self.throw_exception( &format!( "Failed to clone {} via {} protocols, aborting.\n\n{}", url, implode(", ", &protocols_list), implode("\n", &messages) ), url, )?; } } // if we have a private github url and the ssh protocol is disabled then we skip it and directly fallback to https let protocols_list: Vec = match self.config.borrow_mut().get("github-protocols") { PhpMixed::List(l) => l .iter() .filter_map(|v| v.as_string().map(|s| s.to_string())) .collect(), _ => vec![], }; let bypass_ssh_for_github = Preg::is_match( &format!( "{{^git@{}:(.+?)\\.git$}}i", Self::get_github_domains_regex(&*self.config.borrow()) ), url, ) .unwrap_or(false) && !in_array( PhpMixed::String("ssh".to_string()), &PhpMixed::List( protocols_list .iter() .map(|s| Box::new(PhpMixed::String(s.clone()))) .collect(), ), true, ); let mut auth: Option>> = None; let mut credentials: Vec = vec![]; if bypass_ssh_for_github || 0 != run_commands_inline( url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) { let mut error_msg = self.process.borrow().get_error_output().to_string(); // private github repository without ssh key access, try https with auth // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups let mut m: IndexMap = IndexMap::new(); let github_matched = Preg::is_match_strict_groups3( &format!( "{{^git@{}:(.+?)\\.git$}}i", Self::get_github_domains_regex(&*self.config.borrow()) ), url, Some(&mut m), ) .unwrap_or(false) || Preg::is_match_strict_groups3( &format!( "{{^https?://{}/(.*?)(?:\\.git)?$}}i", Self::get_github_domains_regex(&*self.config.borrow()) ), url, Some(&mut m), ) .unwrap_or(false); if github_matched { let m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); let m2 = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); if !self.io.has_authentication(&m1) { let mut git_hub_util = GitHub::new( self.io.clone(), self.config.clone(), Some(self.process.clone()), self.http_downloader.clone(), )?; let message = "Cloning failed using an ssh key for authentication, enter your GitHub credentials to access private repos"; if !git_hub_util.authorize_oauth(&m1) && self.io.is_interactive() { git_hub_util.authorize_oauth_interactively(&m1, Some(message)); } } if self.io.has_authentication(&m1) { auth = Some(self.io.get_authentication(&m1)); let auth_inner = auth.as_ref().unwrap(); let username = auth_inner .get("username") .cloned() .unwrap_or(None) .unwrap_or_default(); let password = auth_inner .get("password") .cloned() .unwrap_or(None) .unwrap_or_default(); let auth_url = format!( "https://{}:{}@{}/{}.git", rawurlencode(&username), rawurlencode(&password), m1, m2 ); if run_commands_inline( &auth_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } credentials = vec![rawurlencode(&username), rawurlencode(&password)]; error_msg = self.process.borrow().get_error_output().to_string(); } } else if { let bb_matched = Preg::is_match_strict_groups3( r"{^(https?)://(bitbucket\.org)/(.*?)(?:\.git)?$}i", url, Some(&mut m), ) .unwrap_or(false) || Preg::is_match_strict_groups3( r"{^(git)@(bitbucket\.org):(.+?\.git)$}i", url, Some(&mut m), ) .unwrap_or(false); bb_matched } { // bitbucket either through oauth or app password, with fallback to ssh. let mut bitbucket_util = Bitbucket::new( self.io.clone(), self.config.clone(), Some(self.process.clone()), self.http_downloader.clone(), None, )?; let domain = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); let mut repo_with_git_part = m.get(&CaptureKey::ByIndex(3)).cloned().unwrap_or_default(); if !str_ends_with(&repo_with_git_part, ".git") { repo_with_git_part.push_str(".git"); } if !self.io.has_authentication(&domain) { let message = "Enter your Bitbucket credentials to access private repos"; if !bitbucket_util.authorize_oauth(&domain) && self.io.is_interactive() { bitbucket_util.authorize_oauth_interactively(&domain, Some(message)); let access_token = bitbucket_util.get_token(); self.io.borrow_mut().set_authentication( domain.clone(), "x-token-auth".to_string(), Some(access_token), ); } } // First we try to authenticate with whatever we have stored. if self.io.has_authentication(&domain) { auth = Some(self.io.get_authentication(&domain)); let mut username = auth .as_ref() .unwrap() .get("username") .cloned() .unwrap_or(None) .unwrap_or_default(); let password = auth .as_ref() .unwrap() .get("password") .cloned() .unwrap_or(None) .unwrap_or_default(); // Bitbucket API tokens use the email address as the username for HTTP API calls and // either the Bitbucket username or 'x-bitbucket-api-token-auth' as the username for git operations. if strpos(&password, "ATAT") == Some(0) { username = "x-bitbucket-api-token-auth".to_string(); } let auth_url = format!( "https://{}:{}@{}/{}", rawurlencode(&username), rawurlencode(&password), domain, repo_with_git_part ); if run_commands_inline( &auth_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } // We already have an access_token from a previous request. if username != "x-token-auth" { let access_token = bitbucket_util.request_token(&domain, &username, &password)?; if !access_token.is_empty() { self.io.borrow_mut().set_authentication( domain.clone(), "x-token-auth".to_string(), Some(access_token), ); } } } if self.io.has_authentication(&domain) { auth = Some(self.io.get_authentication(&domain)); let username = auth .as_ref() .unwrap() .get("username") .cloned() .unwrap_or(None) .unwrap_or_default(); let password = auth .as_ref() .unwrap() .get("password") .cloned() .unwrap_or(None) .unwrap_or_default(); let auth_url = format!( "https://{}:{}@{}/{}", rawurlencode(&username), rawurlencode(&password), domain, repo_with_git_part ); if run_commands_inline( &auth_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } credentials = vec![rawurlencode(&username), rawurlencode(&password)]; } // Falling back to ssh let ssh_url = format!("git@bitbucket.org:{}", repo_with_git_part); self.io.write_error3( " No bitbucket authentication configured. Falling back to ssh.", true, io_interface::NORMAL, ); if run_commands_inline( &ssh_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } error_msg = self.process.borrow().get_error_output().to_string(); } else if { let gl_matched = Preg::is_match_strict_groups3( &format!( "{{^(git)@{}:(.+?\\.git)$}}i", Self::get_gitlab_domains_regex(&*self.config.borrow()) ), url, Some(&mut m), ) .unwrap_or(false) || Preg::is_match_strict_groups3( &format!( "{{^(https?)://{}/(.*)}}i", Self::get_gitlab_domains_regex(&*self.config.borrow()) ), url, Some(&mut m), ) .unwrap_or(false); gl_matched } { let mut m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); let m2 = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); let m3 = m.get(&CaptureKey::ByIndex(3)).cloned().unwrap_or_default(); if m1 == "git" { m1 = "https".to_string(); } if !self.io.has_authentication(&m2) { let mut git_lab_util = GitLab::new( self.io.clone(), self.config.clone(), Some(self.process.clone()), self.http_downloader.clone(), )?; let message = "Cloning failed, enter your GitLab credentials to access private repos"; if !git_lab_util.authorize_oauth(&m2) && self.io.is_interactive() { git_lab_util.authorize_oauth_interactively(&m1, &m2, Some(message)); } } if self.io.has_authentication(&m2) { auth = Some(self.io.get_authentication(&m2)); let username = auth .as_ref() .unwrap() .get("username") .cloned() .unwrap_or(None) .unwrap_or_default(); let password = auth .as_ref() .unwrap() .get("password") .cloned() .unwrap_or(None) .unwrap_or_default(); let auth_url = if password == "private-token" || password == "oauth2" || password == "gitlab-ci-token" { format!( "{}://{}:{}@{}/{}", m1, rawurlencode(&password), rawurlencode(&username), m2, m3 ) // swap username and password } else { format!( "{}://{}:{}@{}/{}", m1, rawurlencode(&username), rawurlencode(&password), m2, m3 ) }; if run_commands_inline( &auth_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { return Ok(()); } credentials = vec![rawurlencode(&username), rawurlencode(&password)]; error_msg = self.process.borrow().get_error_output().to_string(); } } else if let Some(m) = self.get_authentication_failure(url) { // private non-github/gitlab/bitbucket repo that failed to authenticate let mut m1 = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); let mut m2 = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); let m3 = m.get(&CaptureKey::ByIndex(3)).cloned().unwrap_or_default(); let mut auth_parts: Option = None; if str_contains(&m2, "@") { let parts = explode("@", &m2); auth_parts = parts.get(0).cloned(); m2 = parts.get(1).cloned().unwrap_or_default(); } let mut store_auth: PhpMixed = PhpMixed::Bool(false); if self.io.has_authentication(&m2) { auth = Some(self.io.get_authentication(&m2)); } else if self.io.is_interactive() { let mut default_username: Option = None; if let Some(ref parts) = auth_parts { if !parts.is_empty() { if str_contains(parts, ":") { let split = explode(":", parts); default_username = split.get(0).cloned(); } else { default_username = Some(parts.clone()); } } } self.io.write_error3( &format!(" Authentication required ({}):", m2), true, io_interface::NORMAL, ); self.io.write_error3( &format!("{}", trim(&error_msg, None)), true, io_interface::VERBOSE, ); let mut auth_map: IndexMap> = IndexMap::new(); auth_map.insert( "username".to_string(), self.io .ask( " Username: ".to_string(), default_username .clone() .map(PhpMixed::String) .unwrap_or(PhpMixed::Null), ) .as_string() .map(|s| s.to_string()), ); auth_map.insert( "password".to_string(), self.io.ask_and_hide_answer(" Password: ".to_string()), ); auth = Some(auth_map); store_auth = self.config.borrow_mut().get("store-auths"); } if let Some(auth_inner) = auth.as_ref() { let username = auth_inner .get("username") .cloned() .unwrap_or(None) .unwrap_or_default(); let password = auth_inner .get("password") .cloned() .unwrap_or(None) .unwrap_or_default(); let auth_url = format!( "{}{}:{}@{}{}", m1, rawurlencode(&username), rawurlencode(&password), m2, m3 ); if run_commands_inline( &auth_url, &mut *self.process.borrow_mut(), &mut last_command, command_output.as_deref_mut(), ) == 0 { self.io.borrow_mut().set_authentication( m2.clone(), username, Some(password), ); let mut auth_helper = AuthHelper::new(self.io.clone(), self.config.clone()); let store_auth_enum = match &store_auth { PhpMixed::String(s) if s == "prompt" => StoreAuth::Prompt, PhpMixed::Bool(b) => StoreAuth::Bool(*b), _ => StoreAuth::Bool(false), }; auth_helper.store_auth(&m2, store_auth_enum)?; return Ok(()); } credentials = vec![rawurlencode(&username), rawurlencode(&password)]; error_msg = self.process.borrow().get_error_output().to_string(); } } if initial_clone { if let Some(ref orig) = orig_cwd { self.filesystem.borrow_mut().remove_directory(orig); } } let mut last_command_str = match &last_command { PhpMixed::List(l) => { let parts: Vec = l .iter() .filter_map(|v| v.as_string().map(|s| s.to_string())) .collect(); implode(" ", &parts) } _ => last_command.as_string().unwrap_or("").to_string(), }; let mut error_msg = self.process.borrow().get_error_output().to_string(); if (credentials.len() as i64) > 0 { last_command_str = self.mask_credentials(&last_command_str, &credentials); error_msg = self.mask_credentials(&error_msg, &credentials); } self.throw_exception( &format!("Failed to execute {}\n\n{}", last_command_str, error_msg), url, )?; } Ok(()) } pub fn sync_mirror(&mut self, url: &str, dir: &str) -> Result { let composer_disable_network = Platform::get_env("COMPOSER_DISABLE_NETWORK"); if composer_disable_network .as_ref() .map(|v| !v.is_empty() && v != "0") .unwrap_or(false) && composer_disable_network.as_deref() != Some("prime") { self.io.write_error3( &format!( "Aborting git mirror sync of {} as network is disabled", url ), true, io_interface::NORMAL, ); return Ok(false); } // update the repo if it is a valid git repository let mut output = String::new(); if is_dir(dir) && self.process.borrow_mut().execute_args( &vec![ "git".to_string(), "rev-parse".to_string(), "--git-dir".to_string(), ], &mut output, Some(dir.to_string()), ) == 0 && trim(&output, None) == "." { // PHP try/finally let try_result: Result<()> = (|| -> Result<()> { let commands = vec![ vec![ "git".to_string(), "remote".to_string(), "set-url".to_string(), "origin".to_string(), "--".to_string(), "%url%".to_string(), ], vec![ "git".to_string(), "remote".to_string(), "update".to_string(), "--prune".to_string(), "origin".to_string(), ], vec!["git".to_string(), "gc".to_string(), "--auto".to_string()], ]; self.run_commands(commands, url, Some(dir), false, None)?; Ok(()) })(); // finally let _ = self.run_commands( vec![vec![ "git".to_string(), "remote".to_string(), "set-url".to_string(), "origin".to_string(), "--".to_string(), "%sanitizedUrl%".to_string(), ]], url, Some(dir), false, None, ); if let Err(e) = try_result { self.io.write_error3( &format!("Sync mirror failed: {}", e), true, io_interface::DEBUG, ); return Ok(false); } return Ok(true); } Self::check_for_repo_ownership_error(self.process.borrow().get_error_output(), dir, None)?; // clean up directory and do a fresh clone into it self.filesystem.borrow_mut().remove_directory(dir); self.run_commands( vec![vec![ "git".to_string(), "clone".to_string(), "--mirror".to_string(), "--".to_string(), "%url%".to_string(), dir.to_string(), ]], url, Some(dir), true, None, )?; self.run_commands( vec![vec![ "git".to_string(), "remote".to_string(), "set-url".to_string(), "origin".to_string(), "--".to_string(), "%sanitizedUrl%".to_string(), ]], url, Some(dir), false, None, )?; Ok(true) } pub fn fetch_ref_or_sync_mirror( &mut self, url: &str, dir: &str, r#ref: &str, pretty_version: Option<&str>, ) -> Result { if self.check_ref_is_in_mirror(dir, r#ref)? { if Preg::is_match(r"{^[a-f0-9]{40}$}", r#ref).unwrap_or(false) && pretty_version.is_some() { let branch = Preg::replace(r"{(?:^dev-|(?:\.x)?-dev$)}i", "", &pretty_version.unwrap())?; let mut branches: Option = None; let mut tags: Option = None; let mut output = String::new(); if self.process.borrow_mut().execute_args( &vec!["git".to_string(), "branch".to_string()], &mut output, Some(dir.to_string()), ) == 0 { branches = Some(output); } let mut output = String::new(); if self.process.borrow_mut().execute_args( &vec!["git".to_string(), "tag".to_string()], &mut output, Some(dir.to_string()), ) == 0 { tags = Some(output); } // if the pretty version cannot be found as a branch (nor branch with 'v' in front of the branch as it may have been stripped when generating pretty name), // nor as a tag, then we sync the mirror as otherwise it will likely fail during install. // this can occur if a git tag gets created *after* the reference is already put into the cache, as the ref check above will then not sync the new tags // see https://github.com/composer/composer/discussions/11002 if branches.is_some() && !Preg::is_match( &format!(r"{{^[\s*]*v?{}$}}m", preg_quote(&branch, None)), branches.as_deref().unwrap_or(""), ) .unwrap_or(false) && tags.is_some() && !Preg::is_match( &format!(r"{{^[\s*]*{}$}}m", preg_quote(&branch, None)), tags.as_deref().unwrap_or(""), ) .unwrap_or(false) { self.sync_mirror(url, dir)?; } } return Ok(true); } if self.sync_mirror(url, dir)? { return self.check_ref_is_in_mirror(dir, r#ref); } Ok(false) } pub fn get_no_show_signature_flag( process: &std::rc::Rc>, ) -> String { let git_version = Self::get_version(process); if let Some(v) = git_version { if version_compare(&v, "2.10.0-rc0", ">=") { return " --no-show-signature".to_string(); } } String::new() } /// @return list pub fn get_no_show_signature_flags( process: &std::rc::Rc>, ) -> Vec { let flags = Self::get_no_show_signature_flag(process); if flags.is_empty() { return vec![]; } explode(" ", &substr(&flags, 1, None)) } /// Checks if git version supports --no-commit-header flag (git 2.33+) /// /// @internal pub fn supports_no_commit_header_flag( process: &std::rc::Rc>, ) -> bool { let git_version = Self::get_version(process); git_version .map(|v| version_compare(&v, "2.33.0-rc0", ">=")) .unwrap_or(false) } /// Builds a git rev-list command with --no-commit-header flag when supported (git 2.33+) /// /// @internal /// @param list $arguments Additional arguments for git rev-list /// @return non-empty-list pub fn build_rev_list_command( process: &std::rc::Rc>, arguments: Vec, ) -> Vec { let mut command = vec!["git".to_string(), "rev-list".to_string()]; if Self::supports_no_commit_header_flag(process) { command.push("--no-commit-header".to_string()); } command.extend(arguments); command } /// Parses git rev-list output, removing 'commit ' header lines for git < 2.33. /// /// When --no-commit-header is not available (git < 2.33), git rev-list --format outputs /// "commit " before formatted output. This removes those lines. /// /// @internal pub fn parse_rev_list_output( output: &str, process: &std::rc::Rc>, ) -> String { // If git supports --no-commit-header, output is already clean if Self::supports_no_commit_header_flag(process) { return output.to_string(); } // Filter out "commit " lines for older git versions Preg::replace(r"{^commit [a-f0-9]{40}\n?}m", "", output).unwrap_or_default() } fn check_ref_is_in_mirror(&mut self, dir: &str, r#ref: &str) -> Result { let mut output = String::new(); if is_dir(dir) && self.process.borrow_mut().execute_args( &vec![ "git".to_string(), "rev-parse".to_string(), "--git-dir".to_string(), ], &mut output, Some(dir.to_string()), ) == 0 && trim(&output, None) == "." { let mut ignored_output = String::new(); let exit_code = self.process.borrow_mut().execute_args( &vec![ "git".to_string(), "rev-parse".to_string(), "--quiet".to_string(), "--verify".to_string(), format!("{}^{{commit}}", r#ref), ], &mut ignored_output, Some(dir.to_string()), ); if exit_code == 0 { return Ok(true); } } Self::check_for_repo_ownership_error(self.process.borrow().get_error_output(), dir, None)?; Ok(false) } /// @return array|null fn get_authentication_failure(&self, url: &str) -> Option> { let mut m: IndexMap = IndexMap::new(); if !Preg::is_match_strict_groups3(r"{^(https?://)([^/]+)(.*)$}i", url, Some(&mut m)) .unwrap_or(false) { return None; } let auth_failures = [ "fatal: Authentication failed", "remote error: Invalid username or password.", "error: 401 Unauthorized", "fatal: unable to access", "fatal: could not read Username", ]; let error_output = self.process.borrow().get_error_output().to_string(); for auth_failure in &auth_failures { if strpos(&error_output, auth_failure).is_some() { return Some(m); } } None } pub fn get_mirror_default_branch( &mut self, url: &str, dir: &str, is_local_path_repository: bool, ) -> Option { if Platform::get_env("COMPOSER_DISABLE_NETWORK") .map(|v| !v.is_empty() && v != "0") .unwrap_or(false) { return None; } let result: Result> = (|| -> Result> { let mut output_mixed = PhpMixed::String(String::new()); if is_local_path_repository { let mut output = String::new(); self.process.borrow_mut().execute_args( &vec![ "git".to_string(), "remote".to_string(), "show".to_string(), "origin".to_string(), ], &mut output, Some(dir.to_string()), ); output_mixed = PhpMixed::String(output); } else { let commands = vec![ vec![ "git".to_string(), "remote".to_string(), "set-url".to_string(), "origin".to_string(), "--".to_string(), "%url%".to_string(), ], vec![ "git".to_string(), "remote".to_string(), "show".to_string(), "origin".to_string(), ], vec![ "git".to_string(), "remote".to_string(), "set-url".to_string(), "origin".to_string(), "--".to_string(), "%sanitizedUrl%".to_string(), ], ]; self.run_commands(commands, url, Some(dir), false, Some(&mut output_mixed))?; } let lines = self .process .borrow() .split_lines(output_mixed.as_string().unwrap_or("")); for line in lines { let mut matches: IndexMap = IndexMap::new(); if Preg::is_match_strict_groups3( r"{^\s*HEAD branch:\s(.+)\s*$}m", &line, Some(&mut matches), ) .unwrap_or(false) { return Ok(Some( matches .get(&CaptureKey::ByIndex(1)) .cloned() .unwrap_or_default(), )); } } Ok(None) })(); match result { Ok(v) => v, Err(e) => { self.io.write_error3( &format!( "Failed to fetch root identifier from remote: {}", e ), true, io_interface::DEBUG, ); None } } } pub fn clean_env(process: &std::rc::Rc>) { // PHP: $process ?? new ProcessExecutor() let git_version = Self::get_version(process); if let Some(v) = git_version { if version_compare(&v, "2.3.0", ">=") { // added in git 2.3.0, prevents prompting the user for username/password if Platform::get_env("GIT_TERMINAL_PROMPT").as_deref() != Some("0") { Platform::put_env("GIT_TERMINAL_PROMPT", "0"); } } else { // added in git 1.7.1, prevents prompting the user for username/password if Platform::get_env("GIT_ASKPASS").as_deref() != Some("echo") { Platform::put_env("GIT_ASKPASS", "echo"); } } } // clean up rogue git env vars in case this is running in a git hook if Platform::get_env("GIT_DIR").is_some() { Platform::clear_env("GIT_DIR"); } if Platform::get_env("GIT_WORK_TREE").is_some() { Platform::clear_env("GIT_WORK_TREE"); } // Run processes with predictable LANGUAGE if Platform::get_env("LANGUAGE").as_deref() != Some("C") { Platform::put_env("LANGUAGE", "C"); } // clean up env for OSX, see https://github.com/composer/composer/issues/2146#issuecomment-35478940 Platform::clear_env("DYLD_LIBRARY_PATH"); } /// @return non-empty-string pub fn get_github_domains_regex(config: &Config) -> String { let domains: Vec = match config.get("github-domains") { PhpMixed::List(l) => l .iter() .filter_map(|v| v.as_string().map(|s| s.to_string())) .collect(), _ => vec![], }; let escaped: Vec = array_map(|s: &String| preg_quote(s, None), &domains); format!("({})", implode("|", &escaped)) } /// @return non-empty-string pub fn get_gitlab_domains_regex(config: &Config) -> String { let domains: Vec = match config.get("gitlab-domains") { PhpMixed::List(l) => l .iter() .filter_map(|v| v.as_string().map(|s| s.to_string())) .collect(), _ => vec![], }; let escaped: Vec = array_map(|s: &String| preg_quote(s, None), &domains); format!("({})", implode("|", &escaped)) } /// @param non-empty-string $message /// /// @return never fn throw_exception(&mut self, message: &str, url: &str) -> Result<()> { // git might delete a directory when it fails and php will not know clearstatcache(); let mut ignored_output = String::new(); if self.process.borrow_mut().execute_args( &vec!["git".to_string(), "--version".to_string()], &mut ignored_output, Option::<&str>::None, ) != 0 { return Err(RuntimeException { message: Url::sanitize(format!( "Failed to clone {}, git was not found, check that it is installed and in your PATH env.\n\n{}", url, self.process.borrow().get_error_output() )), code: 0, } .into()); } Err(RuntimeException { message: Url::sanitize(message.to_string()), code: 0, } .into()) } /// Retrieves the current git version. /// /// @return string|null The git version number, if present. pub fn get_version( process: &std::rc::Rc>, ) -> Option { let mut version = VERSION.lock().unwrap(); if version.is_none() { *version = Some(None); let mut output = String::new(); // TODO(phase-b): ProcessExecutor::execute takes &mut self; this static fn takes &ProcessExecutor // For now, mimic the call signature (compilation fix is Phase B) let exit_code: i64 = 0; // process.execute(&["git", "--version"].map(String::from).to_vec(), &mut output, None); if exit_code == 0 { let mut matches: IndexMap = IndexMap::new(); if Preg::is_match_strict_groups3( r"/^git version (\d+(?:\.\d+)+)/m", &output, Some(&mut matches), ) .unwrap_or(false) { *version = Some(matches.get(&CaptureKey::ByIndex(1)).cloned()); } } } version.clone().unwrap_or(None) } /// @param string[] $credentials fn mask_credentials(&self, error: &str, credentials: &[String]) -> String { let mut masked_credentials: Vec = vec![]; for credential in credentials { if in_array( PhpMixed::String(credential.clone()), &PhpMixed::List(vec![ Box::new(PhpMixed::String("private-token".to_string())), Box::new(PhpMixed::String("x-token-auth".to_string())), Box::new(PhpMixed::String("oauth2".to_string())), Box::new(PhpMixed::String("gitlab-ci-token".to_string())), Box::new(PhpMixed::String("x-oauth-basic".to_string())), ]), false, ) { masked_credentials.push(credential.clone()); } else if strlen(credential) > 6 { masked_credentials.push(format!( "{}...{}", substr(credential, 0, Some(3)), substr(credential, -3, None) )); } else if strlen(credential) > 3 { masked_credentials.push(format!("{}...", substr(credential, 0, Some(3)))); } else { masked_credentials.push("XXX".to_string()); } } str_replace_array(credentials, &masked_credentials, error) } }