diff --git a/config/artifact/php-src.yml b/config/artifact/php-src.yml index 32bcb6cf..e304db9d 100644 --- a/config/artifact/php-src.yml +++ b/config/artifact/php-src.yml @@ -5,3 +5,7 @@ php-src: license: PHP-3.01 source: type: php-release + domain: 'https://www.php.net' + source-mirror: + type: php-release + domain: 'https://phpmirror.static-php.dev' diff --git a/config/downloader.php b/config/downloader.php index 48710a88..2d81a57e 100644 --- a/config/downloader.php +++ b/config/downloader.php @@ -10,6 +10,7 @@ use StaticPHP\Artifact\Downloader\Type\GitHubRelease; use StaticPHP\Artifact\Downloader\Type\GitHubTarball; use StaticPHP\Artifact\Downloader\Type\HostedPackageBin; use StaticPHP\Artifact\Downloader\Type\LocalDir; +use StaticPHP\Artifact\Downloader\Type\PECL; use StaticPHP\Artifact\Downloader\Type\PhpRelease; use StaticPHP\Artifact\Downloader\Type\PIE; use StaticPHP\Artifact\Downloader\Type\Url; @@ -24,6 +25,7 @@ return [ 'ghtagtar' => GitHubTarball::class, 'local' => LocalDir::class, 'pie' => PIE::class, + 'pecl' => PECL::class, 'url' => Url::class, 'php-release' => PhpRelease::class, 'hosted' => HostedPackageBin::class, diff --git a/config/pkg/ext/builtin-extensions.yml b/config/pkg/ext/builtin-extensions.yml index d12cd219..0149fe97 100644 --- a/config/pkg/ext/builtin-extensions.yml +++ b/config/pkg/ext/builtin-extensions.yml @@ -1,5 +1,19 @@ ext-bcmath: type: php-extension +ext-mbregex: + type: php-extension + depends: + - onig + - ext-mbstring + php-extension: + arg-type: custom + build-shared: false + build-static: true + display-name: mbstring +ext-mbstring: + type: php-extension + php-extension: + arg-type: custom ext-openssl: type: php-extension depends: @@ -10,6 +24,21 @@ ext-openssl: arg-type: custom arg-type@windows: with build-with-php: true +ext-phar: + type: php-extension + depends: + - zlib +ext-readline: + type: php-extension + depends: + - libedit + php-extension: + support: + Windows: wip + BSD: wip + arg-type: with-path + build-shared: false + build-static: true ext-zlib: type: php-extension depends: diff --git a/config/pkg/ext/ext-amqp.yml b/config/pkg/ext/ext-amqp.yml index 93756914..1c802360 100644 --- a/config/pkg/ext/ext-amqp.yml +++ b/config/pkg/ext/ext-amqp.yml @@ -2,10 +2,8 @@ ext-amqp: type: php-extension artifact: source: - type: url - url: 'https://pecl.php.net/get/amqp' - extract: php-src/ext/amqp - filename: amqp.tgz + type: pecl + name: amqp metadata: license-files: [LICENSE] license: PHP-3.01 diff --git a/config/pkg/ext/ext-apcu.yml b/config/pkg/ext/ext-apcu.yml index 289de301..331b04f7 100644 --- a/config/pkg/ext/ext-apcu.yml +++ b/config/pkg/ext/ext-apcu.yml @@ -2,10 +2,8 @@ ext-apcu: type: php-extension artifact: source: - type: url - url: 'https://pecl.php.net/get/APCu' - extract: php-src/ext/apcu - filename: apcu.tgz + type: pecl + name: APCu metadata: license-files: [LICENSE] license: PHP-3.01 diff --git a/config/pkg/ext/ext-ast.yml b/config/pkg/ext/ext-ast.yml new file mode 100644 index 00000000..0684959d --- /dev/null +++ b/config/pkg/ext/ext-ast.yml @@ -0,0 +1,6 @@ +ext-ast: + type: php-extension + artifact: + source: + type: pecl + name: ast diff --git a/config/pkg/ext/ext-mbregex.yml b/config/pkg/ext/ext-mbregex.yml deleted file mode 100644 index ae59f023..00000000 --- a/config/pkg/ext/ext-mbregex.yml +++ /dev/null @@ -1,10 +0,0 @@ -ext-mbregex: - type: php-extension - depends: - - onig - - ext-mbstring - php-extension: - arg-type: custom - build-shared: false - build-static: true - display-name: mbstring diff --git a/config/pkg/ext/ext-mbstring.yml b/config/pkg/ext/ext-mbstring.yml deleted file mode 100644 index 6583ca61..00000000 --- a/config/pkg/ext/ext-mbstring.yml +++ /dev/null @@ -1,4 +0,0 @@ -ext-mbstring: - type: php-extension - php-extension: - arg-type: custom diff --git a/config/pkg/ext/ext-phar.yml b/config/pkg/ext/ext-phar.yml deleted file mode 100644 index 3625d2c0..00000000 --- a/config/pkg/ext/ext-phar.yml +++ /dev/null @@ -1,4 +0,0 @@ -ext-phar: - type: php-extension - depends: - - zlib diff --git a/config/pkg/ext/ext-readline.yml b/config/pkg/ext/ext-readline.yml deleted file mode 100644 index 19b1886c..00000000 --- a/config/pkg/ext/ext-readline.yml +++ /dev/null @@ -1,11 +0,0 @@ -ext-readline: - type: php-extension - depends: - - libedit - php-extension: - support: - Windows: wip - BSD: wip - arg-type: with-path - build-shared: false - build-static: true diff --git a/config/pkg/lib/gmp.yml b/config/pkg/lib/gmp.yml index bdc13b55..c1469774 100644 --- a/config/pkg/lib/gmp.yml +++ b/config/pkg/lib/gmp.yml @@ -3,7 +3,7 @@ gmp: artifact: source: type: filelist - url: 'https://gmplib.org/download/gmp/' + url: 'https://ftp.gnu.org/gnu/gmp/' regex: '/href="(?gmp-(?[^"]+)\.tar\.xz)"/' source-mirror: type: url diff --git a/src/StaticPHP/Artifact/ArtifactCache.php b/src/StaticPHP/Artifact/ArtifactCache.php index 2cdd0d0a..cd9ad640 100644 --- a/src/StaticPHP/Artifact/ArtifactCache.php +++ b/src/StaticPHP/Artifact/ArtifactCache.php @@ -169,7 +169,7 @@ class ArtifactCache throw new SPCInternalException("Invalid lock type '{$lock_type}' for artifact {$artifact_name}"); } // save cache to file - file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT)); + file_put_contents($this->cache_file, json_encode($this->cache, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } /** @@ -287,7 +287,7 @@ class ArtifactCache */ public function save(): void { - file_put_contents($this->cache_file, json_encode($this->cache, JSON_PRETTY_PRINT)); + file_put_contents($this->cache_file, json_encode($this->cache, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } private function isObjectDownloaded(?array $object, bool $compare_hash = false): bool diff --git a/src/StaticPHP/Artifact/Downloader/Type/Git.php b/src/StaticPHP/Artifact/Downloader/Type/Git.php index 4a712005..1ee9da4d 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/Git.php +++ b/src/StaticPHP/Artifact/Downloader/Type/Git.php @@ -22,7 +22,7 @@ class Git implements DownloadTypeInterface, CheckUpdateInterface if (isset($config['rev'])) { default_shell()->executeGitClone($config['url'], $config['rev'], $path, $shallow, $config['submodules'] ?? null); $shell = PHP_OS_FAMILY === 'Windows' ? cmd(false) : shell(false); - $hash_result = $shell->execWithResult(SPC_GIT_EXEC . ' -C ' . escapeshellarg($path) . ' rev-parse HEAD'); + $hash_result = $shell->execWithResult(SPC_GIT_EXEC . ' -C ' . escapeshellarg($path) . ' rev-parse --short HEAD'); $hash = ($hash_result[0] === 0 && !empty($hash_result[1])) ? trim($hash_result[1][0]) : ''; $version = $hash !== '' ? "dev-{$config['rev']}+{$hash}" : "dev-{$config['rev']}"; return DownloadResult::git($name, $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class); @@ -80,7 +80,7 @@ class Git implements DownloadTypeInterface, CheckUpdateInterface if ($result[0] !== 0 || empty($result[1])) { throw new DownloaderException("Failed to ls-remote from {$config['url']}"); } - $new_hash = substr($result[1][0], 0, 40); + $new_hash = substr($result[1][0], 0, 7); $new_version = "dev-{$config['rev']}+{$new_hash}"; // Extract stored hash from "dev-{rev}+{hash}", null if bare mode or old format without hash $old_hash = ($old_version !== null && str_contains($old_version, '+')) ? substr(strrchr($old_version, '+'), 1) : null; diff --git a/src/StaticPHP/Artifact/Downloader/Type/PECL.php b/src/StaticPHP/Artifact/Downloader/Type/PECL.php new file mode 100644 index 00000000..0b14b05d --- /dev/null +++ b/src/StaticPHP/Artifact/Downloader/Type/PECL.php @@ -0,0 +1,79 @@ +VERSIONSTATE per release */ + private const string PECL_REST_URL = 'https://pecl.php.net/rest/r/%s/allreleases.xml'; + + public function checkUpdate(string $name, array $config, ?string $old_version, ArtifactDownloader $downloader): CheckUpdateResult + { + [, $version] = $this->fetchPECLInfo($name, $config, $downloader); + return new CheckUpdateResult( + old: $old_version, + new: $version, + needUpdate: $old_version === null || version_compare($version, $old_version, '>'), + ); + } + + public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult + { + [$filename, $version] = $this->fetchPECLInfo($name, $config, $downloader); + $url = self::PECL_BASE_URL . '/get/' . $filename; + $path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . $filename; + logger()->debug("Downloading {$name} from URL: {$url}"); + default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); + $extract = $config['extract'] ?? ('php-src/ext/' . $this->getExtractName($name)); + return DownloadResult::archive($filename, $config, $extract, version: $version, downloader: static::class); + } + + protected function fetchPECLInfo(string $name, array $config, ArtifactDownloader $downloader): array + { + $peclName = strtolower($config['name'] ?? $this->getExtractName($name)); + $url = sprintf(self::PECL_REST_URL, $peclName); + logger()->debug("Fetching PECL release list for {$name} from REST API"); + $xml = default_shell()->executeCurl($url, retries: $downloader->getRetry()); + if ($xml === false) { + throw new DownloaderException("Failed to fetch PECL release list for {$name}"); + } + // Match VERSIONSTATE + preg_match_all('/(?P[^<]+)<\/v>(?P[^<]+)<\/s><\/r>/', $xml, $matches); + if (empty($matches['version'])) { + throw new DownloaderException("Failed to parse PECL release list for {$name}"); + } + $versions = []; + logger()->debug('Matched ' . count($matches['version']) . " releases for {$name} from PECL"); + foreach ($matches['version'] as $i => $version) { + if ($matches['state'][$i] !== 'stable') { + continue; + } + $versions[$version] = $peclName . '-' . $version . '.tgz'; + } + if (empty($versions)) { + throw new DownloaderException("No stable releases found for {$name} on PECL"); + } + uksort($versions, 'version_compare'); + $filename = end($versions); + $version = array_key_last($versions); + return [$filename, $version, $versions]; + } + + /** + * Derive the lowercase PECL package / extract name from the artifact name. + * e.g. "ext-apcu" -> "apcu", "ext-ast" -> "ast" + */ + private function getExtractName(string $name): string + { + return strtolower(preg_replace('/^ext-/i', '', $name)); + } +} diff --git a/src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php b/src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php index 372c7f50..b1fad70e 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php +++ b/src/StaticPHP/Artifact/Downloader/Type/PhpRelease.php @@ -10,9 +10,15 @@ use StaticPHP\Exception\DownloaderException; class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpdateInterface { - public const string PHP_API = 'https://www.php.net/releases/index.php?json&version={version}'; + public const string DEFAULT_PHP_DOMAIN = 'https://www.php.net'; - public const string DOWNLOAD_URL = 'https://www.php.net/distributions/php-{version}.tar.xz'; + public const string API_URL = '/releases/index.php?json&version={version}'; + + public const string DOWNLOAD_URL = '/distributions/php-{version}.tar.xz'; + + public const string GIT_URL = 'https://github.com/php/php-src.git'; + + public const string GIT_REV = 'master'; private ?string $sha256 = ''; @@ -22,9 +28,9 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpda // Handle 'git' version to clone from php-src repository if ($phpver === 'git') { $this->sha256 = null; - return (new Git())->download($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $downloader); + return (new Git())->download($name, ['url' => self::GIT_URL, 'rev' => self::GIT_REV], $downloader); } - $info = $this->fetchPhpReleaseInfo($name, $downloader); + $info = $this->fetchPhpReleaseInfo($name, $config, $downloader); $version = $info['version']; foreach ($info['source'] as $source) { if (str_ends_with($source['filename'], '.tar.xz')) { @@ -36,7 +42,8 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpda if (!isset($filename)) { throw new DownloaderException("No suitable source tarball found for PHP version {$version}"); } - $url = str_replace('{version}', $version, self::DOWNLOAD_URL); + $url = $config['domain'] ?? self::DEFAULT_PHP_DOMAIN; + $url .= str_replace('{version}', $version, self::DOWNLOAD_URL); logger()->debug("Downloading PHP release {$version} from {$url}"); $path = DOWNLOAD_PATH . "/{$filename}"; default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); @@ -72,7 +79,7 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpda // git version: delegate to Git checkUpdate with master branch return (new Git())->checkUpdate($name, ['url' => 'https://github.com/php/php-src.git', 'rev' => 'master'], $old_version, $downloader); } - $info = $this->fetchPhpReleaseInfo($name, $downloader); + $info = $this->fetchPhpReleaseInfo($name, $config, $downloader); $new_version = $info['version']; return new CheckUpdateResult( old: $old_version, @@ -81,7 +88,7 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpda ); } - protected function fetchPhpReleaseInfo(string $name, ArtifactDownloader $downloader): array + protected function fetchPhpReleaseInfo(string $name, array $config, ArtifactDownloader $downloader): array { $phpver = $downloader->getOption('with-php', '8.4'); // Handle 'git' version to clone from php-src repository @@ -90,8 +97,13 @@ class PhpRelease implements DownloadTypeInterface, ValidatorInterface, CheckUpda throw new DownloaderException("Cannot fetch PHP release info for 'git' version."); } + $url = $config['domain'] ?? self::DEFAULT_PHP_DOMAIN; + $url .= self::API_URL; + $url = str_replace('{version}', $phpver, $url); + logger()->debug("Fetching PHP release info for version {$phpver} from {$url}"); + // Fetch PHP release info first - $info = default_shell()->executeCurl(str_replace('{version}', $phpver, self::PHP_API), retries: $downloader->getRetry()); + $info = default_shell()->executeCurl($url, retries: $downloader->getRetry()); if ($info === false) { throw new DownloaderException("Failed to fetch PHP release info for version {$phpver}"); } diff --git a/src/StaticPHP/Config/ConfigValidator.php b/src/StaticPHP/Config/ConfigValidator.php index fbf88321..f011482c 100644 --- a/src/StaticPHP/Config/ConfigValidator.php +++ b/src/StaticPHP/Config/ConfigValidator.php @@ -89,7 +89,8 @@ class ConfigValidator 'bitbuckettag' => [['repo'], ['extract']], 'local' => [['dirname'], ['extract']], 'pie' => [['repo'], ['extract']], - 'php-release' => [[], ['extract']], + 'pecl' => [['name'], ['extract']], + 'php-release' => [['domain'], ['extract']], 'custom' => [[], ['func']], ]; diff --git a/src/StaticPHP/Runtime/Shell/DefaultShell.php b/src/StaticPHP/Runtime/Shell/DefaultShell.php index 5b50d152..66dfb7ab 100644 --- a/src/StaticPHP/Runtime/Shell/DefaultShell.php +++ b/src/StaticPHP/Runtime/Shell/DefaultShell.php @@ -25,7 +25,7 @@ class DefaultShell extends Shell /** * Execute a cURL command to fetch data from a URL. */ - public function executeCurl(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0): false|string + public function executeCurl(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0, bool $compressed = false): false|string { foreach ($hooks as $hook) { $hook($method, $url, $headers); @@ -39,7 +39,8 @@ class DefaultShell extends Shell }; $header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers)); $retry_arg = $retries > 0 ? "--retry {$retries}" : ''; - $cmd = SPC_CURL_EXEC . " -sfSL {$retry_arg} {$method_arg} {$header_arg} {$url_arg}"; + $compressed_arg = $compressed ? '--compressed' : ''; + $cmd = SPC_CURL_EXEC . " -sfSL --max-time 3600 {$retry_arg} {$compressed_arg} {$method_arg} {$header_arg} {$url_arg}"; $this->logCommandInfo($cmd); $result = $this->passthru($cmd, capture_output: true, throw_on_error: false); @@ -72,7 +73,7 @@ class DefaultShell extends Shell $header_arg = implode(' ', array_map(fn ($v) => '"-H' . $v . '"', $headers)); $retry_arg = $retries > 0 ? "--retry {$retries}" : ''; $check = $this->console_putput ? '#' : 's'; - $cmd = clean_spaces(SPC_CURL_EXEC . " -{$check}fSL {$retry_arg} {$header_arg} -o {$path_arg} {$url_arg}"); + $cmd = clean_spaces(SPC_CURL_EXEC . " -{$check}fSL --max-time 3600 {$retry_arg} {$header_arg} -o {$path_arg} {$url_arg}"); $this->logCommandInfo($cmd); logger()->debug('[CURL DOWNLOAD] ' . $cmd); $this->passthru($cmd, $this->console_putput, capture_output: false, throw_on_error: true); @@ -93,7 +94,7 @@ class DefaultShell extends Shell $path_arg = escapeshellarg($path); $shallow_arg = $shallow ? '--depth 1 --single-branch' : ''; $submodules_arg = ($submodules === null && $shallow) ? '--recursive --shallow-submodules' : ($submodules === null ? '--recursive' : ''); - $cmd = clean_spaces("{$git} clone --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}"); + $cmd = clean_spaces("{$git} clone -c http.lowSpeedLimit=1 -c http.lowSpeedTime=3600 --config core.autocrlf=false --branch {$branch_arg} {$shallow_arg} {$submodules_arg} {$url_arg} {$path_arg}"); $this->logCommandInfo($cmd); logger()->debug("[GIT CLONE] {$cmd}"); $this->passthru($cmd, $this->console_putput);