feat(compiler): add proxy support for file downloads

- Add downloadProxy property to CompilerBase with null default
- Register proxy command line option in Constants.php with description
- Create new Downloader class with proxy handling capabilities
- Integrate Downloader into LibPhpInstaller and OfficialPhpSource
- Remove old downloadText/downloadFile methods from installer classes
- Add proxy parameter to Installer and Builder constructors
- Update SapiPhpBuilder to pass proxy to OfficialPhpSource
- Modify Translator to parse and validate --proxy command line argument
- Skip proxy arguments during path detection in Translator
- Support both HTTP and SOCKS proxy protocols via curl and streams
master
韩天峰 2 weeks ago
parent 3b33d79109
commit 15e17230ac
  1. 53
      src/Build/OfficialPhpSource.php
  2. 8
      src/Build/SapiExtensionRequirements.php
  3. 2
      src/Build/SapiPhpBuilder.php
  4. 3
      src/Build/SourcePipelineTrait.php
  5. 1
      src/CompilerBase.php
  6. 146
      src/Http/Downloader.php
  7. 40
      src/Installer/LibPhpInstaller.php
  8. 5
      src/Metadata/Constants.php
  9. 16
      src/Translator.php

@ -2,17 +2,22 @@
namespace TypePhp\Build; namespace TypePhp\Build;
use TypePhp\Http\Downloader;
final class OfficialPhpSource final class OfficialPhpSource
{ {
private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100'; private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100';
private readonly \Closure $output; private readonly \Closure $output;
private readonly Downloader $downloader;
/** @param callable(string):void $output */ /** @param callable(string):void $output */
public function __construct( public function __construct(
private readonly string $cacheDirectory, private readonly string $cacheDirectory,
callable $output, callable $output,
?string $proxy = null,
) { ) {
$this->output = \Closure::fromCallable($output); $this->output = \Closure::fromCallable($output);
$this->downloader = new Downloader($proxy);
} }
public static function defaultCacheDirectory(?string $home = null): string public static function defaultCacheDirectory(?string $home = null): string
@ -82,7 +87,7 @@ final class OfficialPhpSource
} }
$data = json_decode( $data = json_decode(
$this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))), $this->downloader->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))),
true, true,
flags: JSON_THROW_ON_ERROR, flags: JSON_THROW_ON_ERROR,
); );
@ -131,7 +136,7 @@ final class OfficialPhpSource
($this->output)('Downloading PHP ' . $release['version'] . ' source from php.net'); ($this->output)('Downloading PHP ' . $release['version'] . ' source from php.net');
$temporary = $archive . '.part-' . bin2hex(random_bytes(6)); $temporary = $archive . '.part-' . bin2hex(random_bytes(6));
try { try {
$this->downloadFile($release['url'], $temporary); $this->downloader->downloadFile($release['url'], $temporary);
if (hash_file('sha256', $temporary) !== $release['sha256']) { if (hash_file('sha256', $temporary) !== $release['sha256']) {
throw new \RuntimeException('PHP source archive SHA-256 verification failed'); throw new \RuntimeException('PHP source archive SHA-256 verification failed');
} }
@ -176,33 +181,6 @@ final class OfficialPhpSource
return $match[1]; return $match[1];
} }
private function downloadText(string $url): string
{
$context = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => 'TypePHP/tpc']]);
$contents = @file_get_contents($url, false, $context);
if (is_string($contents)) {
return $contents;
}
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl === '') {
throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl");
}
return $this->capture([$curl, '--fail', '--location', '--retry', '3', $url]);
}
private function downloadFile(string $url, string $target): void
{
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl !== '') {
$this->run([$curl, '--fail', '--location', '--retry', '3', '--output', $target, $url]);
return;
}
$contents = $this->downloadText($url);
if (file_put_contents($target, $contents) === false) {
throw new \RuntimeException("Unable to write {$target}");
}
}
/** @param list<string> $command */ /** @param list<string> $command */
private function run(array $command): void private function run(array $command): void
{ {
@ -212,23 +190,6 @@ final class OfficialPhpSource
} }
} }
/** @param list<string> $command */
private function capture(array $command): string
{
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('Unable to run command: ' . implode(' ', $command));
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) {
throw new \RuntimeException(trim($stderr));
}
return $stdout;
}
private function mkdir(string $directory): void private function mkdir(string $directory): void
{ {
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {

@ -44,14 +44,14 @@ final class SapiExtensionRequirements
} }
} }
$extensions = []; $extensions = [];
foreach (array_keys($manifests) as $manifest) { foreach (array_keys($manifests) as $manifestPath) {
$decoded = json_decode((string) file_get_contents($manifest), true); $decoded = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($decoded)) { if (!is_array($decoded)) {
throw new \RuntimeException("Invalid Composer manifest: {$manifest}"); throw new \RuntimeException("Invalid Composer manifest: {$manifestPath}");
} }
$require = $decoded['require'] ?? []; $require = $decoded['require'] ?? [];
if (!is_array($require)) { if (!is_array($require)) {
throw new \RuntimeException("Composer `require` must be an object: {$manifest}"); throw new \RuntimeException("Composer `require` must be an object: {$manifestPath}");
} }
foreach (array_keys($require) as $package) { foreach (array_keys($require) as $package) {
if (is_string($package) && str_starts_with(strtolower($package), 'ext-')) { if (is_string($package) && str_starts_with(strtolower($package), 'ext-')) {

@ -18,6 +18,7 @@ final class SapiPhpBuilder
public function __construct( public function __construct(
private readonly string $phpxSourceDirectory, private readonly string $phpxSourceDirectory,
callable $output, callable $output,
private readonly ?string $proxy = null,
) { ) {
$this->output = \Closure::fromCallable($output); $this->output = \Closure::fromCallable($output);
} }
@ -41,6 +42,7 @@ final class SapiPhpBuilder
$source = (new OfficialPhpSource( $source = (new OfficialPhpSource(
OfficialPhpSource::defaultCacheDirectory(), OfficialPhpSource::defaultCacheDirectory(),
$this->output, $this->output,
$this->proxy,
))->prepare($phpVersion); ))->prepare($phpVersion);
$sourceVersion = OfficialPhpSource::version($source); $sourceVersion = OfficialPhpSource::version($source);
$baseOptions = $this->sourceConfigureOptions($source); $baseOptions = $this->sourceConfigureOptions($source);

@ -583,7 +583,7 @@ trait SourcePipelineTrait
$phpDir = $this->getPhpDir(); $phpDir = $this->getPhpDir();
if ($this->isBuildModeEmbed() && $platform instanceof Linux) { if ($this->isBuildModeEmbed() && $platform instanceof Linux) {
try { try {
$phpDir = (new LibPhpInstaller())->ensure($phpDir) ?? $phpDir; $phpDir = (new LibPhpInstaller(proxy: $this->downloadProxy))->ensure($phpDir) ?? $phpDir;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Unable to install libphp.so: ' . $e->getMessage()); $this->error('Unable to install libphp.so: ' . $e->getMessage());
} }
@ -665,6 +665,7 @@ trait SourcePipelineTrait
$runtime = (new SapiPhpBuilder( $runtime = (new SapiPhpBuilder(
$this->getPhpxDir(), $this->getPhpxDir(),
fn (string $message) => $this->output($message, 'lightBlue'), fn (string $message) => $this->output($message, 'lightBlue'),
$this->downloadProxy,
))->prepare( ))->prepare(
$this->phpVersion, $this->phpVersion,
$this->sapiTargets, $this->sapiTargets,

@ -690,6 +690,7 @@ class CompilerBase implements PropertyAccessContext
protected array $externalImportStubFiles = []; protected array $externalImportStubFiles = [];
protected bool $enableProfiler = false; protected bool $enableProfiler = false;
protected bool $noProgress = false; protected bool $noProgress = false;
protected ?string $downloadProxy = null;
protected bool $forTest = false; protected bool $forTest = false;
protected Parser $parser; protected Parser $parser;
protected ?AstCache $astCache = null; protected ?AstCache $astCache = null;

@ -0,0 +1,146 @@
<?php
namespace TypePhp\Http;
final class Downloader
{
private readonly ?string $proxy;
public function __construct(?string $proxy = null)
{
$proxy = $proxy === null ? null : trim($proxy);
if ($proxy === '') {
throw new \InvalidArgumentException('Proxy URL must not be empty');
}
$this->proxy = $proxy;
}
public function downloadText(string $url): string
{
$curl = $this->findCurl();
// curl has consistent support for HTTP(S) and SOCKS proxies. Keep the
// existing stream-first path when no explicit proxy was requested.
if ($this->proxy !== null && $curl !== null) {
return $this->capture($this->curlCommand($curl, $url));
}
$context = stream_context_create($this->streamContextOptions());
$contents = @file_get_contents($url, false, $context);
if (is_string($contents)) {
return $contents;
}
if ($curl === null) {
throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl");
}
return $this->capture($this->curlCommand($curl, $url));
}
public function downloadFile(string $url, string $target): void
{
$curl = $this->findCurl();
if ($curl !== null) {
$this->run($this->curlCommand($curl, $url, $target));
return;
}
$contents = $this->downloadText($url);
if (file_put_contents($target, $contents) === false) {
throw new \RuntimeException("Unable to write {$target}");
}
}
/** @return array{http: array<string, int|string|bool>} */
public function streamContextOptions(): array
{
$options = [
'timeout' => 30,
'user_agent' => 'TypePHP/tpc',
];
if ($this->proxy !== null) {
[$proxy, $authorization] = $this->streamProxy();
$options['proxy'] = $proxy;
$options['request_fulluri'] = true;
if ($authorization !== null) {
$options['header'] = 'Proxy-Authorization: Basic ' . $authorization;
}
}
return ['http' => $options];
}
/** @return list<string> */
public function curlCommand(string $curl, string $url, ?string $target = null): array
{
$command = [$curl, '--fail', '--location', '--retry', '3'];
if ($this->proxy !== null) {
$command[] = '--proxy';
$command[] = $this->proxy;
}
if ($target !== null) {
$command[] = '--output';
$command[] = $target;
}
$command[] = $url;
return $command;
}
/** @return array{string, string|null} */
private function streamProxy(): array
{
$proxy = $this->proxy;
if (!str_contains($proxy, '://')) {
$proxy = 'http://' . $proxy;
}
$parts = parse_url($proxy);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'tcp'], true) || $host === '') {
throw new \RuntimeException(
"Proxy {$this->proxy} requires curl; the PHP stream fallback supports only HTTP proxies",
);
}
$port = (int) ($parts['port'] ?? 80);
$connectionHost = str_contains($host, ':') ? '[' . $host . ']' : $host;
$authorization = null;
if (isset($parts['user'])) {
$authorization = base64_encode(
rawurldecode((string) $parts['user']) . ':' . rawurldecode((string) ($parts['pass'] ?? '')),
);
}
return ["tcp://{$connectionHost}:{$port}", $authorization];
}
private function findCurl(): ?string
{
$command = PHP_OS_FAMILY === 'Windows' ? 'where curl 2>NUL' : 'command -v curl 2>/dev/null';
$output = trim((string) shell_exec($command));
if ($output === '') {
return null;
}
return strtok($output, "\r\n") ?: null;
}
/** @param list<string> $command */
private function run(array $command): void
{
$process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes);
if (!is_resource($process) || proc_close($process) !== 0) {
throw new \RuntimeException('Command failed: ' . implode(' ', $command));
}
}
/** @param list<string> $command */
private function capture(array $command): string
{
$process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('Unable to run command: ' . implode(' ', $command));
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (proc_close($process) !== 0) {
throw new \RuntimeException(trim($stderr));
}
return $stdout;
}
}

@ -2,15 +2,20 @@
namespace TypePhp\Installer; namespace TypePhp\Installer;
use TypePhp\Http\Downloader;
use TypePhp\Platform\Linux; use TypePhp\Platform\Linux;
final class LibPhpInstaller final class LibPhpInstaller
{ {
private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100'; private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100';
private ?string $sourcePhpDir = null; private ?string $sourcePhpDir = null;
private readonly Downloader $downloader;
public function __construct(private readonly InteractiveConsole $console = new InteractiveConsole()) public function __construct(
{ private readonly InteractiveConsole $console = new InteractiveConsole(),
?string $proxy = null,
) {
$this->downloader = new Downloader($proxy);
} }
public function ensure(string $currentPhpDir): ?string public function ensure(string $currentPhpDir): ?string
@ -119,7 +124,7 @@ final class LibPhpInstaller
private function fetchReleaseList(string $branch): array private function fetchReleaseList(string $branch): array
{ {
$json = $this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))); $json = $this->downloader->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch)));
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data)) { if (!is_array($data)) {
throw new \RuntimeException('Invalid release list returned by PHP.net'); throw new \RuntimeException('Invalid release list returned by PHP.net');
@ -173,7 +178,7 @@ final class LibPhpInstaller
if (!is_file($archive) || hash_file('sha256', $archive) !== $release['sha256']) { if (!is_file($archive) || hash_file('sha256', $archive) !== $release['sha256']) {
$this->console->write('Downloading ' . $release['url']); $this->console->write('Downloading ' . $release['url']);
$this->downloadFile($release['url'], $archive); $this->downloader->downloadFile($release['url'], $archive);
} }
if (hash_file('sha256', $archive) !== $release['sha256']) { if (hash_file('sha256', $archive) !== $release['sha256']) {
throw new \RuntimeException('PHP source archive SHA-256 verification failed'); throw new \RuntimeException('PHP source archive SHA-256 verification failed');
@ -322,33 +327,6 @@ final class LibPhpInstaller
return null; return null;
} }
private function downloadText(string $url): string
{
$context = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => 'TypePHP/tpc']]);
$data = @file_get_contents($url, false, $context);
if ($data === false) {
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl !== '') {
return $this->capture([$curl, '--fail', '--location', '--retry', '3', $url]);
}
throw new \RuntimeException("Unable to download {$url}; enable allow_url_fopen or install curl");
}
return $data;
}
private function downloadFile(string $url, string $target): void
{
$curl = trim((string) shell_exec('command -v curl 2>/dev/null'));
if ($curl !== '') {
$this->run([$curl, '--fail', '--location', '--retry', '3', '--output', $target, $url]);
return;
}
$data = $this->downloadText($url);
if (file_put_contents($target, $data) === false) {
throw new \RuntimeException("Unable to write {$target}");
}
}
private function run(array $command, ?string $cwd = null): void private function run(array $command, ?string $cwd = null): void
{ {
$this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command))); $this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command)));

@ -177,6 +177,11 @@ class Constants
'description' => 'PHP language version to accept (8.4 or 8.5; default: 8.5)', 'description' => 'PHP language version to accept (8.4 or 8.5; default: 8.5)',
'required' => false, 'required' => false,
], ],
'proxy' => [
'longPrefix' => 'proxy',
'description' => 'Proxy URL used for file downloads (HTTP(S) or SOCKS)',
'required' => false,
],
'force' => [ 'force' => [
'prefix' => 'f', 'prefix' => 'f',
'longPrefix' => 'force', 'longPrefix' => 'force',

@ -362,6 +362,13 @@ class Translator extends Preprocessor
{ {
$path = null; $path = null;
for ($i = 1; $i < count($argv); $i++) { for ($i = 1; $i < count($argv); $i++) {
if ($argv[$i] === '--proxy') {
++$i;
continue;
}
if (str_starts_with($argv[$i], '--proxy=')) {
continue;
}
if ($argv[$i] !== '' && $argv[$i][0] !== '-') { if ($argv[$i] !== '' && $argv[$i][0] !== '-') {
$path = $argv[$i]; $path = $argv[$i];
break; break;
@ -443,6 +450,7 @@ class Translator extends Preprocessor
['--lto', 'Enable Link Time Optimization (-flto)'], ['--lto', 'Enable Link Time Optimization (-flto)'],
['--no-literal-strings', 'Disable literal string optimization'], ['--no-literal-strings', 'Disable literal string optimization'],
['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'], ['--php-version <ver>', 'Accepted PHP language version (8.4-8.5, default: 8.5)'],
['--proxy <url>', 'Proxy URL used for file downloads'],
['--no-progress', 'Print one compilation line per file instead of a progress bar'], ['--no-progress', 'Print one compilation line per file instead of a progress bar'],
['--no-console', 'Hide the console window (Windows GUI applications only)'], ['--no-console', 'Hide the console window (Windows GUI applications only)'],
['--sanitize <type>', 'Enable a sanitizer such as address or undefined'], ['--sanitize <type>', 'Enable a sanitizer such as address or undefined'],
@ -473,6 +481,14 @@ class Translator extends Preprocessor
{ {
$this->applyPhpVersionCommandLineArgument(); $this->applyPhpVersionCommandLineArgument();
if ($this->climate->arguments->defined('proxy')) {
$proxy = trim((string) $this->climate->arguments->get('proxy'));
if ($proxy === '') {
$this->error('Option --proxy requires a non-empty URL');
}
$this->downloadProxy = $proxy;
}
// The Nano syntax policy is platform-independent. On non-Windows hosts // The Nano syntax policy is platform-independent. On non-Windows hosts
// only the runtime source and link inputs change; argument parsing, // only the runtime source and link inputs change; argument parsing,
// translation, compilation scheduling and diagnostics remain shared. // translation, compilation scheduling and diagnostics remain shared.

Loading…
Cancel
Save