aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-31 00:07:36 +0900
committernsfisis <nsfisis@gmail.com>2026-08-31 00:07:36 +0900
commitdc1f030e3904677cd65eac0a90c1d850e0ad1bbf (patch)
tree36f5177d90ae34f1b0b28cae5d1a3075acb2f8bd
parenta38ed045e9981664d90c05ab43ab4ab6026eb31b (diff)
downloadphp-shirabe-dc1f030e3904677cd65eac0a90c1d850e0ad1bbf.tar.gz
php-shirabe-dc1f030e3904677cd65eac0a90c1d850e0ad1bbf.tar.zst
php-shirabe-dc1f030e3904677cd65eac0a90c1d850e0ad1bbf.zip
feat(plugin): serve HttpDownloader as a proxy stub
A downloader carries its own options, TLS defaults and request backends, and what it shares with the graph is the IO it collects authentication into and the config it reads. Plugin code writing `new HttpDownloader($io, $config)` therefore allocates a Rust-side entity of its own rather than a second downloader the graph knows nothing about, and the guard that shadowed the class in the worker is gone. The request surface is not served yet: get() and copy() have no wire representation for the Response they return, and the async surface resolves its promises with one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php86
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Util/HttpDownloader.php216
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs4
-rw-r--r--crates/shirabe/src/plugin/php_plugin_proxy.rs84
-rw-r--r--crates/shirabe/tests/plugin/e2e_http_downloader_test.rs77
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json17
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php94
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json24
-rw-r--r--crates/shirabe/tests/plugin/main.rs1
-rw-r--r--docs/dev/plugin-class-classification.md26
-rw-r--r--scripts/plugin-stub-generator/targets.list1
11 files changed, 536 insertions, 94 deletions
diff --git a/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php b/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php
deleted file mode 100644
index a2fad123..00000000
--- a/crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-// Generated by scripts/plugin-stub-generator; do not edit by hand.
-// Guard for Composer\Util\HttpDownloader.
-// The Rust side owns this class and the worker has no proxy for it, so this
-// declaration shadows the real one: the constants and the hierarchy stay, while
-// constructing it or calling anything on it raises an explicit error.
-
-namespace Composer\Util;
-
-use Composer\Config;
-use Composer\IO\IOInterface;
-
-class HttpDownloader
-{
- private const STATUS_QUEUED = 1;
- private const STATUS_STARTED = 2;
- private const STATUS_COMPLETED = 3;
- private const STATUS_FAILED = 4;
- private const STATUS_ABORTED = 5;
-
- public function __construct(IOInterface $io, Config $config, array $options = [], bool $disableTls = false)
- {
- \ShirabeUnsupportedClass::fail(self::class, '__construct');
- }
-
- public function get(string $url, array $options = [])
- {
- \ShirabeUnsupportedClass::fail(self::class, 'get');
- }
-
- public function add(string $url, array $options = [])
- {
- \ShirabeUnsupportedClass::fail(self::class, 'add');
- }
-
- public function copy(string $url, string $to, array $options = [])
- {
- \ShirabeUnsupportedClass::fail(self::class, 'copy');
- }
-
- public function addCopy(string $url, string $to, array $options = [])
- {
- \ShirabeUnsupportedClass::fail(self::class, 'addCopy');
- }
-
- public function getOptions()
- {
- \ShirabeUnsupportedClass::fail(self::class, 'getOptions');
- }
-
- public function setOptions(array $options)
- {
- \ShirabeUnsupportedClass::fail(self::class, 'setOptions');
- }
-
- public function wait(?int $index = null)
- {
- \ShirabeUnsupportedClass::fail(self::class, 'wait');
- }
-
- public function enableAsync(): void
- {
- \ShirabeUnsupportedClass::fail(self::class, 'enableAsync');
- }
-
- public function countActiveJobs(?int $index = null): int
- {
- \ShirabeUnsupportedClass::fail(self::class, 'countActiveJobs');
- }
-
- public static function outputWarnings(IOInterface $io, string $url, $data): void
- {
- \ShirabeUnsupportedClass::fail(self::class, 'outputWarnings');
- }
-
- public static function getExceptionHints(\Throwable $e): ?array
- {
- \ShirabeUnsupportedClass::fail(self::class, 'getExceptionHints');
- }
-
- public static function isCurlEnabled(): bool
- {
- \ShirabeUnsupportedClass::fail(self::class, 'isCurlEnabled');
- }
-}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Util/HttpDownloader.php b/crates/shirabe-php-rpc/php/stubs/Composer/Util/HttpDownloader.php
new file mode 100644
index 00000000..c29b1abb
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Util/HttpDownloader.php
@@ -0,0 +1,216 @@
+<?php
+
+// Generated by scripts/plugin-stub-generator; do not edit by hand.
+// Proxy stub for Composer\Util\HttpDownloader: the public surface forwards to the Rust-side entity over RPC.
+
+namespace Composer\Util;
+
+use Composer\Config;
+use Composer\IO\IOInterface;
+use Composer\Downloader\TransportException;
+use Composer\Pcre\Preg;
+use Composer\Composer;
+use Composer\Package\Version\VersionParser;
+use Composer\Semver\Constraint\Constraint;
+
+class HttpDownloader implements \ShirabeRustStub
+{
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ /**
+ * Binds a stub the registry built for an existing entity. Proxy instantiation bypasses
+ * the constructor, which belongs to plugin code building a new entity instead.
+ */
+ public function __shirabeBind(int $rhandle, int $epoch): void
+ {
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+
+ public function __clone()
+ {
+ // PHP has already shallow-copied this stub, so both copies would point at one
+ // entity and release it twice. The Rust side clones the entity instead, applying
+ // whatever __clone semantics the real class defines, and this copy rebinds to the
+ // fresh handle. Entities without clone semantics answer with an explicit error.
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust($this->__rhandle, '__shirabeClone', []);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ public function __get($name)
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, '__get', [$name]);
+ }
+
+ public function __set($name, $value): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, '__set', [$name, $value]);
+ }
+
+ public function __isset($name): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, '__isset', [$name]);
+ }
+
+ public function __unset($name): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, '__unset', [$name]);
+ }
+
+ public function __construct(IOInterface $io, Config $config, array $options = [], bool $disableTls = false)
+ {
+ [$this->__rhandle, $this->__epoch] = \ShirabeRpcRuntime::callRust(0, '__shirabeConstruct', [static::class, [$io, $config, $options, $disableTls]]);
+ \ShirabeRustObjectRegistry::adopt($this->__rhandle, $this);
+ }
+
+ private const STATUS_QUEUED = 1;
+ private const STATUS_STARTED = 2;
+ private const STATUS_COMPLETED = 3;
+ private const STATUS_FAILED = 4;
+ private const STATUS_ABORTED = 5;
+
+ public static function outputWarnings(IOInterface $io, string $url, $data): void
+ {
+ $cleanMessage = static function ($msg) use ($io) {
+ if (!$io->isDecorated()) {
+ $msg = Preg::replace('{'.chr(27).'\\[[;\d]*m}u', '', $msg);
+ }
+
+ return $msg;
+ };
+
+ // legacy warning/info keys
+ foreach (['warning', 'info'] as $type) {
+ if (empty($data[$type])) {
+ continue;
+ }
+
+ if (!empty($data[$type . '-versions'])) {
+ $versionParser = new VersionParser();
+ $constraint = $versionParser->parseConstraints($data[$type . '-versions']);
+ $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion()));
+ if (!$constraint->matches($composer)) {
+ continue;
+ }
+ }
+
+ $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($data[$type]).'</'.$type.'>');
+ }
+
+ // modern Composer 2.2+ format with support for multiple warning/info messages
+ foreach (['warnings', 'infos'] as $key) {
+ if (empty($data[$key])) {
+ continue;
+ }
+
+ $versionParser = new VersionParser();
+ foreach ($data[$key] as $spec) {
+ $type = substr($key, 0, -1);
+ $constraint = $versionParser->parseConstraints($spec['versions']);
+ $composer = new Constraint('==', $versionParser->normalize(Composer::getVersion()));
+ if (!$constraint->matches($composer)) {
+ continue;
+ }
+
+ $io->writeError('<'.$type.'>'.ucfirst($type).' from '.Url::sanitize($url).': '.$cleanMessage($spec['message']).'</'.$type.'>');
+ }
+ }
+ }
+
+ public static function getExceptionHints(\Throwable $e): ?array
+ {
+ if (!$e instanceof TransportException) {
+ return null;
+ }
+
+ if (
+ false !== strpos($e->getMessage(), 'Resolving timed out')
+ || false !== strpos($e->getMessage(), 'Could not resolve host')
+ ) {
+ Silencer::suppress();
+ $testConnectivity = file_get_contents('https://8.8.8.8', false, stream_context_create([
+ 'ssl' => ['verify_peer' => false],
+ 'http' => ['follow_location' => false, 'ignore_errors' => true],
+ ]));
+ Silencer::restore();
+ if (false !== $testConnectivity) {
+ return [
+ '<error>The following exception probably indicates you have misconfigured DNS resolver(s)</error>',
+ ];
+ }
+
+ return [
+ '<error>The following exception probably indicates you are offline or have misconfigured DNS resolver(s)</error>',
+ ];
+ }
+
+ return null;
+ }
+
+ public static function isCurlEnabled(): bool
+ {
+ return \extension_loaded('curl') && \function_exists('curl_multi_exec') && \function_exists('curl_multi_init');
+ }
+
+ public function get(string $url, array $options = [])
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'get', [$url, $options]);
+ }
+
+ public function add(string $url, array $options = [])
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'add', [$url, $options]);
+ }
+
+ public function copy(string $url, string $to, array $options = [])
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'copy', [$url, $to, $options]);
+ }
+
+ public function addCopy(string $url, string $to, array $options = [])
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'addCopy', [$url, $to, $options]);
+ }
+
+ public function getOptions()
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOptions', []);
+ }
+
+ public function setOptions(array $options)
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'setOptions', [$options]);
+ }
+
+ public function wait(?int $index = null)
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'wait', [$index]);
+ }
+
+ public function enableAsync(): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'enableAsync', []);
+ }
+
+ public function countActiveJobs(?int $index = null): int
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'countActiveJobs', [$index]);
+ }
+}
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index c898fb82..3731c691 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -1040,6 +1040,10 @@ const STUB_FILES: &[(&str, &str)] = &[
"Composer/Util/ProcessExecutor.php",
include_str!("../php/stubs/Composer/Util/ProcessExecutor.php"),
),
+ (
+ "Composer/Util/HttpDownloader.php",
+ include_str!("../php/stubs/Composer/Util/HttpDownloader.php"),
+ ),
];
/// Hand-written worker-side classes (two-world implementations with behavior of their own, not
diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs
index a1233391..491a320c 100644
--- a/crates/shirabe/src/plugin/php_plugin_proxy.rs
+++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs
@@ -59,6 +59,7 @@ enum RustEntity {
Operation(std::rc::Rc<AnyOperation>),
Plugin(std::rc::Rc<std::cell::RefCell<dyn PluginInterface>>),
ProcessExecutor(std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>>),
+ HttpDownloader(std::rc::Rc<std::cell::RefCell<crate::util::HttpDownloader>>),
}
/// The pointer identity backing R-table interning: the same shared instance must always cross
@@ -82,6 +83,9 @@ fn entity_ptr_id(entity: &RustEntity) -> usize {
RustEntity::Operation(operation) => std::rc::Rc::as_ptr(operation) as *const () as usize,
RustEntity::Plugin(plugin) => std::rc::Rc::as_ptr(plugin) as *const () as usize,
RustEntity::ProcessExecutor(process) => std::rc::Rc::as_ptr(process) as *const () as usize,
+ RustEntity::HttpDownloader(downloader) => {
+ std::rc::Rc::as_ptr(downloader) as *const () as usize
+ }
}
}
@@ -382,6 +386,9 @@ pub(crate) fn dispatch_r_table_method(
Some(RustEntity::ProcessExecutor(process)) => {
dispatch_process_executor_method(&process, method_name, args, out_params)
}
+ Some(RustEntity::HttpDownloader(downloader)) => {
+ dispatch_http_downloader_method(&downloader, method_name, args)
+ }
None => Err(runtime_throw(format!("unknown Rust handle {rhandle}"))),
}
}
@@ -416,6 +423,9 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT
&class, ctor_args, position,
)
};
+ let config_arg = |position: usize| {
+ arg::<std::rc::Rc<std::cell::RefCell<crate::config::Config>>>(&class, ctor_args, position)
+ };
let alias_package_arg =
|position: usize| -> Result<crate::package::AliasPackageHandle, PhpThrow> {
package_arg(position)?
@@ -527,6 +537,20 @@ pub(crate) fn construct_entity(args: &[PluginValue]) -> Result<PluginValue, PhpT
)));
return Ok(construction_result(rhandle));
}
+ // A downloader carries its own options, TLS defaults and request backends; what it
+ // shares with the graph is the IO it collects authentication into and the config it
+ // reads, both of which it receives as the proxies the plugin already holds.
+ "Composer\\Util\\HttpDownloader" => {
+ let rhandle = register_entity(RustEntity::HttpDownloader(std::rc::Rc::new(
+ std::cell::RefCell::new(crate::util::HttpDownloader::new(
+ io_arg(0)?,
+ config_arg(1)?,
+ arg_or::<IndexMap<String, PhpMixed>>(&class, ctor_args, 2, IndexMap::new())?,
+ arg_or::<bool>(&class, ctor_args, 3, false)?,
+ )),
+ )));
+ return Ok(construction_result(rhandle));
+ }
// TODO(plugin): the remaining proxied classes get a construction story on demand,
// driven by explicit errors from real plugins. Each one has to decide what a
// plugin-built instance means for the Rust-side graph, which is why none of them is
@@ -578,7 +602,8 @@ fn clone_entity(entity: &RustEntity) -> Result<PluginValue, PhpThrow> {
| RustEntity::EventDispatcher(_)
| RustEntity::Operation(_)
| RustEntity::Plugin(_)
- | RustEntity::ProcessExecutor(_) => {
+ | RustEntity::ProcessExecutor(_)
+ | RustEntity::HttpDownloader(_) => {
return Err(runtime_throw(
"cloning this Rust-side entity over RPC is not supported".to_string(),
));
@@ -1134,6 +1159,41 @@ fn dispatch_process_executor_method(
}
}
+/// Serves the `HttpDownloader` proxy stub, whether the downloader behind it belongs to the object
+/// graph or was built by plugin code writing `new HttpDownloader(...)`. Both run their requests
+/// out of the Rust process, which is what keeps the authentication a run collects, the TLS
+/// defaults and the parallel-request budget single-sourced across the boundary.
+fn dispatch_http_downloader_method(
+ downloader: &std::rc::Rc<std::cell::RefCell<crate::util::HttpDownloader>>,
+ method_name: &str,
+ args: &[PluginValue],
+) -> Result<PluginValue, PhpThrow> {
+ match method_name {
+ "getOptions" => Ok(PluginValue::from_php_mixed(&PhpMixed::Array(
+ downloader.borrow().get_options().clone(),
+ ))),
+ "setOptions" => {
+ let options = arg::<IndexMap<String, PhpMixed>>(method_name, args, 0)?;
+ downloader.borrow_mut().set_options(options);
+ Ok(PluginValue::Null)
+ }
+ // TODO(plugin): a `Composer\Util\Http\Response` has no representation on the wire, so
+ // the two synchronous request methods have nothing to answer with.
+ "get" | "copy" => Err(runtime_throw(format!(
+ "Shirabe does not support HttpDownloader::{method_name}() from a plugin yet"
+ ))),
+ // TODO(plugin,async): the async surface resolves its promises with a Response the wire
+ // cannot carry, and driving it needs a promise representation that crosses the boundary
+ // unresolved. Neither exists yet.
+ "add" | "addCopy" | "wait" | "enableAsync" | "countActiveJobs" => Err(runtime_throw(
+ format!("Shirabe does not support HttpDownloader::{method_name}() from a plugin yet"),
+ )),
+ other => Err(runtime_throw(format!(
+ "unknown HttpDownloader method `{other}`"
+ ))),
+ }
+}
+
/// Decodes the `string|non-empty-list<string>` a process executor takes as its command.
fn exec_command_arg(
method: &str,
@@ -1626,6 +1686,28 @@ impl FromPluginArg for std::rc::Rc<std::cell::RefCell<dyn IOInterface>> {
}
}
+/// Resolves a config argument back to the Rust-side entity its proxy stub stands for.
+impl FromPluginArg for std::rc::Rc<std::cell::RefCell<crate::config::Config>> {
+ fn from_arg(
+ method: &str,
+ position: usize,
+ value: Option<&PluginValue>,
+ ) -> Result<Self, PhpThrow> {
+ match value {
+ Some(PluginValue::RustHandle(handle)) => {
+ match R_TABLE.with(|table| table.borrow().get(&handle.rhandle).cloned()) {
+ Some(RustEntity::Config(config)) => Ok(config),
+ _ => Err(runtime_throw(format!(
+ "{method} expects a Config handle, got Rust handle {}",
+ handle.rhandle
+ ))),
+ }
+ }
+ other => Err(arg_throw(method, position, "a Config", other)),
+ }
+ }
+}
+
/// Resolves a process executor argument back to the Rust-side entity its proxy stub stands for.
impl FromPluginArg for std::rc::Rc<std::cell::RefCell<crate::util::ProcessExecutor>> {
fn from_arg(
diff --git a/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs
new file mode 100644
index 00000000..ecb0b34c
--- /dev/null
+++ b/crates/shirabe/tests/plugin/e2e_http_downloader_test.rs
@@ -0,0 +1,77 @@
+//! HttpDownloader E2E compatibility check: upstream Composer and Shirabe each install a fixture
+//! project whose plugin builds its own `HttpDownloader` and writes what every call on it reports
+//! to a trace file. Upstream has no test that drives a downloader from plugin code, so the whole
+//! fixture is Shirabe-authored (`fixtures/e2e-http-downloader/`) and nothing has to be fetched;
+//! the test skips only while the PHP runtime or the Composer checkout is missing.
+
+use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin};
+use crate::php_worker::{lock_php_worker, php_runtime_available};
+use std::path::{Path, PathBuf};
+use tempfile::TempDir;
+
+fn fixture_dir() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-http-downloader")
+}
+
+struct Run {
+ exit_code: i32,
+ trace: String,
+}
+
+/// Runs `install` in a fresh copy of the fixture and returns the exit code with the plugin's trace.
+fn install(program: &str, prefix_args: &[&str]) -> Run {
+ let work = TempDir::new().unwrap();
+ copy_dir(&fixture_dir(), work.path());
+ let project = work.path().join("project");
+ let output = std::process::Command::new(program)
+ .args(prefix_args)
+ .arg("install")
+ .current_dir(&project)
+ .env("COMPOSER_HOME", work.path().join("home"))
+ .env("COMPOSER_CACHE_DIR", work.path().join("cache"))
+ .env("COMPOSER_NO_INTERACTION", "1")
+ .env("COLUMNS", "120")
+ .env("LINES", "30")
+ .output()
+ .unwrap();
+ Run {
+ exit_code: output.status.code().unwrap_or(-1),
+ trace: std::fs::read_to_string(project.join("http-downloader-trace.txt"))
+ .unwrap_or_default(),
+ }
+}
+
+#[test]
+fn test_plugin_owned_http_downloader_matches_upstream_composer() {
+ if !php_runtime_available() {
+ return;
+ }
+ let Some(composer_bin) = upstream_composer_bin() else {
+ return;
+ };
+ let _worker = lock_php_worker();
+ let composer_bin = composer_bin.to_str().unwrap().to_string();
+
+ let upstream = install("php", &[composer_bin.as_str()]);
+ let shirabe = install(env!("CARGO_BIN_EXE_shirabe"), &[]);
+
+ assert_eq!(0, upstream.exit_code, "upstream install must succeed");
+ assert_eq!(upstream.exit_code, shirabe.exit_code);
+ assert_eq!(upstream.trace, shirabe.trace);
+
+ // Pinned as well as compared, so a run where neither side wrote a trace cannot pass. The
+ // second options line is the evidence that both worlds merge into one value rather than each
+ // holding its own copy of the map.
+ assert_eq!(
+ "\
+event=post-update-cmd
+class=\"Composer\\\\Util\\\\HttpDownloader\" instanceof=true
+options header=[\"X-Probe: 1\"]
+options merged=[\"X-Probe: 2\"]
+isCurlEnabled=true
+hints other=null transport=null
+outputWarnings=ok
+",
+ upstream.trace
+ );
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json
new file mode 100644
index 00000000..08eec6ce
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/composer.json
@@ -0,0 +1,17 @@
+{
+ "name": "shirabe-test/http-downloader-probe",
+ "version": "1.0.0",
+ "type": "composer-plugin",
+ "description": "Fixture plugin driving an HttpDownloader it constructs itself.",
+ "autoload": {
+ "psr-4": {
+ "ShirabeTest\\HttpDownloader\\": "src/"
+ }
+ },
+ "require": {
+ "composer-plugin-api": "^2.0"
+ },
+ "extra": {
+ "class": "ShirabeTest\\HttpDownloader\\Plugin"
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php
new file mode 100644
index 00000000..504c2108
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/plugin/src/Plugin.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace ShirabeTest\HttpDownloader;
+
+use Composer\Composer;
+use Composer\Downloader\TransportException;
+use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+use Composer\Script\Event;
+use Composer\Script\ScriptEvents;
+use Composer\Util\HttpDownloader;
+
+/**
+ * Drives an HttpDownloader the plugin constructs itself and appends what every call reports to
+ * http-downloader-trace.txt, so the surface can be compared line by line between implementations:
+ * the identity of the object a plugin gets, the options it merges, and the static helpers.
+ * Nothing here reaches the network — the trace has to be reproducible offline.
+ */
+class Plugin implements PluginInterface, EventSubscriberInterface
+{
+ /** @var IOInterface */
+ private $io;
+
+ public function activate(Composer $composer, IOInterface $io): void
+ {
+ $this->io = $io;
+ }
+
+ public function deactivate(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public function uninstall(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public static function getSubscribedEvents()
+ {
+ // Whether an install resolves or replays a lock file decides which of the two fires, so
+ // both are subscribed and the trace records the one that ran.
+ return [
+ ScriptEvents::POST_INSTALL_CMD => 'onPostCommand',
+ ScriptEvents::POST_UPDATE_CMD => 'onPostCommand',
+ ];
+ }
+
+ public function onPostCommand(Event $event): void
+ {
+ $downloader = new HttpDownloader($this->io, $event->getComposer()->getConfig());
+ $lines = ['event=' . $event->getName()];
+
+ $lines[] = 'class=' . json_encode(\get_class($downloader))
+ . ' instanceof=' . json_encode($downloader instanceof HttpDownloader);
+
+ // Only the key the plugin set is compared: the rest of the map is the TLS defaults, whose
+ // CA paths depend on the machine rather than on the implementation.
+ $downloader->setOptions(['http' => ['header' => ['X-Probe: 1']]]);
+ $options = $downloader->getOptions();
+ $lines[] = 'options header=' . json_encode($options['http']['header'] ?? null);
+ $downloader->setOptions(['http' => ['header' => ['X-Probe: 2']]]);
+ $lines[] = 'options merged=' . json_encode($downloader->getOptions()['http']['header'] ?? null);
+
+ $lines[] = 'isCurlEnabled=' . json_encode(HttpDownloader::isCurlEnabled());
+
+ // getExceptionHints() only inspects the exception it is handed; both arguments below stay
+ // clear of the branch that probes connectivity.
+ $lines[] = 'hints other=' . json_encode(HttpDownloader::getExceptionHints(new \RuntimeException('x')))
+ . ' transport=' . json_encode(HttpDownloader::getExceptionHints(new TransportException('plain', 400)));
+
+ // The version constraint cannot match, so this reaches Composer::getVersion() and the
+ // version parser without writing anything to the terminal.
+ $lines[] = 'outputWarnings=' . $this->describe(static function () use ($event): void {
+ HttpDownloader::outputWarnings(
+ $event->getIO(),
+ 'https://example.org/packages.json',
+ ['warning' => 'unreachable', 'warning-versions' => '^0.0.1']
+ );
+ });
+
+ file_put_contents('http-downloader-trace.txt', implode("\n", $lines) . "\n");
+ }
+
+ private function describe(callable $call): string
+ {
+ try {
+ $call();
+
+ return 'ok';
+ } catch (\Throwable $e) {
+ return \get_class($e) . ': ' . $e->getMessage();
+ }
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json
new file mode 100644
index 00000000..42318a10
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e-http-downloader/project/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "shirabe/e2e-http-downloader",
+ "description": "E2E fixture project: record what a plugin's own HttpDownloader reports.",
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../plugin",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "packagist.org": false
+ }
+ ],
+ "require": {
+ "shirabe-test/http-downloader-probe": "1.0.0"
+ },
+ "config": {
+ "allow-plugins": {
+ "shirabe-test/http-downloader-probe": true
+ }
+ }
+}
diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs
index 3ef3f647..c62499e5 100644
--- a/crates/shirabe/tests/plugin/main.rs
+++ b/crates/shirabe/tests/plugin/main.rs
@@ -9,6 +9,7 @@ mod alias_package_test;
mod e2e_command_provider_test;
mod e2e_exception_test;
mod e2e_extension_installer_test;
+mod e2e_http_downloader_test;
mod e2e_installer_test;
mod e2e_installers_test;
mod e2e_normalize_test;
diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md
index 516cf226..6f52bd62 100644
--- a/docs/dev/plugin-class-classification.md
+++ b/docs/dev/plugin-class-classification.md
@@ -399,9 +399,20 @@ can reach the async path on an instance of its own, since `enableAsync()`,
`wait()` and `countActiveJobs()` are all public (`@internal` is a docblock
note).
-`HttpDownloader` and `Loop` remain guarded, so `Composer::getLoop()` is still
-an explicit error and the graph's own executor is not reachable yet. Serving
-it needs only a `Loop` stub, since the executor's surface is already served.
+`HttpDownloader` is a proxy stub on the same terms and for the same reasons:
+its options, its TLS defaults and the authentication it collects into the
+run's IO are state the two worlds have to share, and a plugin-`new`ed one
+allocates a Rust-side entity rather than a second downloader the graph knows
+nothing about. What it does not serve is the request surface itself —
+`get()` and `copy()` have no wire representation for the
+`Composer\Util\Http\Response` they return, and
+`add()`/`addCopy()`/`wait()`/`enableAsync()`/`countActiveJobs()` resolve
+promises with one.
+
+`Loop` remains guarded, so `Composer::getLoop()` is still an explicit error
+and neither the graph's own executor nor its downloader is reachable through
+it. Serving it needs only a `Loop` stub, since both surfaces it hands out are
+already served.
### Dual instantiation
@@ -423,8 +434,9 @@ the entity and answers with its handle, and the plugin-`new`ed object is
then the same entity the graph sees. It is filled in per class, driven by
the explicit errors real plugins hit — today `Package`, `CompletePackage`,
the three alias packages, the five solver operations,
-`Composer\Util\Filesystem` (with or without its `ProcessExecutor` argument)
-and `Composer\Util\ProcessExecutor` can be built this way. Every other
+`Composer\Util\Filesystem` (with or without its `ProcessExecutor` argument),
+`Composer\Util\ProcessExecutor` and `Composer\Util\HttpDownloader` can be
+built this way. Every other
proxied class answers with an explicit error naming it.
`JsonFile` and `ArrayLoader` are still undecided, so the classes above stay
@@ -438,8 +450,8 @@ service can now be built. `Composer\Package\Archiver\ArchivableFilesFinder`
is `unsupported` for `new Filesystem` alone, and the VCS/auth belt is
`unsupported` for `new ProcessExecutor` alone; neither forks any state any
more. Promoting them means feeding the per-class construction stories back
-into the rule — and, for the belt, deciding `HttpDownloader` too, since
-those classes take one and no route to it exists yet.
+into the rule; the `HttpDownloader` those classes take as a constructor
+argument is one a plugin can now build.
### Package and CompletePackage
diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list
index 2c9609ce..22faf744 100644
--- a/scripts/plugin-stub-generator/targets.list
+++ b/scripts/plugin-stub-generator/targets.list
@@ -35,3 +35,4 @@ Composer\DependencyResolver\Operation\MarkAliasInstalledOperation
Composer\DependencyResolver\Operation\MarkAliasUninstalledOperation
Composer\Util\Filesystem
Composer\Util\ProcessExecutor
+Composer\Util\HttpDownloader