diff --git a/src/Build/OfficialPhpSource.php b/src/Build/OfficialPhpSource.php index ec42014c..cb19bb00 100644 --- a/src/Build/OfficialPhpSource.php +++ b/src/Build/OfficialPhpSource.php @@ -2,17 +2,22 @@ namespace TypePhp\Build; +use TypePhp\Http\Downloader; + final class OfficialPhpSource { private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100'; private readonly \Closure $output; + private readonly Downloader $downloader; /** @param callable(string):void $output */ public function __construct( private readonly string $cacheDirectory, callable $output, + ?string $proxy = null, ) { $this->output = \Closure::fromCallable($output); + $this->downloader = new Downloader($proxy); } public static function defaultCacheDirectory(?string $home = null): string @@ -82,7 +87,7 @@ final class OfficialPhpSource } $data = json_decode( - $this->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))), + $this->downloader->downloadText(sprintf(self::RELEASE_API, rawurlencode($branch))), true, flags: JSON_THROW_ON_ERROR, ); @@ -131,7 +136,7 @@ final class OfficialPhpSource ($this->output)('Downloading PHP ' . $release['version'] . ' source from php.net'); $temporary = $archive . '.part-' . bin2hex(random_bytes(6)); try { - $this->downloadFile($release['url'], $temporary); + $this->downloader->downloadFile($release['url'], $temporary); if (hash_file('sha256', $temporary) !== $release['sha256']) { throw new \RuntimeException('PHP source archive SHA-256 verification failed'); } @@ -176,33 +181,6 @@ final class OfficialPhpSource 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 $command */ private function run(array $command): void { @@ -212,23 +190,6 @@ final class OfficialPhpSource } } - /** @param list $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 { if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { diff --git a/src/Build/SapiExtensionRequirements.php b/src/Build/SapiExtensionRequirements.php index 9ada9329..a30aa65d 100644 --- a/src/Build/SapiExtensionRequirements.php +++ b/src/Build/SapiExtensionRequirements.php @@ -44,14 +44,14 @@ final class SapiExtensionRequirements } } $extensions = []; - foreach (array_keys($manifests) as $manifest) { - $decoded = json_decode((string) file_get_contents($manifest), true); + foreach (array_keys($manifests) as $manifestPath) { + $decoded = json_decode((string) file_get_contents($manifestPath), true); if (!is_array($decoded)) { - throw new \RuntimeException("Invalid Composer manifest: {$manifest}"); + throw new \RuntimeException("Invalid Composer manifest: {$manifestPath}"); } $require = $decoded['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) { if (is_string($package) && str_starts_with(strtolower($package), 'ext-')) { diff --git a/src/Build/SapiPhpBuilder.php b/src/Build/SapiPhpBuilder.php index 77f3eb9e..ed42549f 100644 --- a/src/Build/SapiPhpBuilder.php +++ b/src/Build/SapiPhpBuilder.php @@ -18,6 +18,7 @@ final class SapiPhpBuilder public function __construct( private readonly string $phpxSourceDirectory, callable $output, + private readonly ?string $proxy = null, ) { $this->output = \Closure::fromCallable($output); } @@ -41,6 +42,7 @@ final class SapiPhpBuilder $source = (new OfficialPhpSource( OfficialPhpSource::defaultCacheDirectory(), $this->output, + $this->proxy, ))->prepare($phpVersion); $sourceVersion = OfficialPhpSource::version($source); $baseOptions = $this->sourceConfigureOptions($source); diff --git a/src/Build/SourcePipelineTrait.php b/src/Build/SourcePipelineTrait.php index 07404c93..16bdac2a 100644 --- a/src/Build/SourcePipelineTrait.php +++ b/src/Build/SourcePipelineTrait.php @@ -583,7 +583,7 @@ trait SourcePipelineTrait $phpDir = $this->getPhpDir(); if ($this->isBuildModeEmbed() && $platform instanceof Linux) { try { - $phpDir = (new LibPhpInstaller())->ensure($phpDir) ?? $phpDir; + $phpDir = (new LibPhpInstaller(proxy: $this->downloadProxy))->ensure($phpDir) ?? $phpDir; } catch (\Throwable $e) { $this->error('Unable to install libphp.so: ' . $e->getMessage()); } @@ -665,6 +665,7 @@ trait SourcePipelineTrait $runtime = (new SapiPhpBuilder( $this->getPhpxDir(), fn (string $message) => $this->output($message, 'lightBlue'), + $this->downloadProxy, ))->prepare( $this->phpVersion, $this->sapiTargets, diff --git a/src/CompilerBase.php b/src/CompilerBase.php index bcf0b634..7fec8c31 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -690,6 +690,7 @@ class CompilerBase implements PropertyAccessContext protected array $externalImportStubFiles = []; protected bool $enableProfiler = false; protected bool $noProgress = false; + protected ?string $downloadProxy = null; protected bool $forTest = false; protected Parser $parser; protected ?AstCache $astCache = null; diff --git a/src/Http/Downloader.php b/src/Http/Downloader.php new file mode 100644 index 00000000..0cd270d8 --- /dev/null +++ b/src/Http/Downloader.php @@ -0,0 +1,146 @@ +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} */ + 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 */ + 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 $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 $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; + } +} diff --git a/src/Installer/LibPhpInstaller.php b/src/Installer/LibPhpInstaller.php index 5bfda3a9..f9fd9196 100644 --- a/src/Installer/LibPhpInstaller.php +++ b/src/Installer/LibPhpInstaller.php @@ -2,15 +2,20 @@ namespace TypePhp\Installer; +use TypePhp\Http\Downloader; use TypePhp\Platform\Linux; final class LibPhpInstaller { private const string RELEASE_API = 'https://www.php.net/releases/index.php?json=1&version=%s&max=100'; 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 @@ -119,7 +124,7 @@ final class LibPhpInstaller 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); if (!is_array($data)) { 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']) { $this->console->write('Downloading ' . $release['url']); - $this->downloadFile($release['url'], $archive); + $this->downloader->downloadFile($release['url'], $archive); } if (hash_file('sha256', $archive) !== $release['sha256']) { throw new \RuntimeException('PHP source archive SHA-256 verification failed'); @@ -322,33 +327,6 @@ final class LibPhpInstaller 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 { $this->console->write('$ ' . implode(' ', array_map('escapeshellarg', $command))); diff --git a/src/Metadata/Constants.php b/src/Metadata/Constants.php index 45517c33..e5c70fb0 100644 --- a/src/Metadata/Constants.php +++ b/src/Metadata/Constants.php @@ -177,6 +177,11 @@ class Constants 'description' => 'PHP language version to accept (8.4 or 8.5; default: 8.5)', 'required' => false, ], + 'proxy' => [ + 'longPrefix' => 'proxy', + 'description' => 'Proxy URL used for file downloads (HTTP(S) or SOCKS)', + 'required' => false, + ], 'force' => [ 'prefix' => 'f', 'longPrefix' => 'force', diff --git a/src/Translator.php b/src/Translator.php index f8f63f16..9c9ae93b 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -362,6 +362,13 @@ class Translator extends Preprocessor { $path = null; 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] !== '-') { $path = $argv[$i]; break; @@ -443,6 +450,7 @@ class Translator extends Preprocessor ['--lto', 'Enable Link Time Optimization (-flto)'], ['--no-literal-strings', 'Disable literal string optimization'], ['--php-version ', 'Accepted PHP language version (8.4-8.5, default: 8.5)'], + ['--proxy ', 'Proxy URL used for file downloads'], ['--no-progress', 'Print one compilation line per file instead of a progress bar'], ['--no-console', 'Hide the console window (Windows GUI applications only)'], ['--sanitize ', 'Enable a sanitizer such as address or undefined'], @@ -473,6 +481,14 @@ class Translator extends Preprocessor { $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 // only the runtime source and link inputs change; argument parsing, // translation, compilation scheduling and diagnostics remain shared.