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 | |
| 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')
| -rw-r--r-- | crates/shirabe-php-shim/src/lib.rs | 16 | ||||
| -rw-r--r-- | crates/shirabe/src/command/bump_command.rs | 369 | ||||
| -rw-r--r-- | crates/shirabe/src/package/version/version_selector.rs | 317 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/vcs/git_driver.rs | 435 | ||||
| -rw-r--r-- | crates/shirabe/src/util/bitbucket.rs | 526 | ||||
| -rw-r--r-- | crates/shirabe/src/util/github.rs | 390 |
6 files changed, 2053 insertions, 0 deletions
diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index b0f1c6c..62510f8 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -730,3 +730,19 @@ pub const GLOB_BRACE: i64 = 4096; pub fn glob_with_flags(pattern: &str, flags: i64) -> Vec<String> { todo!() } + +pub fn time() -> i64 { + todo!() +} + +pub fn date(format: &str, timestamp: Option<i64>) -> String { + todo!() +} + +pub fn trigger_error(message: &str, error_level: i64) { + todo!() +} + +pub fn sys_get_temp_dir() -> String { + todo!() +} diff --git a/crates/shirabe/src/command/bump_command.rs b/crates/shirabe/src/command/bump_command.rs index 7199d9d..2787fcc 100644 --- a/crates/shirabe/src/command/bump_command.rs +++ b/crates/shirabe/src/command/bump_command.rs @@ -1 +1,370 @@ //! ref: composer/src/Composer/Command/BumpCommand.php + +use anyhow::Result; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_external_packages::symfony::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::console::output::output_interface::OutputInterface; +use shirabe_php_shim::{file_get_contents, file_put_contents, is_writable, strtolower, PhpMixed}; + +use crate::command::base_command::BaseCommand; +use crate::command::completion_trait::CompletionTrait; +use crate::console::input::input_argument::InputArgument; +use crate::console::input::input_option::InputOption; +use crate::factory::Factory; +use crate::io::io_interface::IOInterface; +use crate::json::json_file::JsonFile; +use crate::json::json_manipulator::JsonManipulator; +use crate::package::alias_package::AliasPackage; +use crate::package::base_package::BasePackage; +use crate::package::version::version_bumper::VersionBumper; +use crate::repository::platform_repository::PlatformRepository; +use crate::util::filesystem::Filesystem; +use crate::util::silencer::Silencer; + +#[derive(Debug)] +pub struct BumpCommand { + inner: BaseCommand, +} + +impl CompletionTrait for BumpCommand {} + +impl BumpCommand { + const ERROR_GENERIC: i64 = 1; + const ERROR_LOCK_OUTDATED: i64 = 2; + + pub fn configure(&mut self) { + let suggest_root_requirement = self.suggest_root_requirement(); + self.inner + .set_name("bump") + .set_description("Increases the lower limit of your composer.json requirements to the currently installed versions") + .set_definition(vec![ + InputArgument::new( + "packages", + Some(InputArgument::IS_ARRAY | InputArgument::OPTIONAL), + "Optional package name(s) to restrict which packages are bumped.", + None, + suggest_root_requirement, + ), + InputOption::new("dev-only", Some(PhpMixed::String("D".to_string())), Some(InputOption::VALUE_NONE), "Only bump requirements in \"require-dev\".", None, vec![]), + InputOption::new("no-dev-only", Some(PhpMixed::String("R".to_string())), Some(InputOption::VALUE_NONE), "Only bump requirements in \"require\".", None, vec![]), + InputOption::new("dry-run", None, Some(InputOption::VALUE_NONE), "Outputs the packages to bump, but will not execute anything.", None, vec![]), + ]) + .set_help( + "The <info>bump</info> command increases the lower limit of your composer.json requirements\n\ + to the currently installed versions. This helps to ensure your dependencies do not\n\ + accidentally get downgraded due to some other conflict, and can slightly improve\n\ + dependency resolution performance as it limits the amount of package versions\n\ + Composer has to look at.\n\n\ + Running this blindly on libraries is **NOT** recommended as it will narrow down\n\ + your allowed dependencies, which may cause dependency hell for your users.\n\ + Running it with <info>--dev-only</info> on libraries may be fine however as dev requirements\n\ + are local to the library and do not affect consumers of the package.\n" + ); + } + + pub fn execute( + &self, + input: &dyn InputInterface, + _output: &dyn OutputInterface, + ) -> Result<i64> { + let packages_filter: Vec<String> = input + .get_argument("packages") + .as_list() + .map(|l| { + l.iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + self.do_bump( + self.inner.get_io(), + input.get_option("dev-only").as_bool().unwrap_or(false), + input.get_option("no-dev-only").as_bool().unwrap_or(false), + input.get_option("dry-run").as_bool().unwrap_or(false), + packages_filter, + "--dev-only".to_string(), + ) + } + + pub fn do_bump( + &self, + io: &dyn IOInterface, + dev_only: bool, + no_dev_only: bool, + dry_run: bool, + packages_filter: Vec<String>, + dev_only_flag_hint: String, + ) -> Result<i64> { + let composer_json_path = Factory::get_composer_file(); + + if !Filesystem::is_readable(&composer_json_path) { + io.write_error( + PhpMixed::String(format!("<error>{} is not readable.</error>", composer_json_path)), + true, + IOInterface::NORMAL, + ); + return Ok(Self::ERROR_GENERIC); + } + + let composer_json = JsonFile::new(composer_json_path.clone()); + let contents = match file_get_contents(&composer_json.get_path()) { + Some(c) => c, + None => { + io.write_error( + PhpMixed::String(format!( + "<error>{} is not readable.</error>", + composer_json_path + )), + true, + IOInterface::NORMAL, + ); + return Ok(Self::ERROR_GENERIC); + } + }; + + if !is_writable(&composer_json_path) + && Silencer::call(|| { + file_put_contents(&composer_json_path, contents.as_bytes()) + .map(|_| ()) + .ok_or_else(|| anyhow::anyhow!("file_put_contents failed")) + }) + .is_err() + { + io.write_error( + PhpMixed::String(format!( + "<error>{} is not writable.</error>", + composer_json_path + )), + true, + IOInterface::NORMAL, + ); + return Ok(Self::ERROR_GENERIC); + } + + let composer = self.inner.require_composer()?; + let has_lock_file_disabled = !composer.get_config().has("lock") + || composer.get_config().get("lock").as_bool().unwrap_or(true); + let repo = if !has_lock_file_disabled { + composer.get_locker().get_locked_repository(true)? + } else if composer.get_locker().is_locked() { + if !composer.get_locker().is_fresh() { + io.write_error( + PhpMixed::String( + "<error>The lock file is not up to date with the latest changes in composer.json. Run the appropriate `update` to fix that before you use the `bump` command.</error>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + return Ok(Self::ERROR_LOCK_OUTDATED); + } + composer.get_locker().get_locked_repository(true)? + } else { + composer.get_repository_manager().get_local_repository() + }; + + if composer.get_package().get_type() != "project" && !dev_only { + io.write_error( + PhpMixed::String( + "<warning>Warning: Bumping dependency constraints is not recommended for libraries as it will narrow down your dependencies and may cause problems for your users.</warning>".to_string(), + ), + true, + IOInterface::NORMAL, + ); + + let contents_data = composer_json.read()?; + if !contents_data.contains_key("type") { + io.write_error( + PhpMixed::String( + "If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".".to_string(), + ), + true, + IOInterface::NORMAL, + ); + io.write_error( + PhpMixed::String(format!( + "<warning>Alternatively you can use {} to only bump dependencies within \"require-dev\".</warning>", + dev_only_flag_hint + )), + true, + IOInterface::NORMAL, + ); + } + } + + let bumper = VersionBumper; + let mut tasks = indexmap::IndexMap::new(); + if !dev_only { + tasks.insert("require", composer.get_package().get_requires()); + } + if !no_dev_only { + tasks.insert("require-dev", composer.get_package().get_dev_requires()); + } + + let packages_filter = if !packages_filter.is_empty() { + let packages_filter: Vec<String> = packages_filter + .iter() + .map(|constraint| { + Preg::replace(r"{[:= ].+}", "", constraint.clone()) + .unwrap_or_else(|_| constraint.clone()) + }) + .collect(); + let mut unique_lower: Vec<String> = packages_filter + .iter() + .map(|s| strtolower(s)) + .collect::<std::collections::HashSet<_>>() + .into_iter() + .collect(); + let pattern = + BasePackage::package_names_to_regexp(&unique_lower); + for (key, reqs) in tasks.iter_mut() { + reqs.retain(|pkg_name, _| { + Preg::is_match(&pattern, pkg_name).unwrap_or(false) + }); + } + packages_filter + } else { + packages_filter + }; + + let mut updates: indexmap::IndexMap<&str, indexmap::IndexMap<String, String>> = + indexmap::IndexMap::new(); + for (key, reqs) in &tasks { + for (pkg_name, link) in reqs { + if PlatformRepository::is_platform_package(pkg_name) { + continue; + } + let current_constraint = link.get_pretty_constraint(); + + let package_opt = repo.find_package(pkg_name, "*"); + let package = match package_opt { + None => continue, + Some(p) => p, + }; + let mut package = package; + while let Some(alias) = package.as_any().downcast_ref::<AliasPackage>() { + package = alias.get_alias_of(); + } + + let bumped = bumper + .bump_requirement(link.get_constraint().as_ref(), package.as_ref())?; + + if bumped == current_constraint { + continue; + } + + updates + .entry(key) + .or_default() + .insert(pkg_name.clone(), bumped); + } + } + + if !dry_run && !self.update_file_cleanly(&composer_json, &updates)? { + let mut composer_definition = composer_json.read()?; + for (key, packages) in &updates { + for (package, version) in packages { + composer_definition + .entry(key.to_string()) + .or_insert_with(indexmap::IndexMap::new) + .insert(package.clone(), version.clone()); + } + } + composer_json.write(composer_definition)?; + } + + let change_count: usize = updates.values().map(|m| m.len()).sum(); + if change_count > 0 { + if dry_run { + io.write( + PhpMixed::String(format!( + "<info>{} would be updated with:</info>", + composer_json_path + )), + true, + IOInterface::NORMAL, + ); + for (require_type, packages) in &updates { + for (package, version) in packages { + io.write( + PhpMixed::String(format!( + "<info> - {}.{}: {}</info>", + require_type, package, version + )), + true, + IOInterface::NORMAL, + ); + } + } + } else { + io.write( + PhpMixed::String(format!( + "<info>{} has been updated ({} changes).</info>", + composer_json_path, change_count + )), + true, + IOInterface::NORMAL, + ); + } + } else { + io.write( + PhpMixed::String(format!( + "<info>No requirements to update in {}.</info>", + composer_json_path + )), + true, + IOInterface::NORMAL, + ); + } + + if !dry_run + && composer.get_locker().is_locked() + && composer.get_config().get("lock").as_bool().unwrap_or(true) + && change_count > 0 + { + composer.get_locker().update_hash(&composer_json)?; + } + + if dry_run && change_count > 0 { + return Ok(Self::ERROR_GENERIC); + } + + Ok(0) + } + + fn update_file_cleanly( + &self, + json: &JsonFile, + updates: &indexmap::IndexMap<&str, indexmap::IndexMap<String, String>>, + ) -> Result<bool> { + let contents = match file_get_contents(&json.get_path()) { + Some(c) => c, + None => { + return Err(shirabe_php_shim::RuntimeException { + message: format!("Unable to read {} contents.", json.get_path()), + code: 0, + } + .into()); + } + }; + + let mut manipulator = JsonManipulator::new(contents)?; + + for (key, packages) in updates { + for (package, version) in packages { + if !manipulator.add_link(key, package, version)? { + return Ok(false); + } + } + } + + match file_put_contents(&json.get_path(), manipulator.get_contents().as_bytes()) { + Some(_) => Ok(true), + None => Err(shirabe_php_shim::RuntimeException { + message: format!("Unable to write new {} contents.", json.get_path()), + code: 0, + } + .into()), + } + } +} diff --git a/crates/shirabe/src/package/version/version_selector.rs b/crates/shirabe/src/package/version/version_selector.rs index 39f9227..85ce71d 100644 --- a/crates/shirabe/src/package/version/version_selector.rs +++ b/crates/shirabe/src/package/version/version_selector.rs @@ -1 +1,318 @@ //! ref: composer/src/Composer/Package/Version/VersionSelector.php + +use std::any::Any; + +use indexmap::IndexMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{ + strtolower, version_compare, PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION, +}; +use shirabe_semver::constraint::constraint::Constraint; +use shirabe_semver::constraint::constraint_interface::ConstraintInterface; + +use crate::filter::platform_requirement_filter::ignore_all_platform_requirement_filter::IgnoreAllPlatformRequirementFilter; +use crate::filter::platform_requirement_filter::ignore_list_platform_requirement_filter::IgnoreListPlatformRequirementFilter; +use crate::filter::platform_requirement_filter::platform_requirement_filter_factory::PlatformRequirementFilterFactory; +use crate::filter::platform_requirement_filter::platform_requirement_filter_interface::PlatformRequirementFilterInterface; +use crate::io::io_interface::IOInterface; +use crate::package::alias_package::AliasPackage; +use crate::package::base_package::BasePackage; +use crate::package::dumper::array_dumper::ArrayDumper; +use crate::package::loader::array_loader::ArrayLoader; +use crate::package::package_interface::PackageInterface; +use crate::repository::platform_repository::PlatformRepository; +use crate::repository::repository_set::RepositorySet; +use crate::package::version::version_parser::VersionParser; + +#[derive(Debug)] +pub struct VersionSelector { + repository_set: RepositorySet, + platform_constraints: IndexMap<String, Vec<Box<dyn ConstraintInterface>>>, + parser: Option<VersionParser>, +} + +impl VersionSelector { + pub fn new( + repository_set: RepositorySet, + platform_repo: Option<&crate::repository::platform_repository::PlatformRepository>, + ) -> anyhow::Result<Self> { + let mut platform_constraints: IndexMap<String, Vec<Box<dyn ConstraintInterface>>> = + IndexMap::new(); + if let Some(platform_repo) = platform_repo { + for package in platform_repo.get_packages() { + let constraint = Constraint::new("==", package.get_version())?; + platform_constraints + .entry(package.get_name().to_string()) + .or_default() + .push(Box::new(constraint)); + } + } + Ok(Self { + repository_set, + platform_constraints, + parser: None, + }) + } + + pub fn find_best_candidate( + &mut self, + package_name: &str, + target_package_version: Option<&str>, + preferred_stability: &str, + platform_requirement_filter: Option<Box<dyn PlatformRequirementFilterInterface>>, + repo_set_flags: i64, + io: Option<&dyn IOInterface>, + show_warnings: shirabe_php_shim::PhpMixed, + ) -> anyhow::Result<Option<Box<dyn PackageInterface>>> { + if !BasePackage::STABILITIES.contains_key(preferred_stability) { + return Err(shirabe_php_shim::UnexpectedValueException { + message: format!( + "Expected a valid stability name as 3rd argument, got {}", + preferred_stability + ), + code: 0, + } + .into()); + } + + let platform_requirement_filter: Box<dyn PlatformRequirementFilterInterface> = + match platform_requirement_filter { + Some(f) => f, + None => PlatformRequirementFilterFactory::ignore_nothing(), + }; + + let constraint = match target_package_version { + Some(v) => Some(self.get_parser().parse_constraints(v)?), + None => None, + }; + let mut candidates = self.repository_set.find_packages( + &strtolower(package_name), + constraint.as_deref(), + repo_set_flags, + )?; + + let min_priority = *BasePackage::STABILITIES.get(preferred_stability).unwrap(); + candidates.sort_by(|a, b| { + let a_priority = a.get_stability_priority(); + let b_priority = b.get_stability_priority(); + + if min_priority < a_priority && b_priority < a_priority { + return std::cmp::Ordering::Greater; + } + if min_priority < a_priority && a_priority < b_priority { + return std::cmp::Ordering::Less; + } + if min_priority >= a_priority && min_priority < b_priority { + return std::cmp::Ordering::Less; + } + + if version_compare(b.get_version(), a.get_version(), ">") { + std::cmp::Ordering::Greater + } else if version_compare(b.get_version(), a.get_version(), "<") { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }); + + let is_ignore_all = (platform_requirement_filter.as_ref() as &dyn Any) + .downcast_ref::<IgnoreAllPlatformRequirementFilter>() + .is_some(); + + let package: Option<Box<dyn PackageInterface>>; + if !self.platform_constraints.is_empty() && !is_ignore_all { + let mut already_warned_names: IndexMap<String, bool> = IndexMap::new(); + let mut already_seen_names: IndexMap<String, bool> = IndexMap::new(); + let mut found_package: Option<Box<dyn PackageInterface>> = None; + + 'pkgs: for pkg in candidates.iter() { + let reqs = pkg.get_requires(); + let mut skip = false; + 'reqs: for (name, link) in &reqs { + if !PlatformRepository::is_platform_package(name) + || platform_requirement_filter.is_ignored(name) + { + continue; + } + let reason; + if let Some(provided_constraints) = self.platform_constraints.get(name) { + for provided_constraint in provided_constraints { + if link.get_constraint().matches(provided_constraint.as_ref()) { + continue 'reqs; + } + let list_filter_opt = (platform_requirement_filter.as_ref() as &dyn Any) + .downcast_ref::<IgnoreListPlatformRequirementFilter>(); + if let Some(list_filter) = list_filter_opt { + if list_filter.is_upper_bound_ignored(name) { + let filtered_constraint = list_filter.filter_constraint( + name, + link.get_constraint().clone_box(), + false, + )?; + if filtered_constraint.matches(provided_constraint.as_ref()) { + continue 'reqs; + } + } + } + } + reason = "is not satisfied by your platform"; + } else { + reason = "is missing from your platform"; + } + + let is_latest_version = !already_seen_names.contains_key(pkg.get_name()); + already_seen_names.insert(pkg.get_name().to_string(), true); + if let Some(io) = io { + let should_warn = match &show_warnings { + shirabe_php_shim::PhpMixed::Bool(b) => *b, + _ => true, + }; + if should_warn { + let warn_key = + format!("{}/{}", pkg.get_name(), link.get_target()); + let is_first_warning = !already_warned_names.contains_key(&warn_key); + already_warned_names.insert(warn_key, true); + let latest = if is_latest_version { + "'s latest version" + } else { + "" + }; + io.write_error( + shirabe_php_shim::PhpMixed::String(format!( + "<warning>Cannot use {}{} {} as it {} {} {} which {}.</>", + pkg.get_pretty_name(), + latest, + pkg.get_pretty_version(), + link.get_description(), + link.get_target(), + link.get_pretty_constraint(), + reason + )), + true, + if is_first_warning { + IOInterface::NORMAL + } else { + IOInterface::VERBOSE + }, + ); + } + } + + skip = true; + } + + if skip { + continue; + } + + found_package = Some(pkg.clone_box()); + break; + } + package = found_package; + } else { + package = if !candidates.is_empty() { + Some(candidates.remove(0)) + } else { + None + }; + } + + let package = match package { + None => return Ok(None), + Some(p) => p, + }; + + let package = if let Some(alias) = (package.as_ref() as &dyn Any).downcast_ref::<AliasPackage>() { + if alias.get_version() == VersionParser::DEFAULT_BRANCH_ALIAS { + alias.get_alias_of() + } else { + package + } + } else { + package + }; + + Ok(Some(package)) + } + + pub fn find_recommended_require_version( + &mut self, + package: &dyn PackageInterface, + ) -> anyhow::Result<String> { + if package.get_name().starts_with("ext-") { + let php_version = format!( + "{}.{}.{}", + PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION + ); + let ext_parts: Vec<&str> = package.get_version().splitn(4, '.').collect(); + let ext_version = ext_parts[..3.min(ext_parts.len())].join("."); + if php_version == ext_version { + return Ok("*".to_string()); + } + } + + let version = package.get_version().to_string(); + if !package.is_dev() { + return self.transform_version( + &version, + package.get_pretty_version(), + package.get_stability(), + ); + } + + let loader = ArrayLoader::new(self.get_parser()); + let dumper = ArrayDumper::new(); + let extra = loader.get_branch_alias(&dumper.dump(package)?)?; + if let Some(extra) = extra { + if extra != VersionParser::DEFAULT_BRANCH_ALIAS { + let new_extra = + Preg::replace(r"{^(\d+\.\d+\.\d+)(\.9999999)-dev$}", "$1.0", extra.clone())?; + if new_extra != extra { + let new_extra = new_extra.replace(".9999999", ".0"); + return self.transform_version(&new_extra, &new_extra, "dev"); + } + } + } + + Ok(package.get_pretty_version().to_string()) + } + + fn transform_version( + &self, + version: &str, + pretty_version: &str, + stability: &str, + ) -> anyhow::Result<String> { + let semantic_version_parts: Vec<&str> = version.split('.').collect(); + + if semantic_version_parts.len() == 4 + && Preg::is_match(r"{^\d+\D?}", semantic_version_parts[3]).unwrap_or(false) + { + let mut parts: Vec<String> = semantic_version_parts.iter().map(|s| s.to_string()).collect(); + let version = if parts[0] == "0" { + parts.truncate(3); + parts.join(".") + } else { + parts.truncate(2); + parts.join(".") + }; + + let version = if stability != "stable" { + format!("{}@{}", version, stability) + } else { + version + }; + + Ok(format!("^{}", version)) + } else { + Ok(pretty_version.to_string()) + } + } + + fn get_parser(&mut self) -> &VersionParser { + if self.parser.is_none() { + self.parser = Some(VersionParser::new()); + } + self.parser.as_ref().unwrap() + } +} diff --git a/crates/shirabe/src/repository/vcs/git_driver.rs b/crates/shirabe/src/repository/vcs/git_driver.rs index 33ee5f4..5e2e547 100644 --- a/crates/shirabe/src/repository/vcs/git_driver.rs +++ b/crates/shirabe/src/repository/vcs/git_driver.rs @@ -1 +1,436 @@ //! ref: composer/src/Composer/Repository/Vcs/GitDriver.php + +use chrono::TimeZone; +use chrono::{DateTime, Utc}; +use indexmap::IndexMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{ + dirname, is_dir, is_writable, realpath, sys_get_temp_dir, InvalidArgumentException, + RuntimeException, +}; + +use crate::cache::Cache; +use crate::config::Config; +use crate::io::io_interface::IOInterface; +use crate::repository::vcs::vcs_driver::VcsDriver; +use crate::util::filesystem::Filesystem; +use crate::util::git::Git as GitUtil; +use crate::util::process_executor::ProcessExecutor; +use crate::util::url::Url; + +#[derive(Debug)] +pub struct GitDriver { + pub(crate) inner: VcsDriver, + pub(crate) tags: Option<IndexMap<String, String>>, + pub(crate) branches: Option<IndexMap<String, String>>, + pub(crate) root_identifier: Option<String>, + pub(crate) repo_dir: String, +} + +impl GitDriver { + pub fn initialize(&mut self) -> anyhow::Result<()> { + let cache_url; + if Filesystem::is_local_path(&self.inner.url) { + self.inner.url = + Preg::replace(r"{[\\/]\.git/?$}", "", self.inner.url.clone())?; + if !is_dir(&self.inner.url) { + return Err(RuntimeException { + message: format!( + "Failed to read package information from {} as the path does not exist", + self.inner.url + ), + code: 0, + } + .into()); + } + self.repo_dir = self.inner.url.clone(); + cache_url = realpath(&self.inner.url).unwrap_or_else(|| self.inner.url.clone()); + } else { + let cache_vcs_dir = self + .inner + .config + .get("cache-vcs-dir") + .as_string() + .unwrap_or("") + .to_string(); + if !Cache::is_usable(&cache_vcs_dir) { + return Err(RuntimeException { + message: "GitDriver requires a usable cache directory, and it looks like you set it to be disabled".to_string(), + code: 0, + } + .into()); + } + + self.repo_dir = format!( + "{}/{}/", + cache_vcs_dir, + Preg::replace( + r"{[^a-z0-9.]}i", + "-", + Url::sanitize(self.inner.url.clone()) + )? + ); + + GitUtil::clean_env(&self.inner.process); + + let fs = Filesystem::new(); + fs.ensure_directory_exists(&dirname(&self.repo_dir))?; + + if !is_writable(&dirname(&self.repo_dir)) { + return Err(RuntimeException { + message: format!( + "Can not clone {} to access package information. The \"{}\" directory is not writable by the current user.", + self.inner.url, + dirname(&self.repo_dir) + ), + code: 0, + } + .into()); + } + + if Preg::is_match(r"{^ssh://[^@]+@[^:]+:[^0-9]+}", &self.inner.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.", + self.inner.url + ), + code: 0, + } + .into()); + } + + let git_util = GitUtil::new( + &*self.inner.io, + &self.inner.config, + &self.inner.process, + &Filesystem::new(), + ); + if !git_util.sync_mirror(&self.inner.url, &self.repo_dir)? { + if !is_dir(&self.repo_dir) { + return Err(RuntimeException { + message: format!( + "Failed to clone {} to read package information from it", + self.inner.url + ), + code: 0, + } + .into()); + } + self.inner.io.write_error( + shirabe_php_shim::PhpMixed::String(format!( + "<error>Failed to update {}, package information from this repository may be outdated</error>", + self.inner.url + )), + true, + IOInterface::NORMAL, + ); + } + + cache_url = self.inner.url.clone(); + } + + self.get_tags()?; + self.get_branches()?; + + let cache_repo_dir = self + .inner + .config + .get("cache-repo-dir") + .as_string() + .unwrap_or("") + .to_string(); + self.inner.cache = Some(Cache::new( + &*self.inner.io, + format!( + "{}/{}", + cache_repo_dir, + Preg::replace(r"{[^a-z0-9.]}i", "-", Url::sanitize(cache_url))? + ), + )); + self.inner.cache.as_mut().map(|c| { + c.set_read_only( + self.inner + .config + .get("cache-read-only") + .as_bool() + .unwrap_or(false), + ) + }); + + Ok(()) + } + + pub fn get_root_identifier(&mut self) -> anyhow::Result<String> { + if self.root_identifier.is_none() { + self.root_identifier = Some("master".to_string()); + + let git_util = GitUtil::new( + &*self.inner.io, + &self.inner.config, + &self.inner.process, + &Filesystem::new(), + ); + if !Filesystem::is_local_path(&self.inner.url) { + let default_branch = git_util.get_mirror_default_branch( + &self.inner.url, + &self.repo_dir, + false, + )?; + if let Some(branch) = default_branch { + self.root_identifier = Some(branch.clone()); + return Ok(branch); + } + } + + let mut output = String::new(); + self.inner.process.execute( + &[ + "git".to_string(), + "branch".to_string(), + "--no-color".to_string(), + ], + &mut output, + Some(self.repo_dir.clone()), + ); + let branches = self.inner.process.split_lines(&output); + if !branches.contains(&"* master".to_string()) { + for branch in &branches { + if !branch.is_empty() { + if let Some(caps) = + Preg::match_strict_groups(r"{^\* +(\S+)}", branch) + { + if let Some(name) = caps.get("1") { + self.root_identifier = Some(name.clone()); + break; + } + } + } + } + } + } + + Ok(self.root_identifier.clone().unwrap_or_default()) + } + + pub fn get_url(&self) -> String { + self.inner.url.clone() + } + + pub fn get_source(&self, identifier: &str) -> IndexMap<String, String> { + let mut map = IndexMap::new(); + map.insert("type".to_string(), "git".to_string()); + map.insert("url".to_string(), self.get_url()); + map.insert("reference".to_string(), identifier.to_string()); + map + } + + pub fn get_dist(&self, _identifier: &str) -> Option<IndexMap<String, String>> { + None + } + + pub fn get_file_content( + &mut self, + file: &str, + identifier: &str, + ) -> anyhow::Result<Option<String>> { + if identifier.starts_with('-') { + return Err(RuntimeException { + message: format!( + "Invalid git identifier detected. Identifier must not start with a -, given: {}", + identifier + ), + code: 0, + } + .into()); + } + + let mut content = String::new(); + self.inner.process.execute( + &[ + "git".to_string(), + "show".to_string(), + format!("{}:{}", identifier, file), + ], + &mut content, + Some(self.repo_dir.clone()), + ); + + if content.trim().is_empty() { + return Ok(None); + } + + Ok(Some(content)) + } + + pub fn get_change_date( + &mut self, + identifier: &str, + ) -> anyhow::Result<Option<DateTime<Utc>>> { + if identifier.starts_with('-') { + return Err(RuntimeException { + message: format!( + "Invalid git identifier detected. Identifier must not start with a -, given: {}", + identifier + ), + code: 0, + } + .into()); + } + + let command = GitUtil::build_rev_list_command( + &self.inner.process, + &[ + "-n1".to_string(), + "--format=%at".to_string(), + identifier.to_string(), + ], + ); + let mut output = String::new(); + self.inner + .process + .execute(&command, &mut output, Some(self.repo_dir.clone())); + + let timestamp_str = + GitUtil::parse_rev_list_output(&output, &self.inner.process); + let timestamp: i64 = timestamp_str.trim().parse().unwrap_or(0); + Ok(Some(Utc.timestamp_opt(timestamp, 0).unwrap())) + } + + pub fn get_tags(&mut self) -> anyhow::Result<IndexMap<String, String>> { + if self.tags.is_none() { + self.tags = Some(IndexMap::new()); + + let mut output = String::new(); + self.inner.process.execute( + &[ + "git".to_string(), + "show-ref".to_string(), + "--tags".to_string(), + "--dereference".to_string(), + ], + &mut output, + Some(self.repo_dir.clone()), + ); + for tag in self.inner.process.split_lines(&output) { + if !tag.is_empty() { + if let Some(caps) = Preg::match_strict_groups( + r"{^([a-f0-9]{40}) refs/tags/(\S+?)(\^\{\})?$}", + &tag, + ) { + if let (Some(hash), Some(name)) = + (caps.get("1"), caps.get("2")) + { + self.tags + .as_mut() + .unwrap() + .insert(name.clone(), hash.clone()); + } + } + } + } + } + + Ok(self.tags.clone().unwrap_or_default()) + } + + pub fn get_branches(&mut self) -> anyhow::Result<IndexMap<String, String>> { + if self.branches.is_none() { + let mut branches = IndexMap::new(); + + let mut output = String::new(); + self.inner.process.execute( + &[ + "git".to_string(), + "branch".to_string(), + "--no-color".to_string(), + "--no-abbrev".to_string(), + "-v".to_string(), + ], + &mut output, + Some(self.repo_dir.clone()), + ); + for branch in self.inner.process.split_lines(&output) { + if !branch.is_empty() + && !Preg::is_match(r"{^ *[^/]+/HEAD }", &branch).unwrap_or(false) + { + if let Some(caps) = Preg::match_strict_groups( + r"{^(?:\* )? *(\S+) *([a-f0-9]+)(?: .*)?$}", + &branch, + ) { + if let (Some(name), Some(hash)) = (caps.get("1"), caps.get("2")) + { + if !name.starts_with('-') { + branches.insert(name.clone(), hash.clone()); + } + } + } + } + } + + self.branches = Some(branches); + } + + Ok(self.branches.clone().unwrap_or_default()) + } + + pub fn supports( + io: &dyn IOInterface, + _config: &Config, + url: &str, + deep: bool, + ) -> anyhow::Result<bool> { + if Preg::is_match( + r"#(^git://|\.git/?$|git(?:olite)?@|//git\.|//github.com/)#i", + url, + ) + .unwrap_or(false) + { + return Ok(true); + } + + if Filesystem::is_local_path(url) { + let url = Filesystem::get_platform_path(url); + if !is_dir(&url) { + return Ok(false); + } + + let process = ProcessExecutor::new(io); + let mut output = String::new(); + if process.execute( + &["git".to_string(), "tag".to_string()], + &mut output, + Some(url.clone()), + ) == 0 + { + return Ok(true); + } + GitUtil::check_for_repo_ownership_error(&process.get_error_output(), &url); + } + + if !deep { + return Ok(false); + } + + let process = ProcessExecutor::new(io); + let git_util = GitUtil::new(io, _config, &process, &Filesystem::new()); + GitUtil::clean_env(&process); + + let result = git_util.run_commands( + &[vec![ + "git".to_string(), + "ls-remote".to_string(), + "--heads".to_string(), + "--".to_string(), + "%url%".to_string(), + ]], + url, + &sys_get_temp_dir(), + ); + match result { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } +} 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 + } +} |
