diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index f19d5d6f..74d0fd4f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -45,6 +45,84 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + bundled-php-extension-tests: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: + - ubuntu-latest + php-versions: + - '8.1.33' + - '8.2.29' + - '8.3.23' + - '8.4.10' + - '8.5.0alpha2' + steps: + - name: "Purge built-in PHP version" + run: | + echo "libmemcached11 php* hhvm libhashkit2" | xargs -n 1 sudo apt-get purge --assume-yes || true + sudo apt-add-repository --remove ppa:ondrej/php -y + - name: Install platform dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + g++ gcc make autoconf libtool bison re2c pkg-config unzip \ + libcurl4-openssl-dev \ + liblmdb-dev \ + libdb-dev \ + libqdbm-dev \ + libenchant-2-dev \ + libexif-dev \ + libgd-dev \ + libpng-dev \ + libtiff-dev \ + libfreetype-dev \ + libfreetype6 \ + libfontconfig1-dev \ + libgmp-dev \ + libssl-dev \ + libsodium-dev \ + libxml2-dev \ + libonig-dev \ + libldap-dev \ + libedit-dev \ + libsnmp-dev \ + libtidy-dev \ + libxslt1-dev \ + libsasl2-dev \ + libpq-dev \ + libsqlite3-dev \ + libzip-dev + - name: "Set php-src download URL" + run: echo "php_src_download_url=https://www.php.net/distributions/php-${{ matrix.php-versions }}.tar.gz" >> $GITHUB_ENV + - name: "Set php-src download URL (8.5 pre-release)" + if: ${{ startsWith(matrix.php-versions, '8.5.') }} + run: echo "php_src_download_url=https://downloads.php.net/~edorian/php-${{ matrix.php-versions }}.tar.gz" >> $GITHUB_ENV + - name: "Install PHP ${{ matrix.php-versions }}" + run: | + mkdir -p /tmp/php + mkdir -p /tmp/php.ini.d + cd /tmp/php + echo "Downloading release from ${{ env.php_src_download_url }} ..." + wget -O php.tgz ${{ env.php_src_download_url }} + tar zxf php.tgz + rm php.tgz + ls -l + cd * + ls -l + ./buildconf --force + ./configure --disable-dom --disable-xml --disable-xmlreader --disable-xmlwriter --disable-json --with-openssl --with-config-file-scan-dir=/tmp/php.ini.d + make -j$(nproc) + sudo make install + cd $GITHUB_WORKSPACE + - uses: actions/checkout@v4 + - name: Composer Install + run: composer install --ignore-platform-reqs + - name: Run bundled PHP install test + run: sudo php test/install-bundled-php-exts.php php-config + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + behaviour-tests: runs-on: ${{ matrix.operating-system }} strategy: diff --git a/composer.json b/composer.json index b4c2c1e6..d002bcfe 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": "8.1.*||8.2.*||8.3.*||8.4.*||8.5.*", - "composer/composer": "^2.8.9", + "composer/composer": "^2.8.10", "composer/pcre": "^3.3.2", "composer/semver": "^3.4.3", "fidry/cpu-core-counter": "^1.2", diff --git a/composer.lock b/composer.lock index 7a4b00ad..3663e94c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3006380a459a925f6f7ab8c60088cf36", + "content-hash": "22b9d91ed0b0ad3f3ccd5a5afd5b1732", "packages": [ { "name": "composer/ca-bundle", @@ -157,16 +157,16 @@ }, { "name": "composer/composer", - "version": "2.8.9", + "version": "2.8.10", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "b4e6bff2db7ce756ddb77ecee958a0f41f42bd9d" + "reference": "53834f587d7ab2527eb237459d7b94d1fb9d4c5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/b4e6bff2db7ce756ddb77ecee958a0f41f42bd9d", - "reference": "b4e6bff2db7ce756ddb77ecee958a0f41f42bd9d", + "url": "https://api.github.com/repos/composer/composer/zipball/53834f587d7ab2527eb237459d7b94d1fb9d4c5a", + "reference": "53834f587d7ab2527eb237459d7b94d1fb9d4c5a", "shasum": "" }, "require": { @@ -251,7 +251,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.8.9" + "source": "https://github.com/composer/composer/tree/2.8.10" }, "funding": [ { @@ -267,7 +267,7 @@ "type": "tidelift" } ], - "time": "2025-05-13T12:01:37+00:00" + "time": "2025-07-10T17:08:33+00:00" }, { "name": "composer/metadata-minifier", diff --git a/src/Building/UnixBuild.php b/src/Building/UnixBuild.php index ec91385f..18958e8c 100644 --- a/src/Building/UnixBuild.php +++ b/src/Building/UnixBuild.php @@ -4,6 +4,7 @@ namespace Php\Pie\Building; +use Php\Pie\ComposerIntegration\BundledPhpExtensionsRepository; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\PhpizePath; @@ -15,8 +16,11 @@ use function count; use function file_exists; use function implode; +use function rename; use function sprintf; +use const DIRECTORY_SEPARATOR; + /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class UnixBuild implements Build { @@ -90,6 +94,25 @@ public function __invoke( return BinaryFile::fromFileWithSha256Checksum($expectedSoFile); } + private function renamesToConfigM4(DownloadedPackage $downloadedPackage, OutputInterface $output): void + { + $configM4 = $downloadedPackage->extractedSourcePath . DIRECTORY_SEPARATOR . 'config.m4'; + if (file_exists($configM4)) { + return; + } + + $output->writeln('config.m4 does not exist; checking alternatives', OutputInterface::VERBOSITY_VERY_VERBOSE); + foreach (['config0.m4', 'config9.m4'] as $alternateConfigM4) { + $fullPathToAlternate = $downloadedPackage->extractedSourcePath . DIRECTORY_SEPARATOR . $alternateConfigM4; + if (file_exists($fullPathToAlternate)) { + $output->writeln(sprintf('Renaming %s to config.m4', $alternateConfigM4), OutputInterface::VERBOSITY_VERY_VERBOSE); + rename($fullPathToAlternate, $configM4); + + return; + } + } + } + /** @param callable(SymfonyProcess::ERR|SymfonyProcess::OUT, string): void|null $outputCallback */ private function phpize( PhpizePath $phpize, @@ -103,6 +126,8 @@ private function phpize( $output->writeln('Running phpize step using: ' . implode(' ', $phpizeCommand) . ''); } + $this->renamesToConfigM4($downloadedPackage, $output); + Process::run( $phpizeCommand, $downloadedPackage->extractedSourcePath, @@ -150,6 +175,11 @@ private function make( $makeCommand[] = sprintf('-j%d', $targetPlatform->makeParallelJobs); } + $makeCommand = BundledPhpExtensionsRepository::augmentMakeCommandForPhpBundledExtensions( + $makeCommand, + $downloadedPackage, + ); + if ($output->isVerbose()) { $output->writeln('Running make step with: ' . implode(' ', $makeCommand) . ''); } diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 09e0b668..0fe3deb2 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -9,6 +9,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -88,6 +89,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $this->container, ); + } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { + $output->writeln(''); + $output->writeln('' . $bundledPhpExtensionRefusal->getMessage() . ''); + + return self::INVALID; } $output->writeln(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index fc659236..64d0f074 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -9,6 +9,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -90,6 +91,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $this->container, ); + } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { + $output->writeln(''); + $output->writeln('' . $bundledPhpExtensionRefusal->getMessage() . ''); + + return self::INVALID; } $output->writeln(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); diff --git a/src/Command/InfoCommand.php b/src/Command/InfoCommand.php index fa3d6593..d31c9a36 100644 --- a/src/Command/InfoCommand.php +++ b/src/Command/InfoCommand.php @@ -7,6 +7,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -87,6 +88,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $this->container, ); + } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { + $output->writeln(''); + $output->writeln('' . $bundledPhpExtensionRefusal->getMessage() . ''); + + return self::INVALID; } $output->writeln(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index a0a3359d..b78632ed 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -9,6 +9,7 @@ use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\DependencyResolver; use Php\Pie\DependencyResolver\InvalidPackageName; use Php\Pie\DependencyResolver\UnableToResolveRequirement; @@ -103,6 +104,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $this->container, ); + } catch (BundledPhpExtensionRefusal $bundledPhpExtensionRefusal) { + $output->writeln(''); + $output->writeln('' . $bundledPhpExtensionRefusal->getMessage() . ''); + + return self::INVALID; } $output->writeln(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); diff --git a/src/ComposerIntegration/BundledPhpExtensionsRepository.php b/src/ComposerIntegration/BundledPhpExtensionsRepository.php new file mode 100644 index 00000000..3c6a9f20 --- /dev/null +++ b/src/ComposerIntegration/BundledPhpExtensionsRepository.php @@ -0,0 +1,308 @@ +, + * os-families?: non-empty-list, + * type?: ExtensionType, + * priority?: int, + * }> + */ + private static array $bundledPhpExtensions = [ + ['name' => 'bcmath'], + ['name' => 'bz2'], + ['name' => 'calendar'], + ['name' => 'ctype'], + ['name' => 'curl'], + ['name' => 'dba'], + [ + 'name' => 'dom', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + [ + 'name' => 'enchant', + 'require' => ['php' => '>= 5.2.0'], + ], + ['name' => 'exif'], + [ + 'name' => 'ffi', + 'require' => ['php' => '>= 7.4.0'], + ], + // ['name' => 'gd'], // build failure - ext/gd/gd.c:79:11: fatal error: ft2build.h: No such file or directory + ['name' => 'gettext'], + ['name' => 'gmp'], + ['name' => 'iconv'], + [ + 'name' => 'intl', + 'require' => ['php' => '>= 5.3.0'], + ], + ['name' => 'ldap'], + ['name' => 'mbstring'], + [ + 'name' => 'mysqlnd', + 'require' => [ + 'php' => '>= 5.3.0', + 'ext-openssl' => '*', + ], + ], + [ + 'name' => 'mysqli', + 'priority' => 90, // must load after mysqlnd + 'require' => [ + 'php' => '>= 5.3.0', + /** + * Note: Whilst mysqli can be built without mysqlnd (you could + * specify `--with-mysqli=...`, we have to make installation + * with PIE practical at least to start with. We can look at + * improving this later, but for now something is better than + * nothing :) + */ + 'ext-mysqlnd' => '*', + ], + ], + [ + 'name' => 'opcache', + 'type' => ExtensionType::ZendExtension, + 'require' => ['php' => '>= 5.5.0'], + ], +// ['name' => 'openssl'], // Not building in CI + ['name' => 'pcntl'], + [ + 'name' => 'pdo', + 'require' => ['php' => '>= 5.1.0'], + ], + [ + 'name' => 'pdo_mysql', + 'require' => [ + 'php' => '>= 5.1.0', + 'ext-pdo' => '*', + ], + ], + [ + 'name' => 'pdo_pgsql', + 'require' => [ + 'php' => '>= 5.1.0', + 'ext-pdo' => '*', + ], + ], + [ + 'name' => 'pdo_sqlite', + 'require' => [ + 'php' => '>= 5.1.0', + 'ext-pdo' => '*', + ], + ], + ['name' => 'pgsql'], + ['name' => 'posix'], + ['name' => 'readline'], + ['name' => 'session'], + ['name' => 'shmop'], + [ + 'name' => 'simplexml', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + ['name' => 'snmp'], + [ + 'name' => 'soap', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + ['name' => 'sockets'], + [ + 'name' => 'sodium', + 'require' => ['php' => '>= 7.2.0'], + ], + [ + 'name' => 'sqlite3', + 'require' => ['php' => '>= 5.3.0'], + ], + ['name' => 'sysvmsg'], + ['name' => 'sysvsem'], + ['name' => 'sysvshm'], + ['name' => 'tidy'], + [ + 'name' => 'xml', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + [ + 'name' => 'xmlreader', + 'require' => [ + 'php' => '>= 5.1.0', + 'ext-libxml' => '*', + 'ext-dom' => '*', + ], + ], + [ + 'name' => 'xmlwriter', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + [ + 'name' => 'xsl', + 'require' => [ + 'php' => '>= 5.2.0', + 'ext-libxml' => '*', + ], + ], + [ + 'name' => 'zip', + 'require' => ['php' => '>= 5.2.0'], + ], + ['name' => 'zlib'], + ]; + + public static function forTargetPlatform(TargetPlatform $targetPlatform): self + { + $versionParser = new VersionParser(); + $phpVersion = $targetPlatform->phpBinaryPath->version(); + + return new self(array_map( + static function (array $extension) use ($versionParser, $phpVersion): Package { + if (! array_key_exists('require', $extension)) { + $extension['require'] = ['php' => $phpVersion]; + } + + $requireLinks = array_map( + static function (string $target, string $constraint) use ($extension, $versionParser): Link { + return new Link( + 'php/' . $extension['name'], + $target, + $versionParser->parseConstraints($constraint), + 'requires', + $constraint, + ); + }, + array_keys($extension['require']), + $extension['require'], + ); + + $package = new CompletePackage('php/' . $extension['name'], $phpVersion . '.0', $phpVersion); + $package->setType(($extension['type'] ?? ExtensionType::PhpModule)->value); + $package->setDistType('zip'); + $package->setRequires(array_combine( + array_map(static fn (Link $link) => $link->getTarget(), $requireLinks), + $requireLinks, + )); + $package->setDistUrl(sprintf('https://github.com/php/php-src/archive/refs/tags/php-%s.zip', $phpVersion)); + $package->setDistReference(sprintf('php-%s', $phpVersion)); + $phpExt = [ + 'extension-name' => $extension['name'], + 'build-path' => 'ext/' . $extension['name'], + ]; + + if (array_key_exists('os-families', $extension)) { + $phpExt['os-families'] = array_map( + static fn (OperatingSystemFamily $osFamily) => $osFamily->value, + $extension['os-families'], + ); + } + + if (array_key_exists('priority', $extension)) { + $phpExt['priority'] = $extension['priority']; + } + + $package->setPhpExt($phpExt); + + return $package; + }, + self::$bundledPhpExtensions, + )); + } + + private static function findRe2c(): string + { + try { + return Process::run(['which', 're2c']); + } catch (ProcessFailedException $processFailed) { + throw new RuntimeException('Unable to find re2c on the system', previous: $processFailed); + } + } + + /** + * @param list $makeCommand + * + * @return list + */ + public static function augmentMakeCommandForPhpBundledExtensions(array $makeCommand, DownloadedPackage $downloadedPackage): array + { + $extraCflags = []; + if ( + in_array($downloadedPackage->package->name(), [ + 'php/xmlreader', + 'php/dom', + ]) + ) { + $path = (string) realpath($downloadedPackage->extractedSourcePath . '/../..'); + if ($path !== '') { + $extraCflags[] = '-I' . $path; + } + } + + if ($downloadedPackage->package->name() === 'php/dom') { + $path = (string) realpath($downloadedPackage->extractedSourcePath . '/../../ext/lexbor'); + if ($path !== '') { + $extraCflags[] = '-I' . $path; + } + } + + if (count($extraCflags)) { + $makeCommand[] = 'EXTRA_CFLAGS=' . implode(' ', $extraCflags); + } + + if ( + in_array($downloadedPackage->package->name(), [ + 'php/pdo', + 'php/pdo_mysql', + 'php/pdo_pgsql', + 'php/pdo_sqlite', + ]) + ) { + $makeCommand[] = 'RE2C=' . self::findRe2c(); + } + + return $makeCommand; + } +} diff --git a/src/ComposerIntegration/PieComposerFactory.php b/src/ComposerIntegration/PieComposerFactory.php index ba9dcd28..b7c2bb05 100644 --- a/src/ComposerIntegration/PieComposerFactory.php +++ b/src/ComposerIntegration/PieComposerFactory.php @@ -63,6 +63,12 @@ public static function createPieComposer( true, ); + $composer + ->getRepositoryManager() + ->addRepository(BundledPhpExtensionsRepository::forTargetPlatform( + $composerRequest->targetPlatform, + )); + OverrideDownloadUrlInstallListener::selfRegister($composer, $io, $container, $composerRequest); RemoveUnrelatedInstallOperations::selfRegister($composer, $composerRequest); diff --git a/src/DependencyResolver/BundledPhpExtensionRefusal.php b/src/DependencyResolver/BundledPhpExtensionRefusal.php new file mode 100644 index 00000000..e780d109 --- /dev/null +++ b/src/DependencyResolver/BundledPhpExtensionRefusal.php @@ -0,0 +1,27 @@ +name(), + PHP_EOL, + PHP_EOL, + PHP_EOL, + PHP_EOL, + $package->name(), + )); + } +} diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php index 2e4d31e8..692a2009 100644 --- a/src/DependencyResolver/Package.php +++ b/src/DependencyResolver/Package.php @@ -163,6 +163,11 @@ public function extensionName(): ExtensionName return $this->extensionName; } + public function isBundledPhpExtension(): bool + { + return str_starts_with($this->name(), 'php/'); + } + public function name(): string { return $this->name; diff --git a/src/DependencyResolver/ResolveDependencyWithComposer.php b/src/DependencyResolver/ResolveDependencyWithComposer.php index 9e4fe603..2eb46309 100644 --- a/src/DependencyResolver/ResolveDependencyWithComposer.php +++ b/src/DependencyResolver/ResolveDependencyWithComposer.php @@ -12,14 +12,17 @@ use Php\Pie\ExtensionType; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Platform\ThreadSafetyMode; +use Symfony\Component\Console\Output\OutputInterface; use function in_array; use function preg_match; +use function sprintf; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ final class ResolveDependencyWithComposer implements DependencyResolver { public function __construct( + private readonly OutputInterface $output, private readonly QuieterConsoleIO $arrayCollectionIo, ) { } @@ -62,6 +65,7 @@ public function __invoke( $piePackage = Package::fromComposerCompletePackage($package); + $this->assertBuildProviderProvidersBundledExtensions($targetPlatform, $piePackage, $forceInstallPackageVersion); $this->assertCompatibleOsFamily($targetPlatform, $piePackage); $this->assertCompatibleThreadSafetyMode($targetPlatform->threadSafety, $piePackage); @@ -95,4 +99,48 @@ private function assertCompatibleOsFamily(TargetPlatform $targetPlatform, Packag ); } } + + private function assertBuildProviderProvidersBundledExtensions(TargetPlatform $targetPlatform, Package $piePackage, bool $forceInstallPackageVersion): void + { + if (! $piePackage->isBundledPhpExtension()) { + return; + } + + $buildProvider = $targetPlatform->phpBinaryPath->buildProvider(); + $identifiedBuildProvider = false; + $note = 'Note: '; + + if ($buildProvider === 'https://github.com/docker-library/php') { + $identifiedBuildProvider = true; + $this->output->writeln(sprintf( + '%sYou should probably use "docker-php-ext-install %s" instead', + $note, + $piePackage->extensionName()->name(), + )); + } + + if ($buildProvider === 'Debian') { + $identifiedBuildProvider = true; + $this->output->writeln(sprintf( + '%sYou should probably use "apt install php%s-%s" or "apt install php-%s" (or similar) instead', + $note, + $targetPlatform->phpBinaryPath->majorMinorVersion(), + $piePackage->extensionName()->name(), + $piePackage->extensionName()->name(), + )); + } + + if ($buildProvider === 'Remi\'s RPM repository #StandWithUkraine') { + $identifiedBuildProvider = true; + $this->output->writeln(sprintf( + '%sYou should probably use "dnf install php-%s" instead', + $note, + $piePackage->extensionName()->name(), + )); + } + + if ($identifiedBuildProvider && ! $forceInstallPackageVersion) { + throw BundledPhpExtensionRefusal::forPackage($piePackage); + } + } } diff --git a/src/Installing/InstallForPhpProject/FindMatchingPackages.php b/src/Installing/InstallForPhpProject/FindMatchingPackages.php index 8886ad4d..46239baf 100644 --- a/src/Installing/InstallForPhpProject/FindMatchingPackages.php +++ b/src/Installing/InstallForPhpProject/FindMatchingPackages.php @@ -8,6 +8,7 @@ use Composer\Repository\RepositoryInterface; use OutOfRangeException; +use function array_key_exists; use function array_merge; use function count; use function usort; @@ -33,7 +34,8 @@ public function for(Composer $pieComposer, string $searchTerm): array } usort($matches, static function (array $a, array $b): int { - return $b['downloads'] <=> $a['downloads']; + return (array_key_exists('downloads', $b) ? $b['downloads'] : 0) + <=> (array_key_exists('downloads', $a) ? $a['downloads'] : 0); }); return $matches; diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php index d303d9a8..c3f8e082 100644 --- a/src/Platform/InstalledPiePackages.php +++ b/src/Platform/InstalledPiePackages.php @@ -47,7 +47,12 @@ static function (BasePackage $basePackage): bool { array_map( /** @return non-empty-string */ static function (Package $package): string { - return $package->extensionName()->name(); + return match ($package->extensionName()->name()) { + 'ffi' => 'FFI', + 'opcache' => 'Zend OPcache', + 'simplexml' => 'SimpleXML', + default => $package->extensionName()->name(), + }; }, $composerInstalledPackages, ), diff --git a/src/Platform/TargetPhp/PhpBinaryPath.php b/src/Platform/TargetPhp/PhpBinaryPath.php index dcd5ee9b..a878046f 100644 --- a/src/Platform/TargetPhp/PhpBinaryPath.php +++ b/src/Platform/TargetPhp/PhpBinaryPath.php @@ -175,6 +175,25 @@ public function loadedIniConfigurationFile(): string|null return null; } + /** @return non-empty-string|null */ + public function buildProvider(): string|null + { + /** + * Newer versions of PHP will have a `PHP_BUILD_PROVIDER` constant + * defined - {@link https://github.com/php/php-src/pull/19157} + */ + if ( + preg_match('/Build Provider([ =>\t]*)(.*)/', $this->phpinfo(), $m) + && array_key_exists(2, $m) + && $m[2] !== '' + && $m[2] !== '(none)' + ) { + return $m[2]; + } + + return null; + } + /** * Returns a map where the key is the name of the extension and the value is the version ('0' if not defined) * @@ -250,7 +269,7 @@ public function version(): string $phpVersion = self::cleanWarningAndDeprecationsFromOutput(Process::run([ $this->phpBinaryPath, '-r', - 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION . "." . PHP_RELEASE_VERSION;', + 'echo PHP_VERSION;', ])); Assert::stringNotEmpty($phpVersion, 'Could not determine PHP version'); diff --git a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php index 68db5e87..4c7ef9f4 100644 --- a/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php +++ b/src/SelfManage/Verify/FallbackVerificationUsingOpenSsl.php @@ -100,7 +100,6 @@ private function assertCertificateSignedByTrustedRoot(Attestation $attestation): { $attestationCertificateInfo = openssl_x509_parse($attestation->certificate); - // @todo process in place to make sure this gets updated frequently enough: gh attestation trusted-root > resources/trusted-root.jsonl $trustedRootJsonLines = explode("\n", trim(file_get_contents($this->trustedRootFilePath))); /** diff --git a/test/install-bundled-php-exts.php b/test/install-bundled-php-exts.php new file mode 100644 index 00000000..b37b64d2 --- /dev/null +++ b/test/install-bundled-php-exts.php @@ -0,0 +1,58 @@ +' . PHP_EOL; + exit(1); +} + +$phpBinaryPath = PhpBinaryPath::fromPhpConfigExecutable($phpConfigPath); + +$packageNames = array_map( + static fn (PackageInterface $package): string => $package->getName(), + BundledPhpExtensionsRepository::forTargetPlatform( + TargetPlatform::fromPhpBinaryPath( + $phpBinaryPath, + null, + ), + ) + ->getPackages(), +); + +$anyFailures = false; + +foreach ($packageNames as $packageName) { + $cmd = [ + 'sudo', + 'bin/pie', + 'install', + $packageName . ':@dev', + '--with-php-config=' . $phpBinaryPath->phpConfigPath(), + ]; + + echo ' - ' . implode(' ', $cmd) . PHP_EOL; + + try { + Process::run($cmd, timeout: null); + } catch (Throwable $e) { + echo $e->__toString() . PHP_EOL; + $anyFailures = true; + } +} + +echo Process::run(['bin/pie', 'show', '--with-php-config=' . $phpBinaryPath->phpConfigPath()]); + +if ($anyFailures) { + exit(1); +} diff --git a/test/unit/ComposerIntegration/BundledPhpExtensionsRepositoryTest.php b/test/unit/ComposerIntegration/BundledPhpExtensionsRepositoryTest.php new file mode 100644 index 00000000..d85678fb --- /dev/null +++ b/test/unit/ComposerIntegration/BundledPhpExtensionsRepositoryTest.php @@ -0,0 +1,203 @@ + */ + public static function bundledRepositoryPackageNames(): array + { + return [ + ['php/bcmath'], + ['php/bz2'], + ['php/calendar'], + ['php/ctype'], + ['php/curl'], + ['php/dba'], + ['php/dom'], + ['php/enchant'], + ['php/exif'], + ['php/ffi'], + ['php/gettext'], + ['php/gmp'], + ['php/iconv'], + ['php/intl'], + ['php/ldap'], + ['php/mbstring'], + ['php/mysqlnd'], + ['php/mysqli'], + ['php/opcache'], + ['php/pcntl'], + ['php/pdo'], + ['php/pdo_mysql'], + ['php/pdo_pgsql'], + ['php/pdo_sqlite'], + ['php/pgsql'], + ['php/posix'], + ['php/readline'], + ['php/session'], + ['php/shmop'], + ['php/simplexml'], + ['php/snmp'], + ['php/soap'], + ['php/sockets'], + ['php/sodium'], + ['php/sqlite3'], + ['php/sysvmsg'], + ['php/sysvsem'], + ['php/sysvshm'], + ['php/tidy'], + ['php/xml'], + ['php/xmlreader'], + ['php/xmlwriter'], + ['php/xsl'], + ['php/zip'], + ['php/zlib'], + ]; + } + + #[DataProvider('bundledRepositoryPackageNames')] + public function testBundledRepository(string $packageName): void + { + $phpBinary = $this->createMock(PhpBinaryPath::class); + $phpBinary->expects(self::once()) + ->method('version') + ->willReturn('8.1.0'); + + $repository = BundledPhpExtensionsRepository::forTargetPlatform( + new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinary, + Architecture::x86_64, + ThreadSafetyMode::NonThreadSafe, + 1, + null, + ), + ); + + $package = $repository->findPackage($packageName, '8.1.0'); + self::assertNotNull($package); + self::assertSame($packageName, $package->getName()); + self::assertSame('8.1.0', $package->getPrettyVersion()); + } + + public function testMakeCommandForXmlReader(): void + { + $phpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_bundled_', true); + $xmlReaderPath = $phpPath . DIRECTORY_SEPARATOR . 'ext' . DIRECTORY_SEPARATOR . 'xmlreader'; + mkdir($xmlReaderPath, recursive: true); + + self::assertEquals( + ['EXTRA_CFLAGS=-I' . realpath($phpPath)], + BundledPhpExtensionsRepository::augmentMakeCommandForPhpBundledExtensions( + [], + DownloadedPackage::fromPackageAndExtractedPath( + new Package( + $this->createMock(CompletePackageInterface::class), + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('xmlreader'), + 'php/xmlreader', + '1.2.3', + null, + ), + realpath($xmlReaderPath), + ), + ), + ); + } + + public function testMakeCommandForDom(): void + { + $phpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_bundled_', true); + $domPath = $phpPath . DIRECTORY_SEPARATOR . 'ext' . DIRECTORY_SEPARATOR . 'dom'; + $lexborPath = $phpPath . DIRECTORY_SEPARATOR . 'ext' . DIRECTORY_SEPARATOR . 'lexbor'; + mkdir($domPath, recursive: true); + mkdir($lexborPath, recursive: true); + + self::assertEquals( + ['EXTRA_CFLAGS=-I' . realpath($phpPath) . ' -I' . realpath($lexborPath)], + BundledPhpExtensionsRepository::augmentMakeCommandForPhpBundledExtensions( + [], + DownloadedPackage::fromPackageAndExtractedPath( + new Package( + $this->createMock(CompletePackageInterface::class), + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('dom'), + 'php/dom', + '1.2.3', + null, + ), + realpath($domPath), + ), + ), + ); + } + + /** @return list */ + public static function dependantsOnRe2c(): array + { + return [ + ['pdo'], + ['pdo_mysql'], + ['pdo_pgsql'], + ['pdo_sqlite'], + ]; + } + + #[DataProvider('dependantsOnRe2c')] + public function testMakeCommandForRe2cDependants(string $extensionName): void + { + try { + $re2cPath = Process::run(['which', 're2c']); + } catch (ProcessFailedException) { + self::markTestSkipped('re2c not installed'); + } + + self::assertEquals( + ['RE2C=' . $re2cPath], + BundledPhpExtensionsRepository::augmentMakeCommandForPhpBundledExtensions( + [], + DownloadedPackage::fromPackageAndExtractedPath( + new Package( + $this->createMock(CompletePackageInterface::class), + ExtensionType::PhpModule, + ExtensionName::normaliseFromString($extensionName), + 'php/' . $extensionName, + '1.2.3', + null, + ), + '/path/to/ext', + ), + ), + ); + } +} diff --git a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php index 18680bea..a8eb2a4c 100644 --- a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -30,6 +30,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Output\OutputInterface; #[CoversClass(ResolveDependencyWithComposer::class)] final class ResolveDependencyWithComposerTest extends TestCase @@ -80,6 +81,7 @@ public function testPackageThatCanBeResolved(): void ); $package = (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))($this->composer, $targetPlatform, new RequestedPackageAndVersion('asgrim/example-pie-extension', '^1.0'), false); @@ -123,6 +125,7 @@ public function testPackageThatCannotBeResolvedThrowsException(array $platformOv $this->expectException(UnableToResolveRequirement::class); (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -161,6 +164,7 @@ public function testUnresolvedPackageCanBeInstalledWithForceOption(array $platfo $this->expectException(UnableToResolveRequirement::class); $package = (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -212,6 +216,7 @@ public function testZtsOnlyPackageCannotBeInstalledOnNtsSystem(): void $this->expectException(IncompatibleThreadSafetyMode::class); $this->expectExceptionMessage('This extension does not support being installed on a non-Thread Safe PHP installation'); (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -260,6 +265,7 @@ public function testNtsOnlyPackageCannotBeInstalledOnZtsSystem(): void $this->expectException(IncompatibleThreadSafetyMode::class); $this->expectExceptionMessage('This extension does not support being installed on a Thread Safe PHP installation'); (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -308,6 +314,7 @@ public function testExtensionCanOnlyBeInstalledIfOsFamilyIsCompatible(): void $this->expectException(IncompatibleOperatingSystemFamily::class); $this->expectExceptionMessage('This extension does not support the "linux" operating system family. It is compatible with the following families: "solaris", "darwin"'); (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -356,6 +363,7 @@ public function testExtensionCanOnlyBeInstalledIfOsFamilyIsNotInCompatible(): vo $this->expectException(IncompatibleOperatingSystemFamily::class); $this->expectExceptionMessage('This extension does not support the "darwin" operating system family. It is incompatible with the following families: "darwin", "solaris".'); (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))( $this->composer, @@ -392,6 +400,7 @@ public function testPackageThatCanBeResolvedWithReplaceConflict(): void ); $package = (new ResolveDependencyWithComposer( + $this->createMock(OutputInterface::class), $this->createMock(QuieterConsoleIO::class), ))($this->composer, $targetPlatform, new RequestedPackageAndVersion('asgrim/example-pie-extension', '^1.0'), false); diff --git a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php index 6b8d8c92..4e97f9cb 100644 --- a/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php +++ b/test/unit/Platform/TargetPhp/PhpBinaryPathTest.php @@ -45,7 +45,7 @@ use const PHP_MAJOR_VERSION; use const PHP_MINOR_VERSION; use const PHP_OS_FAMILY; -use const PHP_RELEASE_VERSION; +use const PHP_VERSION; #[CoversClass(PhpBinaryPath::class)] final class PhpBinaryPathTest extends TestCase @@ -101,7 +101,7 @@ public function testVersionFromCurrentProcess(): void $phpBinary = PhpBinaryPath::fromCurrentProcess(); self::assertSame( - sprintf('%s.%s.%s', PHP_MAJOR_VERSION, PHP_MINOR_VERSION, PHP_RELEASE_VERSION), + PHP_VERSION, $phpBinary->version(), ); self::assertNull($phpBinary->phpConfigPath()); @@ -342,4 +342,26 @@ public function testAssertExtensionFailsWhenNotLoaded(): void 'hopefully_this_extension_name_is_not_real_otherwise_this_test_will_fail', )); } + + public function testBuildProviderWhenConfigured(): void + { + $phpBinary = $this->createPartialMock(PhpBinaryPath::class, ['phpinfo']); + + $phpBinary->expects(self::once()) + ->method('phpinfo') + ->willReturn('Build Provider => My build provider'); + + self::assertSame('My build provider', $phpBinary->buildProvider()); + } + + public function testBuildProviderNullWhenNotConfigured(): void + { + $phpBinary = $this->createPartialMock(PhpBinaryPath::class, ['phpinfo']); + + $phpBinary->expects(self::once()) + ->method('phpinfo') + ->willReturn(''); + + self::assertNull($phpBinary->buildProvider()); + } }