diff --git a/src/DependencyResolver/BundledPhpExtensionRefusal.php b/src/DependencyResolver/BundledPhpExtensionRefusal.php index e780d109..9ee58d63 100644 --- a/src/DependencyResolver/BundledPhpExtensionRefusal.php +++ b/src/DependencyResolver/BundledPhpExtensionRefusal.php @@ -4,6 +4,7 @@ namespace Php\Pie\DependencyResolver; +use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use RuntimeException; use function sprintf; @@ -24,4 +25,12 @@ public static function forPackage(Package $package): self $package->name(), )); } + + public static function forPhpExtraVersion(PhpBinaryPath $phpBinaryPath): self + { + return new self(sprintf( + 'Cannot install bundled PHP extension for non-stable versions of PHP (detected: %s)', + $phpBinaryPath->phpVersionWithExtra(), + )); + } } diff --git a/src/DependencyResolver/ResolveDependencyWithComposer.php b/src/DependencyResolver/ResolveDependencyWithComposer.php index a2d9ec31..d7873aa7 100644 --- a/src/DependencyResolver/ResolveDependencyWithComposer.php +++ b/src/DependencyResolver/ResolveDependencyWithComposer.php @@ -17,6 +17,7 @@ use function in_array; use function preg_match; use function sprintf; +use function str_ends_with; /** @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 @@ -109,6 +110,10 @@ private function assertBuildProviderProvidersBundledExtensions(TargetPlatform $t return; } + if (str_ends_with($targetPlatform->phpBinaryPath->phpVersionWithExtra(), '-dev')) { + throw BundledPhpExtensionRefusal::forPhpExtraVersion($targetPlatform->phpBinaryPath); + } + $buildProvider = $targetPlatform->phpBinaryPath->buildProvider(); $identifiedBuildProvider = false; $note = 'Note: '; diff --git a/src/Platform/TargetPhp/PhpBinaryPath.php b/src/Platform/TargetPhp/PhpBinaryPath.php index f0f231c9..4a1a8fc7 100644 --- a/src/Platform/TargetPhp/PhpBinaryPath.php +++ b/src/Platform/TargetPhp/PhpBinaryPath.php @@ -328,6 +328,19 @@ public function version(): string return $phpVersion; } + /** @return non-empty-string */ + public function phpVersionWithExtra(): string + { + $phpVersionWithExtra = self::cleanWarningAndDeprecationsFromOutput(Process::run([ + $this->phpBinaryPath, + '-r', + 'echo PHP_VERSION;', + ])); + Assert::stringNotEmpty($phpVersionWithExtra, 'Could not determine PHP_VERSION'); + + return $phpVersionWithExtra; + } + /** @return non-empty-string */ public function majorMinorVersion(): string { diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php index bcd6a9c6..f39ec6ad 100644 --- a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -10,12 +10,24 @@ use Composer\Package\CompletePackage; use Composer\Package\Link; use Composer\Semver\Constraint\Constraint; +use Composer\Semver\VersionParser; use Php\Pie\DependencyResolver\FetchDependencyStatuses; +use Php\Pie\Platform\Architecture; +use Php\Pie\Platform\OperatingSystem; +use Php\Pie\Platform\OperatingSystemFamily; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPlatform; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function assert; + +use const PHP_MAJOR_VERSION; +use const PHP_MINOR_VERSION; +use const PHP_RELEASE_VERSION; +use const PHP_VERSION; + #[CoversClass(FetchDependencyStatuses::class)] final class FetchDependencyStatusesTest extends TestCase { @@ -26,15 +38,44 @@ public function testNoRequiresReturnsEmptyArray(): void self::assertEquals([], (new FetchDependencyStatuses())(TargetPlatform::fromPhpBinaryPath(PhpBinaryPath::fromCurrentProcess(), null, null), $this->createMock(Composer::class), $package)); } - public function testRequiresReturnsListOfStatuses(): void + /** @return array */ + public function phpVersionProvider(): array + { + return [ + '8.2.0' => ['8.2.0', '8.2.0'], + '8.2.0-dev' => ['8.2.0', '8.2.0-dev'], + '8.2.0-alpha' => ['8.2.0', '8.2.0-alpha'], + '8.2.0-RC1' => ['8.2.0', '8.2.0-RC1'], + PHP_VERSION => [PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION, PHP_VERSION], + ]; + } + + #[DataProvider('phpVersionProvider')] + public function testRequiresReturnsListOfStatuses(string $version, string $versionWithExtra): void { - $php = PhpBinaryPath::fromCurrentProcess(); + $php = $this->createMock(PhpBinaryPath::class); + $php->method('operatingSystem')->willReturn(OperatingSystem::NonWindows); + $php->method('operatingSystemFamily')->willReturn(OperatingSystemFamily::Linux); + $php->method('machineType')->willReturn(Architecture::x86_64); + $php->expects(self::any()) + ->method('version') + ->willReturn($version); + $php->expects(self::any()) + ->method('phpVersionWithExtra') + ->willReturn($versionWithExtra); + $php->expects(self::any()) + ->method('extensions') + ->willReturn(['Core' => $versionWithExtra, 'standard' => $versionWithExtra]); + + $versionParser = new VersionParser(); + $parsedPhpVersion = $versionParser->parseConstraints($php->phpVersionWithExtra()); + assert($parsedPhpVersion instanceof Constraint); $package = new CompletePackage('vendor/foo', '1.2.3.0', '1.2.3'); $package->setRequires([ - 'ext-core' => new Link('__root__', 'ext-core', new Constraint('=', $php->version() . '.0')), - 'ext-nonsense_extension' => new Link('__root__', 'ext-nonsense_extension', new Constraint('=', '*')), - 'ext-standard' => new Link('__root__', 'ext-standard', new Constraint('<', '1.0.0.0')), + 'ext-core' => new Link('__root__', 'ext-core', $versionParser->parseConstraints('= ' . $php->phpVersionWithExtra())), + 'ext-nonsense_extension' => new Link('__root__', 'ext-nonsense_extension', $versionParser->parseConstraints('*')), + 'ext-standard' => new Link('__root__', 'ext-standard', $versionParser->parseConstraints('< 1.0.0')), ]); $deps = (new FetchDependencyStatuses())( @@ -45,8 +86,8 @@ public function testRequiresReturnsListOfStatuses(): void self::assertCount(3, $deps); - self::assertSame('ext-core: == ' . $php->version() . '.0 ✅', $deps[0]->asPrettyString()); - self::assertSame('ext-nonsense_extension: == * 🚫 (not installed)', $deps[1]->asPrettyString()); - self::assertSame('ext-standard: < 1.0.0.0 🚫 (your version is ' . $php->version() . '.0)', $deps[2]->asPrettyString()); + self::assertSame('ext-core: = ' . $php->phpVersionWithExtra() . ' ✅', $deps[0]->asPrettyString()); + self::assertSame('ext-nonsense_extension: * 🚫 (not installed)', $deps[1]->asPrettyString()); + self::assertSame('ext-standard: < 1.0.0 🚫 (your version is ' . $parsedPhpVersion->getVersion() . ')', $deps[2]->asPrettyString()); } } diff --git a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php index 33ab2c9a..114f66cc 100644 --- a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -15,12 +15,15 @@ use Composer\Repository\RepositoryFactory; use Composer\Repository\RepositoryManager; use Composer\Semver\Constraint\Constraint; +use Php\Pie\ComposerIntegration\BundledPhpExtensionsRepository; use Php\Pie\ComposerIntegration\QuieterConsoleIO; +use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; use Php\Pie\DependencyResolver\IncompatibleOperatingSystemFamily; use Php\Pie\DependencyResolver\IncompatibleThreadSafetyMode; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\ResolveDependencyWithComposer; use Php\Pie\DependencyResolver\UnableToResolveRequirement; +use Php\Pie\ExtensionType; use Php\Pie\Platform\Architecture; use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\OperatingSystemFamily; @@ -49,13 +52,21 @@ public function setUp(): void ]); $this->localRepo = $this->createMock(InstalledRepositoryInterface::class); $this->localRepo->method('getPackages')->willReturn([ - new CompletePackage('already/installed1', '1.2.3.0', '1.2.3'), + new CompletePackage('a/installed1', '1.2.3.0', '1.2.3'), $packageWithReplaces, ]); + $bundledPhpPackage = new CompletePackage('php/bundled', '8.3.0', '8.3.0.0'); + $bundledPhpPackage->setType(ExtensionType::PhpModule->value); + $repoManager = $this->createMock(RepositoryManager::class); $repoManager->method('getRepositories') - ->willReturn([new CompositeRepository(RepositoryFactory::defaultReposWithDefaultManager(new NullIO()))]); + ->willReturn([ + new CompositeRepository([ + ...RepositoryFactory::defaultReposWithDefaultManager(new NullIO()), + new BundledPhpExtensionsRepository([$bundledPhpPackage]), + ]), + ]); $repoManager->method('getLocalRepository')->willReturn($this->localRepo); $this->composer = $this->createMock(Composer::class); @@ -415,4 +426,119 @@ public function testPackageThatCanBeResolvedWithReplaceConflict(): void self::assertSame('asgrim/example-pie-extension', $package->name()); self::assertStringStartsWith('1.', $package->version()); } + + public function testBundledExtensionCannotBeInstalledOnDevPhpVersion(): void + { + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath->expects(self::any()) + ->method('version') + ->willReturn('8.3.0'); + $phpBinaryPath->expects(self::any()) + ->method('phpVersionWithExtra') + ->willReturn('8.3.0-dev'); + + $targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinaryPath, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + null, + ); + + $resolver = new ResolveDependencyWithComposer( + $this->createMock(IOInterface::class), + $this->createMock(QuieterConsoleIO::class), + ); + $requestedPackage = new RequestedPackageAndVersion('php/bundled', null); + + $this->expectException(BundledPhpExtensionRefusal::class); + $this->expectExceptionMessage('Cannot install bundled PHP extension for non-stable versions of PHP'); + $resolver->__invoke($this->composer, $targetPlatform, $requestedPackage, false); + } + + /** @return array */ + public function buildProvidersWithBundledExtensionWarnings(): array + { + return [ + 'Docker' => ['https://github.com/docker-library/php'], + 'Debian/Ubuntu' => ['Debian'], + 'Remi Repo' => ['Remi\'s RPM repository #StandWithUkraine'], + 'Brew' => ['Homebrew'], + ]; + } + + #[DataProvider('buildProvidersWithBundledExtensionWarnings')] + public function testBundledExtensionWillNotInstallOnBuildProviderWithoutForce(string $buildProvider): void + { + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath->expects(self::any()) + ->method('version') + ->willReturn('8.3.0'); + $phpBinaryPath->expects(self::any()) + ->method('phpVersionWithExtra') + ->willReturn('8.3.0'); + $phpBinaryPath->expects(self::any()) + ->method('buildProvider') + ->willReturn($buildProvider); + + $targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinaryPath, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + null, + ); + + $resolver = new ResolveDependencyWithComposer( + $this->createMock(IOInterface::class), + $this->createMock(QuieterConsoleIO::class), + ); + $requestedPackage = new RequestedPackageAndVersion('php/bundled', null); + + $this->expectException(BundledPhpExtensionRefusal::class); + $this->expectExceptionMessage('Bundled PHP extension php/bundled should be installed by your distribution, not by PIE'); + $resolver->__invoke($this->composer, $targetPlatform, $requestedPackage, false); + } + + #[DataProvider('buildProvidersWithBundledExtensionWarnings')] + public function testBundledExtensionWillInstallOnBuildProviderWithForce(string $buildProvider): void + { + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath->expects(self::any()) + ->method('version') + ->willReturn('8.3.0'); + $phpBinaryPath->expects(self::any()) + ->method('phpVersionWithExtra') + ->willReturn('8.3.0'); + $phpBinaryPath->expects(self::any()) + ->method('buildProvider') + ->willReturn($buildProvider); + + $targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinaryPath, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + null, + ); + + $resolver = new ResolveDependencyWithComposer( + $this->createMock(IOInterface::class), + $this->createMock(QuieterConsoleIO::class), + ); + $requestedPackage = new RequestedPackageAndVersion('php/bundled', null); + + $package = $resolver->__invoke($this->composer, $targetPlatform, $requestedPackage, true); + + self::assertSame('php/bundled', $package->name()); + } }