diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 991627163ad5..36ab4ddf05ef 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -37,6 +37,7 @@ CHANGELOG * Added new `error_controller` configuration to handle system exceptions * Added sort option for `translation:update` command. * [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore. + * Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly. 4.3.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php new file mode 100644 index 000000000000..c9370768f0a0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsDecryptToLocalCommand.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsDecryptToLocalCommand extends Command +{ + protected static $defaultName = 'secrets:decrypt-to-local'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Decrypts all secrets and stores them in the local vault.') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the local vault') + ->setHelp(<<<'EOF' +The %command.name% command list decrypts all secrets and stores them in the local vault.. + + %command.full_name% + +When the option --force is provided, secrets that already exist in the local vault are overriden. + + %command.full_name% --force +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if (null === $this->localVault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + $secrets = $this->vault->list(true); + + if (!$input->getOption('force')) { + foreach ($this->localVault->list() as $k => $v) { + unset($secrets[$k]); + } + } + + foreach ($secrets as $k => $v) { + if (null === $v) { + $io->error($this->vault->getLastMessage()); + + return 1; + } + + $this->localVault->seal($k, $v); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php new file mode 100644 index 000000000000..3161a2198206 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsEncryptFromLocalCommand.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsEncryptFromLocalCommand extends Command +{ + protected static $defaultName = 'secrets:encrypt-from-local'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Encrypts all local secrets to the vault.') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the vault') + ->setHelp(<<<'EOF' +The %command.name% command list encrypts all local secrets and stores them in the vault.. + + %command.full_name% + +When the option --force is provided, secrets that already exist in the vault are overriden. + + %command.full_name% --force +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + if (null === $this->localVault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + $secrets = $this->localVault->list(true); + + if (!$input->getOption('force')) { + foreach ($this->vault->list() as $k => $v) { + unset($secrets[$k]); + } + } + + foreach ($secrets as $k => $v) { + if (null === $v) { + $io->error($this->localVault->getLastMessage()); + + return 1; + } + + $this->vault->seal($k, $v); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php new file mode 100644 index 000000000000..f56fd0fe6c5e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeysCommand.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsGenerateKeysCommand extends Command +{ + protected static $defaultName = 'secrets:generate-keys'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Generates new encryption keys.') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') + ->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.') + ->setHelp(<<<'EOF' +The %command.name% command generates a new encryption key. + + %command.full_name% + +If encryption keys already exist, the command must be called with +the --rotate option in order to override those keys and re-encrypt +existing secrets. + + %command.full_name% --rotate +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->success('The local vault is disabled.'); + + return 1; + } + + if (!$input->getOption('rotate')) { + if ($vault->generateKeys()) { + $io->success($vault->getLastMessage()); + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + + return 0; + } + + $io->warning($vault->getLastMessage()); + + return 1; + } + + $secrets = []; + foreach ($vault->list(true) as $name => $value) { + if (null === $value) { + $io->error($vault->getLastMessage()); + + return 1; + } + + $secrets[$name] = $value; + } + + if (!$vault->generateKeys(true)) { + $io->warning($vault->getLastMessage()); + + return 1; + } + + $io->success($vault->getLastMessage()); + + if ($secrets) { + foreach ($secrets as $name => $value) { + $vault->seal($name, $value); + } + + $io->comment('Existing secrets have been rotated to the new keys.'); + } + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php new file mode 100644 index 000000000000..4ae190435db8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Dumper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsListCommand extends Command +{ + protected static $defaultName = 'secrets:list'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Lists all secrets.') + ->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names') + ->setHelp(<<<'EOF' +The %command.name% command list all stored secrets. + + %command.full_name% + +When the option --reveal is provided, the decrypted secrets are also displayed. + + %command.full_name% --reveal +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + + $io->comment('Use "%env(secret:)%" to reference a secret in a config file.'); + + if (!$reveal = $input->getOption('reveal')) { + $io->comment(sprintf('To reveal the secrets run php %s %s --reveal', $_SERVER['PHP_SELF'], $this->getName())); + } + + $secrets = $this->vault->list($reveal); + $localSecrets = null !== $this->localVault ? $this->localVault->list($reveal) : null; + + $rows = []; + + $dump = new Dumper($output); + $dump = static function (?string $v) use ($dump) { + return null === $v ? '******' : $dump($v); + }; + + foreach ($secrets as $name => $value) { + $rows[$name] = [$name, $dump($value)]; + } + + if (null !== $message = $this->vault->getLastMessage()) { + $io->comment($message); + } + + foreach ($localSecrets ?? [] as $name => $value) { + $rows[$name] = [$name, $rows[$name][1] ?? '', $dump($value)]; + } + + uksort($rows, 'strnatcmp'); + + if (null !== $this->localVault && null !== $message = $this->localVault->getLastMessage()) { + $io->comment($message); + } + + (new SymfonyStyle($input, $output)) + ->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows); + + $io->comment("Local values override secret values.\nUse secrets:set --local to defined them."); + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php new file mode 100644 index 000000000000..b0ce9a89fedf --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsRemoveCommand.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsRemoveCommand extends Command +{ + protected static $defaultName = 'secrets:remove'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Removes a secret from the vault.') + ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') + ->setHelp(<<<'EOF' +The %command.name% command removes a secret from the vault. + + %command.full_name% +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->success('The local vault is disabled.'); + + return 1; + } + + if ($vault->remove($name = $input->getArgument('name'))) { + $io->success($vault->getLastMessage() ?? 'Secret was removed from the vault.'); + } else { + $io->comment($vault->getLastMessage() ?? 'Secret was not found in the vault.'); + } + + if ($this->vault === $vault && null !== $this->localVault->reveal($name)) { + $io->comment('Note that this secret is overridden in the local vault.'); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php new file mode 100644 index 000000000000..850cf08ee32b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +final class SecretsSetCommand extends Command +{ + protected static $defaultName = 'secrets:set'; + + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Sets a secret in the vault.') + ->addArgument('name', InputArgument::REQUIRED, 'The name of the secret') + ->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN') + ->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.') + ->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generates a random value.', false) + ->setHelp(<<<'EOF' +The %command.name% command stores a secret in the vault. + + %command.full_name% + +To reference secrets in services.yaml or any other config +files, use "%env(secret:)%". + +By default, the secret value should be entered interactively. +Alternatively, provide a file where to read the secret from: + + php %command.full_name% filename + +Use "-" as a file name to read from STDIN: + + cat filename | php %command.full_name% - + +Use --local to override secrets for local needs. +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $io = new SymfonyStyle($input, $errOutput); + $name = $input->getArgument('name'); + $vault = $input->getOption('local') ? $this->localVault : $this->vault; + + if (null === $vault) { + $io->error('The local vault is disabled.'); + + return 1; + } + + if (0 < $random = $input->getOption('random') ?? 16) { + $value = strtr(substr(base64_encode(random_bytes($random)), 0, $random), '+/', '-_'); + } elseif (!$file = $input->getArgument('file')) { + $value = $io->askHidden('Please type the secret value'); + } elseif ('-' === $file) { + $value = file_get_contents('php://stdin'); + } elseif (is_file($file) && is_readable($file)) { + $value = file_get_contents($file); + } elseif (!is_file($file)) { + throw new \InvalidArgumentException(sprintf('File not found: "%s".', $file)); + } elseif (!is_readable($file)) { + throw new \InvalidArgumentException(sprintf('File is not readable: "%s".', $file)); + } + + if (null === $value) { + $io->warning('No value provided, aborting.'); + + return 1; + } + + if ($vault->generateKeys()) { + $io->success($vault->getLastMessage()); + + if ($this->vault === $vault) { + $io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️'); + } + } + + $vault->seal($name, $value); + + $io->success($vault->getLastMessage() ?? 'Secret was successfully stored in the vault.'); + + if (0 < $random) { + $errOutput->write(' // The generated random value is: '); + $output->write($value); + $errOutput->writeln(''); + $io->newLine(); + } + + if ($this->vault === $vault && null !== $this->localVault->reveal($name)) { + $io->comment('Note that this secret is overridden in the local vault.'); + } + + return 0; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 5ec36132c6bf..2f5793a0a3b9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -115,11 +115,28 @@ public function getConfigTreeBuilder() $this->addRobotsIndexSection($rootNode); $this->addHttpClientSection($rootNode); $this->addMailerSection($rootNode); + $this->addSecretsSection($rootNode); $this->addNotifierSection($rootNode); return $treeBuilder; } + private function addSecretsSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('secrets') + ->canBeDisabled() + ->children() + ->scalarNode('vault_directory')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end() + ->scalarNode('local_dotenv_file')->defaultValue('%kernel.project_dir%/.env.local')->end() + ->scalarNode('decryption_env_var')->defaultValue('base64:default::SYMFONY_DECRYPTION_SECRET')->end() + ->end() + ->end() + ->end() + ; + } + private function addCsrfSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index b71d848674a5..5ccbc9e3560d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -336,6 +336,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerRouterConfiguration($config['router'], $container, $loader); $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); + $this->registerSecretsConfiguration($config['secrets'], $container, $loader); if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists('Symfony\Component\Serializer\Serializer')) { @@ -1316,6 +1317,34 @@ private function registerPropertyAccessConfiguration(array $config, ContainerBui ; } + private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!$this->isConfigEnabled($container, $config)) { + $container->removeDefinition('console.command.secrets_set'); + $container->removeDefinition('console.command.secrets_list'); + $container->removeDefinition('console.command.secrets_remove'); + $container->removeDefinition('console.command.secrets_generate_key'); + $container->removeDefinition('console.command.secrets_decrypt_to_local'); + $container->removeDefinition('console.command.secrets_encrypt_from_local'); + + return; + } + + $loader->load('secrets.xml'); + + $container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']); + + if (!$config['local_dotenv_file']) { + $container->removeDefinition('secrets.local_vault'); + } + + if ($config['decryption_env_var']) { + $container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']); + } else { + $container->removeDefinition('secrets.decryption_key'); + } + } + private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { if (!$this->isConfigEnabled($container, $config)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index 46909703835c..de1c0bb37c50 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -199,5 +199,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml new file mode 100644 index 000000000000..d0ba0bcb3450 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -0,0 +1,41 @@ + + + + + + + %kernel.project_dir%/config/secrets/%kernel.environment% + + + + + + + + + + + + getEnv + + + + base64:default::SYMFONY_DECRYPTION_SECRET + + + + %kernel.project_dir%/.env.local + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php new file mode 100644 index 000000000000..eeecbbb68b68 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Nicolas Grekas + * + * @internal + */ +abstract class AbstractVault +{ + protected $lastMessage; + + public function getLastMessage(): ?string + { + return $this->lastMessage; + } + + abstract public function generateKeys(bool $override = false): bool; + + abstract public function seal(string $name, string $value): void; + + abstract public function reveal(string $name): ?string; + + abstract public function remove(string $name): bool; + + abstract public function list(bool $reveal = false): array; + + protected function validateName(string $name): void + { + if (!preg_match('/^\w++$/D', $name)) { + throw new \LogicException(sprintf('Invalid secret name "%s": only "word" characters are allowed.', $name)); + } + } + + protected function getPrettyPath(string $path) + { + return str_replace(getcwd().\DIRECTORY_SEPARATOR, '', $path); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php new file mode 100644 index 000000000000..16df2a6045f7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class DotenvVault extends AbstractVault +{ + private $dotenvFile; + + public function __construct(string $dotenvFile) + { + $this->dotenvFile = strtr($dotenvFile, '/', \DIRECTORY_SEPARATOR); + } + + public function generateKeys(bool $override = false): bool + { + $this->lastMessage = 'The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.'; + + return false; + } + + public function seal(string $name, string $value): void + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + $v = str_replace("'", "'\\''", $value); + + $content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : ''; + $content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)/m", "$k='$v'", $content, -1, $count); + + if (!$count) { + $content .= "$k='$v'\n"; + } + + file_put_contents($this->dotenvFile, $content); + + $this->lastMessage = sprintf('Secret "%s" %s in "%s".', $name, $count ? 'added' : 'updated', $this->getPrettyPath($this->dotenvFile)); + } + + public function reveal(string $name): ?string + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + $v = \is_string($_SERVER[$k] ?? null) ? $_SERVER[$k] : ($_ENV[$k] ?? null); + + if (null === $v) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return null; + } + + return $v; + } + + public function remove(string $name): bool + { + $this->lastMessage = null; + $this->validateName($name); + $k = $name.'_SECRET'; + + $content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : ''; + $content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)\n?/m", '', $content, -1, $count); + + if ($count) { + file_put_contents($this->dotenvFile, $content); + $this->lastMessage = sprintf('Secret "%s" removed from file "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return true; + } + + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile)); + + return false; + } + + public function list(bool $reveal = false): array + { + $this->lastMessage = null; + $secrets = []; + + foreach ($_ENV as $k => $v) { + if (preg_match('/^(\w+)_SECRET$/D', $k, $m)) { + $secrets[$m[1]] = $reveal ? $v : null; + } + } + + foreach ($_SERVER as $k => $v) { + if (\is_string($v) && preg_match('/^(\w+)_SECRET$/D', $k, $m)) { + $secrets[$m[1]] = $reveal ? $v : null; + } + } + + return $secrets; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php new file mode 100644 index 000000000000..5a4771fa365f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/SecretEnvVarProcessor.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; +use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException; + +/** + * @author Tobias Schultze + * @author Nicolas Grekas + * + * @internal + */ +class SecretEnvVarProcessor implements EnvVarProcessorInterface +{ + private $vault; + private $localVault; + + public function __construct(AbstractVault $vault, AbstractVault $localVault = null) + { + $this->vault = $vault; + $this->localVault = $localVault; + } + + /** + * {@inheritdoc} + */ + public static function getProvidedTypes() + { + return [ + 'secret' => 'string', + ]; + } + + /** + * {@inheritdoc} + */ + public function getEnv($prefix, $name, \Closure $getEnv) + { + if (null !== $this->localVault && null !== $secret = $this->localVault->reveal($name)) { + return $secret; + } + + if (null !== $secret = $this->vault->reveal($name)) { + return $secret; + } + + throw new EnvNotFoundException($this->vault->getLastMessage() ?? sprintf('Secret "%s" not found or decryption key is missing.', $name)); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php b/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php new file mode 100644 index 000000000000..8aae669d8f53 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secrets; + +/** + * @author Tobias Schultze + * @author Jérémy Derussé + * @author Nicolas Grekas + * + * @internal + */ +class SodiumVault extends AbstractVault +{ + private $encryptionKey; + private $decryptionKey; + private $pathPrefix; + + /** + * @param string|object|null $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault + * or null to store generated keys in the provided $secretsDir + */ + public function __construct(string $secretsDir, $decryptionKey = null) + { + if (!\function_exists('sodium_crypto_box_seal')) { + throw new \LogicException('The "sodium" PHP extension is required to deal with secrets. Alternatively, try running "composer require paragonie/sodium_compat" if you cannot enable the extension."'); + } + + if (null !== $decryptionKey && !\is_string($decryptionKey) && !(\is_object($decryptionKey) && method_exists($decryptionKey, '__toString'))) { + throw new \TypeError(sprintf('Decryption key should be a string or an object that implements the __toString() method, %s given.', \gettype($decryptionKey))); + } + + if (!is_dir($secretsDir) && !@mkdir($secretsDir, 0777, true) && !is_dir($secretsDir)) { + throw new \RuntimeException(sprintf('Unable to create the secrets directory (%s)', $secretsDir)); + } + + $this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.'; + $this->decryptionKey = $decryptionKey; + } + + public function generateKeys(bool $override = false): bool + { + $this->lastMessage = null; + + if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) { + throw new \LogicException('Cannot generate keys when a decryption key has been provided while instantiating the vault.'); + } + + try { + $this->loadKeys(); + } catch (\LogicException $e) { + // ignore failures to load keys + } + + if ('' !== $this->decryptionKey && !file_exists($this->pathPrefix.'sodium.encrypt.public')) { + $this->export('sodium.encrypt.public', $this->encryptionKey); + } + + if (!$override && null !== $this->encryptionKey) { + $this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix)); + + return false; + } + + $this->decryptionKey = sodium_crypto_box_keypair(); + $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey); + + $this->export('sodium.encrypt.public', $this->encryptionKey); + $this->export('sodium.decrypt.private', $this->decryptionKey); + + $this->lastMessage = sprintf('Sodium keys have been generated at "%s*.{public,private}".', $this->getPrettyPath($this->pathPrefix)); + + return true; + } + + public function seal(string $name, string $value): void + { + $this->lastMessage = null; + $this->validateName($name); + $this->loadKeys(); + $this->export($name.'.'.substr_replace(md5($name), '.sodium', -26), sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey))); + $this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + } + + public function reveal(string $name): ?string + { + $this->lastMessage = null; + $this->validateName($name); + + if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return null; + } + + $this->loadKeys(); + + if ('' === $this->decryptionKey) { + $this->lastMessage = sprintf('Secrets cannot be revealed as no decryption key was found in "%s".', $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return null; + } + + return sodium_crypto_box_seal_open(include $file, $this->decryptionKey); + } + + public function remove(string $name): bool + { + $this->lastMessage = null; + $this->validateName($name); + + if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) { + $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return false; + } + + $this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR)); + + return @unlink($file) || !file_exists($file); + } + + public function list(bool $reveal = false): array + { + $this->lastMessage = null; + $secrets = []; + $regexp = sprintf('{^%s(\w++)\.[0-9a-f]{6}\.sodium$}D', preg_quote(basename($this->pathPrefix))); + + foreach (scandir(\dirname($this->pathPrefix)) as $name) { + if (preg_match($regexp, $name, $m)) { + $secrets[$m[1]] = $reveal ? $this->reveal($m[1]) : null; + } + } + + return $secrets; + } + + private function loadKeys(): void + { + if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) { + return; + } + + if (file_exists($this->pathPrefix.'sodium.decrypt.private')) { + $this->decryptionKey = (string) include $this->pathPrefix.'sodium.decrypt.private'; + } + + if (file_exists($this->pathPrefix.'sodium.encrypt.public')) { + $this->encryptionKey = (string) include $this->pathPrefix.'sodium.encrypt.public'; + } elseif ('' !== $this->decryptionKey) { + $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey); + } else { + throw new \LogicException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix))); + } + } + + private function export(string $file, string $data): void + { + $name = basename($this->pathPrefix.$file); + $data = str_replace('%', '\x', rawurlencode($data)); + $data = sprintf("pathPrefix.$file, $data, LOCK_EX)) { + $e = error_get_last(); + throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? E_USER_WARNING); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index f452cf475ed2..36ece319e241 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -420,6 +420,12 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'admin_recipients' => [], ], 'error_controller' => 'error_controller', + 'secrets' => [ + 'enabled' => true, + 'vault_directory' => '%kernel.project_dir%/config/secrets/%kernel.environment%', + 'local_dotenv_file' => '%kernel.project_dir%/.env.local', + 'decryption_env_var' => 'base64:default::SYMFONY_DECRYPTION_SECRET', + ], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index b547a84604b6..b7e98903a65e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -280,7 +280,7 @@ public function testWorkflows() public function testWorkflowAreValidated() { $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); - $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "go" from place/state "first" where found on StateMachine "my_workflow".'); + $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "go" from place/state "first" were found on StateMachine "my_workflow".'); $this->createContainerFromFile('workflow_not_valid'); } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php index 5430cd607f75..3218a2d5d43d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/PhpFrameworkExtensionTest.php @@ -56,7 +56,7 @@ public function testAssetPackageCannotHavePathAndUrl() public function testWorkflowValidationStateMachine() { $this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException'); - $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "a_to_b" from place/state "a" where found on StateMachine "article".'); + $this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "a_to_b" from place/state "a" were found on StateMachine "article".'); $this->createContainerFromClosure(function ($container) { $container->loadFromExtension('framework', [ 'workflows' => [ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php new file mode 100644 index 000000000000..ba234349d76b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/DotenvVaultTest.php @@ -0,0 +1,57 @@ +envFile = sys_get_temp_dir().'/sf_secrets.env.test'; + @unlink($this->envFile); + } + + protected function tearDown(): void + { + @unlink($this->envFile); + } + + public function testGenerateKeys() + { + $vault = new DotenvVault($this->envFile); + + $this->assertFalse($vault->generateKeys()); + $this->assertSame('The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.', $vault->getLastMessage()); + } + + public function testEncryptAndDecrypt() + { + $vault = new DotenvVault($this->envFile); + + $plain = "plain\ntext"; + + $vault->seal('foo', $plain); + + unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']); + (new Dotenv(false))->load($this->envFile); + + $decrypted = $vault->reveal('foo'); + $this->assertSame($plain, $decrypted); + + $this->assertSame(['foo' => null], $vault->list()); + $this->assertSame(['foo' => $plain], $vault->list(true)); + + $this->assertTrue($vault->remove('foo')); + $this->assertFalse($vault->remove('foo')); + + unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']); + (new Dotenv(false))->load($this->envFile); + + $this->assertSame([], $vault->list()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php new file mode 100644 index 000000000000..2e25df902462 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Secrets/SodiumVaultTest.php @@ -0,0 +1,64 @@ +secretsDir = sys_get_temp_dir().'/sf_secrets/test/'; + (new Filesystem())->remove($this->secretsDir); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->secretsDir); + } + + public function testGenerateKeys() + { + $vault = new SodiumVault($this->secretsDir); + + $this->assertTrue($vault->generateKeys()); + $this->assertFileExists($this->secretsDir.'/test.sodium.encrypt.public'); + $this->assertFileExists($this->secretsDir.'/test.sodium.decrypt.private'); + + $encKey = file_get_contents($this->secretsDir.'/test.sodium.encrypt.public'); + $decKey = file_get_contents($this->secretsDir.'/test.sodium.decrypt.private'); + + $this->assertFalse($vault->generateKeys()); + $this->assertStringEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey); + $this->assertStringEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey); + + $this->assertTrue($vault->generateKeys(true)); + $this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey); + $this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey); + } + + public function testEncryptAndDecrypt() + { + $vault = new SodiumVault($this->secretsDir); + $vault->generateKeys(); + + $plain = "plain\ntext"; + + $vault->seal('foo', $plain); + + $decrypted = $vault->reveal('foo'); + $this->assertSame($plain, $decrypted); + + $this->assertSame(['foo' => null], $vault->list()); + $this->assertSame(['foo' => $plain], $vault->list(true)); + + $this->assertTrue($vault->remove('foo')); + $this->assertFalse($vault->remove('foo')); + + $this->assertSame([], $vault->list()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 045ce0af9d3e..3c927f16f306 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -37,6 +37,7 @@ "symfony/console": "^4.4|^5.0", "symfony/css-selector": "^4.4|^5.0", "symfony/dom-crawler": "^4.4|^5.0", + "symfony/dotenv": "^4.4|^5.0", "symfony/polyfill-intl-icu": "~1.0", "symfony/form": "^4.4|^5.0", "symfony/expression-language": "^4.4|^5.0", diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index bba719b534f2..80906db4eda2 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -115,7 +115,7 @@ private function doAsk(OutputInterface $output, Question $question) { $this->writePrompt($output, $question); - $inputStream = $this->inputStream ?: fopen('php://stdin', 'r'); + $inputStream = $this->inputStream ?: STDIN; $autocomplete = $question->getAutocompleterCallback(); if (null === $autocomplete || !Terminal::hasSttyAvailable()) { diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index bddd592b3b8a..a116ff72ec34 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 4.4.0 ----- + * added `canceled` to `ResponseInterface::getInfo()` * added `HttpClient::createForBaseUri()` * added `HttplugClient` with support for sync and async requests * added `max_duration` option diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index 8a54e9c0e912..ac98e61bea85 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -96,6 +96,7 @@ public function request(string $method, string $url, array $options = []): Respo 'response_headers' => [], 'url' => $url, 'error' => null, + 'canceled' => false, 'http_method' => $method, 'http_code' => 0, 'redirect_count' => 0, diff --git a/src/Symfony/Component/HttpClient/Response/MockResponse.php b/src/Symfony/Component/HttpClient/Response/MockResponse.php index 4c5c322c1b2c..59db21cc3fd0 100644 --- a/src/Symfony/Component/HttpClient/Response/MockResponse.php +++ b/src/Symfony/Component/HttpClient/Response/MockResponse.php @@ -84,6 +84,7 @@ public function getInfo(string $type = null) */ public function cancel(): void { + $this->info['canceled'] = true; $this->info['error'] = 'Response has been canceled.'; $this->body = null; } diff --git a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php index d27acfa5502c..4cd7710f788a 100644 --- a/src/Symfony/Component/HttpClient/Response/ResponseTrait.php +++ b/src/Symfony/Component/HttpClient/Response/ResponseTrait.php @@ -52,6 +52,7 @@ trait ResponseTrait 'response_headers' => [], 'http_code' => 0, 'error' => null, + 'canceled' => false, ]; /** @var resource */ @@ -178,6 +179,7 @@ public function toArray(bool $throw = true): array */ public function cancel(): void { + $this->info['canceled'] = true; $this->info['error'] = 'Response has been canceled.'; $this->close(); } diff --git a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php index efbaebc4e710..f0721aa8a27c 100644 --- a/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php @@ -105,6 +105,7 @@ protected function getHttpClient(string $testCase): HttpClientInterface case 'testOnProgressError': case 'testReentrantBufferCallback': case 'testThrowingBufferCallback': + case 'testInfoOnCanceledResponse': $responses[] = new MockResponse($body, ['response_headers' => $headers]); break; diff --git a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php index 2bb81d21a09b..023ca5df5e04 100644 --- a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php +++ b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php @@ -37,7 +37,7 @@ public function validate(Definition $definition, string $name) // Enforcing uniqueness of the names of transitions starting at each node $from = reset($froms); if (isset($transitionFromNames[$from][$transition->getName()])) { - throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" where found on StateMachine "%s".', $transition->getName(), $from, $name)); + throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); } $transitionFromNames[$from][$transition->getName()] = true; diff --git a/src/Symfony/Contracts/HttpClient/ResponseInterface.php b/src/Symfony/Contracts/HttpClient/ResponseInterface.php index 8628c8daf163..b9917ac5f354 100644 --- a/src/Symfony/Contracts/HttpClient/ResponseInterface.php +++ b/src/Symfony/Contracts/HttpClient/ResponseInterface.php @@ -88,15 +88,16 @@ public function cancel(): void; * another, as the request/response progresses. * * The following info MUST be returned: - * - response_headers - an array modelled after the special $http_response_header variable - * - redirect_count - the number of redirects followed while executing the request - * - redirect_url - the resolved location of redirect responses, null otherwise - * - start_time - the time when the request was sent or 0.0 when it's pending - * - http_method - the HTTP verb of the last request - * - http_code - the last response code or 0 when it is not known yet - * - error - the error message when the transfer was aborted, null otherwise - * - user_data - the value of the "user_data" request option, null if not set - * - url - the last effective URL of the request + * - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise + * - error (string|null) - the error message when the transfer was aborted, null otherwise + * - http_code (int) - the last response code or 0 when it is not known yet + * - http_method (string) - the HTTP verb of the last request + * - redirect_count (int) - the number of redirects followed while executing the request + * - redirect_url (string|null) - the resolved location of redirect responses, null otherwise + * - response_headers (array) - an array modelled after the special $http_response_header variable + * - start_time (float) - the time when the request was sent or 0.0 when it's pending + * - url (string) - the last effective URL of the request + * - user_data (mixed|null) - the value of the "user_data" request option, null if not set * * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php index 0c76a41f64fb..11ba1ae6d2a3 100644 --- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php +++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php @@ -515,6 +515,17 @@ public function testCancel() $response->getHeaders(); } + public function testInfoOnCanceledResponse() + { + $client = $this->getHttpClient(__FUNCTION__); + + $response = $client->request('GET', 'http://localhost:8057/timeout-header'); + + $this->assertFalse($response->getInfo('canceled')); + $response->cancel(); + $this->assertTrue($response->getInfo('canceled')); + } + public function testCancelInStream() { $client = $this->getHttpClient(__FUNCTION__);