Lorem ipsum dolor sit amet, consectetur.
',
+ 'Lorem ipsum dolor sit amet, consectetur.
',
+ ],
+ [
+ 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.
',
+ 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.
',
+ ],
+ [
+ 'T', 10000)));
+ }
+
+ public function testSanitizeNullByte()
+ {
+ $this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), "Null byte\0"));
+ $this->assertSame('Null byte', $this->sanitize(new HtmlSanitizerConfig(), 'Null byte'));
+ }
+
+ public function testSanitizeDefaultBody()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ (new HtmlSanitizer($config))->sanitize('
Hello
world')
+ );
+ }
+
+ public function testAllowElement()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ ' world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowElementWithAttribute()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div', ['style'])
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ ' world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testBlockElement()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->blockElement('div')
+ ;
+
+ $this->assertSame(
+ 'Hello world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ ' world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testDropElement()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->blockElement('div')
+ ->dropElement('div')
+ ;
+
+ $this->assertSame(
+ ' world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ ' world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowAttributeOnElement()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ->allowElement('span')
+ ->allowAttribute('style', ['div'])
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowAttributeEverywhere()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ->allowElement('span')
+ ->allowAttribute('style', '*')
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testDropAttributeOnElement()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ->allowElement('span')
+ ->allowAttribute('style', '*')
+ ->dropAttribute('style', 'span')
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testDropAttributeEverywhere()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ->allowElement('span')
+ ->allowAttribute('style', '*')
+ ->dropAttribute('style', '*')
+ ;
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testForceAttribute()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div')
+ ->allowElement('a', ['href'])
+ ->forceAttribute('a', 'rel', 'noopener noreferrer')
+ ;
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello
world',
+ $this->sanitize($config, '
Hello
world')
+ );
+ }
+
+ public function testForceHttps()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('a', ['href'])
+ ->forceHttpsUrls()
+ ;
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowLinksSchemes()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('a', ['href'])
+ ->allowLinkSchemes(['https'])
+ ;
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowLinksHosts()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('a', ['href'])
+ ->allowLinkHosts(['trusted.com'])
+ ;
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowLinksRelative()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('a', ['href'])
+ ->allowRelativeLinks()
+ ;
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+
+ $this->assertSame(
+ '
Hello world',
+ $this->sanitize($config, '
Hello world')
+ );
+ }
+
+ public function testAllowMediaSchemes()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('img', ['src'])
+ ->allowMediaSchemes(['https'])
+ ;
+
+ $this->assertSame(
+ '

',
+ $this->sanitize($config, '

')
+ );
+
+ $this->assertSame(
+ '
![]()
',
+ $this->sanitize($config, '

')
+ );
+
+ $this->assertSame(
+ '
![]()
',
+ $this->sanitize($config, '

')
+ );
+ }
+
+ public function testAllowMediasHosts()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('img', ['src'])
+ ->allowMediaHosts(['trusted.com'])
+ ;
+
+ $this->assertSame(
+ '

',
+ $this->sanitize($config, '

')
+ );
+
+ $this->assertSame(
+ '
![]()
',
+ $this->sanitize($config, '

')
+ );
+
+ $this->assertSame(
+ '
![]()
',
+ $this->sanitize($config, '

')
+ );
+ }
+
+ public function testAllowMediasRelative()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('img', ['src'])
+ ->allowRelativeMedias()
+ ;
+
+ $this->assertSame(
+ '

',
+ $this->sanitize($config, '

')
+ );
+
+ $this->assertSame(
+ '

',
+ $this->sanitize($config, '

')
+ );
+ }
+
+ public function testCustomAttributeSanitizer()
+ {
+ $config = (new HtmlSanitizerConfig())
+ ->allowElement('div', ['data-attr'])
+ ->withAttributeSanitizer(new class() implements AttributeSanitizerInterface {
+ public function getSupportedElements(): ?array
+ {
+ return ['div'];
+ }
+
+ public function getSupportedAttributes(): ?array
+ {
+ return ['data-attr'];
+ }
+
+ public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string
+ {
+ return 'new value';
+ }
+ })
+ ;
+
+ $this->assertSame(
+ '
Hello world
',
+ $this->sanitize($config, '
Hello world
')
+ );
+ }
+
+ private function sanitize(HtmlSanitizerConfig $config, string $input): string
+ {
+ return (new HtmlSanitizer($config))->sanitize($input);
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/Parser/MastermindsParserTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/Parser/MastermindsParserTest.php
new file mode 100644
index 0000000000000..a013d44ca9ed5
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Tests/Parser/MastermindsParserTest.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Tests\Parser;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HtmlSanitizer\Parser\MastermindsParser;
+
+class MastermindsParserTest extends TestCase
+{
+ public function testParseValid()
+ {
+ $node = (new MastermindsParser())->parse('
');
+ $this->assertInstanceOf(\DOMNode::class, $node);
+ $this->assertSame('#document-fragment', $node->nodeName);
+ $this->assertCount(1, $node->childNodes);
+ $this->assertSame('div', $node->childNodes->item(0)->nodeName);
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/Reference/W3CReferenceTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/Reference/W3CReferenceTest.php
new file mode 100644
index 0000000000000..9749b851e7f6b
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Tests/Reference/W3CReferenceTest.php
@@ -0,0 +1,55 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Tests\Reference;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HtmlSanitizer\Reference\W3CReference;
+
+/**
+ * Check that the W3CReference class is up to date with the standard resources.
+ *
+ * @see https://github.com/WICG/sanitizer-api/blob/main/resources
+ */
+class W3CReferenceTest extends TestCase
+{
+ private const STANDARD_RESOURCES = [
+ 'elements' => 'https://raw.githubusercontent.com/WICG/sanitizer-api/main/resources/baseline-element-allow-list.json',
+ 'attributes' => 'https://raw.githubusercontent.com/WICG/sanitizer-api/main/resources/baseline-attribute-allow-list.json',
+ ];
+
+ public function testElements()
+ {
+ if (!\in_array('https', stream_get_wrappers(), true)) {
+ $this->markTestSkipped('"https" stream wrapper is not enabled.');
+ }
+
+ $referenceElements = array_values(array_merge(array_keys(W3CReference::HEAD_ELEMENTS), array_keys(W3CReference::BODY_ELEMENTS)));
+ sort($referenceElements);
+
+ $this->assertSame(
+ json_decode(file_get_contents(self::STANDARD_RESOURCES['elements']), true, 512, \JSON_THROW_ON_ERROR),
+ $referenceElements
+ );
+ }
+
+ public function testAttributes()
+ {
+ if (!\in_array('https', stream_get_wrappers(), true)) {
+ $this->markTestSkipped('"https" stream wrapper is not enabled.');
+ }
+
+ $this->assertSame(
+ json_decode(file_get_contents(self::STANDARD_RESOURCES['attributes']), true, 512, \JSON_THROW_ON_ERROR),
+ array_keys(W3CReference::ATTRIBUTES)
+ );
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/StringSanitizerTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/StringSanitizerTest.php
new file mode 100644
index 0000000000000..a8149f2df3e95
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/StringSanitizerTest.php
@@ -0,0 +1,76 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Tests\TextSanitizer;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
+
+class StringSanitizerTest extends TestCase
+{
+ public function provideHtmlLower()
+ {
+ $cases = [
+ 'exampleAttr' => 'exampleattr',
+ 'aTTrΔ' => 'attrΔ',
+ 'data-attr' => 'data-attr',
+ 'test with space' => 'test with space',
+ ];
+
+ foreach ($cases as $input => $expected) {
+ yield $input => [$input, $expected];
+ }
+ }
+
+ /**
+ * @dataProvider provideHtmlLower
+ */
+ public function testHtmlLower(string $input, string $expected)
+ {
+ $this->assertSame($expected, StringSanitizer::htmlLower($input));
+ }
+
+ public function provideEncodeHtmlEntites()
+ {
+ $cases = [
+ '' => '',
+ '"' => '"',
+ '\'' => ''',
+ '&' => '&',
+ '<' => '<',
+ '>' => '>',
+ '<' => '<',
+ '>' => '>',
+ '+' => '+',
+ '=' => '=',
+ '@' => '@',
+ '`' => '`',
+ '<' => '<',
+ '>' => '>',
+ '+' => '+',
+ '=' => '=',
+ '@' => '@',
+ '`' => '`',
+ ];
+
+ foreach ($cases as $input => $expected) {
+ yield $input => [$input, $expected];
+ }
+ }
+
+ /**
+ * @dataProvider provideEncodeHtmlEntites
+ */
+ public function testEncodeHtmlEntites(string $input, string $expected)
+ {
+ $this->assertSame($expected, StringSanitizer::encodeHtmlEntities($input));
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php
new file mode 100644
index 0000000000000..3216244e9ed10
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Tests/TextSanitizer/UrlSanitizerTest.php
@@ -0,0 +1,783 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Tests\TextSanitizer;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HtmlSanitizer\TextSanitizer\UrlSanitizer;
+
+class UrlSanitizerTest extends TestCase
+{
+ /**
+ * @dataProvider provideSanitize
+ */
+ public function testSanitize(?string $input, ?array $allowedSchemes, ?array $allowedHosts, bool $forceHttps, bool $allowRelative, ?string $expected)
+ {
+ $this->assertSame($expected, UrlSanitizer::sanitize($input, $allowedSchemes, $forceHttps, $allowedHosts, $allowRelative));
+ }
+
+ public function provideSanitize()
+ {
+ // Simple accepted cases
+ yield [
+ 'input' => '',
+ 'allowedSchemes' => ['https'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => ':invalid',
+ 'allowedSchemes' => ['https'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'https://trusted.com/link.php',
+ 'allowedSchemes' => ['https'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'https://trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'https://trusted.com/link.php',
+ 'allowedSchemes' => ['https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'https://trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'http://trusted.com/link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'http://trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['data'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ ];
+
+ // Simple filtered cases
+ yield [
+ 'input' => 'ws://trusted.com/link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'http:link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'http:link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => true,
+ 'output' => 'http:link.php',
+ ];
+
+ yield [
+ 'input' => 'ws://trusted.com/link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'https://trusted.com/link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'https://untrusted.com/link.php',
+ 'allowedSchemes' => ['https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'http://untrusted.com/link.php',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => null,
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['http'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ // Allow null host (data scheme for instance)
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['http', 'https', 'data'],
+ 'allowedHosts' => ['trusted.com', null],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ ];
+
+ // Force HTTPS
+ yield [
+ 'input' => 'http://trusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => true,
+ 'allowRelative' => false,
+ 'output' => 'https://trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'https://trusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => true,
+ 'allowRelative' => false,
+ 'output' => 'https://trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['http', 'https', 'data'],
+ 'allowedHosts' => null,
+ 'forceHttps' => true,
+ 'allowRelative' => false,
+ 'output' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ ];
+
+ yield [
+ 'input' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ 'allowedSchemes' => ['http', 'https', 'data'],
+ 'allowedHosts' => ['trusted.com', null],
+ 'forceHttps' => true,
+ 'allowRelative' => false,
+ 'output' => 'data:text/plain;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
+ ];
+
+ // Domain matching
+ yield [
+ 'input' => 'https://subdomain.trusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'https://subdomain.trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'https://subdomain.trusted.com.untrusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ yield [
+ 'input' => 'https://deep.subdomain.trusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => 'https://deep.subdomain.trusted.com/link.php',
+ ];
+
+ yield [
+ 'input' => 'https://deep.subdomain.trusted.com.untrusted.com/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => false,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+
+ // Allow relative
+ yield [
+ 'input' => '/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => true,
+ 'allowRelative' => true,
+ 'output' => '/link.php',
+ ];
+
+ yield [
+ 'input' => '/link.php',
+ 'allowedSchemes' => ['http', 'https'],
+ 'allowedHosts' => ['trusted.com'],
+ 'forceHttps' => true,
+ 'allowRelative' => false,
+ 'output' => null,
+ ];
+ }
+
+ /**
+ * @dataProvider provideParse
+ */
+ public function testParse(string $url, ?array $expected)
+ {
+ $parsed = UrlSanitizer::parse($url);
+
+ if (null === $expected) {
+ $this->assertNull($parsed);
+ } else {
+ $this->assertIsArray($parsed);
+ $this->assertArrayHasKey('scheme', $parsed);
+ $this->assertArrayHasKey('host', $parsed);
+ $this->assertSame($expected['scheme'], $parsed['scheme']);
+ $this->assertSame($expected['host'], $parsed['host']);
+ }
+ }
+
+ public function provideParse(): iterable
+ {
+ $urls = [
+ '' => null,
+
+ // Simple tests
+ 'https://trusted.com/link.php' => ['scheme' => 'https', 'host' => 'trusted.com'],
+ 'https://trusted.com/link.php?query=1#foo' => ['scheme' => 'https', 'host' => 'trusted.com'],
+ 'https://subdomain.trusted.com/link' => ['scheme' => 'https', 'host' => 'subdomain.trusted.com'],
+ '//trusted.com/link.php' => ['scheme' => null, 'host' => 'trusted.com'],
+ 'https:trusted.com/link.php' => ['scheme' => 'https', 'host' => null],
+ 'https://untrusted.com/link' => ['scheme' => 'https', 'host' => 'untrusted.com'],
+
+ // Ensure https://bugs.php.net/bug.php?id=73192 is handled
+ 'https://untrusted.com:80?@trusted.com/' => ['scheme' => 'https', 'host' => 'untrusted.com'],
+ 'https://untrusted.com:80#@trusted.com/' => ['scheme' => 'https', 'host' => 'untrusted.com'],
+
+ // Ensure https://medium.com/secjuice/php-ssrf-techniques-9d422cb28d51 is handled
+ '0://untrusted.com;trusted.com' => null,
+ '0://untrusted.com:80;trusted.com:80' => null,
+ '0://untrusted.com:80,trusted.com:80' => null,
+
+ // Data-URI
+ 'data:text/plain;base64,SSBsb3ZlIFBIUAo' => ['scheme' => 'data', 'host' => null],
+ 'data:text/plain;base64,SSBsb3ZlIFBIUAo=trusted.com' => ['scheme' => 'data', 'host' => null],
+ 'data:http://trusted.com' => ['scheme' => 'data', 'host' => null],
+ 'data://text/plain;base64,SSBsb3ZlIFBIUAo=trusted.com' => ['scheme' => 'data', 'host' => 'text'],
+ 'data://image/png;base64,SSBsb3ZlIFBIUAo=trusted.com' => ['scheme' => 'data', 'host' => 'image'],
+ 'data:google.com/plain;base64,SSBsb3ZlIFBIUAo=' => ['scheme' => 'data', 'host' => null],
+ 'data://google.com/plain;base64,SSBsb3ZlIFBIUAo=' => ['scheme' => 'data', 'host' => 'google.com'],
+
+ // Inspired by https://github.com/punkave/sanitize-html/blob/master/test/test.js
+ "java\0\t\r\n script:alert(\'foo\')" => null,
+ 'javascript:alert(\\\'foo\\\')' => ['scheme' => null, 'host' => null],
+ 'javascript:alert(\\\'foo\\\')' => ['scheme' => null, 'host' => null],
+ 'javascript:alert(\'foo\')' => null,
+
+ // Extracted from https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json
+ "http://example .\norg" => null,
+ 'http://user:pass@foo:21/bar;par?b#c' => ['scheme' => 'http', 'host' => 'foo'],
+ 'https://trusted.com:@untrusted.com' => ['scheme' => 'https', 'host' => 'untrusted.com'],
+ 'https://:@untrusted.com' => ['scheme' => 'https', 'host' => 'untrusted.com'],
+ 'non-special://test:@untrusted.com/x' => ['scheme' => 'non-special', 'host' => 'untrusted.com'],
+ 'non-special://:@untrusted.com/x' => ['scheme' => 'non-special', 'host' => 'untrusted.com'],
+ 'http:foo.com' => ['scheme' => 'http', 'host' => null],
+ " :foo.com \n" => null,
+ ' foo.com ' => ['scheme' => null, 'host' => null],
+ 'a: foo.com' => null,
+ 'http://f:21/ b ? d # e ' => ['scheme' => 'http', 'host' => 'f'],
+ 'lolscheme:x x#x x' => ['scheme' => 'lolscheme', 'host' => null],
+ 'http://f:/c' => ['scheme' => 'http', 'host' => 'f'],
+ 'http://f:0/c' => ['scheme' => 'http', 'host' => 'f'],
+ 'http://f:00000000000000/c' => ['scheme' => 'http', 'host' => 'f'],
+ 'http://f:00000000000000000000080/c' => ['scheme' => 'http', 'host' => 'f'],
+ "http://f:\n/c" => null,
+ ' ' => null,
+ ':foo.com/' => null,
+ ':foo.com\\' => ['scheme' => null, 'host' => null],
+ ':' => ['scheme' => null, 'host' => null],
+ ':a' => ['scheme' => null, 'host' => null],
+ ':/' => null,
+ ':\\' => ['scheme' => null, 'host' => null],
+ ':#' => ['scheme' => null, 'host' => null],
+ '#' => ['scheme' => null, 'host' => null],
+ '#/' => ['scheme' => null, 'host' => null],
+ '#\\' => ['scheme' => null, 'host' => null],
+ '#;?' => ['scheme' => null, 'host' => null],
+ '?' => ['scheme' => null, 'host' => null],
+ '/' => ['scheme' => null, 'host' => null],
+ ':23' => ['scheme' => null, 'host' => null],
+ '/:23' => ['scheme' => null, 'host' => null],
+ '::' => ['scheme' => null, 'host' => null],
+ '::23' => ['scheme' => null, 'host' => null],
+ 'foo://' => ['scheme' => 'foo', 'host' => ''],
+ 'http://a:b@c:29/d' => ['scheme' => 'http', 'host' => 'c'],
+ 'http::@c:29' => ['scheme' => 'http', 'host' => null],
+ 'http://&a:foo(b]c@d:2/' => ['scheme' => 'http', 'host' => 'd'],
+ 'http://::@c@d:2' => null,
+ 'http://foo.com:b@d/' => ['scheme' => 'http', 'host' => 'd'],
+ 'http://foo.com/\\@' => ['scheme' => 'http', 'host' => 'foo.com'],
+ 'http:\\foo.com\\' => ['scheme' => 'http', 'host' => null],
+ 'http:\\a\\b:c\\d@foo.com\\' => ['scheme' => 'http', 'host' => null],
+ 'foo:/' => ['scheme' => 'foo', 'host' => null],
+ 'foo:/bar.com/' => ['scheme' => 'foo', 'host' => null],
+ 'foo://///////' => ['scheme' => 'foo', 'host' => ''],
+ 'foo://///////bar.com/' => ['scheme' => 'foo', 'host' => ''],
+ 'foo:////://///' => ['scheme' => 'foo', 'host' => ''],
+ 'c:/foo' => ['scheme' => 'c', 'host' => null],
+ '//foo/bar' => ['scheme' => null, 'host' => 'foo'],
+ 'http://foo/path;a??e#f#g' => ['scheme' => 'http', 'host' => 'foo'],
+ 'http://foo/abcd?efgh?ijkl' => ['scheme' => 'http', 'host' => 'foo'],
+ 'http://foo/abcd#foo?bar' => ['scheme' => 'http', 'host' => 'foo'],
+ '[61:24:74]:98' => null,
+ 'http:[61:27]/:foo' => ['scheme' => 'http', 'host' => null],
+ 'http://[2001::1]' => ['scheme' => 'http', 'host' => '[2001::1]'],
+ 'http://[::127.0.0.1]' => ['scheme' => 'http', 'host' => '[::127.0.0.1]'],
+ 'http://[0:0:0:0:0:0:13.1.68.3]' => ['scheme' => 'http', 'host' => '[0:0:0:0:0:0:13.1.68.3]'],
+ 'http://[2001::1]:80' => ['scheme' => 'http', 'host' => '[2001::1]'],
+ 'http:/example.com/' => ['scheme' => 'http', 'host' => null],
+ 'ftp:/example.com/' => ['scheme' => 'ftp', 'host' => null],
+ 'https:/example.com/' => ['scheme' => 'https', 'host' => null],
+ 'madeupscheme:/example.com/' => ['scheme' => 'madeupscheme', 'host' => null],
+ 'file:/example.com/' => ['scheme' => 'file', 'host' => null],
+ 'ftps:/example.com/' => ['scheme' => 'ftps', 'host' => null],
+ 'gopher:/example.com/' => ['scheme' => 'gopher', 'host' => null],
+ 'ws:/example.com/' => ['scheme' => 'ws', 'host' => null],
+ 'wss:/example.com/' => ['scheme' => 'wss', 'host' => null],
+ 'data:/example.com/' => ['scheme' => 'data', 'host' => null],
+ 'javascript:/example.com/' => ['scheme' => 'javascript', 'host' => null],
+ 'mailto:/example.com/' => ['scheme' => 'mailto', 'host' => null],
+ 'http:example.com/' => ['scheme' => 'http', 'host' => null],
+ 'ftp:example.com/' => ['scheme' => 'ftp', 'host' => null],
+ 'https:example.com/' => ['scheme' => 'https', 'host' => null],
+ 'madeupscheme:example.com/' => ['scheme' => 'madeupscheme', 'host' => null],
+ 'ftps:example.com/' => ['scheme' => 'ftps', 'host' => null],
+ 'gopher:example.com/' => ['scheme' => 'gopher', 'host' => null],
+ 'ws:example.com/' => ['scheme' => 'ws', 'host' => null],
+ 'wss:example.com/' => ['scheme' => 'wss', 'host' => null],
+ 'data:example.com/' => ['scheme' => 'data', 'host' => null],
+ 'javascript:example.com/' => ['scheme' => 'javascript', 'host' => null],
+ 'mailto:example.com/' => ['scheme' => 'mailto', 'host' => null],
+ '/a/b/c' => ['scheme' => null, 'host' => null],
+ '/a/ /c' => ['scheme' => null, 'host' => null],
+ '/a%2fc' => ['scheme' => null, 'host' => null],
+ '/a/%2f/c' => ['scheme' => null, 'host' => null],
+ '#β' => ['scheme' => null, 'host' => null],
+ 'data:text/html,test#test' => ['scheme' => 'data', 'host' => null],
+ 'tel:1234567890' => ['scheme' => 'tel', 'host' => null],
+ 'ssh://example.com/foo/bar.git' => ['scheme' => 'ssh', 'host' => 'example.com'],
+ "file:c:\foo\bar.html" => null,
+ ' File:c|////foo\\bar.html' => null,
+ 'C|/foo/bar' => ['scheme' => null, 'host' => null],
+ "/C|\foo\bar" => null,
+ '//C|/foo/bar' => null,
+ '//server/file' => ['scheme' => null, 'host' => 'server'],
+ "\\server\file" => null,
+ '/\\server/file' => ['scheme' => null, 'host' => null],
+ 'file:///foo/bar.txt' => ['scheme' => 'file', 'host' => ''],
+ 'file:///home/me' => ['scheme' => 'file', 'host' => ''],
+ '//' => ['scheme' => null, 'host' => ''],
+ '///' => ['scheme' => null, 'host' => ''],
+ '///test' => ['scheme' => null, 'host' => ''],
+ 'file://test' => ['scheme' => 'file', 'host' => 'test'],
+ 'file://localhost' => ['scheme' => 'file', 'host' => 'localhost'],
+ 'file://localhost/' => ['scheme' => 'file', 'host' => 'localhost'],
+ 'file://localhost/test' => ['scheme' => 'file', 'host' => 'localhost'],
+ 'test' => ['scheme' => null, 'host' => null],
+ 'file:test' => ['scheme' => 'file', 'host' => null],
+ 'http://example.com/././foo' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/./.foo' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/.' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/./' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar/..' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar/../' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/..bar' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar/../ton' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar/../ton/../../a' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/../../..' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/../../../ton' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/%2e' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/%2e%2' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com////../..' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar//../..' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo/bar//..' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/%20foo' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo%' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo%2' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo%2zbar' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo%2©zbar' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo%41%7a' => ['scheme' => 'http', 'host' => 'example.com'],
+ "http://example.com/foo \u{0091}%91" => null,
+ 'http://example.com/foo%00%51' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/(%28:%3A%29)' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/%3A%3a%3C%3c' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/foo bar' => null,
+ 'http://example.com\\foo\\bar' => null,
+ 'http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/@asdf%40' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/你好你好' => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://example.com/‥/foo' => ['scheme' => 'http', 'host' => 'example.com'],
+ "http://example.com/\u{feff}/foo" => ['scheme' => 'http', 'host' => 'example.com'],
+ "http://example.com\u{002f}\u{202e}\u{002f}\u{0066}\u{006f}\u{006f}\u{002f}\u{202d}\u{002f}\u{0062}\u{0061}\u{0072}\u{0027}\u{0020}" => ['scheme' => 'http', 'host' => 'example.com'],
+ 'http://www.google.com/foo?bar=baz#' => ['scheme' => 'http', 'host' => 'www.google.com'],
+ 'http://www.google.com/foo?bar=baz# »' => ['scheme' => 'http', 'host' => 'www.google.com'],
+ 'data:test# »' => ['scheme' => 'data', 'host' => null],
+ 'http://www.google.com' => ['scheme' => 'http', 'host' => 'www.google.com'],
+ 'http://192.0x00A80001' => ['scheme' => 'http', 'host' => '192.0x00A80001'],
+ 'http://www/foo%2Ehtml' => ['scheme' => 'http', 'host' => 'www'],
+ 'http://www/foo/%2E/html' => ['scheme' => 'http', 'host' => 'www'],
+ 'http://%25DOMAIN:foobar@foodomain.com/' => ['scheme' => 'http', 'host' => 'foodomain.com'],
+ "http:\\www.google.com\foo" => null,
+ 'http://foo:80/' => ['scheme' => 'http', 'host' => 'foo'],
+ 'http://foo:81/' => ['scheme' => 'http', 'host' => 'foo'],
+ 'httpa://foo:80/' => ['scheme' => 'httpa', 'host' => 'foo'],
+ 'https://foo:443/' => ['scheme' => 'https', 'host' => 'foo'],
+ 'https://foo:80/' => ['scheme' => 'https', 'host' => 'foo'],
+ 'ftp://foo:21/' => ['scheme' => 'ftp', 'host' => 'foo'],
+ 'ftp://foo:80/' => ['scheme' => 'ftp', 'host' => 'foo'],
+ 'gopher://foo:70/' => ['scheme' => 'gopher', 'host' => 'foo'],
+ 'gopher://foo:443/' => ['scheme' => 'gopher', 'host' => 'foo'],
+ 'ws://foo:80/' => ['scheme' => 'ws', 'host' => 'foo'],
+ 'ws://foo:81/' => ['scheme' => 'ws', 'host' => 'foo'],
+ 'ws://foo:443/' => ['scheme' => 'ws', 'host' => 'foo'],
+ 'ws://foo:815/' => ['scheme' => 'ws', 'host' => 'foo'],
+ 'wss://foo:80/' => ['scheme' => 'wss', 'host' => 'foo'],
+ 'wss://foo:81/' => ['scheme' => 'wss', 'host' => 'foo'],
+ 'wss://foo:443/' => ['scheme' => 'wss', 'host' => 'foo'],
+ 'wss://foo:815/' => ['scheme' => 'wss', 'host' => 'foo'],
+ 'http:@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:/@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http://@www.example.com' => ['scheme' => 'http', 'host' => 'www.example.com'],
+ 'http:a:b@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:/a:b@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http://a:b@www.example.com' => ['scheme' => 'http', 'host' => 'www.example.com'],
+ 'http://@pple.com' => ['scheme' => 'http', 'host' => 'pple.com'],
+ 'http::b@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:/:b@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http://:b@www.example.com' => ['scheme' => 'http', 'host' => 'www.example.com'],
+ 'http:a:@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:/a:@www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http://a:@www.example.com' => ['scheme' => 'http', 'host' => 'www.example.com'],
+ 'http://www.@pple.com' => ['scheme' => 'http', 'host' => 'pple.com'],
+ 'http://:@www.example.com' => ['scheme' => 'http', 'host' => 'www.example.com'],
+ '/test.txt' => ['scheme' => null, 'host' => null],
+ '.' => ['scheme' => null, 'host' => null],
+ '..' => ['scheme' => null, 'host' => null],
+ 'test.txt' => ['scheme' => null, 'host' => null],
+ './test.txt' => ['scheme' => null, 'host' => null],
+ '../test.txt' => ['scheme' => null, 'host' => null],
+ '../aaa/test.txt' => ['scheme' => null, 'host' => null],
+ '../../test.txt' => ['scheme' => null, 'host' => null],
+ '中/test.txt' => ['scheme' => null, 'host' => null],
+ 'http://www.example2.com' => ['scheme' => 'http', 'host' => 'www.example2.com'],
+ '//www.example2.com' => ['scheme' => null, 'host' => 'www.example2.com'],
+ 'file:...' => ['scheme' => 'file', 'host' => null],
+ 'file:..' => ['scheme' => 'file', 'host' => null],
+ 'file:a' => ['scheme' => 'file', 'host' => null],
+ 'http://ExAmPlE.CoM' => ['scheme' => 'http', 'host' => 'ExAmPlE.CoM'],
+ "http://GOO\u{200b}\u{2060}\u{feff}goo.com" => ['scheme' => 'http', 'host' => "GOO\u{200b}\u{2060}\u{feff}goo.com"],
+ 'http://www.foo。bar.com' => ['scheme' => 'http', 'host' => 'www.foo。bar.com'],
+ 'https://x/�?�#�' => ['scheme' => 'https', 'host' => 'x'],
+ 'http://Go.com' => ['scheme' => 'http', 'host' => 'Go.com'],
+ 'http://你好你好' => ['scheme' => 'http', 'host' => '你好你好'],
+ 'https://faß.ExAmPlE/' => ['scheme' => 'https', 'host' => 'faß.ExAmPlE'],
+ 'sc://faß.ExAmPlE/' => ['scheme' => 'sc', 'host' => 'faß.ExAmPlE'],
+ 'http://%30%78%63%30%2e%30%32%35%30.01' => ['scheme' => 'http', 'host' => '%30%78%63%30%2e%30%32%35%30.01'],
+ 'http://%30%78%63%30%2e%30%32%35%30.01%2e' => ['scheme' => 'http', 'host' => '%30%78%63%30%2e%30%32%35%30.01%2e'],
+ 'http://0Xc0.0250.01' => ['scheme' => 'http', 'host' => '0Xc0.0250.01'],
+ 'http://./' => ['scheme' => 'http', 'host' => '.'],
+ 'http://../' => ['scheme' => 'http', 'host' => '..'],
+ 'http://0..0x300/' => ['scheme' => 'http', 'host' => '0..0x300'],
+ 'http://foo:💩@example.com/bar' => ['scheme' => 'http', 'host' => 'example.com'],
+ '#x' => ['scheme' => null, 'host' => null],
+ 'https://@test@test@example:800/' => null,
+ 'https://@@@example' => null,
+ 'http://`{}:`{}@h/`{}?`{}' => ['scheme' => 'http', 'host' => 'h'],
+ 'http://host/?\'' => ['scheme' => 'http', 'host' => 'host'],
+ 'notspecial://host/?\'' => ['scheme' => 'notspecial', 'host' => 'host'],
+ '/some/path' => ['scheme' => null, 'host' => null],
+ 'i' => ['scheme' => null, 'host' => null],
+ '../i' => ['scheme' => null, 'host' => null],
+ '/i' => ['scheme' => null, 'host' => null],
+ '?i' => ['scheme' => null, 'host' => null],
+ '#i' => ['scheme' => null, 'host' => null],
+ 'about:/../' => ['scheme' => 'about', 'host' => null],
+ 'data:/../' => ['scheme' => 'data', 'host' => null],
+ 'javascript:/../' => ['scheme' => 'javascript', 'host' => null],
+ 'mailto:/../' => ['scheme' => 'mailto', 'host' => null],
+ 'sc://ñ.test/' => ['scheme' => 'sc', 'host' => 'ñ.test'],
+ 'sc://!"$&\'()*+,-.;<=>^_`{|}~/' => null,
+ 'sc://%/' => null,
+ 'x' => ['scheme' => null, 'host' => null],
+ 'sc:\\../' => ['scheme' => 'sc', 'host' => null],
+ 'sc::a@example.net' => ['scheme' => 'sc', 'host' => null],
+ 'wow:%NBD' => ['scheme' => 'wow', 'host' => null],
+ 'wow:%1G' => ['scheme' => 'wow', 'host' => null],
+ 'ftp://%e2%98%83' => ['scheme' => 'ftp', 'host' => '%e2%98%83'],
+ 'https://%e2%98%83' => ['scheme' => 'https', 'host' => '%e2%98%83'],
+ 'http://127.0.0.1:10100/relative_import.html' => ['scheme' => 'http', 'host' => '127.0.0.1'],
+ 'http://facebook.com/?foo=%7B%22abc%22' => ['scheme' => 'http', 'host' => 'facebook.com'],
+ 'https://localhost:3000/jqueryui@1.2.3' => ['scheme' => 'https', 'host' => 'localhost'],
+ '?a=b&c=d' => ['scheme' => null, 'host' => null],
+ '??a=b&c=d' => ['scheme' => null, 'host' => null],
+ 'http:' => ['scheme' => 'http', 'host' => null],
+ 'sc:' => ['scheme' => 'sc', 'host' => null],
+ 'http://foo.bar/baz?qux#fobar' => ['scheme' => 'http', 'host' => 'foo.bar'],
+ 'http://foo.bar/baz?qux#foo"bar' => ['scheme' => 'http', 'host' => 'foo.bar'],
+ 'http://foo.bar/baz?qux#foo
['scheme' => 'http', 'host' => 'foo.bar'],
+ 'http://foo.bar/baz?qux#foo>bar' => ['scheme' => 'http', 'host' => 'foo.bar'],
+ 'http://foo.bar/baz?qux#foo`bar' => ['scheme' => 'http', 'host' => 'foo.bar'],
+ 'http://192.168.257' => ['scheme' => 'http', 'host' => '192.168.257'],
+ 'http://192.168.257.com' => ['scheme' => 'http', 'host' => '192.168.257.com'],
+ 'http://256' => ['scheme' => 'http', 'host' => '256'],
+ 'http://256.com' => ['scheme' => 'http', 'host' => '256.com'],
+ 'http://999999999' => ['scheme' => 'http', 'host' => '999999999'],
+ 'http://999999999.com' => ['scheme' => 'http', 'host' => '999999999.com'],
+ 'http://10000000000.com' => ['scheme' => 'http', 'host' => '10000000000.com'],
+ 'http://4294967295' => ['scheme' => 'http', 'host' => '4294967295'],
+ 'http://0xffffffff' => ['scheme' => 'http', 'host' => '0xffffffff'],
+ 'http://256.256.256.256.256' => ['scheme' => 'http', 'host' => '256.256.256.256.256'],
+ 'https://0x.0x.0' => ['scheme' => 'https', 'host' => '0x.0x.0'],
+ 'file:///C%3A/' => ['scheme' => 'file', 'host' => ''],
+ 'file:///C%7C/' => ['scheme' => 'file', 'host' => ''],
+ 'pix/submit.gif' => ['scheme' => null, 'host' => null],
+ '//d:' => ['scheme' => null, 'host' => 'd'],
+ '//d:/..' => ['scheme' => null, 'host' => 'd'],
+ 'file:' => ['scheme' => 'file', 'host' => null],
+ '?x' => ['scheme' => null, 'host' => null],
+ 'file:?x' => ['scheme' => 'file', 'host' => null],
+ 'file:#x' => ['scheme' => 'file', 'host' => null],
+ 'file:\\//' => ['scheme' => 'file', 'host' => null],
+ 'file:\\\\' => ['scheme' => 'file', 'host' => null],
+ 'file:\\\\?fox' => ['scheme' => 'file', 'host' => null],
+ 'file:\\\\#guppy' => ['scheme' => 'file', 'host' => null],
+ 'file://spider///' => ['scheme' => 'file', 'host' => 'spider'],
+ 'file:\\localhost//' => ['scheme' => 'file', 'host' => null],
+ 'file:///localhost//cat' => ['scheme' => 'file', 'host' => ''],
+ 'file://\\/localhost//cat' => null,
+ 'file://localhost//a//../..//' => ['scheme' => 'file', 'host' => 'localhost'],
+ '/////mouse' => ['scheme' => null, 'host' => ''],
+ '\\//pig' => ['scheme' => null, 'host' => null],
+ '\\/localhost//pig' => ['scheme' => null, 'host' => null],
+ '//localhost//pig' => ['scheme' => null, 'host' => 'localhost'],
+ '/..//localhost//pig' => ['scheme' => null, 'host' => null],
+ 'file://' => ['scheme' => 'file', 'host' => ''],
+ '/rooibos' => ['scheme' => null, 'host' => null],
+ '/?chai' => ['scheme' => null, 'host' => null],
+ 'C|' => ['scheme' => null, 'host' => null],
+ 'C|#' => ['scheme' => null, 'host' => null],
+ 'C|?' => ['scheme' => null, 'host' => null],
+ 'C|/' => ['scheme' => null, 'host' => null],
+ "C|\n/" => null,
+ 'C|\\' => ['scheme' => null, 'host' => null],
+ 'C' => ['scheme' => null, 'host' => null],
+ 'C|a' => ['scheme' => null, 'host' => null],
+ '/c:/foo/bar' => ['scheme' => null, 'host' => null],
+ '/c|/foo/bar' => ['scheme' => null, 'host' => null],
+ "file:\c:\foo\bar" => null,
+ 'file://example.net/C:/' => ['scheme' => 'file', 'host' => 'example.net'],
+ 'file://1.2.3.4/C:/' => ['scheme' => 'file', 'host' => '1.2.3.4'],
+ 'file://[1::8]/C:/' => ['scheme' => 'file', 'host' => '[1::8]'],
+ 'file:/C|/' => ['scheme' => 'file', 'host' => null],
+ 'file://C|/' => null,
+ 'file:?q=v' => ['scheme' => 'file', 'host' => null],
+ 'file:#frag' => ['scheme' => 'file', 'host' => null],
+ 'http://[1:0::]' => ['scheme' => 'http', 'host' => '[1:0::]'],
+ 'sc://ñ' => ['scheme' => 'sc', 'host' => 'ñ'],
+ 'sc://ñ?x' => ['scheme' => 'sc', 'host' => 'ñ'],
+ 'sc://ñ#x' => ['scheme' => 'sc', 'host' => 'ñ'],
+ 'sc://?' => ['scheme' => 'sc', 'host' => ''],
+ 'sc://#' => ['scheme' => 'sc', 'host' => ''],
+ '////' => ['scheme' => null, 'host' => ''],
+ '////x/' => ['scheme' => null, 'host' => ''],
+ 'tftp://foobar.com/someconfig;mode=netascii' => ['scheme' => 'tftp', 'host' => 'foobar.com'],
+ 'telnet://user:pass@foobar.com:23/' => ['scheme' => 'telnet', 'host' => 'foobar.com'],
+ 'ut2004://10.10.10.10:7777/Index.ut2' => ['scheme' => 'ut2004', 'host' => '10.10.10.10'],
+ 'redis://foo:bar@somehost:6379/0?baz=bam&qux=baz' => ['scheme' => 'redis', 'host' => 'somehost'],
+ 'rsync://foo@host:911/sup' => ['scheme' => 'rsync', 'host' => 'host'],
+ 'git://github.com/foo/bar.git' => ['scheme' => 'git', 'host' => 'github.com'],
+ 'irc://myserver.com:6999/channel?passwd' => ['scheme' => 'irc', 'host' => 'myserver.com'],
+ 'dns://fw.example.org:9999/foo.bar.org?type=TXT' => ['scheme' => 'dns', 'host' => 'fw.example.org'],
+ 'ldap://localhost:389/ou=People,o=JNDITutorial' => ['scheme' => 'ldap', 'host' => 'localhost'],
+ 'git+https://github.com/foo/bar' => ['scheme' => 'git+https', 'host' => 'github.com'],
+ 'urn:ietf:rfc:2648' => ['scheme' => 'urn', 'host' => null],
+ 'tag:joe@example.org,2001:foo/bar' => ['scheme' => 'tag', 'host' => null],
+ 'non-special://%E2%80%A0/' => ['scheme' => 'non-special', 'host' => '%E2%80%A0'],
+ 'non-special://H%4fSt/path' => ['scheme' => 'non-special', 'host' => 'H%4fSt'],
+ 'non-special://[1:2:0:0:5:0:0:0]/' => ['scheme' => 'non-special', 'host' => '[1:2:0:0:5:0:0:0]'],
+ 'non-special://[1:2:0:0:0:0:0:3]/' => ['scheme' => 'non-special', 'host' => '[1:2:0:0:0:0:0:3]'],
+ 'non-special://[1:2::3]:80/' => ['scheme' => 'non-special', 'host' => '[1:2::3]'],
+ 'blob:https://example.com:443/' => ['scheme' => 'blob', 'host' => null],
+ 'blob:d3958f5c-0777-0845-9dcf-2cb28783acaf' => ['scheme' => 'blob', 'host' => null],
+ 'http://0177.0.0.0189' => ['scheme' => 'http', 'host' => '0177.0.0.0189'],
+ 'http://0x7f.0.0.0x7g' => ['scheme' => 'http', 'host' => '0x7f.0.0.0x7g'],
+ 'http://0X7F.0.0.0X7G' => ['scheme' => 'http', 'host' => '0X7F.0.0.0X7G'],
+ 'http://[0:1:0:1:0:1:0:1]' => ['scheme' => 'http', 'host' => '[0:1:0:1:0:1:0:1]'],
+ 'http://[1:0:1:0:1:0:1:0]' => ['scheme' => 'http', 'host' => '[1:0:1:0:1:0:1:0]'],
+ 'http://example.org/test?"' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?#' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?<' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?>' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?⌣' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?%23%23' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?%GH' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?a#%EF' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http://example.org/test?a#%GH' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'test-a-colon-slash.html' => ['scheme' => null, 'host' => null],
+ 'test-a-colon-slash-slash.html' => ['scheme' => null, 'host' => null],
+ 'test-a-colon-slash-b.html' => ['scheme' => null, 'host' => null],
+ 'test-a-colon-slash-slash-b.html' => ['scheme' => null, 'host' => null],
+ 'http://example.org/test?a#bc' => ['scheme' => 'http', 'host' => 'example.org'],
+ 'http:\\/\\/f:b\\/c' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f: \\/c' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f:fifty-two\\/c' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f:999999\\/c' => ['scheme' => 'http', 'host' => null],
+ 'non-special:\\/\\/f:999999\\/c' => ['scheme' => 'non-special', 'host' => null],
+ 'http:\\/\\/f: 21 \\/ b ? d # e ' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[1::2]:3:4' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/2001::1' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/2001::1]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/2001::1]:80' => ['scheme' => 'http', 'host' => null],
+ 'file:\\/\\/example:1\\/' => ['scheme' => 'file', 'host' => null],
+ 'file:\\/\\/example:test\\/' => ['scheme' => 'file', 'host' => null],
+ 'file:\\/\\/example%\\/' => ['scheme' => 'file', 'host' => null],
+ 'file:\\/\\/[example]\\/' => ['scheme' => 'file', 'host' => null],
+ 'http:\\/\\/user:pass@\\/' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/foo:-80\\/' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/:@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/user@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'https:@\\/www.example.com' => ['scheme' => 'https', 'host' => null],
+ 'http:a:b@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/a:b@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/a:b@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http::@\\/www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:@:www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/@:www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/@:www.example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/example example.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/Goo%20 goo%7C|.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[:]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/GOO\\u00a0\\u3000goo.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/\\ufdd0zyx.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%ef%b7%90zyx.com' => ['scheme' => 'http', 'host' => null],
+ 'https:\\/\\/\\ufffd' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/%EF%BF%BD' => ['scheme' => 'https', 'host' => null],
+ 'http:\\/\\/\\uff05\\uff14\\uff11.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%ef%bc%85%ef%bc%94%ef%bc%91.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/\\uff05\\uff10\\uff10.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%ef%bc%85%ef%bc%90%ef%bc%90.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%zz%66%a.com' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%25' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/hello%00' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/192.168.0.257' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/%3g%78%63%30%2e%30%32%35%30%2E.01' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/192.168.0.1 hello' => ['scheme' => 'http', 'host' => null],
+ 'https:\\/\\/x x:12' => ['scheme' => 'https', 'host' => null],
+ 'http:\\/\\/[www.google.com]\\/' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[google.com]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[::1.2.3.4x]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[::1.2.3.]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[::1.2.]' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/[::1.]' => ['scheme' => 'http', 'host' => null],
+ '..\\/i' => ['scheme' => null, 'host' => null],
+ '\\/i' => ['scheme' => null, 'host' => null],
+ 'sc:\\/\\/\\u0000\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/ \\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/@\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/te@s:t@\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/:\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/:12\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/[\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/\\\\/' => ['scheme' => 'sc', 'host' => null],
+ 'sc:\\/\\/]\\/' => ['scheme' => 'sc', 'host' => null],
+ 'ftp:\\/\\/example.com%80\\/' => ['scheme' => 'ftp', 'host' => null],
+ 'ftp:\\/\\/example.com%A0\\/' => ['scheme' => 'ftp', 'host' => null],
+ 'https:\\/\\/example.com%80\\/' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/example.com%A0\\/' => ['scheme' => 'https', 'host' => null],
+ 'http:\\/\\/10000000000' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/4294967296' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/0xffffffff1' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/256.256.256.256' => ['scheme' => 'http', 'host' => null],
+ 'https:\\/\\/0x100000000\\/test' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/256.0.0.1\\/test' => ['scheme' => 'https', 'host' => null],
+ 'http:\\/\\/[0:1:2:3:4:5:6:7:8]' => ['scheme' => 'http', 'host' => null],
+ 'https:\\/\\/[0::0::0]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:.0]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:0:]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:1:2:3:4:5:6:7.0.0.0.1]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:1.00.0.0.0]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:1.290.0.0.0]' => ['scheme' => 'https', 'host' => null],
+ 'https:\\/\\/[0:1.23.23]' => ['scheme' => 'https', 'host' => null],
+ 'http:\\/\\/?' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/#' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f:4294967377\\/c' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f:18446744073709551697\\/c' => ['scheme' => 'http', 'host' => null],
+ 'http:\\/\\/f:340282366920938463463374607431768211537\\/c' => ['scheme' => 'http', 'host' => null],
+ 'non-special:\\/\\/[:80\\/' => ['scheme' => 'non-special', 'host' => null],
+ 'http:\\/\\/[::127.0.0.0.1]' => ['scheme' => 'http', 'host' => null],
+ 'a' => ['scheme' => null, 'host' => null],
+ 'a\\/' => ['scheme' => null, 'host' => null],
+ 'a\\/\\/' => ['scheme' => null, 'host' => null],
+ 'test-a-colon.html' => ['scheme' => null, 'host' => null],
+ 'test-a-colon-b.html' => ['scheme' => null, 'host' => null],
+ ];
+
+ foreach ($urls as $url => $expected) {
+ yield $url => [$url, $expected];
+ }
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/TextSanitizer/StringSanitizer.php b/src/Symfony/Component/HtmlSanitizer/TextSanitizer/StringSanitizer.php
new file mode 100644
index 0000000000000..99c56469709e5
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/TextSanitizer/StringSanitizer.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\Component\HtmlSanitizer\TextSanitizer;
+
+/**
+ * @internal
+ */
+final class StringSanitizer
+{
+ private const LOWERCASE = [
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'abcdefghijklmnopqrstuvwxyz',
+ ];
+
+ private const REPLACEMENTS = [
+ [
+ // """ is shorter than """
+ '"',
+
+ // Fix several potential issues in how browsers interpret attributes values
+ '+',
+ '=',
+ '@',
+ '`',
+
+ // Some DB engines will transform UTF8 full-width characters their classical version
+ // if the data is saved in a non-UTF8 field
+ '<',
+ '>',
+ '+',
+ '=',
+ '@',
+ '`',
+ ],
+ [
+ '"',
+
+ '+',
+ '=',
+ '@',
+ '`',
+
+ '<',
+ '>',
+ '+',
+ '=',
+ '@',
+ '`',
+ ],
+ ];
+
+ /**
+ * Applies a transformation to lowercase following W3C HTML Standard.
+ *
+ * @see https://w3c.github.io/html-reference/terminology.html#case-insensitive
+ */
+ public static function htmlLower(string $string): string
+ {
+ return strtr($string, self::LOWERCASE[0], self::LOWERCASE[1]);
+ }
+
+ /**
+ * Encodes the HTML entities in the given string for safe injection in a document's DOM.
+ */
+ public static function encodeHtmlEntities(string $string): string
+ {
+ return str_replace(
+ self::REPLACEMENTS[0],
+ self::REPLACEMENTS[1],
+ htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8')
+ );
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php b/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php
new file mode 100644
index 0000000000000..c4643f7b24635
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/TextSanitizer/UrlSanitizer.php
@@ -0,0 +1,136 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\TextSanitizer;
+
+use League\Uri\Exceptions\SyntaxError;
+use League\Uri\UriString;
+
+/**
+ * @internal
+ */
+final class UrlSanitizer
+{
+ /**
+ * Sanitizes a given URL string.
+ *
+ * In addition to ensuring $input is a valid URL, this sanitizer checks that:
+ * * the URL's host is allowed ;
+ * * the URL's scheme is allowed ;
+ * * the URL is allowed to be relative if it is ;
+ *
+ * It also transforms the URL to HTTPS if requested.
+ */
+ public static function sanitize(?string $input, array $allowedSchemes = null, bool $forceHttps = false, array $allowedHosts = null, bool $allowRelative = false): ?string
+ {
+ if (!$input) {
+ return null;
+ }
+
+ $url = self::parse($input);
+
+ // Malformed URL
+ if (!$url || !\is_array($url)) {
+ return null;
+ }
+
+ // No scheme and relative not allowed
+ if (!$allowRelative && !$url['scheme']) {
+ return null;
+ }
+
+ // Forbidden scheme
+ if ($url['scheme'] && null !== $allowedSchemes && !\in_array($url['scheme'], $allowedSchemes, true)) {
+ return null;
+ }
+
+ // If the scheme used is not supposed to have a host, do not check the host
+ if (!self::isHostlessScheme($url['scheme'])) {
+ // No host and relative not allowed
+ if (!$allowRelative && !$url['host']) {
+ return null;
+ }
+
+ // Forbidden host
+ if ($url['host'] && null !== $allowedHosts && !self::isAllowedHost($url['host'], $allowedHosts)) {
+ return null;
+ }
+ }
+
+ // Force HTTPS
+ if ($forceHttps && 'http' === $url['scheme']) {
+ $url['scheme'] = 'https';
+ }
+
+ return UriString::build($url);
+ }
+
+ /**
+ * Parses a given URL and returns an array of its components.
+ *
+ * @return null|array{
+ * scheme:?string,
+ * user:?string,
+ * pass:?string,
+ * host:?string,
+ * port:?int,
+ * path:string,
+ * query:?string,
+ * fragment:?string
+ * }
+ */
+ public static function parse(string $url): ?array
+ {
+ if (!$url) {
+ return null;
+ }
+
+ try {
+ return UriString::parse($url);
+ } catch (SyntaxError) {
+ return null;
+ }
+ }
+
+ private static function isHostlessScheme(?string $scheme): bool
+ {
+ return \in_array($scheme, ['blob', 'chrome', 'data', 'file', 'geo', 'mailto', 'maps', 'tel', 'view-source'], true);
+ }
+
+ private static function isAllowedHost(?string $host, array $allowedHosts): bool
+ {
+ if (null === $host) {
+ return \in_array(null, $allowedHosts, true);
+ }
+
+ $parts = array_reverse(explode('.', $host));
+
+ foreach ($allowedHosts as $allowedHost) {
+ if (self::matchAllowedHostParts($parts, array_reverse(explode('.', $allowedHost)))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function matchAllowedHostParts(array $uriParts, array $trustedParts): bool
+ {
+ // Check each chunk of the domain is valid
+ foreach ($trustedParts as $key => $trustedPart) {
+ if ($uriParts[$key] !== $trustedPart) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/AttributeSanitizerInterface.php b/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/AttributeSanitizerInterface.php
new file mode 100644
index 0000000000000..c4daa1d17fbe3
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/AttributeSanitizerInterface.php
@@ -0,0 +1,43 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer;
+
+use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
+
+/**
+ * Implements attribute-specific sanitization logic.
+ *
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+interface AttributeSanitizerInterface
+{
+ /**
+ * Returns the list of element names supported, or null to support all elements.
+ *
+ * @return list|null
+ */
+ public function getSupportedElements(): ?array;
+
+ /**
+ * Returns the list of attributes names supported, or null to support all attributes.
+ *
+ * @return list|null
+ */
+ public function getSupportedAttributes(): ?array;
+
+ /**
+ * Returns the sanitized value of a given attribute for the given element.
+ */
+ public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string;
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php b/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php
new file mode 100644
index 0000000000000..2d5c5f0b975db
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/AttributeSanitizer/UrlAttributeSanitizer.php
@@ -0,0 +1,53 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer;
+
+use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
+use Symfony\Component\HtmlSanitizer\TextSanitizer\UrlSanitizer;
+
+/**
+ * @experimental
+ */
+final class UrlAttributeSanitizer implements AttributeSanitizerInterface
+{
+ public function getSupportedElements(): ?array
+ {
+ // Check all elements for URL attributes
+ return null;
+ }
+
+ public function getSupportedAttributes(): ?array
+ {
+ return ['src', 'href', 'lowsrc', 'background', 'ping'];
+ }
+
+ public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string
+ {
+ if ('a' === $element) {
+ return UrlSanitizer::sanitize(
+ $value,
+ $config->getAllowedLinkSchemes(),
+ $config->getForceHttpsUrls(),
+ $config->getAllowedLinkHosts(),
+ $config->getAllowRelativeLinks(),
+ );
+ }
+
+ return UrlSanitizer::sanitize(
+ $value,
+ $config->getAllowedMediaSchemes(),
+ $config->getForceHttpsUrls(),
+ $config->getAllowedMediaHosts(),
+ $config->getAllowRelativeMedias(),
+ );
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php b/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.php
new file mode 100644
index 0000000000000..4c2eba0c16198
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/DomVisitor.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\Component\HtmlSanitizer\Visitor;
+
+use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
+use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
+use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
+use Symfony\Component\HtmlSanitizer\Visitor\Model\Cursor;
+use Symfony\Component\HtmlSanitizer\Visitor\Node\BlockedNode;
+use Symfony\Component\HtmlSanitizer\Visitor\Node\DocumentNode;
+use Symfony\Component\HtmlSanitizer\Visitor\Node\Node;
+use Symfony\Component\HtmlSanitizer\Visitor\Node\NodeInterface;
+use Symfony\Component\HtmlSanitizer\Visitor\Node\TextNode;
+
+/**
+ * Iterates over the parsed DOM tree to build the sanitized tree.
+ *
+ * The DomVisitor iterates over the parsed DOM tree, visits its nodes and build
+ * a sanitized tree with their attributes and content.
+ *
+ * @author Titouan Galopin
+ *
+ * @internal
+ */
+final class DomVisitor
+{
+ private HtmlSanitizerConfig $config;
+
+ /**
+ * Registry of allowed/blocked elements:
+ * * If an element is present as a key and contains an array, the element should be allowed
+ * and the array is the list of allowed attributes.
+ * * If an element is present as a key and contains "false", the element should be blocked.
+ * * If an element is not present as a key, the element should be dropped.
+ *
+ * @var array>
+ */
+ private array $elementsConfig;
+
+ /**
+ * Registry of attributes to forcefully set on nodes, index by element and attribute.
+ *
+ * @var array>
+ */
+ private array $forcedAttributes;
+
+ /**
+ * Registry of attributes sanitizers indexed by element name and attribute name for
+ * faster sanitization.
+ *
+ * @var array>>
+ */
+ private array $attributeSanitizers = [];
+
+ /**
+ * @param array> $elementsConfig
+ */
+ public function __construct(HtmlSanitizerConfig $config, array $elementsConfig)
+ {
+ $this->config = $config;
+ $this->elementsConfig = $elementsConfig;
+ $this->forcedAttributes = $config->getForcedAttributes();
+
+ foreach ($config->getAttributeSanitizers() as $attributeSanitizer) {
+ foreach ($attributeSanitizer->getSupportedElements() ?? ['*'] as $element) {
+ foreach ($attributeSanitizer->getSupportedAttributes() ?? ['*'] as $attribute) {
+ $this->attributeSanitizers[$element][$attribute][] = $attributeSanitizer;
+ }
+ }
+ }
+ }
+
+ public function visit(\DOMDocumentFragment $domNode): ?NodeInterface
+ {
+ $cursor = new Cursor(new DocumentNode());
+ $this->visitChildren($domNode, $cursor);
+
+ return $cursor->node;
+ }
+
+ private function visitNode(\DOMNode $domNode, Cursor $cursor): void
+ {
+ $nodeName = StringSanitizer::htmlLower($domNode->nodeName);
+
+ // Element should be dropped, including its children
+ if (!\array_key_exists($nodeName, $this->elementsConfig)) {
+ return;
+ }
+
+ // Otherwise, visit recursively
+ $this->enterNode($nodeName, $domNode, $cursor);
+ $this->visitChildren($domNode, $cursor);
+ $cursor->node = $cursor->node->getParent();
+ }
+
+ private function enterNode(string $domNodeName, \DOMNode $domNode, Cursor $cursor): void
+ {
+ // Element should be blocked, retaining its children
+ if (false === $this->elementsConfig[$domNodeName]) {
+ $node = new BlockedNode($cursor->node);
+
+ $cursor->node->addChild($node);
+ $cursor->node = $node;
+
+ return;
+ }
+
+ // Otherwise create the node
+ $node = new Node($cursor->node, $domNodeName);
+ $this->setAttributes($domNodeName, $domNode, $node, $this->elementsConfig[$domNodeName]);
+
+ // Force configured attributes
+ foreach ($this->forcedAttributes[$domNodeName] ?? [] as $attribute => $value) {
+ $node->setAttribute($attribute, $value);
+ }
+
+ $cursor->node->addChild($node);
+ $cursor->node = $node;
+ }
+
+ private function visitChildren(\DOMNode $domNode, Cursor $cursor): void
+ {
+ /** @var \DOMNode $child */
+ foreach ($domNode->childNodes ?? [] as $child) {
+ if ('#text' === $child->nodeName) {
+ // Add text directly for performance
+ $cursor->node->addChild(new TextNode($cursor->node, $child->nodeValue));
+ } elseif (!$child instanceof \DOMText) {
+ // Otherwise continue the visit recursively
+ // Ignore comments for security reasons (interpreted differently by browsers)
+ $this->visitNode($child, $cursor);
+ }
+ }
+ }
+
+ /**
+ * Set attributes from a DOM node to a sanitized node.
+ */
+ private function setAttributes(string $domNodeName, \DOMNode $domNode, Node $node, array $allowedAttributes = []): void
+ {
+ /** @var iterable<\DOMAttr> $domAttributes */
+ if (!$domAttributes = $domNode->attributes ? $domNode->attributes->getIterator() : []) {
+ return;
+ }
+
+ foreach ($domAttributes as $attribute) {
+ $name = StringSanitizer::htmlLower($attribute->name);
+
+ if (isset($allowedAttributes[$name])) {
+ $value = $attribute->value;
+
+ // Sanitize the attribute value if there are attribute sanitizers for it
+ $attributeSanitizers = array_merge(
+ $this->attributeSanitizers[$domNodeName][$name] ?? [],
+ $this->attributeSanitizers['*'][$name] ?? [],
+ $this->attributeSanitizers[$domNodeName]['*'] ?? [],
+ );
+
+ foreach ($attributeSanitizers as $sanitizer) {
+ $value = $sanitizer->sanitizeAttribute($domNodeName, $name, $value, $this->config);
+ }
+
+ $node->setAttribute($name, $value);
+ }
+ }
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Model/Cursor.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Model/Cursor.php
new file mode 100644
index 0000000000000..5214c09b77d20
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Model/Cursor.php
@@ -0,0 +1,26 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Model;
+
+use Symfony\Component\HtmlSanitizer\Visitor\Node\NodeInterface;
+
+/**
+ * @author Titouan Galopin
+ *
+ * @internal
+ */
+final class Cursor
+{
+ public function __construct(public ?NodeInterface $node)
+ {
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Node/BlockedNode.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/BlockedNode.php
new file mode 100644
index 0000000000000..d438313d4ec76
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/BlockedNode.php
@@ -0,0 +1,48 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
+
+/**
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+final class BlockedNode implements NodeInterface
+{
+ private NodeInterface $parentNode;
+ private array $children = [];
+
+ public function __construct(NodeInterface $parentNode)
+ {
+ $this->parentNode = $parentNode;
+ }
+
+ public function addChild(NodeInterface $node): void
+ {
+ $this->children[] = $node;
+ }
+
+ public function getParent(): ?NodeInterface
+ {
+ return $this->parentNode;
+ }
+
+ public function render(): string
+ {
+ $rendered = '';
+ foreach ($this->children as $child) {
+ $rendered .= $child->render();
+ }
+
+ return $rendered;
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Node/DocumentNode.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/DocumentNode.php
new file mode 100644
index 0000000000000..d5ef5363015e7
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/DocumentNode.php
@@ -0,0 +1,42 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
+
+/**
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+final class DocumentNode implements NodeInterface
+{
+ private array $children = [];
+
+ public function addChild(NodeInterface $node): void
+ {
+ $this->children[] = $node;
+ }
+
+ public function getParent(): ?NodeInterface
+ {
+ return null;
+ }
+
+ public function render(): string
+ {
+ $rendered = '';
+ foreach ($this->children as $child) {
+ $rendered .= $child->render();
+ }
+
+ return $rendered;
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Node/Node.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/Node.php
new file mode 100644
index 0000000000000..76838028dbc0d
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/Node.php
@@ -0,0 +1,106 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
+
+use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
+
+/**
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+final class Node implements NodeInterface
+{
+ private NodeInterface $parent;
+ private string $tagName;
+ private array $attributes = [];
+ private array $children = [];
+
+ public function __construct(NodeInterface $parent, string $tagName)
+ {
+ $this->parent = $parent;
+ $this->tagName = $tagName;
+ }
+
+ public function getParent(): ?NodeInterface
+ {
+ return $this->parent;
+ }
+
+ public function getAttribute(string $name): ?string
+ {
+ return $this->attributes[$name] ?? null;
+ }
+
+ public function setAttribute(string $name, ?string $value): void
+ {
+ // Always use only the first declaration (ease sanitization)
+ if (!\array_key_exists($name, $this->attributes)) {
+ $this->attributes[$name] = $value;
+ }
+ }
+
+ public function addChild(NodeInterface $node): void
+ {
+ $this->children[] = $node;
+ }
+
+ public function render(): string
+ {
+ if (!$this->children) {
+ return '<'.$this->tagName.$this->renderAttributes().' />';
+ }
+
+ $rendered = '<'.$this->tagName.$this->renderAttributes().'>';
+ foreach ($this->children as $child) {
+ $rendered .= $child->render();
+ }
+
+ return $rendered.''.$this->tagName.'>';
+ }
+
+ private function renderAttributes(): string
+ {
+ $rendered = [];
+ foreach ($this->attributes as $name => $value) {
+ if (null === $value) {
+ // Tag should be removed as a sanitizer found suspect data inside
+ continue;
+ }
+
+ $attr = StringSanitizer::encodeHtmlEntities($name);
+
+ if ('' !== $value) {
+ // In quirks mode, IE8 does a poor job producing innerHTML values.
+ // If JavaScript does:
+ // nodeA.innerHTML = nodeB.innerHTML;
+ // and nodeB contains (or even if ` was encoded properly):
+ //
+ // then IE8 will produce:
+ //
+ // as the value of nodeB.innerHTML and assign it to nodeA.
+ // IE8's HTML parser treats `` as a blank attribute value and foo=bar becomes a separate attribute.
+ // Adding a space at the end of the attribute prevents this by forcing IE8 to put double
+ // quotes around the attribute when computing nodeB.innerHTML.
+ if (str_contains($value, '`')) {
+ $value .= ' ';
+ }
+
+ $attr .= '="'.StringSanitizer::encodeHtmlEntities($value).'"';
+ }
+
+ $rendered[] = $attr;
+ }
+
+ return $rendered ? ' '.implode(' ', $rendered) : '';
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Node/NodeInterface.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/NodeInterface.php
new file mode 100644
index 0000000000000..27d9da7ed97ac
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/NodeInterface.php
@@ -0,0 +1,39 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
+
+/**
+ * Represents the sanitized version of a DOM node in the sanitized tree.
+ *
+ * Once the sanitization is done, nodes are rendered into the final output string.
+ *
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+interface NodeInterface
+{
+ /**
+ * Add a child node to this node.
+ */
+ public function addChild(self $node): void;
+
+ /**
+ * Return the parent node of this node, or null if it has no parent node.
+ */
+ public function getParent(): ?self;
+
+ /**
+ * Render this node as a string, recursively rendering its children as well.
+ */
+ public function render(): string;
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/Visitor/Node/TextNode.php b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/TextNode.php
new file mode 100644
index 0000000000000..f06b7ccdf47d1
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/Visitor/Node/TextNode.php
@@ -0,0 +1,41 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HtmlSanitizer\Visitor\Node;
+
+use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer;
+
+/**
+ * @author Titouan Galopin
+ *
+ * @experimental
+ */
+final class TextNode implements NodeInterface
+{
+ public function __construct(private NodeInterface $parentNode, private string $text)
+ {
+ }
+
+ public function addChild(NodeInterface $node): void
+ {
+ throw new \LogicException('Text nodes cannot have children.');
+ }
+
+ public function getParent(): ?NodeInterface
+ {
+ return $this->parentNode;
+ }
+
+ public function render(): string
+ {
+ return StringSanitizer::encodeHtmlEntities($this->text);
+ }
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/composer.json b/src/Symfony/Component/HtmlSanitizer/composer.json
new file mode 100644
index 0000000000000..052b480fd1ced
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/composer.json
@@ -0,0 +1,31 @@
+{
+ "name": "symfony/html-sanitizer",
+ "type": "library",
+ "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
+ "keywords": ["html", "sanitizer", "purifier"],
+ "homepage": "https://symfony.com",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Titouan Galopin",
+ "email": "galopintitouan@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "require": {
+ "php": ">=8.0.2",
+ "ext-dom": "*",
+ "league/uri": "^6.5",
+ "masterminds/html5": "^2.4"
+ },
+ "autoload": {
+ "psr-4": { "Symfony\\Component\\HtmlSanitizer\\": "" },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "minimum-stability": "dev"
+}
diff --git a/src/Symfony/Component/HtmlSanitizer/phpunit.xml.dist b/src/Symfony/Component/HtmlSanitizer/phpunit.xml.dist
new file mode 100644
index 0000000000000..bb03155b35ae2
--- /dev/null
+++ b/src/Symfony/Component/HtmlSanitizer/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+ ./Tests/
+
+
+
+
+
+ ./
+
+
+ ./Tests
+ ./vendor
+
+
+
diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php
index 08f064d2d489a..83f420ae28648 100644
--- a/src/Symfony/Component/HttpClient/CurlHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php
@@ -432,8 +432,6 @@ private function validateExtraCurlOptions(array $options): void
\CURLOPT_INFILESIZE => 'body',
\CURLOPT_POSTFIELDS => 'body',
\CURLOPT_UPLOAD => 'body',
- \CURLOPT_PINNEDPUBLICKEY => 'peer_fingerprint',
- \CURLOPT_UNIX_SOCKET_PATH => 'bindto',
\CURLOPT_INTERFACE => 'bindto',
\CURLOPT_TIMEOUT_MS => 'max_duration',
\CURLOPT_TIMEOUT => 'max_duration',
@@ -456,6 +454,14 @@ private function validateExtraCurlOptions(array $options): void
\CURLOPT_PROGRESSFUNCTION => 'on_progress',
];
+ if (\defined('CURLOPT_UNIX_SOCKET_PATH')) {
+ $curloptsToConfig[\CURLOPT_UNIX_SOCKET_PATH] = 'bindto';
+ }
+
+ if (\defined('CURLOPT_PINNEDPUBLICKEY')) {
+ $curloptsToConfig[\CURLOPT_PINNEDPUBLICKEY] = 'peer_fingerprint';
+ }
+
$curloptsToCheck = [
\CURLOPT_PRIVATE,
\CURLOPT_HEADERFUNCTION,
diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php
index 08724df555919..325b17ad430bc 100644
--- a/src/Symfony/Component/HttpClient/HttpClientTrait.php
+++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php
@@ -159,7 +159,10 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
// Finalize normalization of options
$options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
- $options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout'));
+ if (0 > $options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout'))) {
+ $options['timeout'] = 172800.0; // 2 days
+ }
+
$options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
return [$url, $options];
diff --git a/src/Symfony/Component/HttpClient/LICENSE b/src/Symfony/Component/HttpClient/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/HttpClient/LICENSE
+++ b/src/Symfony/Component/HttpClient/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php
index 59e4dc1da7cc8..793053029164c 100644
--- a/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php
+++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php
@@ -373,4 +373,13 @@ public function testDebugInfoOnDestruct()
$this->assertNotEmpty($traceInfo['debug']);
}
+
+ public function testNegativeTimeout()
+ {
+ $client = $this->getHttpClient(__FUNCTION__);
+
+ $this->assertSame(200, $client->request('GET', 'http://localhost:8057', [
+ 'timeout' => -1,
+ ])->getStatusCode());
+ }
}
diff --git a/src/Symfony/Component/HttpFoundation/HeaderBag.php b/src/Symfony/Component/HttpFoundation/HeaderBag.php
index 74bc1f46b9c2d..0883024b3b50b 100644
--- a/src/Symfony/Component/HttpFoundation/HeaderBag.php
+++ b/src/Symfony/Component/HttpFoundation/HeaderBag.php
@@ -238,6 +238,8 @@ public function removeCacheControlDirective(string $key)
/**
* Returns an iterator for headers.
+ *
+ * @return \ArrayIterator>
*/
public function getIterator(): \ArrayIterator
{
diff --git a/src/Symfony/Component/HttpFoundation/LICENSE b/src/Symfony/Component/HttpFoundation/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/HttpFoundation/LICENSE
+++ b/src/Symfony/Component/HttpFoundation/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/HttpFoundation/ParameterBag.php b/src/Symfony/Component/HttpFoundation/ParameterBag.php
index 4e67088f6938c..6044dac9fad7f 100644
--- a/src/Symfony/Component/HttpFoundation/ParameterBag.php
+++ b/src/Symfony/Component/HttpFoundation/ParameterBag.php
@@ -171,6 +171,8 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER
/**
* Returns an iterator for parameters.
+ *
+ * @return \ArrayIterator
*/
public function getIterator(): \ArrayIterator
{
diff --git a/src/Symfony/Component/HttpFoundation/ServerBag.php b/src/Symfony/Component/HttpFoundation/ServerBag.php
index 6f40bbc7cf334..5b0fc8ac46254 100644
--- a/src/Symfony/Component/HttpFoundation/ServerBag.php
+++ b/src/Symfony/Component/HttpFoundation/ServerBag.php
@@ -87,7 +87,7 @@ public function getHeaders(): array
// PHP_AUTH_USER/PHP_AUTH_PW
if (isset($headers['PHP_AUTH_USER'])) {
- $headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.$headers['PHP_AUTH_PW']);
+ $headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? ''));
} elseif (isset($headers['PHP_AUTH_DIGEST'])) {
$headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST'];
}
diff --git a/src/Symfony/Component/HttpFoundation/Session/Attribute/AttributeBag.php b/src/Symfony/Component/HttpFoundation/Session/Attribute/AttributeBag.php
index 283777083edc3..11b884a717d6f 100644
--- a/src/Symfony/Component/HttpFoundation/Session/Attribute/AttributeBag.php
+++ b/src/Symfony/Component/HttpFoundation/Session/Attribute/AttributeBag.php
@@ -130,6 +130,8 @@ public function clear(): mixed
/**
* Returns an iterator for attributes.
+ *
+ * @return \ArrayIterator
*/
public function getIterator(): \ArrayIterator
{
diff --git a/src/Symfony/Component/HttpFoundation/Session/Session.php b/src/Symfony/Component/HttpFoundation/Session/Session.php
index 35070051bb195..6c1d3a23baa21 100644
--- a/src/Symfony/Component/HttpFoundation/Session/Session.php
+++ b/src/Symfony/Component/HttpFoundation/Session/Session.php
@@ -128,6 +128,8 @@ public function isStarted(): bool
/**
* Returns an iterator for attributes.
+ *
+ * @return \ArrayIterator
*/
public function getIterator(): \ArrayIterator
{
diff --git a/src/Symfony/Component/HttpFoundation/Tests/ServerBagTest.php b/src/Symfony/Component/HttpFoundation/Tests/ServerBagTest.php
index 0663b118e675e..e26714bc4640a 100644
--- a/src/Symfony/Component/HttpFoundation/Tests/ServerBagTest.php
+++ b/src/Symfony/Component/HttpFoundation/Tests/ServerBagTest.php
@@ -57,6 +57,16 @@ public function testHttpPasswordIsOptional()
], $bag->getHeaders());
}
+ public function testHttpPasswordIsOptionalWhenPassedWithHttpPrefix()
+ {
+ $bag = new ServerBag(['HTTP_PHP_AUTH_USER' => 'foo']);
+
+ $this->assertEquals([
+ 'AUTHORIZATION' => 'Basic '.base64_encode('foo:'),
+ 'PHP_AUTH_USER' => 'foo',
+ ], $bag->getHeaders());
+ }
+
public function testHttpBasicAuthWithPhpCgi()
{
$bag = new ServerBag(['HTTP_AUTHORIZATION' => 'Basic '.base64_encode('foo:bar')]);
diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md
index 945557d762f5c..a60b754c6d1ed 100644
--- a/src/Symfony/Component/HttpKernel/CHANGELOG.md
+++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========
+6.1
+---
+
+ * Add `BackedEnumValueResolver` to resolve backed enum cases from request attributes in controller arguments
+
6.0
---
diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php
new file mode 100644
index 0000000000000..054354963b313
--- /dev/null
+++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/BackedEnumValueResolver.php
@@ -0,0 +1,67 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
+use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+/**
+ * Attempt to resolve backed enum cases from request attributes, for a route path parameter,
+ * leading to a 404 Not Found if the attribute value isn't a valid backing value for the enum type.
+ *
+ * @author Maxime Steinhausser
+ */
+class BackedEnumValueResolver implements ArgumentValueResolverInterface
+{
+ public function supports(Request $request, ArgumentMetadata $argument): bool
+ {
+ if (!is_subclass_of($argument->getType(), \BackedEnum::class)) {
+ return false;
+ }
+
+ if ($argument->isVariadic()) {
+ // only target route path parameters, which cannot be variadic.
+ return false;
+ }
+
+ // do not support if no value can be resolved at all
+ // letting the \Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver be used
+ // or \Symfony\Component\HttpKernel\Controller\ArgumentResolver fail with a meaningful error.
+ return $request->attributes->has($argument->getName());
+ }
+
+ public function resolve(Request $request, ArgumentMetadata $argument): iterable
+ {
+ $value = $request->attributes->get($argument->getName());
+
+ if (null === $value) {
+ yield null;
+
+ return;
+ }
+
+ if (!\is_int($value) && !\is_string($value)) {
+ throw new \LogicException(sprintf('Could not resolve the "%s $%s" controller argument: expecting an int or string, got %s.', $argument->getType(), $argument->getName(), get_debug_type($value)));
+ }
+
+ /** @var class-string<\BackedEnum> $enumType */
+ $enumType = $argument->getType();
+
+ try {
+ yield $enumType::from($value);
+ } catch (\ValueError $error) {
+ throw new NotFoundHttpException(sprintf('Could not resolve the "%s $%s" controller argument: %s', $argument->getType(), $argument->getName(), $error->getMessage()), $error);
+ }
+ }
+}
diff --git a/src/Symfony/Component/HttpKernel/DataCollector/MemoryDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/MemoryDataCollector.php
index 2d288d5002ddc..392fb82884555 100644
--- a/src/Symfony/Component/HttpKernel/DataCollector/MemoryDataCollector.php
+++ b/src/Symfony/Component/HttpKernel/DataCollector/MemoryDataCollector.php
@@ -58,7 +58,7 @@ public function getMemory(): int
return $this->data['memory'];
}
- public function getMemoryLimit(): int
+ public function getMemoryLimit(): int|float
{
return $this->data['memory_limit'];
}
diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php
index 39fa8fc939309..d11e6e658a1b1 100644
--- a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php
+++ b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php
@@ -147,6 +147,9 @@ public function process(ContainerBuilder $container)
$args[$p->name] = $bindingValue;
}
+ continue;
+ } elseif (is_subclass_of($type, \UnitEnum::class)) {
+ // do not attempt to register enum typed arguments if not already present in bindings
continue;
} elseif (!$type || !$autowire || '\\' !== $target[0]) {
continue;
diff --git a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php
index 2312cf868d4a7..c2f3a2a1b6631 100644
--- a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php
+++ b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php
@@ -88,7 +88,7 @@ public function onKernelRequest(RequestEvent $event)
public function onKernelResponse(ResponseEvent $event)
{
- if (!$event->isMainRequest()) {
+ if (!$event->isMainRequest() || (!$this->container->has('initialized_session') && !$event->getRequest()->hasSession())) {
return;
}
@@ -135,11 +135,12 @@ public function onKernelResponse(ResponseEvent $event)
*/
$sessionName = $session->getName();
$sessionId = $session->getId();
- $sessionCookiePath = $this->sessionOptions['cookie_path'] ?? '/';
- $sessionCookieDomain = $this->sessionOptions['cookie_domain'] ?? null;
- $sessionCookieSecure = $this->sessionOptions['cookie_secure'] ?? false;
- $sessionCookieHttpOnly = $this->sessionOptions['cookie_httponly'] ?? true;
- $sessionCookieSameSite = $this->sessionOptions['cookie_samesite'] ?? Cookie::SAMESITE_LAX;
+ $sessionOptions = $this->getSessionOptions($this->sessionOptions);
+ $sessionCookiePath = $sessionOptions['cookie_path'] ?? '/';
+ $sessionCookieDomain = $sessionOptions['cookie_domain'] ?? null;
+ $sessionCookieSecure = $sessionOptions['cookie_secure'] ?? false;
+ $sessionCookieHttpOnly = $sessionOptions['cookie_httponly'] ?? true;
+ $sessionCookieSameSite = $sessionOptions['cookie_samesite'] ?? Cookie::SAMESITE_LAX;
SessionUtils::popSessionCookie($sessionName, $sessionId);
@@ -157,7 +158,7 @@ public function onKernelResponse(ResponseEvent $event)
);
} elseif ($sessionId !== $requestSessionCookieId) {
$expire = 0;
- $lifetime = $this->sessionOptions['cookie_lifetime'] ?? null;
+ $lifetime = $sessionOptions['cookie_lifetime'] ?? null;
if ($lifetime) {
$expire = time() + $lifetime;
}
@@ -265,4 +266,23 @@ public function reset(): void
* Gets the session object.
*/
abstract protected function getSession(): ?SessionInterface;
+
+ private function getSessionOptions(array $sessionOptions): array
+ {
+ $mergedSessionOptions = [];
+
+ foreach (session_get_cookie_params() as $key => $value) {
+ $mergedSessionOptions['cookie_'.$key] = $value;
+ }
+
+ foreach ($sessionOptions as $key => $value) {
+ // do the same logic as in the NativeSessionStorage
+ if ('cookie_secure' === $key && 'auto' === $value) {
+ continue;
+ }
+ $mergedSessionOptions[$key] = $value;
+ }
+
+ return $mergedSessionOptions;
+ }
}
diff --git a/src/Symfony/Component/HttpKernel/HttpKernelBrowser.php b/src/Symfony/Component/HttpKernel/HttpKernelBrowser.php
index 3ecc2c739c34a..1557da575a9c0 100644
--- a/src/Symfony/Component/HttpKernel/HttpKernelBrowser.php
+++ b/src/Symfony/Component/HttpKernel/HttpKernelBrowser.php
@@ -188,7 +188,7 @@ protected function filterFiles(array $files): array
/**
* {@inheritdoc}
*
- * @param Request $request
+ * @param Response $response
*/
protected function filterResponse(object $response): DomResponse
{
diff --git a/src/Symfony/Component/HttpKernel/LICENSE b/src/Symfony/Component/HttpKernel/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/HttpKernel/LICENSE
+++ b/src/Symfony/Component/HttpKernel/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php
new file mode 100644
index 0000000000000..900d4b2db24dd
--- /dev/null
+++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/BackedEnumValueResolverTest.php
@@ -0,0 +1,140 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
+use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\Tests\Fixtures\Suit;
+
+/**
+ * @requires PHP 8.1
+ */
+class BackedEnumValueResolverTest extends TestCase
+{
+ /**
+ * @dataProvider provideTestSupportsData
+ */
+ public function testSupports(Request $request, ArgumentMetadata $metadata, bool $expectedSupport)
+ {
+ $resolver = new BackedEnumValueResolver();
+
+ self::assertSame($expectedSupport, $resolver->supports($request, $metadata));
+ }
+
+ public function provideTestSupportsData(): iterable
+ {
+ yield 'unsupported type' => [
+ self::createRequest(['suit' => 'H']),
+ self::createArgumentMetadata('suit', \stdClass::class),
+ false,
+ ];
+
+ yield 'supports from attributes' => [
+ self::createRequest(['suit' => 'H']),
+ self::createArgumentMetadata('suit', Suit::class),
+ true,
+ ];
+
+ yield 'with null attribute value' => [
+ self::createRequest(['suit' => null]),
+ self::createArgumentMetadata('suit', Suit::class),
+ true,
+ ];
+
+ yield 'without matching attribute' => [
+ self::createRequest(),
+ self::createArgumentMetadata('suit', Suit::class),
+ false,
+ ];
+
+ yield 'unsupported variadic' => [
+ self::createRequest(['suit' => ['H', 'S']]),
+ self::createArgumentMetadata(
+ 'suit',
+ Suit::class,
+ variadic: true,
+ ),
+ false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestResolveData
+ */
+ public function testResolve(Request $request, ArgumentMetadata $metadata, $expected)
+ {
+ $resolver = new BackedEnumValueResolver();
+ /** @var \Generator $results */
+ $results = $resolver->resolve($request, $metadata);
+
+ self::assertSame($expected, iterator_to_array($results));
+ }
+
+ public function provideTestResolveData(): iterable
+ {
+ yield 'resolves from attributes' => [
+ self::createRequest(['suit' => 'H']),
+ self::createArgumentMetadata('suit', Suit::class),
+ [Suit::Hearts],
+ ];
+
+ yield 'with null attribute value' => [
+ self::createRequest(['suit' => null]),
+ self::createArgumentMetadata(
+ 'suit',
+ Suit::class,
+ ),
+ [null],
+ ];
+ }
+
+ public function testResolveThrowsNotFoundOnInvalidValue()
+ {
+ $resolver = new BackedEnumValueResolver();
+ $request = self::createRequest(['suit' => 'foo']);
+ $metadata = self::createArgumentMetadata('suit', Suit::class);
+
+ $this->expectException(NotFoundHttpException::class);
+ $this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: "foo" is not a valid backing value for enum');
+
+ /** @var \Generator $results */
+ $results = $resolver->resolve($request, $metadata);
+ iterator_to_array($results);
+ }
+
+ public function testResolveThrowsOnUnexpectedType()
+ {
+ $resolver = new BackedEnumValueResolver();
+ $request = self::createRequest(['suit' => false]);
+ $metadata = self::createArgumentMetadata('suit', Suit::class);
+
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: expecting an int or string, got bool.');
+
+ /** @var \Generator $results */
+ $results = $resolver->resolve($request, $metadata);
+ iterator_to_array($results);
+ }
+
+ private static function createRequest(array $attributes = []): Request
+ {
+ return new Request([], [], $attributes);
+ }
+
+ private static function createArgumentMetadata(string $name, string $type, bool $variadic = false): ArgumentMetadata
+ {
+ return new ArgumentMetadata($name, $type, $variadic, false, null);
+ }
+}
diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php
index 003c75456899f..5694f4f0f442c 100644
--- a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php
+++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php
@@ -24,6 +24,7 @@
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass;
+use Symfony\Component\HttpKernel\Tests\Fixtures\Suit;
class RegisterControllerArgumentLocatorsPassTest extends TestCase
{
@@ -400,6 +401,25 @@ public function testAlias()
$this->assertEqualsCanonicalizing([RegisterTestController::class.'::fooAction', 'foo::fooAction'], array_keys($locator));
}
+ /**
+ * @requires PHP 8.1
+ */
+ public function testEnumArgumentIsIgnored()
+ {
+ $container = new ContainerBuilder();
+ $resolver = $container->register('argument_resolver.service')->addArgument([]);
+
+ $container->register('foo', NonNullableEnumArgumentWithDefaultController::class)
+ ->addTag('controller.service_arguments')
+ ;
+
+ $pass = new RegisterControllerArgumentLocatorsPass();
+ $pass->process($container);
+
+ $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
+ $this->assertEmpty(array_keys($locator), 'enum typed argument is ignored');
+ }
+
public function testBindWithTarget()
{
$container = new ContainerBuilder();
@@ -479,6 +499,13 @@ public function fooAction(string $someArg)
}
}
+class NonNullableEnumArgumentWithDefaultController
+{
+ public function fooAction(Suit $suit = Suit::Spades)
+ {
+ }
+}
+
class WithTarget
{
public function fooAction(
diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php
index de434aa75a61a..2da7169002ee5 100644
--- a/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php
+++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php
@@ -14,6 +14,8 @@
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Container;
+use Symfony\Component\DependencyInjection\ServiceLocator;
+use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
@@ -31,6 +33,97 @@
class SessionListenerTest extends TestCase
{
+ /**
+ * @dataProvider provideSessionOptions
+ * @runInSeparateProcess
+ */
+ public function testSessionCookieOptions(array $phpSessionOptions, array $sessionOptions, array $expectedSessionOptions)
+ {
+ $session = $this->createMock(Session::class);
+ $session->method('getUsageIndex')->will($this->onConsecutiveCalls(0, 1));
+ $session->method('getId')->willReturn('123456');
+ $session->method('getName')->willReturn('PHPSESSID');
+ $session->method('save');
+ $session->method('isStarted')->willReturn(true);
+
+ if (isset($phpSessionOptions['samesite'])) {
+ ini_set('session.cookie_samesite', $phpSessionOptions['samesite']);
+ }
+ session_set_cookie_params(0, $phpSessionOptions['path'] ?? null, $phpSessionOptions['domain'] ?? null, $phpSessionOptions['secure'] ?? null, $phpSessionOptions['httponly'] ?? null);
+
+ $listener = new SessionListener(new Container(), false, $sessionOptions);
+ $kernel = $this->createMock(HttpKernelInterface::class);
+
+ $request = new Request();
+ $listener->onKernelRequest(new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST));
+
+ $request->setSession($session);
+ $response = new Response();
+ $listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response));
+
+ $cookies = $response->headers->getCookies();
+ $this->assertSame('PHPSESSID', $cookies[0]->getName());
+ $this->assertSame('123456', $cookies[0]->getValue());
+ $this->assertSame($expectedSessionOptions['cookie_path'], $cookies[0]->getPath());
+ $this->assertSame($expectedSessionOptions['cookie_domain'], $cookies[0]->getDomain());
+ $this->assertSame($expectedSessionOptions['cookie_secure'], $cookies[0]->isSecure());
+ $this->assertSame($expectedSessionOptions['cookie_httponly'], $cookies[0]->isHttpOnly());
+ $this->assertSame($expectedSessionOptions['cookie_samesite'], $cookies[0]->getSameSite());
+ }
+
+ public function provideSessionOptions(): \Generator
+ {
+ if (\PHP_VERSION_ID > 70300) {
+ yield 'set_samesite_by_php' => [
+ 'phpSessionOptions' => ['samesite' => Cookie::SAMESITE_STRICT],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_STRICT],
+ ];
+ }
+
+ yield 'set_cookie_path_by_php' => [
+ 'phpSessionOptions' => ['path' => '/prod/'],
+ 'sessionOptions' => ['cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/prod/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_cookie_secure_by_php' => [
+ 'phpSessionOptions' => ['secure' => true],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_cookiesecure_auto_by_symfony_false_by_php' => [
+ 'phpSessionOptions' => ['secure' => false],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_httponly' => 'auto', 'cookie_secure' => 'auto', 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => false, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_cookiesecure_auto_by_symfony_true_by_php' => [
+ 'phpSessionOptions' => ['secure' => true],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_httponly' => 'auto', 'cookie_secure' => 'auto', 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_cookie_httponly_by_php' => [
+ 'phpSessionOptions' => ['httponly' => true],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_cookie_domain_by_php' => [
+ 'phpSessionOptions' => ['domain' => 'test.symfony'],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => 'test.symfony', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+
+ yield 'set_samesite_by_symfony' => [
+ 'phpSessionOptions' => ['samesite' => Cookie::SAMESITE_STRICT],
+ 'sessionOptions' => ['cookie_path' => '/test/', 'cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ 'expectedSessionOptions' => ['cookie_path' => '/test/', 'cookie_domain' => '', 'cookie_secure' => true, 'cookie_httponly' => true, 'cookie_samesite' => Cookie::SAMESITE_LAX],
+ ];
+ }
+
public function testOnlyTriggeredOnMainRequest()
{
$listener = $this->getMockForAbstractClass(AbstractSessionListener::class);
@@ -215,17 +308,18 @@ public function testSessionSaveAndResponseHasSessionCookie()
$this->assertSame('123456', $cookies[0]->getValue());
}
- public function testUninitializedSession()
+ public function testUninitializedSessionUsingSessionFromRequest()
{
$kernel = $this->createMock(HttpKernelInterface::class);
$response = new Response();
$response->setSharedMaxAge(60);
$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');
- $container = new Container();
+ $request = new Request();
+ $request->setSession(new Session());
- $listener = new SessionListener($container);
- $listener->onKernelResponse(new ResponseEvent($kernel, new Request(), HttpKernelInterface::MAIN_REQUEST, $response));
+ $listener = new SessionListener(new Container());
+ $listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response));
$this->assertFalse($response->headers->has('Expires'));
$this->assertTrue($response->headers->hasCacheControlDirective('public'));
$this->assertFalse($response->headers->hasCacheControlDirective('private'));
@@ -234,6 +328,24 @@ public function testUninitializedSession()
$this->assertFalse($response->headers->has(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER));
}
+ public function testUninitializedSessionWithoutInitializedSession()
+ {
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $response = new Response();
+ $response->setSharedMaxAge(60);
+ $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');
+
+ $container = new ServiceLocator([]);
+
+ $listener = new SessionListener($container);
+ $listener->onKernelResponse(new ResponseEvent($kernel, new Request(), HttpKernelInterface::MASTER_REQUEST, $response));
+ $this->assertFalse($response->headers->has('Expires'));
+ $this->assertTrue($response->headers->hasCacheControlDirective('public'));
+ $this->assertFalse($response->headers->hasCacheControlDirective('private'));
+ $this->assertFalse($response->headers->hasCacheControlDirective('must-revalidate'));
+ $this->assertSame('60', $response->headers->getCacheControlDirective('s-maxage'));
+ }
+
public function testSurrogateMainRequestIsPublic()
{
$session = $this->createMock(Session::class);
diff --git a/src/Symfony/Component/HttpKernel/Tests/Fixtures/Suit.php b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Suit.php
new file mode 100644
index 0000000000000..5d9623b22598d
--- /dev/null
+++ b/src/Symfony/Component/HttpKernel/Tests/Fixtures/Suit.php
@@ -0,0 +1,20 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpKernel\Tests\Fixtures;
+
+enum Suit: string
+{
+ case Hearts = 'H';
+ case Diamonds = 'D';
+ case Clubs = 'C';
+ case Spades = 'S';
+}
diff --git a/src/Symfony/Component/Intl/LICENSE b/src/Symfony/Component/Intl/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Intl/LICENSE
+++ b/src/Symfony/Component/Intl/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php b/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php
index 997b21be1c055..a48bf1022ec3b 100644
--- a/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php
+++ b/src/Symfony/Component/Ldap/Adapter/ExtLdap/Query.php
@@ -24,8 +24,7 @@
*/
class Query extends AbstractQuery
{
- // As of PHP 7.2, we can use LDAP_CONTROL_PAGEDRESULTS instead of this
- public const PAGINATION_OID = '1.2.840.113556.1.4.319';
+ public const PAGINATION_OID = \LDAP_CONTROL_PAGEDRESULTS;
/** @var Connection */
protected $connection;
diff --git a/src/Symfony/Component/Ldap/LICENSE b/src/Symfony/Component/Ldap/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Ldap/LICENSE
+++ b/src/Symfony/Component/Ldap/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Ldap/Tests/Adapter/ExtLdap/AdapterTest.php b/src/Symfony/Component/Ldap/Tests/Adapter/ExtLdap/AdapterTest.php
index 280b11f293d74..4ded705f93299 100644
--- a/src/Symfony/Component/Ldap/Tests/Adapter/ExtLdap/AdapterTest.php
+++ b/src/Symfony/Component/Ldap/Tests/Adapter/ExtLdap/AdapterTest.php
@@ -149,8 +149,7 @@ public function testLdapPagination()
$this->assertEquals(\count($paged_query->getResources()), 5);
// This last query is to ensure that we haven't botched the state of our connection
- // by not resetting pagination properly. extldap <= PHP 7.1 do not implement the necessary
- // bits to work around an implementation flaw, so we simply can't guarantee this to work there.
+ // by not resetting pagination properly.
$final_query = $ldap->createQuery('dc=symfony,dc=com', '(&(objectClass=applicationProcess)(cn=user*))', [
'scope' => Query::SCOPE_ONE,
]);
diff --git a/src/Symfony/Component/Lock/LICENSE b/src/Symfony/Component/Lock/LICENSE
index 3796612f43c2b..7fa9539054928 100644
--- a/src/Symfony/Component/Lock/LICENSE
+++ b/src/Symfony/Component/Lock/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2016-2021 Fabien Potencier
+Copyright (c) 2016-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php
index 8f3249177af26..879c8bffb39b4 100644
--- a/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php
+++ b/src/Symfony/Component/Lock/Store/DoctrineDbalPostgreSqlStore.php
@@ -58,18 +58,28 @@ public function save(Key $key)
// prevent concurrency within the same connection
$this->getInternalStore()->save($key);
- $sql = 'SELECT pg_try_advisory_lock(:key)';
- $result = $this->conn->executeQuery($sql, [
- 'key' => $this->getHashedKey($key),
- ]);
+ $lockAcquired = false;
- // Check if lock is acquired
- if (true === $result->fetchOne()) {
- $key->markUnserializable();
- // release sharedLock in case of promotion
- $this->unlockShared($key);
+ try {
+ $sql = 'SELECT pg_try_advisory_lock(:key)';
+ $result = $this->conn->executeQuery($sql, [
+ 'key' => $this->getHashedKey($key),
+ ]);
- return;
+ // Check if lock is acquired
+ if (true === $result->fetchOne()) {
+ $key->markUnserializable();
+ // release sharedLock in case of promotion
+ $this->unlockShared($key);
+
+ $lockAcquired = true;
+
+ return;
+ }
+ } finally {
+ if (!$lockAcquired) {
+ $this->getInternalStore()->delete($key);
+ }
}
throw new LockConflictedException();
@@ -80,18 +90,28 @@ public function saveRead(Key $key)
// prevent concurrency within the same connection
$this->getInternalStore()->saveRead($key);
- $sql = 'SELECT pg_try_advisory_lock_shared(:key)';
- $result = $this->conn->executeQuery($sql, [
- 'key' => $this->getHashedKey($key),
- ]);
+ $lockAcquired = false;
+
+ try {
+ $sql = 'SELECT pg_try_advisory_lock_shared(:key)';
+ $result = $this->conn->executeQuery($sql, [
+ 'key' => $this->getHashedKey($key),
+ ]);
- // Check if lock is acquired
- if (true === $result->fetchOne()) {
- $key->markUnserializable();
- // release lock in case of demotion
- $this->unlock($key);
+ // Check if lock is acquired
+ if (true === $result->fetchOne()) {
+ $key->markUnserializable();
+ // release lock in case of demotion
+ $this->unlock($key);
- return;
+ $lockAcquired = true;
+
+ return;
+ }
+ } finally {
+ if (!$lockAcquired) {
+ $this->getInternalStore()->delete($key);
+ }
}
throw new LockConflictedException();
diff --git a/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php
index 22fb9e7778705..d0bdca474923d 100644
--- a/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php
+++ b/src/Symfony/Component/Lock/Store/DoctrineDbalStore.php
@@ -90,8 +90,21 @@ public function save(Key $key)
ParameterType::STRING,
]);
} catch (TableNotFoundException $e) {
- $this->createTable();
- $this->save($key);
+ if (!$this->conn->isTransactionActive() || $this->platformSupportsTableCreationInTransaction()) {
+ $this->createTable();
+ }
+
+ try {
+ $this->conn->executeStatement($sql, [
+ $this->getHashedKey($key),
+ $this->getUniqueToken($key),
+ ], [
+ ParameterType::STRING,
+ ParameterType::STRING,
+ ]);
+ } catch (DBALException $e) {
+ $this->putOffExpiration($key, $this->initialTtl);
+ }
} catch (DBALException $e) {
// the lock is already acquired. It could be us. Let's try to put off.
$this->putOffExpiration($key, $this->initialTtl);
@@ -233,4 +246,23 @@ private function getCurrentTimestampStatement(): string
return (string) time();
}
}
+
+ /**
+ * Checks wether current platform supports table creation within transaction.
+ */
+ private function platformSupportsTableCreationInTransaction(): bool
+ {
+ $platform = $this->conn->getDatabasePlatform();
+
+ switch (true) {
+ case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform:
+ case $platform instanceof \Doctrine\DBAL\Platforms\PostgreSQL94Platform:
+ case $platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform:
+ case $platform instanceof \Doctrine\DBAL\Platforms\SQLServerPlatform:
+ case $platform instanceof \Doctrine\DBAL\Platforms\SQLServer2012Platform:
+ return true;
+ default:
+ return false;
+ }
+ }
}
diff --git a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php
index e73a4f883119b..e935c8a49abec 100644
--- a/src/Symfony/Component/Lock/Store/PostgreSqlStore.php
+++ b/src/Symfony/Component/Lock/Store/PostgreSqlStore.php
@@ -72,18 +72,28 @@ public function save(Key $key)
// prevent concurrency within the same connection
$this->getInternalStore()->save($key);
- $sql = 'SELECT pg_try_advisory_lock(:key)';
- $stmt = $this->getConnection()->prepare($sql);
- $stmt->bindValue(':key', $this->getHashedKey($key));
- $result = $stmt->execute();
+ $lockAcquired = false;
- // Check if lock is acquired
- if (true === $stmt->fetchColumn()) {
- $key->markUnserializable();
- // release sharedLock in case of promotion
- $this->unlockShared($key);
+ try {
+ $sql = 'SELECT pg_try_advisory_lock(:key)';
+ $stmt = $this->getConnection()->prepare($sql);
+ $stmt->bindValue(':key', $this->getHashedKey($key));
+ $result = $stmt->execute();
- return;
+ // Check if lock is acquired
+ if (true === $stmt->fetchColumn()) {
+ $key->markUnserializable();
+ // release sharedLock in case of promotion
+ $this->unlockShared($key);
+
+ $lockAcquired = true;
+
+ return;
+ }
+ } finally {
+ if (!$lockAcquired) {
+ $this->getInternalStore()->delete($key);
+ }
}
throw new LockConflictedException();
@@ -94,19 +104,29 @@ public function saveRead(Key $key)
// prevent concurrency within the same connection
$this->getInternalStore()->saveRead($key);
- $sql = 'SELECT pg_try_advisory_lock_shared(:key)';
- $stmt = $this->getConnection()->prepare($sql);
+ $lockAcquired = false;
- $stmt->bindValue(':key', $this->getHashedKey($key));
- $result = $stmt->execute();
+ try {
+ $sql = 'SELECT pg_try_advisory_lock_shared(:key)';
+ $stmt = $this->getConnection()->prepare($sql);
+
+ $stmt->bindValue(':key', $this->getHashedKey($key));
+ $result = $stmt->execute();
- // Check if lock is acquired
- if (true === $stmt->fetchColumn()) {
- $key->markUnserializable();
- // release lock in case of demotion
- $this->unlock($key);
+ // Check if lock is acquired
+ if (true === $stmt->fetchColumn()) {
+ $key->markUnserializable();
+ // release lock in case of demotion
+ $this->unlock($key);
- return;
+ $lockAcquired = true;
+
+ return;
+ }
+ } finally {
+ if (!$lockAcquired) {
+ $this->getInternalStore()->delete($key);
+ }
}
throw new LockConflictedException();
diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php
index 9133280ddc133..30a5d0a1f503b 100644
--- a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php
+++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalPostgreSqlStoreTest.php
@@ -13,6 +13,7 @@
use Doctrine\DBAL\DriverManager;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
+use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\Store\DoctrineDbalPostgreSqlStore;
@@ -59,4 +60,30 @@ public function getInvalidDrivers()
yield ['sqlite:///tmp/foo.db'];
yield [DriverManager::getConnection(['url' => 'sqlite:///tmp/foo.db'])];
}
+
+ public function testSaveAfterConflict()
+ {
+ $store1 = $this->getStore();
+ $store2 = $this->getStore();
+
+ $key = new Key(uniqid(__METHOD__, true));
+
+ $store1->save($key);
+ $this->assertTrue($store1->exists($key));
+
+ $lockConflicted = false;
+ try {
+ $store2->save($key);
+ } catch (LockConflictedException $lockConflictedException) {
+ $lockConflicted = true;
+ }
+
+ $this->assertTrue($lockConflicted);
+ $this->assertFalse($store2->exists($key));
+
+ $store1->delete($key);
+
+ $store2->save($key);
+ $this->assertTrue($store2->exists($key));
+ }
}
diff --git a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php
index 6a89e49399b0c..4db2d2c614b38 100644
--- a/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php
+++ b/src/Symfony/Component/Lock/Tests/Store/DoctrineDbalStoreTest.php
@@ -11,11 +11,16 @@
namespace Symfony\Component\Lock\Tests\Store;
+use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
+use Doctrine\DBAL\Exception\TableNotFoundException;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\Store\DoctrineDbalStore;
+class_exists(\Doctrine\DBAL\Platforms\PostgreSqlPlatform::class);
+
/**
* @author Jérémy Derussé
*
@@ -87,4 +92,126 @@ public function provideDsn()
yield ['sqlite3:///'.$dbFile.'3', $dbFile.'3'];
yield ['sqlite://localhost/:memory:'];
}
+
+ /**
+ * @dataProvider providePlatforms
+ */
+ public function testCreatesTableInTransaction(string $platform)
+ {
+ $conn = $this->createMock(Connection::class);
+ $conn->expects($this->exactly(3))
+ ->method('executeStatement')
+ ->withConsecutive(
+ [$this->stringContains('INSERT INTO')],
+ [$this->matches('create sql stmt')],
+ [$this->stringContains('INSERT INTO')]
+ )
+ ->will(
+ $this->onConsecutiveCalls(
+ $this->throwException(
+ $this->createMock(TableNotFoundException::class)
+ ),
+ 1,
+ 1
+ )
+ );
+
+ $conn->method('isTransactionActive')
+ ->willReturn(true);
+
+ $platform = $this->createMock($platform);
+ $platform->method('getCreateTableSQL')
+ ->willReturn(['create sql stmt']);
+
+ $conn->method('getDatabasePlatform')
+ ->willReturn($platform);
+
+ $store = new DoctrineDbalStore($conn);
+
+ $key = new Key(uniqid(__METHOD__, true));
+
+ $store->save($key);
+ }
+
+ public function providePlatforms()
+ {
+ yield [\Doctrine\DBAL\Platforms\PostgreSQLPlatform::class];
+ yield [\Doctrine\DBAL\Platforms\PostgreSQL94Platform::class];
+ yield [\Doctrine\DBAL\Platforms\SqlitePlatform::class];
+ yield [\Doctrine\DBAL\Platforms\SQLServerPlatform::class];
+ yield [\Doctrine\DBAL\Platforms\SQLServer2012Platform::class];
+ }
+
+ public function testTableCreationInTransactionNotSupported()
+ {
+ $conn = $this->createMock(Connection::class);
+ $conn->expects($this->exactly(2))
+ ->method('executeStatement')
+ ->withConsecutive(
+ [$this->stringContains('INSERT INTO')],
+ [$this->stringContains('INSERT INTO')]
+ )
+ ->will(
+ $this->onConsecutiveCalls(
+ $this->throwException(
+ $this->createMock(TableNotFoundException::class)
+ ),
+ 1,
+ 1
+ )
+ );
+
+ $conn->method('isTransactionActive')
+ ->willReturn(true);
+
+ $platform = $this->createMock(AbstractPlatform::class);
+ $platform->method('getCreateTableSQL')
+ ->willReturn(['create sql stmt']);
+
+ $conn->expects($this->exactly(2))
+ ->method('getDatabasePlatform');
+
+ $store = new DoctrineDbalStore($conn);
+
+ $key = new Key(uniqid(__METHOD__, true));
+
+ $store->save($key);
+ }
+
+ public function testCreatesTableOutsideTransaction()
+ {
+ $conn = $this->createMock(Connection::class);
+ $conn->expects($this->exactly(3))
+ ->method('executeStatement')
+ ->withConsecutive(
+ [$this->stringContains('INSERT INTO')],
+ [$this->matches('create sql stmt')],
+ [$this->stringContains('INSERT INTO')]
+ )
+ ->will(
+ $this->onConsecutiveCalls(
+ $this->throwException(
+ $this->createMock(TableNotFoundException::class)
+ ),
+ 1,
+ 1
+ )
+ );
+
+ $conn->method('isTransactionActive')
+ ->willReturn(false);
+
+ $platform = $this->createMock(AbstractPlatform::class);
+ $platform->method('getCreateTableSQL')
+ ->willReturn(['create sql stmt']);
+
+ $conn->method('getDatabasePlatform')
+ ->willReturn($platform);
+
+ $store = new DoctrineDbalStore($conn);
+
+ $key = new Key(uniqid(__METHOD__, true));
+
+ $store->save($key);
+ }
}
diff --git a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php
index d0358a8ef054a..aef6ee7b86782 100644
--- a/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php
+++ b/src/Symfony/Component/Lock/Tests/Store/PostgreSqlStoreTest.php
@@ -12,6 +12,7 @@
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
+use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\Store\PostgreSqlStore;
@@ -50,4 +51,31 @@ public function testInvalidDriver()
$this->expectExceptionMessage('The adapter "Symfony\Component\Lock\Store\PostgreSqlStore" does not support');
$store->exists(new Key('foo'));
}
+
+ public function testSaveAfterConflict()
+ {
+ $store1 = $this->getStore();
+ $store2 = $this->getStore();
+
+ $key = new Key(uniqid(__METHOD__, true));
+
+ $store1->save($key);
+ $this->assertTrue($store1->exists($key));
+
+ $lockConflicted = false;
+
+ try {
+ $store2->save($key);
+ } catch (LockConflictedException $lockConflictedException) {
+ $lockConflicted = true;
+ }
+
+ $this->assertTrue($lockConflicted);
+ $this->assertFalse($store2->exists($key));
+
+ $store1->delete($key);
+
+ $store2->save($key);
+ $this->assertTrue($store2->exists($key));
+ }
}
diff --git a/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE b/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Amazon/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Google/LICENSE b/src/Symfony/Component/Mailer/Bridge/Google/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Google/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Google/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Mailchimp/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Mailgun/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Mailjet/LICENSE b/src/Symfony/Component/Mailer/Bridge/Mailjet/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Mailjet/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Mailjet/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/OhMySmtp/LICENSE b/src/Symfony/Component/Mailer/Bridge/OhMySmtp/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Mailer/Bridge/OhMySmtp/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/OhMySmtp/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE b/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Postmark/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE b/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Sendgrid/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/Bridge/Sendinblue/LICENSE b/src/Symfony/Component/Mailer/Bridge/Sendinblue/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/Bridge/Sendinblue/LICENSE
+++ b/src/Symfony/Component/Mailer/Bridge/Sendinblue/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mailer/LICENSE b/src/Symfony/Component/Mailer/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Mailer/LICENSE
+++ b/src/Symfony/Component/Mailer/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/LICENSE b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Messenger/Bridge/AmazonSqs/LICENSE
+++ b/src/Symfony/Component/Messenger/Bridge/AmazonSqs/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/LICENSE b/src/Symfony/Component/Messenger/Bridge/Amqp/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/Messenger/Bridge/Amqp/LICENSE
+++ b/src/Symfony/Component/Messenger/Bridge/Amqp/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpExtIntegrationTest.php b/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpExtIntegrationTest.php
index aa551e4e85080..8ca90a554c5b1 100644
--- a/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpExtIntegrationTest.php
+++ b/src/Symfony/Component/Messenger/Bridge/Amqp/Tests/Transport/AmqpExtIntegrationTest.php
@@ -213,6 +213,7 @@ public function testItReceivesSignals()
$this->assertSame($expectedOutput.<<<'TXT'
Get envelope with message: Symfony\Component\Messenger\Bridge\Amqp\Tests\Fixtures\DummyMessage
with stamps: [
+ "Symfony\\Component\\Messenger\\Stamp\\SerializedMessageStamp",
"Symfony\\Component\\Messenger\\Bridge\\Amqp\\Transport\\AmqpReceivedStamp",
"Symfony\\Component\\Messenger\\Stamp\\ReceivedStamp",
"Symfony\\Component\\Messenger\\Stamp\\ConsumedByWorkerStamp",
diff --git a/src/Symfony/Component/Messenger/Bridge/Beanstalkd/LICENSE b/src/Symfony/Component/Messenger/Bridge/Beanstalkd/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/Messenger/Bridge/Beanstalkd/LICENSE
+++ b/src/Symfony/Component/Messenger/Bridge/Beanstalkd/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/Doctrine/LICENSE b/src/Symfony/Component/Messenger/Bridge/Doctrine/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/Messenger/Bridge/Doctrine/LICENSE
+++ b/src/Symfony/Component/Messenger/Bridge/Doctrine/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/LICENSE b/src/Symfony/Component/Messenger/Bridge/Redis/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/Messenger/Bridge/Redis/LICENSE
+++ b/src/Symfony/Component/Messenger/Bridge/Redis/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php
index 979321c906241..1a38315925b41 100644
--- a/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php
+++ b/src/Symfony/Component/Messenger/Bridge/Redis/Transport/Connection.php
@@ -51,10 +51,10 @@ class Connection
private bool $autoSetup;
private int $maxEntries;
private int $redeliverTimeout;
- private int $nextClaim = 0;
- private mixed $claimInterval;
- private mixed $deleteAfterAck;
- private mixed $deleteAfterReject;
+ private float $nextClaim = 0.0;
+ private float $claimInterval;
+ private bool $deleteAfterAck;
+ private bool $deleteAfterReject;
private bool $couldHavePendingMessages = true;
public function __construct(array $configuration, array $connectionCredentials = [], array $redisOptions = [], \Redis|\RedisCluster $redis = null)
@@ -104,7 +104,7 @@ public function __construct(array $configuration, array $connectionCredentials =
$this->deleteAfterAck = $configuration['delete_after_ack'] ?? self::DEFAULT_OPTIONS['delete_after_ack'];
$this->deleteAfterReject = $configuration['delete_after_reject'] ?? self::DEFAULT_OPTIONS['delete_after_reject'];
$this->redeliverTimeout = ($configuration['redeliver_timeout'] ?? self::DEFAULT_OPTIONS['redeliver_timeout']) * 1000;
- $this->claimInterval = $configuration['claim_interval'] ?? self::DEFAULT_OPTIONS['claim_interval'];
+ $this->claimInterval = ($configuration['claim_interval'] ?? self::DEFAULT_OPTIONS['claim_interval']) / 1000;
}
/**
@@ -320,7 +320,7 @@ private function claimOldPendingMessages()
}
}
- $this->nextClaim = $this->getCurrentTimeInMilliseconds() + $this->claimInterval;
+ $this->nextClaim = microtime(true) + $this->claimInterval;
}
public function get(): ?array
@@ -328,36 +328,32 @@ public function get(): ?array
if ($this->autoSetup) {
$this->setup();
}
+ $now = microtime();
+ $now = substr($now, 11).substr($now, 2, 3);
- try {
- $queuedMessageCount = $this->connection->zcount($this->queue, 0, $this->getCurrentTimeInMilliseconds());
- } catch (\RedisException $e) {
- throw new TransportException($e->getMessage(), 0, $e);
- }
+ $queuedMessageCount = $this->rawCommand('ZCOUNT', 0, $now);
- if ($queuedMessageCount) {
- for ($i = 0; $i < $queuedMessageCount; ++$i) {
- try {
- $queuedMessages = $this->connection->zpopmin($this->queue, 1);
- } catch (\RedisException $e) {
- throw new TransportException($e->getMessage(), 0, $e);
- }
+ while ($queuedMessageCount--) {
+ if (![$queuedMessage, $expiry] = $this->rawCommand('ZPOPMIN', 1)) {
+ break;
+ }
+
+ if (\strlen($expiry) === \strlen($now) ? $expiry > $now : \strlen($expiry) < \strlen($now)) {
+ // if a future-placed message is popped because of a race condition with
+ // another running consumer, the message is readded to the queue
- foreach ($queuedMessages as $queuedMessage => $time) {
- $decodedQueuedMessage = json_decode($queuedMessage, true);
- // if a futured placed message is actually popped because of a race condition with
- // another running message consumer, the message is readded to the queue by add function
- // else its just added stream and will be available for all stream consumers
- $this->add(
- \array_key_exists('body', $decodedQueuedMessage) ? $decodedQueuedMessage['body'] : $queuedMessage,
- $decodedQueuedMessage['headers'] ?? [],
- $time - $this->getCurrentTimeInMilliseconds()
- );
+ if (!$this->rawCommand('ZADD', 'NX', $expiry, $queuedMessage)) {
+ throw new TransportException('Could not add a message to the redis stream.');
}
+
+ break;
}
+
+ $decodedQueuedMessage = json_decode($queuedMessage, true);
+ $this->add(\array_key_exists('body', $decodedQueuedMessage) ? $decodedQueuedMessage['body'] : $queuedMessage, $decodedQueuedMessage['headers'] ?? [], 0);
}
- if (!$this->couldHavePendingMessages && $this->nextClaim <= $this->getCurrentTimeInMilliseconds()) {
+ if (!$this->couldHavePendingMessages && $this->nextClaim <= microtime(true)) {
$this->claimOldPendingMessages();
}
@@ -448,7 +444,7 @@ public function add(string $body, array $headers, int $delayInMs = 0): void
}
try {
- if ($delayInMs > 0) { // the delay could be smaller 0 in a queued message
+ if ($delayInMs > 0) { // the delay is <= 0 for queued messages
$message = json_encode([
'body' => $body,
'headers' => $headers,
@@ -460,8 +456,18 @@ public function add(string $body, array $headers, int $delayInMs = 0): void
throw new TransportException(json_last_error_msg());
}
- $score = $this->getCurrentTimeInMilliseconds() + $delayInMs;
- $added = $this->connection->zadd($this->queue, ['NX'], $score, $message);
+ $now = explode(' ', microtime(), 2);
+ $now[0] = str_pad($delayInMs + substr($now[0], 2, 3), 3, '0', \STR_PAD_LEFT);
+ if (3 < \strlen($now[0])) {
+ $now[1] += substr($now[0], 0, -3);
+ $now[0] = substr($now[0], -3);
+
+ if (\is_float($now[1])) {
+ throw new TransportException("Message delay is too big: {$delayInMs}ms.");
+ }
+ }
+
+ $added = $this->rawCommand('ZADD', 'NX', $now[1].$now[0], $message);
} else {
$message = json_encode([
'body' => $body,
@@ -542,6 +548,28 @@ public function cleanup(): void
$this->connection->del($this->stream, $this->queue);
}
}
+
+ private function rawCommand(string $command, ...$arguments): mixed
+ {
+ try {
+ if ($this->connection instanceof \RedisCluster || $this->connection instanceof RedisClusterProxy) {
+ $result = $this->connection->rawCommand($this->queue, $command, $this->queue, ...$arguments);
+ } else {
+ $result = $this->connection->rawCommand($command, $this->queue, ...$arguments);
+ }
+ } catch (\RedisException $e) {
+ throw new TransportException($e->getMessage(), 0, $e);
+ }
+
+ if (false === $result) {
+ if ($error = $this->connection->getLastError() ?: null) {
+ $this->connection->clearLastError();
+ }
+ throw new TransportException($error ?? sprintf('Could not run "%s" on Redis queue.', $command));
+ }
+
+ return $result;
+ }
}
if (!class_exists(\Symfony\Component\Messenger\Transport\RedisExt\Connection::class, false)) {
diff --git a/src/Symfony/Component/Messenger/CHANGELOG.md b/src/Symfony/Component/Messenger/CHANGELOG.md
index 22d9b6e80a3ce..98193ec5c02c4 100644
--- a/src/Symfony/Component/Messenger/CHANGELOG.md
+++ b/src/Symfony/Component/Messenger/CHANGELOG.md
@@ -1,6 +1,12 @@
CHANGELOG
=========
+6.1
+---
+
+ * Add `SerializedMessageStamp` to avoid serializing a message when a retry occurs.
+ * Automatically resolve handled message type when method different from `__invoke` is used as handler.
+
6.0
---
diff --git a/src/Symfony/Component/Messenger/DependencyInjection/MessengerPass.php b/src/Symfony/Component/Messenger/DependencyInjection/MessengerPass.php
index 99d6975ef702e..3c2f0b6fa4eca 100644
--- a/src/Symfony/Component/Messenger/DependencyInjection/MessengerPass.php
+++ b/src/Symfony/Component/Messenger/DependencyInjection/MessengerPass.php
@@ -78,7 +78,7 @@ private function registerHandlers(ContainerBuilder $container, array $busIds)
if (isset($tag['handles'])) {
$handles = isset($tag['method']) ? [$tag['handles'] => $tag['method']] : [$tag['handles']];
} else {
- $handles = $this->guessHandledClasses($r, $serviceId);
+ $handles = $this->guessHandledClasses($r, $serviceId, $tag['method'] ?? '__invoke');
}
$message = null;
@@ -197,25 +197,29 @@ private function registerHandlers(ContainerBuilder $container, array $busIds)
}
}
- private function guessHandledClasses(\ReflectionClass $handlerClass, string $serviceId): iterable
+ private function guessHandledClasses(\ReflectionClass $handlerClass, string $serviceId, string $methodName): iterable
{
if ($handlerClass->implementsInterface(MessageSubscriberInterface::class)) {
return $handlerClass->getName()::getHandledMessages();
}
try {
- $method = $handlerClass->getMethod('__invoke');
+ $method = $handlerClass->getMethod($methodName);
} catch (\ReflectionException $e) {
- throw new RuntimeException(sprintf('Invalid handler service "%s": class "%s" must have an "__invoke()" method.', $serviceId, $handlerClass->getName()));
+ throw new RuntimeException(sprintf('Invalid handler service "%s": class "%s" must have an "%s()" method.', $serviceId, $handlerClass->getName(), $methodName));
}
if (0 === $method->getNumberOfRequiredParameters()) {
- throw new RuntimeException(sprintf('Invalid handler service "%s": method "%s::__invoke()" requires at least one argument, first one being the message it handles.', $serviceId, $handlerClass->getName()));
+ throw new RuntimeException(sprintf('Invalid handler service "%s": method "%s::%s()" requires at least one argument, first one being the message it handles.', $serviceId, $handlerClass->getName(), $methodName));
}
$parameters = $method->getParameters();
- if (!$type = $parameters[0]->getType()) {
- throw new RuntimeException(sprintf('Invalid handler service "%s": argument "$%s" of method "%s::__invoke()" must have a type-hint corresponding to the message class it handles.', $serviceId, $parameters[0]->getName(), $handlerClass->getName()));
+
+ /** @var \ReflectionNamedType|\ReflectionUnionType|null */
+ $type = $parameters[0]->getType();
+
+ if (!$type) {
+ throw new RuntimeException(sprintf('Invalid handler service "%s": argument "$%s" of method "%s::%s()" must have a type-hint corresponding to the message class it handles.', $serviceId, $parameters[0]->getName(), $handlerClass->getName(), $methodName));
}
if ($type instanceof \ReflectionUnionType) {
@@ -232,10 +236,10 @@ private function guessHandledClasses(\ReflectionClass $handlerClass, string $ser
}
if ($type->isBuiltin()) {
- throw new RuntimeException(sprintf('Invalid handler service "%s": type-hint of argument "$%s" in method "%s::__invoke()" must be a class , "%s" given.', $serviceId, $parameters[0]->getName(), $handlerClass->getName(), $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type));
+ throw new RuntimeException(sprintf('Invalid handler service "%s": type-hint of argument "$%s" in method "%s::%s()" must be a class , "%s" given.', $serviceId, $parameters[0]->getName(), $handlerClass->getName(), $methodName, $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type));
}
- return [$type->getName()];
+ return ('__invoke' === $methodName) ? [$type->getName()] : [$type->getName() => $methodName];
}
private function registerReceivers(ContainerBuilder $container, array $busIds)
diff --git a/src/Symfony/Component/Messenger/LICENSE b/src/Symfony/Component/Messenger/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/Messenger/LICENSE
+++ b/src/Symfony/Component/Messenger/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Messenger/Stamp/SerializedMessageStamp.php b/src/Symfony/Component/Messenger/Stamp/SerializedMessageStamp.php
new file mode 100644
index 0000000000000..3feffbc131861
--- /dev/null
+++ b/src/Symfony/Component/Messenger/Stamp/SerializedMessageStamp.php
@@ -0,0 +1,24 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Messenger\Stamp;
+
+final class SerializedMessageStamp implements NonSendableStampInterface
+{
+ public function __construct(private string $serializedMessage)
+ {
+ }
+
+ public function getSerializedMessage(): string
+ {
+ return $this->serializedMessage;
+ }
+}
diff --git a/src/Symfony/Component/Messenger/Tests/DependencyInjection/MessengerPassTest.php b/src/Symfony/Component/Messenger/Tests/DependencyInjection/MessengerPassTest.php
index 7c11dcce89147..e424236804c92 100644
--- a/src/Symfony/Component/Messenger/Tests/DependencyInjection/MessengerPassTest.php
+++ b/src/Symfony/Component/Messenger/Tests/DependencyInjection/MessengerPassTest.php
@@ -40,6 +40,7 @@
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Tests\Fixtures\DummyCommand;
use Symfony\Component\Messenger\Tests\Fixtures\DummyCommandHandler;
+use Symfony\Component\Messenger\Tests\Fixtures\DummyHandlerWithCustomMethods;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
use Symfony\Component\Messenger\Tests\Fixtures\DummyQuery;
use Symfony\Component\Messenger\Tests\Fixtures\DummyQueryHandler;
@@ -105,6 +106,39 @@ public function testFromTransportViaTagAttribute()
$this->assertHandlerDescriptor($container, $handlerDescriptionMapping, DummyMessage::class, [DummyHandler::class], [['from_transport' => 'async']]);
}
+ public function testHandledMessageTypeResolvedWithMethodAndNoHandlesViaTagAttributes()
+ {
+ $container = $this->getContainerBuilder($busId = 'message_bus');
+ $container
+ ->register(DummyHandlerWithCustomMethods::class, DummyHandlerWithCustomMethods::class)
+ ->addTag('messenger.message_handler', [
+ 'method' => 'handleDummyMessage',
+ ])
+ ->addTag('messenger.message_handler', [
+ 'method' => 'handleSecondMessage',
+ ]);
+
+ (new MessengerPass())->process($container);
+
+ $handlersMapping = $container->getDefinition($busId.'.messenger.handlers_locator')->getArgument(0);
+
+ $this->assertArrayHasKey(DummyMessage::class, $handlersMapping);
+ $this->assertHandlerDescriptor(
+ $container,
+ $handlersMapping,
+ DummyMessage::class,
+ [[DummyHandlerWithCustomMethods::class, 'handleDummyMessage']]
+ );
+
+ $this->assertArrayHasKey(SecondMessage::class, $handlersMapping);
+ $this->assertHandlerDescriptor(
+ $container,
+ $handlersMapping,
+ SecondMessage::class,
+ [[DummyHandlerWithCustomMethods::class, 'handleSecondMessage']]
+ );
+ }
+
public function testTaggedMessageHandler()
{
$container = $this->getContainerBuilder($busId = 'message_bus');
diff --git a/src/Symfony/Component/Messenger/Tests/Fixtures/DummyHandlerWithCustomMethods.php b/src/Symfony/Component/Messenger/Tests/Fixtures/DummyHandlerWithCustomMethods.php
new file mode 100644
index 0000000000000..384b3a74519c6
--- /dev/null
+++ b/src/Symfony/Component/Messenger/Tests/Fixtures/DummyHandlerWithCustomMethods.php
@@ -0,0 +1,23 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Messenger\Tests\Fixtures;
+
+class DummyHandlerWithCustomMethods
+{
+ public function handleDummyMessage(DummyMessage $message)
+ {
+ }
+
+ public function handleSecondMessage(SecondMessage $message)
+ {
+ }
+}
diff --git a/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php b/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php
index 6f4d3bad313d1..712077d71ba54 100644
--- a/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php
+++ b/src/Symfony/Component/Messenger/Tests/Transport/Serialization/SerializerTest.php
@@ -15,6 +15,7 @@
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Stamp\NonSendableStampInterface;
+use Symfony\Component\Messenger\Stamp\SerializedMessageStamp;
use Symfony\Component\Messenger\Stamp\SerializerStamp;
use Symfony\Component\Messenger\Stamp\ValidationStamp;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
@@ -29,9 +30,10 @@ public function testEncodedIsDecodable()
{
$serializer = new Serializer();
- $envelope = new Envelope(new DummyMessage('Hello'));
+ $decodedEnvelope = $serializer->decode($serializer->encode(new Envelope(new DummyMessage('Hello'))));
- $this->assertEquals($envelope, $serializer->decode($serializer->encode($envelope)));
+ $this->assertEquals(new DummyMessage('Hello'), $decodedEnvelope->getMessage());
+ $this->assertEquals(new SerializedMessageStamp('{"message":"Hello"}'), $decodedEnvelope->last(SerializedMessageStamp::class));
}
public function testEncodedWithStampsIsDecodable()
@@ -41,11 +43,23 @@ public function testEncodedWithStampsIsDecodable()
$envelope = (new Envelope(new DummyMessage('Hello')))
->with(new SerializerStamp([ObjectNormalizer::GROUPS => ['foo']]))
->with(new ValidationStamp(['foo', 'bar']))
+ ->with(new SerializedMessageStamp('{"message":"Hello"}'))
;
$this->assertEquals($envelope, $serializer->decode($serializer->encode($envelope)));
}
+ public function testSerializedMessageStampIsUsedForEncoding()
+ {
+ $serializer = new Serializer();
+
+ $encoded = $serializer->encode(
+ new Envelope(new DummyMessage(''), [new SerializedMessageStamp('{"message":"Hello"}')])
+ );
+
+ $this->assertSame('{"message":"Hello"}', $encoded['body'] ?? null);
+ }
+
public function testEncodedIsHavingTheBodyAndTypeHeader()
{
$serializer = new Serializer();
diff --git a/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php b/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php
index 30540321f639d..b3c8cb7c6ef3f 100644
--- a/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php
+++ b/src/Symfony/Component/Messenger/Transport/Serialization/Serializer.php
@@ -15,6 +15,7 @@
use Symfony\Component\Messenger\Exception\LogicException;
use Symfony\Component\Messenger\Exception\MessageDecodingFailedException;
use Symfony\Component\Messenger\Stamp\NonSendableStampInterface;
+use Symfony\Component\Messenger\Stamp\SerializedMessageStamp;
use Symfony\Component\Messenger\Stamp\SerializerStamp;
use Symfony\Component\Messenger\Stamp\StampInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
@@ -71,6 +72,8 @@ public function decode(array $encodedEnvelope): Envelope
}
$stamps = $this->decodeStamps($encodedEnvelope);
+ $stamps[] = new SerializedMessageStamp($encodedEnvelope['body']);
+
$serializerStamp = $this->findFirstSerializerStamp($stamps);
$context = $this->context;
@@ -98,12 +101,17 @@ public function encode(Envelope $envelope): array
$context = $serializerStamp->getContext() + $context;
}
+ /** @var SerializedMessageStamp|null $serializedMessageStamp */
+ $serializedMessageStamp = $envelope->last(SerializedMessageStamp::class);
+
$envelope = $envelope->withoutStampsOfType(NonSendableStampInterface::class);
$headers = ['type' => \get_class($envelope->getMessage())] + $this->encodeStamps($envelope) + $this->getContentTypeHeader();
return [
- 'body' => $this->serializer->serialize($envelope->getMessage(), $this->format, $context),
+ 'body' => $serializedMessageStamp
+ ? $serializedMessageStamp->getSerializedMessage()
+ : $this->serializer->serialize($envelope->getMessage(), $this->format, $context),
'headers' => $headers,
];
}
diff --git a/src/Symfony/Component/Mime/Crypto/DkimSigner.php b/src/Symfony/Component/Mime/Crypto/DkimSigner.php
index ef3ee88524aec..1d2005e5c62ec 100644
--- a/src/Symfony/Component/Mime/Crypto/DkimSigner.php
+++ b/src/Symfony/Component/Mime/Crypto/DkimSigner.php
@@ -62,7 +62,7 @@ public function sign(Message $message, array $options = []): Message
{
$options += $this->defaultOptions;
if (!\in_array($options['algorithm'], [self::ALGO_SHA256, self::ALGO_ED25519], true)) {
- throw new InvalidArgumentException('Invalid DKIM signing algorithm "%s".', $options['algorithm']);
+ throw new InvalidArgumentException(sprintf('Invalid DKIM signing algorithm "%s".', $options['algorithm']));
}
$headersToIgnore['return-path'] = true;
$headersToIgnore['x-transport'] = true;
@@ -202,7 +202,7 @@ private function hashBody(AbstractPart $body, string $bodyCanon, int $maxLength)
}
// Add trailing Line return if last line is non empty
- if (\strlen($currentLine) > 0) {
+ if ('' !== $currentLine) {
hash_update($hash, "\r\n");
$length += \strlen("\r\n");
}
diff --git a/src/Symfony/Component/Mime/DraftEmail.php b/src/Symfony/Component/Mime/DraftEmail.php
new file mode 100644
index 0000000000000..a60fea17360a8
--- /dev/null
+++ b/src/Symfony/Component/Mime/DraftEmail.php
@@ -0,0 +1,45 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Mime;
+
+use Symfony\Component\Mime\Header\Headers;
+use Symfony\Component\Mime\Part\AbstractPart;
+
+/**
+ * @author Kevin Bond
+ */
+class DraftEmail extends Email
+{
+ public function __construct(Headers $headers = null, AbstractPart $body = null)
+ {
+ parent::__construct($headers, $body);
+
+ $this->getHeaders()->addTextHeader('X-Unsent', '1');
+ }
+
+ /**
+ * Override default behavior as draft emails do not require From/Sender/Date/Message-ID headers.
+ * These are added by the client that actually sends the email.
+ */
+ public function getPreparedHeaders(): Headers
+ {
+ $headers = clone $this->getHeaders();
+
+ if (!$headers->has('MIME-Version')) {
+ $headers->addTextHeader('MIME-Version', '1.0');
+ }
+
+ $headers->remove('Bcc');
+
+ return $headers;
+ }
+}
diff --git a/src/Symfony/Component/Mime/Email.php b/src/Symfony/Component/Mime/Email.php
index 788e4f351263b..952c7b5c151ca 100644
--- a/src/Symfony/Component/Mime/Email.php
+++ b/src/Symfony/Component/Mime/Email.php
@@ -386,13 +386,22 @@ public function getBody(): AbstractPart
public function ensureValidity()
{
- if (null === $this->text && null === $this->html && !$this->attachments) {
- throw new LogicException('A message must have a text or an HTML part or attachments.');
+ $this->ensureBodyValid();
+
+ if ('1' === $this->getHeaders()->getHeaderBody('X-Unsent')) {
+ throw new LogicException('Cannot send messages marked as "draft".');
}
parent::ensureValidity();
}
+ private function ensureBodyValid(): void
+ {
+ if (null === $this->text && null === $this->html && !$this->attachments) {
+ throw new LogicException('A message must have a text or an HTML part or attachments.');
+ }
+ }
+
/**
* Generates an AbstractPart based on the raw body of a message.
*
@@ -415,7 +424,7 @@ public function ensureValidity()
*/
private function generateBody(): AbstractPart
{
- $this->ensureValidity();
+ $this->ensureBodyValid();
[$htmlPart, $attachmentParts, $inlineParts] = $this->prepareParts();
diff --git a/src/Symfony/Component/Mime/Header/Headers.php b/src/Symfony/Component/Mime/Header/Headers.php
index 52af9a9a8965a..115928155e961 100644
--- a/src/Symfony/Component/Mime/Header/Headers.php
+++ b/src/Symfony/Component/Mime/Header/Headers.php
@@ -34,8 +34,8 @@ final class Headers
'cc' => MailboxListHeader::class,
'bcc' => MailboxListHeader::class,
'message-id' => IdentificationHeader::class,
- 'in-reply-to' => IdentificationHeader::class,
- 'references' => IdentificationHeader::class,
+ 'in-reply-to' => UnstructuredHeader::class, // `In-Reply-To` and `References` are less strict than RFC 2822 (3.6.4) to allow users entering the original email's ...
+ 'references' => UnstructuredHeader::class, // ... `Message-ID`, even if that is no valid `msg-id`
'return-path' => PathHeader::class,
];
diff --git a/src/Symfony/Component/Mime/Header/ParameterizedHeader.php b/src/Symfony/Component/Mime/Header/ParameterizedHeader.php
index 0219e712cd101..ee2e11346bc89 100644
--- a/src/Symfony/Component/Mime/Header/ParameterizedHeader.php
+++ b/src/Symfony/Component/Mime/Header/ParameterizedHeader.php
@@ -123,6 +123,22 @@ private function createParameter(string $name, string $value): string
$maxValueLength = $this->getMaxLineLength() - \strlen($name.'*N*="";') - 1;
$firstLineOffset = \strlen($this->getCharset()."'".$this->getLanguage()."'");
}
+
+ if (\in_array($name, ['name', 'filename'], true) && 'form-data' === $this->getValue() && 'content-disposition' === strtolower($this->getName()) && preg_match('//u', $value)) {
+ // WHATWG HTML living standard 4.10.21.8 2 specifies:
+ // For field names and filenames for file fields, the result of the
+ // encoding in the previous bullet point must be escaped by replacing
+ // any 0x0A (LF) bytes with the byte sequence `%0A`, 0x0D (CR) with `%0D`
+ // and 0x22 (") with `%22`.
+ // The user agent must not perform any other escapes.
+ $value = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], $value);
+
+ if (\strlen($value) <= $maxValueLength) {
+ return $name.'="'.$value.'"';
+ }
+
+ $value = $origValue;
+ }
}
// Encode if we need to
@@ -158,7 +174,7 @@ private function createParameter(string $name, string $value): string
*/
private function getEndOfParameterValue(string $value, bool $encoded = false, bool $firstLine = false): string
{
- $forceHttpQuoting = 'content-disposition' === strtolower($this->getName()) && 'form-data' === $this->getValue();
+ $forceHttpQuoting = 'form-data' === $this->getValue() && 'content-disposition' === strtolower($this->getName());
if ($forceHttpQuoting || !preg_match('/^'.self::TOKEN_REGEX.'$/D', $value)) {
$value = '"'.$value.'"';
}
diff --git a/src/Symfony/Component/Mime/LICENSE b/src/Symfony/Component/Mime/LICENSE
index 151af4bbc71b9..298be14166c20 100644
--- a/src/Symfony/Component/Mime/LICENSE
+++ b/src/Symfony/Component/Mime/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2010-2021 Fabien Potencier
+Copyright (c) 2010-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php b/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php
index e48b0c8e4e3c0..e0eaa54f18757 100644
--- a/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php
+++ b/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php
@@ -16,6 +16,7 @@
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Crypto\DkimSigner;
use Symfony\Component\Mime\Email;
+use Symfony\Component\Mime\Message;
/**
* @group time-sensitive
@@ -90,6 +91,21 @@ public function getSignData()
];
}
+ public function testSignWithUnsupportedAlgorithm()
+ {
+ $message = $this->createMock(Message::class);
+
+ $signer = new DkimSigner(self::$pk, 'testdkim.symfony.net', 'sf', [
+ 'algorithm' => 'unsupported-value',
+ ]);
+
+ $this->expectExceptionObject(
+ new \LogicException('Invalid DKIM signing algorithm "unsupported-value".')
+ );
+
+ $signer->sign($message, []);
+ }
+
/**
* @dataProvider getCanonicalizeHeaderData
*/
diff --git a/src/Symfony/Component/Mime/Tests/DraftEmailTest.php b/src/Symfony/Component/Mime/Tests/DraftEmailTest.php
new file mode 100644
index 0000000000000..713048bfd9e59
--- /dev/null
+++ b/src/Symfony/Component/Mime/Tests/DraftEmailTest.php
@@ -0,0 +1,58 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Mime\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Mime\DraftEmail;
+use Symfony\Component\Mime\Exception\LogicException;
+
+/**
+ * @author Kevin Bond
+ */
+final class DraftEmailTest extends TestCase
+{
+ public function testCanHaveJustBody()
+ {
+ $email = (new DraftEmail())->text('some text')->toString();
+
+ $this->assertStringContainsString('some text', $email);
+ $this->assertStringContainsString('MIME-Version: 1.0', $email);
+ $this->assertStringContainsString('X-Unsent: 1', $email);
+ }
+
+ public function testBccIsRemoved()
+ {
+ $email = (new DraftEmail())->text('some text')->bcc('sam@example.com')->toString();
+
+ $this->assertStringNotContainsString('sam@example.com', $email);
+ }
+
+ public function testMustHaveBody()
+ {
+ $this->expectException(LogicException::class);
+
+ (new DraftEmail())->toString();
+ }
+
+ public function testEnsureValidityAlwaysFails()
+ {
+ $email = (new DraftEmail())
+ ->to('alice@example.com')
+ ->from('webmaster@example.com')
+ ->text('some text')
+ ;
+
+ $this->expectException(LogicException::class);
+
+ $email->ensureValidity();
+ }
+}
diff --git a/src/Symfony/Component/Mime/Tests/Header/HeadersTest.php b/src/Symfony/Component/Mime/Tests/Header/HeadersTest.php
index 37e3192fbe946..c010bc7d33dc3 100644
--- a/src/Symfony/Component/Mime/Tests/Header/HeadersTest.php
+++ b/src/Symfony/Component/Mime/Tests/Header/HeadersTest.php
@@ -280,6 +280,20 @@ public function testToArray()
], $headers->toArray());
}
+ public function testInReplyToAcceptsNonIdentifierValues()
+ {
+ $headers = new Headers();
+ $headers->addTextHeader('In-Reply-To', 'foobar');
+ $this->assertEquals('foobar', $headers->get('In-Reply-To')->getBody());
+ }
+
+ public function testReferencesAcceptsNonIdentifierValues()
+ {
+ $headers = new Headers();
+ $headers->addTextHeader('References' , 'foobar');
+ $this->assertEquals('foobar', $headers->get('References')->getBody());
+ }
+
public function testHeaderBody()
{
$headers = new Headers();
diff --git a/src/Symfony/Component/Mime/Tests/Header/ParameterizedHeaderTest.php b/src/Symfony/Component/Mime/Tests/Header/ParameterizedHeaderTest.php
index e41d03857df08..ddc558435f5b6 100644
--- a/src/Symfony/Component/Mime/Tests/Header/ParameterizedHeaderTest.php
+++ b/src/Symfony/Component/Mime/Tests/Header/ParameterizedHeaderTest.php
@@ -58,6 +58,20 @@ public function testSpaceInParamResultsInQuotedString()
$this->assertEquals('attachment; filename="my file.txt"', $header->getBodyAsString());
}
+ public function testFormDataResultsInQuotedString()
+ {
+ $header = new ParameterizedHeader('Content-Disposition', 'form-data');
+ $header->setParameters(['filename' => 'file.txt']);
+ $this->assertEquals('form-data; filename="file.txt"', $header->getBodyAsString());
+ }
+
+ public function testFormDataUtf8()
+ {
+ $header = new ParameterizedHeader('Content-Disposition', 'form-data');
+ $header->setParameters(['filename' => "déjà%\"\n\r.txt"]);
+ $this->assertEquals('form-data; filename="déjà%%22%0A%0D.txt"', $header->getBodyAsString());
+ }
+
public function testLongParamsAreBrokenIntoMultipleAttributeStrings()
{
/* -- RFC 2231, 3.
diff --git a/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE b/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/AllMySms/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/AmazonSns/LICENSE b/src/Symfony/Component/Notifier/Bridge/AmazonSns/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/AmazonSns/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/AmazonSns/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE b/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Clickatell/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/LICENSE b/src/Symfony/Component/Notifier/Bridge/Discord/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Discord/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Discord/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Esendex/LICENSE b/src/Symfony/Component/Notifier/Bridge/Esendex/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Esendex/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Esendex/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE b/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Expo/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/FakeChat/LICENSE b/src/Symfony/Component/Notifier/Bridge/FakeChat/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/FakeChat/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/FakeChat/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/FakeSms/LICENSE b/src/Symfony/Component/Notifier/Bridge/FakeSms/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/FakeSms/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/FakeSms/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Firebase/LICENSE b/src/Symfony/Component/Notifier/Bridge/Firebase/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Firebase/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Firebase/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/FreeMobile/LICENSE b/src/Symfony/Component/Notifier/Bridge/FreeMobile/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/FreeMobile/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/FreeMobile/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE b/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/GatewayApi/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE b/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Gitter/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/GoogleChat/LICENSE b/src/Symfony/Component/Notifier/Bridge/GoogleChat/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/GoogleChat/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/GoogleChat/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Infobip/LICENSE b/src/Symfony/Component/Notifier/Bridge/Infobip/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Infobip/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Infobip/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE b/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Iqsms/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/LightSms/LICENSE b/src/Symfony/Component/Notifier/Bridge/LightSms/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/LightSms/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/LightSms/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LICENSE b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/LinkedIn/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/LinkedIn/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Mailjet/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mailjet/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Mailjet/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Mailjet/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Mattermost/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mattermost/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/Mattermost/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Mattermost/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/MessageBird/LICENSE b/src/Symfony/Component/Notifier/Bridge/MessageBird/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/MessageBird/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/MessageBird/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/MessageMedia/LICENSE b/src/Symfony/Component/Notifier/Bridge/MessageMedia/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/MessageMedia/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/MessageMedia/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/MicrosoftTeams/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Mobyt/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mobyt/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Mobyt/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Mobyt/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE b/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Octopush/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/OneSignal/LICENSE b/src/Symfony/Component/Notifier/Bridge/OneSignal/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/OneSignal/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/OneSignal/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/OvhCloud/LICENSE b/src/Symfony/Component/Notifier/Bridge/OvhCloud/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/OvhCloud/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/OvhCloud/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/RocketChat/LICENSE b/src/Symfony/Component/Notifier/Bridge/RocketChat/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/RocketChat/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/RocketChat/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Sendinblue/LICENSE b/src/Symfony/Component/Notifier/Bridge/Sendinblue/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Sendinblue/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Sendinblue/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Sinch/LICENSE b/src/Symfony/Component/Notifier/Bridge/Sinch/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Sinch/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Sinch/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE b/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Slack/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Sms77/LICENSE b/src/Symfony/Component/Notifier/Bridge/Sms77/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Sms77/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Sms77/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/SmsBiuras/LICENSE b/src/Symfony/Component/Notifier/Bridge/SmsBiuras/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/SmsBiuras/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/SmsBiuras/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Smsapi/LICENSE b/src/Symfony/Component/Notifier/Bridge/Smsapi/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/Smsapi/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Smsapi/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Smsc/LICENSE b/src/Symfony/Component/Notifier/Bridge/Smsc/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Smsc/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Smsc/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/SpotHit/LICENSE b/src/Symfony/Component/Notifier/Bridge/SpotHit/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/SpotHit/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/SpotHit/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE b/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Telegram/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Telnyx/LICENSE b/src/Symfony/Component/Notifier/Bridge/Telnyx/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Telnyx/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Telnyx/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/TurboSms/LICENSE b/src/Symfony/Component/Notifier/Bridge/TurboSms/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/TurboSms/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/TurboSms/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE b/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Twilio/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/LICENSE b/src/Symfony/Component/Notifier/Bridge/Vonage/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Vonage/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Vonage/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Yunpian/LICENSE b/src/Symfony/Component/Notifier/Bridge/Yunpian/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Notifier/Bridge/Yunpian/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Yunpian/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Bridge/Zulip/LICENSE b/src/Symfony/Component/Notifier/Bridge/Zulip/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Notifier/Bridge/Zulip/LICENSE
+++ b/src/Symfony/Component/Notifier/Bridge/Zulip/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/LICENSE b/src/Symfony/Component/Notifier/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/Notifier/LICENSE
+++ b/src/Symfony/Component/Notifier/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Notifier/Transport.php b/src/Symfony/Component/Notifier/Transport.php
index 7e57bc26d2146..6897f831f58cb 100644
--- a/src/Symfony/Component/Notifier/Transport.php
+++ b/src/Symfony/Component/Notifier/Transport.php
@@ -28,7 +28,7 @@
use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory;
use Symfony\Component\Notifier\Bridge\MessageBird\MessageBirdTransportFactory;
use Symfony\Component\Notifier\Bridge\MessageMedia\MessageMediaTransportFactory;
-use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransport;
+use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory;
use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory;
use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory;
use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory;
@@ -81,7 +81,7 @@ final class Transport
MattermostTransportFactory::class,
MessageBirdTransportFactory::class,
MessageMediaTransportFactory::class,
- MicrosoftTeamsTransport::class,
+ MicrosoftTeamsTransportFactory::class,
MobytTransportFactory::class,
OctopushTransportFactory::class,
OvhCloudTransportFactory::class,
diff --git a/src/Symfony/Component/OptionsResolver/LICENSE b/src/Symfony/Component/OptionsResolver/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/OptionsResolver/LICENSE
+++ b/src/Symfony/Component/OptionsResolver/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/PasswordHasher/LICENSE b/src/Symfony/Component/PasswordHasher/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/PasswordHasher/LICENSE
+++ b/src/Symfony/Component/PasswordHasher/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Process/LICENSE b/src/Symfony/Component/Process/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Process/LICENSE
+++ b/src/Symfony/Component/Process/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Process/Tests/ExecutableFinderTest.php b/src/Symfony/Component/Process/Tests/ExecutableFinderTest.php
index 83f263ff35074..d056841fb79c5 100644
--- a/src/Symfony/Component/Process/Tests/ExecutableFinderTest.php
+++ b/src/Symfony/Component/Process/Tests/ExecutableFinderTest.php
@@ -96,6 +96,9 @@ public function testFindWithExtraDirs()
$this->assertSamePath(\PHP_BINARY, $result);
}
+ /**
+ * @runInSeparateProcess
+ */
public function testFindWithOpenBaseDir()
{
if ('\\' === \DIRECTORY_SEPARATOR) {
@@ -114,6 +117,9 @@ public function testFindWithOpenBaseDir()
$this->assertSamePath(\PHP_BINARY, $result);
}
+ /**
+ * @runInSeparateProcess
+ */
public function testFindProcessInOpenBasedir()
{
if (ini_get('open_basedir')) {
diff --git a/src/Symfony/Component/PropertyAccess/LICENSE b/src/Symfony/Component/PropertyAccess/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/PropertyAccess/LICENSE
+++ b/src/Symfony/Component/PropertyAccess/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/PropertyInfo/LICENSE b/src/Symfony/Component/PropertyInfo/LICENSE
index c9f0202b242b6..4e90b1b5ae4df 100644
--- a/src/Symfony/Component/PropertyInfo/LICENSE
+++ b/src/Symfony/Component/PropertyInfo/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2015-2021 Fabien Potencier
+Copyright (c) 2015-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php b/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php
index a5eb8b47fcfde..1243259607c22 100644
--- a/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php
+++ b/src/Symfony/Component/PropertyInfo/PhpStan/NameScopeFactory.php
@@ -22,21 +22,33 @@ final class NameScopeFactory
{
public function create(string $fullClassName): NameScope
{
+ $reflection = new \ReflectionClass($fullClassName);
$path = explode('\\', $fullClassName);
$className = array_pop($path);
- [$namespace, $uses] = $this->extractFromFullClassName($fullClassName);
+ [$namespace, $uses] = $this->extractFromFullClassName($reflection);
- foreach (class_uses($fullClassName) as $traitFullClassName) {
- [, $traitUses] = $this->extractFromFullClassName($traitFullClassName);
- $uses = array_merge($uses, $traitUses);
- }
+ $uses = array_merge($uses, $this->collectUses($reflection));
return new NameScope($className, $namespace, $uses);
}
- private function extractFromFullClassName(string $fullClassName): array
+ private function collectUses(\ReflectionClass $reflection): array
+ {
+ $uses = [$this->extractFromFullClassName($reflection)[1]];
+
+ foreach ($reflection->getTraits() as $traitReflection) {
+ $uses[] = $this->extractFromFullClassName($traitReflection)[1];
+ }
+
+ if (false !== $parentClass = $reflection->getParentClass()) {
+ $uses[] = $this->collectUses($parentClass);
+ }
+
+ return $uses ? array_merge(...$uses) : [];
+ }
+
+ private function extractFromFullClassName(\ReflectionClass $reflection): array
{
- $reflection = new \ReflectionClass($fullClassName);
$namespace = trim($reflection->getNamespaceName(), '\\');
$fileName = $reflection->getFileName();
diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php
index 01c68216dc026..26c9aa58d6831 100644
--- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php
+++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php
@@ -16,6 +16,7 @@
use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue;
use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
+use Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy\RootDummyItem;
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsedInTrait;
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsingTrait;
use Symfony\Component\PropertyInfo\Type;
@@ -116,6 +117,8 @@ public function typesProvider()
['arrayOfMixed', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_STRING), null)]],
['listOfStrings', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING))]],
['self', [new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]],
+ ['rootDummyItems', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, RootDummyItem::class))]],
+ ['rootDummyItem', [new Type(Type::BUILTIN_TYPE_OBJECT, false, RootDummyItem::class)]],
];
}
@@ -355,7 +358,7 @@ public function constructorTypesProvider()
/**
* @dataProvider unionTypesProvider
*/
- public function testExtractorUnionTypes(string $property, array $types)
+ public function testExtractorUnionTypes(string $property, ?array $types)
{
$this->assertEquals($types, $this->extractor->getTypes('Symfony\Component\PropertyInfo\Tests\Fixtures\DummyUnionType', $property));
}
@@ -368,6 +371,8 @@ public function unionTypesProvider(): array
['c', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]],
['d', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])])]],
['e', [new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class, true, [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])], [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_STRING, false, null, true, [], [new Type(Type::BUILTIN_TYPE_OBJECT, false, DefaultValue::class)])])]), new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)]],
+ ['f', null],
+ ['g', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]],
];
}
diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php
index fdae6f04e3df2..eac817f2060cf 100644
--- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php
+++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php
@@ -75,6 +75,8 @@ public function testGetProperties()
'files',
'propertyTypeStatic',
'parentAnnotationNoParent',
+ 'rootDummyItems',
+ 'rootDummyItem',
'a',
'DOB',
'Id',
@@ -135,6 +137,8 @@ public function testGetPropertiesWithCustomPrefixes()
'files',
'propertyTypeStatic',
'parentAnnotationNoParent',
+ 'rootDummyItems',
+ 'rootDummyItem',
'date',
'c',
'ct',
@@ -184,6 +188,8 @@ public function testGetPropertiesWithNoPrefixes()
'files',
'propertyTypeStatic',
'parentAnnotationNoParent',
+ 'rootDummyItems',
+ 'rootDummyItem',
],
$noPrefixExtractor->getProperties('Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy')
);
diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php
index 60af596bad3b3..86ddb8a1650eb 100644
--- a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php
+++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/DummyUnionType.php
@@ -16,6 +16,9 @@
*/
class DummyUnionType
{
+ private const TYPE_A = 'a';
+ private const TYPE_B = 'b';
+
/**
* @var string|int
*/
@@ -40,4 +43,14 @@ class DummyUnionType
* @var (Dummy, (int | (string)[])> | ParentDummy | null)
*/
public $e;
+
+ /**
+ * @var self::TYPE_*|null
+ */
+ public $f;
+
+ /**
+ * @var non-empty-array
+ */
+ public $g;
}
diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php
index a7c1f513a78c7..4290e1b541a07 100644
--- a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php
+++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/ParentDummy.php
@@ -11,6 +11,8 @@
namespace Symfony\Component\PropertyInfo\Tests\Fixtures;
+use Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy\RootDummyItem;
+
/**
* @author Kévin Dunglas
*/
@@ -58,6 +60,16 @@ class ParentDummy
*/
public $parentAnnotationNoParent;
+ /**
+ * @var RootDummyItem[]
+ */
+ public $rootDummyItems;
+
+ /**
+ * @var \Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy\RootDummyItem
+ */
+ public $rootDummyItem;
+
/**
* @return bool|null
*/
diff --git a/src/Symfony/Component/PropertyInfo/Tests/Fixtures/RootDummy/RootDummyItem.php b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/RootDummy/RootDummyItem.php
new file mode 100644
index 0000000000000..ccbaf7cbf99a2
--- /dev/null
+++ b/src/Symfony/Component/PropertyInfo/Tests/Fixtures/RootDummy/RootDummyItem.php
@@ -0,0 +1,16 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy;
+
+class RootDummyItem
+{
+}
diff --git a/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php b/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php
index 8b618a17bd9c1..d9803fd9bd702 100644
--- a/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php
+++ b/src/Symfony/Component/PropertyInfo/Util/PhpStanTypeHelper.php
@@ -19,6 +19,7 @@
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
+use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
@@ -102,6 +103,10 @@ private function extractTypes(TypeNode $node, NameScope $nameScope): array
if ($node instanceof UnionTypeNode) {
$types = [];
foreach ($node->types as $type) {
+ if ($type instanceof ConstTypeNode) {
+ // It's safer to fall back to other extractors here, as resolving const types correctly is not easy at the moment
+ return [];
+ }
foreach ($this->extractTypes($type, $nameScope) as $subType) {
$types[] = $subType;
}
diff --git a/src/Symfony/Component/RateLimiter/LICENSE b/src/Symfony/Component/RateLimiter/LICENSE
index 3796612f43c2b..7fa9539054928 100644
--- a/src/Symfony/Component/RateLimiter/LICENSE
+++ b/src/Symfony/Component/RateLimiter/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2016-2021 Fabien Potencier
+Copyright (c) 2016-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Routing/LICENSE b/src/Symfony/Component/Routing/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Routing/LICENSE
+++ b/src/Symfony/Component/Routing/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Runtime/LICENSE b/src/Symfony/Component/Runtime/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Runtime/LICENSE
+++ b/src/Symfony/Component/Runtime/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php b/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php
index e589c0f140f7a..6f60970872dd4 100644
--- a/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php
+++ b/src/Symfony/Component/Security/Core/Authentication/Token/AbstractToken.php
@@ -12,6 +12,7 @@
namespace Symfony\Component\Security\Core\Authentication\Token;
use Symfony\Component\Security\Core\User\UserInterface;
+use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* Base class for Token instances.
@@ -114,7 +115,8 @@ public function __serialize(): array
*/
public function __unserialize(array $data): void
{
- [$this->user, , , $this->attributes, $this->roleNames] = $data;
+ [$user, , , $this->attributes, $this->roleNames] = $data;
+ $this->user = \is_string($user) ? new InMemoryUser($user, '', $this->roleNames, false) : $user;
}
/**
diff --git a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
index 19cebceb3b005..1594c1b38cab9 100644
--- a/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
+++ b/src/Symfony/Component/Security/Core/Authorization/AuthorizationChecker.php
@@ -31,7 +31,7 @@ class AuthorizationChecker implements AuthorizationCheckerInterface
public function __construct(TokenStorageInterface $tokenStorage, AccessDecisionManagerInterface $accessDecisionManager, bool $exceptionOnNoToken = false)
{
if ($exceptionOnNoToken) {
- throw new \LogicException('Argument $exceptionOnNoToken of "%s()" must be set to "false".', __METHOD__);
+ throw new \LogicException(sprintf('Argument $exceptionOnNoToken of "%s()" must be set to "false".', __METHOD__));
}
$this->tokenStorage = $tokenStorage;
diff --git a/src/Symfony/Component/Security/Core/LICENSE b/src/Symfony/Component/Security/Core/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Security/Core/LICENSE
+++ b/src/Symfony/Component/Security/Core/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Security/Core/README.md b/src/Symfony/Component/Security/Core/README.md
index b47ab331c82b3..6e31770c4910f 100644
--- a/src/Symfony/Component/Security/Core/README.md
+++ b/src/Symfony/Component/Security/Core/README.md
@@ -3,8 +3,40 @@ Security Component - Core
Security provides an infrastructure for sophisticated authorization systems,
which makes it possible to easily separate the actual authorization logic from
-so called user providers that hold the users credentials. It is inspired by
-the Java Spring framework.
+so called user providers that hold the users credentials.
+
+Getting Started
+---------------
+
+```
+$ composer require symfony/security-core
+```
+
+```php
+use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver;
+use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
+use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
+use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
+use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
+use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Symfony\Component\Security\Core\Role\RoleHierarchy;
+
+$accessDecisionManager = new AccessDecisionManager([
+ new AuthenticatedVoter(new AuthenticationTrustResolver()),
+ new RoleVoter(),
+ new RoleHierarchyVoter(new RoleHierarchy([
+ 'ROLE_ADMIN' => ['ROLE_USER'],
+ ]))
+]);
+
+$user = new \App\Entity\User(...);
+$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
+
+if (!$accessDecisionManager->decide($token, ['ROLE_ADMIN'])) {
+ throw new AccessDeniedException();
+}
+```
Sponsor
-------
diff --git a/src/Symfony/Component/Security/Core/Resources/translations/security.lb.xlf b/src/Symfony/Component/Security/Core/Resources/translations/security.lb.xlf
index 5f707535fa723..36987bc99f37f 100644
--- a/src/Symfony/Component/Security/Core/Resources/translations/security.lb.xlf
+++ b/src/Symfony/Component/Security/Core/Resources/translations/security.lb.xlf
@@ -1,6 +1,6 @@
-
+
An authentication exception occurred.
diff --git a/src/Symfony/Component/Security/Core/Role/Role.php b/src/Symfony/Component/Security/Core/Role/Role.php
new file mode 100644
index 0000000000000..374eb59fe85ca
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Role/Role.php
@@ -0,0 +1,31 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Core\Role;
+
+/**
+ * Allows migrating session payloads from v4.
+ *
+ * @internal
+ */
+class Role
+{
+ private $role;
+
+ private function __construct()
+ {
+ }
+
+ public function __toString(): string
+ {
+ return $this->role;
+ }
+}
diff --git a/src/Symfony/Component/Security/Core/Role/SwitchUserRole.php b/src/Symfony/Component/Security/Core/Role/SwitchUserRole.php
new file mode 100644
index 0000000000000..6a29fb4daa29b
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Role/SwitchUserRole.php
@@ -0,0 +1,23 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Core\Role;
+
+/**
+ * Allows migrating session payloads from v4.
+ *
+ * @internal
+ */
+class SwitchUserRole extends Role
+{
+ private $deprecationTriggered;
+ private $source;
+}
diff --git a/src/Symfony/Component/Security/Core/Tests/Role/LegacyRoleTest.php b/src/Symfony/Component/Security/Core/Tests/Role/LegacyRoleTest.php
new file mode 100644
index 0000000000000..44c9566720b89
--- /dev/null
+++ b/src/Symfony/Component/Security/Core/Tests/Role/LegacyRoleTest.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Core\Tests\Role;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
+
+class LegacyRoleTest extends TestCase
+{
+ public function testPayloadFromV4CanBeUnserialized()
+ {
+ $serialized = 'C:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":236:{a:3:{i:0;N;i:1;s:4:"main";i:2;a:5:{i:0;s:2:"sf";i:1;b:1;i:2;a:1:{i:0;O:41:"Symfony\Component\Security\Core\Role\Role":1:{s:47:"Symfony\Component\Security\Core\Role\Role'."\0".'role'."\0".'";s:9:"ROLE_USER";}}i:3;a:0:{}i:4;a:1:{i:0;s:9:"ROLE_USER";}}}}';
+
+ $token = unserialize($serialized);
+
+ $this->assertInstanceOf(UsernamePasswordToken::class, $token);
+ $this->assertSame(['ROLE_USER'], $token->getRoleNames());
+ }
+}
diff --git a/src/Symfony/Component/Security/Csrf/LICENSE b/src/Symfony/Component/Security/Csrf/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Security/Csrf/LICENSE
+++ b/src/Symfony/Component/Security/Csrf/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php b/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php
index 8234695e23d76..345e8d8b6ace4 100644
--- a/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php
+++ b/src/Symfony/Component/Security/Http/EventListener/CookieClearingLogoutListener.php
@@ -40,7 +40,7 @@ public function onLogout(LogoutEvent $event): void
}
foreach ($this->cookies as $cookieName => $cookieData) {
- $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain']);
+ $response->headers->clearCookie($cookieName, $cookieData['path'], $cookieData['domain'], $cookieData['secure'] ?? false, true, $cookieData['samesite'] ?? null);
}
}
diff --git a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php
index d33f3f265d21c..efa27495b5806 100644
--- a/src/Symfony/Component/Security/Http/Firewall/AccessListener.php
+++ b/src/Symfony/Component/Security/Http/Firewall/AccessListener.php
@@ -37,7 +37,7 @@ class AccessListener extends AbstractListener
public function __construct(TokenStorageInterface $tokenStorage, AccessDecisionManagerInterface $accessDecisionManager, AccessMapInterface $map, bool $exceptionOnNoToken = false)
{
if (false !== $exceptionOnNoToken) {
- throw new \LogicException('Argument $exceptionOnNoToken of "%s()" must be set to "false".', __METHOD__);
+ throw new \LogicException(sprintf('Argument $exceptionOnNoToken of "%s()" must be set to "false".', __METHOD__));
}
$this->tokenStorage = $tokenStorage;
diff --git a/src/Symfony/Component/Security/Http/LICENSE b/src/Symfony/Component/Security/Http/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Security/Http/LICENSE
+++ b/src/Symfony/Component/Security/Http/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php
index c7b079cd13215..6eea997040715 100644
--- a/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php
+++ b/src/Symfony/Component/Security/Http/LoginLink/LoginLinkHandler.php
@@ -46,9 +46,9 @@ public function __construct(UrlGeneratorInterface $urlGenerator, UserProviderInt
public function createLoginLink(UserInterface $user, Request $request = null): LoginLinkDetails
{
- $expiresAt = new \DateTimeImmutable(sprintf('+%d seconds', $this->options['lifetime']));
+ $expires = time() + $this->options['lifetime'];
+ $expiresAt = new \DateTimeImmutable('@'.$expires);
- $expires = $expiresAt->format('U');
$parameters = [
'user' => $user->getUserIdentifier(),
'expires' => $expires,
diff --git a/src/Symfony/Component/Security/Http/README.md b/src/Symfony/Component/Security/Http/README.md
index 594f5adb3aece..91a7583373e68 100644
--- a/src/Symfony/Component/Security/Http/README.md
+++ b/src/Symfony/Component/Security/Http/README.md
@@ -1,10 +1,16 @@
Security Component - HTTP Integration
=====================================
-Security provides an infrastructure for sophisticated authorization systems,
-which makes it possible to easily separate the actual authorization logic from
-so called user providers that hold the users credentials. It is inspired by
-the Java Spring framework.
+The Security HTTP component provides an HTTP integration of the Security Core
+component. It allows securing (parts of) your application using firewalls and
+provides authenticators to authenticate visitors.
+
+Getting Started
+---------------
+
+```
+$ composer require symfony/security-http
+```
Sponsor
-------
diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/CookieClearingLogoutListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/CookieClearingLogoutListenerTest.php
new file mode 100644
index 0000000000000..f4c0e3d89b611
--- /dev/null
+++ b/src/Symfony/Component/Security/Http/Tests/EventListener/CookieClearingLogoutListenerTest.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Security\Http\Tests\EventListener;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Cookie;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\ResponseHeaderBag;
+use Symfony\Component\Security\Http\Event\LogoutEvent;
+use Symfony\Component\Security\Http\EventListener\CookieClearingLogoutListener;
+
+class CookieClearingLogoutListenerTest extends TestCase
+{
+ public function testLogout()
+ {
+ $response = new Response();
+ $event = new LogoutEvent(new Request(), null);
+ $event->setResponse($response);
+
+ $listener = new CookieClearingLogoutListener(['foo' => ['path' => '/foo', 'domain' => 'foo.foo', 'secure' => true, 'samesite' => Cookie::SAMESITE_STRICT], 'foo2' => ['path' => null, 'domain' => null]]);
+
+ $cookies = $response->headers->getCookies();
+ $this->assertCount(0, $cookies);
+
+ $listener->onLogout($event);
+
+ $cookies = $response->headers->getCookies(ResponseHeaderBag::COOKIES_ARRAY);
+ $this->assertCount(2, $cookies);
+
+ $cookie = $cookies['foo.foo']['/foo']['foo'];
+ $this->assertEquals('foo', $cookie->getName());
+ $this->assertEquals('/foo', $cookie->getPath());
+ $this->assertEquals('foo.foo', $cookie->getDomain());
+ $this->assertEquals(Cookie::SAMESITE_STRICT, $cookie->getSameSite());
+ $this->assertTrue($cookie->isSecure());
+ $this->assertTrue($cookie->isCleared());
+
+ $cookie = $cookies['']['/']['foo2'];
+ $this->assertStringStartsWith('foo2', $cookie->getName());
+ $this->assertEquals('/', $cookie->getPath());
+ $this->assertNull($cookie->getDomain());
+ $this->assertNull($cookie->getSameSite());
+ $this->assertFalse($cookie->isSecure());
+ $this->assertTrue($cookie->isCleared());
+ }
+}
diff --git a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php
index 1126edade0915..181454e43ec33 100644
--- a/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php
+++ b/src/Symfony/Component/Security/Http/Tests/Firewall/AccessListenerTest.php
@@ -266,4 +266,18 @@ public function testLazyPublicPagesShouldNotAccessTokenStorage()
$listener = new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, false);
$listener(new LazyResponseEvent(new RequestEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)));
}
+
+ public function testConstructWithTrueExceptionOnNoToken()
+ {
+ $tokenStorage = $this->createMock(TokenStorageInterface::class);
+ $tokenStorage->expects($this->never())->method(self::anything());
+
+ $accessMap = $this->createMock(AccessMapInterface::class);
+
+ $this->expectExceptionObject(
+ new \LogicException('Argument $exceptionOnNoToken of "Symfony\Component\Security\Http\Firewall\AccessListener::__construct()" must be set to "false".')
+ );
+
+ new AccessListener($tokenStorage, $this->createMock(AccessDecisionManagerInterface::class), $accessMap, true);
+ }
}
diff --git a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php
index 1bf2949bcd09c..697584d28b6d7 100644
--- a/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php
+++ b/src/Symfony/Component/Security/Http/Tests/LoginLink/LoginLinkHandlerTest.php
@@ -52,6 +52,7 @@ protected function setUp(): void
}
/**
+ * @group time-sensitive
* @dataProvider provideCreateLoginLinkData
*/
public function testCreateLoginLink($user, array $extraProperties, Request $request = null)
diff --git a/src/Symfony/Component/Semaphore/LICENSE b/src/Symfony/Component/Semaphore/LICENSE
index 3796612f43c2b..7fa9539054928 100644
--- a/src/Symfony/Component/Semaphore/LICENSE
+++ b/src/Symfony/Component/Semaphore/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2016-2021 Fabien Potencier
+Copyright (c) 2016-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md
index c2bbd4b6cff0e..363c3c88c566f 100644
--- a/src/Symfony/Component/Serializer/CHANGELOG.md
+++ b/src/Symfony/Component/Serializer/CHANGELOG.md
@@ -1,6 +1,14 @@
CHANGELOG
=========
+6.1
+---
+
+ * Deprecate `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead
+ * Deprecate `ContextAwareDenormalizerInterface`, use `DenormalizerInterface` instead
+ * Deprecate `ContextAwareEncoderInterface`, use `EncoderInterface` instead
+ * Deprecate `ContextAwareDecoderInterface`, use `DecoderInterface` instead
+
6.0
---
diff --git a/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php b/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php
index 6ac2e38cc4657..910b26bac1fc8 100644
--- a/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php
+++ b/src/Symfony/Component/Serializer/Encoder/ContextAwareDecoderInterface.php
@@ -15,6 +15,8 @@
* Adds the support of an extra $context parameter for the supportsDecoding method.
*
* @author Kévin Dunglas
+ *
+ * @deprecated since symfony/serializer 6.1, use DecoderInterface instead
*/
interface ContextAwareDecoderInterface extends DecoderInterface
{
diff --git a/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php b/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php
index 832b600eeca57..f828f87a4f82f 100644
--- a/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php
+++ b/src/Symfony/Component/Serializer/Encoder/ContextAwareEncoderInterface.php
@@ -15,6 +15,8 @@
* Adds the support of an extra $context parameter for the supportsEncoding method.
*
* @author Kévin Dunglas
+ *
+ * @deprecated since symfony/serializer 6.1, use EncoderInterface instead
*/
interface ContextAwareEncoderInterface extends EncoderInterface
{
diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php
index cee61fa03a680..8a8e47fb82c85 100644
--- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php
+++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php
@@ -123,8 +123,10 @@ public function encode(mixed $data, string $format, array $context = []): string
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
@@ -209,8 +211,10 @@ public function decode(string $data, string $format, array $context = []): mixed
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDecoding(string $format): bool
+ public function supportsDecoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php b/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php
index 84a84ad1f3e69..f38069e471733 100644
--- a/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php
+++ b/src/Symfony/Component/Serializer/Encoder/DecoderInterface.php
@@ -40,8 +40,9 @@ public function decode(string $data, string $format, array $context = []);
* Checks whether the deserializer can decode from given format.
*
* @param string $format Format name
+ * @param array $context Options that decoders have access to
*
* @return bool
*/
- public function supportsDecoding(string $format);
+ public function supportsDecoding(string $format /*, array $context = [] */);
}
diff --git a/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php b/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php
index e0f303b1e3dcd..22da956d22419 100644
--- a/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php
+++ b/src/Symfony/Component/Serializer/Encoder/EncoderInterface.php
@@ -32,7 +32,8 @@ public function encode(mixed $data, string $format, array $context = []): string
/**
* Checks whether the serializer can encode to given format.
*
- * @param string $format Format name
+ * @param string $format Format name
+ * @param array $context Options that normalizers/encoders have access to
*/
- public function supportsEncoding(string $format): bool;
+ public function supportsEncoding(string $format /*, array $context = [] */): bool;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/JsonDecode.php b/src/Symfony/Component/Serializer/Encoder/JsonDecode.php
index ad094afaca161..f0f94f6d7e230 100644
--- a/src/Symfony/Component/Serializer/Encoder/JsonDecode.php
+++ b/src/Symfony/Component/Serializer/Encoder/JsonDecode.php
@@ -95,8 +95,10 @@ public function decode(string $data, string $format, array $context = []): mixed
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDecoding(string $format): bool
+ public function supportsDecoding(string $format /*, array $context = [] */): bool
{
return JsonEncoder::FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/JsonEncode.php b/src/Symfony/Component/Serializer/Encoder/JsonEncode.php
index 23d0fdd960e3e..9a0a9393b0386 100644
--- a/src/Symfony/Component/Serializer/Encoder/JsonEncode.php
+++ b/src/Symfony/Component/Serializer/Encoder/JsonEncode.php
@@ -57,8 +57,10 @@ public function encode(mixed $data, string $format, array $context = []): string
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format /*, array $context = [] */): bool
{
return JsonEncoder::FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php b/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php
index d17ef049285ef..2ce119bcbdde5 100644
--- a/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php
+++ b/src/Symfony/Component/Serializer/Encoder/JsonEncoder.php
@@ -47,16 +47,20 @@ public function decode(string $data, string $format, array $context = []): mixed
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDecoding(string $format): bool
+ public function supportsDecoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php
index 44bfe86308820..e91a2cb3034e6 100644
--- a/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php
+++ b/src/Symfony/Component/Serializer/Encoder/XmlEncoder.php
@@ -174,16 +174,20 @@ public function decode(string $data, string $format, array $context = []): mixed
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDecoding(string $format): bool
+ public function supportsDecoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php
index 64fefee0ee93e..990d0039c091a 100644
--- a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php
+++ b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php
@@ -67,8 +67,10 @@ public function encode(mixed $data, string $format, array $context = []): string
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format;
}
@@ -85,8 +87,10 @@ public function decode(string $data, string $format, array $context = []): mixed
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDecoding(string $format): bool
+ public function supportsDecoding(string $format /*, array $context = [] */): bool
{
return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format;
}
diff --git a/src/Symfony/Component/Serializer/LICENSE b/src/Symfony/Component/Serializer/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Serializer/LICENSE
+++ b/src/Symfony/Component/Serializer/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php
index da0bd48008c2e..c20f1d6fc31ef 100644
--- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php
+++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php
@@ -157,7 +157,21 @@ public function loadAnnotations(object $reflector): iterable
{
foreach ($reflector->getAttributes() as $attribute) {
if ($this->isKnownAttribute($attribute->getName())) {
- yield $attribute->newInstance();
+ try {
+ yield $attribute->newInstance();
+ } catch (\Error $e) {
+ if ($e::class !== \Error::class) {
+ throw $e;
+ }
+ $on = match (true) {
+ $reflector instanceof \ReflectionClass => ' on class '.$reflector->name,
+ $reflector instanceof \ReflectionMethod => sprintf(' on "%s::%s()"', $reflector->getDeclaringClass()->name, $reflector->name),
+ $reflector instanceof \ReflectionProperty => sprintf(' on "%s::$%s"', $reflector->getDeclaringClass()->name, $reflector->name),
+ default => '',
+ };
+
+ throw new MappingException(sprintf('Could not instantiate attribute "%s"%s.', $attribute->getName(), $on), 0, $e);
+ }
}
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php
index 3e1e7edc8e117..a8943113c4291 100644
--- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php
@@ -134,8 +134,10 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null)
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */)
{
return \is_object($data) && !$data instanceof \Traversable;
}
@@ -349,8 +351,10 @@ abstract protected function getAttributeValue(object $object, string $attribute,
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null)
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */)
{
return class_exists($type) || (interface_exists($type, false) && $this->classDiscriminatorResolver && null !== $this->classDiscriminatorResolver->getMappingForClass($type));
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php
index ba17cae4ace25..859a09362d3f0 100644
--- a/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/BackedEnumNormalizer.php
@@ -37,7 +37,7 @@ public function normalize($object, $format = null, array $context = []): int|str
/**
* {@inheritdoc}
*/
- public function supportsNormalization($data, $format = null): bool
+ public function supportsNormalization($data, $format = null, array $context = []): bool
{
return $data instanceof \BackedEnum;
}
@@ -67,7 +67,7 @@ public function denormalize($data, $type, $format = null, array $context = []):
/**
* {@inheritdoc}
*/
- public function supportsDenormalization($data, $type, $format = null): bool
+ public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
{
return is_subclass_of($type, \BackedEnum::class);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php
index fcedfa531f1ee..2ac3c3681c94f 100644
--- a/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php
@@ -106,8 +106,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof ConstraintViolationListInterface;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/ContextAwareDenormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/ContextAwareDenormalizerInterface.php
index 991db4470a0d6..38c07a2682c92 100644
--- a/src/Symfony/Component/Serializer/Normalizer/ContextAwareDenormalizerInterface.php
+++ b/src/Symfony/Component/Serializer/Normalizer/ContextAwareDenormalizerInterface.php
@@ -15,6 +15,8 @@
* Adds the support of an extra $context parameter for the supportsDenormalization method.
*
* @author Kévin Dunglas
+ *
+ * @deprecated since symfony/serializer 6.1, use DenormalizerInterface instead
*/
interface ContextAwareDenormalizerInterface extends DenormalizerInterface
{
diff --git a/src/Symfony/Component/Serializer/Normalizer/ContextAwareNormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/ContextAwareNormalizerInterface.php
index eb28a7048aaba..6f85225bd3487 100644
--- a/src/Symfony/Component/Serializer/Normalizer/ContextAwareNormalizerInterface.php
+++ b/src/Symfony/Component/Serializer/Normalizer/ContextAwareNormalizerInterface.php
@@ -15,6 +15,8 @@
* Adds the support of an extra $context parameter for the supportsNormalization method.
*
* @author Kévin Dunglas
+ *
+ * @deprecated since symfony/serializer 6.1, use NormalizerInterface instead
*/
interface ContextAwareNormalizerInterface extends NormalizerInterface
{
diff --git a/src/Symfony/Component/Serializer/Normalizer/CustomNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/CustomNormalizer.php
index e9e6f3d1a9dad..d12361d50a10c 100644
--- a/src/Symfony/Component/Serializer/Normalizer/CustomNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/CustomNormalizer.php
@@ -44,10 +44,11 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* Checks if the given class implements the NormalizableInterface.
*
- * @param mixed $data Data to normalize
- * @param string $format The format being (de-)serialized from or into
+ * @param mixed $data Data to normalize
+ * @param string $format The format being (de-)serialized from or into
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof NormalizableInterface;
}
@@ -55,11 +56,12 @@ public function supportsNormalization(mixed $data, string $format = null): bool
/**
* Checks if the given class implements the DenormalizableInterface.
*
- * @param mixed $data Data to denormalize from
- * @param string $type The class to which the data should be denormalized
- * @param string $format The format being deserialized from
+ * @param mixed $data Data to denormalize from
+ * @param string $type The class to which the data should be denormalized
+ * @param string $format The format being deserialized from
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return is_subclass_of($type, DenormalizableInterface::class);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php
index 93ad3d905ccb8..675b9a13f04bb 100644
--- a/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/DataUriNormalizer.php
@@ -73,8 +73,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof \SplFileInfo;
}
@@ -117,8 +119,10 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return isset(self::SUPPORTED_TYPES[$type]);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php
index db1117500b5b3..9a7aa04968724 100644
--- a/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/DateIntervalNormalizer.php
@@ -49,8 +49,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof \DateInterval;
}
@@ -117,8 +119,10 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return \DateInterval::class === $type;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php
index da4f503eb53c2..ea7e30f9e2cb0 100644
--- a/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php
@@ -71,8 +71,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof \DateTimeInterface;
}
@@ -112,8 +114,10 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return isset(self::SUPPORTED_TYPES[$type]);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php
index 67ff3d92b3fc7..89adcb56f833a 100644
--- a/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/DateTimeZoneNormalizer.php
@@ -38,8 +38,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof \DateTimeZone;
}
@@ -64,8 +66,10 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return \DateTimeZone::class === $type;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php
index 5e94400b80006..1c708738a1565 100644
--- a/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php
+++ b/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php
@@ -49,11 +49,12 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* Checks whether the given class is supported for denormalization by this normalizer.
*
- * @param mixed $data Data to denormalize from
- * @param string $type The class to which the data should be denormalized
- * @param string $format The format being deserialized from
+ * @param mixed $data Data to denormalize from
+ * @param string $type The class to which the data should be denormalized
+ * @param string $format The format being deserialized from
+ * @param array $context Options available to the denormalizer
*
* @return bool
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null);
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/FormErrorNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/FormErrorNormalizer.php
index 8bd8a845fc793..0ffa9f072a2c1 100644
--- a/src/Symfony/Component/Serializer/Normalizer/FormErrorNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/FormErrorNormalizer.php
@@ -44,7 +44,7 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return $data instanceof FormInterface && $data->isSubmitted() && !$data->isValid();
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/GetSetMethodNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/GetSetMethodNormalizer.php
index bb8b79c2e439f..7e42144f69ff2 100644
--- a/src/Symfony/Component/Serializer/Normalizer/GetSetMethodNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/GetSetMethodNormalizer.php
@@ -38,16 +38,20 @@ class GetSetMethodNormalizer extends AbstractObjectNormalizer
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return parent::supportsNormalization($data, $format) && $this->supports(\get_class($data));
}
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return parent::supportsDenormalization($data, $type, $format) && $this->supports($type);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/JsonSerializableNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/JsonSerializableNormalizer.php
index 2bf4319118581..5560ea9166120 100644
--- a/src/Symfony/Component/Serializer/Normalizer/JsonSerializableNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/JsonSerializableNormalizer.php
@@ -43,16 +43,20 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof \JsonSerializable;
}
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return false;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php
index f421d99e18e51..7c195bf3021c9 100644
--- a/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php
@@ -100,7 +100,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return $data instanceof Message || $data instanceof Headers || $data instanceof HeaderInterface || $data instanceof Address || $data instanceof AbstractPart;
}
@@ -108,7 +108,7 @@ public function supportsNormalization(mixed $data, string $format = null): bool
/**
* {@inheritdoc}
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
{
return is_a($type, Message::class, true) || Headers::class === $type || AbstractPart::class === $type;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php
index 30eeafb47bac7..741f19e50b306 100644
--- a/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php
+++ b/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php
@@ -41,10 +41,11 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* Checks whether the given class is supported for normalization by this normalizer.
*
- * @param mixed $data Data to normalize
- * @param string $format The format being (de-)serialized from or into
+ * @param mixed $data Data to normalize
+ * @param string $format The format being (de-)serialized from or into
+ * @param array $context Context options for the normalizer
*
* @return bool
*/
- public function supportsNormalization(mixed $data, string $format = null);
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php
index 533b24e65bcc9..f7609945f7ee9 100644
--- a/src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php
@@ -64,8 +64,10 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return $data instanceof FlattenException;
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php
index c85cfd36d0d11..dda4246b01eb8 100644
--- a/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php
@@ -34,16 +34,20 @@ class PropertyNormalizer extends AbstractObjectNormalizer
{
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null /*, array $context = [] */): bool
{
return parent::supportsNormalization($data, $format) && $this->supports(\get_class($data));
}
/**
* {@inheritdoc}
+ *
+ * @param array $context
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null /*, array $context = [] */): bool
{
return parent::supportsDenormalization($data, $type, $format) && $this->supports($type);
}
diff --git a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php
index 0be920b7fe8bc..889999031939c 100644
--- a/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php
+++ b/src/Symfony/Component/Serializer/Normalizer/UidNormalizer.php
@@ -59,7 +59,7 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return $data instanceof AbstractUid;
}
@@ -71,10 +71,8 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
{
try {
return AbstractUid::class !== $type ? $type::fromString($data) : Uuid::fromString($data);
- } catch (\InvalidArgumentException $exception) {
- throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
- } catch (\TypeError $exception) {
- throw NotNormalizableValueException::createForUnexpectedDataType('The data is not a valid UUID string representation.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
+ } catch (\InvalidArgumentException|\TypeError $exception) {
+ throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The data is not a valid "%s" string representation.', $type), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true);
} catch (\Error $e) {
if (str_starts_with($e->getMessage(), 'Cannot instantiate abstract class')) {
return $this->denormalize($data, AbstractUid::class, $format, $context);
@@ -87,7 +85,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
{
return is_a($type, AbstractUid::class, true);
}
diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php
index f447c684a39eb..6f999f612ba19 100644
--- a/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php
+++ b/src/Symfony/Component/Serializer/Tests/Encoder/ChainEncoderTest.php
@@ -90,7 +90,7 @@ public function testNeedsNormalizationNormalizationAware()
class NormalizationAwareEncoder implements EncoderInterface, NormalizationAwareInterface
{
- public function supportsEncoding(string $format): bool
+ public function supportsEncoding(string $format, array $context = []): bool
{
return true;
}
diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/AbstractNormalizerDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/AbstractNormalizerDummy.php
index 5fb5ba3d38c0c..afdfdec1604ee 100644
--- a/src/Symfony/Component/Serializer/Tests/Fixtures/AbstractNormalizerDummy.php
+++ b/src/Symfony/Component/Serializer/Tests/Fixtures/AbstractNormalizerDummy.php
@@ -38,7 +38,7 @@ public function normalize(mixed $object, string $format = null, array $context =
/**
* {@inheritdoc}
*/
- public function supportsNormalization(mixed $data, string $format = null): bool
+ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
return true;
}
@@ -53,7 +53,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
/**
* {@inheritdoc}
*/
- public function supportsDenormalization(mixed $data, string $type, string $format = null): bool
+ public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
{
return true;
}
diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadAttributeDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadAttributeDummy.php
new file mode 100644
index 0000000000000..a6bd829152484
--- /dev/null
+++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadAttributeDummy.php
@@ -0,0 +1,23 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class BadAttributeDummy extends ContextDummyParent
+{
+ #[Groups(['bar'])]
+ #[Groups(['foo'])]
+ public function myMethod()
+ {
+ }
+}
diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php
index a135bfdaab16f..9245e1dcdee38 100644
--- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php
+++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php
@@ -28,10 +28,7 @@ abstract class AnnotationLoaderTest extends TestCase
{
use ContextMappingTestTrait;
- /**
- * @var AnnotationLoader
- */
- private $loader;
+ protected AnnotationLoader $loader;
protected function setUp(): void
{
diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php
index b0db50a9a9cf8..0983620b8bbe6 100644
--- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php
+++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php
@@ -11,6 +11,8 @@
namespace Symfony\Component\Serializer\Tests\Mapping\Loader;
+use Symfony\Component\Serializer\Exception\MappingException;
+use Symfony\Component\Serializer\Mapping\ClassMetadata;
use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader;
class AnnotationLoaderWithAttributesTest extends AnnotationLoaderTest
@@ -24,4 +26,14 @@ protected function getNamespace(): string
{
return 'Symfony\Component\Serializer\Tests\Fixtures\Attributes';
}
+
+ public function testLoadWithInvalidAttribute()
+ {
+ $this->expectException(MappingException::class);
+ $this->expectExceptionMessage('Could not instantiate attribute "Symfony\Component\Serializer\Annotation\Groups" on "Symfony\Component\Serializer\Tests\Fixtures\Attributes\BadAttributeDummy::myMethod()".');
+
+ $classMetadata = new ClassMetadata($this->getNamespace().'\BadAttributeDummy');
+
+ $this->loader->loadClassMetadata($classMetadata);
+ }
}
diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php
index b93671aaf9c44..b2f4a08ed0a78 100644
--- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php
+++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php
@@ -535,7 +535,7 @@ public function denormalize($data, string $type, string $format = null, array $c
return null;
}
- public function supportsDenormalization($data, string $type, string $format = null): bool
+ public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
{
return true;
}
diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php
index 3a9a69d29b152..f7aba27286233 100644
--- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php
+++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php
@@ -12,8 +12,10 @@
namespace Symfony\Component\Serializer\Tests\Normalizer;
use Doctrine\Common\Annotations\AnnotationReader;
+use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
+use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Exception\LogicException;
@@ -715,6 +717,22 @@ public function testAcceptJsonNumber()
$this->assertSame(10.0, $serializer->denormalize(['number' => 10], JsonNumber::class, 'jsonld')->number);
}
+ public function testDoesntHaveIssuesWithUnionConstTypes()
+ {
+ if (!class_exists(PhpStanExtractor::class) || !class_exists(PhpDocParser::class)) {
+ $this->markTestSkipped('phpstan/phpdoc-parser required for this test');
+ }
+
+ $extractor = new PropertyInfoExtractor([], [new PhpStanExtractor(), new PhpDocExtractor(), new ReflectionExtractor()]);
+ $normalizer = new ObjectNormalizer(null, null, null, $extractor);
+ $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]);
+
+ $this->assertSame('bar', $serializer->denormalize(['foo' => 'bar'], \get_class(new class() {
+ /** @var self::*|null */
+ public $foo;
+ }))->foo);
+ }
+
public function testExtractAttributesRespectsFormat()
{
$normalizer = new FormatAndContextAwareNormalizer();
diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/TestDenormalizer.php b/src/Symfony/Component/Serializer/Tests/Normalizer/TestDenormalizer.php
index 56398232a9e1e..cef09715d9ede 100644
--- a/src/Symfony/Component/Serializer/Tests/Normalizer/TestDenormalizer.php
+++ b/src/Symfony/Component/Serializer/Tests/Normalizer/TestDenormalizer.php
@@ -30,7 +30,7 @@ public function denormalize($data, string $type, string $format = null, array $c
/**
* {@inheritdoc}
*/
- public function supportsDenormalization($data, string $type, string $format = null): bool
+ public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
{
return true;
}
diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/TestNormalizer.php b/src/Symfony/Component/Serializer/Tests/Normalizer/TestNormalizer.php
index bf1f8f725f401..f3b604bfe063f 100644
--- a/src/Symfony/Component/Serializer/Tests/Normalizer/TestNormalizer.php
+++ b/src/Symfony/Component/Serializer/Tests/Normalizer/TestNormalizer.php
@@ -31,7 +31,7 @@ public function normalize($object, string $format = null, array $context = []):
/**
* {@inheritdoc}
*/
- public function supportsNormalization($data, string $format = null): bool
+ public function supportsNormalization($data, string $format = null, array $context = []): bool
{
return true;
}
diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php
index d62581cd8e6d1..a2ebeae59ab1c 100644
--- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php
+++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php
@@ -883,7 +883,7 @@ public function testCollectDenormalizationErrors()
],
'path' => 'uuid',
'useMessageForUser' => true,
- 'message' => 'The data is not a valid UUID string representation.',
+ 'message' => 'The data is not a valid "Symfony\Component\Uid\Uuid" string representation.',
],
[
'currentType' => 'null',
diff --git a/src/Symfony/Component/Stopwatch/LICENSE b/src/Symfony/Component/Stopwatch/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Stopwatch/LICENSE
+++ b/src/Symfony/Component/Stopwatch/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/String/LICENSE b/src/Symfony/Component/String/LICENSE
index 383e7a54586e7..9c907a46a6218 100644
--- a/src/Symfony/Component/String/LICENSE
+++ b/src/Symfony/Component/String/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2019-2021 Fabien Potencier
+Copyright (c) 2019-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Templating/LICENSE b/src/Symfony/Component/Templating/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Templating/LICENSE
+++ b/src/Symfony/Component/Templating/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE b/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE
+++ b/src/Symfony/Component/Translation/Bridge/Crowdin/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LICENSE b/src/Symfony/Component/Translation/Bridge/Loco/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Translation/Bridge/Loco/LICENSE
+++ b/src/Symfony/Component/Translation/Bridge/Loco/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php
index 8fe7162570613..6941551d8ca86 100644
--- a/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php
+++ b/src/Symfony/Component/Translation/Bridge/Loco/LocoProvider.php
@@ -79,9 +79,14 @@ public function write(TranslatorBagInterface $translatorBag): void
$keysIdsMap[$this->retrieveKeyFromId($id, $domain)] = $id;
}
- $ids = array_intersect_key($keysIdsMap, $messages);
+ $assets = [];
+ foreach ($keysIdsMap as $key => $id) {
+ if (isset($messages[$key])) {
+ $assets[$id] = $messages[$key];
+ }
+ }
- $this->translateAssets(array_combine(array_values($ids), array_values($messages)), $locale);
+ $this->translateAssets($assets, $locale);
}
}
}
@@ -138,7 +143,7 @@ public function delete(TranslatorBagInterface $translatorBag): void
foreach (array_keys($catalogue->all()) as $domain) {
foreach ($this->getAssetsIds($domain) as $id) {
- $responses[$id] = $this->client->request('DELETE', sprintf('assets/%s.json', $id));
+ $responses[$id] = $this->client->request('DELETE', sprintf('assets/%s.json', rawurlencode($id)));
}
}
@@ -200,7 +205,7 @@ private function translateAssets(array $translations, string $locale): void
$responses = [];
foreach ($translations as $id => $message) {
- $responses[$id] = $this->client->request('POST', sprintf('translations/%s/%s', $id, $locale), [
+ $responses[$id] = $this->client->request('POST', sprintf('translations/%s/%s', rawurlencode($id), rawurlencode($locale)), [
'body' => $message,
]);
}
@@ -218,13 +223,35 @@ private function tagsAssets(array $ids, string $tag): void
$this->createTag($tag);
}
- $response = $this->client->request('POST', sprintf('tags/%s.json', $tag), [
- 'body' => implode(',', $ids),
+ // Separate ids with and without comma.
+ $idsWithComma = $idsWithoutComma = [];
+ foreach ($ids as $id) {
+ if (false !== strpos($id, ',')) {
+ $idsWithComma[] = $id;
+ } else {
+ $idsWithoutComma[] = $id;
+ }
+ }
+
+ // Set tags for all ids without comma.
+ $response = $this->client->request('POST', sprintf('tags/%s.json', rawurlencode($tag)), [
+ 'body' => implode(',', $idsWithoutComma),
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to tag assets with "%s" on Loco: "%s".', $tag, $response->getContent(false)));
}
+
+ // Set tags for each id with comma one by one.
+ foreach ($idsWithComma as $id) {
+ $response = $this->client->request('POST', sprintf('assets/%s/tags', rawurlencode($id)), [
+ 'body' => ['name' => $tag],
+ ]);
+
+ if (200 !== $response->getStatusCode()) {
+ $this->logger->error(sprintf('Unable to tag asset "%s" with "%s" on Loco: "%s".', $id, $tag, $response->getContent(false)));
+ }
+ }
}
private function createTag(string $tag): void
diff --git a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php
index 2a2183abf110f..5b224de8aa1be 100644
--- a/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php
+++ b/src/Symfony/Component/Translation/Bridge/Loco/Tests/LocoProviderTest.php
@@ -426,12 +426,16 @@ public function getResponsesForManyLocalesAndManyDomains(): \Generator
$expectedTranslatorBag = new TranslatorBag();
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'index.hello' => 'Hello',
- 'index.greetings' => 'Welcome, {firstname}!',
], 'en'));
+ $expectedTranslatorBag->addCatalogue($arrayLoader->load([
+ 'index.greetings' => 'Welcome, {firstname}!',
+ ], 'en', 'messages+intl-icu'));
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'index.hello' => 'Bonjour',
- 'index.greetings' => 'Bienvenue, {firstname} !',
], 'fr'));
+ $expectedTranslatorBag->addCatalogue($arrayLoader->load([
+ 'index.greetings' => 'Bienvenue, {firstname} !',
+ ], 'fr', 'messages+intl-icu'));
$expectedTranslatorBag->addCatalogue($arrayLoader->load([
'firstname.error' => 'Firstname must contains only letters.',
'lastname.error' => 'Lastname must contains only letters.',
@@ -443,7 +447,7 @@ public function getResponsesForManyLocalesAndManyDomains(): \Generator
yield [
['en', 'fr'],
- ['messages', 'validators'],
+ ['messages', 'messages+intl-icu', 'validators'],
[
'en' => [
'messages' => <<<'XLIFF'
@@ -458,6 +462,19 @@ public function getResponsesForManyLocalesAndManyDomains(): \Generator
index.hello
Hello
+
+
+
+XLIFF
+ ,
+ 'messages+intl-icu' => <<<'XLIFF'
+
+
+
+
+
index.greetings
Welcome, {firstname}!
@@ -502,6 +519,19 @@ public function getResponsesForManyLocalesAndManyDomains(): \Generator
index.hello
Bonjour
+
+
+
+XLIFF
+ ,
+ 'messages+intl-icu' => <<<'XLIFF'
+
+
+
+
+
index.greetings
Bienvenue, {firstname} !
diff --git a/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE b/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE
index efb17f98e7dd3..48d17c4fb34f1 100644
--- a/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE
+++ b/src/Symfony/Component/Translation/Bridge/Lokalise/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2021 Fabien Potencier
+Copyright (c) 2021-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php
index 92a08d3017fa7..43a52fab20029 100644
--- a/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php
+++ b/src/Symfony/Component/Translation/Catalogue/AbstractOperation.php
@@ -83,7 +83,18 @@ public function __construct(MessageCatalogueInterface $source, MessageCatalogueI
public function getDomains(): array
{
if (null === $this->domains) {
- $this->domains = array_values(array_unique(array_merge($this->source->getDomains(), $this->target->getDomains())));
+ $domains = [];
+ foreach ([$this->source, $this->target] as $catalogue) {
+ foreach ($catalogue->getDomains() as $domain) {
+ $domains[$domain] = $domain;
+
+ if ($catalogue->all($domainIcu = $domain.MessageCatalogueInterface::INTL_DOMAIN_SUFFIX)) {
+ $domains[$domainIcu] = $domainIcu;
+ }
+ }
+ }
+
+ $this->domains = array_values($domains);
}
return $this->domains;
diff --git a/src/Symfony/Component/Translation/Command/TranslationPullCommand.php b/src/Symfony/Component/Translation/Command/TranslationPullCommand.php
index 0e055b45a60f7..9f064ab37b822 100644
--- a/src/Symfony/Component/Translation/Command/TranslationPullCommand.php
+++ b/src/Symfony/Component/Translation/Command/TranslationPullCommand.php
@@ -110,7 +110,7 @@ protected function configure()
Full example:
- php %command.full_name% provider --force --domains=messages,validators --locales=en>
+ php %command.full_name% provider --force --domains=messages --domains=validators --locales=en>
This command pulls all translations associated with the messages> and validators> domains for the en> locale.
Local translations for the specified domains and locale are deleted if they're not present on the provider and overwritten if it's the case.
diff --git a/src/Symfony/Component/Translation/Command/TranslationPushCommand.php b/src/Symfony/Component/Translation/Command/TranslationPushCommand.php
index 489040906d7ae..7c9f360f288aa 100644
--- a/src/Symfony/Component/Translation/Command/TranslationPushCommand.php
+++ b/src/Symfony/Component/Translation/Command/TranslationPushCommand.php
@@ -102,7 +102,7 @@ protected function configure()
Full example:
- php %command.full_name% provider --force --delete-missing --domains=messages,validators --locales=en>
+ php %command.full_name% provider --force --delete-missing --domains=messages --domains=validators --locales=en>
This command pushes all translations associated with the messages> and validators> domains for the en> locale.
Provider translations for the specified domains and locale are deleted if they're not present locally and overwritten if it's the case.
diff --git a/src/Symfony/Component/Translation/LICENSE b/src/Symfony/Component/Translation/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Translation/LICENSE
+++ b/src/Symfony/Component/Translation/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php b/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php
index 240c492800acc..3f21abac9dd52 100644
--- a/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php
+++ b/src/Symfony/Component/Translation/Tests/Catalogue/MergeOperationTest.php
@@ -58,7 +58,7 @@ public function testGetResultFromIntlDomain()
$this->assertEquals(
new MessageCatalogue('en', [
'messages' => ['a' => 'old_a', 'b' => 'old_b'],
- 'messages+intl-icu' => ['d' => 'old_d', 'c' => 'new_c'],
+ 'messages+intl-icu' => ['d' => 'old_d', 'c' => 'new_c', 'a' => 'new_a'],
]),
$this->createOperation(
new MessageCatalogue('en', ['messages' => ['a' => 'old_a', 'b' => 'old_b'], 'messages+intl-icu' => ['d' => 'old_d']]),
diff --git a/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php b/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php
index d5441f3bee4ef..2b63cd4166464 100644
--- a/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php
+++ b/src/Symfony/Component/Translation/Tests/Catalogue/TargetOperationTest.php
@@ -72,6 +72,7 @@ public function testGetResultWithMixedDomains()
$this->assertEquals(
new MessageCatalogue('en', [
'messages' => ['a' => 'old_a'],
+ 'messages+intl-icu' => ['a' => 'new_a'],
]),
$this->createOperation(
new MessageCatalogue('en', ['messages' => ['a' => 'old_a']]),
@@ -103,7 +104,7 @@ public function testGetResultWithMixedDomains()
$this->assertEquals(
new MessageCatalogue('en', [
'messages' => ['a' => 'old_a'],
- 'messages+intl-icu' => ['b' => 'new_b'],
+ 'messages+intl-icu' => ['b' => 'new_b', 'a' => 'new_a'],
]),
$this->createOperation(
new MessageCatalogue('en', ['messages' => ['a' => 'old_a']]),
diff --git a/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php b/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php
index e5726f266c77d..c002fc7532b1f 100644
--- a/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php
+++ b/src/Symfony/Component/Translation/Tests/Command/TranslationPullCommandTest.php
@@ -47,19 +47,27 @@ public function testPullNewXlf12Messages()
{
$arrayLoader = new ArrayLoader();
$filenameEn = $this->createFile();
+ $filenameEnIcu = $this->createFile(['say_hello' => 'Welcome, {firstname}!'], 'en', 'messages+intl-icu.%locale%.xlf');
$filenameFr = $this->createFile(['note' => 'NOTE'], 'fr');
+ $filenameFrIcu = $this->createFile(['say_hello' => 'Bonjour, {firstname}!'], 'fr', 'messages+intl-icu.%locale%.xlf');
$locales = ['en', 'fr'];
- $domains = ['messages'];
+ $domains = ['messages', 'messages+intl-icu'];
$providerReadTranslatorBag = new TranslatorBag();
$providerReadTranslatorBag->addCatalogue($arrayLoader->load([
'note' => 'NOTE',
'new.foo' => 'newFoo',
], 'en'));
+ $providerReadTranslatorBag->addCatalogue($arrayLoader->load([
+ 'say_hello' => 'Welcome, {firstname}!',
+ ], 'en', 'messages+intl-icu'));
$providerReadTranslatorBag->addCatalogue($arrayLoader->load([
'note' => 'NOTE',
'new.foo' => 'nouveauFoo',
], 'fr'));
+ $providerReadTranslatorBag->addCatalogue($arrayLoader->load([
+ 'say_hello' => 'Bonjour, {firstname}!',
+ ], 'fr', 'messages+intl-icu'));
$provider = $this->createMock(ProviderInterface::class);
$provider->expects($this->once())
@@ -72,9 +80,9 @@ public function testPullNewXlf12Messages()
->willReturn('null://default');
$tester = $this->createCommandTester($provider, $locales, $domains);
- $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages']]);
+ $tester->execute(['--locales' => ['en', 'fr'], '--domains' => ['messages', 'messages+intl-icu']]);
- $this->assertStringContainsString('[OK] New translations from "null" has been written locally (for "en, fr" locale(s), and "messages" domain(s)).', trim($tester->getDisplay()));
+ $this->assertStringContainsString('[OK] New translations from "null" has been written locally (for "en, fr" locale(s), and "messages, messages+intl-icu"', trim($tester->getDisplay()));
$this->assertXmlStringEqualsXmlString(<<
@@ -98,6 +106,23 @@ public function testPullNewXlf12Messages()
, file_get_contents($filenameEn));
$this->assertXmlStringEqualsXmlString(<<
+
+
+
+
+
+ say_hello
+ Welcome, {firstname}!
+
+
+
+
+XLIFF
+ , file_get_contents($filenameEnIcu));
+ $this->assertXmlStringEqualsXmlString(<<
@@ -117,6 +142,23 @@ public function testPullNewXlf12Messages()
XLIFF
, file_get_contents($filenameFr));
+ $this->assertXmlStringEqualsXmlString(<<
+
+
+
+
+
+ say_hello
+ Bonjour, {firstname}!
+
+
+
+
+XLIFF
+ , file_get_contents($filenameFrIcu));
}
public function testPullNewXlf20Messages()
diff --git a/src/Symfony/Component/Uid/LICENSE b/src/Symfony/Component/Uid/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Component/Uid/LICENSE
+++ b/src/Symfony/Component/Uid/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Validator/Constraint.php b/src/Symfony/Component/Validator/Constraint.php
index d6dcdf178f421..46432f2f4c60a 100644
--- a/src/Symfony/Component/Validator/Constraint.php
+++ b/src/Symfony/Component/Validator/Constraint.php
@@ -224,6 +224,10 @@ public function __isset(string $option): bool
*/
public function addImplicitGroupName(string $group)
{
+ if (null === $this->groups && \array_key_exists('groups', (array) $this)) {
+ throw new \LogicException(sprintf('"%s::$groups" is set to null. Did you forget to call "%s::__construct()"?', static::class, self::class));
+ }
+
if (\in_array(self::DEFAULT_GROUP, $this->groups) && !\in_array($group, $this->groups)) {
$this->groups[] = $group;
}
diff --git a/src/Symfony/Component/Validator/Constraints/CssColor.php b/src/Symfony/Component/Validator/Constraints/CssColor.php
index 19fcd000de228..e1510dafe38f2 100644
--- a/src/Symfony/Component/Validator/Constraints/CssColor.php
+++ b/src/Symfony/Component/Validator/Constraints/CssColor.php
@@ -72,7 +72,7 @@ public function __construct($formats = [], string $message = null, array $groups
if (!$formats) {
$options['value'] = self::$validationModes;
} elseif (\is_array($formats) && \is_string(key($formats))) {
- $options = array_merge($formats, $options);
+ $options = array_merge($formats, $options ?? []);
} elseif (\is_array($formats)) {
if ([] === array_intersect(self::$validationModes, $formats)) {
throw new InvalidArgumentException(sprintf('The "formats" parameter value is not valid. It must contain one or more of the following values: "%s".', $validationModesAsString));
diff --git a/src/Symfony/Component/Validator/LICENSE b/src/Symfony/Component/Validator/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Validator/LICENSE
+++ b/src/Symfony/Component/Validator/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf
index bc03a0a3dc99e..92127773178e7 100644
--- a/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf
+++ b/src/Symfony/Component/Validator/Resources/translations/validators.fr.xlf
@@ -192,7 +192,7 @@
No temporary folder was configured in php.ini.
- Aucun répertoire temporaire n'a été configuré dans le php.ini.
+ Aucun répertoire temporaire n'a été configuré dans le php.ini, ou le répertoire configuré n'existe pas.
Cannot write temporary file to disk.
diff --git a/src/Symfony/Component/Validator/Resources/translations/validators.gl.xlf b/src/Symfony/Component/Validator/Resources/translations/validators.gl.xlf
index 433236d789066..f8c5c0493f731 100644
--- a/src/Symfony/Component/Validator/Resources/translations/validators.gl.xlf
+++ b/src/Symfony/Component/Validator/Resources/translations/validators.gl.xlf
@@ -192,7 +192,7 @@
No temporary folder was configured in php.ini.
- Ningunha carpeta temporal foi configurada en php.ini.
+ Ningunha carpeta temporal foi configurada en php.ini, ou a carpeta non existe.
Cannot write temporary file to disk.
@@ -364,7 +364,7 @@
This value should be between {{ min }} and {{ max }}.
- Este valor debe estar comprendido entre {{min}} e {{max}}.
+ Este valor debe estar comprendido entre {{ min }} e {{ max }}.
This value is not a valid hostname.
@@ -394,6 +394,14 @@
This value is not a valid CSS color.
Este valor non é unha cor CSS válida.
+
+ This value is not a valid CIDR notation.
+ Este valor non ten unha notación CIDR válida.
+
+
+ The value of the netmask should be between {{ min }} and {{ max }}.
+ O valor da máscara de rede debería estar entre {{ min }} e {{ max }}.
+
diff --git a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php
index 9c950a829b10b..73a792b10cdf1 100644
--- a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php
+++ b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php
@@ -60,6 +60,7 @@ abstract class ConstraintValidatorTestCase extends TestCase
protected $propertyPath;
protected $constraint;
protected $defaultTimezone;
+ private string $defaultLocale;
private array $expectedViolations;
private int $call;
@@ -80,17 +81,20 @@ protected function setUp(): void
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
+ $this->defaultLocale = \Locale::getDefault();
+ \Locale::setDefault('en');
+
$this->expectedViolations = [];
$this->call = 0;
- \Locale::setDefault('en');
-
$this->setDefaultTimezone('UTC');
}
protected function tearDown(): void
{
$this->restoreDefaultTimezone();
+
+ \Locale::setDefault($this->defaultLocale);
}
protected function setDefaultTimezone(?string $defaultTimezone)
diff --git a/src/Symfony/Component/VarDumper/LICENSE b/src/Symfony/Component/VarDumper/LICENSE
index c1f0aac1c5614..a843ec124ea70 100644
--- a/src/Symfony/Component/VarDumper/LICENSE
+++ b/src/Symfony/Component/VarDumper/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2014-2021 Fabien Potencier
+Copyright (c) 2014-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php b/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php
index ff4727538399c..447d4856f7329 100644
--- a/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php
+++ b/src/Symfony/Component/VarDumper/Tests/Dumper/ServerDumperTest.php
@@ -37,9 +37,6 @@ public function testDumpForwardsToWrappedDumperWhenServerIsUnavailable()
$dumper->dump($data);
}
- /**
- * @group transient-on-macos
- */
public function testDump()
{
$wrappedDumper = $this->createMock(DataDumperInterface::class);
diff --git a/src/Symfony/Component/VarDumper/Tests/Server/ConnectionTest.php b/src/Symfony/Component/VarDumper/Tests/Server/ConnectionTest.php
index ee89d74d0af3d..70629a221569a 100644
--- a/src/Symfony/Component/VarDumper/Tests/Server/ConnectionTest.php
+++ b/src/Symfony/Component/VarDumper/Tests/Server/ConnectionTest.php
@@ -22,9 +22,6 @@ class ConnectionTest extends TestCase
{
private const VAR_DUMPER_SERVER = 'tcp://127.0.0.1:9913';
- /**
- * @group transient-on-macos
- */
public function testDump()
{
$cloner = new VarCloner();
diff --git a/src/Symfony/Component/VarExporter/LICENSE b/src/Symfony/Component/VarExporter/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Component/VarExporter/LICENSE
+++ b/src/Symfony/Component/VarExporter/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/WebLink/LICENSE b/src/Symfony/Component/WebLink/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/WebLink/LICENSE
+++ b/src/Symfony/Component/WebLink/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Workflow/LICENSE b/src/Symfony/Component/Workflow/LICENSE
index c1f0aac1c5614..a843ec124ea70 100644
--- a/src/Symfony/Component/Workflow/LICENSE
+++ b/src/Symfony/Component/Workflow/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2014-2021 Fabien Potencier
+Copyright (c) 2014-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Yaml/CHANGELOG.md b/src/Symfony/Component/Yaml/CHANGELOG.md
index b9561b2af2155..fe907c9889339 100644
--- a/src/Symfony/Component/Yaml/CHANGELOG.md
+++ b/src/Symfony/Component/Yaml/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========
+6.1
+---
+
+ * In cases where it will likely improve readability, strings containing single quotes will be double-quoted.
+
5.4
---
diff --git a/src/Symfony/Component/Yaml/Inline.php b/src/Symfony/Component/Yaml/Inline.php
index 9b5ddc967da88..b7876efc6c330 100644
--- a/src/Symfony/Component/Yaml/Inline.php
+++ b/src/Symfony/Component/Yaml/Inline.php
@@ -177,6 +177,14 @@ public static function dump(mixed $value, int $flags = 0): string
case Escaper::requiresDoubleQuoting($value):
return Escaper::escapeWithDoubleQuotes($value);
case Escaper::requiresSingleQuoting($value):
+ $singleQuoted = Escaper::escapeWithSingleQuotes($value);
+ if (!str_contains($value, "'")) {
+ return $singleQuoted;
+ }
+ // Attempt double-quoting the string instead to see if it's more efficient.
+ $doubleQuoted = Escaper::escapeWithDoubleQuotes($value);
+
+ return \strlen($doubleQuoted) < \strlen($singleQuoted) ? $doubleQuoted : $singleQuoted;
case Parser::preg_match('{^[0-9]+[_0-9]*$}', $value):
case Parser::preg_match(self::getHexRegex(), $value):
case Parser::preg_match(self::getTimestampRegex(), $value):
diff --git a/src/Symfony/Component/Yaml/LICENSE b/src/Symfony/Component/Yaml/LICENSE
index 9ff2d0d6306da..88bf75bb4d6a2 100644
--- a/src/Symfony/Component/Yaml/LICENSE
+++ b/src/Symfony/Component/Yaml/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2004-2021 Fabien Potencier
+Copyright (c) 2004-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Component/Yaml/Tests/DumperTest.php b/src/Symfony/Component/Yaml/Tests/DumperTest.php
index 6329aec86ccff..0e3b16af91ca2 100644
--- a/src/Symfony/Component/Yaml/Tests/DumperTest.php
+++ b/src/Symfony/Component/Yaml/Tests/DumperTest.php
@@ -60,7 +60,7 @@ public function testIndentationInConstructor()
$expected = <<<'EOF'
'': bar
foo: '#bar'
-'foo''bar': { }
+"foo'bar": { }
bar:
- 1
- foo
@@ -107,7 +107,7 @@ public function testSpecifications()
public function testInlineLevel()
{
$expected = <<<'EOF'
-{ '': bar, foo: '#bar', 'foo''bar': { }, bar: [1, foo], foobar: { foo: bar, bar: [1, foo], foobar: { foo: bar, bar: [1, foo] } } }
+{ '': bar, foo: '#bar', "foo'bar": { }, bar: [1, foo], foobar: { foo: bar, bar: [1, foo], foobar: { foo: bar, bar: [1, foo] } } }
EOF;
$this->assertEquals($expected, $this->dumper->dump($this->array, -10), '->dump() takes an inline level argument');
$this->assertEquals($expected, $this->dumper->dump($this->array, 0), '->dump() takes an inline level argument');
@@ -115,7 +115,7 @@ public function testInlineLevel()
$expected = <<<'EOF'
'': bar
foo: '#bar'
-'foo''bar': { }
+"foo'bar": { }
bar: [1, foo]
foobar: { foo: bar, bar: [1, foo], foobar: { foo: bar, bar: [1, foo] } }
@@ -125,7 +125,7 @@ public function testInlineLevel()
$expected = <<<'EOF'
'': bar
foo: '#bar'
-'foo''bar': { }
+"foo'bar": { }
bar:
- 1
- foo
@@ -140,7 +140,7 @@ public function testInlineLevel()
$expected = <<<'EOF'
'': bar
foo: '#bar'
-'foo''bar': { }
+"foo'bar": { }
bar:
- 1
- foo
@@ -159,7 +159,7 @@ public function testInlineLevel()
$expected = <<<'EOF'
'': bar
foo: '#bar'
-'foo''bar': { }
+"foo'bar": { }
bar:
- 1
- foo
diff --git a/src/Symfony/Component/Yaml/Tests/InlineTest.php b/src/Symfony/Component/Yaml/Tests/InlineTest.php
index 77fcee7cad830..a312965448977 100644
--- a/src/Symfony/Component/Yaml/Tests/InlineTest.php
+++ b/src/Symfony/Component/Yaml/Tests/InlineTest.php
@@ -473,6 +473,10 @@ public function getTestsForDump()
["'foo # bar'", 'foo # bar'],
["'#cfcfcf'", '#cfcfcf'],
+ ["\"isn't it a nice single quote\"", "isn't it a nice single quote"],
+ ['\'this is "double quoted"\'', 'this is "double quoted"'],
+ ["\"one double, four single quotes: \\\"''''\"", 'one double, four single quotes: "\'\'\'\''],
+ ['\'four double, one single quote: """"\'\'\'', 'four double, one single quote: """"\''],
["'a \"string\" with ''quoted strings inside'''", 'a "string" with \'quoted strings inside\''],
["'-dash'", '-dash'],
diff --git a/src/Symfony/Contracts/Cache/LICENSE b/src/Symfony/Contracts/Cache/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/Cache/LICENSE
+++ b/src/Symfony/Contracts/Cache/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/Deprecation/LICENSE b/src/Symfony/Contracts/Deprecation/LICENSE
index ad85e1737485d..406242ff28554 100644
--- a/src/Symfony/Contracts/Deprecation/LICENSE
+++ b/src/Symfony/Contracts/Deprecation/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020-2021 Fabien Potencier
+Copyright (c) 2020-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/EventDispatcher/LICENSE b/src/Symfony/Contracts/EventDispatcher/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/EventDispatcher/LICENSE
+++ b/src/Symfony/Contracts/EventDispatcher/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/HttpClient/LICENSE b/src/Symfony/Contracts/HttpClient/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/HttpClient/LICENSE
+++ b/src/Symfony/Contracts/HttpClient/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php
index 7f94974db6fdc..d5ad8b445b03a 100644
--- a/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php
+++ b/src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php
@@ -835,9 +835,6 @@ public function testTimeoutWithActiveConcurrentStream()
}
}
- /**
- * @group transient-on-macos
- */
public function testTimeoutOnInitialize()
{
$p1 = TestHttpServer::start(8067);
@@ -871,9 +868,6 @@ public function testTimeoutOnInitialize()
}
}
- /**
- * @group transient-on-macos
- */
public function testTimeoutOnDestruct()
{
$p1 = TestHttpServer::start(8067);
diff --git a/src/Symfony/Contracts/LICENSE b/src/Symfony/Contracts/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/LICENSE
+++ b/src/Symfony/Contracts/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/Service/LICENSE b/src/Symfony/Contracts/Service/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/Service/LICENSE
+++ b/src/Symfony/Contracts/Service/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Symfony/Contracts/Translation/LICENSE b/src/Symfony/Contracts/Translation/LICENSE
index 2358414536d95..74cdc2dbf6dbe 100644
--- a/src/Symfony/Contracts/Translation/LICENSE
+++ b/src/Symfony/Contracts/Translation/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2018-2021 Fabien Potencier
+Copyright (c) 2018-2022 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal