From 758dc8e8feed91c15379552159d783699cf44b4e Mon Sep 17 00:00:00 2001 From: Sebastian Tarach Date: Sun, 4 Feb 2024 10:03:14 +0100 Subject: [PATCH] added behat tests --- .github/workflows/build.yaml | 5 +- README.md | 175 ++++- bin/app.php | 15 + bin/console.php | 12 +- bin/create | 2 +- composer.json | 3 +- composer.lock | 735 +++++++++++++++++- example/sslgen.yaml | 6 +- features/AnswerQuestions.feature | 37 + features/AnswersFromConfig.feature | 48 ++ features/bootstrap/ApplicationRunner.php | 146 ++++ features/bootstrap/FeatureContext.php | 95 +++ src/Command/Config/Authority.php | 13 +- src/Command/Config/ConfigSchemaLoader.php | 2 +- src/DirectoryPathNormalizer.php | 22 +- .../EmptyDistinguishedNameException.php | 13 + src/Exception/MissingSchemaException.php | 13 + src/SSLExporterService.php | 11 +- src/SSLGenerateCommand.php | 73 +- 19 files changed, 1380 insertions(+), 46 deletions(-) create mode 100644 bin/app.php create mode 100644 features/AnswerQuestions.feature create mode 100644 features/AnswersFromConfig.feature create mode 100644 features/bootstrap/ApplicationRunner.php create mode 100644 features/bootstrap/FeatureContext.php create mode 100644 src/Exception/EmptyDistinguishedNameException.php create mode 100644 src/Exception/MissingSchemaException.php diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ccce1cb..cbb22cd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,6 +21,9 @@ jobs: - name: Install Composer dependencies run: composer install --no-progress --prefer-dist --optimize-autoloader + - name: Execute tests + run: vendor/bin/behat --format=progress + - name: Generate runtime run: | chmod +x ./bin/create @@ -45,7 +48,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/README.md b/README.md index d7a29e0..b241a14 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,158 @@ +![build](https://github.com/tarach/self-signed-ssl-generator/actions/workflows/build.yaml/badge.svg) +##### Table of Contents +* [Installation](#installation) +* [Features](#features) +* [Basic examples](#basic-examples) +* [Advanced examples](#advanced-examples) + * [Generating Alpine Docker SSL certificate](#generating-alpine-docker-ssl-certificate) + * [Generate](#generate) + * [Upload](#upload) + * [Test](#test) +* [Credits](#credits) + * [Why](#why) + +# Installation +Use as ```sslgen --help``` +```bash +docker pull tarach/sslg +echo 'alias sslgen="docker run -it --rm -v \$(pwd):/app tarach/sslg"' >> ~/.bashrc +source ~/.bashrc +``` + +# Features +* Generate SSL by answering questions +* Generate SSL by passing arguments on cli +* Generate SSL by selecting presets ( schema ) from [configuration file](/example/sslgen.yaml) or on CLI ```-e, --schema``` +* Overwrite schema options + +# Basic examples +## Generate by answering questions +Most basic version of the command will ask user some questions necessary to generate the SSL certificate. +### Command +```bash +sslgen -vv +``` +### Output +```text +Country Name (2 letter code - ISO 3166-1 alfa-2) +: UK +State or province +: Wales +Locality name (e.g., city) +: Newport +Organization Name (e.g., company) +: My Company ltd. +Organization Unit Name (e.g., section) +: IT Department +Common Name (e.g., server FQDN) +: domain.com +Email address +: email@address.com +[2024-02-03T03:37:41.139266+00:00] logger.INFO: Output directory set to [/app/ssl-cert/]. [] [] +[2024-02-03T03:37:41.140534+00:00] logger.INFO: Saving certificate signing request (CSR) as file [csr.req]. [] [] +[2024-02-03T03:37:41.140662+00:00] logger.INFO: Saving certificate as file [cert.pem]. [] [] +[2024-02-03T03:37:41.140735+00:00] logger.INFO: Saving private key as file [pkey.key]. [] [] +``` +## Generate by providing answers as arguments +Options used below set default answers to questions. +Using ``-s`` or ``--skip`` make confirming those answers not necessary. +### Command +```bash +sslgen -vv -s --un=DE \ + --sp=Hesse \ + --ln=Frankfurt \ + --on="Name der Firma" \ + --oun="IT Abteilung" \ + --cn=domain.com \ + --ea=email@address.com +``` +### Output +```text +[2024-02-03T03:22:07.988076+00:00] logger.INFO: Output directory set to [/app/ssl-cert/]. [] [] +[2024-02-03T03:22:07.988709+00:00] logger.INFO: Saving certificate signing request (CSR) as file [csr.req]. [] [] +[2024-02-03T03:22:07.988827+00:00] logger.INFO: Saving certificate as file [cert.pem]. [] [] +[2024-02-03T03:22:07.988958+00:00] logger.INFO: Saving private key as file [pkey.key]. [] [] +``` +# Advanced examples + +## Generating Alpine Docker SSL certificate +Code snippets to generate and upload SSL certificate for Docker API calls + +### Generate +#### Get configuration file +```bash +wget https://raw.githubusercontent.com/tarach/self-signed-ssl-generator/master/example/sslgen.yaml +``` +#### Generate certificate authority and it's private key +When asked to choose schema type ``1`` ( caSchema ) and press ``Enter`` +##### Command +```bash +sslgen -vv +``` +##### Output +```text +[1] caSchema +[2] serverSchema +[3] clientSchema +Choose schema: 1 +[2024-01-28T05:10:48.683176+00:00] logger.INFO: Using schema [caSchema]. [] [] +[2024-01-28T05:10:48.712605+00:00] logger.INFO: Output directory set to [/app/ssl-cert/]. [] [] +[2024-01-28T05:10:48.712759+00:00] logger.INFO: Saving certificate as file [ca.pem]. [] [] +[2024-01-28T05:10:48.713027+00:00] logger.INFO: Saving private key as file [privkey.pem]. [] [] +``` +#### Generate server set of files +##### Command +Use IP Address or domain name of your docker server that will be used to invoke the connection. When incorrect the client connecting to the server won't be able to confirm server identity, and it will cause an error. +```bash +export COMMON_NAME=192.168.56.10 +sslgen -vv --schema=2 -o --cn=${COMMON_NAME} +``` +##### Output +```text +[2024-01-28T05:15:41.434671+00:00] logger.INFO: Using schema [serverSchema]. [] [] +[2024-01-28T05:15:41.435481+00:00] logger.INFO: Directory [/app/ssl-cert/] already exists. Will overwrite files. [] [] +[2024-01-28T05:15:41.453789+00:00] logger.INFO: Output directory set to [/app/ssl-cert/]. [] [] +[2024-01-28T05:15:41.454168+00:00] logger.INFO: Saving certificate signing request (CSR) as file [server.req]. [] [] +[2024-01-28T05:15:41.454423+00:00] logger.INFO: Saving certificate as file [server.pem]. [] [] +[2024-01-28T05:15:41.455042+00:00] logger.INFO: Saving private key as file [server.key]. [] [] +``` +#### Generate client set of files +##### Command +```bash +sslgen -vv --schema=clientSchema +``` +##### Output +```text +[2024-01-28T05:36:23.078259+00:00] logger.INFO: Using schema [clientSchema]. [] [] +[2024-01-28T05:36:23.079187+00:00] logger.INFO: Directory [/app/ssl-cert/] already exists. Will overwrite files. [] [] +Country Name (2 letter code - ISO 3166-1 alfa-2) +default: "PL" +: +State or province +default: "Mazovia" +: +Locality name (e.g., city) +default: "Warsaw" +: +Organization Name (e.g., company) +default: "My Company Ltd." +: +Organization Unit Name (e.g., section) +default: "IT Dept." +: +Common Name (e.g., server FQDN) +default: "docker-client" +: +Email address +default: "address@email.com" +: +[2024-01-28T05:36:23.144258+00:00] logger.INFO: Output directory set to [/app/ssl-cert/]. [] [] +[2024-01-28T05:36:23.144507+00:00] logger.INFO: Saving certificate signing request (CSR) as file [client.req]. [] [] +[2024-01-28T05:36:23.144620+00:00] logger.INFO: Saving certificate as file [client.pem]. [] [] +[2024-01-28T05:36:23.144810+00:00] logger.INFO: Saving private key as file [client.key]. [] [] +``` + +### Upload ```bash export USR=userName ssh ${USR}@192.168.56.10 mkdir ssl-cert/ @@ -9,8 +164,26 @@ ssh ${USR}@192.168.56.10:ssl-cert sudo cp ca.pem /root/.docker/ sudo cp server.key /root/.docker/key.pem sudo cp server.pem /root/.docker/cert.pem +``` +### Test +```bash cat client.pem >> cert-and-key.pem cat client.key >> cert-and-key.pem curl -vv --cacert ca.pem --cert cert-and-key.pem https://192.168.56.10:2376/version -``` \ No newline at end of file +``` + +# Credits +Some time ago I've stumbled upon necessity to generate a ``self-signed SSL certificate`` and convert it a bunch of times. +I found myself completely lost trying to navigate between CSR, PKEY, CRT, PEM and a bunch of other files. +[The topic I've created on SO](https://stackoverflow.com/questions/63195304/difference-between-pem-crt-key-files) clearly proves than I'm not the only person this is confusing. + +Then again after a while I needed to generate a certificate again to communicate with Docker API. After going through complete hell re-learning how to do it again +I've found [this code](https://github.com/php-http/socket-client/blob/2.x/tests/server/ssl/generate.sh). + +What struck me the most was a complete lack of generators written in PHP. Some existed but relied on terminal openssl command. + +## Why + +* To create a PHP CLI capable of generating Self-Signed SSL Certificate without relying on external command tools. +* To over-engineer the hell out of [symfony/console](https://symfony.com/doc/current/components/console.html) \ No newline at end of file diff --git a/bin/app.php b/bin/app.php new file mode 100644 index 0000000..adae94c --- /dev/null +++ b/bin/app.php @@ -0,0 +1,15 @@ +add($command); + +$application->setDefaultCommand($command->getName(), true); + +return $application; \ No newline at end of file diff --git a/bin/console.php b/bin/console.php index 6bd323c..7783b57 100644 --- a/bin/console.php +++ b/bin/console.php @@ -1,14 +1,6 @@ add($command); - -$application->setDefaultCommand($command->getName(), true); $application->run(); diff --git a/bin/create b/bin/create index dab39ba..899aa5d 100755 --- a/bin/create +++ b/bin/create @@ -6,7 +6,7 @@ $fileName = 'sslgen.phar'; $phar = new Phar($fileName); $phar->buildFromDirectory( __DIR__ . '/../', - '/vendor|src|bin\\/console.*/', + '/vendor|src|bin\\/console.*|bin\\/app.php/', ); $phar->setDefaultStub('bin/console.php'); //$phar->compress(Phar::GZ); diff --git a/composer.json b/composer.json index 5bb6a83..20464f4 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "monolog/monolog": "^3.5" }, "require-dev": { - "symfony/error-handler": "^6.3" + "symfony/error-handler": "^6.3", + "behat/behat": "^3.14" } } diff --git a/composer.lock b/composer.lock index d39350a..35e1ab6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2094de64241a48cc88267c78bcc0bc38", + "content-hash": "db3e942759308f90bcbbc1419d4d2bed", "packages": [ { "name": "monolog/monolog", @@ -1077,6 +1077,336 @@ } ], "packages-dev": [ + { + "name": "behat/behat", + "version": "v3.14.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/2a3832d9cb853a794af3a576f9e524ae460f3340", + "reference": "2a3832d9cb853a794af3a576f9e524ae460f3340", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.9.0", + "behat/transliterator": "^1.2", + "ext-mbstring": "*", + "php": "^7.2 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/translation": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/yaml": "^4.4 || ^5.0 || ^6.0 || ^7.0" + }, + "require-dev": { + "herrera-io/box": "~1.6.1", + "phpspec/prophecy": "^1.15", + "phpunit/phpunit": "^8.5 || ^9.0", + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "vimeo/psalm": "^4.8" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Hook\\": "src/Behat/Hook/", + "Behat\\Step\\": "src/Behat/Step/", + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Testwork\\": "src/Behat/Testwork/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP", + "homepage": "http://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.14.0" + }, + "time": "2023-12-09T13:55:02+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "0bc8d1e30e96183e4f36db9dc79caead300beff4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/0bc8d1e30e96183e4f36db9dc79caead300beff4", + "reference": "0bc8d1e30e96183e4f36db9dc79caead300beff4", + "shasum": "" + }, + "require": { + "php": "~7.2|~8.0" + }, + "require-dev": { + "cucumber/cucumber": "dev-gherkin-22.0.0", + "phpunit/phpunit": "~8|~9", + "symfony/yaml": "~3|~4|~5" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Gherkin": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "http://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.9.0" + }, + "time": "2021-10-12T13:05:09+00:00" + }, + { + "name": "behat/transliterator", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Transliterator.git", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "chuyskywalker/rolling-curl": "^3.1", + "php-yaoi/php-yaoi": "^1.0", + "phpunit/phpunit": "^8.5.25 || ^9.5.19" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Transliterator\\": "src/Behat/Transliterator" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Artistic-1.0" + ], + "description": "String transliterator", + "keywords": [ + "i18n", + "slug", + "transliterator" + ], + "support": { + "issues": "https://github.com/Behat/Transliterator/issues", + "source": "https://github.com/Behat/Transliterator/tree/v1.5.0" + }, + "time": "2022-03-30T09:27:43+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v6.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "226ea431b1eda6f0d9f5a4b278757171960bb195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/226ea431b1eda6f0d9f5a4b278757171960bb195", + "reference": "226ea431b1eda6f0d9f5a4b278757171960bb195", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.2.10|^7.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.1", + "symfony/finder": "<5.4", + "symfony/proxy-manager-bridge": "<6.3", + "symfony/yaml": "<5.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.1|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-12-28T19:16:56+00:00" + }, { "name": "symfony/error-handler", "version": "v6.3.5", @@ -1151,6 +1481,335 @@ ], "time": "2023-09-12T06:57:20+00:00" }, + { + "name": "symfony/event-dispatcher", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "098b62ae81fdd6cbf941f355059f617db28f4f9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/098b62ae81fdd6cbf941f355059f617db28f4f9a", + "reference": "098b62ae81fdd6cbf941f355059f617db28f4f9a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.0.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-12-27T22:24:19+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "a76aed96a42d2b521153fb382d418e30d18b59df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/a76aed96a42d2b521153fb382d418e30d18b59df", + "reference": "a76aed96a42d2b521153fb382d418e30d18b59df", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, + { + "name": "symfony/translation", + "version": "v6.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "a2ab2ec1a462e53016de8e8d5e8912bfd62ea681" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/a2ab2ec1a462e53016de8e8d5e8912bfd62ea681", + "reference": "a2ab2ec1a462e53016de8e8d5e8912bfd62ea681", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.13", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v6.4.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-12-18T09:25:29+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "06450585bf65e978026bda220cdebca3f867fde7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/06450585bf65e978026bda220cdebca3f867fde7", + "reference": "06450585bf65e978026bda220cdebca3f867fde7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-12-26T14:02:43+00:00" + }, { "name": "symfony/var-dumper", "version": "v6.3.8", @@ -1234,6 +1893,80 @@ } ], "time": "2023-11-08T10:42:36+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "345c62fefe92243c3a06fc0cc65f2ec1a47e0764" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/345c62fefe92243c3a06fc0cc65f2ec1a47e0764", + "reference": "345c62fefe92243c3a06fc0cc65f2ec1a47e0764", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.0.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-12-27T08:42:13+00:00" } ], "aliases": [], diff --git a/example/sslgen.yaml b/example/sslgen.yaml index c91c131..fcf1df0 100644 --- a/example/sslgen.yaml +++ b/example/sslgen.yaml @@ -24,7 +24,7 @@ serverSchema: cert: ./ssl-cert/ca.pem pkey: ./ssl-cert/privkey.pem overwrite: - skip: true + skip: false files: csr: server.req cert: server.pem @@ -42,8 +42,8 @@ clientSchema: authority: cert: ./ssl-cert/ca.pem pkey: ./ssl-cert/privkey.pem - overwrite: - skip: true + overwrite: true + skip: false files: csr: client.req cert: client.pem diff --git a/features/AnswerQuestions.feature b/features/AnswerQuestions.feature new file mode 100644 index 0000000..d817fb8 --- /dev/null +++ b/features/AnswerQuestions.feature @@ -0,0 +1,37 @@ +Feature: Generate SSL by answering questions on command line + + Scenario: Generate certificate by answering questions + When I execute command sslgen -vv + And when asked about "Country Name" I answer "UK" + And when asked about "State or province" I answer "Wales" + And when asked about "Locality name" I answer "Newport" + And when asked about "Organization Name" I answer "My Company ltd." + And when asked about "Organization Unit Name" I answer "IT Department" + And when asked about "Common Name" I answer "domain.com" + And when asked about "Email address" I answer "email@address.com" + Then displayed log message should contain: + """ + Output directory set to + Saving certificate signing request (CSR) as file [csr.req]. + Saving certificate as file [cert.pem]. + Saving private key as file [pkey.key]. + """ + + Scenario: Generate certificate by answering questions using arguments + When I execute command: + """ + sslgen -vv -s --un=DE \ + --sp=Hesse \ + --ln=Frankfurt \ + --on="Name der Firma" \ + --oun="IT Abteilung" \ + --cn=domain.com \ + --ea=email@address.com + """ + Then displayed log message should contain: + """ + Output directory set to + Saving certificate signing request (CSR) as file [csr.req]. + Saving certificate as file [cert.pem]. + Saving private key as file [pkey.key]. + """ \ No newline at end of file diff --git a/features/AnswersFromConfig.feature b/features/AnswersFromConfig.feature new file mode 100644 index 0000000..40825e2 --- /dev/null +++ b/features/AnswersFromConfig.feature @@ -0,0 +1,48 @@ +Feature: + + Scenario: Generate certificates by answering questions + Given I'm in a project root directory + When I execute command sslgen -vv --config=example/sslgen.yaml --directory=example/test + And when asked to "Choose schema" I answer "1" + Then displayed log message should contain: + """ + Using schema [caSchema]. + Output directory set to + Saving certificate as file [ca.pem]. + Saving private key as file [privkey.pem]. + """ + When I execute command: + """ + sslgen -vv -s -o \ + --config=example/sslgen.yaml \ + --directory=example/test \ + --schema=2 \ + --caCert=example/test/ca.pem \ + --caKey=example/test/privkey.pem + """ + Then displayed log message should contain: + """ + Using schema [serverSchema]. + Output directory set to + Saving certificate signing request (CSR) as file [server.req]. + Saving certificate as file [server.pem]. + Saving private key as file [server.key]. + """ + When I execute command: + """ + sslgen -vv -s -o \ + --config=example/sslgen.yaml \ + --directory=example/test \ + --schema=clientSchema \ + --caCert=example/test/ca.pem \ + --caKey=example/test/privkey.pem + """ + Then displayed log message should contain: + """ + Using schema [clientSchema]. + Output directory set to + Saving certificate signing request (CSR) as file [client.req]. + Saving certificate as file [client.pem]. + Saving private key as file [client.key]. + """ + Then I remove the example/test directory diff --git a/features/bootstrap/ApplicationRunner.php b/features/bootstrap/ApplicationRunner.php new file mode 100644 index 0000000..bc69a8a --- /dev/null +++ b/features/bootstrap/ApplicationRunner.php @@ -0,0 +1,146 @@ + '--un', + 'State or province' => '--sp', + 'Locality name' => '--ln', + 'Organization Name' => '--on', + 'Organization Unit Name' => '--oun', + 'Common Name' => '--cn', + 'Email address' => '--ea', + 'Choose schema' => '', + ]; + private array $options = []; + private Application $application; + private SSLGenerateCommand $command; + private CommandTester $tester; + + public function __construct() + { + $this->application = $this->getApplication(); + $command = $this->application->find('ssl:generate'); + assert($command instanceof SSLGenerateCommand); + $this->command = $command; + + $this->tester = new CommandTester($this->command); + } + + public function run(): int + { + if (!empty($this->answers)) { + $this->tester->setInputs($this->answers); + } + + return $this->tester->execute( + array_merge( + [ + '--directory' => 'php://temp/ssl-test', + ], + $this->options + ), + [ + 'interactive' => true, +// 'verbosity' => OutputInterface::VERBOSITY_VERY_VERBOSE, + ] + ); + } + + public function clearAnswers(): void + { + $this->answers = []; + } + + public function addAnswer(string $topic, string $answer): void + { + if (!array_key_exists($topic, $this->questions)) { + throw new \InvalidArgumentException(sprintf('No command option defined under topic [%s].', $topic)); + } + + $this->answers[] = $answer; + } + + public function setOptions(string $options): void + { + $this->options = $this->parseOptions($options); + } + + public function getTester(): CommandTester + { + return $this->tester; + } + + private function getApplication(): Application + { + return require __DIR__ . '/../../bin/app.php'; + } + + private function parseOptions(string $options): array + { + $options = trim($options) . ' '; + if (!str_starts_with($options, '-')) { + throw new \InvalidArgumentException('Command options need to start with - or --.'); + } + $output = []; + $buffer = ''; + $stringStarted = false; + for ($i=0; $iparseOptionNameAndValue($buffer); + $output[$name] = $value; + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + return $output; + } + + private function parseOptionNameAndValue(string $option): array + { + $name = ''; + $value = null; + $nameEnded = false; + $valueStarted = false; + for ($i=0; $iapplicationRunner = new ApplicationRunner(); + } + + /** + * @When /^I execute command sslgen (?.*)$/ + */ + public function iExecuteCommandSslgen(string $params): void + { + $this->applicationRunner->clearAnswers(); + $this->applicationRunner->setOptions($params); + } + + /** + * @When /^I execute command:$/ + */ + public function iExecuteCommand(PyStringNode $string): void + { + $params = ''; + foreach ($string->getStrings() as $line) { + $params .= rtrim(trim($line), '\\'); + } + if (!str_starts_with($params, 'sslgen ')) { + throw new \Exception('Invalid command. Only sslgen command is supported.'); + } + $this->applicationRunner->clearAnswers(); + $this->applicationRunner->setOptions(substr($params, 7)); + } + + /** + * @When /^when asked (to|about) "(?[^"]*)" I answer "(?[^"]*)"$/ + */ + public function whenAskedAbout(string $question, string $answer): void + { + $this->applicationRunner->addAnswer($question, $answer); + } + + /** + * @When /^displayed log message should contain:$/ + */ + public function displayedLogMessageShouldContain(PyStringNode $string): void + { + if (0 !== $this->applicationRunner->run()) { + throw new \Exception('Command did not returned correct exit code.'); + } + + $display = $this->applicationRunner->getTester()->getDisplay(); + + foreach ($string->getStrings() as $line) { + if (!str_contains($display, $line)) { + throw new \Exception(sprintf('Display does not contain [%s] line.', $line)); + } + } + } + + /** + * @Given I'm in a project root directory + */ + public function imInAProjectRootDirectory(): void + { + if (!is_dir(getcwd() . DIRECTORY_SEPARATOR . 'example')) { + throw new \Exception('Not in project root directory.'); + } + } + + /** + * @Then I remove the example\/test directory + */ + public function iRemoveTheExampleTestDirectory(): void + { + $path = getcwd() . '/example/test/'; + array_map('unlink', glob($path . '*.*')); + rmdir($path); + } + +} diff --git a/src/Command/Config/Authority.php b/src/Command/Config/Authority.php index 0b422bf..9a3f243 100644 --- a/src/Command/Config/Authority.php +++ b/src/Command/Config/Authority.php @@ -17,16 +17,12 @@ public function __construct( ?string $cert, ?string $pkey ){ - $this->cert = $cert ? realpath($cert) : null; - $this->pkey = $pkey ? realpath($pkey) : null; - $files = [ - 'cert' => $this->cert, - 'pkey' => $this->pkey, + 'cert' => $cert, + 'pkey' => $pkey, ]; - foreach ($files as $type => $file) - { + foreach ($files as $type => $file) { if (!$file) { continue; } @@ -39,6 +35,9 @@ public function __construct( throw new RuntimeException(sprintf('Specified file [%s] for [%s] is not readable.', $file, $type)); } } + + $this->cert = $cert ? realpath($cert) : null; + $this->pkey = $pkey ? realpath($pkey) : null; } public function getCertificate(): ?OpenSSLCertificate diff --git a/src/Command/Config/ConfigSchemaLoader.php b/src/Command/Config/ConfigSchemaLoader.php index ec50e3f..3d3c261 100644 --- a/src/Command/Config/ConfigSchemaLoader.php +++ b/src/Command/Config/ConfigSchemaLoader.php @@ -10,7 +10,7 @@ use Tarach\SelfSignedCert\Command\OptionsCollection; use Tarach\SelfSignedCert\Command\QuestionCollectionFactory; -class ConfigSchemaLoader +readonly class ConfigSchemaLoader { use ArrayHelperTrait; diff --git a/src/DirectoryPathNormalizer.php b/src/DirectoryPathNormalizer.php index b107234..4db598b 100644 --- a/src/DirectoryPathNormalizer.php +++ b/src/DirectoryPathNormalizer.php @@ -8,9 +8,18 @@ class DirectoryPathNormalizer { private bool $isCreated = false; - public function normalize(string $directory): string + public function __construct( + private string $directory + ) { + } + + public function normalize(): string { - $directory = rtrim($directory, '\\/') . DIRECTORY_SEPARATOR; + $directory = rtrim($this->directory, '\\/') . DIRECTORY_SEPARATOR; + + if ($this->isStream()) { + return $directory; + } if ('/' !== $directory[0]) { $directory = getcwd() . DIRECTORY_SEPARATOR . $directory; @@ -18,7 +27,7 @@ public function normalize(string $directory): string if (!file_exists($directory)) { $this->isCreated = true; - if (!@mkdir($directory)) { + if (!@mkdir($directory, 0777, true)) { throw new \RuntimeException(sprintf('Unable to create output directory [%s].', $directory)); } } @@ -26,8 +35,13 @@ public function normalize(string $directory): string return $directory; } - public function isCreated(): bool + public function hasCreatedDirectory(): bool { return $this->isCreated; } + + public function isStream(): bool + { + return str_starts_with($this->directory, 'php://'); + } } \ No newline at end of file diff --git a/src/Exception/EmptyDistinguishedNameException.php b/src/Exception/EmptyDistinguishedNameException.php new file mode 100644 index 0000000..fe8ce40 --- /dev/null +++ b/src/Exception/EmptyDistinguishedNameException.php @@ -0,0 +1,13 @@ +config->getSigningRequestFile(); if ($csr->hasName()) { $this->logger->info(sprintf('Saving certificate signing request (CSR) as file [%s].', $csr->getName())); - openssl_csr_export_to_file($ssl->signingRequest, $directory . $csr->getName()); + openssl_csr_export($ssl->signingRequest, $output); + file_put_contents($directory . $csr->getName(), $output); } $cert = $this->config->getCertificateFile(); if ($cert->hasName()) { $this->logger->info(sprintf('Saving certificate as file [%s].', $cert->getName())); - openssl_x509_export_to_file($ssl->certificate, $directory . $cert->getName()); + $output = ''; + openssl_x509_export($ssl->certificate, $output); + file_put_contents($directory . $cert->getName(), $output); } $pkey = $this->config->getPrivateKeyFile(); if ($pkey->hasName()) { $this->logger->info(sprintf('Saving private key as file [%s].', $pkey->getName())); - openssl_pkey_export_to_file($ssl->privateKey, $directory . $pkey->getName()); + $output = ''; + openssl_pkey_export($ssl->privateKey, $output); + file_put_contents($directory . $pkey->getName(), $output); } } } \ No newline at end of file diff --git a/src/SSLGenerateCommand.php b/src/SSLGenerateCommand.php index c7b94ee..e6895b8 100644 --- a/src/SSLGenerateCommand.php +++ b/src/SSLGenerateCommand.php @@ -6,9 +6,10 @@ use Exception; use InvalidArgumentException; -use Monolog\Handler\StreamHandler; +use Monolog\Handler\AbstractProcessingHandler; use Monolog\Level; use Monolog\Logger; +use Monolog\LogRecord; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -24,6 +25,8 @@ use Tarach\SelfSignedCert\Command\OptionsCollection; use Tarach\SelfSignedCert\Command\OptionsCollectionFactory; use Tarach\SelfSignedCert\Command\QuestionCollectionFactory; +use Tarach\SelfSignedCert\Exception\EmptyDistinguishedNameException; +use Tarach\SelfSignedCert\Exception\MissingSchemaException; /** * @method getName(): string @@ -61,9 +64,9 @@ public function __construct() protected function execute(InputInterface $input, OutputInterface $output): int { - $logger = $this->createLogger($output->getVerbosity()); + $logger = $this->createLogger($output->getVerbosity(), $output); if (!$logger) { - $logger = $this->createLogger(128); + $logger = $this->createLogger(128, $output); $logger->error('Incorrect verbosity level ? To many -vv ?'); return self::FAILURE; } @@ -88,7 +91,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $schemas = $configSchema->getAllSchemas(); if (!empty($schemas)) { - $schema = $this->selectSchema($schemas, $input, $output); + try { + $schema = $this->selectSchema($schemas, $input, $output); + } catch (MissingSchemaException $exception) { + $logger->error($exception->getMessage()); + return self::FAILURE; + } + if (!$schema) { $logger->error('Wrong schema selected.'); return self::FAILURE; @@ -97,24 +106,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int $logger->info(sprintf('Using schema [%s].', $schema)); } + if (!$schema && !is_null($input->getOption(SchemaOption::NAME))) { + $logger->error('Trying to select non existing schema.'); + return self::FAILURE; + } + $config = $schema ? $configSchema->getConfig($schema) : new DefaultConfig($commandLineOptions) ; $directory = $config->getOutputDirectory(); - $overwrite = $config->isOverwriteEnabled(); - $normalizer = new DirectoryPathNormalizer(); - $directory = $normalizer->normalize($directory); + $normalizer = new DirectoryPathNormalizer($directory); + $directory = $normalizer->normalize(); - if (!$normalizer->isCreated()) { + if (!$normalizer->hasCreatedDirectory() && !$normalizer->isStream()) { if (!is_dir($directory)) { $logger->error(sprintf('Path [%s] is not a directory.', $directory)); return self::RETURN_INVALID_OUTPUT; } - if (!$overwrite) { + if (!$config->isOverwriteEnabled()) { $logger->warning(sprintf('Directory [%s] already exists. Use -o option to force overwrite.', $directory)); return self::SUCCESS; } @@ -122,7 +135,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $logger->info(sprintf('Directory [%s] already exists. Will overwrite files.', $directory)); } - $names = $this->createDistinguishedNames($config, $input, $output); + try { + $names = $this->createDistinguishedNames($config, $input, $output); + } catch (EmptyDistinguishedNameException $exception) { + $logger->error($exception->getMessage()); + return self::FAILURE; + } try { $service = new SSLGeneratorService($config); @@ -149,7 +167,13 @@ public function createDistinguishedNames(Config $config, InputInterface $input, $names[$question->getName()] = $question->getDefault(); continue; } - $names[$question->getName()] = $helper->ask($input, $output, $question); + $value = $helper->ask($input, $output, $question); + + if (empty($value)) { + throw new EmptyDistinguishedNameException($question->getName()); + } + + $names[$question->getName()] = $value; } return new DistinguishedNames(...$names); } @@ -163,7 +187,7 @@ private function getConfigPaths(): array ]; } - private function createLogger(int $level): ?LoggerInterface + private function createLogger(int $level, OutputInterface $output): ?LoggerInterface { $levels = [ 32 => Level::Notice, @@ -174,8 +198,21 @@ private function createLogger(int $level): ?LoggerInterface return null; } + $handler = new class($output) extends AbstractProcessingHandler { + private OutputInterface $output; + public function __construct(OutputInterface $output) + { + $this->output = $output; + parent::__construct(); + } + protected function write(LogRecord $record): void + { + $this->output->write((string) $record->formatted); + } + }; + $logger = new Logger('logger'); - $logger->pushHandler(new StreamHandler('php://stdout', $levels[$level])); + $logger->pushHandler($handler); return $logger; } @@ -186,6 +223,9 @@ private function addInputOption(InputOption $inputOption): void $this->getDefinition()->addOption($inputOption); } + /** + * @throws MissingSchemaException + */ private function selectSchema(array $schemas, InputInterface $input, OutputInterface $output): ?string { if (empty($schemas)) { @@ -233,12 +273,19 @@ private function userSelectSchema(array $schemas, InputInterface $input, OutputI return $schema; } + /** + * @throws MissingSchemaException + */ private function getSelectedSchema(array $schemas, mixed $schema): ?string { if (in_array($schema, $schemas)) { return $schema; } + if (!is_numeric($schema)) { + throw new MissingSchemaException($schema); + } + if (array_key_exists($schema - 1, $schemas)) { return $schemas[$schema - 1]; }