diff --git a/.appveyor.yml b/.appveyor.yml index 5474f89111c2a..8a880a9b3c063 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -22,6 +22,8 @@ install: - cd ext - appveyor DownloadFile https://github.com/symfony/binary-utils/releases/download/v0.1/php_apcu-5.1.21-8.0-ts-vs16-x86.zip - 7z x php_apcu-5.1.21-8.0-ts-vs16-x86.zip -y >nul + - appveyor DownloadFile https://github.com/symfony/binary-utils/releases/download/v0.1/php_redis-5.3.5-8.0-ts-vs16-x86.zip + - 7z x php_redis-5.3.5-8.0-ts-vs16-x86.zip -y >nul - cd .. - copy /Y php.ini-development php.ini-min - echo memory_limit=-1 >> php.ini-min @@ -37,6 +39,7 @@ install: - echo opcache.enable_cli=1 >> php.ini-max - echo extension=php_openssl.dll >> php.ini-max - echo extension=php_apcu.dll >> php.ini-max + - echo extension=php_redis.dll >> php.ini-max - echo apc.enable_cli=1 >> php.ini-max - echo extension=php_intl.dll >> php.ini-max - echo extension=php_mbstring.dll >> php.ini-max @@ -55,6 +58,7 @@ install: - SET COMPOSER_ROOT_VERSION=%SYMFONY_VERSION%.x-dev - php composer.phar update --no-progress --ansi - php phpunit install + - choco install memurai-developer test_script: - SET X=0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5e7092d385910..5b76de89c7bee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,7 @@ Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Bug fixes must be submitted against the lowest maintained branch where they apply (lowest branches are regularly merged to upper ones so they get the fixes too.) - - Features and deprecations must be submitted against branch 5.x. + - Features and deprecations must be submitted against the latest branch. - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 91fcd5ea0f79e..89f7bf8929467 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -157,7 +157,6 @@ jobs: - name: Run tests run: ./phpunit --group integration -v env: - REDIS_HOST: localhost REDIS_CLUSTER_HOSTS: 'localhost:7000 localhost:7001 localhost:7002 localhost:7003 localhost:7004 localhost:7005' REDIS_SENTINEL_HOSTS: 'localhost:26379' REDIS_SENTINEL_SERVICE: redis_sentinel @@ -165,10 +164,6 @@ jobs: MESSENGER_AMQP_DSN: amqp://localhost/%2f/messages MESSENGER_SQS_DSN: "sqs://localhost:9494/messages?sslmode=disable&poll_timeout=0.01" MESSENGER_SQS_FIFO_QUEUE_DSN: "sqs://localhost:9494/messages.fifo?sslmode=disable&poll_timeout=0.01" - MEMCACHED_HOST: localhost - LDAP_HOST: localhost - LDAP_PORT: 3389 - MONGODB_HOST: localhost KAFKA_BROKER: 127.0.0.1:9092 POSTGRES_HOST: localhost diff --git a/.github/workflows/package-tests.yml b/.github/workflows/package-tests.yml index 3c0a7c36be89f..23b65286814a9 100644 --- a/.github/workflows/package-tests.yml +++ b/.github/workflows/package-tests.yml @@ -82,7 +82,7 @@ jobs: echo "Verifying new package" _correct_license_file $DIR/LICENSE || localExit=1 - if [ $TYPE == 'component_bridge' ]; then + if [ $TYPE != 'component_bridge' ]; then if [ ! $(cat composer.json | jq -e ".replace.\"$NAME\"|test(\"self.version\")") ]; then echo "Composer.json's replace section needs to contain $NAME" localExit=1 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9dc6394adce41..3b4922f87fbde 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,21 +20,15 @@ jobs: matrix: include: - php: '8.1' - os: ubuntu-20.04 - - php: '8.0' - os: macos-11 - php: '8.0' mode: high-deps - os: ubuntu-20.04 - php: '8.1' mode: low-deps - os: ubuntu-20.04 - php: '8.2' mode: experimental - os: ubuntu-20.04 fail-fast: false - runs-on: "${{ matrix.os }}" + runs-on: ubuntu-20.04 steps: - name: Checkout @@ -56,11 +50,6 @@ jobs: extensions: "${{ env.extensions }}" tools: flex - - name: Install Homebrew packages - if: "matrix.os == 'macos-11'" - run: | - brew install parallel - - name: Configure environment run: | git config --global user.email "" @@ -72,7 +61,7 @@ jobs: ([ -d "$COMPOSER_HOME" ] || mkdir "$COMPOSER_HOME") && cp .github/composer-config.json "$COMPOSER_HOME/config.json" echo COLUMNS=120 >> $GITHUB_ENV - echo PHPUNIT="$(pwd)/phpunit --exclude-group tty,benchmark,intl-data$([[ ${{ matrix.os }} = macos* ]] && echo ',transient-on-macos')" >> $GITHUB_ENV + echo PHPUNIT="$(pwd)/phpunit --exclude-group tty,benchmark,intl-data" >> $GITHUB_ENV echo COMPOSER_UP='composer update --no-progress --ansi' >> $GITHUB_ENV SYMFONY_VERSIONS=$(git ls-remote -q --heads | cut -f2 | grep -o '/[1-9][0-9]*\.[0-9].*' | sort -V) @@ -146,7 +135,7 @@ jobs: echo "::endgroup::" - name: Patch return types - if: "matrix.php == '8.1' && ! matrix.mode && matrix.os != 'macos-11'" + if: "matrix.php == '8.1' && ! matrix.mode" run: | patch -sp1 < .github/expected-missing-return-types.diff git add . @@ -224,7 +213,7 @@ jobs: [[ ! $X ]] || (exit 1) - name: Run tests with SIGCHLD enabled PHP - if: "matrix.php == '8.0' && ! matrix.mode && matrix.os != 'macos-11'" + if: "matrix.php == '8.0' && ! matrix.mode" run: | mkdir build cd build diff --git a/CHANGELOG-6.0.md b/CHANGELOG-6.0.md index da7b96d9d2c06..964b98e9923c4 100644 --- a/CHANGELOG-6.0.md +++ b/CHANGELOG-6.0.md @@ -7,6 +7,72 @@ in 6.0 minor versions. To get the diff for a specific change, go to https://github.com/symfony/symfony/commit/XXX where XXX is the change hash To get the diff between two versions, go to https://github.com/symfony/symfony/compare/v6.0.0...v6.0.1 +* 6.0.2 (2021-12-29) + + * bug #44828 [Lock] Release DoctrineDbalPostgreSqlStore connection lock on failure (simon-watiau) + * bug #44838 [DependencyInjection][HttpKernel] Fix enum typed bindings (ogizanagi) + * bug #44723 [Lock] Release PostgreSqlStore connection lock on failure (simon-watiau) * commit 'e5b2f9efba': [Lock] Release PostgreSqlStore connection lock on failure + * bug #44826 [HttpKernel] Do not attempt to register enum arguments in controller service locator (ogizanagi) + * bug #44822 [Mime][Security] Fix missing sprintf and add tests (alamirault) + * bug #44824 [Mime] Fix missing sprintf in DkimSigner (alamirault) + * bug #44816 [Translation] [LocoProvider] Use rawurlencode and separate tag setting (danut007ro) + * bug #44805 [Security] fix unserializing session payloads from v4 (nicolas-grekas) + * bug #44820 [Cache] Don't lock when doing nested computations (nicolas-grekas) + * bug #44807 [Messenger] fix Redis support on 32b arch (nicolas-grekas) + * bug #44759 [HttpFoundation] Fix notice when HTTP_PHP_AUTH_USER passed without pass (Vitali Tsyrkin) + * bug #44809 [WebProfilerBundle] relax return type for memory data collector (94noni) + * bug #44799 [Cache] fix compat with apcu < 5.1.10 (nicolas-grekas) + * bug #44764 [Form] Expand FormView key to include int (biozshock) + * bug #44730 [Console] Fix autocompletion of argument with default value (GromNaN) + * bug #44637 [PropertyInfo] PhpStan extractor nested object fix (rmikalkenas) + * bug #44085 [Translation] Fix TranslationPullCommand with ICU translations (Kocal) + * bug #44578 [PropertyInfo] Fix phpstan extractor issues (ostrolucky) + * bug #44771 [Notifier] Use correct factory for the msteams transport (veewee) + * bug #44618 [HttpKernel] Fix SessionListener without session in request (shyim) + * bug #44743 [HttpClient] fix checking for recent curl consts (nicolas-grekas) + * bug #44752 [Security/Http] Fix cookie clearing on logout (maxhelias) + * bug #44745 [EventDispatcher][HttpFoundation] Restore return type to covariant IteratorAggregate implementations (derrabus) + * bug #44732 [Mime] Relaxing in-reply-to header validation (ThomasLandauer) + * bug #44714 [WebProfilerBundle] fix Email HTML preview (94noni) + * bug #44737 Fix Psr16Cache not being compatible with non-Symfony cache pools (colinodell) + * bug #44728 [Mime] Fix encoding filenames in multipart/form-data (nicolas-grekas) + * bug #44602 [Serializer] Improve UidNormalizer denormalize error message (fancyweb) + * bug #44383 [Lock] Create tables in transaction only if supported by driver (martinssipenko) + * bug #44518 [HttpFoundation] Take php session.cookie settings into account (simonchrz) + * bug #44719 [ErrorHandler] fix on patching return types on Windows (nicolas-grekas) + * bug #44710 [DependencyInjection] fix linting callable classes (nicolas-grekas) + * bug #44639 [DependencyInjection] Cast tag attribute value to string (ruudk) + * bug #44473 [Validator] Restore default locale in ConstraintValidatorTestCase (rodnaph) + * bug #44682 [FrameworkBundle] alias `cache.app.taggable` to `cache.app` if using `cache.adapter.redis_tag_aware` (kbond) + * bug #44649 [HttpKernel] fix how configuring log-level and status-code by exception works (nicolas-grekas) + * bug #44667 [Cache] Revert "feature #41989 make `LockRegistry` use semaphores when possible" (nicolas-grekas) + * bug #44671 [HttpClient] Fix tracing requests made after calling withOptions() (nicolas-grekas) + * bug #44577 [Cache] Fix proxy no expiration to the Redis (Sergey Belyshkin) + * bug #44669 [Cache] disable lock on CLI (nicolas-grekas) + * bug #44598 [Translation] Handle the blank-translation in Loco Adapter (kgonella) + * bug #44448 [Validator] Allow Sequence constraint to be applied onto class as an attribute (sidz) + * bug #44354 [RateLimiter] Make RateLimiter resilient to timeShifting (jderusse) + * bug #44600 [Serializer] Fix denormalizing custom class in UidNormalizer (fancyweb) + * bug #44537 [Config] In XmlUtils, avoid converting from octal every string starting with a 0 (alexandre-daubois) + * bug #44510 [Workflow] Fix eventsToDispatch parameter setup for StateMachine (Olexandr Kalaidzhy) + * bug #44625 [HttpClient] fix monitoring responses issued before reset() (nicolas-grekas) + * bug #44623 [HttpClient] Fix dealing with "HTTP/1.1 000 " responses (nicolas-grekas) + * bug #44430 [PropertyInfo] Fix aliased namespace matching (Korbeil) + * bug #44601 [HttpClient] Fix closing curl-multi handle too early on destruct (nicolas-grekas) + * bug #44554 Make enable_authenticator_manager true as there is no other way in Symfony 6 (alexander-schranz) + * bug #44571 [HttpClient] Don't reset timeout counter when initializing requests (nicolas-grekas) + * bug #44479 [HttpClient] Double check if handle is complete (Nyholm) + * bug #44418 [DependencyInjection] Resolve ChildDefinition in AbstractRecursivePass (fancyweb) + * bug #44474 [Translation] [Bridge] [Lokalise] Fix push keys to lokalise. Closes #… (olegmifle) + * bug #43164 [FrameworkBundle] Fix cache pool configuration with one adapter and one provider (fancyweb) + * bug #44419 [PropertyAccess] Fix accessing public property on Object (kevcomparadise) + * bug #44565 [FrameworkBundle] Use correct cookie domain in loginUser() (wouterj) + * bug #44538 [Process] fixed uppercase ARGC and ARGV should also be skipped (rbaarsma) + * bug #44438 [HttpClient] Fix handling thrown \Exception in \Generator in MockResponse (fancyweb) + * bug #44469 [String] Fix requiring wcswitch table several times (fancyweb) + * bug #44428 [HttpClient] Fix response id property check in MockResponse (fancyweb) + * bug #44539 [Lock] Fix missing argument in PostgreSqlStore::putOffExpiration with DBAL connection (GromNaN) + * 6.0.1 (2021-12-09) * bug #44494 Remove FQCN type hints on properties (fabpot) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b5e87ad1280f4..48ae19030db84 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -12,15 +12,15 @@ The Symfony Connect username in parenthesis allows to get more information - Tobias Schultze (tobion) - Robin Chalas (chalas_r) - Christophe Coevoet (stof) - - Jérémy DERUSSÉ (jderusse) - Wouter De Jong (wouterj) + - Jérémy DERUSSÉ (jderusse) - Grégoire Pineau (lyrixx) - Maxime Steinhausser (ogizanagi) - Kévin Dunglas (dunglas) - Jordi Boggiano (seldaek) + - Thomas Calvet (fancyweb) - Victor Berchet (victor) - Javier Eguiluz (javier.eguiluz) - - Thomas Calvet (fancyweb) - Ryan Weaver (weaverryan) - Roland Franssen (ro0) - Jakub Zalas (jakubzalas) @@ -54,19 +54,19 @@ The Symfony Connect username in parenthesis allows to get more information - Valentin Udaltsov (vudaltsov) - Iltar van der Berg (kjarli) - Jonathan Wage (jwage) + - Jérôme Tamarelle (gromnan) - Matthias Pigulla (mpdude) - Diego Saint Esteben (dosten) - Grégoire Paris (greg0ire) - Alexandre Salomé (alexandresalome) - - Jérôme Tamarelle (gromnan) - William Durand (couac) - ornicar + - Titouan Galopin (tgalopin) - Konstantin Myakshin (koc) - Dany Maillard (maidmaid) - Francis Besset (francisbesset) - stealth35 ‏ (stealth35) - Alexander Mols (asm89) - - Titouan Galopin (tgalopin) - Laurent VOULLEMIER (lvo) - Vasilij Dusko | CREATION - Bulat Shakirzyanov (avalanche123) @@ -79,10 +79,10 @@ The Symfony Connect username in parenthesis allows to get more information - Miha Vrhovnik - Diego Saint Esteben (dii3g0) - Mathieu Piot (mpiot) + - Antoine M (amakdessi) - Konstantin Kudryashov (everzet) - Vladimir Reznichenko (kalessil) - Bilal Amarni (bamarni) - - Antoine M (amakdessi) - Florin Patan (florinpatan) - Jáchym Toušek (enumag) - Alex Pott @@ -107,24 +107,24 @@ The Symfony Connect username in parenthesis allows to get more information - Lee McDermott - Brandon Turner - Luis Cordova (cordoval) + - Mathieu Santostefano (welcomattic) - Daniel Holmes (dholmes) - Sebastiaan Stok (sstok) + - HypeMC (hypemc) - Toni Uebernickel (havvg) - Bart van den Burg (burgov) - Jordan Alliot (jalliot) - - Mathieu Santostefano (welcomattic) - John Wards (johnwards) - Tomas Norkūnas (norkunas) + - Alexandre Daubois (alexandre-daubois) - Baptiste Clavié (talus) - - HypeMC (hypemc) - Antoine Hérault (herzult) - Paráda József (paradajozsef) - - Alexandre Daubois (alexandre-daubois) - Vincent Langlet (deviling) + - Julien Falque (julienfalque) - Massimiliano Arione (garak) - Arnaud Le Blanc (arnaud-lb) - Przemysław Bogusz (przemyslaw-bogusz) - - Julien Falque (julienfalque) - Maxime STEINHAUSSER - Michal Piotrowski (eventhorizon) - Tomáš Votruba (tomas_votruba) @@ -143,6 +143,7 @@ The Symfony Connect username in parenthesis allows to get more information - Włodzimierz Gajda (gajdaw) - Christian Scheb - Adrien Brault (adrienbrault) + - Maxime Helias (maxhelias) - Yanick Witschi (toflar) - Jacob Dreesen (jdreesen) - Malte Schlüter (maltemaltesich) @@ -152,6 +153,7 @@ The Symfony Connect username in parenthesis allows to get more information - Teoh Han Hui (teohhanhui) - Colin Frei - Javier Spagnoletti (phansys) + - Ruud Kamphuis (ruudk) - Joshua Thijssen - Daniel Wehner (dawehner) - Tugdual Saunier (tucksaun) @@ -159,15 +161,13 @@ The Symfony Connect username in parenthesis allows to get more information - Gordon Franke (gimler) - Saif Eddin Gmati (azjezz) - Richard van Laak (rvanlaak) - - Maxime Helias (maxhelias) + - Gary PEGEOT (gary-p) - Jesse Rushlow (geeshoe) - Fabien Pennequin (fabienpennequin) - Olivier Dolbeau (odolbeau) - Smaine Milianni (ismail1432) - Eric GELOEN (gelo) - - Gary PEGEOT (gary-p) - Matthieu Napoli (mnapoli) - - Ruud Kamphuis (ruudk) - Ion Bazan (ionbazan) - Jannik Zschiesche (apfelbox) - Robert Schönthal (digitalkaoz) @@ -190,6 +190,7 @@ The Symfony Connect username in parenthesis allows to get more information - Albert Casademont (acasademont) - Arnaud Kleinpeter (nanocom) - Guilherme Blanco (guilhermeblanco) + - Marco Pivetta (ocramius) - SpacePossum - Alexander Menshchikov (zmey_kk) - Pablo Godel (pgodel) @@ -203,20 +204,23 @@ The Symfony Connect username in parenthesis allows to get more information - jwdeitch - Jeroen Spee (jeroens) - Jérôme Parmentier (lctrs) - - Marco Pivetta (ocramius) - Fabien Bourigault (fbourigault) - Joe Bennett (kralos) - Mikael Pajunen - Andreas Schempp (aschempp) - Alessandro Lai (jean85) - Romaric Drigon (romaricdrigon) + - Christopher Hertel (chertel) - Arman Hosseini (arman) + - Rokas Mikalkėnas (rokasm) - Niels Keurentjes (curry684) - Vyacheslav Pavlov + - Andreas Möller (localheinz) - Richard Shank (iampersistent) - Wouter J - Thomas Rabaix (rande) - Chi-teck + - Baptiste Leduc (korbeil) - Timo Bakx (timobakx) - Vincent Touzet (vincenttouzet) - Nate Wiebe (natewiebe13) @@ -225,16 +229,13 @@ The Symfony Connect username in parenthesis allows to get more information - Ben Davies (bendavies) - Clemens Tolboom - Helmer Aaviksoo - - Christopher Hertel (chertel) - Remon van de Kamp (rpkamp) - - Rokas Mikalkėnas (rokasm) - Filippo Tessarotto (slamdunk) - Hiromi Hishida (77web) - Michael Käfer (michael_kaefer) - Matthieu Ouellette-Vachon (maoueh) - Michał Pipa (michal.pipa) - Dawid Nowak - - Andreas Möller (localheinz) - Roman Martinuk (a2a4) - Amal Raghav (kertz) - Jonathan Ingram (jonathaningram) @@ -244,7 +245,6 @@ The Symfony Connect username in parenthesis allows to get more information - Samuel NELA (snela) - David Prévot - Hugo Monteiro (monteiro) - - Baptiste Leduc (korbeil) - Dmitrii Poddubnyi (karser) - zairig imad (zairigimad) - Tien Vo (tienvx) @@ -275,6 +275,7 @@ The Symfony Connect username in parenthesis allows to get more information - Thibaut Cheymol (tcheymol) - Sebastien Morel (plopix) - mcfedr (mcfedr) + - Colin O'Dell (colinodell) - Ruben Gonzalez (rubenrua) - Benjamin Dulau (dbenjamin) - Baptiste Lafontaine (magnetik) @@ -311,7 +312,6 @@ The Symfony Connect username in parenthesis allows to get more information - Matthieu Auger (matthieuauger) - Leszek Prabucki (l3l0) - Nicolas Philippe (nikophil) - - Colin O'Dell (colinodell) - Emanuele Panzeri (thepanz) - François Zaninotto (fzaninotto) - Dustin Whittle (dustinwhittle) @@ -325,6 +325,7 @@ The Symfony Connect username in parenthesis allows to get more information - Sven Paulus (subsven) - Daniel STANCU - Maxime Veber (nek-) + - Sylvain Fabre (sylfabre) - Loick Piera (pyrech) - Clara van Miert - Valentine Boineau (valentineboineau) @@ -340,6 +341,7 @@ The Symfony Connect username in parenthesis allows to get more information - Victor Bocharsky (bocharsky_bw) - Bozhidar Hristov (warxcell) - Marcel Beerta (mazen) + - Thomas Landauer (thomas-landauer) - Pavel Batanov (scaytrase) - Mantis Development - Loïc Faugeron @@ -373,10 +375,11 @@ The Symfony Connect username in parenthesis allows to get more information - Roman Marintšenko (inori) - Xavier Montaña Carreras (xmontana) - Mickaël Andrieu (mickaelandrieu) + - Soner Sayakci - Xavier Perez - Arjen Brouwer (arjenjb) - Katsuhiro OGAWA - - Sylvain Fabre (sylfabre) + - Artem Lopata - Patrick McDougle (patrick-mcdougle) - Marc Weistroff (futurecat) - Alif Rachmawadi @@ -402,7 +405,6 @@ The Symfony Connect username in parenthesis allows to get more information - Jhonny Lidfors (jhonne) - Diego Agulló (aeoris) - jdhoek - - Thomas Landauer (thomas-landauer) - Jurica Vlahoviček (vjurica) - Bob den Otter (bopp) - Thomas Schulz (king2500) @@ -436,7 +438,6 @@ The Symfony Connect username in parenthesis allows to get more information - Wouter Van Hecke - Iker Ibarguren (ikerib) - Bob van de Vijver (bobvandevijver) - - Soner Sayakci - Peter Kruithof (pkruithof) - Michael Holm (hollo) - Arjen van der Meijden @@ -459,6 +460,7 @@ The Symfony Connect username in parenthesis allows to get more information - Manuel Kiessling (manuelkiessling) - Dimitri Gritsajuk (ottaviano) - Alexey Kopytko (sanmai) + - Gijs van Lammeren - Pol Dellaiera (drupol) - Atsuhiro KUBO (iteman) - Alireza Mirsepassi (alirezamirsepassi) @@ -535,7 +537,6 @@ The Symfony Connect username in parenthesis allows to get more information - Christian Gärtner (dagardner) - Dmytro Borysovskyi (dmytr0) - Tomasz Kowalczyk (thunderer) - - Artem Lopata - Artur Eshenbrener - Thomas Perez (scullwm) - Yoann RENARD (yrenard) @@ -553,6 +554,7 @@ The Symfony Connect username in parenthesis allows to get more information - hossein zolfi (ocean) - Clément Gautier (clementgautier) - Koen Reiniers (koenre) + - Hugo Alliaume (kocal) - Sanpi - Eduardo Gulias (egulias) - giulio de donato (liuggio) @@ -560,10 +562,12 @@ The Symfony Connect username in parenthesis allows to get more information - ShinDarth - Stéphane PY (steph_py) - Philipp Kräutli (pkraeutli) + - Rhodri Pugh (rodnaph) - Grzegorz Zdanowski (kiler129) - Kirill chEbba Chebunin (chebba) - - Fabien Villepinte + - SiD (plbsid) - Matthew Grasmick - Greg Thornton (xdissent) - BENOIT POLASZEK (bpolaszek) @@ -575,12 +579,12 @@ The Symfony Connect username in parenthesis allows to get more information - Loïc Chardonnet (gnusat) - Marek Kalnik (marekkalnik) - Vyacheslav Salakhutdinov (megazoll) + - Antoine Lamirault - Phil Taylor (prazgod) - Hassan Amouhzi - Tamas Szijarto - Michele Locati - Pavel Volokitin (pvolok) - - Gijs van Lammeren - Arthur de Moulins (4rthem) - Matthias Althaus (althaus) - Nicolas Dewez (nicolas_dewez) @@ -650,6 +654,7 @@ The Symfony Connect username in parenthesis allows to get more information - scyzoryck - Matthias Krauser (mkrauser) - Erkhembayar Gantulga (erheme318) + - Alexis Lefebvre - Lorenzo Millucci (lmillucci) - Jérôme Tamarelle (jtamarelle-prismamedia) - Andrii Popov (andrii-popov) @@ -677,6 +682,7 @@ The Symfony Connect username in parenthesis allows to get more information - Chris Sedlmayr (catchamonkey) - Indra Gunawan (indragunawan) - Mathias STRASSER (roukmoute) + - simon chrzanowski (simonch) - Kamil Kokot (pamil) - Seb Koelen - Christoph Mewes (xrstf) @@ -719,7 +725,6 @@ The Symfony Connect username in parenthesis allows to get more information - Marek Zajac - Adam Harvey - Anton Bakai - - Rhodri Pugh (rodnaph) - battye - Sam Fleming (sam_fleming) - William Arslett @@ -749,7 +754,6 @@ The Symfony Connect username in parenthesis allows to get more information - Sebastian Bergmann - Miroslav Sustek - Pablo Díez (pablodip) - - SiD (plbsid) - Michel Roca (mroca) - Kevin McBride - Sergio Santoro @@ -780,6 +784,7 @@ The Symfony Connect username in parenthesis allows to get more information - Markus Lanthaler (lanthaler) - Remi Collet - Vicent Soria Durá (vicentgodella) + - Daniel Gorgan - Michael Moravec - Carlos Buenosvinos (carlosbuenosvinos) - Leevi Graham (leevigraham) @@ -799,6 +804,7 @@ The Symfony Connect username in parenthesis allows to get more information - Scott Arciszewski - Xavier HAUSHERR - Norbert Orzechowicz (norzechowicz) + - stlrnz - Denis Charrier (brucewouaigne) - Matthijs van den Bos (matthijs) - Simon Podlipsky (simpod) @@ -912,8 +918,8 @@ The Symfony Connect username in parenthesis allows to get more information - vitaliytv - Nicolas Martin (cocorambo) - Adrian Nguyen (vuphuong87) + - Khoo Yong Jun - Sebastian Blum - - Alexis Lefebvre - Laurent Clouet - aubx - Julien Turby @@ -930,6 +936,7 @@ The Symfony Connect username in parenthesis allows to get more information - pizzaminded - Stéphane Escandell (sescandell) - Konstantin S. M. Möllers (ksmmoellers) + - Fractal Zombie - linh - James Johnston - Sinan Eldem @@ -978,6 +985,7 @@ The Symfony Connect username in parenthesis allows to get more information - Evgeny Anisiforov - smoench - Max Grigorian (maxakawizard) + - Martins Sipenko - Guilherme Augusto Henschel - Rostyslav Kinash - Cristoforo Cervino (cristoforocervino) @@ -1021,9 +1029,11 @@ The Symfony Connect username in parenthesis allows to get more information - Tomas Javaisis - Ivan Grigoriev - Johann Saunier (prophet777) + - Kevin SCHNEKENBURGER - Fabien Salles (blacked) - Andreas Erhard - John VanDeWeghe + - Sergey Belyshkin - Michael Devery (mickadoo) - Antoine Corcy - Ahmed Ashraf (ahmedash95) @@ -1085,6 +1095,7 @@ The Symfony Connect username in parenthesis allows to get more information - Roromix - Maxime AILLOUD (mailloud) - Richard van den Brand (ricbra) + - Toon Verwerft (veewee) - mohammadreza honarkhah - develop - flip111 @@ -1191,6 +1202,7 @@ The Symfony Connect username in parenthesis allows to get more information - Julien Pauli - Dominik Piekarski (dompie) - Rares Sebastian Moldovan (raresmldvn) + - Jérémy REYNAUD (babeuloula) - Mathieu Rochette (mathroc) - Victor Garcia - Jérôme Tanghe (deuchnord) @@ -1239,7 +1251,7 @@ The Symfony Connect username in parenthesis allows to get more information - Tito Costa - Jan Prieser - GDIBass - - Antoine Lamirault + - Maximilian Bösing - Thiago Melo - Adrien Lucas (adrienlucas) - Zhuravlev Alexander (scif) @@ -1300,7 +1312,6 @@ The Symfony Connect username in parenthesis allows to get more information - Loïc Beurlet - Sébastien COURJEAN - Ana Raro - - Daniel Gorgan - Ana Raro - Tony Malzhacker - Pchol @@ -1380,13 +1391,14 @@ The Symfony Connect username in parenthesis allows to get more information - Forfarle (forfarle) - Harry Walter (haswalt) - Johnson Page (jwpage) + - Kuba Werłos (kuba) - Ruben Gonzalez (rubenruateltek) - Michael Roterman (wtfzdotnet) + - Philipp Keck - Arno Geurts - Adán Lobato (adanlobato) - Ian Jenkins (jenkoian) - Kai Eichinger (kai_eichinger) - - Hugo Alliaume (kocal) - Marcos Gómez Vilches (markitosgv) - Matthew Davis (mdavis1982) - Paulo Ribeiro (paulo) @@ -1396,7 +1408,6 @@ The Symfony Connect username in parenthesis allows to get more information - Antoine LA - den - Pavol Tuka - - stlrnz - pawel-lewtak - omerida - Gábor Tóth @@ -1476,9 +1487,7 @@ The Symfony Connect username in parenthesis allows to get more information - neghmurken - xaav - Mahmoud Mostafa (mahmoud) - - Fractal Zombie - Ahmed Abdou - - Khoo Yong Jun - shreyadenny - Daniel Iwaniec - Pieter @@ -1509,6 +1518,7 @@ The Symfony Connect username in parenthesis allows to get more information - LHommet Nicolas (nicolaslh) - fabios - Sander Coolen (scoolen) + - Emil Masiakowski - Amirreza Shafaat (amirrezashafaat) - Adoni Pavlakis (adoni) - Nicolas Le Goff (nlegoff) @@ -1628,6 +1638,7 @@ The Symfony Connect username in parenthesis allows to get more information - Jean-Guilhem Rouel (jean-gui) - Yoann MOROCUTTI - jfcixmedia + - Tomasz Kusy - Dominic Tubach - Nikita Konstantinov - Martijn Evers @@ -1635,6 +1646,7 @@ The Symfony Connect username in parenthesis allows to get more information - Philipp Fritsche - tarlepp - Benjamin Paap (benjaminpaap) + - Guillaume Aveline - Christian - Denis Golubovskiy (bukashk0zzz) - Arkadiusz Rzadkowolski (flies) @@ -1647,7 +1659,6 @@ The Symfony Connect username in parenthesis allows to get more information - hugofonseca (fonsecas72) - Marc Duboc (icemad) - Martynas Narbutas - - Toon Verwerft (veewee) - Bailey Parker - Eddie Jaoude - Antanas Arvasevicius @@ -1716,6 +1727,7 @@ The Symfony Connect username in parenthesis allows to get more information - Charles Sanquer (csanquer) - Albert Ganiev (helios-ag) - Neil Katin + - Oleg Mifle - David Otton - Will Donohoe - gnito-org @@ -1740,13 +1752,13 @@ The Symfony Connect username in parenthesis allows to get more information - Amine Yakoubi - Eduardo García Sanz (coma) - Sergio (deverad) - - simon chrzanowski (simonch) - Makdessi Alex - James Gilliland - fduch (fduch) - Juan Miguel Besada Vidal (soutlink) - dlorek - Stuart Fyfe + - Jason Schilling (chapterjason) - David de Boer (ddeboer) - Eno Mullaraj (emullaraj) - Nathan PAGE (nathix) @@ -1765,6 +1777,7 @@ The Symfony Connect username in parenthesis allows to get more information - Roger Webb - Dmitriy Simushev - Pawel Smolinski + - Simon Watiau (simonwatiau) - Oxan van Leeuwen - pkowalczyk - Soner Sayakci @@ -1815,6 +1828,7 @@ The Symfony Connect username in parenthesis allows to get more information - Dmitri Petmanson - heccjj - Alexandre Melard + - PierreRebeilleau - Jay Klehr - Sergey Yuferev - Tobias Stöckler @@ -1954,7 +1968,6 @@ The Symfony Connect username in parenthesis allows to get more information - Wojciech Błoszyk (wbloszyk) - Giorgio Premi - abunch - - Sergey Belyshkin - tamcy - Mikko Pesari - ncou @@ -1982,6 +1995,7 @@ The Symfony Connect username in parenthesis allows to get more information - Raphaëll Roussel - Tadcka - Beth Binkovitz + - Maxim Semkin - Gonzalo Míguez - Fabian Haase - Romain Geissler @@ -2027,6 +2041,7 @@ The Symfony Connect username in parenthesis allows to get more information - Tony Vermeiren (tony) - Bart Wach - Jos Elstgeest + - Kirill Lazarev - Thomas Counsell - BilgeXA - r1pp3rj4ck @@ -2197,6 +2212,7 @@ The Symfony Connect username in parenthesis allows to get more information - Matt Farmer - catch - aetxebeste + - Vitali Tsyrkin - Juga Paazmaya - Alexandre Segura - afaricamp @@ -2249,6 +2265,7 @@ The Symfony Connect username in parenthesis allows to get more information - Andreas - Markus - agaktr + - Mostafa - kernig - Thomas Chmielowiec - shdev @@ -2276,6 +2293,7 @@ The Symfony Connect username in parenthesis allows to get more information - Christoph Nissle (derstoffel) - Denys Voronin (hurricane) - Ionel Scutelnicu (ionelscutelnicu) + - Juan Gonzalez Montes (juanwilde) - Mathieu Dewet (mdewet) - Nicolas Tallefourtané (nicolab) - Botond Dani (picur) @@ -2294,7 +2312,6 @@ The Symfony Connect username in parenthesis allows to get more information - Christopher Parotat - Dennis Haarbrink - me_shaon - - Maximilian Bösing - 蝦米 - Grayson Koonce (breerly) - Andrey Helldar (helldar) @@ -2373,6 +2390,7 @@ The Symfony Connect username in parenthesis allows to get more information - Cyril Pascal (paxal) - Cédric Dugat (ph3nol) - Philip Dahlstrøm (phidah) + - Pierre Rebeilleau (pierrereb) - Milos Colakovic (project2481) - Raphael de Almeida (raphaeldealmeida) - Rénald Casagraude (rcasagraude) @@ -2435,6 +2453,7 @@ The Symfony Connect username in parenthesis allows to get more information - Peter Breuls - Chansig - Tischoi + - divinity76 - Andreas Hasenack - J Bruni - Alexey Prilipko @@ -2468,6 +2487,7 @@ The Symfony Connect username in parenthesis allows to get more information - Pedro Magalhães (pmmaga) - Rares Vlaseanu (raresvla) - Sergii Dolgushev (serhey) + - Rein Baarsma (solidwebcode) - tante kinast (tante) - Stephen Lewis (tehanomalousone) - Ahmed Hannachi (tiecoders) @@ -2525,6 +2545,7 @@ The Symfony Connect username in parenthesis allows to get more information - grifx - Robert Campbell - Matt Lehner + - Olexandr Kalaidzhy - Helmut Januschka - Hein Zaw Htet™ - Ruben Kruiswijk @@ -2585,6 +2606,7 @@ The Symfony Connect username in parenthesis allows to get more information - Gerrit Drost - Linnaea Von Lavia - Bastien Clément + - Julius Šakalys - Javan Eskander - Lenar Lõhmus - Cristian Gonzalez @@ -2596,7 +2618,6 @@ The Symfony Connect username in parenthesis allows to get more information - Pavinthan - Sylvain METAYER - ddebree - - Kuba Werłos - Gyula Szucs - Tomas Liubinas - Ivo Valchev @@ -2664,7 +2685,6 @@ The Symfony Connect username in parenthesis allows to get more information - Geordie - Exploit.cz - GuillaumeVerdon - - Philipp Keck - Angel Fernando Quiroz Campos - Ondrej Mirtes - akimsko @@ -2726,6 +2746,7 @@ The Symfony Connect username in parenthesis allows to get more information - arend - Vincent Godé - Dusan Kasan + - helmi - Michael Steininger - Nardberjean - Karolis @@ -2865,6 +2886,7 @@ The Symfony Connect username in parenthesis allows to get more information - Babichev Maxim - Edvin Hultberg - Benjamin Long + - Kévin Gonella - Ben Miller - Peter Gribanov - Ash014 @@ -2896,6 +2918,7 @@ The Symfony Connect username in parenthesis allows to get more information - Steve Marvell - Dawid Nowak - Lesnykh Ilia + - Shyim - sabruss - darnel - Karolis Daužickas @@ -2943,6 +2966,7 @@ The Symfony Connect username in parenthesis allows to get more information - Christian Gripp (core23) - Christoph Schaefer (cvschaefer) - Damon Jones (damon__jones) + - Alexandre Fiocre (demos77) - Łukasz Giza (destroyer) - Daniel Londero (dlondero) - Sebastian Landwehr (dword123) diff --git a/LICENSE b/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/LICENSE +++ b/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/UPGRADE-6.1.md b/UPGRADE-6.1.md new file mode 100644 index 0000000000000..7c1c91fd4e62e --- /dev/null +++ b/UPGRADE-6.1.md @@ -0,0 +1,10 @@ +UPGRADE FROM 6.0 to 6.1 +======================= + +Serializer +---------- + + * Deprecate `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead + * Deprecate `ContextAwareDenormalizerInterface`, use `DenormalizerInterface` instead + * Deprecate `ContextAwareEncoderInterface`, use `EncoderInterface` instead + * Deprecate `ContextAwareDecoderInterface`, use `DecoderInterface` instead diff --git a/composer.json b/composer.json index 1750d55ee22ce..c7b7eac4c77b9 100644 --- a/composer.json +++ b/composer.json @@ -122,12 +122,11 @@ "async-aws/sqs": "^1.0", "async-aws/sns": "^1.0", "cache/integration-tests": "dev-master", - "composer/package-versions-deprecated": "^1.8", "doctrine/annotations": "^1.13.1", "doctrine/collections": "~1.0", "doctrine/data-fixtures": "^1.1", "doctrine/dbal": "^2.13.1|^3.0", - "doctrine/orm": "^2.7.3", + "doctrine/orm": "^2.7.4", "guzzlehttp/promises": "^1.4", "masterminds/html5": "^2.6", "monolog/monolog": "^1.25.1|^2", @@ -161,6 +160,12 @@ "ocramius/proxy-manager": "<2.1", "phpunit/phpunit": "<5.4.3" }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true, + "symfony/runtime": true + } + }, "autoload": { "psr-4": { "Symfony\\Bridge\\Doctrine\\": "src/Symfony/Bridge/Doctrine/", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 05776834693a5..d0f1bf87a1e05 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -18,6 +18,7 @@ + diff --git a/psalm.xml b/psalm.xml index 015c0ed18b21b..3fb94145699cf 100644 --- a/psalm.xml +++ b/psalm.xml @@ -27,5 +27,13 @@ + + + + + + + + diff --git a/src/Symfony/Bridge/Doctrine/LICENSE b/src/Symfony/Bridge/Doctrine/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bridge/Doctrine/LICENSE +++ b/src/Symfony/Bridge/Doctrine/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/Bridge/Doctrine/composer.json b/src/Symfony/Bridge/Doctrine/composer.json index afe4bad51555c..d482efec66af8 100644 --- a/src/Symfony/Bridge/Doctrine/composer.json +++ b/src/Symfony/Bridge/Doctrine/composer.json @@ -25,7 +25,6 @@ "symfony/service-contracts": "^1.1|^2|^3" }, "require-dev": { - "composer/package-versions-deprecated": "^1.8", "symfony/stopwatch": "^5.4|^6.0", "symfony/cache": "^5.4|^6.0", "symfony/config": "^5.4|^6.0", @@ -47,13 +46,13 @@ "doctrine/collections": "~1.0", "doctrine/data-fixtures": "^1.1", "doctrine/dbal": "^2.13.1|^3.0", - "doctrine/orm": "^2.7.3", + "doctrine/orm": "^2.7.4", "psr/log": "^1|^2|^3" }, "conflict": { "doctrine/dbal": "<2.13.1", "doctrine/lexer": "<1.1", - "doctrine/orm": "<2.7.3", + "doctrine/orm": "<2.7.4", "phpunit/phpunit": "<5.4.3", "symfony/cache": "<5.4", "symfony/dependency-injection": "<5.4", diff --git a/src/Symfony/Bridge/Monolog/LICENSE b/src/Symfony/Bridge/Monolog/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bridge/Monolog/LICENSE +++ b/src/Symfony/Bridge/Monolog/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/Bridge/PhpUnit/LICENSE b/src/Symfony/Bridge/PhpUnit/LICENSE index c1f0aac1c5614..a843ec124ea70 100644 --- a/src/Symfony/Bridge/PhpUnit/LICENSE +++ b/src/Symfony/Bridge/PhpUnit/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/Bridge/ProxyManager/LICENSE b/src/Symfony/Bridge/ProxyManager/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bridge/ProxyManager/LICENSE +++ b/src/Symfony/Bridge/ProxyManager/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/Bridge/ProxyManager/composer.json b/src/Symfony/Bridge/ProxyManager/composer.json index 1f3e703199c67..430c4edd1e608 100644 --- a/src/Symfony/Bridge/ProxyManager/composer.json +++ b/src/Symfony/Bridge/ProxyManager/composer.json @@ -17,7 +17,6 @@ ], "require": { "php": ">=8.0.2", - "composer/package-versions-deprecated": "^1.8", "friendsofphp/proxy-manager-lts": "^1.0.2", "symfony/dependency-injection": "^5.4|^6.0" }, diff --git a/src/Symfony/Bridge/Twig/LICENSE b/src/Symfony/Bridge/Twig/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bridge/Twig/LICENSE +++ b/src/Symfony/Bridge/Twig/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/Bundle/DebugBundle/LICENSE b/src/Symfony/Bundle/DebugBundle/LICENSE index c1f0aac1c5614..a843ec124ea70 100644 --- a/src/Symfony/Bundle/DebugBundle/LICENSE +++ b/src/Symfony/Bundle/DebugBundle/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/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2a74d17458882..02b4e24666449 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -79,6 +79,7 @@ use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -238,6 +239,11 @@ public function load(array $configs, ContainerBuilder $container) $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); $loader->load('web.php'); + + if (\PHP_VERSION_ID < 80100 || !class_exists(BackedEnumValueResolver::class)) { + $container->removeDefinition('argument_resolver.backed_enum_resolver'); + } + $loader->load('services.php'); $loader->load('fragment_renderer.php'); $loader->load('error_renderer.php'); @@ -2125,7 +2131,9 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $pool['reset'] = 'reset'; } - if ($isRedisTagAware) { + if ($isRedisTagAware && 'cache.app' === $name) { + $container->setAlias('cache.app.taggable', $name); + } elseif ($isRedisTagAware) { $tagAwareId = $name; $container->setAlias('.'.$name.'.inner', $name); } elseif ($pool['tags']) { diff --git a/src/Symfony/Bundle/FrameworkBundle/LICENSE b/src/Symfony/Bundle/FrameworkBundle/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/LICENSE +++ b/src/Symfony/Bundle/FrameworkBundle/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/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 53613d3b5020c..a7d91bfd4a69d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; @@ -45,6 +46,11 @@ abstract_arg('argument value resolvers'), ]) + ->set('argument_resolver.backed_enum_resolver', BackedEnumValueResolver::class) + ->tag('controller.argument_value_resolver', [ + 'priority' => 105, // prior to the RequestAttributeValueResolver + ]) + ->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class) ->tag('controller.argument_value_resolver', ['priority' => 100]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware.php new file mode 100644 index 0000000000000..44855c62adbf1 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware.php @@ -0,0 +1,7 @@ +loadFromExtension('framework', [ + 'cache' => [ + 'app' => 'cache.adapter.redis_tag_aware', + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware_pool.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware_pool.php new file mode 100644 index 0000000000000..bf3ee2de2b357 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/cache_app_redis_tag_aware_pool.php @@ -0,0 +1,12 @@ +loadFromExtension('framework', [ + 'cache' => [ + 'app' => 'cache.redis_tag_aware.foo', + 'pools' => [ + 'cache.redis_tag_aware.foo' => [ + 'adapter' => 'cache.adapter.redis_tag_aware', + ], + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware.xml new file mode 100644 index 0000000000000..2929e87e200e8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware.xml @@ -0,0 +1,13 @@ + + + + + + cache.adapter.redis_tag_aware + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware_pool.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware_pool.xml new file mode 100644 index 0000000000000..65c06a1da6df7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/cache_app_redis_tag_aware_pool.xml @@ -0,0 +1,14 @@ + + + + + + cache.redis_tag_aware.foo + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware.yml new file mode 100644 index 0000000000000..b1c89adafa0ca --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware.yml @@ -0,0 +1,3 @@ +framework: + cache: + app: cache.adapter.redis_tag_aware diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware_pool.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware_pool.yml new file mode 100644 index 0000000000000..9eb8b83c775c5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/cache_app_redis_tag_aware_pool.yml @@ -0,0 +1,6 @@ +framework: + cache: + app: cache.redis_tag_aware.foo + pools: + cache.redis_tag_aware.foo: + adapter: cache.adapter.redis_tag_aware diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 4b34e561f10b7..c054edd268ad1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -1588,6 +1588,32 @@ public function testRedisTagAwareAdapter() } } + /** + * @dataProvider appRedisTagAwareConfigProvider + */ + public function testAppRedisTagAwareAdapter(string $configFile) + { + $container = $this->createContainerFromFile($configFile); + + foreach ([TagAwareCacheInterface::class, CacheInterface::class, CacheItemPoolInterface::class] as $alias) { + $def = $container->findDefinition($alias); + + while ($def instanceof ChildDefinition) { + $def = $container->getDefinition($def->getParent()); + } + + $this->assertSame(RedisTagAwareAdapter::class, $def->getClass()); + } + } + + public function appRedisTagAwareConfigProvider(): array + { + return [ + ['cache_app_redis_tag_aware'], + ['cache_app_redis_tag_aware_pool'], + ]; + } + public function testRemovesResourceCheckerConfigCacheFactoryArgumentOnlyIfNoDebug() { $container = $this->createContainer(['kernel.debug' => true]); diff --git a/src/Symfony/Bundle/SecurityBundle/LICENSE b/src/Symfony/Bundle/SecurityBundle/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bundle/SecurityBundle/LICENSE +++ b/src/Symfony/Bundle/SecurityBundle/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/Bundle/TwigBundle/LICENSE b/src/Symfony/Bundle/TwigBundle/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bundle/TwigBundle/LICENSE +++ b/src/Symfony/Bundle/TwigBundle/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/Bundle/WebProfilerBundle/LICENSE b/src/Symfony/Bundle/WebProfilerBundle/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/LICENSE +++ b/src/Symfony/Bundle/WebProfilerBundle/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/Bundle/WebProfilerBundle/Resources/views/Collector/mailer.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/mailer.html.twig index bdca4eb968fbd..dab2e9c6c0c67 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/mailer.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/mailer.html.twig @@ -141,42 +141,48 @@ {% if message.htmlBody is defined %} {# Email instance #} -
-

HTML preview

-
-
-                                                            
-                                                        
+ {% set htmlBody = message.htmlBody() %} + {% if htmlBody is not null %} +
+

HTML Preview

+
+
+                                                                
+                                                            
+
-
-
-

HTML Content

-
-
-                                                            {%- if message.htmlCharset() %}
-                                                                {{- message.htmlBody()|convert_encoding('UTF-8', message.htmlCharset()) }}
-                                                            {%- else %}
-                                                                {{- message.htmlBody() }}
-                                                            {%- endif -%}
-                                                        
+
+

HTML Content

+
+
+                                                                {%- if message.htmlCharset() %}
+                                                                    {{- htmlBody|convert_encoding('UTF-8', message.htmlCharset()) }}
+                                                                {%- else %}
+                                                                    {{- htmlBody }}
+                                                                {%- endif -%}
+                                                            
+
-
-
-

Text Content

-
-
-                                                            {%- if message.textCharset() %}
-                                                                {{- message.textBody()|convert_encoding('UTF-8', message.textCharset()) }}
-                                                            {%- else %}
-                                                                {{- message.textBody() }}
-                                                            {%- endif -%}
-                                                        
+ {% endif %} + {% set textBody = message.textBody() %} + {% if textBody is not null %} +
+

Text Content

+
+
+                                                                {%- if message.textCharset() %}
+                                                                    {{- textBody|convert_encoding('UTF-8', message.textCharset()) }}
+                                                                {%- else %}
+                                                                    {{- textBody }}
+                                                                {%- endif -%}
+                                                            
+
-
+ {% endif %} {% for attachment in message.attachments %}

Attachment #{{ loop.index }}

diff --git a/src/Symfony/Component/Asset/LICENSE b/src/Symfony/Component/Asset/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Asset/LICENSE +++ b/src/Symfony/Component/Asset/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/BrowserKit/LICENSE b/src/Symfony/Component/BrowserKit/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/BrowserKit/LICENSE +++ b/src/Symfony/Component/BrowserKit/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/Cache/LICENSE b/src/Symfony/Component/Cache/LICENSE index 3796612f43c2b..7fa9539054928 100644 --- a/src/Symfony/Component/Cache/LICENSE +++ b/src/Symfony/Component/Cache/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/Cache/LockRegistry.php b/src/Symfony/Component/Cache/LockRegistry.php index da67036dc4666..f31840495c8aa 100644 --- a/src/Symfony/Component/Cache/LockRegistry.php +++ b/src/Symfony/Component/Cache/LockRegistry.php @@ -90,7 +90,7 @@ public static function compute(callable $callback, ItemInterface $item, bool &$s $key = self::$files ? abs(crc32($item->getKey())) % \count(self::$files) : -1; - if ($key < 0 || (self::$lockedFiles[$key] ?? false) || !$lock = self::open($key)) { + if ($key < 0 || self::$lockedFiles || !$lock = self::open($key)) { return $callback($item, $save); } diff --git a/src/Symfony/Component/Cache/Psr16Cache.php b/src/Symfony/Component/Cache/Psr16Cache.php index e235df8618166..4d2936ca4c8c8 100644 --- a/src/Symfony/Component/Cache/Psr16Cache.php +++ b/src/Symfony/Component/Cache/Psr16Cache.php @@ -30,7 +30,7 @@ class Psr16Cache implements CacheInterface, PruneableInterface, ResettableInterf private const METADATA_EXPIRY_OFFSET = 1527506807; - private \Closure $createCacheItem; + private ?\Closure $createCacheItem = null; private ?CacheItem $cacheItemPrototype = null; public function __construct(CacheItemPoolInterface $pool) diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php index eee85bff6ee53..ab0f7da134f90 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php @@ -306,6 +306,15 @@ public function testWeirdDataMatchingMetadataWrappedValues() $this->assertTrue($cache->hasItem('foobar')); } + + public function testNullByteInKey() + { + $cache = $this->createCachePool(0, __FUNCTION__); + + $cache->save($cache->getItem("a\0b")->set(123)); + + $this->assertSame(123, $cache->getItem("a\0b")->get()); + } } class NotUnserializable diff --git a/src/Symfony/Component/Cache/Tests/Marshaller/DefaultMarshallerTest.php b/src/Symfony/Component/Cache/Tests/Marshaller/DefaultMarshallerTest.php index f945c4637edab..4bc238ed341bd 100644 --- a/src/Symfony/Component/Cache/Tests/Marshaller/DefaultMarshallerTest.php +++ b/src/Symfony/Component/Cache/Tests/Marshaller/DefaultMarshallerTest.php @@ -44,7 +44,7 @@ public function testNativeUnserialize() public function testIgbinaryUnserialize() { if (version_compare('3.1.6', phpversion('igbinary'), '>')) { - $this->markTestSkipped('igbinary is not compatible with PHP 7.4.'); + $this->markTestSkipped('igbinary needs to be v3.1.6 or higher.'); } $marshaller = new DefaultMarshaller(); @@ -68,7 +68,7 @@ public function testNativeUnserializeNotFoundClass() public function testIgbinaryUnserializeNotFoundClass() { if (version_compare('3.1.6', phpversion('igbinary'), '>')) { - $this->markTestSkipped('igbinary is not compatible with PHP 7.4.'); + $this->markTestSkipped('igbinary needs to be v3.1.6 or higher.'); } $this->expectException(\DomainException::class); @@ -96,7 +96,7 @@ public function testNativeUnserializeInvalid() public function testIgbinaryUnserializeInvalid() { if (version_compare('3.1.6', phpversion('igbinary'), '>')) { - $this->markTestSkipped('igbinary is not compatible with PHP 7.4.'); + $this->markTestSkipped('igbinary needs to be v3.1.6 or higher.'); } $this->expectException(\DomainException::class); diff --git a/src/Symfony/Component/Cache/Tests/Psr16CacheWithExternalAdapter.php b/src/Symfony/Component/Cache/Tests/Psr16CacheWithExternalAdapter.php new file mode 100644 index 0000000000000..e018a276de7ca --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Psr16CacheWithExternalAdapter.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests; + +use Cache\IntegrationTests\SimpleCacheTest; +use Psr\SimpleCache\CacheInterface; +use Symfony\Component\Cache\Psr16Cache; +use Symfony\Component\Cache\Tests\Fixtures\ExternalAdapter; + +/** + * @group time-sensitive + */ +class Psr16CacheWithExternalAdapter extends SimpleCacheTest +{ + protected function setUp(): void + { + parent::setUp(); + + $this->skippedTests['testSetTtl'] = + $this->skippedTests['testSetMultipleTtl'] = 'The ExternalAdapter test class does not support TTLs.'; + } + + public function createSimpleCache(): CacheInterface + { + return new Psr16Cache(new ExternalAdapter()); + } +} diff --git a/src/Symfony/Component/Config/Definition/Builder/ExprBuilder.php b/src/Symfony/Component/Config/Definition/Builder/ExprBuilder.php index 22f8280d6018a..a9f158e8ea259 100644 --- a/src/Symfony/Component/Config/Definition/Builder/ExprBuilder.php +++ b/src/Symfony/Component/Config/Definition/Builder/ExprBuilder.php @@ -90,8 +90,10 @@ public function ifNull(): static /** * Tests if the value is empty. + * + * @return $this */ - public function ifEmpty(): self + public function ifEmpty(): static { $this->ifPart = function ($v) { return empty($v); }; diff --git a/src/Symfony/Component/Config/Exception/LoaderLoadException.php b/src/Symfony/Component/Config/Exception/LoaderLoadException.php index c185c43ad0d5b..7ef6be83a1dc7 100644 --- a/src/Symfony/Component/Config/Exception/LoaderLoadException.php +++ b/src/Symfony/Component/Config/Exception/LoaderLoadException.php @@ -62,12 +62,7 @@ public function __construct(string $resource, string $sourceResource = null, int $message .= sprintf(' Make sure the "%s" bundle is correctly registered and loaded in the application kernel class.', $bundle); $message .= sprintf(' If the bundle is registered, make sure the bundle path "%s" is not empty.', $resource); } elseif (null !== $type) { - // maybe there is no loader for this specific type - if ('annotation' === $type) { - $message .= ' Make sure to use PHP 8+ or that annotations are installed and enabled.'; - } else { - $message .= sprintf(' Make sure there is a loader supporting the "%s" type.', $type); - } + $message .= sprintf(' Make sure there is a loader supporting the "%s" type.', $type); } parent::__construct($message, $code, $previous); diff --git a/src/Symfony/Component/Config/LICENSE b/src/Symfony/Component/Config/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Config/LICENSE +++ b/src/Symfony/Component/Config/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/Config/Tests/Exception/LoaderLoadExceptionTest.php b/src/Symfony/Component/Config/Tests/Exception/LoaderLoadExceptionTest.php index 3150c5a83c31c..995a72963c84b 100644 --- a/src/Symfony/Component/Config/Tests/Exception/LoaderLoadExceptionTest.php +++ b/src/Symfony/Component/Config/Tests/Exception/LoaderLoadExceptionTest.php @@ -31,7 +31,7 @@ public function testMessageCannotLoadResourceWithType() public function testMessageCannotLoadResourceWithAnnotationType() { $exception = new LoaderLoadException('resource', null, 0, null, 'annotation'); - $this->assertEquals('Cannot load resource "resource". Make sure to use PHP 8+ or that annotations are installed and enabled.', $exception->getMessage()); + $this->assertEquals('Cannot load resource "resource". Make sure there is a loader supporting the "annotation" type.', $exception->getMessage()); } public function testMessageCannotImportResourceFromSource() diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 9a93c7991de89..e7f9325125c25 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.1 +--- + + * Add method `__toString()` to `InputInterface` + 6.0 --- diff --git a/src/Symfony/Component/Console/Completion/CompletionInput.php b/src/Symfony/Component/Console/Completion/CompletionInput.php index b0f00088e7150..2a20f4bb77efe 100644 --- a/src/Symfony/Component/Console/Completion/CompletionInput.php +++ b/src/Symfony/Component/Console/Completion/CompletionInput.php @@ -109,12 +109,12 @@ public function bind(InputDefinition $definition): void // complete argument value $this->completionType = self::TYPE_ARGUMENT_VALUE; - $arguments = $this->getArguments(); - foreach ($arguments as $argumentName => $argumentValue) { - if (null === $argumentValue) { + foreach ($this->definition->getArguments() as $argumentName => $argument) { + if (!isset($this->arguments[$argumentName])) { break; } + $argumentValue = $this->arguments[$argumentName]; $this->completionName = $argumentName; if (\is_array($argumentValue)) { $this->completionValue = $argumentValue ? $argumentValue[array_key_last($argumentValue)] : null; @@ -124,7 +124,7 @@ public function bind(InputDefinition $definition): void } if ($this->currentIndex >= \count($this->tokens)) { - if (null === $arguments[$argumentName] || $this->definition->getArgument($argumentName)->isArray()) { + if (!isset($this->arguments[$argumentName]) || $this->definition->getArgument($argumentName)->isArray()) { $this->completionName = $argumentName; $this->completionValue = ''; } else { diff --git a/src/Symfony/Component/Console/Input/InputInterface.php b/src/Symfony/Component/Console/Input/InputInterface.php index 024da1884eacb..3af991a76fbb8 100644 --- a/src/Symfony/Component/Console/Input/InputInterface.php +++ b/src/Symfony/Component/Console/Input/InputInterface.php @@ -18,6 +18,9 @@ * InputInterface is the interface implemented by all input classes. * * @author Fabien Potencier + * + * @method string __toString() Returns a stringified representation of the args passed to the command. + * InputArguments MUST be escaped as well as the InputOption values passed to the command. */ interface InputInterface { diff --git a/src/Symfony/Component/Console/LICENSE b/src/Symfony/Component/Console/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Console/LICENSE +++ b/src/Symfony/Component/Console/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/Console/Tests/Command/HelpCommandTest.php b/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php index bf0ab972061bc..0e8a7f4f7fd1a 100644 --- a/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php +++ b/src/Symfony/Component/Console/Tests/Command/HelpCommandTest.php @@ -92,7 +92,7 @@ public function provideCompletionSuggestions() yield 'nothing' => [ [''], - [], + ['completion', 'help', 'list', 'foo:bar'], ]; yield 'command_name' => [ diff --git a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php index f83a0f89893aa..ee370076c17ac 100644 --- a/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php +++ b/src/Symfony/Component/Console/Tests/Completion/CompletionInputTest.php @@ -97,6 +97,20 @@ public function provideBindWithLastArrayArgumentData() yield [CompletionInput::fromTokens(['bin/console', 'symfony', 'sen'], 2), 'sen']; } + public function testBindArgumentWithDefault() + { + $definition = new InputDefinition([ + new InputArgument('arg-with-default', InputArgument::OPTIONAL, '', 'default'), + ]); + + $input = CompletionInput::fromTokens(['bin/console'], 1); + $input->bind($definition); + + $this->assertEquals(CompletionInput::TYPE_ARGUMENT_VALUE, $input->getCompletionType(), 'Unexpected type'); + $this->assertEquals('arg-with-default', $input->getCompletionName(), 'Unexpected name'); + $this->assertEquals('', $input->getCompletionValue(), 'Unexpected value'); + } + /** * @dataProvider provideFromStringData */ diff --git a/src/Symfony/Component/Console/Tests/EventListener/ErrorListenerTest.php b/src/Symfony/Component/Console/Tests/EventListener/ErrorListenerTest.php index becc9e5405c46..6ad89dc522692 100644 --- a/src/Symfony/Component/Console/Tests/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Component/Console/Tests/EventListener/ErrorListenerTest.php @@ -143,4 +143,9 @@ public function getParameterOption($values, $default = false, $onlyParams = fals public function parse() { } + + public function __toString(): string + { + return ''; + } } diff --git a/src/Symfony/Component/CssSelector/LICENSE b/src/Symfony/Component/CssSelector/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/CssSelector/LICENSE +++ b/src/Symfony/Component/CssSelector/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/DependencyInjection/Argument/TaggedIteratorArgument.php b/src/Symfony/Component/DependencyInjection/Argument/TaggedIteratorArgument.php index b81e3a3a7f93a..c33e8615b254e 100644 --- a/src/Symfony/Component/DependencyInjection/Argument/TaggedIteratorArgument.php +++ b/src/Symfony/Component/DependencyInjection/Argument/TaggedIteratorArgument.php @@ -23,6 +23,7 @@ class TaggedIteratorArgument extends IteratorArgument private ?string $defaultIndexMethod; private ?string $defaultPriorityMethod; private bool $needsIndexes; + private array $exclude; /** * @param string $tag The name of the tag identifying the target services @@ -30,8 +31,9 @@ class TaggedIteratorArgument extends IteratorArgument * @param string|null $defaultIndexMethod The static method that should be called to get each service's key when their tag doesn't define the previous attribute * @param bool $needsIndexes Whether indexes are required and should be generated when computing the map * @param string|null $defaultPriorityMethod The static method that should be called to get each service's priority when their tag doesn't define the "priority" attribute + * @param array $exclude Services to exclude from the iterator */ - public function __construct(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, bool $needsIndexes = false, string $defaultPriorityMethod = null) + public function __construct(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, bool $needsIndexes = false, string $defaultPriorityMethod = null, array $exclude = []) { parent::__construct([]); @@ -44,6 +46,7 @@ public function __construct(string $tag, string $indexAttribute = null, string $ $this->defaultIndexMethod = $defaultIndexMethod ?: ($indexAttribute ? 'getDefault'.str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $indexAttribute))).'Name' : null); $this->needsIndexes = $needsIndexes; $this->defaultPriorityMethod = $defaultPriorityMethod ?: ($indexAttribute ? 'getDefault'.str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $indexAttribute))).'Priority' : null); + $this->exclude = $exclude; } public function getTag() @@ -70,4 +73,9 @@ public function getDefaultPriorityMethod(): ?string { return $this->defaultPriorityMethod; } + + public function getExclude(): array + { + return $this->exclude; + } } diff --git a/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php b/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php index d498f46470c15..5898a6afe0e81 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php @@ -19,6 +19,7 @@ public function __construct( public ?string $indexAttribute = null, public ?string $defaultIndexMethod = null, public ?string $defaultPriorityMethod = null, + public string|array $exclude = [], ) { } } diff --git a/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php b/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php index 4617e0f51dce3..b706a6388bf0d 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php @@ -19,6 +19,7 @@ public function __construct( public ?string $indexAttribute = null, public ?string $defaultIndexMethod = null, public ?string $defaultPriorityMethod = null, + public string|array $exclude = [], ) { } } diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index 44c5d55f14630..f27c70d302433 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +6.1 +--- + + * Add `$exclude` to `TaggedIterator` and `TaggedLocator` attributes + * Add `$exclude` to `tagged_iterator` and `tagged_locator` configurator + 6.0 --- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AbstractRecursivePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AbstractRecursivePass.php index 326d8d4f5fb5c..c2824f4578db3 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AbstractRecursivePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AbstractRecursivePass.php @@ -192,6 +192,10 @@ protected function getReflectionMethod(Definition $definition, string $method): } if (!$r->hasMethod($method)) { + if ($r->hasMethod('__call') && ($r = $r->getMethod('__call')) && $r->isPublic()) { + return new \ReflectionMethod(static function (...$arguments) {}, '__invoke'); + } + throw new RuntimeException(sprintf('Invalid service "%s": method "%s()" does not exist.', $this->currentId, $class !== $this->currentId ? $class.'::'.$method : $method)); } diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php index bd85168a21af2..f65e2b827e4d6 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php @@ -247,13 +247,13 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a foreach ($parameter->getAttributes() as $attribute) { if (TaggedIterator::class === $attribute->getName()) { $attribute = $attribute->newInstance(); - $arguments[$index] = new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod); + $arguments[$index] = new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod, (array) $attribute->exclude); break; } if (TaggedLocator::class === $attribute->getName()) { $attribute = $attribute->newInstance(); - $arguments[$index] = new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod)); + $arguments[$index] = new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod, (array) $attribute->exclude)); break; } } diff --git a/src/Symfony/Component/DependencyInjection/Compiler/PriorityTaggedServiceTrait.php b/src/Symfony/Component/DependencyInjection/Compiler/PriorityTaggedServiceTrait.php index afc081ec1bad8..309bf63118d4e 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/PriorityTaggedServiceTrait.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/PriorityTaggedServiceTrait.php @@ -39,6 +39,7 @@ trait PriorityTaggedServiceTrait */ private function findAndSortTaggedServices(string|TaggedIteratorArgument $tagName, ContainerBuilder $container): array { + $exclude = []; $indexAttribute = $defaultIndexMethod = $needsIndexes = $defaultPriorityMethod = null; if ($tagName instanceof TaggedIteratorArgument) { @@ -46,6 +47,7 @@ private function findAndSortTaggedServices(string|TaggedIteratorArgument $tagNam $defaultIndexMethod = $tagName->getDefaultIndexMethod(); $needsIndexes = $tagName->needsIndexes(); $defaultPriorityMethod = $tagName->getDefaultPriorityMethod() ?? 'getDefaultPriority'; + $exclude = $tagName->getExclude(); $tagName = $tagName->getTag(); } @@ -53,6 +55,10 @@ private function findAndSortTaggedServices(string|TaggedIteratorArgument $tagNam $services = []; foreach ($container->findTaggedServiceIds($tagName, true) as $serviceId => $attributes) { + if (\in_array($serviceId, $exclude, true)) { + continue; + } + $defaultPriority = null; $defaultIndex = null; $definition = $container->getDefinition($serviceId); diff --git a/src/Symfony/Component/DependencyInjection/Compiler/ResolveBindingsPass.php b/src/Symfony/Component/DependencyInjection/Compiler/ResolveBindingsPass.php index b58d4a452549f..dc94a8b95fd73 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/ResolveBindingsPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/ResolveBindingsPass.php @@ -134,6 +134,11 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed continue; } + if (is_subclass_of($m[1], \UnitEnum::class)) { + $bindingNames[substr($key, \strlen($m[0]))] = $binding; + continue; + } + if (null !== $bindingValue && !$bindingValue instanceof Reference && !$bindingValue instanceof Definition && !$bindingValue instanceof TaggedIteratorArgument && !$bindingValue instanceof ServiceLocatorArgument) { throw new InvalidArgumentException(sprintf('Invalid value for binding key "%s" for service "%s": expected "%s", "%s", "%s", "%s" or null, "%s" given.', $key, $this->currentId, Reference::class, Definition::class, TaggedIteratorArgument::class, ServiceLocatorArgument::class, get_debug_type($bindingValue))); } diff --git a/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php b/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php index 4b02e8c5dcfad..038c02f2237f7 100644 --- a/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php +++ b/src/Symfony/Component/DependencyInjection/Dumper/XmlDumper.php @@ -138,7 +138,7 @@ private function addService(Definition $definition, ?string $id, \DOMElement $pa $tag->appendChild($this->document->createTextNode($name)); } foreach ($attributes as $key => $value) { - $tag->setAttribute($key, $value); + $tag->setAttribute($key, $value ?? ''); } $service->appendChild($tag); } diff --git a/src/Symfony/Component/DependencyInjection/LICENSE b/src/Symfony/Component/DependencyInjection/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/DependencyInjection/LICENSE +++ b/src/Symfony/Component/DependencyInjection/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/DependencyInjection/Loader/Configurator/ContainerConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php index 77fc321659d5a..03c09773c27cb 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ContainerConfigurator.php @@ -139,17 +139,17 @@ function iterator(array $values): IteratorArgument /** * Creates a lazy iterator by tag name. */ -function tagged_iterator(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, string $defaultPriorityMethod = null): TaggedIteratorArgument +function tagged_iterator(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, string $defaultPriorityMethod = null, string|array $exclude = []): TaggedIteratorArgument { - return new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, false, $defaultPriorityMethod); + return new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, false, $defaultPriorityMethod, (array) $exclude); } /** * Creates a service locator by tag name. */ -function tagged_locator(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, string $defaultPriorityMethod = null): ServiceLocatorArgument +function tagged_locator(string $tag, string $indexAttribute = null, string $defaultIndexMethod = null, string $defaultPriorityMethod = null, string|array $exclude = []): ServiceLocatorArgument { - return new ServiceLocatorArgument(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, true, $defaultPriorityMethod)); + return new ServiceLocatorArgument(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, true, $defaultPriorityMethod, (array) $exclude)); } /** diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/CheckTypeDeclarationsPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/CheckTypeDeclarationsPassTest.php index c409f80cfcdd7..d26c0928c1050 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/CheckTypeDeclarationsPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/CheckTypeDeclarationsPassTest.php @@ -961,4 +961,22 @@ public function testIntersectionTypeFailsWithReference() (new CheckTypeDeclarationsPass(true))->process($container); } + + public function testCallableClass() + { + $container = new ContainerBuilder(); + $definition = $container->register('foo', CallableClass::class); + $definition->addMethodCall('callMethod', [123]); + + (new CheckTypeDeclarationsPass())->process($container); + + $this->addToAssertionCount(1); + } +} + +class CallableClass +{ + public function __call($name, $arguments) + { + } } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php index 338ba1afc69f3..b0dacbed4fd82 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/IntegrationTest.php @@ -28,6 +28,9 @@ use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomMethodAttribute; use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomParameterAttribute; use Symfony\Component\DependencyInjection\Tests\Fixtures\Attribute\CustomPropertyAttribute; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredInterface2; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredService1; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredService2; use Symfony\Component\DependencyInjection\Tests\Fixtures\BarTagClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedClass; use Symfony\Component\DependencyInjection\Tests\Fixtures\FooBarTaggedForDefaultPriorityClass; @@ -43,6 +46,7 @@ use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithDefaultIndexMethodAndWithDefaultPriorityMethod; use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithDefaultPriorityMethod; use Symfony\Component\DependencyInjection\Tests\Fixtures\LocatorConsumerWithoutIndex; +use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedConsumerWithExclude; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService1; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService2; use Symfony\Component\DependencyInjection\Tests\Fixtures\TaggedService3; @@ -999,6 +1003,44 @@ static function (ChildDefinition $definition) { self::assertSame(6, $service->sum); self::assertTrue($service->hasBeenConfigured); } + + public function testTaggedIteratorAndLocatorWithExclude() + { + $container = new ContainerBuilder(); + + $container->register(AutoconfiguredService1::class) + ->addTag(AutoconfiguredInterface2::class) + ->setPublic(true) + ; + $container->register(AutoconfiguredService2::class) + ->addTag(AutoconfiguredInterface2::class) + ->setPublic(true) + ; + $container->register(TaggedConsumerWithExclude::class) + ->addTag(AutoconfiguredInterface2::class) + ->setAutoconfigured(true) + ->setAutowired(true) + ->setPublic(true) + ; + + $container->compile(); + + $this->assertTrue($container->getDefinition(AutoconfiguredService1::class)->hasTag(AutoconfiguredInterface2::class)); + $this->assertTrue($container->getDefinition(AutoconfiguredService2::class)->hasTag(AutoconfiguredInterface2::class)); + $this->assertTrue($container->getDefinition(TaggedConsumerWithExclude::class)->hasTag(AutoconfiguredInterface2::class)); + + $s = $container->get(TaggedConsumerWithExclude::class); + + $items = iterator_to_array($s->items->getIterator()); + $this->assertCount(2, $items); + $this->assertInstanceOf(AutoconfiguredService1::class, $items[0]); + $this->assertInstanceOf(AutoconfiguredService2::class, $items[1]); + + $locator = $s->locator; + $this->assertTrue($locator->has(AutoconfiguredService1::class)); + $this->assertTrue($locator->has(AutoconfiguredService2::class)); + $this->assertFalse($locator->has(TaggedConsumerWithExclude::class)); + } } class ServiceSubscriberStub implements ServiceSubscriberInterface diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/PriorityTaggedServiceTraitTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/PriorityTaggedServiceTraitTest.php index 7200fff9defad..b2c22619b141e 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/PriorityTaggedServiceTraitTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/PriorityTaggedServiceTraitTest.php @@ -205,12 +205,18 @@ public function testTaggedItemAttributes() $container->register('service3', HelloNamedService2::class) ->setAutoconfigured(true) ->addTag('my_custom_tag'); + $container->register('service4', HelloNamedService2::class) + ->setAutoconfigured(true) + ->addTag('my_custom_tag'); + $container->register('service5', HelloNamedService2::class) + ->setAutoconfigured(true) + ->addTag('my_custom_tag'); (new ResolveInstanceofConditionalsPass())->process($container); $priorityTaggedServiceTraitImplementation = new PriorityTaggedServiceTraitImplementation(); - $tag = new TaggedIteratorArgument('my_custom_tag', 'foo', 'getFooBar'); + $tag = new TaggedIteratorArgument('my_custom_tag', 'foo', 'getFooBar', exclude: ['service4', 'service5']); $expected = [ 'service3' => new TypedReference('service3', HelloNamedService2::class), 'hello' => new TypedReference('service2', HelloNamedService::class), diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/ResolveBindingsPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/ResolveBindingsPassTest.php index 521e2fc87765f..7ca809b4df0cf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/ResolveBindingsPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/ResolveBindingsPassTest.php @@ -25,7 +25,9 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Tests\Fixtures\BarInterface; use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass; +use Symfony\Component\DependencyInjection\Tests\Fixtures\FooUnitEnum; use Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy; +use Symfony\Component\DependencyInjection\Tests\Fixtures\NamedEnumArgumentDummy; use Symfony\Component\DependencyInjection\Tests\Fixtures\ParentNotExists; use Symfony\Component\DependencyInjection\Tests\Fixtures\WithTarget; use Symfony\Component\DependencyInjection\TypedReference; @@ -65,6 +67,27 @@ public function testProcess() $this->assertEquals([['setSensitiveClass', [new Reference('foo')]]], $definition->getMethodCalls()); } + /** + * @requires PHP 8.1 + */ + public function testProcessEnum() + { + $container = new ContainerBuilder(); + + $bindings = [ + FooUnitEnum::class.' $bar' => new BoundArgument(FooUnitEnum::BAR), + ]; + + $definition = $container->register(NamedEnumArgumentDummy::class, NamedEnumArgumentDummy::class); + $definition->setBindings($bindings); + + $pass = new ResolveBindingsPass(); + $pass->process($container); + + $expected = [FooUnitEnum::BAR]; + $this->assertEquals($expected, $definition->getArguments()); + } + public function testUnusedBinding() { $this->expectException(InvalidArgumentException::class); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface2.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface2.php new file mode 100644 index 0000000000000..36172b1945c3e --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface2.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; + +#[AutoconfigureTag] +interface AutoconfiguredInterface2 +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService1.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService1.php new file mode 100755 index 0000000000000..61bb7e723950b --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService1.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +class AutoconfiguredService1 implements AutoconfiguredInterface2 +{ + +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService2.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService2.php new file mode 100755 index 0000000000000..b071af607845c --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredService2.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +class AutoconfiguredService2 implements AutoconfiguredInterface2 +{ + +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/NamedEnumArgumentDummy.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/NamedEnumArgumentDummy.php new file mode 100644 index 0000000000000..c172c996a7fb7 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/NamedEnumArgumentDummy.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Fixtures; + +class NamedEnumArgumentDummy +{ + public function __construct(FooUnitEnum $bar) + { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedConsumerWithExclude.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedConsumerWithExclude.php new file mode 100644 index 0000000000000..ac61f2f4c58d6 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TaggedConsumerWithExclude.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\DependencyInjection\Tests\Fixtures; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; +use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; + +final class TaggedConsumerWithExclude implements AutoconfiguredInterface2 +{ + public function __construct( + #[TaggedIterator(AutoconfiguredInterface2::class, exclude: self::class)] + public iterable $items, + #[TaggedLocator(AutoconfiguredInterface2::class, exclude: self::class)] + public ContainerInterface $locator, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php index 021b921ec208e..47922be9bde58 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/containers/container9.php @@ -17,6 +17,7 @@ ->register('foo', FooClass::class) ->addTag('foo', ['foo' => 'foo']) ->addTag('foo', ['bar' => 'bar', 'baz' => 'baz']) + ->addTag('nullable', ['bar' => 'bar', 'baz' => null]) ->addTag('foo', ['name' => 'bar', 'baz' => 'baz']) ->setFactory(['Bar\\FooClass', 'getInstance']) ->setArguments(['foo', new Reference('foo.baz'), ['%foo%' => 'foo is %foo%', 'foobar' => '%foo%'], true, new Reference('service_container')]) diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml index 0051808007a43..f59838396fb0b 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/xml/services9.xml @@ -11,6 +11,7 @@ foo + foo diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml index 9a78ebe4d6a9d..3f8446f682bdf 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/yaml/services9.yml @@ -14,6 +14,7 @@ services: - foo: { foo: foo } - foo: { bar: bar, baz: baz } - foo: { name: bar, baz: baz } + - nullable: { bar: bar, baz: ~ } arguments: [foo, '@foo.baz', { '%foo%': 'foo is %foo%', foobar: '%foo%' }, true, '@service_container'] properties: { foo: bar, moo: '@foo.baz', qux: { '%foo%': 'foo is %foo%', foobar: '%foo%' } } calls: diff --git a/src/Symfony/Component/DomCrawler/Crawler.php b/src/Symfony/Component/DomCrawler/Crawler.php index ddf437c93ade1..ea570c99f8722 100644 --- a/src/Symfony/Component/DomCrawler/Crawler.php +++ b/src/Symfony/Component/DomCrawler/Crawler.php @@ -1053,7 +1053,7 @@ protected function sibling(\DOMNode $node, string $siblingDir = 'nextSibling'): private function parseHtml5(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument { - return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent, $charset), [], $charset); + return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent, $charset)); } private function parseXhtml(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument diff --git a/src/Symfony/Component/DomCrawler/LICENSE b/src/Symfony/Component/DomCrawler/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/DomCrawler/LICENSE +++ b/src/Symfony/Component/DomCrawler/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/Dotenv/LICENSE b/src/Symfony/Component/Dotenv/LICENSE index 3796612f43c2b..7fa9539054928 100644 --- a/src/Symfony/Component/Dotenv/LICENSE +++ b/src/Symfony/Component/Dotenv/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/ErrorHandler/Internal/TentativeTypes.php b/src/Symfony/Component/ErrorHandler/Internal/TentativeTypes.php index acfb48e76c05c..5707a8355bc90 100644 --- a/src/Symfony/Component/ErrorHandler/Internal/TentativeTypes.php +++ b/src/Symfony/Component/ErrorHandler/Internal/TentativeTypes.php @@ -30,7 +30,7 @@ class TentativeTypes 'format' => 'string', 'getTimezone' => 'DateTimeZone|false', 'getOffset' => 'int', - 'getTimestamp' => 'int|false', + 'getTimestamp' => 'int', 'diff' => 'DateInterval', '__wakeup' => 'void', ], @@ -254,6 +254,7 @@ class TentativeTypes 'isEquivalentTo' => 'bool', 'isLenient' => 'bool', 'isWeekend' => 'bool', + 'roll' => 'bool', 'isSet' => 'bool', 'setTime' => 'bool', 'setTimeZone' => 'bool', diff --git a/src/Symfony/Component/ErrorHandler/LICENSE b/src/Symfony/Component/ErrorHandler/LICENSE index 383e7a54586e7..9c907a46a6218 100644 --- a/src/Symfony/Component/ErrorHandler/LICENSE +++ b/src/Symfony/Component/ErrorHandler/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/ErrorHandler/Resources/bin/patch-type-declarations b/src/Symfony/Component/ErrorHandler/Resources/bin/patch-type-declarations index 5f2597aa34351..2a672bad3400d 100755 --- a/src/Symfony/Component/ErrorHandler/Resources/bin/patch-type-declarations +++ b/src/Symfony/Component/ErrorHandler/Resources/bin/patch-type-declarations @@ -71,7 +71,7 @@ set_error_handler(function ($type, $msg, $file, $line, $context = []) use (&$dep $exclude = getenv('SYMFONY_PATCH_TYPE_EXCLUDE') ?: null; foreach ($loader->getClassMap() as $class => $file) { - if (str_contains($file = realpath($file), '/vendor/')) { + if (str_contains($file = realpath($file), \DIRECTORY_SEPARATOR.'vendor'.\DIRECTORY_SEPARATOR)) { continue; } diff --git a/src/Symfony/Component/EventDispatcher/GenericEvent.php b/src/Symfony/Component/EventDispatcher/GenericEvent.php index 4a573f8fa92fa..68a20306334c3 100644 --- a/src/Symfony/Component/EventDispatcher/GenericEvent.php +++ b/src/Symfony/Component/EventDispatcher/GenericEvent.php @@ -148,6 +148,8 @@ public function offsetExists(mixed $key): bool /** * IteratorAggregate for iterating over the object like an array. + * + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { diff --git a/src/Symfony/Component/EventDispatcher/LICENSE b/src/Symfony/Component/EventDispatcher/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/EventDispatcher/LICENSE +++ b/src/Symfony/Component/EventDispatcher/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/ExpressionLanguage/LICENSE b/src/Symfony/Component/ExpressionLanguage/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/ExpressionLanguage/LICENSE +++ b/src/Symfony/Component/ExpressionLanguage/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/Filesystem/Filesystem.php b/src/Symfony/Component/Filesystem/Filesystem.php index 8df9641f3cd12..bb1cab4954bd0 100644 --- a/src/Symfony/Component/Filesystem/Filesystem.php +++ b/src/Symfony/Component/Filesystem/Filesystem.php @@ -178,8 +178,8 @@ private static function doRemove(array $files, bool $isRecursive): void } } - $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); - self::doRemove(iterator_to_array($files, true), true); + $filesystemIterator = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); + self::doRemove(iterator_to_array($filesystemIterator, true), true); if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { $lastError = self::$lastError; diff --git a/src/Symfony/Component/Filesystem/LICENSE b/src/Symfony/Component/Filesystem/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Filesystem/LICENSE +++ b/src/Symfony/Component/Filesystem/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/Finder/Finder.php b/src/Symfony/Component/Finder/Finder.php index 3519b3568027e..e5772c459f88f 100644 --- a/src/Symfony/Component/Finder/Finder.php +++ b/src/Symfony/Component/Finder/Finder.php @@ -601,6 +601,8 @@ public function in(string|array $dirs): static * * This method implements the IteratorAggregate interface. * + * @return \Iterator + * * @throws \LogicException if the in() method has not been called */ public function getIterator(): \Iterator diff --git a/src/Symfony/Component/Finder/LICENSE b/src/Symfony/Component/Finder/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Finder/LICENSE +++ b/src/Symfony/Component/Finder/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/Form/FormView.php b/src/Symfony/Component/Form/FormView.php index 13c9209e3bd9d..fe32f90ceff61 100644 --- a/src/Symfony/Component/Form/FormView.php +++ b/src/Symfony/Component/Form/FormView.php @@ -16,8 +16,8 @@ /** * @author Bernhard Schussek * - * @implements \ArrayAccess - * @implements \IteratorAggregate + * @implements \ArrayAccess + * @implements \IteratorAggregate */ class FormView implements \ArrayAccess, \IteratorAggregate, \Countable { @@ -37,7 +37,7 @@ class FormView implements \ArrayAccess, \IteratorAggregate, \Countable /** * The child views. * - * @var array + * @var array */ public $children = []; @@ -100,7 +100,7 @@ public function setMethodRendered() /** * Returns a child by name (implements \ArrayAccess). * - * @param string $name The child name + * @param int|string $name The child name */ public function offsetGet(mixed $name): self { @@ -110,7 +110,7 @@ public function offsetGet(mixed $name): self /** * Returns whether the given child exists (implements \ArrayAccess). * - * @param string $name The child name + * @param int|string $name The child name */ public function offsetExists(mixed $name): bool { @@ -130,7 +130,7 @@ public function offsetSet(mixed $name, mixed $value): void /** * Removes a child (implements \ArrayAccess). * - * @param string $name The child name + * @param int|string $name The child name */ public function offsetUnset(mixed $name): void { @@ -140,7 +140,7 @@ public function offsetUnset(mixed $name): void /** * Returns an iterator to iterate over children (implements \IteratorAggregate). * - * @return \ArrayIterator + * @return \ArrayIterator */ public function getIterator(): \ArrayIterator { diff --git a/src/Symfony/Component/Form/LICENSE b/src/Symfony/Component/Form/LICENSE index 9ff2d0d6306da..88bf75bb4d6a2 100644 --- a/src/Symfony/Component/Form/LICENSE +++ b/src/Symfony/Component/Form/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/HtmlSanitizer/.gitattributes b/src/Symfony/Component/HtmlSanitizer/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/HtmlSanitizer/.gitignore b/src/Symfony/Component/HtmlSanitizer/.gitignore new file mode 100644 index 0000000000000..5414c2c655e72 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md b/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md new file mode 100644 index 0000000000000..003f90de7ee87 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.1 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php new file mode 100644 index 0000000000000..78687d6cc2d45 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizer.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer; + +use Symfony\Component\HtmlSanitizer\Parser\MastermindsParser; +use Symfony\Component\HtmlSanitizer\Parser\ParserInterface; +use Symfony\Component\HtmlSanitizer\Reference\W3CReference; +use Symfony\Component\HtmlSanitizer\TextSanitizer\StringSanitizer; +use Symfony\Component\HtmlSanitizer\Visitor\DomVisitor; + +/** + * @author Titouan Galopin + * + * @experimental + */ +final class HtmlSanitizer implements HtmlSanitizerInterface +{ + private HtmlSanitizerConfig $config; + private int $maxInputLength; + private ParserInterface $parser; + + /** + * @var array + */ + private array $domVisitors = []; + + public function __construct(HtmlSanitizerConfig $config, int $maxInputLength = 20000, ParserInterface $parser = null) + { + $this->config = $config; + $this->maxInputLength = $maxInputLength; + $this->parser = $parser ?? new MastermindsParser(); + } + + public function sanitize(string $input): string + { + return $this->sanitizeWithContext(W3CReference::CONTEXT_BODY, $input); + } + + public function sanitizeFor(string $element, string $input): string + { + return $this->sanitizeWithContext( + W3CReference::CONTEXTS_MAP[StringSanitizer::htmlLower($element)] ?? W3CReference::CONTEXT_BODY, + $input + ); + } + + private function sanitizeWithContext(string $context, string $input): string + { + // Text context: early return with HTML encoding + if (W3CReference::CONTEXT_TEXT === $context) { + return StringSanitizer::encodeHtmlEntities($input); + } + + // Other context: build a DOM visitor + $this->domVisitors[$context] ??= $this->createDomVisitorForContext($context); + + // Prevent DOS attack induced by extremely long HTML strings + if (\strlen($input) > $this->maxInputLength) { + $input = substr($input, 0, $this->maxInputLength); + } + + // Only operate on valid UTF-8 strings. This is necessary to prevent cross + // site scripting issues on Internet Explorer 6. Idea from Drupal (filter_xss). + if (!$this->isValidUtf8($input)) { + return ''; + } + + // Remove NULL character + $input = str_replace(\chr(0), '', $input); + + // Parse as HTML + if (!$parsed = $this->parser->parse($input)) { + return ''; + } + + // Visit the DOM tree and render the sanitized nodes + return $this->domVisitors[$context]->visit($parsed)?->render() ?? ''; + } + + private function isValidUtf8(string $html): bool + { + // preg_match() fails silently on strings containing invalid UTF-8. + return '' === $html || preg_match('//u', $html); + } + + private function createDomVisitorForContext(string $context): DomVisitor + { + $elementsConfig = []; + + // Head: only a few elements are allowed + if (W3CReference::CONTEXT_HEAD === $context) { + foreach ($this->config->getAllowedElements() as $allowedElement => $allowedAttributes) { + if (\array_key_exists($allowedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$allowedElement] = $allowedAttributes; + } + } + + foreach ($this->config->getBlockedElements() as $blockedElement => $v) { + if (\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$blockedElement] = false; + } + } + + return new DomVisitor($this->config, $elementsConfig); + } + + // Body: allow any configured element that isn't in + foreach ($this->config->getAllowedElements() as $allowedElement => $allowedAttributes) { + if (!\array_key_exists($allowedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$allowedElement] = $allowedAttributes; + } + } + + foreach ($this->config->getBlockedElements() as $blockedElement => $v) { + if (!\array_key_exists($blockedElement, W3CReference::HEAD_ELEMENTS)) { + $elementsConfig[$blockedElement] = false; + } + } + + return new DomVisitor($this->config, $elementsConfig); + } +} diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php new file mode 100644 index 0000000000000..81a2812a5c862 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerConfig.php @@ -0,0 +1,486 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer; + +use Symfony\Component\HtmlSanitizer\Reference\W3CReference; +use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface; + +/** + * @author Titouan Galopin + * + * @experimental + */ +class HtmlSanitizerConfig +{ + /** + * Elements that should be removed but their children should be retained. + * + * @var array + */ + private array $blockedElements = []; + + /** + * Elements that should be retained, with their allowed attributes. + * + * @var array> + */ + private array $allowedElements = []; + + /** + * Attributes that should always be added to certain elements. + * + * @var array> + */ + private array $forcedAttributes = []; + + /** + * Links schemes that should be retained, other being dropped. + * + * @var list + */ + private array $allowedLinkSchemes = ['http', 'https', 'mailto', 'tel']; + + /** + * Links hosts that should be retained (by default, all hosts are allowed). + * + * @var list|null + */ + private ?array $allowedLinkHosts = null; + + /** + * Should the sanitizer allow relative links (by default, they are dropped). + */ + private bool $allowRelativeLinks = false; + + /** + * Image/Audio/Video schemes that should be retained, other being dropped. + * + * @var list + */ + private array $allowedMediaSchemes = ['http', 'https', 'data']; + + /** + * Image/Audio/Video hosts that should be retained (by default, all hosts are allowed). + * + * @var list|null + */ + private ?array $allowedMediaHosts = null; + + /** + * Should the sanitizer allow relative media URL (by default, they are dropped). + */ + private bool $allowRelativeMedias = false; + + /** + * Should the URL in the sanitized document be transformed to HTTPS if they are using HTTP. + */ + private bool $forceHttpsUrls = false; + + /** + * Sanitizers that should be applied to specific attributes in addition to standard sanitization. + * + * @var list + */ + private array $attributeSanitizers; + + public function __construct() + { + $this->attributeSanitizers = [ + new Visitor\AttributeSanitizer\UrlAttributeSanitizer(), + ]; + } + + /** + * Allows all static elements and attributes from the W3C Sanitizer API standard. + * + * All scripts will be removed but the output may still contain other dangerous + * behaviors like CSS injection (click-jacking), CSS expressions, ... + */ + public function allowStaticElements(): static + { + $elements = array_merge( + array_keys(W3CReference::HEAD_ELEMENTS), + array_keys(W3CReference::BODY_ELEMENTS) + ); + + $clone = clone $this; + foreach ($elements as $element) { + $clone = $clone->allowElement($element, '*'); + } + + return $clone; + } + + /** + * Allows "safe" elements and attributes. + * + * All scripts will be removed, as well as other dangerous behaviors like CSS injection. + */ + public function allowSafeElements(): static + { + $attributes = []; + foreach (W3CReference::ATTRIBUTES as $attribute => $isSafe) { + if ($isSafe) { + $attributes[] = $attribute; + } + } + + $clone = clone $this; + + foreach (W3CReference::HEAD_ELEMENTS as $element => $isSafe) { + if ($isSafe) { + $clone = $clone->allowElement($element, $attributes); + } + } + + foreach (W3CReference::BODY_ELEMENTS as $element => $isSafe) { + if ($isSafe) { + $clone = $clone->allowElement($element, $attributes); + } + } + + return $clone; + } + + /** + * Allows only a given list of schemes to be used in links href attributes. + * + * All other schemes will be dropped. + * + * @param list $allowLinkSchemes + */ + public function allowLinkSchemes(array $allowLinkSchemes): static + { + $clone = clone $this; + $clone->allowedLinkSchemes = $allowLinkSchemes; + + return $clone; + } + + /** + * Allows only a given list of hosts to be used in links href attributes. + * + * All other hosts will be dropped. By default all hosts are allowed + * ($allowedLinkHosts = null). + * + * @param list|null $allowLinkHosts + */ + public function allowLinkHosts(?array $allowLinkHosts): static + { + $clone = clone $this; + $clone->allowedLinkHosts = $allowLinkHosts; + + return $clone; + } + + /** + * Allows relative URLs to be used in links href attributes. + */ + public function allowRelativeLinks(bool $allowRelativeLinks = true): static + { + $clone = clone $this; + $clone->allowRelativeLinks = $allowRelativeLinks; + + return $clone; + } + + /** + * Allows only a given list of schemes to be used in media source attributes (img, audio, video, ...). + * + * All other schemes will be dropped. + * + * @param list $allowMediaSchemes + */ + public function allowMediaSchemes(array $allowMediaSchemes): static + { + $clone = clone $this; + $clone->allowedMediaSchemes = $allowMediaSchemes; + + return $clone; + } + + /** + * Allows only a given list of hosts to be used in media source attributes (img, audio, video, ...). + * + * All other hosts will be dropped. By default all hosts are allowed + * ($allowMediaHosts = null). + * + * @param list|null $allowMediaHosts + */ + public function allowMediaHosts(?array $allowMediaHosts): static + { + $clone = clone $this; + $clone->allowedMediaHosts = $allowMediaHosts; + + return $clone; + } + + /** + * Allows relative URLs to be used in media source attributes (img, audio, video, ...). + */ + public function allowRelativeMedias(bool $allowRelativeMedias = true): static + { + $clone = clone $this; + $clone->allowRelativeMedias = $allowRelativeMedias; + + return $clone; + } + + /** + * Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. + */ + public function forceHttpsUrls(bool $forceHttpsUrls = true): static + { + $clone = clone $this; + $clone->forceHttpsUrls = $forceHttpsUrls; + + return $clone; + } + + /** + * Configures the given element as allowed. + * + * Allowed elements are elements the sanitizer should retain from the input. + * + * A list of allowed attributes for this element can be passed as a second argument. + * Passing "*" will allow all standard attributes on this element. By default, no + * attributes are allowed on the element. + * + * @param list|string $allowedAttributes + */ + public function allowElement(string $element, array|string $allowedAttributes = []): static + { + $clone = clone $this; + + // Unblock the element is necessary + unset($clone->blockedElements[$element]); + + $clone->allowedElements[$element] = []; + + $attrs = ('*' === $allowedAttributes) ? array_keys(W3CReference::ATTRIBUTES) : (array) $allowedAttributes; + foreach ($attrs as $allowedAttr) { + $clone->allowedElements[$element][$allowedAttr] = true; + } + + return $clone; + } + + /** + * Configures the given element as blocked. + * + * Blocked elements are elements the sanitizer should remove from the input, but retain + * their children. + */ + public function blockElement(string $element): static + { + $clone = clone $this; + + // Disallow the element is necessary + unset($clone->allowedElements[$element]); + + $clone->blockedElements[$element] = true; + + return $clone; + } + + /** + * Configures the given element as dropped. + * + * Dropped elements are elements the sanitizer should remove from the input, including + * their children. + * + * Note: when using an empty configuration, all unknown elements are dropped + * automatically. This method let you drop elements that were allowed earlier + * in the configuration. + */ + public function dropElement(string $element): static + { + $clone = clone $this; + unset($clone->allowedElements[$element], $clone->blockedElements[$element]); + + return $clone; + } + + /** + * Configures the given attribute as allowed. + * + * Allowed attributes are attributes the sanitizer should retain from the input. + * + * A list of allowed elements for this attribute can be passed as a second argument. + * Passing "*" will allow all currently allowed elements to use this attribute. + * + * @param list|string $allowedElements + */ + public function allowAttribute(string $attribute, array|string $allowedElements): static + { + $clone = clone $this; + $allowedElements = ('*' === $allowedElements) ? array_keys($clone->allowedElements) : (array) $allowedElements; + + // For each configured element ... + foreach ($clone->allowedElements as $element => $attrs) { + if (\in_array($element, $allowedElements, true)) { + // ... if the attribute should be allowed, add it + $clone->allowedElements[$element][$attribute] = true; + } else { + // ... if the attribute should not be allowed, remove it + unset($clone->allowedElements[$element][$attribute]); + } + } + + return $clone; + } + + /** + * Configures the given attribute as dropped. + * + * Dropped attributes are attributes the sanitizer should remove from the input. + * + * A list of elements on which to drop this attribute can be passed as a second argument. + * Passing "*" will drop this attribute from all currently allowed elements. + * + * Note: when using an empty configuration, all unknown attributes are dropped + * automatically. This method let you drop attributes that were allowed earlier + * in the configuration. + * + * @param list|string $droppedElements + */ + public function dropAttribute(string $attribute, array|string $droppedElements): static + { + $clone = clone $this; + $droppedElements = ('*' === $droppedElements) ? array_keys($clone->allowedElements) : (array) $droppedElements; + + foreach ($droppedElements as $element) { + if (isset($clone->allowedElements[$element][$attribute])) { + unset($clone->allowedElements[$element][$attribute]); + } + } + + return $clone; + } + + /** + * Forcefully set the value of a given attribute on a given element. + * + * The attribute will be created on the nodes if it didn't exist. + */ + public function forceAttribute(string $element, string $attribute, string $value): static + { + $clone = clone $this; + $clone->forcedAttributes[$element][$attribute] = $value; + + return $clone; + } + + /** + * Registers a custom attribute sanitizer. + */ + public function withAttributeSanitizer(AttributeSanitizerInterface $sanitizer): static + { + $clone = clone $this; + $clone->attributeSanitizers[] = $sanitizer; + + return $clone; + } + + /** + * Unregisters a custom attribute sanitizer. + */ + public function withoutAttributeSanitizer(AttributeSanitizerInterface $sanitizer): static + { + $clone = clone $this; + $clone->attributeSanitizers = array_values(array_filter( + $this->attributeSanitizers, + static fn ($current) => $current !== $sanitizer + )); + + return $clone; + } + + /** + * @return array> + */ + public function getAllowedElements(): array + { + return $this->allowedElements; + } + + /** + * @return array + */ + public function getBlockedElements(): array + { + return $this->blockedElements; + } + + /** + * @return array> + */ + public function getForcedAttributes(): array + { + return $this->forcedAttributes; + } + + /** + * @return list + */ + public function getAllowedLinkSchemes(): array + { + return $this->allowedLinkSchemes; + } + + /** + * @return list|null + */ + public function getAllowedLinkHosts(): ?array + { + return $this->allowedLinkHosts; + } + + public function getAllowRelativeLinks(): bool + { + return $this->allowRelativeLinks; + } + + /** + * @return list + */ + public function getAllowedMediaSchemes(): array + { + return $this->allowedMediaSchemes; + } + + /** + * @return list|null + */ + public function getAllowedMediaHosts(): ?array + { + return $this->allowedMediaHosts; + } + + public function getAllowRelativeMedias(): bool + { + return $this->allowRelativeMedias; + } + + public function getForceHttpsUrls(): bool + { + return $this->forceHttpsUrls; + } + + /** + * @return list + */ + public function getAttributeSanitizers(): array + { + return $this->attributeSanitizers; + } +} diff --git a/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerInterface.php b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerInterface.php new file mode 100644 index 0000000000000..559bcb6a46a98 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/HtmlSanitizerInterface.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer; + +/** + * Sanitizes an untrusted HTML input for safe insertion into a document's DOM. + * + * This interface is inspired by the W3C Standard Draft about a HTML Sanitizer API + * ({@see https://wicg.github.io/sanitizer-api/}). + * + * @author Titouan Galopin + * + * @experimental + */ +interface HtmlSanitizerInterface +{ + /** + * Sanitizes an untrusted HTML input for a context. + * + * This method is NOT context sensitive: it assumes the returned HTML string + * will be injected in a "body" context, and therefore will drop tags only + * allowed in the "head" element. To sanitize a string for injection + * in the "head" element, use {@see HtmlSanitizerInterface::sanitizeFor()}. + */ + public function sanitize(string $input): string; + + /** + * Sanitizes an untrusted HTML input for a given context. + * + * This method is context sensitive: by providing a parent element name + * (body, head, title, ...), the sanitizer will adapt its rules to only + * allow elements that are valid inside the given parent element. + */ + public function sanitizeFor(string $element, string $input): string; +} diff --git a/src/Symfony/Component/HtmlSanitizer/LICENSE b/src/Symfony/Component/HtmlSanitizer/LICENSE new file mode 100644 index 0000000000000..48d17c4fb34f1 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/LICENSE @@ -0,0 +1,19 @@ +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/HtmlSanitizer/Parser/MastermindsParser.php b/src/Symfony/Component/HtmlSanitizer/Parser/MastermindsParser.php new file mode 100644 index 0000000000000..f9752fc04901f --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Parser/MastermindsParser.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\HtmlSanitizer\Parser; + +use Masterminds\HTML5; + +/** + * @author Titouan Galopin + * + * @experimental + */ +final class MastermindsParser implements ParserInterface +{ + public function __construct(private array $defaultOptions = []) + { + } + + public function parse(string $html): ?\DOMNode + { + return (new HTML5($this->defaultOptions))->loadHTMLFragment($html); + } +} diff --git a/src/Symfony/Component/HtmlSanitizer/Parser/ParserInterface.php b/src/Symfony/Component/HtmlSanitizer/Parser/ParserInterface.php new file mode 100644 index 0000000000000..50d56fad6d3be --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Parser/ParserInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer\Parser; + +/** + * Transforms an untrusted HTML input string into a DOM tree. + * + * @author Titouan Galopin + * + * @experimental + */ +interface ParserInterface +{ + /** + * Parse a given string and returns a DOMNode tree. + * + * This method must return null if the string cannot be parsed as HTML. + */ + public function parse(string $html): ?\DOMNode; +} diff --git a/src/Symfony/Component/HtmlSanitizer/README.md b/src/Symfony/Component/HtmlSanitizer/README.md new file mode 100644 index 0000000000000..12210c8a87dba --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/README.md @@ -0,0 +1,115 @@ +HtmlSanitizer Component +======================= + +The HtmlSanitizer component provides an object-oriented API to sanitize +untrusted HTML input for safe insertion into a document's DOM. + +Usage +----- + +```php +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; + +// By default, an element not added to the allowed or blocked elements +// will be dropped, including its children +$config = (new HtmlSanitizerConfig()) + // Allow "safe" elements and attributes. All scripts will be removed + // as well as other dangerous behaviors like CSS injection + ->allowSafeElements() + + // Allow all static elements and attributes from the W3C Sanitizer API + // standard. All scripts will be removed but the output may still contain + // other dangerous behaviors like CSS injection (click-jacking), CSS + // expressions, ... + ->allowStaticElements() + + // Allow the "div" element and no attribute can be on it + ->allowElement('div') + + // Allow the "a" element, and the "title" attribute to be on it + ->allowElement('a', ['title']) + + // Allow the "span" element, and any attribute from the Sanitizer API is allowed + // (see https://wicg.github.io/sanitizer-api/#default-configuration) + ->allowElement('span', '*') + + // Block the "section" element: this element will be removed but + // its children will be retained + ->blockElement('section') + + // Drop the "div" element: this element will be removed, including its children + ->dropElement('div') + + // Allow the attribute "title" on the "div" element + ->allowAttribute('title', ['div']) + + // Allow the attribute "data-custom-attr" on all currently allowed elements + ->allowAttribute('data-custom-attr', '*') + + // Drop the "data-custom-attr" attribute from the "div" element: + // this attribute will be removed + ->dropAttribute('data-custom-attr', ['div']) + + // Drop the "data-custom-attr" attribute from all elements: + // this attribute will be removed + ->dropAttribute('data-custom-attr', '*') + + // Forcefully set the value of all "rel" attributes on "a" + // elements to "noopener noreferrer" + ->forceAttribute('a', 'rel', 'noopener noreferrer') + + // Transform all HTTP schemes to HTTPS + ->forceHttpsUrls() + + // Configure which schemes are allowed in links (others will be dropped) + ->allowedLinkSchemes(['https', 'http', 'mailto']) + + // Configure which hosts are allowed in links (by default all are allowed) + ->allowedLinkHosts(['symfony.com', 'example.com']) + + // Allow relative URL in links (by default they are dropped) + ->allowRelativeLinks() + + // Configure which schemes are allowed in img/audio/video/iframe (others will be dropped) + ->allowedMediaSchemes(['https', 'http']) + + // Configure which hosts are allowed in img/audio/video/iframe (by default all are allowed) + ->allowedMediaHosts(['symfony.com', 'example.com']) + + // Allow relative URL in img/audio/video/iframe (by default they are dropped) + ->allowRelativeMedias() + + // Configure a custom attribute sanitizer to apply custom sanitization logic + // ($attributeSanitizer instance of AttributeSanitizerInterface) + ->withAttributeSanitizer($attributeSanitizer) + + // Unregister a previously registered attribute sanitizer + // ($attributeSanitizer instance of AttributeSanitizerInterface) + ->withoutAttributeSanitizer($attributeSanitizer) +; + +$sanitizer = new HtmlSanitizer($config); + +// Sanitize a given string, using the configuration provided and in the +// "body" context (tags only allowed in will be removed) +$sanitizer->sanitize($userInput); + +// Sanitize the given string for a usage in a tag +$sanitizer->sanitizeFor('head', $userInput); + +// Sanitize the given string for a usage in another tag +$sanitizer->sanitizeFor('title', $userInput); // Will encode as HTML entities +$sanitizer->sanitizeFor('textarea', $userInput); // Will encode as HTML entities +$sanitizer->sanitizeFor('div', $userInput); // Will sanitize as body +$sanitizer->sanitizeFor('section', $userInput); // Will sanitize as body +// ... +``` + +Resources +--------- + +* [Contributing](https://symfony.com/doc/current/contributing/index.html) +* [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/HtmlSanitizer/Reference/W3CReference.php b/src/Symfony/Component/HtmlSanitizer/Reference/W3CReference.php new file mode 100644 index 0000000000000..8668bbf67e2ea --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Reference/W3CReference.php @@ -0,0 +1,400 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer\Reference; + +/** + * Stores reference data from the W3C Sanitizer API standard. + * + * @see https://wicg.github.io/sanitizer-api/#default-configuration + * + * @author Titouan Galopin + * + * @internal + */ +final class W3CReference +{ + /** + * Sanitizer supported contexts. + * + * A parent element name can be passed as an argument to {@see HtmlSanitizer::sanitizeFor()}. + * When doing so, depending on the given context, different elements will be allowed. + */ + public const CONTEXT_HEAD = 'head'; + public const CONTEXT_BODY = 'body'; + public const CONTEXT_TEXT = 'text'; + + // Which context to apply depending on the passed parent element name + public const CONTEXTS_MAP = [ + 'head' => self::CONTEXT_HEAD, + 'textarea' => self::CONTEXT_TEXT, + 'title' => self::CONTEXT_TEXT, + ]; + + /** + * Elements allowed by the Sanitizer standard in as keys, including whether + * they are safe or not as values (safe meaning no global display/audio/video impact). + */ + public const HEAD_ELEMENTS = [ + 'head' => true, + 'link' => true, + 'meta' => true, + 'style' => false, + 'title' => true, + ]; + + /** + * Elements allowed by the Sanitizer standard in as keys, including whether + * they are safe or not as values (safe meaning no global display/audio/video impact). + */ + public const BODY_ELEMENTS = [ + 'a' => true, + 'abbr' => true, + 'acronym' => true, + 'address' => true, + 'area' => true, + 'article' => true, + 'aside' => true, + 'audio' => true, + 'b' => true, + 'basefont' => true, + 'bdi' => true, + 'bdo' => true, + 'bgsound' => false, + 'big' => true, + 'blockquote' => true, + 'body' => true, + 'br' => true, + 'button' => true, + 'canvas' => true, + 'caption' => true, + 'center' => true, + 'cite' => true, + 'code' => true, + 'col' => true, + 'colgroup' => true, + 'command' => true, + 'data' => true, + 'datalist' => true, + 'dd' => true, + 'del' => true, + 'details' => true, + 'dfn' => true, + 'dialog' => true, + 'dir' => true, + 'div' => true, + 'dl' => true, + 'dt' => true, + 'em' => true, + 'fieldset' => true, + 'figcaption' => true, + 'figure' => true, + 'font' => true, + 'footer' => true, + 'form' => false, + 'h1' => true, + 'h2' => true, + 'h3' => true, + 'h4' => true, + 'h5' => true, + 'h6' => true, + 'header' => true, + 'hgroup' => true, + 'hr' => true, + 'html' => true, + 'i' => true, + 'image' => true, + 'img' => true, + 'input' => false, + 'ins' => true, + 'kbd' => true, + 'keygen' => true, + 'label' => true, + 'layer' => true, + 'legend' => true, + 'li' => true, + 'listing' => true, + 'main' => true, + 'map' => true, + 'mark' => true, + 'marquee' => true, + 'menu' => true, + 'meter' => true, + 'nav' => true, + 'nobr' => true, + 'ol' => true, + 'optgroup' => true, + 'option' => true, + 'output' => true, + 'p' => true, + 'picture' => true, + 'plaintext' => true, + 'popup' => true, + 'portal' => true, + 'pre' => true, + 'progress' => true, + 'q' => true, + 'rb' => true, + 'rp' => true, + 'rt' => true, + 'rtc' => true, + 'ruby' => true, + 's' => true, + 'samp' => true, + 'section' => true, + 'select' => false, + 'selectmenu' => false, + 'slot' => true, + 'small' => true, + 'source' => true, + 'span' => true, + 'strike' => true, + 'strong' => true, + 'sub' => true, + 'summary' => true, + 'sup' => true, + 'table' => true, + 'tbody' => true, + 'td' => true, + 'template' => true, + 'textarea' => false, + 'tfoot' => true, + 'th' => true, + 'thead' => true, + 'time' => true, + 'tr' => true, + 'track' => true, + 'tt' => true, + 'u' => true, + 'ul' => true, + 'var' => true, + 'video' => true, + 'wbr' => true, + 'xmp' => true, + ]; + + /** + * Attributes allowed by the standard. + */ + public const ATTRIBUTES = [ + 'abbr' => true, + 'accept' => true, + 'accept-charset' => true, + 'accesskey' => true, + 'action' => true, + 'align' => true, + 'alink' => true, + 'allow' => true, + 'allowfullscreen' => true, + 'allowpaymentrequest' => false, + 'alt' => true, + 'anchor' => true, + 'archive' => true, + 'as' => true, + 'async' => false, + 'autocapitalize' => false, + 'autocomplete' => false, + 'autocorrect' => false, + 'autofocus' => false, + 'autopictureinpicture' => false, + 'autoplay' => false, + 'axis' => true, + 'background' => false, + 'behavior' => true, + 'bgcolor' => false, + 'border' => false, + 'bordercolor' => false, + 'capture' => true, + 'cellpadding' => true, + 'cellspacing' => true, + 'challenge' => true, + 'char' => true, + 'charoff' => true, + 'charset' => true, + 'checked' => false, + 'cite' => true, + 'class' => false, + 'classid' => false, + 'clear' => true, + 'code' => true, + 'codebase' => true, + 'codetype' => true, + 'color' => false, + 'cols' => true, + 'colspan' => true, + 'compact' => true, + 'content' => true, + 'contenteditable' => false, + 'controls' => true, + 'controlslist' => true, + 'conversiondestination' => true, + 'coords' => true, + 'crossorigin' => true, + 'csp' => true, + 'data' => true, + 'datetime' => true, + 'declare' => true, + 'decoding' => true, + 'default' => true, + 'defer' => true, + 'dir' => true, + 'direction' => true, + 'dirname' => true, + 'disabled' => true, + 'disablepictureinpicture' => true, + 'disableremoteplayback' => true, + 'disallowdocumentaccess' => true, + 'download' => true, + 'draggable' => true, + 'elementtiming' => true, + 'enctype' => true, + 'end' => true, + 'enterkeyhint' => true, + 'event' => true, + 'exportparts' => true, + 'face' => true, + 'for' => true, + 'form' => false, + 'formaction' => false, + 'formenctype' => false, + 'formmethod' => false, + 'formnovalidate' => false, + 'formtarget' => false, + 'frame' => false, + 'frameborder' => false, + 'headers' => true, + 'height' => true, + 'hidden' => false, + 'high' => true, + 'href' => true, + 'hreflang' => true, + 'hreftranslate' => true, + 'hspace' => true, + 'http-equiv' => false, + 'id' => true, + 'imagesizes' => true, + 'imagesrcset' => true, + 'importance' => true, + 'impressiondata' => true, + 'impressionexpiry' => true, + 'incremental' => true, + 'inert' => true, + 'inputmode' => true, + 'integrity' => true, + 'invisible' => true, + 'is' => true, + 'ismap' => true, + 'keytype' => true, + 'kind' => true, + 'label' => true, + 'lang' => true, + 'language' => true, + 'latencyhint' => true, + 'leftmargin' => true, + 'link' => true, + 'list' => true, + 'loading' => true, + 'longdesc' => true, + 'loop' => true, + 'low' => true, + 'lowsrc' => true, + 'manifest' => true, + 'marginheight' => true, + 'marginwidth' => true, + 'max' => true, + 'maxlength' => true, + 'mayscript' => true, + 'media' => true, + 'method' => true, + 'min' => true, + 'minlength' => true, + 'multiple' => true, + 'muted' => true, + 'name' => true, + 'nohref' => true, + 'nomodule' => true, + 'nonce' => true, + 'noresize' => true, + 'noshade' => true, + 'novalidate' => true, + 'nowrap' => true, + 'object' => true, + 'open' => true, + 'optimum' => true, + 'part' => true, + 'pattern' => true, + 'ping' => false, + 'placeholder' => true, + 'playsinline' => true, + 'policy' => true, + 'poster' => true, + 'preload' => true, + 'pseudo' => true, + 'readonly' => true, + 'referrerpolicy' => true, + 'rel' => true, + 'reportingorigin' => true, + 'required' => true, + 'resources' => true, + 'rev' => true, + 'reversed' => true, + 'role' => true, + 'rows' => true, + 'rowspan' => true, + 'rules' => true, + 'sandbox' => true, + 'scheme' => true, + 'scope' => true, + 'scopes' => true, + 'scrollamount' => true, + 'scrolldelay' => true, + 'scrolling' => true, + 'select' => false, + 'selected' => false, + 'shadowroot' => true, + 'shadowrootdelegatesfocus' => true, + 'shape' => true, + 'size' => true, + 'sizes' => true, + 'slot' => true, + 'span' => true, + 'spellcheck' => true, + 'src' => true, + 'srcdoc' => true, + 'srclang' => true, + 'srcset' => true, + 'standby' => true, + 'start' => true, + 'step' => true, + 'style' => false, + 'summary' => true, + 'tabindex' => true, + 'target' => true, + 'text' => true, + 'title' => true, + 'topmargin' => true, + 'translate' => true, + 'truespeed' => true, + 'trusttoken' => true, + 'type' => true, + 'usemap' => true, + 'valign' => true, + 'value' => false, + 'valuetype' => true, + 'version' => true, + 'virtualkeyboardpolicy' => true, + 'vlink' => false, + 'vspace' => true, + 'webkitdirectory' => true, + 'width' => false, + 'wrap' => true, + ]; +} diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php new file mode 100644 index 0000000000000..7e53d8c3a3207 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerAllTest.php @@ -0,0 +1,554 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; + +class HtmlSanitizerAllTest extends TestCase +{ + private function createSanitizer(): HtmlSanitizer + { + return new HtmlSanitizer( + (new HtmlSanitizerConfig()) + ->allowStaticElements() + ->allowLinkHosts(['trusted.com', 'external.com']) + ->allowMediaHosts(['trusted.com', 'external.com']) + ->allowRelativeLinks() + ->allowRelativeMedias() + ->forceHttpsUrls() + ); + } + + /** + * @dataProvider provideSanitizeHead + */ + public function testSanitizeHead(string $input, string $expected) + { + $this->assertSame($expected, $this->createSanitizer()->sanitizeFor('head', $input)); + } + + public function provideSanitizeHead() + { + $cases = [ + // Scripts + [ + '', + '', + ], + + // Normal tags + [ + '', + '', + ], + [ + '', + '', + ], + ]; + + foreach ($cases as $case) { + yield $case[0] => $case; + } + } + + /** + * @dataProvider provideSanitizeBody + */ + public function testSanitizeBody(string $input, string $expected) + { + $this->assertSame($expected, $this->createSanitizer()->sanitize($input)); + } + + public function provideSanitizeBody() + { + $cases = [ + // Text + [ + 'hello world', + 'hello world', + ], + [ + '<hello world>', + '<hello world>', + ], + [ + '< Hello', + ' Hello', + ], + [ + 'Lorem & Ipsum', + 'Lorem & Ipsum', + ], + + // Unknown tag + [ + 'Lorem ipsum', + '', + ], + + // Scripts + [ + '', + '', + ], + [ + 'javascript:/*-->', + 'javascript:/*-->', + ], + [ + 'ipt>alert(1)', + '', + ], + [ + 'ipt>alert(1)', + '', + ], + [ + '', + '', + ], + [ + '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
', + '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
', + ], + [ + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', + ], + [ + '<a href="javascript:evil"/>', + 'a href="javascript:evil"/>', + ], + [ + 'Test', + 'Test', + ], + [ + 'Test', + 'Test', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Test', + 'Test', + ], + [ + '
', + '
', + ], + [ + '', + '', + ], + [ + '<iframe src="javascript:evil"/>', + 'iframe src="javascript:evil"/>', + ], + [ + '<img src="javascript:evil"/>', + 'img src="javascript:evil"/>', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '"\>', + '"\>', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '
', + '
', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + '!!', + '!!', + ], + + // Inspired by https://www.youtube.com/watch?v=kz7wmRV9xsU + [ + '<script>alert(\'ok\');</script>', + '<script>alert('ok');</script>', + ], + + // Inspired by https://twitter.com/brutelogic/status/1066333383276593152?s=19 + [ + '">"@x.y', + '">', + ], + + // Styles + [ + '', + '', + ], + [ + '
Lorem ipsum dolor sit amet, consectetur.
', + '
Lorem ipsum dolor sit amet, consectetur.
', + ], + [ + '', + '', + ], + [ + 'Lorem ipsum dolor sit amet, consectetur.', + 'Lorem ipsum dolor sit amet, consectetur.', + ], + + // Comments + [ + 'Lorem ipsum dolor sit amet, consectetur', + 'Lorem ipsum dolor sit amet, consectetur', + ], + [ + 'Lorem ipsum ', + 'Lorem ipsum ', + ], + + // Normal tags + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + 'Lorem ipsum
dolor sit amet
consectetur adipisicing.', + 'Lorem ipsum
dolor sit amet
consectetur adipisicing.', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
', + '
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '

Lorem ipsum

', + '

Lorem ipsum

', + ], + [ + '

Lorem ipsum

', + '

Lorem ipsum

', + ], + [ + '

Lorem ipsum

', + '

Lorem ipsum

', + ], + [ + '

Lorem ipsum

', + '

Lorem ipsum

', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
Lorem ipsum
', + '
Lorem ipsum
', + ], + [ + '
', + '
', + ], + [ + 'Image alternative text', + 'Image alternative text', + ], + [ + 'Image alternative text', + 'Image alternative text', + ], + [ + 'Image alternative text', + 'Image alternative text', + ], + [ + '', + '', + ], + [ + '', + '', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
  • Lorem ipsum
  • ', + '
  • Lorem ipsum
  • ', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
      Lorem ipsum
    ', + '
      Lorem ipsum
    ', + ], + [ + '

    Lorem ipsum

    ', + '

    Lorem ipsum

    ', + ], + [ + '
    Lorem ipsum
    ', + '
    Lorem ipsum
    ', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '', + '', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum
    ', + 'Lorem ipsum
    ', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + 'Lorem ipsum', + 'Lorem ipsum', + ], + [ + '
      Lorem ipsum
    ', + '
      Lorem ipsum
    ', + ], + ]; + + foreach ($cases as $case) { + yield $case[0] => $case; + } + } +} diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerConfigTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerConfigTest.php new file mode 100644 index 0000000000000..b98af74d02818 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerConfigTest.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; +use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface; + +class HtmlSanitizerConfigTest extends TestCase +{ + public function testCreateEmpty() + { + $config = new HtmlSanitizerConfig(); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + $this->assertSame(['http', 'https', 'mailto', 'tel'], $config->getAllowedLinkSchemes()); + $this->assertNull($config->getAllowedLinkHosts()); + $this->assertSame(['http', 'https', 'data'], $config->getAllowedMediaSchemes()); + $this->assertNull($config->getAllowedMediaHosts()); + $this->assertFalse($config->getForceHttpsUrls()); + } + + public function testSimpleOptions() + { + $config = new HtmlSanitizerConfig(); + $this->assertSame(['http', 'https', 'mailto', 'tel'], $config->getAllowedLinkSchemes()); + $this->assertNull($config->getAllowedLinkHosts()); + $this->assertSame(['http', 'https', 'data'], $config->getAllowedMediaSchemes()); + $this->assertNull($config->getAllowedMediaHosts()); + $this->assertFalse($config->getForceHttpsUrls()); + + $config = $config->allowLinkSchemes(['http', 'ftp']); + $this->assertSame(['http', 'ftp'], $config->getAllowedLinkSchemes()); + + $config = $config->allowLinkHosts(['symfony.com', 'example.com']); + $this->assertSame(['symfony.com', 'example.com'], $config->getAllowedLinkHosts()); + + $config = $config->allowRelativeLinks(); + $this->assertTrue($config->getAllowRelativeLinks()); + + $config = $config->allowMediaSchemes(['https']); + $this->assertSame(['https'], $config->getAllowedMediaSchemes()); + + $config = $config->allowMediaHosts(['symfony.com']); + $this->assertSame(['symfony.com'], $config->getAllowedMediaHosts()); + + $config = $config->allowRelativeMedias(); + $this->assertTrue($config->getAllowRelativeMedias()); + + $config = $config->forceHttpsUrls(); + $this->assertTrue($config->getForceHttpsUrls()); + } + + public function testAllowElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', ['style']); + $this->assertSame(['div' => ['style' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowElementTwiceOverridesIt() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', ['style']); + $config = $config->allowElement('div', ['width']); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + // Allowing a blocked element should remove it from blocked + $config = $config->blockElement('div'); + $this->assertSame(['div' => true], $config->getBlockedElements()); + + $config = $config->allowElement('div', ['width']); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowBlockedElementUnblocksIt() + { + $config = new HtmlSanitizerConfig(); + $config = $config->blockElement('div'); + $this->assertSame(['div' => true], $config->getBlockedElements()); + + $config = $config->allowElement('div', ['width']); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowElementNoAttributes() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', []); + $this->assertSame(['div' => []], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowElementStandardAttributes() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', '*'); + $this->assertSame(['div'], array_keys($config->getAllowedElements())); + $this->assertCount(211, $config->getAllowedElements()['div']); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowElementStringAttribute() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testBlockElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->blockElement('div'); + $this->assertSame(['div' => true], $config->getBlockedElements()); + } + + public function testBlockElementDisallowsIt() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->blockElement('div'); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame(['div' => true], $config->getBlockedElements()); + } + + public function testDropAllowedElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->dropElement('div'); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testDropBlockedElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->blockElement('div'); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame(['div' => true], $config->getBlockedElements()); + + $config = $config->dropElement('div'); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeNoElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowAttribute('width', 'div'); + $this->assertSame([], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeAllowedElement() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div'); + $config = $config->allowAttribute('width', 'div'); + $this->assertSame(['div' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeAllElements() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div'); + $config = $config->allowElement('section'); + $config = $config->allowAttribute('width', '*'); + $this->assertSame(['div' => ['width' => true], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeElementsArray() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div'); + $config = $config->allowElement('section'); + $config = $config->allowAttribute('width', ['section']); + $this->assertSame(['div' => [], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeElementsString() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div'); + $config = $config->allowElement('section'); + $config = $config->allowAttribute('width', 'section'); + $this->assertSame(['div' => [], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testAllowAttributeOverridesIt() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div'); + $config = $config->allowElement('section'); + + $config = $config->allowAttribute('width', 'div'); + $this->assertSame(['div' => ['width' => true], 'section' => []], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->allowAttribute('width', 'section'); + $this->assertSame(['div' => [], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testDropAllowedAttributeAllowedElementsArray() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $config = $config->allowElement('section', 'width'); + $this->assertSame(['div' => ['width' => true], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->dropAttribute('width', ['div']); + $this->assertSame(['div' => [], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testDropAllowedAttributeAllowedElementString() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $config = $config->allowElement('section', 'width'); + $this->assertSame(['div' => ['width' => true], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->dropAttribute('width', 'section'); + $this->assertSame(['div' => ['width' => true], 'section' => []], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testDropAllowedAttributeAllElements() + { + $config = new HtmlSanitizerConfig(); + $config = $config->allowElement('div', 'width'); + $config = $config->allowElement('section', 'width'); + $this->assertSame(['div' => ['width' => true], 'section' => ['width' => true]], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + + $config = $config->dropAttribute('width', '*'); + $this->assertSame(['div' => [], 'section' => []], $config->getAllowedElements()); + $this->assertSame([], $config->getBlockedElements()); + } + + public function testWithWithoutAttributeSanitizer() + { + $config = new HtmlSanitizerConfig(); + + $sanitizer = new class() implements AttributeSanitizerInterface { + public function getSupportedElements(): ?array + { + return null; + } + + public function getSupportedAttributes(): ?array + { + return null; + } + + public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string + { + return ''; + } + }; + + $config = $config->withAttributeSanitizer($sanitizer); + $this->assertContains($sanitizer, $config->getAttributeSanitizers()); + + $config = $config->withoutAttributeSanitizer($sanitizer); + $this->assertNotContains($sanitizer, $config->getAttributeSanitizers()); + } +} diff --git a/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php new file mode 100644 index 0000000000000..f44c62414f4f4 --- /dev/null +++ b/src/Symfony/Component/HtmlSanitizer/Tests/HtmlSanitizerCustomTest.php @@ -0,0 +1,428 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HtmlSanitizer\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; +use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface; + +class HtmlSanitizerCustomTest extends TestCase +{ + public function testSanitizeForHead() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('div') + ; + + $this->assertSame( + ' world', + (new HtmlSanitizer($config))->sanitizeFor('head', '
    Hello
    world') + ); + } + + public function testSanitizeForTextarea() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('div') + ; + + $this->assertSame( + '<div style="width: 100px">Hello</div> world', + (new HtmlSanitizer($config))->sanitizeFor('textarea', '
    Hello
    world') + ); + } + + public function testSanitizeForTitle() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('div') + ; + + $this->assertSame( + '<div style="width: 100px">Hello</div> world', + (new HtmlSanitizer($config))->sanitizeFor('title', '
    Hello
    world') + ); + } + + public function testSanitizeDeepNestedString() + { + $config = (new HtmlSanitizerConfig()) + ->allowElement('div') + ; + + $this->assertNotEmpty($this->sanitize($config, str_repeat('
    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 = [ + '' => '', + '"' => '"', + '\'' => ''', + '&' => '&', + '<' => '<', + '>' => '>', + '<' => '&lt;', + '>' => '&gt;', + '+' => '+', + '=' => '=', + '@' => '@', + '`' => '`', + '<' => '<', + '>' => '>', + '+' => '+', + '=' => '=', + '@' => '@', + '`' => '`', + ]; + + 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], + 'java�script: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.'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