aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src/repository
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-16 10:22:49 +0900
committernsfisis <nsfisis@gmail.com>2026-05-16 10:22:49 +0900
commitf17eb98f1b73602fa87399cc04f0fbe2afd1f3f2 (patch)
tree76ccdbb5ec356abe00309e1ec44ec29e3ca45e3e /crates/shirabe/src/repository
parent7c58ca16cb5bc4e14ff5c8c192c67e8a47afeaa1 (diff)
downloadphp-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.rs329
-rw-r--r--crates/shirabe/src/repository/vcs/fossil_driver.rs311
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
+ }
+}