diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 10:22:49 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 10:22:49 +0900 |
| commit | f17eb98f1b73602fa87399cc04f0fbe2afd1f3f2 (patch) | |
| tree | 76ccdbb5ec356abe00309e1ec44ec29e3ca45e3e | |
| parent | 7c58ca16cb5bc4e14ff5c8c192c67e8a47afeaa1 (diff) | |
| download | php-shirabe-f17eb98f1b73602fa87399cc04f0fbe2afd1f3f2.tar.gz php-shirabe-f17eb98f1b73602fa87399cc04f0fbe2afd1f3f2.tar.zst php-shirabe-f17eb98f1b73602fa87399cc04f0fbe2afd1f3f2.zip | |
feat(port): port SvnDownloader.php, FossilDriver.php, Request.php, PathRepository.php, StreamContextFactory.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | crates/shirabe-php-shim/src/lib.rs | 47 | ||||
| -rw-r--r-- | crates/shirabe/src/dependency_resolver/request.rs | 224 | ||||
| -rw-r--r-- | crates/shirabe/src/downloader/svn_downloader.rs | 400 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/path_repository.rs | 329 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/vcs/fossil_driver.rs | 311 | ||||
| -rw-r--r-- | crates/shirabe/src/util/stream_context_factory.rs | 381 |
6 files changed, 1692 insertions, 0 deletions
diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index e2d745f..b0f1c6c 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -683,3 +683,50 @@ pub fn strtolower(s: &str) -> String { pub fn array_intersect_key(array1: &IndexMap<String, Box<PhpMixed>>, array2: &IndexMap<String, Box<PhpMixed>>) -> IndexMap<String, Box<PhpMixed>> { todo!() } + +pub fn is_file(path: &str) -> bool { + todo!() +} + +pub fn spl_object_hash<T: ?Sized>(object: &T) -> String { + todo!() +} + +pub fn serialize(value: &PhpMixed) -> String { + todo!() +} + +pub fn stream_context_create(options: &IndexMap<String, PhpMixed>, params: Option<&IndexMap<String, PhpMixed>>) -> PhpMixed { + todo!() +} + +pub fn stripos(haystack: &str, needle: &str) -> Option<usize> { + todo!() +} + +pub fn php_uname(mode: &str) -> String { + todo!() +} + +pub fn uasort<F>(array: &mut Vec<String>, compare: F) +where + F: Fn(&str, &str) -> i64, +{ + todo!() +} + +pub fn array_replace_recursive(base: IndexMap<String, PhpMixed>, replacement: IndexMap<String, PhpMixed>) -> IndexMap<String, PhpMixed> { + todo!() +} + +pub const PHP_MAJOR_VERSION: i64 = 8; +pub const PHP_MINOR_VERSION: i64 = 1; +pub const PHP_RELEASE_VERSION: i64 = 0; + +pub const GLOB_MARK: i64 = 8; +pub const GLOB_ONLYDIR: i64 = 1024; +pub const GLOB_BRACE: i64 = 4096; + +pub fn glob_with_flags(pattern: &str, flags: i64) -> Vec<String> { + todo!() +} diff --git a/crates/shirabe/src/dependency_resolver/request.rs b/crates/shirabe/src/dependency_resolver/request.rs index d6edc98..9aef975 100644 --- a/crates/shirabe/src/dependency_resolver/request.rs +++ b/crates/shirabe/src/dependency_resolver/request.rs @@ -1 +1,225 @@ //! ref: composer/src/Composer/DependencyResolver/Request.php + +use indexmap::IndexMap; +use shirabe_php_shim::{spl_object_hash, strtolower, LogicException}; +use shirabe_semver::constraint::constraint_interface::ConstraintInterface; +use shirabe_semver::constraint::match_all_constraint::MatchAllConstraint; + +use crate::package::base_package::BasePackage; +use crate::package::package_interface::PackageInterface; +use crate::repository::lock_array_repository::LockArrayRepository; + +/// Identifies a partial update for listed packages only, all dependencies will remain at locked versions +pub const UPDATE_ONLY_LISTED: i64 = 0; + +/// Identifies a partial update for listed packages and recursively all their dependencies, however +/// dependencies also directly required by the root composer.json and their dependencies will remain +/// at the locked version. +pub const UPDATE_LISTED_WITH_TRANSITIVE_DEPS_NO_ROOT_REQUIRE: i64 = 1; + +/// Identifies a partial update for listed packages and recursively all their dependencies, even +/// dependencies also directly required by the root composer.json will be updated. +pub const UPDATE_LISTED_WITH_TRANSITIVE_DEPS: i64 = 2; + +/// Represents the value of updateAllowTransitiveDependencies, which is false|UPDATE_* in PHP. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateAllowTransitiveDeps { + /// Corresponds to PHP false (initial value) + False, + UpdateOnlyListed, + UpdateListedWithTransitiveDepsNoRootRequire, + UpdateListedWithTransitiveDeps, +} + +#[derive(Debug)] +pub struct Request { + pub(crate) locked_repository: Option<LockArrayRepository>, + pub(crate) requires: IndexMap<String, Box<dyn ConstraintInterface>>, + pub(crate) fixed_packages: IndexMap<String, BasePackage>, + pub(crate) locked_packages: IndexMap<String, BasePackage>, + pub(crate) fixed_locked_packages: IndexMap<String, BasePackage>, + pub(crate) update_allow_list: Vec<String>, + pub(crate) update_allow_transitive_dependencies: UpdateAllowTransitiveDeps, + restrict_packages: Option<Vec<String>>, +} + +impl Request { + pub fn new(locked_repository: Option<LockArrayRepository>) -> Self { + Self { + locked_repository, + requires: IndexMap::new(), + fixed_packages: IndexMap::new(), + locked_packages: IndexMap::new(), + fixed_locked_packages: IndexMap::new(), + update_allow_list: vec![], + update_allow_transitive_dependencies: UpdateAllowTransitiveDeps::False, + restrict_packages: None, + } + } + + pub fn require_name( + &mut self, + package_name: &str, + constraint: Option<Box<dyn ConstraintInterface>>, + ) -> anyhow::Result<()> { + let package_name = strtolower(package_name); + let constraint = constraint.unwrap_or_else(|| Box::new(MatchAllConstraint::new())); + if self.requires.contains_key(&package_name) { + return Err(LogicException { + message: format!( + "Overwriting requires seems like a bug ({} {} => {}, check why it is happening, might be a root alias", + package_name, + self.requires[&package_name].get_pretty_string(), + constraint.get_pretty_string() + ), + code: 0, + } + .into()); + } + self.requires.insert(package_name, constraint); + Ok(()) + } + + /// Mark a package as currently present and having to remain installed. + /// + /// This is used for platform packages which cannot be modified by Composer. A rule enforcing + /// their installation is generated for dependency resolution. Partial updates with dependencies + /// cannot in any way modify these packages. + pub fn fix_package(&mut self, package: BasePackage) { + let hash = spl_object_hash(&package); + self.fixed_packages.insert(hash, package); + } + + /// Mark a package as locked to a specific version but removable. + /// + /// This is used for lock file packages which need to be treated similar to fixed packages by + /// the pool builder in that by default they should really only have the currently present + /// version loaded and no remote alternatives. + /// + /// However unlike fixed packages there will not be a special rule enforcing their installation + /// for the solver, so if nothing requires these packages they will be removed. Additionally in + /// a partial update these packages can be unlocked, meaning other versions can be installed if + /// explicitly requested as part of the update. + pub fn lock_package(&mut self, package: BasePackage) { + let hash = spl_object_hash(&package); + self.locked_packages.insert(hash, package); + } + + /// Marks a locked package fixed. So it's treated irremovable like a platform package. + /// + /// This is necessary for the composer install step which verifies the lock file integrity and + /// should not allow removal of any packages. At the same time lock packages there cannot simply + /// be marked fixed, as error reporting would then report them as platform packages, so this + /// still marks them as locked packages at the same time. + pub fn fix_locked_package(&mut self, package: BasePackage) { + let hash = spl_object_hash(&package); + self.fixed_packages.insert(hash.clone(), package.clone()); + self.fixed_locked_packages.insert(hash, package); + } + + pub fn unlock_package(&mut self, package: &BasePackage) { + self.locked_packages.remove(&spl_object_hash(package)); + } + + pub fn set_update_allow_list( + &mut self, + update_allow_list: Vec<String>, + update_allow_transitive_dependencies: UpdateAllowTransitiveDeps, + ) { + self.update_allow_list = update_allow_list; + self.update_allow_transitive_dependencies = update_allow_transitive_dependencies; + } + + pub fn get_update_allow_list(&self) -> &Vec<String> { + &self.update_allow_list + } + + pub fn get_update_allow_transitive_dependencies(&self) -> bool { + // PHP: $this->updateAllowTransitiveDependencies !== self::UPDATE_ONLY_LISTED + // false !== 0 is true in PHP (strict inequality, different types) + self.update_allow_transitive_dependencies != UpdateAllowTransitiveDeps::UpdateOnlyListed + } + + pub fn get_update_allow_transitive_root_dependencies(&self) -> bool { + self.update_allow_transitive_dependencies + == UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDeps + } + + pub fn get_requires(&self) -> &IndexMap<String, Box<dyn ConstraintInterface>> { + &self.requires + } + + pub fn get_fixed_packages(&self) -> &IndexMap<String, BasePackage> { + &self.fixed_packages + } + + pub fn is_fixed_package(&self, package: &BasePackage) -> bool { + self.fixed_packages.contains_key(&spl_object_hash(package)) + } + + pub fn get_locked_packages(&self) -> &IndexMap<String, BasePackage> { + &self.locked_packages + } + + pub fn is_locked_package(&self, package: &dyn PackageInterface) -> bool { + let hash = spl_object_hash(package); + self.locked_packages.contains_key(&hash) || self.fixed_locked_packages.contains_key(&hash) + } + + pub fn get_fixed_or_locked_packages(&self) -> IndexMap<String, BasePackage> { + let mut result = self.fixed_packages.clone(); + result.extend(self.locked_packages.clone()); + result + } + + /// @TODO look into removing the packageIds option, the only place true is used + /// is for the installed map in the solver problems. + /// Some locked packages may not be in the pool, + /// so they have a package->id of -1 + pub fn get_present_map(&self, package_ids: bool) -> IndexMap<String, BasePackage> { + let mut present_map: IndexMap<String, BasePackage> = IndexMap::new(); + + if let Some(ref locked_repository) = self.locked_repository { + for package in locked_repository.get_packages() { + let key = if package_ids { + package.get_id().to_string() + } else { + spl_object_hash(&package) + }; + present_map.insert(key, package); + } + } + + for (_, package) in &self.fixed_packages { + let key = if package_ids { + package.get_id().to_string() + } else { + spl_object_hash(package) + }; + present_map.insert(key, package.clone()); + } + + present_map + } + + pub fn get_fixed_packages_map(&self) -> IndexMap<i64, BasePackage> { + let mut fixed_packages_map: IndexMap<i64, BasePackage> = IndexMap::new(); + for (_, package) in &self.fixed_packages { + fixed_packages_map.insert(package.get_id(), package.clone()); + } + fixed_packages_map + } + + pub fn get_locked_repository(&self) -> Option<&LockArrayRepository> { + self.locked_repository.as_ref() + } + + /// Restricts the pool builder from loading other packages than those listed here. + pub fn restrict_packages(&mut self, names: Vec<String>) { + self.restrict_packages = Some(names); + } + + pub fn get_restricted_packages(&self) -> Option<&Vec<String>> { + self.restrict_packages.as_ref() + } +} diff --git a/crates/shirabe/src/downloader/svn_downloader.rs b/crates/shirabe/src/downloader/svn_downloader.rs index d66e7d9..75e5c02 100644 --- a/crates/shirabe/src/downloader/svn_downloader.rs +++ b/crates/shirabe/src/downloader/svn_downloader.rs @@ -1 +1,401 @@ //! ref: composer/src/Composer/Downloader/SvnDownloader.php + +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_external_packages::react::promise; +use shirabe_external_packages::react::promise::promise_interface::PromiseInterface; +use shirabe_php_shim::{is_dir, version_compare, PhpMixed, RuntimeException}; + +use crate::downloader::vcs_downloader::VcsDownloader; +use crate::io::io_interface::IOInterface; +use crate::package::package_interface::PackageInterface; +use crate::repository::vcs_repository::VcsRepository; +use crate::util::svn::Svn as SvnUtil; + +#[derive(Debug)] +pub struct SvnDownloader { + inner: VcsDownloader, + pub(crate) cache_credentials: bool, +} + +impl SvnDownloader { + pub(crate) fn do_download( + &mut self, + package: &dyn PackageInterface, + path: &str, + url: &str, + prev_package: Option<&dyn PackageInterface>, + ) -> anyhow::Result<Box<dyn PromiseInterface>> { + SvnUtil::clean_env(); + let util = SvnUtil::new(url, &*self.inner.io, &self.inner.config, &self.inner.process); + if util.binary_version().is_none() { + return Err(RuntimeException { + message: "svn was not found in your PATH, skipping source download".to_string(), + code: 0, + } + .into()); + } + + Ok(promise::resolve(None)) + } + + pub(crate) fn do_install( + &mut self, + package: &dyn PackageInterface, + path: &str, + url: &str, + ) -> anyhow::Result<Box<dyn PromiseInterface>> { + SvnUtil::clean_env(); + let r#ref = package.get_source_reference(); + + let repo = package.get_repository(); + if let Some(repo) = repo { + if let Some(vcs_repo) = repo.as_any().downcast_ref::<VcsRepository>() { + let repo_config = vcs_repo.get_repo_config(); + if repo_config.contains_key("svn-cache-credentials") { + if let Some(val) = repo_config.get("svn-cache-credentials").and_then(|v| v.as_bool()) { + self.cache_credentials = val; + } + } + } + } + + self.inner.io.write_error( + PhpMixed::String(format!(" Checking out {}", package.get_source_reference())), + true, + IOInterface::NORMAL, + ); + self.execute( + package, + url, + vec!["svn".to_string(), "co".to_string()], + &format!("{}/{}", url, r#ref), + None, + Some(path), + )?; + + Ok(promise::resolve(None)) + } + + pub(crate) fn do_update( + &mut self, + initial: &dyn PackageInterface, + target: &dyn PackageInterface, + path: &str, + url: &str, + ) -> anyhow::Result<Box<dyn PromiseInterface>> { + SvnUtil::clean_env(); + let r#ref = target.get_source_reference(); + + if !self.has_metadata_repository(path) { + return Err(RuntimeException { + message: format!( + "The .svn directory is missing from {}, see https://getcomposer.org/commit-deps for more information", + path + ), + code: 0, + } + .into()); + } + + let util = SvnUtil::new(url, &*self.inner.io, &self.inner.config, &self.inner.process); + let mut flags: Vec<String> = vec![]; + if version_compare(&util.binary_version().unwrap_or_default(), "1.7.0", ">=") { + flags.push("--ignore-ancestry".to_string()); + } + + self.inner.io.write_error( + PhpMixed::String(format!(" Checking out {}", r#ref)), + true, + IOInterface::NORMAL, + ); + let mut command = vec!["svn".to_string(), "switch".to_string()]; + command.extend(flags); + self.execute( + target, + url, + command, + &format!("{}/{}", url, r#ref), + Some(path), + None, + )?; + + Ok(promise::resolve(None)) + } + + pub fn get_local_changes(&self, package: &dyn PackageInterface, path: &str) -> Option<String> { + if !self.has_metadata_repository(path) { + return None; + } + + let mut output = String::new(); + self.inner.process.execute( + &["svn", "status", "--ignore-externals"] + .map(|s| s.to_string()) + .to_vec(), + &mut output, + Some(path.to_string()), + ); + + if Preg::is_match("{^ *[^X ] +}m", &output).unwrap_or(false) { + Some(output) + } else { + None + } + } + + pub(crate) fn execute( + &self, + package: &dyn PackageInterface, + base_url: &str, + command: Vec<String>, + url: &str, + cwd: Option<&str>, + path: Option<&str>, + ) -> anyhow::Result<String> { + let mut util = SvnUtil::new( + base_url, + &*self.inner.io, + &self.inner.config, + &self.inner.process, + ); + util.set_cache_credentials(self.cache_credentials); + util.execute(command, url, cwd, path, self.inner.io.is_verbose()) + .map_err(|e| { + anyhow::anyhow!( + "{} could not be downloaded, {}", + package.get_pretty_name(), + e + ) + }) + } + + pub(crate) fn clean_changes( + &mut self, + package: &dyn PackageInterface, + path: &str, + update: bool, + ) -> anyhow::Result<Box<dyn PromiseInterface>> { + let changes = self.get_local_changes(package, path); + if changes.is_none() { + return Ok(promise::resolve(None)); + } + + if !self.inner.io.is_interactive() { + if self.inner.config.get("discard-changes").as_bool() == Some(true) { + return self.discard_changes(path); + } + + return self.inner.clean_changes(package, path, update); + } + + let changes_str = changes.unwrap(); + let changes: Vec<String> = Preg::split(r"{\s*\r?\n\s*}", &changes_str) + .into_iter() + .map(|elem| format!(" {}", elem)) + .collect(); + let count_changes = changes.len() as i64; + self.inner.io.write_error( + PhpMixed::String(format!( + " <error>{} has modified file{}:</error>", + package.get_pretty_name(), + if count_changes == 1 { "" } else { "s" } + )), + true, + IOInterface::NORMAL, + ); + let slice_end = 10_usize.min(changes.len()); + self.inner.io.write_error( + PhpMixed::List( + changes[..slice_end] + .iter() + .map(|s| Box::new(PhpMixed::String(s.clone()))) + .collect(), + ), + true, + IOInterface::NORMAL, + ); + if count_changes > 10 { + let remaining_changes = count_changes - 10; + self.inner.io.write_error( + PhpMixed::String(format!( + " <info>{} more file{} modified, choose \"v\" to view the full list</info>", + remaining_changes, + if remaining_changes == 1 { "" } else { "s" } + )), + true, + IOInterface::NORMAL, + ); + } + + loop { + match self + .inner + .io + .ask( + " <info>Discard changes [y,n,v,?]?</info> ".to_string(), + PhpMixed::String("?".to_string()), + ) + .as_string() + { + Some("y") => { + self.discard_changes(path)?; + break; + } + Some("n") => { + return Err(RuntimeException { + message: "Update aborted".to_string(), + code: 0, + } + .into()); + } + Some("v") => { + self.inner.io.write_error( + PhpMixed::List( + changes + .iter() + .map(|s| Box::new(PhpMixed::String(s.clone()))) + .collect(), + ), + true, + IOInterface::NORMAL, + ); + } + _ => { + self.inner.io.write_error( + PhpMixed::List(vec![ + Box::new(PhpMixed::String(format!( + " y - discard changes and apply the {}", + if update { "update" } else { "uninstall" } + ))), + Box::new(PhpMixed::String(format!( + " n - abort the {} and let you manually clean things up", + if update { "update" } else { "uninstall" } + ))), + Box::new(PhpMixed::String( + " v - view modified files".to_string(), + )), + Box::new(PhpMixed::String(" ? - print help".to_string())), + ]), + true, + IOInterface::NORMAL, + ); + } + } + } + + Ok(promise::resolve(None)) + } + + pub(crate) fn get_commit_logs( + &self, + from_reference: &str, + to_reference: &str, + path: &str, + ) -> anyhow::Result<String> { + if Preg::is_match(r"{@(\d+)$}", from_reference).unwrap_or(false) + && Preg::is_match(r"{@(\d+)$}", to_reference).unwrap_or(false) + { + // retrieve the svn base url from the checkout folder + let command = vec![ + "svn".to_string(), + "info".to_string(), + "--non-interactive".to_string(), + "--xml".to_string(), + "--".to_string(), + path.to_string(), + ]; + let mut output = String::new(); + if self + .inner + .process + .execute(&command, &mut output, Some(path.to_string())) + != 0 + { + return Err(RuntimeException { + message: format!( + "Failed to execute {}\n\n{}", + command.join(" "), + self.inner.process.get_error_output() + ), + code: 0, + } + .into()); + } + + let url_pattern = "#<url>(.*)</url>#"; + let base_url = if let Some(matches) = Preg::match_strict_groups(url_pattern, &output) { + matches.get("1").cloned().unwrap_or_default() + } else { + return Err(RuntimeException { + message: format!("Unable to determine svn url for path {}", path), + code: 0, + } + .into()); + }; + + // strip paths from references and only keep the actual revision + let from_revision = Preg::replace(r"{.*@(\d+)$}", "$1", from_reference.to_string()); + let to_revision = Preg::replace(r"{.*@(\d+)$}", "$1", to_reference.to_string()); + + let command = vec![ + "svn".to_string(), + "log".to_string(), + "-r".to_string(), + format!("{}:{}", from_revision, to_revision), + "--incremental".to_string(), + ]; + + let mut util = SvnUtil::new( + &base_url, + &*self.inner.io, + &self.inner.config, + &self.inner.process, + ); + util.set_cache_credentials(self.cache_credentials); + util.execute_local(command.clone(), path, None, self.inner.io.is_verbose()) + .map_err(|e| { + RuntimeException { + message: format!( + "Failed to execute {}\n\n{}", + command.join(" "), + e + ), + code: 0, + } + .into() + }) + } else { + Ok(format!( + "Could not retrieve changes between {} and {} due to missing revision information", + from_reference, to_reference + )) + } + } + + pub(crate) fn discard_changes(&self, path: &str) -> anyhow::Result<Box<dyn PromiseInterface>> { + let mut output = String::new(); + if self.inner.process.execute( + &["svn", "revert", "-R", "."] + .map(|s| s.to_string()) + .to_vec(), + &mut output, + Some(path.to_string()), + ) != 0 + { + return Err(RuntimeException { + message: format!( + "Could not reset changes\n\n:{}", + self.inner.process.get_error_output() + ), + code: 0, + } + .into()); + } + + Ok(promise::resolve(None)) + } + + pub(crate) fn has_metadata_repository(&self, path: &str) -> bool { + is_dir(&format!("{}/.svn", path)) + } +} diff --git a/crates/shirabe/src/repository/path_repository.rs b/crates/shirabe/src/repository/path_repository.rs index 894668b..f2b470d 100644 --- a/crates/shirabe/src/repository/path_repository.rs +++ b/crates/shirabe/src/repository/path_repository.rs @@ -1 +1,330 @@ //! ref: composer/src/Composer/Repository/PathRepository.php + +use indexmap::IndexMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{ + defined, file_exists, file_get_contents, glob_with_flags, hash, realpath, serialize, + PhpMixed, RuntimeException, DIRECTORY_SEPARATOR, GLOB_BRACE, GLOB_MARK, GLOB_ONLYDIR, +}; + +use crate::config::Config; +use crate::event_dispatcher::event_dispatcher::EventDispatcher; +use crate::io::io_interface::IOInterface; +use crate::json::json_file::JsonFile; +use crate::package::loader::array_loader::ArrayLoader; +use crate::package::version::version_guesser::VersionGuesser; +use crate::package::version::version_parser::VersionParser; +use crate::repository::array_repository::ArrayRepository; +use crate::repository::configurable_repository_interface::ConfigurableRepositoryInterface; +use crate::util::filesystem::Filesystem; +use crate::util::git::Git as GitUtil; +use crate::util::http_downloader::HttpDownloader; +use crate::util::platform::Platform; +use crate::util::process_executor::ProcessExecutor; +use crate::util::url::Url; + +#[derive(Debug)] +pub struct PathRepository { + inner: ArrayRepository, + loader: ArrayLoader, + version_guesser: VersionGuesser, + url: String, + repo_config: IndexMap<String, PhpMixed>, + process: ProcessExecutor, + options: IndexMap<String, PhpMixed>, +} + +impl ConfigurableRepositoryInterface for PathRepository { + fn get_repo_config(&self) -> IndexMap<String, PhpMixed> { + self.repo_config.clone() + } +} + +impl PathRepository { + pub fn new( + repo_config: IndexMap<String, PhpMixed>, + io: Box<dyn IOInterface>, + config: Config, + http_downloader: Option<HttpDownloader>, + dispatcher: Option<EventDispatcher>, + process: Option<ProcessExecutor>, + ) -> anyhow::Result<Self> { + if !repo_config.contains_key("url") { + return Err(RuntimeException { + message: "You must specify the `url` configuration for the path repository" + .to_string(), + code: 0, + } + .into()); + } + + let url_str = repo_config + .get("url") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + let url = Platform::expand_path(&url_str); + let process = process.unwrap_or_else(|| ProcessExecutor::new(&*io)); + let version_guesser = + VersionGuesser::new(&config, &process, VersionParser::new(), &*io); + let mut options = repo_config + .get("options") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, *v)) + .collect::<IndexMap<String, PhpMixed>>(); + if !options.contains_key("relative") { + let filesystem = Filesystem::new(); + let is_relative = !filesystem.is_absolute_path(&url); + options.insert("relative".to_string(), PhpMixed::Bool(is_relative)); + } + + Ok(Self { + inner: ArrayRepository::new(), + loader: ArrayLoader::new(None, true), + version_guesser, + url, + repo_config, + process, + options, + }) + } + + pub fn get_repo_name(&self) -> String { + format!( + "path repo ({})", + Url::sanitize( + self.repo_config + .get("url") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string() + ) + ) + } + + pub(crate) fn initialize(&mut self) -> anyhow::Result<()> { + self.inner.initialize()?; + + let url_matches = self.get_url_matches()?; + + if url_matches.is_empty() { + if Preg::is_match(r"{[*{}]}", &self.url).unwrap_or(false) { + let mut url = self.url.clone(); + while Preg::is_match(r"{[*{}]}", &url).unwrap_or(false) { + url = shirabe_php_shim::dirname(&url); + } + // the parent directory before any wildcard exists, so we assume it is correctly configured but simply empty + if shirabe_php_shim::is_dir(&url) { + return Ok(()); + } + } + + return Err(RuntimeException { + message: format!( + "The `url` supplied for the path ({}) repository does not exist", + self.url + ), + code: 0, + } + .into()); + } + + for url in url_matches { + let path = format!( + "{}/", + realpath(&url).unwrap_or_default() + ); + let composer_file_path = format!("{}composer.json", path); + + if !file_exists(&composer_file_path) { + continue; + } + + let json = file_get_contents(&composer_file_path).unwrap_or_default(); + let mut package = JsonFile::parse_json(&json, Some(&composer_file_path))? + .unwrap_or_default(); + let dist = { + let mut dist = IndexMap::new(); + dist.insert("type".to_string(), Box::new(PhpMixed::String("path".to_string()))); + dist.insert("url".to_string(), Box::new(PhpMixed::String(url.clone()))); + dist + }; + package.insert("dist".to_string(), PhpMixed::Array(dist)); + + let reference = self + .options + .get("reference") + .and_then(|v| v.as_string()) + .unwrap_or("auto") + .to_string(); + if reference == "none" { + if let Some(PhpMixed::Array(ref mut dist)) = package.get_mut("dist") { + dist.insert("reference".to_string(), Box::new(PhpMixed::Null)); + } + } else if reference == "config" || reference == "auto" { + let options_mixed = PhpMixed::Array( + self.options + .iter() + .map(|(k, v)| (k.clone(), Box::new(v.clone()))) + .collect(), + ); + let ref_hash = hash("sha1", &format!("{}{}", json, serialize(&options_mixed))); + if let Some(PhpMixed::Array(ref mut dist)) = package.get_mut("dist") { + dist.insert( + "reference".to_string(), + Box::new(PhpMixed::String(ref_hash)), + ); + } + } + + // copy symlink/relative options to transport options + let transport_options: IndexMap<String, Box<PhpMixed>> = self + .options + .iter() + .filter(|(k, _)| k.as_str() == "symlink" || k.as_str() == "relative") + .map(|(k, v)| (k.clone(), Box::new(v.clone()))) + .collect(); + package.insert( + "transport-options".to_string(), + PhpMixed::Array(transport_options), + ); + + // use the version provided as option if available + if let Some(name) = package.get("name").and_then(|v| v.as_string()).map(|s| s.to_string()) { + if let Some(version) = self + .options + .get("versions") + .and_then(|v| v.as_array()) + .and_then(|a| a.get(&name)) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()) + { + package.insert("version".to_string(), PhpMixed::String(version)); + } + } + + // carry over the root package version if this path repo is in the same git repository as root package + if !package.contains_key("version") { + if let Some(root_version) = Platform::get_env("COMPOSER_ROOT_VERSION") { + if !root_version.is_empty() { + let mut ref1 = String::new(); + let mut ref2 = String::new(); + if self.process.execute( + &["git", "rev-parse", "HEAD"].map(|s| s.to_string()).to_vec(), + &mut ref1, + Some(path.clone()), + ) == 0 + && self.process.execute( + &["git", "rev-parse", "HEAD"].map(|s| s.to_string()).to_vec(), + &mut ref2, + None, + ) == 0 + && ref1 == ref2 + { + package.insert( + "version".to_string(), + PhpMixed::String( + self.version_guesser.get_root_version_from_env(), + ), + ); + } + } + } + } + + let mut output = String::new(); + let command = GitUtil::build_rev_list_command( + &self.process, + { + let mut args = vec!["-n1".to_string(), "--format=%H".to_string(), "HEAD".to_string()]; + args.extend(GitUtil::get_no_show_signature_flags(&self.process)); + args + }, + ); + if reference == "auto" + && shirabe_php_shim::is_dir(&format!("{}/.git", path.trim_end_matches('/'))) + && self.process.execute(&command, &mut output, Some(path.clone())) == 0 + { + let ref_val = + GitUtil::parse_rev_list_output(&output, &self.process).trim().to_string(); + if let Some(PhpMixed::Array(ref mut dist)) = package.get_mut("dist") { + dist.insert("reference".to_string(), Box::new(PhpMixed::String(ref_val))); + } + } + + if !package.contains_key("version") { + let version_data = self.version_guesser.guess_version(&package, &path); + if let Some(version_data) = version_data { + if let Some(pretty_version) = version_data + .get("pretty_version") + .and_then(|v| v.as_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + { + // if there is a feature branch detected, we add a second package with the feature branch version + if let Some(feature_pretty_version) = version_data + .get("feature_pretty_version") + .and_then(|v| v.as_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + { + package.insert( + "version".to_string(), + PhpMixed::String(feature_pretty_version), + ); + self.inner.add_package(self.loader.load(package.clone())?); + } + + package.insert("version".to_string(), PhpMixed::String(pretty_version)); + } else { + package.insert( + "version".to_string(), + PhpMixed::String("dev-main".to_string()), + ); + } + } else { + package.insert( + "version".to_string(), + PhpMixed::String("dev-main".to_string()), + ); + } + } + + self.inner + .add_package(self.loader.load(package.clone()).map_err(|e| { + RuntimeException { + message: format!("Failed loading the package in {}", composer_file_path), + code: 0, + } + })?); + } + + Ok(()) + } + + fn get_url_matches(&self) -> anyhow::Result<Vec<String>> { + let mut flags = GLOB_MARK | GLOB_ONLYDIR; + + if defined("GLOB_BRACE") { + flags |= GLOB_BRACE; + } else if self.url.contains('{') || self.url.contains('}') { + return Err(RuntimeException { + message: format!( + "The operating system does not support GLOB_BRACE which is required for the url {}", + self.url + ), + code: 0, + } + .into()); + } + + // Ensure environment-specific path separators are normalized to URL separators + Ok(glob_with_flags(&self.url, flags) + .into_iter() + .map(|val| val.replace(DIRECTORY_SEPARATOR, "/").trim_end_matches('/').to_string()) + .collect()) + } +} diff --git a/crates/shirabe/src/repository/vcs/fossil_driver.rs b/crates/shirabe/src/repository/vcs/fossil_driver.rs index b1e7ac9..da81548 100644 --- a/crates/shirabe/src/repository/vcs/fossil_driver.rs +++ b/crates/shirabe/src/repository/vcs/fossil_driver.rs @@ -1 +1,312 @@ //! ref: composer/src/Composer/Repository/Vcs/FossilDriver.php + +use chrono::{DateTime, Utc}; +use indexmap::IndexMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_php_shim::{dirname, is_dir, is_file, is_writable, PhpMixed, 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::process_executor::ProcessExecutor; + +#[derive(Debug)] +pub struct FossilDriver { + 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_file: Option<String>, + pub(crate) checkout_dir: String, +} + +impl FossilDriver { + pub fn initialize(&mut self) -> anyhow::Result<()> { + // Make sure fossil is installed and reachable. + self.check_fossil()?; + + // Ensure we are allowed to use this URL by config. + self.inner.config.prohibit_url_by_config(&self.inner.url, &*self.inner.io)?; + + // Only if url points to a locally accessible directory, assume it's the checkout directory. + // Otherwise, it should be something fossil can clone from. + if Filesystem::is_local_path(&self.inner.url) && is_dir(&self.inner.url) { + self.checkout_dir = self.inner.url.clone(); + } else { + let cache_repo_dir = self.inner.config.get("cache-repo-dir").as_string().unwrap_or("").to_string(); + let cache_vcs_dir = self.inner.config.get("cache-vcs-dir").as_string().unwrap_or("").to_string(); + if !Cache::is_usable(&cache_repo_dir) || !Cache::is_usable(&cache_vcs_dir) { + return Err(RuntimeException { + message: "FossilDriver requires a usable cache directory, and it looks like you set it to be disabled".to_string(), + code: 0, + } + .into()); + } + + let local_name = Preg::replace(r"{[^a-z0-9]}i", "-", self.inner.url.clone()); + self.repo_file = Some(format!("{}/{}.fossil", cache_repo_dir, local_name)); + self.checkout_dir = format!("{}/{}/", cache_vcs_dir, local_name); + + self.update_local_repo()?; + } + + self.get_tags()?; + self.get_branches()?; + + Ok(()) + } + + pub(crate) fn check_fossil(&self) -> anyhow::Result<()> { + let mut ignored_output = String::new(); + if self.inner.process.execute( + &["fossil", "version"].map(|s| s.to_string()).to_vec(), + &mut ignored_output, + None, + ) != 0 + { + return Err(RuntimeException { + message: format!( + "fossil was not found, check that it is installed and in your PATH env.\n\n{}", + self.inner.process.get_error_output() + ), + code: 0, + } + .into()); + } + Ok(()) + } + + pub(crate) fn update_local_repo(&mut self) -> anyhow::Result<()> { + assert!(self.repo_file.is_some()); + + let fs = Filesystem::new(); + fs.ensure_directory_exists(&self.checkout_dir)?; + + if !is_writable(&dirname(&self.checkout_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, self.checkout_dir + ), + code: 0, + } + .into()); + } + + let repo_file = self.repo_file.as_ref().unwrap().clone(); + + // update the repo if it is a valid fossil repository + if is_file(&repo_file) + && is_dir(&self.checkout_dir) + && self.inner.process.execute( + &["fossil", "info"].map(|s| s.to_string()).to_vec(), + &mut String::new(), + Some(self.checkout_dir.clone()), + ) == 0 + { + if self.inner.process.execute( + &["fossil", "pull"].map(|s| s.to_string()).to_vec(), + &mut String::new(), + Some(self.checkout_dir.clone()), + ) != 0 + { + self.inner.io.write_error( + PhpMixed::String(format!( + "<error>Failed to update {}, package information from this repository may be outdated ({})</error>", + self.inner.url, + self.inner.process.get_error_output() + )), + true, + IOInterface::NORMAL, + ); + } + } else { + // clean up directory and do a fresh clone into it + fs.remove_directory(&self.checkout_dir)?; + fs.remove(&repo_file)?; + fs.ensure_directory_exists(&self.checkout_dir)?; + + let mut output = String::new(); + if self.inner.process.execute( + &["fossil", "clone", "--", &self.inner.url, &repo_file] + .map(|s| s.to_string()) + .to_vec(), + &mut output, + None, + ) != 0 + { + let output = self.inner.process.get_error_output(); + return Err(RuntimeException { + message: format!( + "Failed to clone {} to repository {}\n\n{}", + self.inner.url, repo_file, output + ), + code: 0, + } + .into()); + } + + if self.inner.process.execute( + &["fossil", "open", "--nested", "--", &repo_file] + .map(|s| s.to_string()) + .to_vec(), + &mut output, + Some(self.checkout_dir.clone()), + ) != 0 + { + let output = self.inner.process.get_error_output(); + return Err(RuntimeException { + message: format!( + "Failed to open repository {} in {}\n\n{}", + repo_file, self.checkout_dir, output + ), + code: 0, + } + .into()); + } + } + + Ok(()) + } + + pub fn get_root_identifier(&mut self) -> String { + if self.root_identifier.is_none() { + self.root_identifier = Some("trunk".to_string()); + } + self.root_identifier.clone().unwrap() + } + + 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(), "fossil".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(&self, file: &str, identifier: &str) -> anyhow::Result<Option<String>> { + if identifier.starts_with('-') { + return Err(RuntimeException { + message: format!( + "Invalid fossil identifier detected. Identifier must not start with a -, given: {}", + identifier + ), + code: 0, + } + .into()); + } + + let mut content = String::new(); + self.inner.process.execute( + &["fossil", "cat", "-r", identifier, "--", file] + .map(|s| s.to_string()) + .to_vec(), + &mut content, + Some(self.checkout_dir.clone()), + ); + + if content.trim().is_empty() { + return Ok(None); + } + + Ok(Some(content)) + } + + pub fn get_change_date(&self, _identifier: &str) -> anyhow::Result<Option<DateTime<Utc>>> { + let mut output = String::new(); + self.inner.process.execute( + &["fossil", "finfo", "-b", "-n", "1", "composer.json"] + .map(|s| s.to_string()) + .to_vec(), + &mut output, + Some(self.checkout_dir.clone()), + ); + let parts: Vec<&str> = output.trim().splitn(3, ' ').collect(); + let date = parts.get(1).copied().unwrap_or(""); + + let date = DateTime::parse_from_rfc3339(date).map(|d| d.with_timezone(&Utc))?; + Ok(Some(date)) + } + + pub fn get_tags(&mut self) -> anyhow::Result<IndexMap<String, String>> { + if self.tags.is_none() { + let mut tags: IndexMap<String, String> = IndexMap::new(); + let mut output = String::new(); + self.inner.process.execute( + &["fossil", "tag", "list"].map(|s| s.to_string()).to_vec(), + &mut output, + Some(self.checkout_dir.clone()), + ); + for tag in self.inner.process.split_lines(&output) { + tags.insert(tag.clone(), tag); + } + self.tags = Some(tags); + } + 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<String, String> = IndexMap::new(); + let mut output = String::new(); + self.inner.process.execute( + &["fossil", "branch", "list"].map(|s| s.to_string()).to_vec(), + &mut output, + Some(self.checkout_dir.clone()), + ); + for branch in self.inner.process.split_lines(&output) { + let branch = Preg::replace(r"/^\*/", "", branch.trim().to_string()); + let branch = branch.trim().to_string(); + branches.insert(branch.clone(), branch); + } + self.branches = Some(branches); + } + Ok(self.branches.clone().unwrap_or_default()) + } + + pub fn supports(io: &dyn IOInterface, config: &Config, url: &str, deep: bool) -> bool { + if Preg::is_match( + r"#(^(?:https?|ssh)://(?:[^@]@)?(?:chiselapp\.com|fossil\.))#i", + url, + ) + .unwrap_or(false) + { + return true; + } + + if Preg::is_match(r"!/fossil/|\.fossil!", url).unwrap_or(false) { + return true; + } + + // local filesystem + if Filesystem::is_local_path(url) { + let url = Filesystem::get_platform_path(url); + if !is_dir(&url) { + return false; + } + + let process = ProcessExecutor::new(io); + let mut output = String::new(); + if process.execute( + &["fossil", "info"].map(|s| s.to_string()).to_vec(), + &mut output, + Some(url), + ) == 0 + { + return true; + } + } + + false + } +} diff --git a/crates/shirabe/src/util/stream_context_factory.rs b/crates/shirabe/src/util/stream_context_factory.rs index 414d403..aba3a50 100644 --- a/crates/shirabe/src/util/stream_context_factory.rs +++ b/crates/shirabe/src/util/stream_context_factory.rs @@ -1 +1,382 @@ //! ref: composer/src/Composer/Util/StreamContextFactory.php + +use indexmap::IndexMap; +use shirabe_external_packages::composer::ca_bundle::ca_bundle::CaBundle; +use shirabe_external_packages::psr::log::logger_interface::LoggerInterface; +use shirabe_php_shim::{ + array_replace_recursive, curl_version, extension_loaded, function_exists, php_uname, + stream_context_create, stripos, uasort, PhpMixed, RuntimeException, + HHVM_VERSION, PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION, +}; + +use crate::composer::Composer; +use crate::downloader::transport_exception::TransportException; +use crate::repository::platform_repository::PlatformRepository; +use crate::util::filesystem::Filesystem; +use crate::util::http::proxy_manager::ProxyManager; +use crate::util::platform::Platform; + +pub struct StreamContextFactory; + +impl StreamContextFactory { + /// Creates a context supporting HTTP proxies. + pub fn get_context( + url: &str, + default_options: IndexMap<String, PhpMixed>, + default_params: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<PhpMixed, TransportException> { + let mut options: IndexMap<String, PhpMixed> = { + let mut http = IndexMap::new(); + // specify defaults again to try and work better with curlwrappers enabled + http.insert("follow_location".to_string(), PhpMixed::Int(1)); + http.insert("max_redirects".to_string(), PhpMixed::Int(20)); + let mut o = IndexMap::new(); + o.insert("http".to_string(), PhpMixed::Array( + http.into_iter().map(|(k, v)| (k, Box::new(v))).collect() + )); + o + }; + + options = array_replace_recursive(options, Self::init_options(url, default_options.clone(), false)?); + let default_options = { + let mut o = default_options; + if let Some(PhpMixed::Array(ref mut http)) = o.get_mut("http") { + http.remove("header"); + } + o + }; + options = array_replace_recursive(options, default_options); + + if let Some(PhpMixed::Array(ref mut http)) = options.get_mut("http") { + if let Some(header) = http.get("header").cloned() { + let fixed = Self::fix_http_header_field(&*header); + http.insert( + "header".to_string(), + Box::new(PhpMixed::List( + fixed.into_iter().map(|s| Box::new(PhpMixed::String(s))).collect(), + )), + ); + } + } + + Ok(stream_context_create(&options, Some(&default_params))) + } + + pub fn init_options( + url: &str, + mut options: IndexMap<String, PhpMixed>, + for_curl: bool, + ) -> anyhow::Result<IndexMap<String, PhpMixed>, TransportException> { + // Make sure the headers are in an array form + let has_header = options + .get("http") + .and_then(|v| v.as_array()) + .map(|a| a.contains_key("header")) + .unwrap_or(false); + if !has_header { + if let Some(PhpMixed::Array(ref mut http)) = options.get_mut("http") { + http.insert( + "header".to_string(), + Box::new(PhpMixed::List(vec![])), + ); + } + } + // Convert string header to array + let header_is_string = options + .get("http") + .and_then(|v| v.as_array()) + .and_then(|a| a.get("header")) + .map(|v| matches!(**v, PhpMixed::String(_))) + .unwrap_or(false); + if header_is_string { + if let Some(PhpMixed::Array(ref mut http)) = options.get_mut("http") { + if let Some(PhpMixed::String(header_str)) = http.get("header").map(|v| *v.clone()) { + let parts: Vec<Box<PhpMixed>> = header_str + .split("\r\n") + .map(|s| Box::new(PhpMixed::String(s.to_string()))) + .collect(); + http.insert("header".to_string(), Box::new(PhpMixed::List(parts))); + } + } + } + + // Add stream proxy options if there is a proxy + if !for_curl { + let proxy_manager = ProxyManager::get_instance().lock().unwrap(); + let proxy_manager = proxy_manager.as_ref().unwrap(); + let proxy = proxy_manager.get_proxy_for_request(url)?; + let proxy_options = proxy.get_context_options(); + if let Some(proxy_options) = proxy_options { + let is_https_request = url.starts_with("https://"); + + if proxy.is_secure() { + if !extension_loaded("openssl") { + return Err(TransportException::new( + "You must enable the openssl extension to use a secure proxy.".to_string(), + )); + } + if is_https_request { + return Err(TransportException::new( + "You must enable the curl extension to make https requests through a secure proxy.".to_string(), + )); + } + } else if is_https_request && !extension_loaded("openssl") { + return Err(TransportException::new( + "You must enable the openssl extension to make https requests through a proxy.".to_string(), + )); + } + + // Header will be a Proxy-Authorization string or not set + let proxy_http = proxy_options.get("http"); + if let Some(proxy_header) = proxy_http.and_then(|h| h.get("header")) { + if let Some(PhpMixed::Array(ref mut http)) = options.get_mut("http") { + if let Some(PhpMixed::List(ref mut headers)) = http.get_mut("header").map(|v| &mut **v) { + headers.push(Box::new(*proxy_header.clone())); + } + } + } + + let proxy_options_flat: IndexMap<String, PhpMixed> = proxy_options + .iter() + .map(|(k, v)| { + let inner: IndexMap<String, Box<PhpMixed>> = v + .iter() + .filter(|(ik, _)| ik.as_str() != "header") + .map(|(ik, iv)| (ik.clone(), iv.clone())) + .collect(); + (k.clone(), PhpMixed::Array(inner)) + }) + .collect(); + options = array_replace_recursive(options, proxy_options_flat); + } + } + + let php_version = if HHVM_VERSION.is_some() { + format!("HHVM {}", HHVM_VERSION.unwrap()) + } else { + format!( + "PHP {}.{}.{}", + PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION + ) + }; + + let http_version = if for_curl { + let curl = curl_version().unwrap_or_default(); + let version = curl + .get("version") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + format!("cURL {}", version) + } else { + "streams".to_string() + }; + + let has_user_agent = options + .get("http") + .and_then(|v| v.as_array()) + .and_then(|a| a.get("header")) + .and_then(|v| match **v { + PhpMixed::List(ref list) => { + let joined: String = list + .iter() + .filter_map(|item| item.as_string()) + .collect::<Vec<_>>() + .join(""); + Some(joined.to_lowercase().contains("user-agent")) + } + _ => None, + }) + .unwrap_or(false); + + if !has_user_agent { + let platform_php_version = PlatformRepository::get_platform_php_version(); + let user_agent = format!( + "User-Agent: Composer/{} ({os}; {release}; {php_version}; {http_version}{platform}{ci})", + Composer::get_version(), + os = if function_exists("php_uname") { php_uname("s") } else { "Unknown".to_string() }, + release = if function_exists("php_uname") { php_uname("r") } else { "Unknown".to_string() }, + php_version = php_version, + http_version = http_version, + platform = platform_php_version + .as_deref() + .map(|v| format!("; Platform-PHP {}", v)) + .unwrap_or_default(), + ci = if Platform::get_env("CI").is_some() { "; CI" } else { "" }, + ); + if let Some(PhpMixed::Array(ref mut http)) = options.get_mut("http") { + if let Some(PhpMixed::List(ref mut headers)) = http.get_mut("header").map(|v| &mut **v) { + headers.push(Box::new(PhpMixed::String(user_agent))); + } + } + } + + Ok(options) + } + + pub fn get_tls_defaults( + options: &IndexMap<String, PhpMixed>, + logger: Option<&dyn LoggerInterface>, + ) -> anyhow::Result<IndexMap<String, PhpMixed>, TransportException> { + let ciphers = [ + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "DHE-DSS-AES128-GCM-SHA256", + "kEDH+AESGCM", + "ECDHE-RSA-AES128-SHA256", + "ECDHE-ECDSA-AES128-SHA256", + "ECDHE-RSA-AES128-SHA", + "ECDHE-ECDSA-AES128-SHA", + "ECDHE-RSA-AES256-SHA384", + "ECDHE-ECDSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA", + "ECDHE-ECDSA-AES256-SHA", + "DHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA", + "DHE-DSS-AES128-SHA256", + "DHE-RSA-AES256-SHA256", + "DHE-DSS-AES256-SHA", + "DHE-RSA-AES256-SHA", + "AES128-GCM-SHA256", + "AES256-GCM-SHA384", + "AES128-SHA256", + "AES256-SHA256", + "AES128-SHA", + "AES256-SHA", + "AES", + "CAMELLIA", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!3DES", + "!RC4", + "!MD5", + "!PSK", + "!aECDH", + "!EDH-DSS-DES-CBC3-SHA", + "!EDH-RSA-DES-CBC3-SHA", + "!KRB5-DES-CBC3-SHA", + ] + .join(":"); + + // CN_match and SNI_server_name are only known once a URL is passed. + // They will be set in the getOptionsForUrl() method which receives a URL. + // + // cafile or capath can be overridden by passing in those options to constructor. + let ssl_defaults: IndexMap<String, PhpMixed> = { + let mut ssl = IndexMap::new(); + ssl.insert("ciphers".to_string(), PhpMixed::String(ciphers)); + ssl.insert("verify_peer".to_string(), PhpMixed::Bool(true)); + ssl.insert("verify_depth".to_string(), PhpMixed::Int(7)); + ssl.insert("SNI_enabled".to_string(), PhpMixed::Bool(true)); + ssl.insert("capture_peer_cert".to_string(), PhpMixed::Bool(true)); + ssl + }; + + let mut defaults: IndexMap<String, PhpMixed> = { + let mut d = IndexMap::new(); + d.insert("ssl".to_string(), PhpMixed::Array( + ssl_defaults.into_iter().map(|(k, v)| (k, Box::new(v))).collect() + )); + d + }; + + if let Some(ssl_options) = options.get("ssl") { + if let Some(ssl_defaults_mixed) = defaults.get("ssl").cloned() { + let merged = array_replace_recursive( + match ssl_defaults_mixed { + PhpMixed::Array(a) => a.into_iter().map(|(k, v)| (k, *v)).collect(), + _ => IndexMap::new(), + }, + match ssl_options.clone() { + PhpMixed::Array(a) => a.into_iter().map(|(k, v)| (k, *v)).collect(), + _ => IndexMap::new(), + }, + ); + defaults.insert( + "ssl".to_string(), + PhpMixed::Array(merged.into_iter().map(|(k, v)| (k, Box::new(v))).collect()), + ); + } + } + + // Attempt to find a local cafile or throw an exception if none pre-set. + // The user may go download one if this occurs. + let ssl = defaults.get("ssl").and_then(|v| v.as_array()); + let has_cafile = ssl.as_ref().and_then(|a| a.get("cafile")).is_some(); + let has_capath = ssl.as_ref().and_then(|a| a.get("capath")).is_some(); + if !has_cafile && !has_capath { + let result = CaBundle::get_system_ca_root_bundle_path(logger); + if shirabe_php_shim::is_dir(&result) { + if let Some(PhpMixed::Array(ref mut ssl)) = defaults.get_mut("ssl") { + ssl.insert("capath".to_string(), Box::new(PhpMixed::String(result))); + } + } else { + if let Some(PhpMixed::Array(ref mut ssl)) = defaults.get_mut("ssl") { + ssl.insert("cafile".to_string(), Box::new(PhpMixed::String(result))); + } + } + } + + let cafile = defaults.get("ssl").and_then(|v| v.as_array()) + .and_then(|a| a.get("cafile")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(ref cafile) = cafile { + if !Filesystem::is_readable(cafile) || !CaBundle::validate_ca_file(cafile, logger) { + return Err(TransportException::new( + "The configured cafile was not valid or could not be read.".to_string(), + )); + } + } + + let capath = defaults.get("ssl").and_then(|v| v.as_array()) + .and_then(|a| a.get("capath")) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + if let Some(ref capath) = capath { + if !shirabe_php_shim::is_dir(capath) || !Filesystem::is_readable(capath) { + return Err(TransportException::new( + "The configured capath was not valid or could not be read.".to_string(), + )); + } + } + + // Disable TLS compression to prevent CRIME attacks where supported. + if let Some(PhpMixed::Array(ref mut ssl)) = defaults.get_mut("ssl") { + ssl.insert( + "disable_compression".to_string(), + Box::new(PhpMixed::Bool(true)), + ); + } + + Ok(defaults) + } + + /// A bug in PHP prevents the headers from correctly being sent when a content-type header is + /// present and NOT at the end of the array. This method fixes the array by moving the + /// content-type header to the end. + fn fix_http_header_field(header: &PhpMixed) -> Vec<String> { + let mut headers: Vec<String> = match header { + PhpMixed::String(s) => s.split("\r\n").map(|p| p.to_string()).collect(), + PhpMixed::List(list) => list + .iter() + .filter_map(|v| v.as_string()) + .map(|s| s.to_string()) + .collect(), + _ => vec![], + }; + uasort(&mut headers, |el, _| { + if stripos(el, "content-type") == Some(0) { + 1 + } else { + -1 + } + }); + headers + } +} |
