aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-16 11:06:13 +0900
committernsfisis <nsfisis@gmail.com>2026-05-16 11:06:13 +0900
commit3c5ad3a5e3984a54e58714c81465288c43c4cc69 (patch)
tree02741a5cc33eab7c0f5a64842beaa75d4858b6fc /crates
parentf17eb98f1b73602fa87399cc04f0fbe2afd1f3f2 (diff)
downloadphp-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.rs16
-rw-r--r--crates/shirabe/src/command/bump_command.rs369
-rw-r--r--crates/shirabe/src/package/version/version_selector.rs317
-rw-r--r--crates/shirabe/src/repository/vcs/git_driver.rs435
-rw-r--r--crates/shirabe/src/util/bitbucket.rs526
-rw-r--r--crates/shirabe/src/util/github.rs390
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(&note).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
+ }
+}