//! ref: composer/src/Composer/Repository/Vcs/GitBitbucketDriver.php use crate::cache::Cache; use crate::config::Config; use crate::downloader::TransportException; use crate::io::IOInterface; use crate::io::IOInterfaceImmutable; use crate::io::io_interface; use crate::json::JsonEncodeOptions; use crate::json::JsonFile; use crate::repository::vcs::GitDriver; use crate::repository::vcs::VcsDriverBase; use crate::repository::vcs::VcsDriverInterface; use crate::util::Bitbucket; use crate::util::http::Response; use anyhow::Result; use chrono::{DateTime, FixedOffset}; use indexmap::IndexMap; use shirabe_external_packages::composer::pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ InvalidArgumentException, LogicException, PhpMixed, RuntimeException, array_key_exists, array_search_mixed, extension_loaded, http_build_query_mixed, implode, in_array, is_array, strpos, }; #[derive(Debug)] pub struct GitBitbucketDriver { pub(crate) inner: VcsDriverBase, /// @var string pub(crate) owner: String, /// @var string pub(crate) repository: String, /// @var bool has_issues: bool, /// @var ?string root_identifier: Option, /// @var array Map of tag name to identifier tags: Option>, /// @var array Map of branch name to identifier branches: Option>, /// @var string branches_url: String, /// @var string tags_url: String, /// @var string home_url: String, /// @var string website: String, /// @var string clone_https_url: String, /// @var array repo_data: IndexMap, /// @var ?VcsDriver pub(crate) fallback_driver: Option>, /// @var string|null if set either git or hg vcs_type: Option, } impl GitBitbucketDriver { pub fn new( repo_config: IndexMap, io: std::rc::Rc>, config: std::rc::Rc>, http_downloader: std::rc::Rc>, process: std::rc::Rc>, ) -> Self { Self { inner: VcsDriverBase::new(repo_config, io, config, http_downloader, process), owner: String::new(), repository: String::new(), has_issues: false, root_identifier: None, tags: None, branches: None, branches_url: String::new(), tags_url: String::new(), home_url: String::new(), website: String::new(), clone_https_url: String::new(), repo_data: IndexMap::new(), fallback_driver: None, vcs_type: None, } } /// @inheritDoc pub fn initialize(&mut self) -> Result<()> { let mut m: indexmap::IndexMap = indexmap::IndexMap::new(); if !Preg::is_match3( r"#^https?://bitbucket\.org/([^/]+)/([^/]+?)(?:\.git|/?)?$#i", &self.inner.url, Some(&mut m), ) { return Err(InvalidArgumentException { message: format!( "The Bitbucket repository URL {} is invalid. It must be the HTTPS URL of a Bitbucket repository.", self.inner.url.clone(), ), code: 0, } .into()); } self.owner = m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default(); self.repository = m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default(); self.inner.origin_url = "bitbucket.org".to_string(); self.inner.cache = Some(Cache::new( self.inner.io.clone(), &implode( "/", &[ self.inner .config .borrow_mut() .get("cache-repo-dir") .as_string() .unwrap_or("") .to_string(), self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), ], ), None, None, false, )); self.inner.cache.as_mut().unwrap().set_read_only( self.inner .config .borrow_mut() .get("cache-read-only") .as_bool() .unwrap_or(false), ); Ok(()) } /// @inheritDoc pub fn get_url(&self) -> String { if let Some(fallback) = self.fallback_driver.as_ref() { return fallback.get_url(); } self.clone_https_url.clone() } /// Attempts to fetch the repository data via the BitBucket API and /// sets some parameters which are used in other methods /// /// @phpstan-impure fn get_repo_data(&mut self) -> Result { let resource = format!( "https://api.bitbucket.org/2.0/repositories/{}/{}?{}", self.owner.clone(), self.repository.clone(), http_build_query_mixed( &{ let mut m: IndexMap = IndexMap::new(); m.insert( "fields".to_string(), PhpMixed::String("-project,-owner".to_string()), ); m }, "", "&", ), ); let repo_data = self .fetch_with_oauth_credentials(&resource, true)? .decode_json()?; if self.fallback_driver.is_some() { return Ok(false); } let clone_links = repo_data .get("links") .and_then(|v| match v { PhpMixed::Array(m) => m.get("clone"), _ => None, }) .cloned(); self.parse_clone_urls(clone_links); self.has_issues = !shirabe_php_shim::empty( repo_data .get("has_issues") .cloned() .as_ref() .unwrap_or(&PhpMixed::Null), ); self.branches_url = repo_data .get("links") .and_then(|v| match v { PhpMixed::Array(m) => m.get("branches"), _ => None, }) .and_then(|v| match v { PhpMixed::Array(m) => m.get("href").and_then(|v| v.as_string()).map(String::from), _ => None, }) .unwrap_or_default(); self.tags_url = repo_data .get("links") .and_then(|v| match v { PhpMixed::Array(m) => m.get("tags"), _ => None, }) .and_then(|v| match v { PhpMixed::Array(m) => m.get("href").and_then(|v| v.as_string()).map(String::from), _ => None, }) .unwrap_or_default(); self.home_url = repo_data .get("links") .and_then(|v| match v { PhpMixed::Array(m) => m.get("html"), _ => None, }) .and_then(|v| match v { PhpMixed::Array(m) => m.get("href").and_then(|v| v.as_string()).map(String::from), _ => None, }) .unwrap_or_default(); self.website = repo_data .get("website") .and_then(|v| v.as_string()) .map(String::from) .unwrap_or_default(); self.vcs_type = repo_data .get("scm") .and_then(|v| v.as_string()) .map(String::from); self.repo_data = match repo_data { PhpMixed::Array(m) => m, _ => IndexMap::new(), }; Ok(true) } /// @inheritDoc pub fn get_composer_information( &mut self, identifier: &str, ) -> Result>> { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_composer_information(identifier); } if !self.inner.info_cache.contains_key(identifier) { let mut composer: Option> = None; if self.inner.should_cache(identifier) && { let res = self.inner.cache.as_mut().and_then(|c| c.read(identifier)); if let Some(res) = res { composer = JsonFile::parse_json(Some(&res), None)?.as_array().cloned(); true } else { false } } { // composer already set above } else { let file_content = self.get_file_content("composer.json", identifier)?; composer = VcsDriverBase::finish_base_composer_information( identifier, file_content, || self.get_change_date(identifier), )?; if self.inner.should_cache(identifier) { self.inner.cache.as_mut().unwrap().write( identifier, &JsonFile::encode_with_options( &PhpMixed::Array( composer.clone().unwrap_or_default().into_iter().collect(), ), JsonEncodeOptions { pretty_print: false, ..Default::default() }, ), )?; } } if let Some(mut composer_map) = composer.clone() { // specials for bitbucket if composer_map.contains_key("support") && !is_array(composer_map.get("support").unwrap()) { composer_map.insert("support".to_string(), PhpMixed::Array(IndexMap::new())); } let support_has_source = composer_map .get("support") .and_then(|v| match v { PhpMixed::Array(m) => Some(m.contains_key("source")), _ => None, }) .unwrap_or(false); if !support_has_source { let tags = self.get_tags()?; let branches_for_search = self.get_branches()?; let label = array_search_mixed( &PhpMixed::String(identifier.to_string()), &PhpMixed::Array( tags.iter() .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) .collect(), ), false, ) .or_else(|| { array_search_mixed( &PhpMixed::String(identifier.to_string()), &PhpMixed::Array( branches_for_search .iter() .map(|(k, v)| (k.clone(), PhpMixed::String(v.clone()))) .collect(), ), false, ) }) .map(|v| v.as_string().unwrap_or("").to_string()) .unwrap_or_else(|| identifier.to_string()); let tags2 = self.get_tags()?; let branches2 = self.get_branches()?; let mut hash: Option = None; if array_key_exists(&label, &tags2) { hash = tags2.get(&label).cloned(); } else if array_key_exists(&label, &branches2) { hash = branches2.get(&label).cloned(); } let support_entry = composer_map .entry("support".to_string()) .or_insert(PhpMixed::Array(IndexMap::new())); match &hash { None => { if let PhpMixed::Array(support_map) = support_entry { support_map.insert( "source".to_string(), PhpMixed::String(format!( "https://{}/{}/{}/src", self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), )), ); } } Some(hash) => { if let PhpMixed::Array(support_map) = support_entry { support_map.insert( "source".to_string(), PhpMixed::String(format!( "https://{}/{}/{}/src/{}/?at={}", self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), hash, label.clone(), )), ); } } } } let support_has_issues = composer_map .get("support") .and_then(|v| match v { PhpMixed::Array(m) => Some(m.contains_key("issues")), _ => None, }) .unwrap_or(false); if !support_has_issues && self.has_issues { let support_entry = composer_map .entry("support".to_string()) .or_insert(PhpMixed::Array(IndexMap::new())); if let PhpMixed::Array(support_map) = support_entry { support_map.insert( "issues".to_string(), PhpMixed::String(format!( "https://{}/{}/{}/issues", self.inner.origin_url.clone(), self.owner.clone(), self.repository.clone(), )), ); } } if !composer_map.contains_key("homepage") { composer_map.insert( "homepage".to_string(), if self.website.is_empty() { PhpMixed::String(self.home_url.clone()) } else { PhpMixed::String(self.website.clone()) }, ); } composer = Some(composer_map); } self.inner .info_cache .insert(identifier.to_string(), composer); } Ok(self.inner.info_cache.get(identifier).cloned().flatten()) } /// @inheritDoc pub fn get_file_content(&mut self, file: &str, identifier: &str) -> Result> { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_file_content(file, identifier); } let mut identifier = identifier.to_string(); if strpos(&identifier, "/").is_some() { let branches = self.get_branches()?; if let Some(b) = branches.get(&identifier) { identifier = b.clone(); } } let resource = format!( "https://api.bitbucket.org/2.0/repositories/{}/{}/src/{}/{}", self.owner.clone(), self.repository.clone(), identifier, file, ); Ok(self .fetch_with_oauth_credentials(&resource, false)? .get_body() .map(|s| s.to_string())) } /// @inheritDoc pub fn get_change_date(&mut self, identifier: &str) -> Result>> { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_change_date(identifier); } let mut identifier = identifier.to_string(); if strpos(&identifier, "/").is_some() { let branches = self.get_branches()?; if let Some(b) = branches.get(&identifier) { identifier = b.clone(); } } let resource = format!( "https://api.bitbucket.org/2.0/repositories/{}/{}/commit/{}?fields=date", self.owner.clone(), self.repository.clone(), identifier, ); let commit = self .fetch_with_oauth_credentials(&resource, false)? .decode_json()?; let date_str = commit.get("date").and_then(|v| v.as_string()).unwrap_or(""); let date: DateTime = shirabe_php_shim::date_create(date_str)?; Ok(Some(date)) } /// @inheritDoc pub fn get_source(&self, identifier: &str) -> IndexMap { if let Some(fallback) = self.fallback_driver.as_ref() { // TODO(phase-c): PHP getSource is infallible (: array), but the Rust trait made it // Result, so the fallback's Result is flattened here. The faithful fix is making the // VcsDriverInterface get_source/get_dist infallible across all implementations. return fallback.get_source(identifier).unwrap_or_default(); } let mut m: IndexMap = IndexMap::new(); m.insert( "type".to_string(), self.vcs_type.clone().unwrap_or_default(), ); m.insert("url".to_string(), self.get_url()); m.insert("reference".to_string(), identifier.to_string()); m } /// @inheritDoc pub fn get_dist(&self, identifier: &str) -> Option> { if let Some(fallback) = self.fallback_driver.as_ref() { // TODO(phase-c): see get_source above — the trait's over-fallibility is flattened here. return fallback.get_dist(identifier).ok().flatten(); } let url = format!( "https://bitbucket.org/{}/{}/get/{}.zip", self.owner.clone(), self.repository.clone(), identifier, ); let mut m: IndexMap = IndexMap::new(); m.insert("type".to_string(), "zip".to_string()); m.insert("url".to_string(), url); m.insert("reference".to_string(), identifier.to_string()); m.insert("shasum".to_string(), String::new()); Some(m) } /// @inheritDoc pub fn get_tags(&mut self) -> Result> { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_tags(); } if self.tags.is_none() { let mut tags: IndexMap = IndexMap::new(); let mut resource = format!( "{}?{}", self.tags_url.clone(), http_build_query_mixed( &{ let mut m: IndexMap = IndexMap::new(); m.insert("pagelen".to_string(), PhpMixed::Int(100)); m.insert( "fields".to_string(), PhpMixed::String("values.name,values.target.hash,next".to_string()), ); m.insert( "sort".to_string(), PhpMixed::String("-target.date".to_string()), ); m }, "", "&", ), ); let mut has_next = true; while has_next { let tags_data = self .fetch_with_oauth_credentials(&resource, false)? .decode_json()?; let values = tags_data.get("values").cloned(); if let Some(PhpMixed::List(list)) = values { for data in list { if let PhpMixed::Array(m) = data { let name = m .get("name") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); let hash = m .get("target") .and_then(|v| match v { PhpMixed::Array(m) => m.get("hash"), _ => None, }) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); tags.insert(name, hash); } } } if shirabe_php_shim::empty( tags_data .get("next") .cloned() .as_ref() .unwrap_or(&PhpMixed::Null), ) { has_next = false; } else { resource = tags_data .get("next") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); } } self.tags = Some(tags); } Ok(self.tags.clone().unwrap_or_default()) } /// @inheritDoc pub fn get_branches(&mut self) -> Result> { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_branches(); } if self.branches.is_none() { let mut branches: IndexMap = IndexMap::new(); let mut resource = format!( "{}?{}", self.branches_url.clone(), http_build_query_mixed( &{ let mut m: IndexMap = IndexMap::new(); m.insert("pagelen".to_string(), PhpMixed::Int(100)); m.insert( "fields".to_string(), PhpMixed::String( "values.name,values.target.hash,values.heads,next".to_string(), ), ); m.insert( "sort".to_string(), PhpMixed::String("-target.date".to_string()), ); m }, "", "&", ), ); let mut has_next = true; while has_next { let branch_data = self .fetch_with_oauth_credentials(&resource, false)? .decode_json()?; let values = branch_data.get("values").cloned(); if let Some(PhpMixed::List(list)) = values { for data in list { if let PhpMixed::Array(m) = data { let name = m .get("name") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); let hash = m .get("target") .and_then(|v| match v { PhpMixed::Array(m) => m.get("hash"), _ => None, }) .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); branches.insert(name, hash); } } } if shirabe_php_shim::empty( branch_data .get("next") .cloned() .as_ref() .unwrap_or(&PhpMixed::Null), ) { has_next = false; } else { resource = branch_data .get("next") .and_then(|v| v.as_string()) .unwrap_or("") .to_string(); } } self.branches = Some(branches); } Ok(self.branches.clone().unwrap_or_default()) } /// Get the remote content. /// /// @phpstan-impure fn fetch_with_oauth_credentials( &mut self, url: &str, fetching_repo_data: bool, ) -> Result { match self.inner.get_contents(url) { Ok(r) => Ok(r), Err(e) => { let mut bitbucket_util = Bitbucket::new( self.inner.io.clone(), self.inner.config.clone(), Some(self.inner.process.clone()), Some(self.inner.http_downloader.clone()), None, )?; { let te = &e; let code = te.get_code(); let in_set = in_array( PhpMixed::Int(code), &PhpMixed::List(vec![PhpMixed::Int(403), PhpMixed::Int(404)]), true, ); if in_set || (401 == code && strpos(te.get_message(), "Could not authenticate against") == Some(0)) { if !self.inner.io.has_authentication(&self.inner.origin_url) && bitbucket_util.authorize_oauth(&self.inner.origin_url) { return self.inner.get_contents(url).map_err(anyhow::Error::from); } if !self.inner.io.is_interactive() && fetching_repo_data { self.attempt_clone_fallback()?; return Ok(Response::new( "dummy".to_string(), Some(200), vec![], Some("null".to_string()), )); } } } Err(e.into()) } } } /// Generate an SSH URL fn generate_ssh_url(&self) -> String { format!( "git@{}:{}/{}.git", self.inner.origin_url, self.owner, self.repository ) } /// @phpstan-impure /// /// @return true /// @throws \RuntimeException fn attempt_clone_fallback(&mut self) -> Result { match self.setup_fallback_driver(&self.generate_ssh_url()) { Ok(()) => Ok(true), Err(e) => { // TODO(phase-c): PHP catches \RuntimeException (and all its subclasses), letting // other exceptions propagate without this cleanup. Modeling that precisely needs the // PHP exception hierarchy, which is intentionally not reproduced (see CLAUDE.md). self.fallback_driver = None; self.inner.io.write_error(&format!( "Failed to clone the {} repository, try running in interactive mode so that you can enter your Bitbucket OAuth consumer credentials", self.generate_ssh_url() )); Err(e) } } } fn setup_fallback_driver(&mut self, url: &str) -> Result<()> { let mut repo_config: IndexMap = IndexMap::new(); repo_config.insert("url".to_string(), PhpMixed::String(url.to_string())); let mut driver = GitDriver::new( repo_config, self.inner.io.clone(), self.inner.config.clone(), self.inner.http_downloader.clone(), self.inner.process.clone(), ); driver.initialize()?; self.fallback_driver = Some(Box::new(driver)); Ok(()) } /// @param array $cloneLinks fn parse_clone_urls(&mut self, clone_links: Option) { let list = match clone_links { Some(PhpMixed::List(l)) => l, _ => return, }; for clone_link in list { if let PhpMixed::Array(m) = clone_link && m.get("name").and_then(|v| v.as_string()) == Some("https") { // Format: https://(user@)bitbucket.org/{user}/{repo} // Strip username from URL (only present in clone URL's for private repositories) self.clone_https_url = Preg::replace( r"/https:\/\/([^@]+@)?/", "https://", m.get("href").and_then(|v| v.as_string()).unwrap_or(""), ); } } } /// @inheritDoc pub fn get_root_identifier(&mut self) -> Result { if let Some(fallback) = self.fallback_driver.as_mut() { return fallback.get_root_identifier(); } if self.root_identifier.is_none() { if !self.get_repo_data()? { if self.fallback_driver.is_none() { return Err(LogicException { message: "A fallback driver should be setup if getRepoData returns false" .to_string(), code: 0, } .into()); } return self.fallback_driver.as_mut().unwrap().get_root_identifier(); } if self.vcs_type.as_deref() != Some("git") { return Err(RuntimeException { message: format!( "{} does not appear to be a git repository, use {} but remember that Bitbucket no longer supports the mercurial repositories. https://bitbucket.org/blog/sunsetting-mercurial-support-in-bitbucket", self.inner.url, self.clone_https_url ), code: 0, } .into()); } self.root_identifier = self .repo_data .get("mainbranch") .and_then(|v| match v { PhpMixed::Array(m) => m.get("name"), _ => None, }) .and_then(|v| v.as_string()) .map(String::from) .or_else(|| Some("master".to_string())); } Ok(self.root_identifier.clone().unwrap_or_default()) } /// @inheritDoc pub fn supports( io: std::rc::Rc>, _config: std::rc::Rc>, url: &str, _deep: bool, ) -> anyhow::Result { if !Preg::is_match( r"#^https?://bitbucket\.org/([^/]+)/([^/]+?)(\.git|/?)?$#i", url, ) { return Ok(false); } if !extension_loaded("openssl") { io.write_error3( &format!( "Skipping Bitbucket git driver for {} because the OpenSSL PHP extension is missing.", url ), true, io_interface::VERBOSE, ); return Ok(false); } Ok(true) } } impl crate::repository::vcs::VcsDriverInterface for GitBitbucketDriver { fn initialize(&mut self) -> anyhow::Result<()> { GitBitbucketDriver::initialize(self) } fn get_composer_information( &mut self, identifier: &str, ) -> anyhow::Result>> { GitBitbucketDriver::get_composer_information(self, identifier) } fn get_file_content(&mut self, file: &str, identifier: &str) -> anyhow::Result> { GitBitbucketDriver::get_file_content(self, file, identifier) } fn get_change_date( &mut self, identifier: &str, ) -> anyhow::Result>> { GitBitbucketDriver::get_change_date(self, identifier) } fn get_root_identifier(&mut self) -> anyhow::Result { GitBitbucketDriver::get_root_identifier(self) } fn get_branches(&mut self) -> anyhow::Result> { GitBitbucketDriver::get_branches(self) } fn get_tags(&mut self) -> anyhow::Result> { GitBitbucketDriver::get_tags(self) } fn get_dist(&self, identifier: &str) -> anyhow::Result>> { Ok(GitBitbucketDriver::get_dist(self, identifier)) } fn get_source(&self, identifier: &str) -> anyhow::Result> { Ok(GitBitbucketDriver::get_source(self, identifier)) } fn get_url(&self) -> String { GitBitbucketDriver::get_url(self) } fn has_composer_file(&mut self, identifier: &str) -> anyhow::Result { match self.get_composer_information(identifier) { Ok(info) => Ok(info.is_some()), Err(e) => { if e.downcast_ref::().is_some() { Ok(false) } else { Err(e) } } } } fn cleanup(&mut self) -> anyhow::Result<()> { Ok(()) } fn supports( io: std::rc::Rc>, config: std::rc::Rc>, url: &str, deep: bool, ) -> anyhow::Result { GitBitbucketDriver::supports(io, config, url, deep) } }