From ac19411d4828974b2135b7e05f2763a574055319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20FIDRY?= Date: Mon, 27 Nov 2023 17:45:12 +0100 Subject: [PATCH] feat: Add a command to check the requirements --- Makefile | 3 + src/Console/Application.php | 7 +- src/Console/Command/Check/Requirements.php | 110 ++++++++++++++++++ src/Console/Command/Check/Signature.php | 2 +- src/Console/Command/Compile.php | 3 +- .../DockerFile.php} | 15 ++- src/Console/Command/Generate/Requirements.php | 79 +++++++++++++ src/Console/PharInfoRenderer.php | 29 ++--- src/Phar/PharInfo.php | 46 ++++++++ .../DecodedComposerLock.php | 1 + .../InvalidRequirements.php | 26 +++++ .../NoRequirementsFound.php | 24 ++++ src/RequirementChecker/Requirement.php | 14 +++ src/RequirementChecker/Requirements.php | 31 +++++ .../SuccinctRequirementListFactory.php | 40 +++++++ tests/Build/expected-box-requirements.txt | 7 ++ .../Command/Check/RequirementsParserTest.php | 16 +++ tests/Console/Command/CompileTest.php | 3 +- .../DockerFileTest.php} | 10 +- 19 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 src/Console/Command/Check/Requirements.php rename src/Console/Command/{GenerateDockerFile.php => Generate/DockerFile.php} (93%) create mode 100644 src/Console/Command/Generate/Requirements.php create mode 100644 src/RequirementChecker/InvalidRequirements.php create mode 100644 src/RequirementChecker/NoRequirementsFound.php create mode 100644 src/RequirementChecker/Requirements.php create mode 100644 src/RequirementChecker/SuccinctRequirementListFactory.php create mode 100644 tests/Build/expected-box-requirements.txt create mode 100644 tests/Console/Command/Check/RequirementsParserTest.php rename tests/Console/Command/{GenerateDockerFileTest.php => Generate/DockerFileTest.php} (91%) diff --git a/Makefile b/Makefile index 869144f79..e58cb26a7 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ SCOPED_BOX_BIN = bin/box.phar TMP_SCOPED_BOX_BIN = bin/_box.phar SCOPED_BOX = $(SCOPED_BOX_BIN) SCOPED_BOX_DEPS = bin/box bin/box.bat $(shell find src res) box.json.dist scoper.inc.php vendor +BOX_EXPECTED_REQUIREMENTS = tests/Build/expected-box-requirements.txt DEFAULT_STUB = dist/default_stub.php @@ -490,6 +491,8 @@ $(SCOPED_BOX_BIN): $(SCOPED_BOX_DEPS) @# Use parallelization $(BOX) compile --ansi + $(BOX) check:requirements bin/box.phar $(BOX_EXPECTED_REQUIREMENTS) + rm $(TMP_SCOPED_BOX_BIN) || true mv -v bin/box.phar $(TMP_SCOPED_BOX_BIN) diff --git a/src/Console/Application.php b/src/Console/Application.php index 92043fe81..9b957a772 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -15,13 +15,15 @@ namespace KevinGH\Box\Console; use Fidry\Console\Application\Application as FidryApplication; +use KevinGH\Box\Console\Command\Check\Requirements as CheckRequirements; use KevinGH\Box\Console\Command\Check\Signature as CheckSignature; use KevinGH\Box\Console\Command\Compile; use KevinGH\Box\Console\Command\Composer\ComposerCheckVersion; use KevinGH\Box\Console\Command\Composer\ComposerVendorDir; use KevinGH\Box\Console\Command\Diff; use KevinGH\Box\Console\Command\Extract; -use KevinGH\Box\Console\Command\GenerateDockerFile; +use KevinGH\Box\Console\Command\Generate\DockerFile as GenerateDockerFile; +use KevinGH\Box\Console\Command\Generate\Requirements as GenerateRequirements; use KevinGH\Box\Console\Command\Info; use KevinGH\Box\Console\Command\Info\Signature as InfoSignature; use KevinGH\Box\Console\Command\Namespace_; @@ -99,11 +101,14 @@ public function getCommands(): array new Info('info:general'), new InfoSignature(), new CheckSignature(), + new CheckRequirements(), new Process(), new Extract(), new Validate(), new Verify(), new GenerateDockerFile(), + new GenerateDockerFile('generate:docker'), + new GenerateRequirements(), new Namespace_(), ]; } diff --git a/src/Console/Command/Check/Requirements.php b/src/Console/Command/Check/Requirements.php new file mode 100644 index 000000000..3256c02e3 --- /dev/null +++ b/src/Console/Command/Check/Requirements.php @@ -0,0 +1,110 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Check; + +use Fidry\Console\Command\Command; +use Fidry\Console\Command\Configuration; +use Fidry\Console\ExitCode; +use Fidry\Console\IO; +use Fidry\FileSystem\FS; +use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\SuccinctRequirementListFactory; +use SebastianBergmann\Diff\Diff; +use SebastianBergmann\Diff\Differ; +use SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder; +use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Filesystem\Path; +use function implode; +use function sprintf; +use function trim; + +/** + * @private + */ +final class Requirements implements Command +{ + private const PHAR_ARG = 'phar'; + private const EXPECTED_REQUIREMENTS_ARG = 'expected-requirements'; + + public function getConfiguration(): Configuration + { + return new Configuration( + 'check:requirements', + 'Displays the hash of the signature', + <<<'HELP' + The %command.name% command will check that the requirements of the provided PHAR + matches the list of provided requirements. + The purpose of this command is to check that the PHAR ships the requirement checker and to keep + track of the extensions required. + + If what you want to do is check if the current environment satisfies the PHAR + requirements simply execute the PHAR instead. + + The requirements should be listed in the file as follows: + + ``` + PHP ^7.2 + ext-phar + ext-xml + ext-filter + ``` + HELP, + [ + new InputArgument( + self::PHAR_ARG, + InputArgument::REQUIRED, + 'The PHAR file.', + ), + new InputArgument( + self::EXPECTED_REQUIREMENTS_ARG, + InputArgument::REQUIRED, + 'Path to a file containing a line return separated list of requirements.', + ), + ], + ); + } + + public function execute(IO $io): int + { + $pharPath = $io->getTypedArgument(self::PHAR_ARG)->asNonEmptyString(); + $expectedRequirementPath = $io->getTypedArgument(self::EXPECTED_REQUIREMENTS_ARG)->asNonEmptyString(); + + $pharPath = Path::canonicalize($pharPath); + + $pharInfo = new PharInfo($pharPath); + + $actualRequirements = trim( + implode( + "\n", + SuccinctRequirementListFactory::create($pharInfo->getRequirements()), + ), + ); + $expectedRequirements = trim( + FS::getFileContents($expectedRequirementPath), + ); + + if ($expectedRequirements === $actualRequirements) { + return ExitCode::SUCCESS; + } + + $differ = new Differ(new UnifiedDiffOutputBuilder()); + $result = $differ->diff($expectedRequirements, $actualRequirements); + + $io->writeln($result); + + return ExitCode::FAILURE; + } +} diff --git a/src/Console/Command/Check/Signature.php b/src/Console/Command/Check/Signature.php index 3bbbfab0f..09ebd42a8 100644 --- a/src/Console/Command/Check/Signature.php +++ b/src/Console/Command/Check/Signature.php @@ -29,7 +29,7 @@ final class Signature implements Command { private const PHAR_ARG = 'phar'; - private const HASH = 'hash'; + private const HASH = 'expected-hash'; public function getConfiguration(): Configuration { diff --git a/src/Console/Command/Compile.php b/src/Console/Command/Compile.php index 4e5247295..751b798ec 100644 --- a/src/Console/Command/Compile.php +++ b/src/Console/Command/Compile.php @@ -34,6 +34,7 @@ use KevinGH\Box\Composer\ComposerProcessFactory; use KevinGH\Box\Composer\IncompatibleComposerVersion; use KevinGH\Box\Configuration\Configuration; +use KevinGH\Box\Console\Command\Generate\DockerFile; use KevinGH\Box\Console\Logger\CompilerLogger; use KevinGH\Box\Console\MessageRenderer; use KevinGH\Box\Console\OpenFileDescriptorLimiter; @@ -986,6 +987,6 @@ private function generateDockerFile(IO $io): int private function getDockerCommand(): Command { - return $this->getCommandRegistry()->findCommand(GenerateDockerFile::NAME); + return $this->getCommandRegistry()->findCommand(DockerFile::NAME); } } diff --git a/src/Console/Command/GenerateDockerFile.php b/src/Console/Command/Generate/DockerFile.php similarity index 93% rename from src/Console/Command/GenerateDockerFile.php rename to src/Console/Command/Generate/DockerFile.php index 9ba23ce41..69667d5e4 100644 --- a/src/Console/Command/GenerateDockerFile.php +++ b/src/Console/Command/Generate/DockerFile.php @@ -12,7 +12,7 @@ * with this source code in the file LICENSE. */ -namespace KevinGH\Box\Console\Command; +namespace KevinGH\Box\Console\Command\Generate; use Fidry\Console\Command\CommandAware; use Fidry\Console\Command\CommandAwareness; @@ -20,7 +20,10 @@ use Fidry\Console\ExitCode; use Fidry\Console\IO; use Fidry\FileSystem\FS; +use KevinGH\Box\Console\Command\Compile; +use KevinGH\Box\Console\Command\ConfigOption; use KevinGH\Box\DockerFileGenerator; +use KevinGH\Box\Phar\PharInfo; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\StringInput; @@ -35,7 +38,7 @@ /** * @private */ -final class GenerateDockerFile implements CommandAware +final class DockerFile implements CommandAware { use CommandAwareness; @@ -44,10 +47,14 @@ final class GenerateDockerFile implements CommandAware private const PHAR_ARG = 'phar'; private const DOCKER_FILE_NAME = 'Dockerfile'; + public function __construct(private readonly string $commandName = self::NAME) + { + } + public function getConfiguration(): Configuration { return new Configuration( - 'docker', + $this->commandName, '🐳 Generates a Dockerfile for the given PHAR', '', [ @@ -78,7 +85,7 @@ public function execute(IO $io): int ); $io->newLine(); - $requirementsFilePhar = 'phar://'.$pharFilePath.'/.box/.requirements.php'; + $requirementsFilePhar = 'phar://'.$pharFilePath.'/'.PharInfo::BOX_REQUIREMENTS; return $this->generateFile( $pharFilePath, diff --git a/src/Console/Command/Generate/Requirements.php b/src/Console/Command/Generate/Requirements.php new file mode 100644 index 000000000..fcee9dc7c --- /dev/null +++ b/src/Console/Command/Generate/Requirements.php @@ -0,0 +1,79 @@ + + * Théo Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Generate; + +use Fidry\Console\Command\Command; +use Fidry\Console\Command\Configuration; +use Fidry\Console\ExitCode; +use Fidry\Console\IO; +use KevinGH\Box\Phar\InvalidPhar; +use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\Requirement; +use KevinGH\Box\RequirementChecker\RequirementType; +use KevinGH\Box\RequirementChecker\SuccinctRequirementListFactory; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Filesystem\Path; +use function array_map; +use function array_unique; +use function ksort; +use function sprintf; +use const SORT_STRING; + +/** + * @private + */ +final class Requirements implements Command +{ + private const PHAR_ARG = 'phar'; + + public function getConfiguration(): Configuration + { + return new Configuration( + 'generate:requirements', + 'Outputs a succinct list of the PHAR requirements', + <<<'HELP' + The %command.name% command will generate a succinct list of the requirements of the PHAR + if it ships the Box's RequirementChecker. + + This command is mostly to generate a list to be able to use it with check:requirements to + keep track of the PHAR requirements. + + If what you want to do is check the more detailed list of the requirements of your PHAR use the + info> command instead. + HELP, + [ + new InputArgument( + self::PHAR_ARG, + InputArgument::REQUIRED, + 'The PHAR file.', + ), + ], + ); + } + + public function execute(IO $io): int + { + $pharPath = $io->getTypedArgument(self::PHAR_ARG)->asNonEmptyString(); + + $pharPath = Path::canonicalize($pharPath); + + $pharInfo = new PharInfo($pharPath); + $requirements = $pharInfo->getRequirements(); + + $io->writeln(SuccinctRequirementListFactory::create($requirements)); + + return ExitCode::SUCCESS; + } +} diff --git a/src/Console/PharInfoRenderer.php b/src/Console/PharInfoRenderer.php index 63f1c80da..e99b1ed59 100644 --- a/src/Console/PharInfoRenderer.php +++ b/src/Console/PharInfoRenderer.php @@ -21,7 +21,10 @@ use KevinGH\Box\NotInstantiable; use KevinGH\Box\Phar\CompressionAlgorithm; use KevinGH\Box\Phar\PharInfo; +use KevinGH\Box\RequirementChecker\InvalidRequirements; +use KevinGH\Box\RequirementChecker\NoRequirementsFound; use KevinGH\Box\RequirementChecker\Requirement; +use KevinGH\Box\RequirementChecker\Requirements; use KevinGH\Box\RequirementChecker\RequirementType; use SplFileInfo; use Symfony\Component\Console\Output\OutputInterface; @@ -35,6 +38,7 @@ use function count; use function implode; use function is_array; +use function iter\toArray; use function KevinGH\Box\format_size; use function key; use function preg_match; @@ -51,7 +55,6 @@ final class PharInfoRenderer { use NotInstantiable; - private const BOX_REQUIREMENTS = '.box/.requirements.php'; private const BOX_VERSION_PATTERN = '/ \* Generated by Humbug Box (?.+)\.\s/'; private const INDENT_SIZE = 2; @@ -215,17 +218,14 @@ public static function renderRequirementChecker( PharInfo $pharInfo, IO $io, ): void { - $requirements = $pharInfo->getFiles()[self::BOX_REQUIREMENTS] ?? null; - if (null === $requirements) { + try { + $requirements = $pharInfo->getRequirements(); + } catch (NoRequirementsFound) { $io->writeln('RequirementChecker: Not found.'); return; - } - - $evaluatedRequirements = require $requirements->getPathname(); - - if (!is_array($evaluatedRequirements)) { + } catch (InvalidRequirements) { $io->writeln('RequirementChecker: Could not be checked.'); return; @@ -233,14 +233,14 @@ public static function renderRequirementChecker( $io->write('RequirementChecker:'); - if (0 === count($evaluatedRequirements)) { + if (0 === count($requirements)) { $io->writeln(' No requirement found.'); return; } $io->writeln(''); - [$required, $conflicting] = self::retrieveRequirements($evaluatedRequirements); + [$required, $conflicting] = self::retrieveRequirements($requirements); self::renderRequiredSection($required, $io); self::renderConflictingSection($conflicting, $io); @@ -328,15 +328,10 @@ private static function extractBoxVersion(PharInfo $pharInfo): ?string /** * @return array{Requirement[], Requirement[]} */ - private static function retrieveRequirements(array $requirements): array + private static function retrieveRequirements(Requirements $requirements): array { - $evaluatedRequirements = array_map( - Requirement::fromArray(...), - $requirements, - ); - [$required, $conflicting] = array_reduce( - $evaluatedRequirements, + toArray($requirements), static function ($carry, Requirement $requirement): array { $hash = implode( ':', diff --git a/src/Phar/PharInfo.php b/src/Phar/PharInfo.php index 2865938b9..7f53d94ea 100644 --- a/src/Phar/PharInfo.php +++ b/src/Phar/PharInfo.php @@ -46,6 +46,10 @@ use Fidry\FileSystem\FS; use KevinGH\Box\Console\Command\Extract; use KevinGH\Box\ExecutableFinder; +use KevinGH\Box\RequirementChecker\InvalidRequirements; +use KevinGH\Box\RequirementChecker\Requirement; +use KevinGH\Box\RequirementChecker\Requirements; +use KevinGH\Box\RequirementChecker\NoRequirementsFound; use OutOfBoundsException; use Phar; use Symfony\Component\Filesystem\Path; @@ -53,6 +57,9 @@ use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; +use Throwable; +use function array_key_exists; +use function array_map; use function bin2hex; use function file_exists; use function is_readable; @@ -74,6 +81,8 @@ */ final class PharInfo { + public const BOX_REQUIREMENTS = '.box/.requirements.php'; + private static array $ALGORITHMS; private static string $stubfile; @@ -263,6 +272,43 @@ public function getFiles(): array return $this->files; } + /** + * @throws NoRequirementsFound + * @throws InvalidRequirements + */ + public function getRequirements(): Requirements + { + $file = $this->getFileName(); + + if (!array_key_exists(self::BOX_REQUIREMENTS, $this->files)) { + throw NoRequirementsFound::forFile($file); + } + + $evaluatedRequirements = require $this->files[self::BOX_REQUIREMENTS]->getPathname(); + + if (!is_array($evaluatedRequirements)) { + throw InvalidRequirements::forRequirements($file, $evaluatedRequirements); + } + + try { + return new Requirements( + array_map( + Requirement::fromArray(...), + $evaluatedRequirements, + ), + ); + } catch (Throwable $throwable) { + throw new InvalidRequirements( + sprintf( + 'Could not interpret Box\'s RequirementChecker shipped in "%s": %s', + $file, + $throwable->getMessage(), + ), + previous: $throwable, + ); + } + } + private static function initAlgorithms(): void { if (!isset(self::$ALGORITHMS)) { diff --git a/src/RequirementChecker/DecodedComposerLock.php b/src/RequirementChecker/DecodedComposerLock.php index 651b64527..1d1b491df 100644 --- a/src/RequirementChecker/DecodedComposerLock.php +++ b/src/RequirementChecker/DecodedComposerLock.php @@ -17,6 +17,7 @@ use function array_map; /** + * TODO: move it under the Composer namespace * @private */ final readonly class DecodedComposerLock diff --git a/src/RequirementChecker/InvalidRequirements.php b/src/RequirementChecker/InvalidRequirements.php new file mode 100644 index 000000000..58aba3a5d --- /dev/null +++ b/src/RequirementChecker/InvalidRequirements.php @@ -0,0 +1,26 @@ + $this->helpMessage, ]; } + + /** + * @return string Represents what this requirement is about omitting the source and remedies. + */ + public function toSuccinctDescription(): string + { + $type = match($this->type) { + RequirementType::PHP => 'req. PHP ', + RequirementType::EXTENSION => 'req. ext-', + RequirementType::EXTENSION_CONFLICT => 'confl. ext-', + }; + + return $type.$this->condition; + } } diff --git a/src/RequirementChecker/Requirements.php b/src/RequirementChecker/Requirements.php new file mode 100644 index 000000000..4883766d1 --- /dev/null +++ b/src/RequirementChecker/Requirements.php @@ -0,0 +1,31 @@ +requirements); + } + + public function count(): int + { + return count($this->requirements); + } +} \ No newline at end of file diff --git a/src/RequirementChecker/SuccinctRequirementListFactory.php b/src/RequirementChecker/SuccinctRequirementListFactory.php new file mode 100644 index 000000000..f6693d8da --- /dev/null +++ b/src/RequirementChecker/SuccinctRequirementListFactory.php @@ -0,0 +1,40 @@ + + */ + public static function create(Requirements $requirements): array + { + $succinctRequirements = array_unique( + array_map( + static fn (Requirement $requirement) => $requirement->toSuccinctDescription(), + toArray($requirements), + ), + ); + + ksort($succinctRequirements, SORT_STRING); + + return $succinctRequirements; + } +} \ No newline at end of file diff --git a/tests/Build/expected-box-requirements.txt b/tests/Build/expected-box-requirements.txt new file mode 100644 index 000000000..d648e4896 --- /dev/null +++ b/tests/Build/expected-box-requirements.txt @@ -0,0 +1,7 @@ +req. PHP ^8.2 +req. ext-zlib +req. ext-phar +req. ext-filter +req. ext-openssl +req. ext-tokenizer +confl. ext-psr diff --git a/tests/Console/Command/Check/RequirementsParserTest.php b/tests/Console/Command/Check/RequirementsParserTest.php new file mode 100644 index 000000000..9170a20fc --- /dev/null +++ b/tests/Console/Command/Check/RequirementsParserTest.php @@ -0,0 +1,16 @@ +add($command); - $application->add(new SymfonyCommand(new GenerateDockerFile())); + $application->add(new SymfonyCommand(new DockerFile())); $this->commandTester = new CommandTester( $application->get( diff --git a/tests/Console/Command/GenerateDockerFileTest.php b/tests/Console/Command/Generate/DockerFileTest.php similarity index 91% rename from tests/Console/Command/GenerateDockerFileTest.php rename to tests/Console/Command/Generate/DockerFileTest.php index 68c4d2a99..3baee1bc7 100644 --- a/tests/Console/Command/GenerateDockerFileTest.php +++ b/tests/Console/Command/Generate/DockerFileTest.php @@ -12,7 +12,7 @@ * with this source code in the file LICENSE. */ -namespace KevinGH\Box\Console\Command; +namespace KevinGH\Box\Console\Command\Generate; use Fidry\Console\Command\Command; use Fidry\Console\ExitCode; @@ -21,15 +21,15 @@ use function Safe\realpath; /** - * @covers \KevinGH\Box\Console\Command\GenerateDockerFile + * @covers \KevinGH\Box\Console\Command\Generate\DockerFile * * @internal */ -class GenerateDockerFileTest extends CommandTestCase +class DockerFileTest extends CommandTestCase { use RequiresPharReadonlyOff; - private const FIXTURES_DIR = __DIR__.'/../../../fixtures/docker'; + private const FIXTURES_DIR = __DIR__.'/../../../../fixtures/docker'; protected function setUp(): void { @@ -40,7 +40,7 @@ protected function setUp(): void protected function getCommand(): Command { - return new GenerateDockerFile(); + return new DockerFile(); } public function test_it_generates_a_dockerfile_for_a_given_phar(): void