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 /crates/shirabe/src/repository | |
| 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>
Diffstat (limited to 'crates/shirabe/src/repository')
| -rw-r--r-- | crates/shirabe/src/repository/path_repository.rs | 329 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/vcs/fossil_driver.rs | 311 |
2 files changed, 640 insertions, 0 deletions
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 + } +} |
