diff --git a/.github/workflows/build-assets.yml b/.github/workflows/build-assets.yml index b788cd03..64d24873 100644 --- a/.github/workflows/build-assets.yml +++ b/.github/workflows/build-assets.yml @@ -68,6 +68,8 @@ jobs: needs: - build-phar runs-on: ${{ matrix.operating-system }} + env: + SPC_VERSION: 2.8.5 strategy: fail-fast: false matrix: @@ -76,7 +78,7 @@ jobs: - ubuntu-24.04-arm - macos-15-intel - macos-26 - - windows-2025 +# - windows-2025 - disabled for now, seems broken permissions: # id-token:write is required for build provenance attestation. id-token: write @@ -92,19 +94,19 @@ jobs: # Source URL: https://static-php.dev/en/guide/manual-build.html#build-locally-using-spc-binary-recommended case "${{ matrix.operating-system }}" in ubuntu-24.04) - curl -fsSL -o spc https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-linux-x86_64 + curl -fsSL -o spc.tgz https://github.com/crazywhalecc/static-php-cli/releases/download/${{ env.SPC_VERSION }}/spc-linux-x86_64.tar.gz ;; ubuntu-24.04-arm) - curl -fsSL -o spc https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-linux-aarch64 + curl -fsSL -o spc.tgz https://github.com/crazywhalecc/static-php-cli/releases/download/${{ env.SPC_VERSION }}/spc-linux-aarch64.tar.gz ;; macos-15-intel) - curl -fsSL -o spc https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-macos-x86_64 + curl -fsSL -o spc.tgz https://github.com/crazywhalecc/static-php-cli/releases/download/${{ env.SPC_VERSION }}/spc-macos-x86_64.tar.gz ;; macos-26) - curl -fsSL -o spc https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-macos-aarch64 + curl -fsSL -o spc.tgz https://github.com/crazywhalecc/static-php-cli/releases/download/${{ env.SPC_VERSION }}/spc-macos-aarch64.tar.gz ;; *) @@ -112,13 +114,14 @@ jobs: exit 1 ;; esac + tar zxvf spc.tgz chmod +x spc echo "SPC_BINARY=./spc" >> $GITHUB_ENV echo "PIE_BINARY_OUTPUT=pie-${{ runner.os }}-${{ runner.arch }}" >> $GITHUB_ENV - name: Download SPC (Windows) if: runner.os == 'Windows' run: | - curl.exe -fsSL -o spc.exe https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-windows-x64.exe + curl.exe -fsSL -o spc.exe https://github.com/crazywhalecc/static-php-cli/releases/download/${{ env.SPC_VERSION }}/spc-windows-x64.exe chmod +x spc.exe echo "SPC_BINARY=.\spc.exe" >> $env:GITHUB_ENV echo "PIE_BINARY_OUTPUT=pie-${{ runner.os }}-${{ runner.arch }}.exe" >> $env:GITHUB_ENV diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 2ae5b02e..008bc5b8 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -167,13 +167,18 @@ public function execute(InputInterface $input, OutputInterface $output): int array_walk( $extensionsRequired, - function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened): void { + function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePackages, $input, &$anyErrorsHappened, $targetPlatform): void { $extension = ExtensionName::normaliseFromString($link->getTarget()); $linkRequiresConstraint = $link->getPrettyConstraint(); + $piePackagesForExtension = $installedPiePackages + ->findByPhpFormattedExtensionName($extension->phpFormattedExtensionName()) + ->onlyVerifiedFor($targetPlatform); + $piePackageVersion = null; - if (in_array($extension->name(), array_keys($installedPiePackages))) { - $piePackageVersion = $installedPiePackages[$extension->name()]->version(); + + if (count($piePackagesForExtension) === 1) { + $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); } $piePackageVersionMatchesLinkConstraint = null; diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index 77f88b9f..c291dc6a 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -10,15 +10,14 @@ use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieInstalledJsonMetadataKeys; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; +use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; use Php\Pie\DependencyResolver\ResolveDependencyWithComposer; use Php\Pie\DependencyResolver\UnableToResolveRequirement; -use Php\Pie\File\BinaryFile; -use Php\Pie\File\BinaryFileFailedVerification; use Php\Pie\Platform as PiePlatform; use Php\Pie\Platform\InstalledPiePackages; -use Php\Pie\Platform\OperatingSystem; use Php\Pie\Util\Emoji; +use Php\Pie\Util\PackageVerificationStatus; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -28,15 +27,11 @@ use Webmozart\Assert\Assert; use function array_diff; -use function array_key_exists; -use function array_keys; +use function array_map; use function array_walk; use function count; -use function file_exists; +use function rtrim; use function sprintf; -use function substr; - -use const DIRECTORY_SEPARATOR; /** @phpstan-import-type PieMetadata from PieInstalledJsonMetadataKeys */ #[AsCommand( @@ -99,8 +94,6 @@ public function execute(InputInterface $input, OutputInterface $output): int $piePackages = $this->installedPiePackages->allPiePackages($composer); $phpEnabledExtensions = $targetPlatform->phpBinaryPath->extensions(); - $extensionPath = $targetPlatform->phpBinaryPath->extensionPath(); - $extensionEnding = $targetPlatform->operatingSystem === OperatingSystem::Windows ? '.dll' : '.so'; $piePackagesMatched = []; $rootPackageRequires = $composer->getPackage()->getRequires(); @@ -110,8 +103,10 @@ public function execute(InputInterface $input, OutputInterface $output): int )); array_walk( $phpEnabledExtensions, - function (string $version, string $phpExtensionName) use ($composer, $rootPackageRequires, $targetPlatform, $showAll, $piePackages, $extensionPath, $extensionEnding, &$piePackagesMatched): void { - if (! array_key_exists($phpExtensionName, $piePackages)) { + function (string $version, string $phpExtensionName) use ($composer, $rootPackageRequires, $targetPlatform, $showAll, $piePackages, &$piePackagesMatched): void { + $pieMatchesForExtension = $piePackages->findByPhpFormattedExtensionName($phpExtensionName); + + if (! count($pieMatchesForExtension)) { if ($showAll) { $this->io->write(sprintf(' %s:%s', $phpExtensionName, $version)); } @@ -119,64 +114,66 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag return; } - $piePackage = $piePackages[$phpExtensionName]; - $piePackagesMatched[] = $phpExtensionName; - $packageName = $piePackage->name(); - $packageRequirement = $rootPackageRequires[$piePackage->name()]->getPrettyConstraint(); + foreach ($pieMatchesForExtension->packages() as $piePackage) { + $packageName = $piePackage->name(); + $verificationStatus = $piePackage->verifyPackageStatus($targetPlatform); + $packageRequirement = $rootPackageRequires[$packageName]->getPrettyConstraint(); - try { - // Don't check for updates for bundled PHP extensions - if ($piePackage->isBundledPhpExtension()) { - throw new BundledPhpExtensionRefusal(); + if ($verificationStatus === PackageVerificationStatus::InstalledBinaryMetadataMissing) { + continue; } - Assert::stringNotEmpty($packageName); - Assert::stringNotEmpty($packageRequirement); - - $latestConstrainedPackage = ($this->resolveDependencyWithComposer)( - $composer, - $targetPlatform, - new RequestedPackageAndVersion($packageName, $packageRequirement), - false, - ); - - $latestPackage = ($this->resolveDependencyWithComposer)( - $composer, - $targetPlatform, - new RequestedPackageAndVersion($packageName, '*'), - false, - ); - } catch (UnableToResolveRequirement | BundledPhpExtensionRefusal) { - $latestConstrainedPackage = null; - $latestPackage = null; - } + $piePackagesMatched[] = $packageName; + + try { + // Don't check for updates for bundled PHP extensions + if ($piePackage->isBundledPhpExtension()) { + throw new BundledPhpExtensionRefusal(); + } + + Assert::stringNotEmpty($packageName); + Assert::stringNotEmpty($packageRequirement); + + $latestConstrainedPackage = ($this->resolveDependencyWithComposer)( + $composer, + $targetPlatform, + new RequestedPackageAndVersion($packageName, $packageRequirement), + false, + ); + + $latestPackage = ($this->resolveDependencyWithComposer)( + $composer, + $targetPlatform, + new RequestedPackageAndVersion($packageName, '*'), + false, + ); + } catch (UnableToResolveRequirement | BundledPhpExtensionRefusal) { + $latestConstrainedPackage = null; + $latestPackage = null; + } - $updateNotice = ''; - if ($latestConstrainedPackage !== null && $latestConstrainedPackage->version() !== $piePackage->version()) { - $updateNotice = sprintf( - ', upgradable to %s (within %s)', - $latestConstrainedPackage->version(), - $packageRequirement, - ); - } + $updateNotice = ''; + if ($latestConstrainedPackage !== null && $latestConstrainedPackage->version() !== $piePackage->version()) { + $updateNotice = sprintf( + ', upgradable to %s (within %s)', + $latestConstrainedPackage->version(), + $packageRequirement, + ); + } - if ($latestPackage !== null && $latestPackage->version() !== $latestConstrainedPackage->version()) { - $updateNotice .= sprintf(', latest version is %s', $latestPackage->version()); - } + if ($latestPackage !== null && $latestPackage->version() !== $latestConstrainedPackage->version()) { + $updateNotice .= sprintf(', latest version is %s', $latestPackage->version()); + } - $this->io->write(sprintf( - ' %s:%s (from 🥧 %s%s)%s', - $phpExtensionName, - $version, - $piePackage->prettyNameAndVersion(), - self::verifyChecksumInformation( - $extensionPath, + $this->io->write(sprintf( + ' %s:%s (from 🥧 %s %s)%s', $phpExtensionName, - $extensionEnding, - PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackage($piePackage->composerPackage()), - ), - $updateNotice, - )); + $version, + $piePackage->prettyNameAndVersion(), + $verificationStatus->description(), + $updateNotice, + )); + } }, ); @@ -184,67 +181,28 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag $this->io->write('(none)'); } - $unmatchedPiePackages = array_diff(array_keys($piePackages), $piePackagesMatched); + $unmatchedPiePackageNames = array_diff(array_map(static fn (Package $piePackage) => $piePackage->name(), $piePackages->packages()), $piePackagesMatched); - if (count($unmatchedPiePackages)) { + if (count($unmatchedPiePackageNames)) { $this->io->write(sprintf( '%s %s PIE packages not loaded:', "\n", Emoji::WARNING, )); - $this->io->write('These extensions were installed with PIE but are not currently enabled.' . "\n"); + $this->io->write('These extensions were set up with PIE but are not currently enabled.' . "\n"); - foreach ($unmatchedPiePackages as $unmatchedPiePackage) { - $this->io->write(sprintf(' - %s', $piePackages[$unmatchedPiePackage]->prettyNameAndVersion())); + foreach ($unmatchedPiePackageNames as $unmatchedPiePackageName) { + $unmatchedPiePackage = $piePackages->findByPackageName($unmatchedPiePackageName); + + $message = match ($unmatchedPiePackage->verifyPackageStatus($targetPlatform)) { + PackageVerificationStatus::ChecksumMetadataMissing => '- was built but not installed yet.', + PackageVerificationStatus::InstalledBinaryMetadataMissing => '- was downloaded but has not been built yet.', + default => '- installed but not enabled in INI file', + }; + $this->io->write(rtrim(sprintf(' - %s %s', $unmatchedPiePackage->prettyNameAndVersion(), $message))); } } return Command::SUCCESS; } - - /** - * @param PieMetadata $installedJsonMetadata - * @phpstan-param '.dll'|'.so' $extensionEnding - */ - private static function verifyChecksumInformation( - string $extensionPath, - string $phpExtensionName, - string $extensionEnding, - array $installedJsonMetadata, - ): string { - $actualBinaryPathByConvention = $extensionPath . DIRECTORY_SEPARATOR . $phpExtensionName . $extensionEnding; - - // The extension may not be in the usual path (since you can specify a full path to an extension in the INI file) - if (! file_exists($actualBinaryPathByConvention)) { - return ''; - } - - $pieExpectedBinaryPath = array_key_exists(PieInstalledJsonMetadataKeys::InstalledBinary->value, $installedJsonMetadata) ? $installedJsonMetadata[PieInstalledJsonMetadataKeys::InstalledBinary->value] : null; - $pieExpectedChecksum = array_key_exists(PieInstalledJsonMetadataKeys::BinaryChecksum->value, $installedJsonMetadata) ? $installedJsonMetadata[PieInstalledJsonMetadataKeys::BinaryChecksum->value] : null; - - // Some other kind of mismatch of file path, or we don't have a stored checksum available - if ( - $pieExpectedBinaryPath === null - || $pieExpectedChecksum === null - || $pieExpectedBinaryPath !== $actualBinaryPathByConvention - ) { - return ''; - } - - $expectedBinaryFileFromMetadata = new BinaryFile($pieExpectedBinaryPath, $pieExpectedChecksum); - $actualBinaryFile = BinaryFile::fromFileWithSha256Checksum($actualBinaryPathByConvention); - - try { - $expectedBinaryFileFromMetadata->verifyAgainstOther($actualBinaryFile); - } catch (BinaryFileFailedVerification) { - return sprintf( - ' %s was %s..., expected %s...', - Emoji::WARNING, - substr($actualBinaryFile->checksum, 0, 8), - substr($expectedBinaryFileFromMetadata->checksum, 0, 8), - ); - } - - return ' ' . Emoji::GREEN_CHECKMARK; - } } diff --git a/src/Command/UninstallCommand.php b/src/Command/UninstallCommand.php index c06980a0..86b2bf67 100644 --- a/src/Command/UninstallCommand.php +++ b/src/Command/UninstallCommand.php @@ -109,7 +109,7 @@ private function findPiePackageByPackageName(string $packageToRemove, Composer $ { $piePackages = $this->installedPiePackages->allPiePackages($composer); - foreach ($piePackages as $piePackage) { + foreach ($piePackages->packages() as $piePackage) { if ($piePackage->name() === $packageToRemove) { return $piePackage; } diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index 2e60dd95..1adabc02 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -44,7 +44,7 @@ public function __construct(PhpBinaryPath $phpBinaryPath, Composer $composer, In $piePackages = $installedPiePackages->allPiePackages($composer); $extensionsBeingReplacedByPiePackages = []; - foreach ($piePackages as $piePackage) { + foreach ($piePackages->packages() as $piePackage) { foreach ($piePackage->composerPackage()->getReplaces() as $replaceLink) { $target = $replaceLink->getTarget(); if ( diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php index f37df660..62302a73 100644 --- a/src/DependencyResolver/Package.php +++ b/src/DependencyResolver/Package.php @@ -6,11 +6,17 @@ use Composer\Package\CompletePackageInterface; use InvalidArgumentException; +use Php\Pie\ComposerIntegration\PieInstalledJsonMetadataKeys; use Php\Pie\ConfigureOption; use Php\Pie\Downloading\DownloadUrlMethod; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; +use Php\Pie\File\BinaryFile; +use Php\Pie\File\BinaryFileFailedVerification; +use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\OperatingSystemFamily; +use Php\Pie\Platform\TargetPlatform; +use Php\Pie\Util\PackageVerificationStatus; use Safe\Exceptions\UrlException; use Webmozart\Assert\Assert; @@ -19,6 +25,7 @@ use function array_slice; use function count; use function explode; +use function file_exists; use function implode; use function is_array; use function is_string; @@ -27,6 +34,8 @@ use function str_starts_with; use function strtolower; +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 * @@ -242,4 +251,45 @@ public function supportedDownloadUrlMethods(): array|null { return $this->supportedDownloadUrlMethods; } + + public function verifyPackageStatus(TargetPlatform $targetPlatform): PackageVerificationStatus + { + $extensionPath = $targetPlatform->phpBinaryPath->extensionPath(); + $extensionEnding = $targetPlatform->operatingSystem === OperatingSystem::Windows ? '.dll' : '.so'; + $phpExtensionName = $this->extensionName->name(); + + $actualBinaryPathByConvention = $extensionPath . DIRECTORY_SEPARATOR . $phpExtensionName . $extensionEnding; + + // The extension may not be in the usual path (since you can specify a full path to an extension in the INI file) + if (! file_exists($actualBinaryPathByConvention)) { + return PackageVerificationStatus::ActualBinaryNotFound; + } + + $installedJsonMetadata = PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackage($this->composerPackage()); + $pieExpectedBinaryPath = array_key_exists(PieInstalledJsonMetadataKeys::InstalledBinary->value, $installedJsonMetadata) ? $installedJsonMetadata[PieInstalledJsonMetadataKeys::InstalledBinary->value] : null; + $pieExpectedChecksum = array_key_exists(PieInstalledJsonMetadataKeys::BinaryChecksum->value, $installedJsonMetadata) ? $installedJsonMetadata[PieInstalledJsonMetadataKeys::BinaryChecksum->value] : null; + + if ($pieExpectedBinaryPath === null) { + return PackageVerificationStatus::InstalledBinaryMetadataMissing; + } + + if ($pieExpectedChecksum === null) { + return PackageVerificationStatus::ChecksumMetadataMissing; + } + + if ($pieExpectedBinaryPath !== $actualBinaryPathByConvention) { + return PackageVerificationStatus::InstalledBinaryPathDoesNotMatchActualBinaryPath; + } + + $expectedBinaryFileFromMetadata = new BinaryFile($pieExpectedBinaryPath, $pieExpectedChecksum); + $actualBinaryFile = BinaryFile::fromFileWithSha256Checksum($actualBinaryPathByConvention); + + try { + $expectedBinaryFileFromMetadata->verifyAgainstOther($actualBinaryFile); + } catch (BinaryFileFailedVerification) { + return PackageVerificationStatus::ChecksumMismatch; + } + + return PackageVerificationStatus::Verified; + } } diff --git a/src/ExtensionName.php b/src/ExtensionName.php index 164cbd2b..2c37aed2 100644 --- a/src/ExtensionName.php +++ b/src/ExtensionName.php @@ -94,4 +94,20 @@ public function nameWithExtPrefix(): string { return 'ext-' . $this->normalisedExtensionName; } + + /** @return non-empty-string */ + public function phpFormattedExtensionName(): string + { + return match ($this->name()) { + 'core' => 'Core', + 'spl' => 'SPL', + 'phar' => 'Phar', + 'reflection' => 'Reflection', + 'pdo' => 'PDO', + 'ffi' => 'FFI', + 'opcache' => 'Zend OPcache', + 'simplexml' => 'SimpleXML', + default => $this->name(), + }; + } } diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php index 4eb5346f..9324586a 100644 --- a/src/Platform/InstalledPiePackages.php +++ b/src/Platform/InstalledPiePackages.php @@ -11,26 +11,20 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\ExtensionName; -use function array_combine; use function array_filter; use function array_map; +use function array_values; -/** - * @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks - * - * @phpstan-type ListOfPiePackages = array - */ +/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class InstalledPiePackages { /** * Returns a list of PIE packages according to PIE; this does NOT check if * the extension is actually enabled in the target PHP. - * - * @return ListOfPiePackages */ - public function allPiePackages(Composer $composer): array + public function allPiePackages(Composer $composer): PiePackageList { - $composerInstalledPackages = array_map( + return new PiePackageList(array_values(array_map( static function (CompletePackageInterface $package): Package { return Package::fromComposerCompletePackage($package); }, @@ -49,27 +43,6 @@ static function (BasePackage $basePackage): bool { return $basePackage instanceof CompletePackageInterface; }, ), - ); - - return array_combine( - array_map( - /** @return non-empty-string */ - static function (Package $package): string { - return match ($package->extensionName()->name()) { - 'core' => 'Core', - 'spl' => 'SPL', - 'phar' => 'Phar', - 'reflection' => 'Reflection', - 'pdo' => 'PDO', - 'ffi' => 'FFI', - 'opcache' => 'Zend OPcache', - 'simplexml' => 'SimpleXML', - default => $package->extensionName()->name(), - }; - }, - $composerInstalledPackages, - ), - $composerInstalledPackages, - ); + ))); } } diff --git a/src/Platform/PackageManager.php b/src/Platform/PackageManager.php index 561a23a4..572391a0 100644 --- a/src/Platform/PackageManager.php +++ b/src/Platform/PackageManager.php @@ -12,8 +12,6 @@ use function array_unshift; use function implode; -use function str_contains; -use function strtolower; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ enum PackageManager: string @@ -69,7 +67,7 @@ public function install(array $packages): void return; } catch (ProcessFailedException $e) { - if (Platform::isInteractive() && self::isProbablyPermissionDenied($e)) { + if (Platform::isInteractive() && Process::processProbablyPermissionDenied($e)) { array_unshift($cmd, Sudo::find()); Process::run($cmd); @@ -80,24 +78,4 @@ public function install(array $packages): void throw $e; } } - - private static function isProbablyPermissionDenied(ProcessFailedException $e): bool - { - $mergedProcessOutput = strtolower($e->getProcess()->getErrorOutput() . $e->getProcess()->getOutput()); - - $needles = [ - 'permission denied', - 'you must be root', - 'operation not permitted', - 'are you root', - ]; - - foreach ($needles as $needle) { - if (str_contains($mergedProcessOutput, $needle)) { - return true; - } - } - - return false; - } } diff --git a/src/Platform/PiePackageList.php b/src/Platform/PiePackageList.php new file mode 100644 index 00000000..4e7078bf --- /dev/null +++ b/src/Platform/PiePackageList.php @@ -0,0 +1,70 @@ + $piePackages */ + public function __construct(private readonly array $piePackages) + { + } + + public function findByPhpFormattedExtensionName(string $phpFormattedExtensionName): PiePackageList + { + return new self(array_values(array_filter( + $this->piePackages, + static fn (Package $piePackage) => $piePackage->extensionName()->phpFormattedExtensionName() === $phpFormattedExtensionName, + ))); + } + + public function findByPackageName(string $packageName): Package + { + foreach ($this->piePackages as $piePackage) { + if ($piePackage->name() === $packageName) { + return $piePackage; + } + } + + throw new OutOfRangeException('Package ' . $packageName . ' not in the list'); + } + + public function onlyVerifiedFor(TargetPlatform $targetPlatform): self + { + return new self(array_values(array_filter( + $this->piePackages, + static fn (Package $piePackage) => $piePackage->verifyPackageStatus($targetPlatform) === PackageVerificationStatus::Verified, + ))); + } + + public function onlyOne(): Package + { + Assert::count($this->piePackages, 1); + + return $this->piePackages[array_key_first($this->piePackages)]; + } + + /** @return list */ + public function packages(): array + { + return $this->piePackages; + } + + public function count(): int + { + return count($this->piePackages); + } +} diff --git a/src/SelfManage/BuildTools/CheckAllBuildTools.php b/src/SelfManage/BuildTools/CheckAllBuildTools.php index 715a25f4..ac02c6ab 100644 --- a/src/SelfManage/BuildTools/CheckAllBuildTools.php +++ b/src/SelfManage/BuildTools/CheckAllBuildTools.php @@ -50,26 +50,6 @@ public static function buildToolsFactory(): self PackageManager::Brew->value => 'autoconf', ], ), - new BinaryBuildToolFinder( - 'bison', - [ - PackageManager::Apt->value => 'bison', - PackageManager::Apk->value => 'bison', - PackageManager::Dnf->value => 'bison', - PackageManager::Yum->value => 'bison', - PackageManager::Brew->value => 'bison', - ], - ), - new BinaryBuildToolFinder( - 're2c', - [ - PackageManager::Apt->value => 're2c', - PackageManager::Apk->value => 're2c', - PackageManager::Dnf->value => 're2c', - PackageManager::Yum->value => 're2c', - PackageManager::Brew->value => 're2c', - ], - ), new BinaryBuildToolFinder( 'pkg-config', [ diff --git a/src/Util/PackageVerificationStatus.php b/src/Util/PackageVerificationStatus.php new file mode 100644 index 00000000..d0048194 --- /dev/null +++ b/src/Util/PackageVerificationStatus.php @@ -0,0 +1,27 @@ + Emoji::GREEN_CHECKMARK, + self::ChecksumMismatch => Emoji::PROHIBITED . ' - checksum mismatch', + self::ActualBinaryNotFound => Emoji::WARNING . ' - extension file not found', + self::InstalledBinaryMetadataMissing => Emoji::WARNING . ' - installed extension metadata missing', + self::ChecksumMetadataMissing => Emoji::WARNING . ' - binary checksum metadata missing', + self::InstalledBinaryPathDoesNotMatchActualBinaryPath => Emoji::WARNING . ' - binary path mismatch', + }; + } +} diff --git a/src/Util/Process.php b/src/Util/Process.php index 8e32df16..41abed8e 100644 --- a/src/Util/Process.php +++ b/src/Util/Process.php @@ -7,6 +7,8 @@ use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process as SymfonyProcess; +use function str_contains; +use function strtolower; use function trim; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ @@ -45,4 +47,25 @@ public static function run( ->mustRun($outputCallback) ->getOutput()); } + + public static function processProbablyPermissionDenied(ProcessFailedException $e): bool + { + $mergedProcessOutput = strtolower($e->getProcess()->getErrorOutput() . $e->getProcess()->getOutput()); + + $needles = [ + 'permission denied', + 'you must be root', + 'operation not permitted', + 'are you root', + 'has to be run with superuser privileges', + ]; + + foreach ($needles as $needle) { + if (str_contains($mergedProcessOutput, $needle)) { + return true; + } + } + + return false; + } } diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index ae1833d5..ce55f0b5 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -26,6 +26,7 @@ use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Platform\InstalledPiePackages; +use Php\Pie\Platform\PiePackageList; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -135,6 +136,8 @@ public function testInstallingExtensionsForPhpProject(): void ) ->willReturn(0); + $this->installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); + $this->commandTester->execute( ['--allow-non-interactive-project-install' => true], ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], @@ -182,6 +185,8 @@ public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void $this->installSelectedPackage->expects(self::never()) ->method('withSubCommand'); + $this->installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); + $this->commandTester->execute( ['--allow-non-interactive-project-install' => true], ['verbosity' => BufferedOutput::VERBOSITY_VERY_VERBOSE], diff --git a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php index fa38aec8..e827f201 100644 --- a/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php +++ b/test/unit/ComposerIntegration/PhpBinaryPathBasedPlatformRepositoryTest.php @@ -14,6 +14,7 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\ExtensionName; use Php\Pie\Platform\InstalledPiePackages; +use Php\Pie\Platform\PiePackageList; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Util\Process; use PHPUnit\Framework\Attributes\CoversClass; @@ -35,7 +36,7 @@ public function testPlatformRepositoryContainsExpectedPacakges(): void $composer = $this->createMock(Composer::class); $installedPiePackages = $this->createMock(InstalledPiePackages::class); - $installedPiePackages->method('allPiePackages')->willReturn([]); + $installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); $phpBinaryPath = $this->createMock(PhpBinaryPath::class); $phpBinaryPath->expects(self::once()) @@ -75,7 +76,7 @@ public function testPlatformRepositoryExcludesExtensionBeingInstalled(): void $composer = $this->createMock(Composer::class); $installedPiePackages = $this->createMock(InstalledPiePackages::class); - $installedPiePackages->method('allPiePackages')->willReturn([]); + $installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); $extensionBeingInstalled = ExtensionName::normaliseFromString('extension_being_installed'); @@ -116,9 +117,9 @@ public function testPlatformRepositoryExcludesReplacedExtensions(): void 'ext-replaced_extension' => new Link('myvendor/replaced_extension', 'ext-replaced_extension', new Constraint('==', '*')), ]); $installedPiePackages = $this->createMock(InstalledPiePackages::class); - $installedPiePackages->method('allPiePackages')->willReturn([ + $installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([ Package::fromComposerCompletePackage($composerPackage), - ]); + ])); $extensionBeingInstalled = ExtensionName::normaliseFromString('extension_being_installed'); @@ -216,6 +217,9 @@ public function testLibrariesAreIncluded(string $packageName): void self::markTestSkipped('pkg-config not available on Windows'); } + $installedPiePackages = $this->createMock(InstalledPiePackages::class); + $installedPiePackages->method('allPiePackages')->willReturn(new PiePackageList([])); + self::assertTrue(in_array( 'lib-' . $packageName, array_map( @@ -223,7 +227,7 @@ public function testLibrariesAreIncluded(string $packageName): void (new PhpBinaryPathBasedPlatformRepository( PhpBinaryPath::fromCurrentProcess(), $this->createMock(Composer::class), - $this->createMock(InstalledPiePackages::class), + $installedPiePackages, ExtensionName::normaliseFromString('extension_being_installed'), ))->getPackages(), ), diff --git a/test/unit/Platform/InstalledPiePackagesTest.php b/test/unit/Platform/InstalledPiePackagesTest.php index b273cd2a..e4682ce1 100644 --- a/test/unit/Platform/InstalledPiePackagesTest.php +++ b/test/unit/Platform/InstalledPiePackagesTest.php @@ -29,15 +29,14 @@ public function testAllPiePackages(): void $composer = $this->createMock(Composer::class); $composer->method('getRepositoryManager')->willReturn($repoManager); - $packages = (new InstalledPiePackages())->allPiePackages($composer); + $packages = (new InstalledPiePackages())->allPiePackages($composer)->packages(); - self::assertArrayHasKey('bar1', $packages); - self::assertArrayHasKey('bar2', $packages); + self::assertCount(2, $packages); - self::assertSame('bar1', $packages['bar1']->extensionName()->name()); - self::assertSame('foo/bar1', $packages['bar1']->name()); - self::assertSame('bar2', $packages['bar2']->extensionName()->name()); - self::assertSame('foo/bar2', $packages['bar2']->name()); + self::assertSame('bar1', $packages[0]->extensionName()->name()); + self::assertSame('foo/bar1', $packages[0]->name()); + self::assertSame('bar2', $packages[1]->extensionName()->name()); + self::assertSame('foo/bar2', $packages[1]->name()); } public function testInvalidExtensionNamesAreFilteredOut(): void diff --git a/test/unit/Util/ProcessTest.php b/test/unit/Util/ProcessTest.php new file mode 100644 index 00000000..8e1d1c20 --- /dev/null +++ b/test/unit/Util/ProcessTest.php @@ -0,0 +1,45 @@ + */ + public static function permissionDeniedProvider(): array + { + return [ + 'apt 1 denied stderr' => ['', 'Error: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)', true], + 'apt 1 denied stdout' => ['Error: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)', '', true], + 'apt 2 denied stderr' => ['', 'Error: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?', true], + 'apt 2 denied stdout' => ['Error: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?', '', true], + 'dnf denied stderr' => ['', 'Error: This command has to be run with superuser privileges (under the root user on most systems).', true], + 'dnf denied stdout' => ['Error: This command has to be run with superuser privileges (under the root user on most systems).', '', true], + 'apk denied stderr' => ['', 'ERROR: Unable to open log: Permission denied', true], + 'apk denied stdout' => ['ERROR: Unable to open log: Permission denied', '', true], + 'no permission denied' => ['some other error', 'exit code 1', false], + ]; + } + + #[DataProvider('permissionDeniedProvider')] + public function testProcessProbablyPermissionDenied(string $stdout, string $stderr, bool $expected): void + { + $symfonyProcess = $this->createMock(SymfonyProcess::class); + $symfonyProcess->method('getOutput')->willReturn($stdout); + $symfonyProcess->method('getErrorOutput')->willReturn($stderr); + + $exception = $this->createMock(ProcessFailedException::class); + $exception->method('getProcess')->willReturn($symfonyProcess); + + self::assertSame($expected, Process::processProbablyPermissionDenied($exception)); + } +}