aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/src/downloader
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-16 13:47:39 +0900
committernsfisis <nsfisis@gmail.com>2026-05-16 13:47:39 +0900
commit94f6bd3f9d8347537f533d4edc414132601389d5 (patch)
tree16cba355d5b49a722f16a6415700bdcf33a91401 /crates/shirabe/src/downloader
parentcd7d993215e310629f34fc1b48322cd451949893 (diff)
downloadphp-shirabe-94f6bd3f9d8347537f533d4edc414132601389d5.tar.gz
php-shirabe-94f6bd3f9d8347537f533d4edc414132601389d5.tar.zst
php-shirabe-94f6bd3f9d8347537f533d4edc414132601389d5.zip
feat(port): port PathDownloader.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src/downloader')
-rw-r--r--crates/shirabe/src/downloader/path_downloader.rs508
1 files changed, 508 insertions, 0 deletions
diff --git a/crates/shirabe/src/downloader/path_downloader.rs b/crates/shirabe/src/downloader/path_downloader.rs
index 4886deb..2ad3312 100644
--- a/crates/shirabe/src/downloader/path_downloader.rs
+++ b/crates/shirabe/src/downloader/path_downloader.rs
@@ -1 +1,509 @@
//! ref: composer/src/Composer/Downloader/PathDownloader.php
+
+use anyhow::Result;
+use indexmap::IndexMap;
+use shirabe_external_packages::react::promise::promise_interface::PromiseInterface;
+use shirabe_external_packages::symfony::component::filesystem::exception::io_exception::IOException;
+use shirabe_external_packages::symfony::component::filesystem::filesystem::Filesystem as SymfonyFilesystem;
+use shirabe_php_shim::{
+ file_exists, function_exists, is_dir, realpath, PhpMixed, RuntimeException,
+ DIRECTORY_SEPARATOR, PHP_WINDOWS_VERSION_MAJOR, PHP_WINDOWS_VERSION_MINOR,
+};
+
+use crate::dependency_resolver::operation::install_operation::InstallOperation;
+use crate::dependency_resolver::operation::uninstall_operation::UninstallOperation;
+use crate::downloader::file_downloader::FileDownloader;
+use crate::downloader::vcs_capable_downloader_interface::VcsCapableDownloaderInterface;
+use crate::io::io_interface::IOInterface;
+use crate::package::archiver::archivable_files_finder::ArchivableFilesFinder;
+use crate::package::dumper::array_dumper::ArrayDumper;
+use crate::package::package_interface::PackageInterface;
+use crate::package::version::version_guesser::VersionGuesser;
+use crate::package::version::version_parser::VersionParser;
+use crate::util::filesystem::Filesystem;
+use crate::util::platform::Platform;
+
+#[derive(Debug)]
+pub struct PathDownloader {
+ pub(crate) inner: FileDownloader,
+}
+
+impl PathDownloader {
+ const STRATEGY_SYMLINK: i64 = 10;
+ const STRATEGY_MIRROR: i64 = 20;
+
+ pub fn download(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: String,
+ _prev_package: Option<&dyn PackageInterface>,
+ _output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ let path = Filesystem::trim_trailing_slash(&path);
+ let url = package.get_dist_url().ok_or_else(|| RuntimeException {
+ message: format!(
+ "The package {} has no dist url configured, cannot download.",
+ package.get_pretty_name()
+ ),
+ code: 0,
+ })?;
+ let real_url = realpath(&url);
+ if real_url.is_none()
+ || !file_exists(real_url.as_deref().unwrap_or(""))
+ || !is_dir(real_url.as_deref().unwrap_or(""))
+ {
+ return Err(RuntimeException {
+ message: format!(
+ "Source path \"{}\" is not found for package {}",
+ url,
+ package.get_name()
+ ),
+ code: 0,
+ }
+ .into());
+ }
+ let real_url = real_url.unwrap();
+
+ if realpath(&path).as_deref() == Some(&real_url) {
+ return Ok(shirabe_external_packages::react::promise::resolve(None));
+ }
+
+ if format!("{}{}", realpath(&path).unwrap_or_default(), DIRECTORY_SEPARATOR)
+ .starts_with(&format!("{}{}", real_url, DIRECTORY_SEPARATOR))
+ {
+ // IMPORTANT NOTICE: If you wish to change this, don't. You are wasting your time and ours.
+ //
+ // Please see https://github.com/composer/composer/pull/5974 and https://github.com/composer/composer/pull/6174
+ // for previous attempts that were shut down because they did not work well enough or introduced too many risks.
+ return Err(RuntimeException {
+ message: format!(
+ "Package {} cannot install to \"{}\" inside its source at \"{}\"",
+ package.get_name(),
+ realpath(&path).unwrap_or_default(),
+ real_url
+ ),
+ code: 0,
+ }
+ .into());
+ }
+
+ Ok(shirabe_external_packages::react::promise::resolve(None))
+ }
+
+ pub fn install(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: String,
+ output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ let path = Filesystem::trim_trailing_slash(&path);
+ let url = package.get_dist_url().ok_or_else(|| RuntimeException {
+ message: format!(
+ "The package {} has no dist url configured, cannot install.",
+ package.get_pretty_name()
+ ),
+ code: 0,
+ })?;
+ let real_url = realpath(&url).ok_or_else(|| RuntimeException {
+ message: format!("Failed to realpath {}", url),
+ code: 0,
+ })?;
+
+ if realpath(&path).as_deref() == Some(&real_url) {
+ if output {
+ let appendix = self.get_install_operation_appendix(package, &path)?;
+ self.inner.io.write_error(
+ PhpMixed::String(format!(
+ " - {}{}",
+ InstallOperation::format(package, false),
+ appendix
+ )),
+ true,
+ IOInterface::NORMAL,
+ );
+ }
+
+ return Ok(shirabe_external_packages::react::promise::resolve(None));
+ }
+
+ // Get the transport options with default values
+ let mut transport_options = package.get_transport_options();
+ transport_options
+ .entry("relative".to_string())
+ .or_insert(PhpMixed::Bool(true));
+
+ let (mut current_strategy, allowed_strategies) =
+ self.compute_allowed_strategies(&transport_options)?;
+
+ let symfony_filesystem = SymfonyFilesystem::new();
+ self.inner.filesystem.remove_directory(&path);
+
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!(" - {}: ", InstallOperation::format(package, false))),
+ false,
+ IOInterface::NORMAL,
+ );
+ }
+
+ let mut is_fallback = false;
+ if Self::STRATEGY_SYMLINK == current_strategy {
+ let symlink_result: Result<Result<(), IOException>> = (|| {
+ if Platform::is_windows() {
+ // Implement symlinks as NTFS junctions on Windows
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!("Junctioning from {}", url)),
+ false,
+ IOInterface::NORMAL,
+ );
+ }
+ Ok(self.inner.filesystem.junction(&real_url, &path))
+ } else {
+ let path = path.trim_end_matches('/').to_string();
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!("Symlinking from {}", url)),
+ false,
+ IOInterface::NORMAL,
+ );
+ }
+ if transport_options
+ .get("relative")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ let absolute_path = if !self.inner.filesystem.is_absolute_path(&path) {
+ format!(
+ "{}{}{}",
+ Platform::get_cwd(false),
+ DIRECTORY_SEPARATOR,
+ path
+ )
+ } else {
+ path.clone()
+ };
+ let shortest_path = self.inner.filesystem.find_shortest_path(
+ &absolute_path,
+ &real_url,
+ false,
+ true,
+ );
+ Ok(symfony_filesystem.symlink(&format!("{}/", shortest_path), &path))
+ } else {
+ Ok(symfony_filesystem.symlink(&format!("{}/", real_url), &path))
+ }
+ }
+ })();
+
+ match symlink_result? {
+ Ok(()) => {}
+ Err(_e) => {
+ if allowed_strategies.contains(&Self::STRATEGY_MIRROR) {
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String("".to_string()),
+ true,
+ IOInterface::NORMAL,
+ );
+ self.inner.io.write_error(
+ PhpMixed::String(
+ " <error>Symlink failed, fallback to use mirroring!</error>"
+ .to_string(),
+ ),
+ true,
+ IOInterface::NORMAL,
+ );
+ }
+ current_strategy = Self::STRATEGY_MIRROR;
+ is_fallback = true;
+ } else {
+ return Err(RuntimeException {
+ message: format!(
+ "Symlink from \"{}\" to \"{}\" failed!",
+ real_url, path
+ ),
+ code: 0,
+ }
+ .into());
+ }
+ }
+ }
+ }
+
+ // Fallback if symlink failed or if symlink is not allowed for the package
+ if Self::STRATEGY_MIRROR == current_strategy {
+ let real_url = self.inner.filesystem.normalize_path(&real_url);
+
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!(
+ "{}Mirroring from {}",
+ if is_fallback { " " } else { "" },
+ url
+ )),
+ false,
+ IOInterface::NORMAL,
+ );
+ }
+ let iterator = ArchivableFilesFinder::new(&real_url, vec![]);
+ symfony_filesystem.mirror(&real_url, &path, Some(&iterator));
+ }
+
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String("".to_string()),
+ true,
+ IOInterface::NORMAL,
+ );
+ }
+
+ Ok(shirabe_external_packages::react::promise::resolve(None))
+ }
+
+ pub fn remove(
+ &mut self,
+ package: &dyn PackageInterface,
+ path: String,
+ output: bool,
+ ) -> Result<Box<dyn PromiseInterface>> {
+ let path = Filesystem::trim_trailing_slash(&path);
+ // realpath() may resolve Windows junctions to the source path, so we'll check for a junction
+ // first to prevent a false positive when checking if the dist and install paths are the same.
+ // See https://bugs.php.net/bug.php?id=77639
+ //
+ // For junctions don't blindly rely on Filesystem::removeDirectory as it may be overzealous. If a
+ // process inadvertently locks the file the removal will fail, but it would fall back to recursive
+ // delete which is disastrous within a junction. So in that case we have no other real choice but
+ // to fail hard.
+ if Platform::is_windows() && self.inner.filesystem.is_junction(&path) {
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!(
+ " - {}, source is still present in {}",
+ UninstallOperation::format(package, false),
+ path
+ )),
+ true,
+ IOInterface::NORMAL,
+ );
+ }
+ if !self.inner.filesystem.remove_junction(&path) {
+ self.inner.io.write_error(
+ PhpMixed::String(format!(
+ " <warning>Could not remove junction at {} - is another process locking it?</warning>",
+ path
+ )),
+ true,
+ IOInterface::NORMAL,
+ );
+ return Err(RuntimeException {
+ message: format!(
+ "Could not reliably remove junction for package {}",
+ package.get_name()
+ ),
+ code: 0,
+ }
+ .into());
+ }
+
+ return Ok(shirabe_external_packages::react::promise::resolve(None));
+ }
+
+ let url = package.get_dist_url().ok_or_else(|| RuntimeException {
+ message: format!(
+ "The package {} has no dist url configured, cannot remove.",
+ package.get_pretty_name()
+ ),
+ code: 0,
+ })?;
+
+ // ensure that the source path (dist url) is not the same as the install path, which
+ // can happen when using custom installers, see https://github.com/composer/composer/pull/9116
+ // not using realpath here as we do not want to resolve the symlink to the original dist url
+ // it points to
+ let fs = Filesystem::new();
+ let abs_path = if fs.is_absolute_path(&path) {
+ path.clone()
+ } else {
+ format!("{}/{}", Platform::get_cwd(false), path)
+ };
+ let abs_dist_url = if fs.is_absolute_path(&url) {
+ url.clone()
+ } else {
+ format!("{}/{}", Platform::get_cwd(false), url)
+ };
+ if fs.normalize_path(&abs_path) == fs.normalize_path(&abs_dist_url) {
+ if output {
+ self.inner.io.write_error(
+ PhpMixed::String(format!(
+ " - {}, source is still present in {}",
+ UninstallOperation::format(package, false),
+ path
+ )),
+ true,
+ IOInterface::NORMAL,
+ );
+ }
+
+ return Ok(shirabe_external_packages::react::promise::resolve(None));
+ }
+
+ self.inner.remove(package, &path, output)
+ }
+
+ pub fn get_vcs_reference(
+ &self,
+ package: &dyn PackageInterface,
+ path: &str,
+ ) -> Option<String> {
+ let path = Filesystem::trim_trailing_slash(path);
+ let parser = VersionParser::new();
+ let guesser = VersionGuesser::new(
+ &self.inner.config,
+ &self.inner.process,
+ &parser,
+ Some(&*self.inner.io),
+ );
+ let dumper = ArrayDumper::new();
+
+ let package_config = dumper.dump(package);
+ let package_version = guesser.guess_version(&package_config, &path);
+ if let Some(version) = package_version {
+ return version
+ .get("commit")
+ .and_then(|v| v.as_string())
+ .map(|s| s.to_owned());
+ }
+
+ None
+ }
+
+ pub(crate) fn get_install_operation_appendix(
+ &self,
+ package: &dyn PackageInterface,
+ path: &str,
+ ) -> Result<String> {
+ let url = package.get_dist_url().ok_or_else(|| RuntimeException {
+ message: format!(
+ "The package {} has no dist url configured, cannot install.",
+ package.get_pretty_name()
+ ),
+ code: 0,
+ })?;
+ let real_url = realpath(&url).ok_or_else(|| RuntimeException {
+ message: format!("Failed to realpath {}", url),
+ code: 0,
+ })?;
+
+ if realpath(path).as_deref() == Some(&real_url) {
+ return Ok(": Source already present".to_string());
+ }
+
+ let (current_strategy, _) =
+ self.compute_allowed_strategies(&package.get_transport_options())?;
+
+ if current_strategy == Self::STRATEGY_SYMLINK {
+ if Platform::is_windows() {
+ return Ok(format!(
+ ": Junctioning from {}",
+ package.get_dist_url().unwrap_or_default()
+ ));
+ }
+
+ return Ok(format!(
+ ": Symlinking from {}",
+ package.get_dist_url().unwrap_or_default()
+ ));
+ }
+
+ Ok(format!(
+ ": Mirroring from {}",
+ package.get_dist_url().unwrap_or_default()
+ ))
+ }
+
+ fn compute_allowed_strategies(
+ &self,
+ transport_options: &IndexMap<String, PhpMixed>,
+ ) -> Result<(i64, Vec<i64>)> {
+ // When symlink transport option is null, both symlink and mirror are allowed
+ let mut current_strategy = Self::STRATEGY_SYMLINK;
+ let mut allowed_strategies = vec![Self::STRATEGY_SYMLINK, Self::STRATEGY_MIRROR];
+
+ let mirror_path_repos = Platform::get_env("COMPOSER_MIRROR_PATH_REPOS");
+ if mirror_path_repos.map_or(false, |v| !v.is_empty()) {
+ current_strategy = Self::STRATEGY_MIRROR;
+ }
+
+ let symlink_option = transport_options.get("symlink");
+
+ match symlink_option {
+ Some(PhpMixed::Bool(true)) => {
+ current_strategy = Self::STRATEGY_SYMLINK;
+ allowed_strategies = vec![Self::STRATEGY_SYMLINK];
+ }
+ Some(PhpMixed::Bool(false)) => {
+ current_strategy = Self::STRATEGY_MIRROR;
+ allowed_strategies = vec![Self::STRATEGY_MIRROR];
+ }
+ _ => {}
+ }
+
+ // Check we can use junctions safely if we are on Windows
+ if Platform::is_windows()
+ && Self::STRATEGY_SYMLINK == current_strategy
+ && !self.safe_junctions()
+ {
+ if !allowed_strategies.contains(&Self::STRATEGY_MIRROR) {
+ return Err(RuntimeException {
+ message: "You are on an old Windows / old PHP combo which does not allow Composer to use junctions/symlinks and this path repository has symlink:true in its options so copying is not allowed".to_string(),
+ code: 0,
+ }
+ .into());
+ }
+ current_strategy = Self::STRATEGY_MIRROR;
+ allowed_strategies = vec![Self::STRATEGY_MIRROR];
+ }
+
+ // Check we can use symlink() otherwise
+ if !Platform::is_windows()
+ && Self::STRATEGY_SYMLINK == current_strategy
+ && !function_exists("symlink")
+ {
+ if !allowed_strategies.contains(&Self::STRATEGY_MIRROR) {
+ return Err(RuntimeException {
+ message: "Your PHP has the symlink() function disabled which does not allow Composer to use symlinks and this path repository has symlink:true in its options so copying is not allowed".to_string(),
+ code: 0,
+ }
+ .into());
+ }
+ current_strategy = Self::STRATEGY_MIRROR;
+ allowed_strategies = vec![Self::STRATEGY_MIRROR];
+ }
+
+ Ok((current_strategy, allowed_strategies))
+ }
+
+ // Returns true if junctions can be created and safely used on Windows.
+ //
+ // A PHP bug makes junction detection fragile, leading to possible data loss when removing a
+ // package. See https://bugs.php.net/bug.php?id=77552
+ //
+ // For safety we require a minimum version of Windows 7, so we can call the system rmdir which
+ // will preserve target content if given a junction.
+ //
+ // The PHP bug was fixed in 7.2.16 and 7.3.3 (requires at least Windows 7).
+ fn safe_junctions(&self) -> bool {
+ // We need to call mklink, and rmdir on Windows 7 (version 6.1)
+ function_exists("proc_open")
+ && (PHP_WINDOWS_VERSION_MAJOR > 6
+ || (PHP_WINDOWS_VERSION_MAJOR == 6 && PHP_WINDOWS_VERSION_MINOR >= 1))
+ }
+}
+
+impl VcsCapableDownloaderInterface for PathDownloader {
+ fn get_vcs_reference(&self, package: &dyn PackageInterface, path: String) -> Option<String> {
+ PathDownloader::get_vcs_reference(self, package, &path)
+ }
+}