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());
+ }
}