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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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
+ *
+ * @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__);