diff --git a/bin/pie b/bin/pie index 2b907bb1..caa0a2f4 100755 --- a/bin/pie +++ b/bin/pie @@ -13,6 +13,7 @@ use Php\Pie\Command\RepositoryAddCommand; use Php\Pie\Command\RepositoryListCommand; use Php\Pie\Command\RepositoryRemoveCommand; use Php\Pie\Command\ShowCommand; +use Php\Pie\Command\UninstallCommand; use Php\Pie\Util\PieVersion; use Symfony\Component\Console\Application; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; @@ -37,6 +38,7 @@ $application->setCommandLoader(new ContainerCommandLoader( 'repository:list' => RepositoryListCommand::class, 'repository:add' => RepositoryAddCommand::class, 'repository:remove' => RepositoryRemoveCommand::class, + 'uninstall' => UninstallCommand::class, ] )); diff --git a/features/uninstall-extensions.feature b/features/uninstall-extensions.feature new file mode 100644 index 00000000..154c10cf --- /dev/null +++ b/features/uninstall-extensions.feature @@ -0,0 +1,8 @@ +Feature: Extensions can be uninstalled with PIE + + # See https://github.com/php/pie/issues/190 for why this is non-Windows + @non-windows + Example: An extension can be uninstalled + Given an extension was previously installed + When I run a command to uninstall an extension + Then the extension should not be installed anymore diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2808b462..430ba14c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,6 +8,7 @@ requireCoverageMetadata="true" beStrictAboutOutputDuringTests="true" displayDetailsOnSkippedTests="true" + displayDetailsOnTestsThatTriggerWarnings="true" failOnRisky="true" failOnWarning="true"> diff --git a/src/Building/Build.php b/src/Building/Build.php index 884b170b..4ec00668 100644 --- a/src/Building/Build.php +++ b/src/Building/Build.php @@ -4,8 +4,8 @@ namespace Php\Pie\Building; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Building/UnixBuild.php b/src/Building/UnixBuild.php index 3130407d..ec91385f 100644 --- a/src/Building/UnixBuild.php +++ b/src/Building/UnixBuild.php @@ -4,8 +4,8 @@ namespace Php\Pie\Building; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Process; diff --git a/src/Building/WindowsBuild.php b/src/Building/WindowsBuild.php index 991ac77d..66cbff49 100644 --- a/src/Building/WindowsBuild.php +++ b/src/Building/WindowsBuild.php @@ -4,8 +4,8 @@ namespace Php\Pie\Building; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\PhpizePath; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Platform\WindowsExtensionAssetName; diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index b118d682..fd5588fa 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -85,7 +85,7 @@ public function execute(InputInterface $input, OutputInterface $output): int ); try { - ($this->composerIntegrationHandler)( + $this->composerIntegrationHandler->runInstall( $package, $composer, $targetPlatform, diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index 8614fd2f..489b2474 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use Php\Pie\DependencyResolver\Package; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\Platform as PiePlatform; use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Php\Pie\Platform\TargetPhp\PhpizePath; @@ -64,6 +65,12 @@ public static function configurePhpConfigOptions(Command $command): void InputOption::VALUE_REQUIRED, 'The path to the `php` binary to use as the target PHP platform on ' . OperatingSystem::Windows->asFriendlyName() . ', e.g. --' . self::OPTION_WITH_PHP_PATH . '=C:\usr\php7.4.33\php.exe', ); + $command->addOption( + self::OPTION_WITH_PHPIZE_PATH, + null, + InputOption::VALUE_REQUIRED, + 'The path to the `phpize` binary to use as the target PHP platform, e.g. --' . self::OPTION_WITH_PHPIZE_PATH . '=/usr/bin/phpize7.4', + ); } public static function configureDownloadBuildInstallOptions(Command $command): void @@ -71,7 +78,7 @@ public static function configureDownloadBuildInstallOptions(Command $command): v $command->addArgument( self::ARG_REQUESTED_PACKAGE_AND_VERSION, InputArgument::REQUIRED, - 'The extension name and version constraint to use, in the format {ext-name}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.', + 'The PIE package name and version constraint to use, in the format {vendor/package}{?:{?version-constraint}{?@stability}}, for example `xdebug/xdebug:^3.4@alpha`, `xdebug/xdebug:@alpha`, `xdebug/xdebug:^3.4`, etc.', ); $command->addOption( self::OPTION_MAKE_PARALLEL_JOBS, @@ -79,12 +86,6 @@ public static function configureDownloadBuildInstallOptions(Command $command): v InputOption::VALUE_REQUIRED, 'Override many jobs to run in parallel when running compiling (this is passed to "make -jN" during build). PIE will try to detect this by default.', ); - $command->addOption( - self::OPTION_WITH_PHPIZE_PATH, - null, - InputOption::VALUE_REQUIRED, - 'The path to the `phpize` binary to use as the target PHP platform, e.g. --' . self::OPTION_WITH_PHPIZE_PATH . '=/usr/bin/phpize7.4', - ); $command->addOption( self::OPTION_SKIP_ENABLE_EXTENSION, null, @@ -168,6 +169,13 @@ public static function determineTargetPlatformFromInputs(InputInterface $input, $targetPlatform->architecture->name, $phpBinaryPath->phpBinaryPath, )); + $output->writeln( + sprintf( + 'Using pie.json: %s', + PiePlatform::getPieJsonFilename($targetPlatform), + ), + OutputInterface::VERBOSITY_VERBOSE, + ); return $targetPlatform; } diff --git a/src/Command/DownloadCommand.php b/src/Command/DownloadCommand.php index 7d27adde..9c156459 100644 --- a/src/Command/DownloadCommand.php +++ b/src/Command/DownloadCommand.php @@ -69,7 +69,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Found package: %s which provides %s', $package->prettyNameAndVersion(), $package->extensionName()->nameWithExtPrefix())); try { - ($this->composerIntegrationHandler)( + $this->composerIntegrationHandler->runInstall( $package, $composer, $targetPlatform, diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 068f3c36..ae405949 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -90,7 +90,7 @@ public function execute(InputInterface $input, OutputInterface $output): int ); try { - ($this->composerIntegrationHandler)( + $this->composerIntegrationHandler->runInstall( $package, $composer, $targetPlatform, diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index 2c07282d..6bf3a9a7 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -4,25 +4,21 @@ namespace Php\Pie\Command; -use Composer\Package\BasePackage; -use Composer\Package\CompletePackageInterface; -use Php\Pie\BinaryFile; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieInstalledJsonMetadataKeys; -use Php\Pie\DependencyResolver\Package; +use Php\Pie\File\BinaryFile; +use Php\Pie\File\BinaryFileFailedVerification; +use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\OperatingSystem; -use Php\Pie\Platform\TargetPlatform; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; -use function array_combine; -use function array_filter; use function array_key_exists; -use function array_map; use function array_walk; use function file_exists; use function sprintf; @@ -38,6 +34,7 @@ final class ShowCommand extends Command { public function __construct( + private readonly InstalledPiePackages $installedPiePackages, private readonly ContainerInterface $container, ) { parent::__construct(); @@ -54,7 +51,15 @@ public function execute(InputInterface $input, OutputInterface $output): int { $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); - $piePackages = $this->buildListOfPieInstalledPackages($output, $targetPlatform); + $composer = PieComposerFactory::createPieComposer( + $this->container, + PieComposerRequest::noOperation( + new NullOutput(), + $targetPlatform, + ), + ); + + $piePackages = $this->installedPiePackages->allPiePackages($composer); $phpEnabledExtensions = $targetPlatform->phpBinaryPath->extensions(); $extensionPath = $targetPlatform->phpBinaryPath->extensionPath(); $extensionEnding = $targetPlatform->operatingSystem === OperatingSystem::Windows ? '.dll' : '.so'; @@ -99,10 +104,10 @@ private static function verifyChecksumInformation( string $extensionEnding, array $installedJsonMetadata, ): string { - $expectedConventionalBinaryPath = $extensionPath . DIRECTORY_SEPARATOR . $phpExtensionName . $extensionEnding; + $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($expectedConventionalBinaryPath)) { + if (! file_exists($actualBinaryPathByConvention)) { return ''; } @@ -110,53 +115,23 @@ private static function verifyChecksumInformation( $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 ($expectedConventionalBinaryPath !== $pieExpectedBinaryPath || $pieExpectedChecksum === null) { + if ( + $pieExpectedBinaryPath === null + || $pieExpectedChecksum === null + || $pieExpectedBinaryPath !== $actualBinaryPathByConvention + ) { return ''; } - $actualInstalledBinary = BinaryFile::fromFileWithSha256Checksum($expectedConventionalBinaryPath); - if ($actualInstalledBinary->checksum !== $pieExpectedChecksum) { - return ' ⚠️ was ' . substr($actualInstalledBinary->checksum, 0, 8) . '..., expected ' . substr($pieExpectedChecksum, 0, 8) . '...'; + $expectedBinaryFileFromMetadata = new BinaryFile($pieExpectedBinaryPath, $pieExpectedChecksum); + $actualBinaryFile = BinaryFile::fromFileWithSha256Checksum($actualBinaryPathByConvention); + + try { + $expectedBinaryFileFromMetadata->verifyAgainstOther($actualBinaryFile); + } catch (BinaryFileFailedVerification) { + return ' ⚠️ was ' . substr($actualBinaryFile->checksum, 0, 8) . '..., expected ' . substr($expectedBinaryFileFromMetadata->checksum, 0, 8) . '...'; } return ' ✅'; } - - /** @return array */ - private function buildListOfPieInstalledPackages( - OutputInterface $output, - TargetPlatform $targetPlatform, - ): array { - $composerInstalledPackages = array_map( - static function (CompletePackageInterface $package): Package { - return Package::fromComposerCompletePackage($package); - }, - array_filter( - PieComposerFactory::createPieComposer( - $this->container, - PieComposerRequest::noOperation( - $output, - $targetPlatform, - ), - ) - ->getRepositoryManager() - ->getLocalRepository() - ->getPackages(), - static function (BasePackage $basePackage): bool { - return $basePackage instanceof CompletePackageInterface; - }, - ), - ); - - return array_combine( - array_map( - /** @return non-empty-string */ - static function (Package $package): string { - return $package->extensionName()->name(); - }, - $composerInstalledPackages, - ), - $composerInstalledPackages, - ); - } } diff --git a/src/Command/UninstallCommand.php b/src/Command/UninstallCommand.php new file mode 100644 index 00000000..0e6997bf --- /dev/null +++ b/src/Command/UninstallCommand.php @@ -0,0 +1,128 @@ +addArgument( + self::ARG_PACKAGE_NAME, + InputArgument::REQUIRED, + 'The package name to remove, in the format {vendor/package}, for example `xdebug/xdebug`', + ); + + CommandHelper::configurePhpConfigOptions($this); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + if (Platform::isWindows()) { + /** + * @todo add support for uninstalling in Windows - see + * {@link https://github.com/php/pie/issues/190} for details + */ + $output->writeln('Uninstalling extensions on Windows is not currently supported.'); + + return 1; + } + + if (! TargetPlatform::isRunningAsRoot()) { + $output->writeln('This command may need elevated privileges, and may prompt you for your password.'); + } + + $packageToRemove = (string) $input->getArgument(self::ARG_PACKAGE_NAME); + Assert::stringNotEmpty($packageToRemove); + $requestedPackageAndVersionToRemove = new RequestedPackageAndVersion($packageToRemove, null); + + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output); + + $composer = PieComposerFactory::createPieComposer( + $this->container, + PieComposerRequest::noOperation( + new NullOutput(), + $targetPlatform, + ), + ); + + $piePackage = $this->findPiePackageByPackageName($packageToRemove, $composer); + + if ($piePackage === null) { + $output->writeln('No package found: ' . $packageToRemove . ''); + + return 1; + } + + $composer = PieComposerFactory::createPieComposer( + $this->container, + new PieComposerRequest( + $output, + $targetPlatform, + $requestedPackageAndVersionToRemove, + PieOperation::Uninstall, + [], // Configure options are not needed for uninstall + null, + true, + ), + ); + + $this->composerIntegrationHandler->runUninstall( + $piePackage, + $composer, + $targetPlatform, + $requestedPackageAndVersionToRemove, + ); + + return 0; + } + + private function findPiePackageByPackageName(string $packageToRemove, Composer $composer): Package|null + { + $piePackages = $this->installedPiePackages->allPiePackages($composer); + + foreach ($piePackages as $piePackage) { + if ($piePackage->name() === $packageToRemove) { + return $piePackage; + } + } + + return null; + } +} diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index d9b8f7b2..4c51f724 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -26,7 +26,7 @@ public function __construct( ) { } - public function __invoke( + public function runInstall( Package $package, Composer $composer, TargetPlatform $targetPlatform, @@ -88,4 +88,46 @@ public function __invoke( ($this->vendorCleanup)($composer); } + + public function runUninstall( + Package $packageToRemove, + Composer $composer, + TargetPlatform $targetPlatform, + RequestedPackageAndVersion $requestedPackageAndVersionToRemove, + ): void { + // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file + $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); + $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); + $originalPieJsonContent = $pieJsonEditor->removeRequire($requestedPackageAndVersionToRemove->package); + + // Refresh the Composer instance so it re-reads the updated pie.json + $composer = PieComposerFactory::recreatePieComposer($this->container, $composer); + + $composerInstaller = PieComposerInstaller::createWithPhpBinary( + $targetPlatform->phpBinaryPath, + $packageToRemove->extensionName(), + $this->arrayCollectionIo, + $composer, + ); + $composerInstaller + ->setAllowedTypes(['php-ext', 'php-ext-zend']) + ->setInstall(true) + ->setIgnoredTypes([]) + ->setDryRun(false) + ->setDownloadOnly(false); + + if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { + $composerInstaller->setUpdate(true); + $composerInstaller->setUpdateAllowList([$requestedPackageAndVersionToRemove->package]); + } + + $resultCode = $composerInstaller->run(); + + if ($resultCode !== Installer::ERROR_NONE) { + // Revert composer.json change + $pieJsonEditor->revert($originalPieJsonContent); + + throw ComposerRunFailed::fromExitCode($resultCode); + } + } } diff --git a/src/ComposerIntegration/InstalledJsonMetadata.php b/src/ComposerIntegration/InstalledJsonMetadata.php index 61bc857f..e91aaaaa 100644 --- a/src/ComposerIntegration/InstalledJsonMetadata.php +++ b/src/ComposerIntegration/InstalledJsonMetadata.php @@ -7,8 +7,8 @@ use Composer\Package\CompletePackage; use Composer\Package\CompletePackageInterface; use Composer\PartialComposer; -use Php\Pie\BinaryFile; use Php\Pie\ComposerIntegration\PieInstalledJsonMetadataKeys as MetadataKey; +use Php\Pie\File\BinaryFile; use Webmozart\Assert\Assert; use function array_merge; diff --git a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php index eb7a8a44..905a276f 100644 --- a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php +++ b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php @@ -8,6 +8,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Transaction; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; @@ -49,7 +50,7 @@ public function __invoke(InstallerEvent $installerEvent): void $newOperations = array_filter( $installerEvent->getTransaction()?->getOperations() ?? [], function (OperationInterface $operation) use ($pieOutput): bool { - if (! $operation instanceof InstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation) { $pieOutput->writeln( sprintf( 'Unexpected operation during installer: %s', diff --git a/src/ComposerIntegration/PieComposerFactory.php b/src/ComposerIntegration/PieComposerFactory.php index 7acb2352..ba9dcd28 100644 --- a/src/ComposerIntegration/PieComposerFactory.php +++ b/src/ComposerIntegration/PieComposerFactory.php @@ -9,6 +9,7 @@ use Composer\Installer; use Composer\IO\IOInterface; use Composer\PartialComposer; +use Composer\Repository\InstalledRepositoryInterface; use Composer\Util\Filesystem; use Composer\Util\ProcessExecutor; use Php\Pie\ComposerIntegration\Listeners\OverrideDownloadUrlInstallListener; @@ -38,6 +39,7 @@ protected function createDefaultInstallers(Installer\InstallationManager $im, Pa $type, $fs, $this->container->get(InstallAndBuildProcess::class), + $this->container->get(UninstallProcess::class), $this->composerRequest, ); }; @@ -70,6 +72,21 @@ public static function createPieComposer( return $composer; } + protected function purgePackages(InstalledRepositoryInterface $repo, Installer\InstallationManager $im): void + { + /** + * This is intentionally a no-op in PIE.... + * + * Why not purge packages? + * + * We have a post install job in {@see VendorCleanup} that cleans up the vendor directory to remove all the + * actual package files; however, this means that Composer thinks they are not installed after that. When + * creating the Composer instance, the last step is to purge packages from the + * {@see InstalledRepositoryInterface} if they no longer exist on disk. But, that means we can't list the + * packages installed with PIE any more! So, we override this method to become a no-op ✅ + */ + } + public static function recreatePieComposer( ContainerInterface $container, Composer $existingComposer, diff --git a/src/ComposerIntegration/PieJsonEditor.php b/src/ComposerIntegration/PieJsonEditor.php index 973bfbff..475bfc47 100644 --- a/src/ComposerIntegration/PieJsonEditor.php +++ b/src/ComposerIntegration/PieJsonEditor.php @@ -82,6 +82,26 @@ public function addRequire(string $package, string $version): string return $originalPieJsonContent; } + /** + * Remove a package from the `require` section of the given `pie.json`. + * Returns the original `pie.json` content, in case it needs to be + * restored later. + * + * @param non-empty-string $package + */ + public function removeRequire(string $package): string + { + $originalPieJsonContent = file_get_contents($this->pieJsonFilename); + + (new JsonConfigSource( + new JsonFile( + $this->pieJsonFilename, + ), + ))->removeLink('require', $package); + + return $originalPieJsonContent; + } + public function revert(string $originalPieJsonContent): void { file_put_contents($this->pieJsonFilename, $originalPieJsonContent); diff --git a/src/ComposerIntegration/PieOperation.php b/src/ComposerIntegration/PieOperation.php index a3345d51..1967ab2e 100644 --- a/src/ComposerIntegration/PieOperation.php +++ b/src/ComposerIntegration/PieOperation.php @@ -11,6 +11,7 @@ enum PieOperation case Download; case Build; case Install; + case Uninstall; public function shouldBuild(): bool { diff --git a/src/ComposerIntegration/PiePackageInstaller.php b/src/ComposerIntegration/PiePackageInstaller.php index d2185cc9..db6b025c 100644 --- a/src/ComposerIntegration/PiePackageInstaller.php +++ b/src/ComposerIntegration/PiePackageInstaller.php @@ -25,6 +25,7 @@ public function __construct( ExtensionType $type, Filesystem $filesystem, private readonly InstallAndBuildProcess $installAndBuildProcess, + private readonly UninstallProcess $uninstallProcess, private readonly PieComposerRequest $composerRequest, ) { parent::__construct($io, $composer, $type->value, $filesystem); @@ -71,4 +72,44 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa return null; }); } + + /** @inheritDoc */ + public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $package) + { + $composerPackage = $package; + + return parent::uninstall($repo, $composerPackage) + ?->then(function () use ($composerPackage) { + $output = $this->composerRequest->pieOutput; + + if ($this->composerRequest->requestedPackage->package !== $composerPackage->getName()) { + $output->writeln( + sprintf( + 'Skipping %s uninstall request from Composer as it was not the expected PIE package %s', + $composerPackage->getName(), + $this->composerRequest->requestedPackage->package, + ), + OutputInterface::VERBOSITY_VERY_VERBOSE, + ); + + return null; + } + + if (! $composerPackage instanceof CompletePackage) { + $output->writeln(sprintf( + 'Not using PIE to install %s as it was not a Complete Package', + $composerPackage->getName(), + )); + + return null; + } + + ($this->uninstallProcess)( + $this->composerRequest, + $composerPackage, + ); + + return null; + }); + } } diff --git a/src/ComposerIntegration/UninstallProcess.php b/src/ComposerIntegration/UninstallProcess.php new file mode 100644 index 00000000..6f4d747a --- /dev/null +++ b/src/ComposerIntegration/UninstallProcess.php @@ -0,0 +1,58 @@ +pieOutput; + + $piePackage = Package::fromComposerCompletePackage($composerPackage); + + $affectedIniFiles = ($this->removeIniEntry)($piePackage, $composerRequest->targetPlatform, $output); + + if (count($affectedIniFiles) === 1) { + $output->writeln( + sprintf('INI file "%s" was updated to remove the extension.', reset($affectedIniFiles)), + OutputInterface::VERBOSITY_VERBOSE, + ); + } elseif (count($affectedIniFiles) === 0) { + $output->writeln( + 'No INI files were updated to remove the extension.', + OutputInterface::VERBOSITY_VERBOSE, + ); + } else { + $output->writeln( + 'The following INI files were updated to remove the extnesion:', + OutputInterface::VERBOSITY_VERBOSE, + ); + array_walk($affectedIniFiles, static fn (string $ini) => $output->writeln(' - ' . $ini)); + } + + $output->writeln(sprintf('👋 Removed extension: %s', ($this->uninstall)($piePackage)->filePath)); + } +} diff --git a/src/Container.php b/src/Container.php index 6f8ef418..351db890 100644 --- a/src/Container.php +++ b/src/Container.php @@ -17,6 +17,7 @@ use Php\Pie\Command\RepositoryListCommand; use Php\Pie\Command\RepositoryRemoveCommand; use Php\Pie\Command\ShowCommand; +use Php\Pie\Command\UninstallCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\DependencyResolver\DependencyResolver; @@ -25,6 +26,8 @@ use Php\Pie\Downloading\PackageReleaseAssets; use Php\Pie\Installing\Ini; use Php\Pie\Installing\Install; +use Php\Pie\Installing\Uninstall; +use Php\Pie\Installing\UninstallUsingUnlink; use Php\Pie\Installing\UnixInstall; use Php\Pie\Installing\WindowsInstall; use Psr\Container\ContainerInterface; @@ -52,6 +55,7 @@ public static function factory(): ContainerInterface $container->singleton(RepositoryListCommand::class); $container->singleton(RepositoryAddCommand::class); $container->singleton(RepositoryRemoveCommand::class); + $container->singleton(UninstallCommand::class); $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO { return new QuieterConsoleIO( @@ -107,6 +111,10 @@ static function (ContainerInterface $container): Install { }, ); + $container->alias(UninstallUsingUnlink::class, Uninstall::class); + + $container->alias(Ini\RemoveIniEntryWithFileGetContents::class, Ini\RemoveIniEntry::class); + return $container; } } diff --git a/src/BinaryFile.php b/src/File/BinaryFile.php similarity index 50% rename from src/BinaryFile.php rename to src/File/BinaryFile.php index 54611187..db3ae942 100644 --- a/src/BinaryFile.php +++ b/src/File/BinaryFile.php @@ -2,8 +2,11 @@ declare(strict_types=1); -namespace Php\Pie; +namespace Php\Pie\File; +use Php\Pie\Util; + +use function file_exists; use function hash_file; /** @@ -33,4 +36,25 @@ public static function fromFileWithSha256Checksum(string $filePath): self hash_file(self::HASH_TYPE_SHA256, $filePath), ); } + + public function verify(): void + { + if (! file_exists($this->filePath)) { + throw Util\FileNotFound::fromFilename($this->filePath); + } + + self::verifyAgainstOther(self::fromFileWithSha256Checksum($this->filePath)); + } + + /** @throws BinaryFileFailedVerification */ + public function verifyAgainstOther(self $other): void + { + if ($this->filePath !== $other->filePath) { + throw BinaryFileFailedVerification::fromFilenameMismatch($this, $other); + } + + if ($other->checksum !== $this->checksum) { + throw BinaryFileFailedVerification::fromChecksumMismatch($this, $other); + } + } } diff --git a/src/File/BinaryFileFailedVerification.php b/src/File/BinaryFileFailedVerification.php new file mode 100644 index 00000000..7dd141bb --- /dev/null +++ b/src/File/BinaryFileFailedVerification.php @@ -0,0 +1,32 @@ +filePath, + $actual->filePath, + )); + } + + public static function fromChecksumMismatch(BinaryFile $expected, BinaryFile $actual): self + { + return new self(sprintf( + 'File "%s" failed checksum verification. Expected %s..., was %s...', + $expected->filePath, + substr($expected->checksum, 0, 8), + substr($actual->checksum, 0, 8), + )); + } +} diff --git a/src/File/FailedToUnlinkFile.php b/src/File/FailedToUnlinkFile.php new file mode 100644 index 00000000..745ac02f --- /dev/null +++ b/src/File/FailedToUnlinkFile.php @@ -0,0 +1,47 @@ +getMessage(), + ), + previous: $processFailed, + ); + } +} diff --git a/src/File/FailedToWriteFile.php b/src/File/FailedToWriteFile.php new file mode 100644 index 00000000..b856ea48 --- /dev/null +++ b/src/File/FailedToWriteFile.php @@ -0,0 +1,26 @@ +find('sudo'); + + if ($sudo === null || $sudo === '') { + throw SudoNotFoundOnSystem::new(); + } + + self::$memoizedSudo = $sudo; + } + + return self::$memoizedSudo; + } + + public static function exists(): bool + { + try { + self::find(); + + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/src/File/SudoFilePut.php b/src/File/SudoFilePut.php new file mode 100644 index 00000000..32cdcc19 --- /dev/null +++ b/src/File/SudoFilePut.php @@ -0,0 +1,66 @@ + file_put_contents($filename, $content), + $capturedErrors, + ); + + if ($writeSuccessful === false) { + throw FailedToWriteFile::fromFilePutContentErrors($filename, $capturedErrors); + } + + if (! isset($previousPermissions) || ! is_string($previousPermissions) || ! $didChangePermissions || ! Sudo::exists()) { + return; + } + + Process::run([Sudo::find(), 'chmod', $previousPermissions, $filename]); + } + + private static function attemptToMakeFileEditable(string $filename): bool + { + if (! Sudo::exists()) { + return false; + } + + if (! is_writable($filename)) { + try { + Process::run([Sudo::find(), 'chmod', '0777', $filename]); + + return true; + } catch (ProcessFailedException) { + return false; + } + } + + return false; + } +} diff --git a/src/File/SudoNotFoundOnSystem.php b/src/File/SudoNotFoundOnSystem.php new file mode 100644 index 00000000..e3818aa3 --- /dev/null +++ b/src/File/SudoNotFoundOnSystem.php @@ -0,0 +1,15 @@ + unlink($filename), + $capturedErrors, + ); + + if (! $unlinkSuccessful || file_exists($filename)) { + throw FailedToUnlinkFile::fromUnlinkErrors($filename, $capturedErrors); + } + + return; + } + + if (! Sudo::exists()) { + throw FailedToUnlinkFile::fromNoPermissions($filename); + } + + try { + Process::run([Sudo::find(), 'rm', $filename]); + } catch (ProcessFailedException $processFailedException) { + throw FailedToUnlinkFile::fromSudoRmProcessFailed($filename, $processFailedException); + } + + if (! file_exists($filename)) { + return; + } + + FailedToUnlinkFile::fromNoPermissions($filename); + } +} diff --git a/src/Installing/FailedToRemoveExtension.php b/src/Installing/FailedToRemoveExtension.php new file mode 100644 index 00000000..f5004db2 --- /dev/null +++ b/src/Installing/FailedToRemoveExtension.php @@ -0,0 +1,25 @@ +filePath, + ), + previous: $previous, + ); + } +} diff --git a/src/Installing/Ini/AddExtensionToTheIniFile.php b/src/Installing/Ini/AddExtensionToTheIniFile.php index f4cece32..94ba60d9 100644 --- a/src/Installing/Ini/AddExtensionToTheIniFile.php +++ b/src/Installing/Ini/AddExtensionToTheIniFile.php @@ -6,12 +6,13 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\ExtensionType; +use Php\Pie\File\Sudo; +use Php\Pie\File\SudoFilePut; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; use Symfony\Component\Console\Output\OutputInterface; use Throwable; use function file_get_contents; -use function file_put_contents; use function is_string; use function is_writable; use function sprintf; @@ -29,7 +30,7 @@ public function __invoke( OutputInterface $output, callable|null $additionalEnableStep, ): bool { - if (! is_writable($ini)) { + if (! is_writable($ini) && ! Sudo::exists()) { $output->writeln( sprintf( 'PHP is configured to use %s, but it is not writable by PIE.', @@ -56,7 +57,7 @@ public function __invoke( } try { - file_put_contents( + SudoFilePut::contents( $ini, $originalIniContent . $this->iniFileContent($package), ); @@ -77,7 +78,7 @@ public function __invoke( return true; } catch (Throwable $anything) { - file_put_contents($ini, $originalIniContent); + SudoFilePut::contents($ini, $originalIniContent); $output->writeln(sprintf( 'Something went wrong enabling the %s extension: %s', diff --git a/src/Installing/Ini/DockerPhpExtEnable.php b/src/Installing/Ini/DockerPhpExtEnable.php index 195b80b1..588a5e02 100644 --- a/src/Installing/Ini/DockerPhpExtEnable.php +++ b/src/Installing/Ini/DockerPhpExtEnable.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\Exception\ExtensionIsNotLoaded; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Process; diff --git a/src/Installing/Ini/OndrejPhpenmod.php b/src/Installing/Ini/OndrejPhpenmod.php index 7c52d7de..7d3865dc 100644 --- a/src/Installing/Ini/OndrejPhpenmod.php +++ b/src/Installing/Ini/OndrejPhpenmod.php @@ -5,13 +5,16 @@ namespace Php\Pie\Installing\Ini; use Composer\Util\Platform; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; +use Php\Pie\File\Sudo; +use Php\Pie\File\SudoUnlink; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Process; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Exception\ProcessFailedException; +use function array_unshift; use function file_exists; use function is_dir; use function is_writable; @@ -19,7 +22,6 @@ use function rtrim; use function sprintf; use function touch; -use function unlink; use const DIRECTORY_SEPARATOR; @@ -103,16 +105,21 @@ public function setup( return false; } + $needSudo = false; if (! is_writable($expectedModsAvailablePath)) { - $output->writeln( - sprintf( - 'Mods available path %s is not writable', - $expectedModsAvailablePath, - ), - OutputInterface::VERBOSITY_VERBOSE, - ); - - return false; + if (! Sudo::exists()) { + $output->writeln( + sprintf( + 'Mods available path %s is not writable', + $expectedModsAvailablePath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + $needSudo = true; } $expectedIniFile = sprintf( @@ -140,16 +147,23 @@ public function setup( $targetPlatform, $downloadedPackage, $output, - static function () use ($phpenmodPath, $targetPlatform, $downloadedPackage, $output): bool { + static function () use ($needSudo, $phpenmodPath, $targetPlatform, $downloadedPackage, $output): bool { try { - Process::run([ + $processArgs = [ $phpenmodPath, '-v', $targetPlatform->phpBinaryPath->majorMinorVersion(), '-s', 'ALL', $downloadedPackage->package->extensionName()->name(), - ]); + ]; + + if ($needSudo && Sudo::exists()) { + $output->writeln('Using sudo to elevate privileges for phpenmod', OutputInterface::VERBOSITY_VERBOSE); + array_unshift($processArgs, Sudo::find()); + } + + Process::run($processArgs); return true; } catch (ProcessFailedException $processFailedException) { @@ -170,7 +184,7 @@ static function () use ($phpenmodPath, $targetPlatform, $downloadedPackage, $out ); if (! $addingExtensionWasSuccessful && $pieCreatedTheIniFile) { - unlink($expectedIniFile); + SudoUnlink::singleFile($expectedIniFile); } return $addingExtensionWasSuccessful; diff --git a/src/Installing/Ini/PickBestSetupIniApproach.php b/src/Installing/Ini/PickBestSetupIniApproach.php index 83c719d3..a122491e 100644 --- a/src/Installing/Ini/PickBestSetupIniApproach.php +++ b/src/Installing/Ini/PickBestSetupIniApproach.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPlatform; use ReflectionClass; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/Ini/PreCheckExtensionAlreadyLoaded.php b/src/Installing/Ini/PreCheckExtensionAlreadyLoaded.php index 9ae8c55f..23c8b0ce 100644 --- a/src/Installing/Ini/PreCheckExtensionAlreadyLoaded.php +++ b/src/Installing/Ini/PreCheckExtensionAlreadyLoaded.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPhp\Exception\ExtensionIsNotLoaded; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/Ini/RemoveIniEntry.php b/src/Installing/Ini/RemoveIniEntry.php new file mode 100644 index 00000000..347c9168 --- /dev/null +++ b/src/Installing/Ini/RemoveIniEntry.php @@ -0,0 +1,16 @@ + Returns a list of INI files that were updated to remove the extension */ + public function __invoke(Package $package, TargetPlatform $targetPlatform, OutputInterface $output): array; +} diff --git a/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php b/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php new file mode 100644 index 00000000..6b4235c5 --- /dev/null +++ b/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php @@ -0,0 +1,106 @@ + Returns a list of INI files that were updated to remove the extension */ + public function __invoke(Package $package, TargetPlatform $targetPlatform, OutputInterface $output): array + { + $allIniFiles = []; + + $mainIni = $targetPlatform->phpBinaryPath->loadedIniConfigurationFile(); + if ($mainIni !== null) { + $allIniFiles[] = $mainIni; + } + + $additionalIniDirectory = $targetPlatform->phpBinaryPath->additionalIniDirectory(); + if ($additionalIniDirectory !== null) { + $allIniFiles = array_merge( + array_map( + static function (string $path) use ($additionalIniDirectory): string { + return $additionalIniDirectory . DIRECTORY_SEPARATOR . $path; + }, + array_filter( + scandir($additionalIniDirectory), + static function (string $path) use ($additionalIniDirectory): bool { + if (in_array($path, ['.', '..'])) { + return false; + } + + return file_exists($additionalIniDirectory . DIRECTORY_SEPARATOR . $path); + }, + ), + ), + $allIniFiles, + ); + } + + $regex = sprintf( + '/^(%s\w*=\w*%s)/m', + $package->extensionType() === ExtensionType::PhpModule ? 'extension' : 'zend_extension', + $package->extensionName()->name(), + ); + + $updatedIniFiles = []; + array_walk( + $allIniFiles, + static function (string $iniFile) use (&$updatedIniFiles, $regex, $package, $output): void { + $currentContent = file_get_contents($iniFile); + + if ($currentContent === false || $currentContent === '') { + return; + } + + $replacedContent = preg_replace( + $regex, + '; $1 ; removed by PIE', + $currentContent, + ); + + if ($replacedContent === null || $replacedContent === $currentContent) { + return; + } + + try { + SudoFilePut::contents($iniFile, $replacedContent); + } catch (FailedToWriteFile) { + $output->writeln(sprintf( + 'Failed to remove extension "%s" from INI file "%s"', + $package->extensionName()->name(), + $iniFile, + )); + + return; + } + + $updatedIniFiles[] = $iniFile; + }, + ); + + return $updatedIniFiles; + } +} diff --git a/src/Installing/Ini/SetupIniApproach.php b/src/Installing/Ini/SetupIniApproach.php index 37870e90..0e003e0d 100644 --- a/src/Installing/Ini/SetupIniApproach.php +++ b/src/Installing/Ini/SetupIniApproach.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/Ini/StandardAdditionalPhpIniDirectory.php b/src/Installing/Ini/StandardAdditionalPhpIniDirectory.php index af94dab8..b2744360 100644 --- a/src/Installing/Ini/StandardAdditionalPhpIniDirectory.php +++ b/src/Installing/Ini/StandardAdditionalPhpIniDirectory.php @@ -4,8 +4,11 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; +use Php\Pie\File\Sudo; +use Php\Pie\File\SudoFilePut; +use Php\Pie\File\SudoUnlink; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; @@ -13,8 +16,6 @@ use function is_writable; use function rtrim; use function sprintf; -use function touch; -use function unlink; use const DIRECTORY_SEPARATOR; @@ -44,10 +45,22 @@ public function setup( return false; } - if (! file_exists($additionalIniFilesPath) || ! is_writable($additionalIniFilesPath)) { + if (! file_exists($additionalIniFilesPath)) { $output->writeln( sprintf( - 'PHP is configured to use additional INI file path %s, but it did not exist, or is not writable by PIE.', + 'PHP is configured to use additional INI file path %s, but it did not exist.', + $additionalIniFilesPath, + ), + OutputInterface::VERBOSITY_VERBOSE, + ); + + return false; + } + + if (! is_writable($additionalIniFilesPath) && ! Sudo::exists()) { + $output->writeln( + sprintf( + 'PHP is configured to use additional INI file path %s, but it was not writable by PIE.', $additionalIniFilesPath, ), OutputInterface::VERBOSITY_VERBOSE, @@ -74,7 +87,7 @@ public function setup( OutputInterface::VERBOSITY_VERY_VERBOSE, ); $pieCreatedTheIniFile = true; - touch($expectedIniFile); + SudoFilePut::contents($expectedIniFile, ''); } $addingExtensionWasSuccessful = ($this->checkAndAddExtensionToIniIfNeeded)( @@ -86,7 +99,7 @@ public function setup( ); if (! $addingExtensionWasSuccessful && $pieCreatedTheIniFile) { - unlink($expectedIniFile); + SudoUnlink::singleFile($expectedIniFile); } return $addingExtensionWasSuccessful; diff --git a/src/Installing/Ini/StandardSinglePhpIni.php b/src/Installing/Ini/StandardSinglePhpIni.php index 0eb046e8..decaf6da 100644 --- a/src/Installing/Ini/StandardSinglePhpIni.php +++ b/src/Installing/Ini/StandardSinglePhpIni.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing\Ini; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/Install.php b/src/Installing/Install.php index 17982b41..0e1b3fee 100644 --- a/src/Installing/Install.php +++ b/src/Installing/Install.php @@ -4,8 +4,8 @@ namespace Php\Pie\Installing; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/PackageMetadataMissing.php b/src/Installing/PackageMetadataMissing.php new file mode 100644 index 00000000..50c5ff7a --- /dev/null +++ b/src/Installing/PackageMetadataMissing.php @@ -0,0 +1,33 @@ + $actualMetadata + * @param list $wantedKeys + */ + public static function duringUninstall(Package $package, array $actualMetadata, array $wantedKeys): self + { + $missingKeys = array_diff($wantedKeys, array_keys($actualMetadata)); + + return new self(sprintf( + 'PIE metadata was missing for package %s. Missing metadata key%s: %s', + $package->name(), + count($missingKeys) === 1 ? '' : 's', + implode(', ', $missingKeys), + )); + } +} diff --git a/src/Installing/SetupIniFile.php b/src/Installing/SetupIniFile.php index 35b11f5c..e169246c 100644 --- a/src/Installing/SetupIniFile.php +++ b/src/Installing/SetupIniFile.php @@ -4,9 +4,9 @@ namespace Php\Pie\Installing; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\ExtensionType; +use Php\Pie\File\BinaryFile; use Php\Pie\Installing\Ini\SetupIniApproach; use Php\Pie\Platform\TargetPlatform; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Installing/Uninstall.php b/src/Installing/Uninstall.php new file mode 100644 index 00000000..1c72af83 --- /dev/null +++ b/src/Installing/Uninstall.php @@ -0,0 +1,14 @@ +composerPackage()); + + if ( + ! array_key_exists(PieInstalledJsonMetadataKeys::InstalledBinary->value, $pieMetadata) + || ! array_key_exists(PieInstalledJsonMetadataKeys::BinaryChecksum->value, $pieMetadata) + ) { + throw PackageMetadataMissing::duringUninstall( + $package, + $pieMetadata, + [ + PieInstalledJsonMetadataKeys::InstalledBinary->value, + PieInstalledJsonMetadataKeys::BinaryChecksum->value, + ], + ); + } + + $expectedBinaryFile = new BinaryFile( + $pieMetadata[PieInstalledJsonMetadataKeys::InstalledBinary->value], + $pieMetadata[PieInstalledJsonMetadataKeys::BinaryChecksum->value], + ); + + $expectedBinaryFile->verify(); + + // If the target directory isn't writable, or a .so file already exists and isn't writable, try to use sudo + if (file_exists($expectedBinaryFile->filePath) && ! is_writable($expectedBinaryFile->filePath) && Sudo::exists()) { + Process::run([Sudo::find(), 'rm', $expectedBinaryFile->filePath]); + + // Removal worked, bail out + if (! file_exists($expectedBinaryFile->filePath)) { + return $expectedBinaryFile; + } + } + + try { + SudoUnlink::singleFile($expectedBinaryFile->filePath); + } catch (FailedToUnlinkFile $failedToUnlinkFile) { + throw FailedToRemoveExtension::withFilename($expectedBinaryFile, $failedToUnlinkFile); + } + + return $expectedBinaryFile; + } +} diff --git a/src/Installing/UnixInstall.php b/src/Installing/UnixInstall.php index efe9beae..571cc4df 100644 --- a/src/Installing/UnixInstall.php +++ b/src/Installing/UnixInstall.php @@ -4,8 +4,9 @@ namespace Php\Pie\Installing; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; +use Php\Pie\File\BinaryFile; +use Php\Pie\File\Sudo; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Util\Process; use RuntimeException; @@ -44,14 +45,17 @@ public function __invoke( // If the target directory isn't writable, or a .so file already exists and isn't writable, try to use sudo if ( - ! is_writable($targetExtensionPath) - || (file_exists($expectedSharedObjectLocation) && ! is_writable($expectedSharedObjectLocation)) + Sudo::exists() + && ( + ! is_writable($targetExtensionPath) + || (file_exists($expectedSharedObjectLocation) && ! is_writable($expectedSharedObjectLocation)) + ) ) { $output->writeln(sprintf( 'Cannot write to %s, so using sudo to elevate privileges.', $targetExtensionPath, )); - array_unshift($makeInstallCommand, 'sudo'); + array_unshift($makeInstallCommand, Sudo::find()); } $makeInstallOutput = Process::run( diff --git a/src/Installing/WindowsInstall.php b/src/Installing/WindowsInstall.php index 29d4ef2b..23c8f2a9 100644 --- a/src/Installing/WindowsInstall.php +++ b/src/Installing/WindowsInstall.php @@ -4,9 +4,9 @@ namespace Php\Pie\Installing; -use Php\Pie\BinaryFile; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\ExtensionType; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\TargetPlatform; use Php\Pie\Platform\WindowsExtensionAssetName; use RecursiveDirectoryIterator; diff --git a/src/Platform/InstalledPiePackages.php b/src/Platform/InstalledPiePackages.php new file mode 100644 index 00000000..d303d9a8 --- /dev/null +++ b/src/Platform/InstalledPiePackages.php @@ -0,0 +1,57 @@ + + */ +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 + { + $composerInstalledPackages = array_map( + static function (CompletePackageInterface $package): Package { + return Package::fromComposerCompletePackage($package); + }, + array_filter( + $composer + ->getRepositoryManager() + ->getLocalRepository() + ->getPackages(), + static function (BasePackage $basePackage): bool { + return $basePackage instanceof CompletePackageInterface; + }, + ), + ); + + return array_combine( + array_map( + /** @return non-empty-string */ + static function (Package $package): string { + return $package->extensionName()->name(); + }, + $composerInstalledPackages, + ), + $composerInstalledPackages, + ); + } +} diff --git a/src/Util/CaptureErrors.php b/src/Util/CaptureErrors.php new file mode 100644 index 00000000..4d706a48 --- /dev/null +++ b/src/Util/CaptureErrors.php @@ -0,0 +1,44 @@ + + */ +final class CaptureErrors +{ + /** + * @param callable():T $code + * @param CapturedErrorList $captured + * + * @return T + * + * @template T + */ + public static function for(callable $code, array &$captured): mixed + { + set_error_handler(static function (int $level, string $message, string $filename, int $line) use (&$captured): bool { + $captured[] = [ + 'level' => $level, + 'message' => $message, + 'filename' => $filename, + 'line' => $line, + ]; + + return true; + }); + + $returnValue = $code(); + + restore_error_handler(); + + return $returnValue; + } +} diff --git a/src/Util/FileNotFound.php b/src/Util/FileNotFound.php new file mode 100644 index 00000000..445bfe50 --- /dev/null +++ b/src/Util/FileNotFound.php @@ -0,0 +1,20 @@ +runPieCommand(['install', 'asgrim/example-pie-extension']); } + #[When('I run a command to uninstall an extension')] + public function iRunACommandToUninstallAnExtension(): void + { + $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + } + + #[Then('the extension should not be installed anymore')] + public function theExtensionShouldNotBeInstalled(): void + { + $this->assertCommandSuccessful(); + + if (Platform::isWindows()) { + Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_example_pie_extension.dll#'); + } else { + Assert::regex($this->output, '#👋 Removed extension: [-_a-zA-Z0-9/]+/example_pie_extension.so#'); + } + + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("example_pie_extension")?"yes":"no";'])) + ->mustRun() + ->getOutput(); + + Assert::same($isExtEnabled, 'no'); + } + #[Then('the extension should have been installed')] public function theExtensionShouldHaveBeenInstalled(): void { @@ -138,7 +163,7 @@ public function theExtensionShouldHaveBeenInstalled(): void ->mustRun() ->getOutput(); - Assert::same('yes', $isExtEnabled); + Assert::same($isExtEnabled, 'yes'); } #[Given('I have an invalid extension installed')] diff --git a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php index a0137f5b..5d3f5a94 100644 --- a/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php +++ b/test/unit/ComposerIntegration/InstallAndBuildProcessTest.php @@ -6,13 +6,13 @@ use Composer\Package\CompletePackage; use Composer\PartialComposer; -use Php\Pie\BinaryFile; use Php\Pie\Building\Build; use Php\Pie\ComposerIntegration\InstallAndBuildProcess; use Php\Pie\ComposerIntegration\InstalledJsonMetadata; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\File\BinaryFile; use Php\Pie\Installing\Install; use Php\Pie\Platform\Architecture; use Php\Pie\Platform\OperatingSystem; diff --git a/test/unit/ComposerIntegration/InstalledJsonMetadataTest.php b/test/unit/ComposerIntegration/InstalledJsonMetadataTest.php index 48790e74..a4df418d 100644 --- a/test/unit/ComposerIntegration/InstalledJsonMetadataTest.php +++ b/test/unit/ComposerIntegration/InstalledJsonMetadataTest.php @@ -8,11 +8,11 @@ use Composer\Package\CompletePackage; use Composer\Repository\InstalledArrayRepository; use Composer\Repository\RepositoryManager; -use Php\Pie\BinaryFile; use Php\Pie\ComposerIntegration\InstalledJsonMetadata; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\ComposerIntegration\PieOperation; use Php\Pie\DependencyResolver\RequestedPackageAndVersion; +use Php\Pie\File\BinaryFile; use Php\Pie\Platform\Architecture; use Php\Pie\Platform\OperatingSystem; use Php\Pie\Platform\OperatingSystemFamily; diff --git a/test/unit/ComposerIntegration/PieJsonEditorTest.php b/test/unit/ComposerIntegration/PieJsonEditorTest.php index 29edc044..164ad9e2 100644 --- a/test/unit/ComposerIntegration/PieJsonEditorTest.php +++ b/test/unit/ComposerIntegration/PieJsonEditorTest.php @@ -53,6 +53,12 @@ public function testCanAddRequire(): void EOF), $this->normaliseJson(file_get_contents($testPieJson)), ); + + $editor->removeRequire('foo/bar'); + self::assertSame( + $this->normaliseJson('{}'), + $this->normaliseJson(file_get_contents($testPieJson)), + ); } public function testCanRevert(): void diff --git a/test/unit/ComposerIntegration/PieOperationTest.php b/test/unit/ComposerIntegration/PieOperationTest.php index 8387800a..a6f00dd2 100644 --- a/test/unit/ComposerIntegration/PieOperationTest.php +++ b/test/unit/ComposerIntegration/PieOperationTest.php @@ -17,6 +17,7 @@ public function testShouldBuild(): void self::assertFalse(PieOperation::Download->shouldBuild()); self::assertTrue(PieOperation::Build->shouldBuild()); self::assertTrue(PieOperation::Install->shouldBuild()); + self::assertFalse(PieOperation::Uninstall->shouldBuild()); } public function testShouldInstall(): void @@ -25,5 +26,6 @@ public function testShouldInstall(): void self::assertFalse(PieOperation::Download->shouldInstall()); self::assertFalse(PieOperation::Build->shouldInstall()); self::assertTrue(PieOperation::Install->shouldInstall()); + self::assertFalse(PieOperation::Uninstall->shouldBuild()); } } diff --git a/test/unit/File/BinaryFileTest.php b/test/unit/File/BinaryFileTest.php new file mode 100644 index 00000000..0b7fc210 --- /dev/null +++ b/test/unit/File/BinaryFileTest.php @@ -0,0 +1,67 @@ +expectNotToPerformAssertions(); + $expectation->verify(); + } + + public function testVerifyFailsWithFileThatDoesNotExist(): void + { + $expectation = new BinaryFile( + '/path/to/a/file/that/does/not/exist', + self::TEST_FILE_HASH, + ); + + $this->expectException(FileNotFound::class); + $expectation->verify(); + } + + public function testVerifyFailsWithWrongHash(): void + { + $expectation = new BinaryFile( + self::TEST_FILE, + 'another hash that is wrong', + ); + + $this->expectException(BinaryFileFailedVerification::class); + $this->expectExceptionMessageMatches('/File "[^"]+" failed checksum verification\. Expected [^\.]+\.\.\., was [^\.]+\.\.\./'); + $expectation->verify(); + } + + public function testVerifyFailsWithDifferentFile(): void + { + $expectation = new BinaryFile( + self::TEST_FILE, + self::TEST_FILE_HASH, + ); + + $this->expectException(BinaryFileFailedVerification::class); + $this->expectExceptionMessageMatches('/Expected file "[^"]+" but actual file was "[^"]+"/'); + $expectation->verifyAgainstOther(new BinaryFile( + __FILE__, + self::TEST_FILE_HASH, + )); + } +} diff --git a/test/unit/File/SudoFilePutTest.php b/test/unit/File/SudoFilePutTest.php new file mode 100644 index 00000000..64707640 --- /dev/null +++ b/test/unit/File/SudoFilePutTest.php @@ -0,0 +1,38 @@ +iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_remove_ini_test', true); + mkdir($this->iniFilePath); + Assert::positiveInteger(file_put_contents( + $this->iniFilePath . DIRECTORY_SEPARATOR . 'with_commented_exts.ini', + self::INI_WITH_COMMENTED_EXTS, + )); + Assert::positiveInteger(file_put_contents( + $this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini', + self::INI_WITH_ACTIVE_EXTS, + )); + } + + public function tearDown(): void + { + parent::tearDown(); + + (new Filesystem())->remove($this->iniFilePath); + } + + /** + * @return array + * + * @psalm-suppress PossiblyUnusedMethod https://github.com/psalm/psalm-plugin-phpunit/issues/131 + */ + public static function extensionTypeProvider(): array + { + return [ + 'phpModule' => [ExtensionType::PhpModule, "; extension=foobar ; removed by PIE\nzend_extension=foobar\n"], + 'zendExtension' => [ExtensionType::ZendExtension, "extension=foobar\n; zend_extension=foobar ; removed by PIE\n"], + ]; + } + + #[DataProvider('extensionTypeProvider')] + public function testRelevantIniFilesHaveExtensionRemoved(ExtensionType $extensionType, string $expectedActiveContent): void + { + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath + ->method('loadedIniConfigurationFile') + ->willReturn(null); + $phpBinaryPath + ->method('additionalIniDirectory') + ->willReturn($this->iniFilePath); + + $package = new Package( + $this->createMock(CompletePackageInterface::class), + $extensionType, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinaryPath, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + ); + + $affectedFiles = (new RemoveIniEntryWithFileGetContents())( + $package, + $targetPlatform, + $this->createMock(OutputInterface::class), + ); + + self::assertSame( + [$this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini'], + $affectedFiles, + ); + + self::assertSame( + self::INI_WITH_COMMENTED_EXTS, + file_get_contents($this->iniFilePath . DIRECTORY_SEPARATOR . 'with_commented_exts.ini'), + ); + + self::assertSame( + $expectedActiveContent, + file_get_contents($this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini'), + ); + } +} diff --git a/test/unit/Installing/Ini/StandardAdditionalPhpIniDirectoryTest.php b/test/unit/Installing/Ini/StandardAdditionalPhpIniDirectoryTest.php index 45a073d6..9ecc06a0 100644 --- a/test/unit/Installing/Ini/StandardAdditionalPhpIniDirectoryTest.php +++ b/test/unit/Installing/Ini/StandardAdditionalPhpIniDirectoryTest.php @@ -5,11 +5,11 @@ namespace Php\PieUnitTest\Installing\Ini; use Composer\Package\CompletePackageInterface; -use Php\Pie\BinaryFile; use Php\Pie\DependencyResolver\Package; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; +use Php\Pie\File\BinaryFile; use Php\Pie\Installing\Ini\CheckAndAddExtensionToIniIfNeeded; use Php\Pie\Installing\Ini\StandardAdditionalPhpIniDirectory; use Php\Pie\Platform\Architecture; @@ -145,7 +145,7 @@ public function testSetupReturnsWhenAdditionalPhpIniDirectoryDoesNotExist(): voi $this->output, )); self::assertStringContainsString( - 'PHP is configured to use additional INI file path /path/to/something/does/not/exist, but it did not exist, or is not writable by PIE.', + 'PHP is configured to use additional INI file path /path/to/something/does/not/exist, but it did not exist', $this->output->fetch(), ); } diff --git a/test/unit/Installing/Ini/StandardSinglePhpIniTest.php b/test/unit/Installing/Ini/StandardSinglePhpIniTest.php index 21f47714..3d35fd0e 100644 --- a/test/unit/Installing/Ini/StandardSinglePhpIniTest.php +++ b/test/unit/Installing/Ini/StandardSinglePhpIniTest.php @@ -5,11 +5,11 @@ namespace Php\PieUnitTest\Installing\Ini; use Composer\Package\CompletePackageInterface; -use Php\Pie\BinaryFile; use Php\Pie\DependencyResolver\Package; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; +use Php\Pie\File\BinaryFile; use Php\Pie\Installing\Ini\CheckAndAddExtensionToIniIfNeeded; use Php\Pie\Installing\Ini\StandardSinglePhpIni; use Php\Pie\Platform\Architecture; diff --git a/test/unit/Installing/PackageMetadataMissingTest.php b/test/unit/Installing/PackageMetadataMissingTest.php new file mode 100644 index 00000000..5fdc5a78 --- /dev/null +++ b/test/unit/Installing/PackageMetadataMissingTest.php @@ -0,0 +1,43 @@ +createMock(CompletePackageInterface::class), + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foo/bar', + '1.2.3', + null, + ); + + $exception = PackageMetadataMissing::duringUninstall( + $package, + [ + 'a' => 'something', + 'b' => 'something else', + ], + ['b', 'c', 'd'], + ); + + self::assertSame( + 'PIE metadata was missing for package foo/bar. Missing metadata keys: c, d', + $exception->getMessage(), + ); + } +} diff --git a/test/unit/Installing/UninstallUsingUnlinkTest.php b/test/unit/Installing/UninstallUsingUnlinkTest.php new file mode 100644 index 00000000..8b928d14 --- /dev/null +++ b/test/unit/Installing/UninstallUsingUnlinkTest.php @@ -0,0 +1,76 @@ +createMock(CompletePackageInterface::class); + $composerPackage + ->method('getExtra') + ->willReturn([]); + + $package = new Package( + $composerPackage, + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $this->expectException(PackageMetadataMissing::class); + $this->expectExceptionMessage('PIE metadata was missing for package foobar/foobar. Missing metadata keys: pie-installed-binary, pie-installed-binary-checksum'); + (new UninstallUsingUnlink())($package); + } + + public function testBinaryFileIsRemoved(): void + { + $testFilename = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_uninstall_binary_test_', true); + file_put_contents($testFilename, 'test content'); + $testHash = hash_file('sha256', $testFilename); + + $composerPackage = $this->createMock(CompletePackageInterface::class); + $composerPackage + ->method('getExtra') + ->willReturn([ + PieInstalledJsonMetadataKeys::InstalledBinary->value => $testFilename, + PieInstalledJsonMetadataKeys::BinaryChecksum->value => $testHash, + ]); + + $package = new Package( + $composerPackage, + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $uninstalled = (new UninstallUsingUnlink())($package); + + self::assertSame($testFilename, $uninstalled->filePath); + self::assertFileDoesNotExist($testFilename); + } +} diff --git a/test/unit/Platform/InstalledPiePackagesTest.php b/test/unit/Platform/InstalledPiePackagesTest.php new file mode 100644 index 00000000..22e6357a --- /dev/null +++ b/test/unit/Platform/InstalledPiePackagesTest.php @@ -0,0 +1,42 @@ +createMock(InstalledRepositoryInterface::class); + $localRepo->method('getPackages')->willReturn([ + new CompletePackage('foo/bar1', '1.2.3.0', '1.2.3'), + new CompletePackage('foo/bar2', '1.2.3.0', '1.2.3'), + ]); + + $repoManager = $this->createMock(RepositoryManager::class); + $repoManager->method('getLocalRepository')->willReturn($localRepo); + + $composer = $this->createMock(Composer::class); + $composer->method('getRepositoryManager')->willReturn($repoManager); + + $packages = (new InstalledPiePackages())->allPiePackages($composer); + + self::assertArrayHasKey('bar1', $packages); + self::assertArrayHasKey('bar2', $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()); + } +} diff --git a/test/unit/Util/CaptureErrorsTest.php b/test/unit/Util/CaptureErrorsTest.php new file mode 100644 index 00000000..a1cddea9 --- /dev/null +++ b/test/unit/Util/CaptureErrorsTest.php @@ -0,0 +1,38 @@ +