diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 11:06:13 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 11:06:13 +0900 |
| commit | 3c5ad3a5e3984a54e58714c81465288c43c4cc69 (patch) | |
| tree | 02741a5cc33eab7c0f5a64842beaa75d4858b6fc /crates/shirabe/src/util | |
| parent | f17eb98f1b73602fa87399cc04f0fbe2afd1f3f2 (diff) | |
| download | php-shirabe-3c5ad3a5e3984a54e58714c81465288c43c4cc69.tar.gz php-shirabe-3c5ad3a5e3984a54e58714c81465288c43c4cc69.tar.zst php-shirabe-3c5ad3a5e3984a54e58714c81465288c43c4cc69.zip | |
feat(port): port Bitbucket.php, GitDriver.php, GitHub.php, BumpCommand.php, VersionSelector.php
Diffstat (limited to 'crates/shirabe/src/util')
| -rw-r--r-- | crates/shirabe/src/util/bitbucket.rs | 526 | ||||
| -rw-r--r-- | crates/shirabe/src/util/github.rs | 390 |
2 files changed, 916 insertions, 0 deletions
diff --git a/crates/shirabe/src/util/bitbucket.rs b/crates/shirabe/src/util/bitbucket.rs index 29717e1..42bfea5 100644 --- a/crates/shirabe/src/util/bitbucket.rs +++ b/crates/shirabe/src/util/bitbucket.rs @@ -1 +1,527 @@ //! ref: composer/src/Composer/Util/Bitbucket.php + +use indexmap::IndexMap; +use shirabe_php_shim::{time, LogicException, PhpMixed}; + +use crate::config::config_source_interface::ConfigSourceInterface; +use crate::config::Config; +use crate::downloader::transport_exception::TransportException; +use crate::factory::Factory; +use crate::io::io_interface::IOInterface; +use crate::util::http_downloader::HttpDownloader; +use crate::util::process_executor::ProcessExecutor; + +#[derive(Debug)] +pub struct Bitbucket { + io: Box<dyn IOInterface>, + config: Config, + process: ProcessExecutor, + http_downloader: HttpDownloader, + token: Option<IndexMap<String, PhpMixed>>, + time: Option<i64>, +} + +impl Bitbucket { + pub const OAUTH2_ACCESS_TOKEN_URL: &'static str = + "https://bitbucket.org/site/oauth2/access_token"; + + pub fn new( + io: Box<dyn IOInterface>, + config: Config, + process: Option<ProcessExecutor>, + http_downloader: Option<HttpDownloader>, + time: Option<i64>, + ) -> anyhow::Result<Self> { + let process = process.unwrap_or_else(|| ProcessExecutor::new(&*io)); + let http_downloader = match http_downloader { + Some(h) => h, + None => Factory::create_http_downloader(&*io, &config)?, + }; + Ok(Self { + io, + config, + process, + http_downloader, + token: None, + time, + }) + } + + pub fn get_token(&self) -> String { + match &self.token { + Some(token) => token + .get("access_token") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()) + .unwrap_or_default(), + None => String::new(), + } + } + + pub fn authorize_oauth(&mut self, origin_url: &str) -> bool { + if origin_url != "bitbucket.org" { + return false; + } + + let mut output = String::new(); + if self.process.execute( + &[ + "git".to_string(), + "config".to_string(), + "bitbucket.accesstoken".to_string(), + ], + &mut output, + None, + ) == 0 + { + self.io.set_authentication( + origin_url.to_string(), + "x-token-auth".to_string(), + Some(output.trim().to_string()), + ); + return true; + } + + false + } + + fn request_access_token(&mut self) -> anyhow::Result<bool> { + let mut http = IndexMap::new(); + http.insert( + "method".to_string(), + Box::new(PhpMixed::String("POST".to_string())), + ); + http.insert( + "content".to_string(), + Box::new(PhpMixed::String( + "grant_type=client_credentials".to_string(), + )), + ); + let mut options = IndexMap::new(); + options.insert( + "retry-auth-failure".to_string(), + Box::new(PhpMixed::Bool(false)), + ); + options.insert( + "http".to_string(), + Box::new(PhpMixed::Array(http)), + ); + let options = PhpMixed::Array(options); + + let response = + match self + .http_downloader + .get(Self::OAUTH2_ACCESS_TOKEN_URL, &options) + { + Ok(r) => r, + Err(te) => { + if te.code == 400 { + self.io.write_error( + PhpMixed::String( + "<error>Invalid OAuth consumer provided.</error>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String("This can have three reasons:".to_string()), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "1. You are authenticating with a bitbucket username/password combination".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "2. You are using an OAuth consumer, but didn't configure a (dummy) callback url".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "3. You are using an OAuth consumer, but didn't configure it as private consumer".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + if te.code == 403 || te.code == 401 { + self.io.write_error( + PhpMixed::String( + "<error>Invalid OAuth consumer provided.</error>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "You can also add it manually later by using \"composer config --global --auth bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>\"".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + return Err(te.into()); + } + }; + + let token = response.decode_json()?; + let token_map = match token { + PhpMixed::Array(ref m) => m.clone(), + _ => { + return Err(LogicException { + message: format!( + "Expected a token configured with expires_in and access_token present, got {}", + shirabe_php_shim::json_encode(&token).unwrap_or_default() + ), + code: 0, + } + .into()); + } + }; + if !token_map.contains_key("expires_in") || !token_map.contains_key("access_token") { + return Err(LogicException { + message: format!( + "Expected a token configured with expires_in and access_token present, got {}", + shirabe_php_shim::json_encode(&token).unwrap_or_default() + ), + code: 0, + } + .into()); + } + self.token = Some( + token_map + .into_iter() + .map(|(k, v)| (k, *v)) + .collect(), + ); + + Ok(true) + } + + pub fn authorize_oauth_interactively( + &mut self, + origin_url: &str, + message: Option<&str>, + ) -> anyhow::Result<bool> { + if let Some(msg) = message { + self.io.write_error( + PhpMixed::String(msg.to_string()), + true, + IOInterface::NORMAL, + ); + } + + let local_auth_config = self.config.get_local_auth_config_source(); + let url = + "https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/"; + self.io.write_error( + PhpMixed::String("Follow the instructions here:".to_string()), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String(url.to_string()), + true, + IOInterface::NORMAL, + ); + let auth_config_source_name = self.config.get_auth_config_source().get_name(); + let local_name_prefix = local_auth_config + .as_ref() + .map(|c| format!("{} OR ", c.get_name())) + .unwrap_or_default(); + self.io.write_error( + PhpMixed::String(format!( + "to create a consumer. It will be stored in \"{}\" for future use by Composer.", + local_name_prefix + &auth_config_source_name + )), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "Ensure you enter a \"Callback URL\" (http://example.com is fine) or it will not be possible to create an Access Token (this callback url will not be used by composer)".to_string(), + ), + true, + IOInterface::NORMAL, + ); + + let mut store_in_local_auth_config = false; + if local_auth_config.is_some() { + store_in_local_auth_config = self.io.ask_confirmation( + "A local auth config source was found, do you want to store the token there?" + .to_string(), + true, + ); + } + + let consumer_key = self + .io + .ask_and_hide_answer("Consumer Key (hidden): ".to_string()) + .unwrap_or_default() + .trim() + .to_string(); + + if consumer_key.is_empty() { + self.io.write_error( + PhpMixed::String( + "<warning>No consumer key given, aborting.</warning>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "You can also add it manually later by using \"composer config --global --auth bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>\"".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + + let consumer_secret = self + .io + .ask_and_hide_answer("Consumer Secret (hidden): ".to_string()) + .unwrap_or_default() + .trim() + .to_string(); + + if consumer_secret.is_empty() { + self.io.write_error( + PhpMixed::String( + "<warning>No consumer secret given, aborting.</warning>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "You can also add it manually later by using \"composer config --global --auth bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>\"".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + + self.io.set_authentication( + origin_url.to_string(), + consumer_key.clone(), + Some(consumer_secret.clone()), + ); + + if !self.request_access_token()? { + return Ok(false); + } + + let use_local = store_in_local_auth_config && self.config.get_local_auth_config_source().is_some(); + if use_local { + let mut auth_config_source = self.config.get_local_auth_config_source().unwrap(); + self.store_in_auth_config( + &mut *auth_config_source, + origin_url, + &consumer_key, + &consumer_secret, + )?; + } else { + let mut auth_config_source = self.config.get_auth_config_source(); + self.store_in_auth_config( + &mut *auth_config_source, + origin_url, + &consumer_key, + &consumer_secret, + )?; + } + + self.config + .get_auth_config_source() + .remove_config_setting(&format!("http-basic.{}", origin_url))?; + + self.io.write_error( + PhpMixed::String("<info>Consumer stored successfully.</info>".to_string()), + true, + IOInterface::NORMAL, + ); + + Ok(true) + } + + pub fn request_token( + &mut self, + origin_url: &str, + consumer_key: &str, + consumer_secret: &str, + ) -> anyhow::Result<String> { + if self.token.is_some() || self.get_token_from_config(origin_url) { + return Ok(self + .token + .as_ref() + .unwrap() + .get("access_token") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()) + .unwrap_or_default()); + } + + self.io.set_authentication( + origin_url.to_string(), + consumer_key.to_string(), + Some(consumer_secret.to_string()), + ); + if !self.request_access_token()? { + return Ok(String::new()); + } + + let use_local = self.config.get_local_auth_config_source().is_some(); + if use_local { + let mut auth_config_source = self.config.get_local_auth_config_source().unwrap(); + self.store_in_auth_config( + &mut *auth_config_source, + origin_url, + consumer_key, + consumer_secret, + )?; + } else { + let mut auth_config_source = self.config.get_auth_config_source(); + self.store_in_auth_config( + &mut *auth_config_source, + origin_url, + consumer_key, + consumer_secret, + )?; + } + + let access_token = self + .token + .as_ref() + .and_then(|t| t.get("access_token")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + + match access_token { + Some(t) => Ok(t), + None => Err(LogicException { + message: "Failed to initialize token above".to_string(), + code: 0, + } + .into()), + } + } + + fn store_in_auth_config( + &mut self, + auth_config_source: &mut dyn ConfigSourceInterface, + origin_url: &str, + consumer_key: &str, + consumer_secret: &str, + ) -> anyhow::Result<()> { + self.config + .get_config_source() + .remove_config_setting(&format!("bitbucket-oauth.{}", origin_url))?; + + let token = self.token.as_ref().ok_or_else(|| LogicException { + message: format!( + "Expected a token configured with expires_in present, got null", + ), + code: 0, + })?; + let expires_in = token + .get("expires_in") + .and_then(|v| v.as_int()) + .ok_or_else(|| { + let token_mixed = PhpMixed::Array( + token.iter().map(|(k, v)| (k.clone(), Box::new(v.clone()))).collect(), + ); + LogicException { + message: format!( + "Expected a token configured with expires_in present, got {}", + shirabe_php_shim::json_encode(&token_mixed).unwrap_or_default() + ), + code: 0, + } + })?; + + let t = self.time.unwrap_or_else(time); + let mut consumer: IndexMap<String, Box<PhpMixed>> = IndexMap::new(); + consumer.insert( + "consumer-key".to_string(), + Box::new(PhpMixed::String(consumer_key.to_string())), + ); + consumer.insert( + "consumer-secret".to_string(), + Box::new(PhpMixed::String(consumer_secret.to_string())), + ); + consumer.insert( + "access-token".to_string(), + Box::new( + token + .get("access_token") + .cloned() + .unwrap_or(PhpMixed::Null), + ), + ); + consumer.insert( + "access-token-expiration".to_string(), + Box::new(PhpMixed::Int(t + expires_in)), + ); + + self.config + .get_auth_config_source() + .add_config_setting( + &format!("bitbucket-oauth.{}", origin_url), + PhpMixed::Array(consumer), + )?; + + Ok(()) + } + + fn get_token_from_config(&mut self, origin_url: &str) -> bool { + let auth_config = self.config.get("bitbucket-oauth"); + + let auth_map = match auth_config.as_array() { + Some(m) => m.clone(), + None => return false, + }; + let origin_config = match auth_map.get(origin_url) { + Some(v) => match v.as_array() { + Some(m) => m.clone(), + None => return false, + }, + None => return false, + }; + + if !origin_config.contains_key("access-token") + || !origin_config.contains_key("access-token-expiration") + { + return false; + } + if let Some(expiration) = origin_config + .get("access-token-expiration") + .and_then(|v| v.as_int()) + { + if time() > expiration { + return false; + } + } else { + return false; + } + + let access_token = match origin_config.get("access-token").map(|v| *v.clone()) { + Some(t) => t, + None => return false, + }; + let mut token = IndexMap::new(); + token.insert("access_token".to_string(), access_token); + self.token = Some(token); + + true + } +} diff --git a/crates/shirabe/src/util/github.rs b/crates/shirabe/src/util/github.rs index 67de075..23d5b1f 100644 --- a/crates/shirabe/src/util/github.rs +++ b/crates/shirabe/src/util/github.rs @@ -1 +1,391 @@ //! ref: composer/src/Composer/Util/GitHub.php + +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{date, stripos, strtolower, PhpMixed}; + +use crate::config::Config; +use crate::downloader::transport_exception::TransportException; +use crate::factory::Factory; +use crate::io::io_interface::IOInterface; +use crate::util::http_downloader::HttpDownloader; +use crate::util::process_executor::ProcessExecutor; + +#[derive(Debug)] +pub struct GitHub { + io: Box<dyn IOInterface>, + config: Config, + process: ProcessExecutor, + http_downloader: HttpDownloader, +} + +impl GitHub { + pub const GITHUB_TOKEN_REGEX: &'static str = + r"{^([a-f0-9]{12,}|gh[a-z]_[a-zA-Z0-9_]+|github_pat_[a-zA-Z0-9_]+)$}"; + + pub fn new( + io: Box<dyn IOInterface>, + config: Config, + process: Option<ProcessExecutor>, + http_downloader: Option<HttpDownloader>, + ) -> anyhow::Result<Self> { + let process = process.unwrap_or_else(|| ProcessExecutor::new(&*io)); + let http_downloader = match http_downloader { + Some(h) => h, + None => Factory::create_http_downloader(&*io, &config)?, + }; + Ok(Self { + io, + config, + process, + http_downloader, + }) + } + + pub fn authorize_oauth(&mut self, origin_url: &str) -> bool { + let github_domains = self.config.get("github-domains"); + let domains = match github_domains.as_array() { + Some(arr) => arr.clone(), + None => return false, + }; + let origin_in_domains = domains + .values() + .any(|v| v.as_string() == Some(origin_url)); + if !origin_in_domains { + return false; + } + + let mut output = String::new(); + if self.process.execute( + &[ + "git".to_string(), + "config".to_string(), + "github.accesstoken".to_string(), + ], + &mut output, + None, + ) == 0 + { + self.io.set_authentication( + origin_url.to_string(), + output.trim().to_string(), + Some("x-oauth-basic".to_string()), + ); + return true; + } + + false + } + + pub fn authorize_oauth_interactively( + &mut self, + origin_url: &str, + message: Option<&str>, + ) -> anyhow::Result<bool> { + if let Some(msg) = message { + self.io.write_error( + PhpMixed::String(msg.to_string()), + true, + IOInterface::NORMAL, + ); + } + + let mut note = "Composer".to_string(); + let expose_hostname = self + .config + .get("github-expose-hostname") + .as_bool() + .unwrap_or(false); + if expose_hostname { + let mut output = String::new(); + if self + .process + .execute(&["hostname".to_string()], &mut output, None) + == 0 + { + note += &format!(" on {}", output.trim()); + } + } + note += &format!(" {}", date("Y-m-d Hi", None)); + + let local_auth_config = self.config.get_local_auth_config_source(); + + self.io.write_error( + PhpMixed::List(vec![ + Box::new(PhpMixed::String( + "You need to provide a GitHub access token.".to_string(), + )), + Box::new(PhpMixed::String(format!( + "Tokens will be stored in plain text in \"{}\" for future use by Composer.", + local_auth_config + .as_ref() + .map(|c| format!("{} OR ", c.get_name())) + .unwrap_or_default() + + &self.config.get_auth_config_source().get_name() + ))), + Box::new(PhpMixed::String( + "Due to the security risk of tokens being exfiltrated, use tokens with short expiration times and only the minimum permissions necessary.".to_string(), + )), + Box::new(PhpMixed::String(String::new())), + Box::new(PhpMixed::String( + "Carefully consider the following options in order:".to_string(), + )), + Box::new(PhpMixed::String(String::new())), + ]), + true, + IOInterface::NORMAL, + ); + + let encoded_note = shirabe_php_shim::rawurlencode(¬e).replace("%20", "+"); + self.io.write_error( + PhpMixed::List(vec![ + Box::new(PhpMixed::String( + "1. When you don't use 'vcs' type 'repositories' in composer.json and do not need to clone source or download dist files".to_string(), + )), + Box::new(PhpMixed::String( + "from private GitHub repositories over HTTPS, use a fine-grained token with read-only access to public information.".to_string(), + )), + Box::new(PhpMixed::String( + "Use the following URL to create such a token:".to_string(), + )), + Box::new(PhpMixed::String(format!( + "https://{}/settings/personal-access-tokens/new?name={}", + origin_url, encoded_note + ))), + Box::new(PhpMixed::String(String::new())), + ]), + true, + IOInterface::NORMAL, + ); + + self.io.write_error( + PhpMixed::List(vec![ + Box::new(PhpMixed::String( + "2. When all relevant _private_ GitHub repositories belong to a single user or organisation, use a fine-grained token with".to_string(), + )), + Box::new(PhpMixed::String( + "repository \"content\" read-only permissions. You can start with the following URL, but you may need to change the resource owner".to_string(), + )), + Box::new(PhpMixed::String( + "to the right user or organisation. Additionally, you can scope permissions down to apply only to selected repositories.".to_string(), + )), + Box::new(PhpMixed::String(format!( + "https://{}/settings/personal-access-tokens/new?contents=read&name={}", + origin_url, encoded_note + ))), + Box::new(PhpMixed::String(String::new())), + ]), + true, + IOInterface::NORMAL, + ); + + self.io.write_error( + PhpMixed::List(vec![ + Box::new(PhpMixed::String( + "3. A \"classic\" token grants broad permissions on your behalf to all repositories accessible by you.".to_string(), + )), + Box::new(PhpMixed::String( + "This may include write permissions, even though not needed by Composer. Use it only when you need to access".to_string(), + )), + Box::new(PhpMixed::String( + "private repositories across multiple organisations at the same time and using directory-specific authentication sources".to_string(), + )), + Box::new(PhpMixed::String( + "is not an option. You can generate a classic token here:".to_string(), + )), + Box::new(PhpMixed::String(format!( + "https://{}/settings/tokens/new?scopes=repo&description={}", + origin_url, encoded_note + ))), + Box::new(PhpMixed::String(String::new())), + ]), + true, + IOInterface::NORMAL, + ); + + self.io.write_error( + PhpMixed::String( + "For additional information, check https://getcomposer.org/doc/articles/authentication-for-private-packages.md#github-oauth".to_string(), + ), + true, + IOInterface::NORMAL, + ); + + let mut store_in_local_auth_config = false; + if local_auth_config.is_some() { + store_in_local_auth_config = self.io.ask_confirmation( + "A local auth config source was found, do you want to store the token there?" + .to_string(), + true, + ); + } + + let token = self + .io + .ask_and_hide_answer("Token (hidden): ".to_string()) + .unwrap_or_default() + .trim() + .to_string(); + + if token.is_empty() { + self.io.write_error( + PhpMixed::String("<warning>No token given, aborting.</warning>".to_string()), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "You can also add it manually later by using \"composer config --global --auth github-oauth.github.com <token>\"".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + + self.io.set_authentication( + origin_url.to_string(), + token.clone(), + Some("x-oauth-basic".to_string()), + ); + + let api_url = if origin_url == "github.com" { + "api.github.com/".to_string() + } else { + format!("{}/api/v3/", origin_url) + }; + + let mut http_options = indexmap::IndexMap::new(); + http_options.insert( + "retry-auth-failure".to_string(), + Box::new(PhpMixed::Bool(false)), + ); + let http_options = PhpMixed::Array(http_options); + + match self + .http_downloader + .get(&format!("https://{}", api_url), &http_options) + { + Ok(_) => {} + Err(te) => { + if te.code == 403 || te.code == 401 { + self.io.write_error( + PhpMixed::String("<error>Invalid token provided.</error>".to_string()), + true, + IOInterface::NORMAL, + ); + self.io.write_error( + PhpMixed::String( + "You can also add it manually later by using \"composer config --global --auth github-oauth.github.com <token>\"".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(false); + } + return Err(te.into()); + } + } + + let use_local = store_in_local_auth_config && self.config.get_local_auth_config_source().is_some(); + let auth_config_source_name; + if use_local { + let mut auth_config_source = self.config.get_local_auth_config_source().unwrap(); + self.config + .get_config_source() + .remove_config_setting(&format!("github-oauth.{}", origin_url))?; + auth_config_source.add_config_setting( + &format!("github-oauth.{}", origin_url), + PhpMixed::String(token), + )?; + } else { + let mut auth_config_source = self.config.get_auth_config_source(); + self.config + .get_config_source() + .remove_config_setting(&format!("github-oauth.{}", origin_url))?; + auth_config_source.add_config_setting( + &format!("github-oauth.{}", origin_url), + PhpMixed::String(token), + )?; + } + + self.io.write_error( + PhpMixed::String("<info>Token stored successfully.</info>".to_string()), + true, + IOInterface::NORMAL, + ); + + Ok(true) + } + + pub fn get_rate_limit(&self, headers: &[String]) -> indexmap::IndexMap<String, PhpMixed> { + let mut rate_limit = indexmap::IndexMap::new(); + rate_limit.insert("limit".to_string(), PhpMixed::String("?".to_string())); + rate_limit.insert("reset".to_string(), PhpMixed::String("?".to_string())); + + for header in headers { + let header = header.trim(); + if stripos(header, "x-ratelimit-").is_none() { + continue; + } + let parts: Vec<&str> = header.splitn(2, ':').collect(); + if parts.len() < 2 { + continue; + } + let (r#type, value) = (parts[0], parts[1]); + match strtolower(r#type).as_str() { + "x-ratelimit-limit" => { + let v: i64 = value.trim().parse().unwrap_or(0); + rate_limit.insert("limit".to_string(), PhpMixed::Int(v)); + } + "x-ratelimit-reset" => { + let ts: i64 = value.trim().parse().unwrap_or(0); + rate_limit.insert( + "reset".to_string(), + PhpMixed::String(date("Y-m-d H:i:s", Some(ts))), + ); + } + _ => {} + } + } + + rate_limit + } + + pub fn get_sso_url(&self, headers: &[String]) -> Option<String> { + for header in headers { + let header = header.trim(); + if stripos(header, "x-github-sso: required").is_none() { + continue; + } + if let Some(caps) = Preg::match_strict_groups(r"{\burl=(?P<url>[^\s;]+)}", header) { + return caps.get("url").cloned(); + } + } + + None + } + + pub fn is_rate_limited(&self, headers: &[String]) -> bool { + for header in headers { + if Preg::is_match(r"{^x-ratelimit-remaining: *0$}i", header.trim()) + .unwrap_or(false) + { + return true; + } + } + + false + } + + pub fn requires_sso(&self, headers: &[String]) -> bool { + for header in headers { + if Preg::is_match(r"{^x-github-sso: required}i", header.trim()) + .unwrap_or(false) + { + return true; + } + } + + false + } +} |
